Skip to content

Commit 0dff93b

Browse files
yethikrishnaclaude
andcommitted
feat: v0.3.0 — TUI overhaul, universal OAuth, smart model routing, multi-provider search
TUI Component Library (16 primitives): - Panel, ListNavigator, TabView, SearchInput, StatusBadge, KeyHint - ConfirmDialog, MultiSelect, TextInput, BreadcrumbNav, Toast, Spinner - Divider, Table, Alert, Switch Redesigned Screens: - Model picker: provider groups, capability badges [R][V][T], cost display, available vs unconfigured models sorted - Provider wizard: BreadcrumbNav, back navigation, TextInput with masking, Spinner during test, auto-sets first model as active - Settings: 4 tabs (General/Providers/OAuth/Theme), interactive Switch toggle, OAuth connection status, color palette preview - Help modal: F1 shortcut, grouped shortcuts, full command catalog - Status bar: active provider/model display Universal OAuth System: - Generic PKCE flow, localhost callback server, token encryption (AES-256-CBC) - Provider configs: Google, GitHub, Azure, OpenRouter, Claude - /connect and /disconnect slash commands - Auto-refresh manager, startup initialization Smart Model Routing: - isClaudeModel() guard prevents non-Anthropic models hitting Anthropic SDK - Aggregator fallback: OpenRouter-style model IDs (org/model) auto-route - getDefaultModel() fallback when requested model unavailable - OPEN_ROUTER_API_KEY and CODEBUFF_BYOK_OPENROUTER env var support Multi-Provider Web Search: - Tavily, Brave Search, Serper, SearXNG, DuckDuckGo providers - Automatic fallback chain, DuckDuckGo always-free default - No backend required for web search in standalone mode Team System Fixes: - Commander bypasses ALL phase restrictions - Per-member toolOverrides (allowed/blocked) for grant/revoke - Teams use user's swarmDefaultPhase setting (no more forced planning) - resolveActiveTeam() with proper priority chain - setLastActiveTeam() after phase transitions - maxMembers default raised to 999 (limitless) Bug Fixes: - Fixed <span> must be inside <text> crash - Fixed ripgrep binary discovery (PATH, scoop, choco, cargo) - Fixed model picker showing 0 models (merged catalog + provider models) - Phase transition messages inform agent of unlocked tools Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent baa519d commit 0dff93b

64 files changed

Lines changed: 5221 additions & 325 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@levelcode/cli",
3-
"version": "0.2.7",
3+
"version": "0.3.0",
44
"description": "LevelCode CLI - Terminal-based AI coding agent that outperforms Claude Code",
55
"author": {
66
"name": "Yethikrishna R",

cli/src/chat.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { AnalyticsEvent } from '@levelcode/common/constants/analytics-events'
22
import { isStandaloneMode } from '@levelcode/sdk'
3+
import { useKeyboard } from '@opentui/react'
34
import open from 'open'
5+
import { HelpModal } from './components/help-modal'
6+
import { OAuthConnectFlow } from './components/oauth-connect-flow'
7+
import { ProviderStatusList } from './components/provider-status-list'
48
import { ProviderWizard } from './components/provider-wizard'
59
import { ModelPicker } from './components/model-picker'
610
import { SettingsPanel } from './components/settings-panel'
@@ -56,11 +60,14 @@ import { useChatStore } from './state/chat-store'
5660
import { useReviewStore } from './state/review-store'
5761
import { useTeamSettingsStore } from './state/team-settings-store'
5862
import { useTeamStore } from './state/team-store'
63+
import { useOAuthStore } from './state/oauth-store'
5964
import { useFeedbackStore } from './state/feedback-store'
6065
import { useMessageBlockStore } from './state/message-block-store'
6166
import { usePublishStore } from './state/publish-store'
6267
import { reportActivity } from './utils/activity-tracker'
6368
import { trackEvent } from './utils/analytics'
69+
import { OAUTH_CONFIGS } from '@levelcode/common/providers/oauth-configs'
70+
import { getProviderDefinition } from '@levelcode/common/providers/provider-registry'
6471
import { getClaudeOAuthStatus } from './utils/claude-oauth'
6572
import { showClipboardMessage } from './utils/clipboard'
6673
import { readClipboardImage } from './utils/clipboard-image'
@@ -91,7 +98,7 @@ import type { MatchedSlashCommand } from './hooks/use-suggestion-engine'
9198
import type { User } from './utils/auth'
9299
import type { AgentMode } from './utils/constants'
93100
import type { FileTreeNode } from '@levelcode/common/util/file'
94-
import type { ScrollBoxRenderable } from '@opentui/core'
101+
import type { KeyEvent, ScrollBoxRenderable } from '@opentui/core'
95102
import type { UseMutationResult } from '@tanstack/react-query'
96103
import type { Dispatch, SetStateAction } from 'react'
97104

@@ -130,6 +137,9 @@ export const Chat = ({
130137
const [providerWizardMode, setProviderWizardMode] = useState(false)
131138
const [modelPickerMode, setModelPickerMode] = useState(false)
132139
const [providerSettingsMode, setProviderSettingsMode] = useState(false)
140+
const [helpModalMode, setHelpModalMode] = useState(false)
141+
const [oauthFlowMode, setOauthFlowMode] = useState(false)
142+
const [oauthProviderId, setOauthProviderId] = useState<string | null>(null)
133143

134144
const { validate: validateAgents } = useAgentValidation()
135145

@@ -711,6 +721,17 @@ export const Chat = ({
711721
if (result.openSettings) {
712722
setProviderSettingsMode(true)
713723
}
724+
725+
if (result.openHelpModal) {
726+
setHelpModalMode(true)
727+
}
728+
729+
if (result.openProviderOAuth) {
730+
if (result.oauthProviderId) {
731+
setOauthProviderId(result.oauthProviderId)
732+
}
733+
setOauthFlowMode(true)
734+
}
714735
},
715736
[
716737
saveCurrentInput,
@@ -1209,9 +1230,29 @@ export const Chat = ({
12091230
useChatKeyboard({
12101231
state: chatKeyboardState,
12111232
handlers: chatKeyboardHandlers,
1212-
disabled: askUserState !== null || reviewMode || providerWizardMode || modelPickerMode || providerSettingsMode,
1233+
disabled: askUserState !== null || reviewMode || providerWizardMode || modelPickerMode || providerSettingsMode || helpModalMode || oauthFlowMode,
12131234
})
12141235

1236+
// F1 opens help modal (separate handler since it's not in the chat keyboard action system)
1237+
useKeyboard(
1238+
useCallback(
1239+
(key: KeyEvent) => {
1240+
if (
1241+
key.name === 'f1' &&
1242+
!askUserState &&
1243+
!reviewMode &&
1244+
!providerWizardMode &&
1245+
!modelPickerMode &&
1246+
!providerSettingsMode &&
1247+
!helpModalMode
1248+
) {
1249+
setHelpModalMode(true)
1250+
}
1251+
},
1252+
[askUserState, reviewMode, providerWizardMode, modelPickerMode, providerSettingsMode, helpModalMode],
1253+
),
1254+
)
1255+
12151256
// Sync message block context to zustand store for child components
12161257
const setMessageBlockContext = useMessageBlockStore(
12171258
(state) => state.setContext,
@@ -1474,6 +1515,26 @@ export const Chat = ({
14741515
<SettingsPanel onClose={() => setProviderSettingsMode(false)} />
14751516
)}
14761517

1518+
{helpModalMode && (
1519+
<HelpModal onClose={() => setHelpModalMode(false)} />
1520+
)}
1521+
1522+
{oauthFlowMode && oauthProviderId && OAUTH_CONFIGS[oauthProviderId] && (
1523+
<OAuthConnectFlow
1524+
providerId={oauthProviderId}
1525+
providerName={getProviderDefinition(oauthProviderId)?.name ?? oauthProviderId}
1526+
config={OAUTH_CONFIGS[oauthProviderId]}
1527+
onSuccess={() => { setOauthFlowMode(false); setOauthProviderId(null) }}
1528+
onCancel={() => { setOauthFlowMode(false); setOauthProviderId(null) }}
1529+
/>
1530+
)}
1531+
{oauthFlowMode && !oauthProviderId && (
1532+
<ProviderStatusList
1533+
onConnect={(id) => setOauthProviderId(id)}
1534+
onClose={() => setOauthFlowMode(false)}
1535+
/>
1536+
)}
1537+
14771538
{reviewMode ? (
14781539
<ReviewScreen
14791540
onSelectOption={handleReviewOptionSelect}

cli/src/commands/command-registry.ts

Lines changed: 109 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import open from 'open'
22

33
import { handleAdsEnable, handleAdsDisable } from './ads'
44
import { useThemeStore } from '../hooks/use-theme'
5-
import { handleHelpCommand } from './help'
5+
66
import { handleImageCommand } from './image'
77
import { handleInitializationFlowLocally } from './init'
88
import { runBashCommand } from './router'
@@ -15,7 +15,7 @@ import { useTeamStore } from '../state/team-store'
1515
import { AGENT_MODES } from '../utils/constants'
1616
import { getSystemMessage, getUserMessage } from '../utils/message-history'
1717
import { capturePendingAttachments } from '../utils/pending-attachments'
18-
import { saveSwarmPreference } from '../utils/settings'
18+
import { saveSwarmPreference, loadSwarmSettings } from '../utils/settings'
1919
import { useTeamSettingsStore } from '../state/team-settings-store'
2020
import { getSkillByName } from '../utils/skill-registry'
2121
import {
@@ -25,7 +25,7 @@ import {
2525
listTasks,
2626
saveTeamConfig,
2727
} from '@levelcode/common/utils/team-fs'
28-
import { listAllTeams } from '@levelcode/common/utils/team-discovery'
28+
import { listAllTeams, getLastActiveTeam, setLastActiveTeam } from '@levelcode/common/utils/team-discovery'
2929
import {
3030
canTransition,
3131
transitionPhase,
@@ -93,6 +93,9 @@ export type CommandResult = {
9393
openProviderWizard?: boolean
9494
openModelPicker?: boolean
9595
openSettings?: boolean
96+
openHelpModal?: boolean
97+
openProviderOAuth?: boolean
98+
oauthProviderId?: string
9699
preSelectAgents?: string[]
97100
} | void
98101

@@ -197,6 +200,38 @@ const clearInput = (params: RouterParams) => {
197200
params.setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false })
198201
}
199202

203+
/**
204+
* Find the active team, preferring Zustand store → last-active-team marker → first team on disk.
205+
* Returns null if no teams exist.
206+
*/
207+
function resolveActiveTeam(): import('@levelcode/common/types/team-config').TeamConfig | null {
208+
// 1. Zustand store (set by /team:create or previous commands)
209+
const storeTeam = useTeamStore.getState().activeTeam
210+
if (storeTeam) return storeTeam
211+
212+
// 2. Last-active team marker (most recently used team)
213+
const lastActiveName = getLastActiveTeam()
214+
if (lastActiveName) {
215+
const config = loadTeamConfig(lastActiveName)
216+
if (config) {
217+
useTeamStore.getState().setActiveTeam(config)
218+
return config
219+
}
220+
}
221+
222+
// 3. First team on disk (fallback)
223+
const teams = listAllTeams()
224+
if (teams.length > 0) {
225+
const config = loadTeamConfig(teams[0]!.name)
226+
if (config) {
227+
useTeamStore.getState().setActiveTeam(config)
228+
return config
229+
}
230+
}
231+
232+
return null
233+
}
234+
200235
export const COMMAND_REGISTRY: CommandDefinition[] = [
201236
defineCommand({
202237
name: 'ads:enable',
@@ -219,11 +254,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
219254
defineCommand({
220255
name: 'help',
221256
aliases: ['h', '?'],
222-
handler: async (params) => {
223-
const { postUserMessage } = await handleHelpCommand()
224-
params.setMessages((prev) => postUserMessage(prev))
257+
handler: (params) => {
225258
params.saveToHistory(params.inputValue.trim())
226259
clearInput(params)
260+
return { openHelpModal: true }
227261
},
228262
}),
229263
defineCommandWithArgs({
@@ -439,14 +473,16 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
439473
}
440474

441475
try {
476+
// Use user's swarm settings for defaults
477+
const swarmSettings = loadSwarmSettings()
442478
const config: TeamConfig = {
443479
name: teamName,
444480
description: '',
445481
createdAt: Date.now(),
446482
leadAgentId: 'user',
447-
phase: 'planning',
483+
phase: (swarmSettings.swarmDefaultPhase ?? 'planning') as import('@levelcode/common/types/team-config').DevPhase,
448484
members: [],
449-
settings: { maxMembers: 10, autoAssign: false },
485+
settings: { maxMembers: swarmSettings.swarmMaxMembers ?? 999, autoAssign: swarmSettings.swarmAutoAssign ?? true },
450486
}
451487
createTeam(config)
452488

@@ -474,18 +510,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
474510
name: 'team:delete',
475511
handler: (params) => {
476512
const { reset } = useTeamStore.getState()
477-
// Try the Zustand store first; if empty, discover teams from disk.
478-
let activeTeam = useTeamStore.getState().activeTeam
479-
if (!activeTeam) {
480-
const teams = listAllTeams()
481-
if (teams.length > 0) {
482-
const diskConfig = loadTeamConfig(teams[0]!.name)
483-
if (diskConfig) {
484-
useTeamStore.getState().setActiveTeam(diskConfig)
485-
activeTeam = diskConfig
486-
}
487-
}
488-
}
513+
const activeTeam = resolveActiveTeam()
489514

490515
if (!activeTeam) {
491516
params.setMessages((prev) => [
@@ -630,18 +655,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
630655
return
631656
}
632657

633-
// Try the Zustand store first; if empty, discover teams from disk.
634-
let activeTeam = useTeamStore.getState().activeTeam
635-
if (!activeTeam) {
636-
const teams = listAllTeams()
637-
if (teams.length > 0) {
638-
const diskConfig = loadTeamConfig(teams[0]!.name)
639-
if (diskConfig) {
640-
useTeamStore.getState().setActiveTeam(diskConfig)
641-
activeTeam = diskConfig
642-
}
643-
}
644-
}
658+
const activeTeam = resolveActiveTeam()
645659

646660
if (!activeTeam) {
647661
params.setMessages((prev) => [
@@ -685,6 +699,9 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
685699
const updated = transitionPhase(config, targetPhase)
686700
await saveTeamConfig(activeTeam.name, updated)
687701

702+
// Update last-active-team marker so subsequent tool calls resolve correctly
703+
setLastActiveTeam(activeTeam.name)
704+
688705
const { setActiveTeam, setPhase } = useTeamStore.getState()
689706
setActiveTeam(updated)
690707
setPhase(targetPhase)
@@ -706,10 +723,24 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
706723
toPhase: hookEvent.toPhase,
707724
})
708725

726+
// Build informative message so the agent knows what's now available
727+
const phaseInfo = [
728+
`Phase transitioned: ${fromPhase} -> ${targetPhase}`,
729+
'',
730+
`IMPORTANT: The team is now in "${targetPhase}" phase. All tools for this phase are now unlocked.`,
731+
]
732+
if (targetPhase === 'alpha' || targetPhase === 'beta' || targetPhase === 'production' || targetPhase === 'mature') {
733+
phaseInfo.push('You can now spawn agents, send messages, and perform all team operations.')
734+
phaseInfo.push('Available tools: spawn_agents, send_message, task_create, task_update, task_list, task_get, team_delete, and all standard tools.')
735+
} else if (targetPhase === 'pre-alpha') {
736+
phaseInfo.push('You can now send messages and use research agents.')
737+
phaseInfo.push('Available tools: send_message, task_create, task_update, task_list, task_get, and research tools.')
738+
}
739+
709740
params.setMessages((prev) => [
710741
...prev,
711742
getUserMessage(params.inputValue.trim()),
712-
getSystemMessage(`Phase transitioned: ${fromPhase} -> ${targetPhase}`),
743+
getSystemMessage(phaseInfo.join('\n')),
713744
])
714745
} catch (error) {
715746
params.setMessages((prev) => [
@@ -771,18 +802,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
771802
defineCommand({
772803
name: 'team:members',
773804
handler: (params) => {
774-
// Try the Zustand store first; if empty, discover teams from disk.
775-
let activeTeam = useTeamStore.getState().activeTeam
776-
if (!activeTeam) {
777-
const teams = listAllTeams()
778-
if (teams.length > 0) {
779-
const diskConfig = loadTeamConfig(teams[0]!.name)
780-
if (diskConfig) {
781-
useTeamStore.getState().setActiveTeam(diskConfig)
782-
activeTeam = diskConfig
783-
}
784-
}
785-
}
805+
const activeTeam = resolveActiveTeam()
786806

787807
if (!activeTeam) {
788808
params.setMessages((prev) => [
@@ -854,10 +874,53 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
854874
return { openTeamSettings: true }
855875
},
856876
}),
877+
// ── OAuth commands ────────────────────────────────────────────────────
878+
defineCommandWithArgs({
879+
name: 'connect',
880+
aliases: ['oauth'],
881+
handler: (params, args) => {
882+
const providerId = args.trim()
883+
params.saveToHistory(params.inputValue.trim())
884+
clearInput(params)
885+
886+
if (providerId) {
887+
return { openProviderOAuth: true, oauthProviderId: providerId }
888+
}
889+
// No args: show OAuth provider selector
890+
return { openProviderOAuth: true }
891+
},
892+
}),
893+
defineCommandWithArgs({
894+
name: 'disconnect',
895+
handler: async (params, args) => {
896+
const providerId = args.trim()
897+
if (!providerId) {
898+
params.setMessages((prev) => [
899+
...prev,
900+
getUserMessage(params.inputValue.trim()),
901+
getSystemMessage('Usage: /disconnect <provider-id>'),
902+
])
903+
params.saveToHistory(params.inputValue.trim())
904+
clearInput(params)
905+
return
906+
}
907+
908+
// Import dynamically to avoid circular deps
909+
const { clearOAuthToken } = await import('@levelcode/common/providers/oauth-storage')
910+
await clearOAuthToken(providerId)
911+
912+
params.setMessages((prev) => [
913+
...prev,
914+
getUserMessage(params.inputValue.trim()),
915+
getSystemMessage(`Disconnected OAuth for "${providerId}".`),
916+
])
917+
params.saveToHistory(params.inputValue.trim())
918+
clearInput(params)
919+
},
920+
}),
857921
// ── Provider & model commands ──────────────────────────────────────────
858922
defineCommand({
859923
name: 'provider:add',
860-
aliases: ['connect'],
861924
handler: (params) => {
862925
params.saveToHistory(params.inputValue.trim())
863926
clearInput(params)

0 commit comments

Comments
 (0)