import { Box, Static, Text, useApp, useInput, useStdout } from 'ink'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { parseCommand, matchCommands } from '../commands'; import { expandCommand, type CustomCommand } from '../custom-commands'; import { THINKING_LEVELS, VARIANTS } from '../agents'; import { completePath, matchPaths, pathToken } from '../complete'; import type { Config } from '../config'; import { type NotebookState } from '../notebook'; import { costOf, formatUsd, usageLine } from '../pricing'; import type { Session } from '../session'; import { interruptBash, toolSetOf } from '../tools'; import { diagStart, diagStop, diagStatus } from '../diagnostics'; import { AskPanel, type AskBridge, type AskPending } from './Ask'; import { Approval, createApprovalBridge, type ApprovalBridge, type Pending } from './Approval'; import { applySubagentEvent, createNoticeBus, createSubagentBus, type NoticeBus, type SubagentBus } from './buses'; import { Markdown } from './Markdown'; import { McpAdd, type McpAddResult } from './McpAdd'; import { Onboard, type OnboardResult } from './Onboard'; import { InfoPanel, OutputPanel, QueuePanel, RegistryPanel, DiagnosticsPanel, Footer, InputStatus, StatusBar, SubagentPanel, ThinkingPanel, TodoPanel, ActiveTool, FileMenu, Working, type RegistryRow, type SubagentView, } from './Panels'; import { CommandMenu, InstallConfirm, Picker } from './Pickers'; import { contextPanel, costPanel, todosPanel, toolsPanel, changesPanel, diffPanel, diffReviewPanel, workflowPanel } from './panel-bodies'; import { PromptInput } from './PromptInput'; import { accent, glyph } from './theme'; import { historyFromMessages, nextKey, resultSummary, toolDetail, withResult, type Line, type NewLine } from './transcript'; export { createApprovalBridge, createNoticeBus, createSubagentBus, applySubagentEvent }; export type { ApprovalBridge, NoticeBus, SubagentBus }; /** Everything the slash commands need from the outside world. */ export type AppHooks = { sessionId: string; /** Session title for the welcome dashboard; absent in tests. */ title?: string; config: () => Config; switchModel: (id: string) => string; switchAgent: (name: string) => string; switchThinking: (level: string) => string; agentName: () => string; thinkingLevel: () => string; applyProvider: (result: OnboardResult) => Promise; listModels: () => Promise<{ models: string[]; warning?: string }>; listSessions: () => Promise; /** Full-text search over saved sessions; returns a formatted panel body or ''. */ searchSessions: (query: string) => Promise; /** Fork the current session at the last turn boundary; returns a resume hint. */ forkSession: () => Promise; listSkills: () => string; listPlugins: () => string; listMemory: () => Promise; summarizeMemory: () => Promise; resumeSession: (idOrPrefix: string) => Promise; saveSession: () => Promise; /** Loaded AGENTS.md-style files, for /context. */ instructionFiles: () => string[]; /** Ignore-aware workspace paths for `@` completion, loaded on first use and invalidated when files change. */ listPaths: () => Promise; /** Monotonically increments when the workspace changes — lets the `@` completer know to re-walk. */ fileChangeSeq: () => number; /** Custom slash commands from markdown files, for the menu and the parser. */ customCommands?: () => readonly CustomCommand[]; /** Registry index, installed set, and the install/remove actions. */ registry: { list: () => Promise; installed: () => Promise; /** Fetches and validates without writing, so the body can be shown first. */ stage: (name: string) => Promise<{ row: RegistryRow; url: string; preview: string }>; install: (name: string) => Promise; remove: (name: string) => Promise; }; /** Configured MCP servers, and the add/remove actions that write config.json. */ mcp: { names: () => string[]; list: () => string; add: (result: McpAddResult) => Promise; remove: (name: string) => Promise; }; /** Prompt to hand the model for /init. */ initPrompt: string; /** Directly scaffold TODO.md / ROADMAP.md / docs/ when they are missing; returns what was written. */ scaffoldWorkflow: () => string[]; /** Background commands started with bash background: true — list, stop one, stop all. */ backgroundCommands: { list: () => string; stop: (handle: number) => Promise; stopAll: () => Promise; }; history: string[]; recordPrompt: (text: string) => void; }; export function App({ session, bridge, header, headerNode, version, hooks, notices, askBridge, subagents, needsProvider = false, }: { session: Session; bridge: ApprovalBridge; header: string; /** Rich welcome screen; when present it replaces the plain `header` string. */ headerNode?: React.ReactNode; /** Build version, shown in the welcome dashboard's meta panel. */ version?: string; hooks: AppHooks; notices?: NoticeBus; askBridge?: AskBridge; subagents?: SubagentBus; needsProvider?: boolean; }) { const { exit } = useApp(); const { write, stdout } = useStdout(); // The footer splits hints left from context/cost right, and the input box and // dashboards lay out against the real terminal width, so it is tracked and // kept current on resize rather than read once. const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80); useEffect(() => { if (!stdout) return; const onResize = () => setTermWidth(stdout.columns ?? 80); stdout.on('resize', onResize); return () => { stdout.off('resize', onResize); }; }, [stdout]); // Seeded from the resumed history: a session loaded with -r/-c should show its // saved conversation rather than a blank transcript. Only read once, at mount. const [history, setHistory] = useState(() => session.messages.length === 0 ? [] : historyFromMessages(session.messages as { role?: string; content?: unknown }[]), ); const [draft, setDraft] = useState(''); const [live, setLive] = useState(''); const [busy, setBusy] = useState(false); const [pending, setPending] = useState(); const [asking, setAsking] = useState(); const [onboarding, setOnboarding] = useState(needsProvider); const [unconfigured, setUnconfigured] = useState(needsProvider); const [modelPicker, setModelPicker] = useState(); const [agentPicker, setAgentPicker] = useState(false); const [thinkPicker, setThinkPicker] = useState(false); const [menuIndex, setMenuIndex] = useState(0); const [menuDismissed, setMenuDismissed] = useState(false); const [inputGeneration, setInputGeneration] = useState(0); const [inputCursor, setInputCursor] = useState(0); const [toolOutput, setToolOutput] = useState(''); const [active, setActive] = useState<{ name: string; detail?: string[] } | undefined>(); const [thinking, setThinking] = useState(''); const [thinkingOpen, setThinkingOpen] = useState(false); const [queue, setQueue] = useState([]); const [cursor, setCursor] = useState(0); const [paths, setPaths] = useState(); const [fileIndex, setFileIndex] = useState(0); const [fileDismissed, setFileDismissed] = useState(false); const [recall, setRecall] = useState(hooks.history); const [notebook, setNotebook] = useState(session.notebook.state()); const [agents, setAgents] = useState([]); const [panel, setPanel] = useState<{ title: string; hint?: string; body: string } | undefined>(); /** Live diagnostics runner: command + start time, so the panel can re-read /diagnostics state on a timer. */ const [diag, setDiag] = useState<{ command: string; startedAt: number } | undefined>(); const [registry, setRegistry] = useState<{ title: string; hint?: string; rows: RegistryRow[] } | undefined>(); const [installing, setInstalling] = useState< { row: RegistryRow; url: string; preview: string } | undefined >(); const [addingMcp, setAddingMcp] = useState(false); const [seconds, setElapsed] = useState(0); const startedAt = useRef(undefined); const modal = pending !== undefined || asking !== undefined || onboarding || installing !== undefined || addingMcp; const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker; const matches = matchCommands(draft, hooks.customCommands?.() ?? []); const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel; const highlighted = matches[Math.min(menuIndex, matches.length - 1)]; const token = pathToken(draft, cursor); const fileOpen = token !== undefined && !fileDismissed && !modal && !anyPicker; const fileMatches = token && paths ? matchPaths(paths, token.query) : []; const highlightedPath = fileMatches[Math.min(fileIndex, Math.max(0, fileMatches.length - 1))]; // The walk costs a full ignore-aware traversal, so it happens on the first `@` // rather than at startup, and re-runs when files change (listPaths is cached // in hook, but App keeps seq so a stale `paths` is dropped). A slow cooldown // also re-walks so a file created after that first `@` shows up within a short // window instead of staying hidden all session. const pathsRef = useRef(paths); useEffect(() => { pathsRef.current = paths; }, [paths]); const didLoadRef = useRef(false); useEffect(() => { if (token === undefined) return; if (!didLoadRef.current) { didLoadRef.current = true; let live = true; void hooks.listPaths().then((all) => { if (live) setPaths(all); }); const refresh = setInterval(async () => { if (!live) return; const all = await hooks.listPaths(); if (live && JSON.stringify(all) !== JSON.stringify(pathsRef.current)) setPaths(all); }, 10_000); return () => { live = false; clearInterval(refresh); }; } }, [hooks, token]); // A file mutated this turn: drop the cached walk so next `@` re-walks. const seq = hooks.fileChangeSeq(); useEffect(() => { setPaths(undefined); }, [seq]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => bridge.bind(setPending), [bridge]); useEffect(() => askBridge?.bind(setAsking), [askBridge]); useEffect( () => subagents?.bind((event) => { setAgents((current) => applySubagentEvent(current, event)); }), [subagents], ); // Ink re-renders the whole tree per setState, so deltas accumulate in refs // and are flushed on a timer instead of once per token. const text = useRef(''); const reasoning = useRef(''); useEffect(() => { const t = setInterval(() => { setLive((s) => (s === text.current ? s : text.current)); setThinking((s) => (s === reasoning.current ? s : reasoning.current)); }, 60); return () => clearInterval(t); }, []); // The queue is a ref as well as state: runTurn drains it synchronously as the // turn ends, and a stale closure over the array would lose a prompt. const queued = useRef([]); const busyRef = useRef(false); const submitRef = useRef<((raw: string) => Promise) | undefined>(undefined); // Kept in step with busy, since the queue drain reads it synchronously between // renders and a state read there would be one turn stale. const setWorking = useCallback((value: boolean) => { busyRef.current = value; setBusy(value); setElapsed(0); startedAt.current = value ? Date.now() : undefined; }, []); // One tick per second while busy, so a long turn reports how long it has been // going. Derived from a timestamp rather than counted, because Ink's render loop // is not a clock and a dropped tick would drift. useEffect(() => { if (!busy) return; const t = setInterval(() => { const from = startedAt.current; if (from !== undefined) setElapsed(Math.floor((Date.now() - from) / 1000)); }, 1000); return () => clearInterval(t); }, [busy]); const push = useCallback((line: NewLine) => { setHistory((h) => [...h, { ...line, key: nextKey() }]); }, []); useEffect(() => notices?.bind((text) => push({ kind: 'info', text })), [notices, push]); useInput( (input, key) => { // esc drops the queue too: interrupting and then watching two more prompts // fire anyway is not what anyone means by interrupt. if (key.escape) { queued.current.length = 0; setQueue([]); session.abort(); return; } if (key.ctrl && input === 'r') setThinkingOpen((o) => !o); }, { isActive: busy && !modal }, ); // ctrl-c kills only the command in flight, leaving the turn alive so the model // gets a tool error and can decide what to do. With nothing running it keeps its // usual meaning and quits, which is why Ink's own ctrl-c handling is turned off // in cli.tsx rather than left to race with this. When a background command is // running (started by the model with bash background: true) and nothing // foreground is in flight, ctrl-c stops the most recently started one instead // of quitting — an accidental quit killing a dev server the user still wants. useInput(async (input, key) => { if (!key.ctrl || input !== 'c') return; const killed = interruptBash(); if (killed.length > 0) { push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` }); return; } const handles = hooks.backgroundCommands.list(); if (handles.trim() !== '' && handles.trim() !== 'no background commands') { const lines = handles.split('\n').map((l) => l.trim()).filter(Boolean); const first = lines[0]?.match(/^(\d+):/)?.[1]; if (first) { const msg = await hooks.backgroundCommands.stop(Number(first)); push({ kind: 'info', text: `ctrl-c: no foreground command; ${msg}` }); return; } } return exit(); }); useInput( (_input, key) => { if (!key.escape) return; setModelPicker(undefined); setAgentPicker(false); setThinkPicker(false); }, { isActive: anyPicker }, ); // PromptInput hands up/down/tab/esc to us first, so the menus and any open panel // can claim them before the input treats them as editing keys. const handleInputKey = useCallback( (_input: string, key: { upArrow: boolean; downArrow: boolean; tab: boolean; escape: boolean; return: boolean }) => { if (key.escape && panel) { setPanel(undefined); return true; } if (key.escape && registry) { setRegistry(undefined); return true; } // The file picker gets first refusal: while an `@` token is open its keys // mean navigation, not history recall or command completion. if (fileOpen) { if (key.escape) { setFileDismissed(true); return true; } if (fileMatches.length > 0) { if (key.upArrow) { setFileIndex((i) => (i - 1 + fileMatches.length) % fileMatches.length); return true; } if (key.downArrow) { setFileIndex((i) => (i + 1) % fileMatches.length); return true; } if ((key.tab || key.return) && highlightedPath && token) { const next = completePath(draft, token, highlightedPath); setDraft(next.value); setCursor(next.cursor); setFileIndex(0); setInputCursor(next.cursor); setInputGeneration((g) => g + 1); return true; } } } if (!menuOpen) return false; if (key.escape) { setMenuDismissed(true); return true; } if (key.upArrow) { setMenuIndex((i) => (i - 1 + matches.length) % matches.length); return true; } if (key.downArrow) { setMenuIndex((i) => (i + 1) % matches.length); return true; } if (key.tab && highlighted) { const value = highlighted.arg ? `/${highlighted.name} ` : `/${highlighted.name}`; setDraft(value); setCursor(value.length); setMenuIndex(0); setMenuDismissed(true); setInputCursor(value.length); setInputGeneration((g) => g + 1); return true; } return false; }, [draft, fileMatches.length, fileOpen, highlighted, highlightedPath, matches.length, menuOpen, panel, registry, token], ); const onDraftChange = useCallback((value: string, at: number) => { setDraft(value); setCursor(at); setMenuIndex(0); setMenuDismissed(false); setFileIndex(0); setFileDismissed(false); }, []); const runTurn = useCallback( async (value: string) => { setWorking(true); text.current = ''; reasoning.current = ''; setThinking(''); for await (const ev of session.send(value)) { switch (ev.type) { case 'text': text.current += ev.text; break; case 'reasoning': reasoning.current += ev.text; break; case 'tool-start': setActive({ name: ev.name }); break; case 'tool-call': setActive({ name: ev.name, detail: toolDetail(ev.name, ev.input) }); push({ kind: 'tool', name: ev.name, detail: toolDetail(ev.name, ev.input), ok: true }); break; case 'tool-output': setToolOutput((s) => `${s}${ev.chunk}`.slice(-2000)); break; case 'tool-error': setActive(undefined); // Attaches to the call rather than pushing a second line, so the // transcript reads as one entry per call with its outcome. setHistory((h) => withResult(h, ev.name, String(ev.error), false)); break; case 'tool-result': setActive(undefined); setToolOutput(''); setHistory((h) => withResult(h, ev.name, resultSummary(ev.name, ev.output), true)); setNotebook(session.notebook.state()); break; case 'tool-denied': setActive(undefined); push({ kind: 'info', text: `denied ${ev.name}` }); break; case 'notice': push({ kind: 'info', text: ev.text }); break; case 'compacted': push({ kind: 'info', text: `context compacted: ${ev.before} messages pruned to ${ev.after} on the wire` }); break; case 'error': push({ kind: 'error', text: ev.error instanceof Error ? ev.error.message : String(ev.error) }); break; case 'done': { const full = text.current.trim(); text.current = ''; // Reasoning is progress, not the answer, so it leaves with the turn. reasoning.current = ''; setThinking(''); setLive(''); setActive(undefined); setToolOutput(''); setAgents([]); setHistory((h) => { const merged: Line[] = [...h]; if (full) merged.push({ kind: 'assistant', text: full, key: nextKey() }); if (ev.inputTokens !== undefined) { merged.push({ kind: 'info', text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} est. in context)`, key: nextKey(), }); } return merged; }); break; } default: break; } } setWorking(false); // Drain one queued prompt per finished turn, in order. Going back through // submit means a queued slash command behaves exactly as if typed now, and // its own turn drains the next one. const next = queued.current.shift(); if (next !== undefined) { setQueue([...queued.current]); await submitRef.current?.(next); } }, [hooks, push, session, setWorking], ); const submit = useCallback( async (raw: string) => { setDraft(''); setCursor(0); setMenuIndex(0); setMenuDismissed(false); setFileIndex(0); setFileDismissed(false); setPanel(undefined); setRegistry(undefined); // Enter on an open menu runs the highlighted entry, so `/mo` + enter works. const chosen = menuOpen && highlighted ? `/${highlighted.name}` : raw; const action = parseCommand(chosen, hooks.customCommands?.() ?? []); switch (action.type) { case 'none': return; case 'exit': return exit(); default: break; } // Typed during a turn: queue it whole, including a slash command, and let // the drain replay it once the model is free. Losing the thought to a // swallowed keystroke is the thing this exists to prevent. if (busyRef.current) { queued.current.push(chosen); setQueue([...queued.current]); return; } // Nothing can reach the model until a provider is configured. if (unconfigured && action.type !== 'provider' && action.type !== 'info') { push({ kind: 'user', text: chosen.trim() }); push({ kind: 'error', text: 'no provider configured yet - run /provider' }); return; } switch (action.type) { case 'clear': session.reset(); setHistory([]); setNotebook(session.notebook.state()); // lines are already committed to the scrollback, so clearing // React state alone leaves them on screen. Wipe screen + scrollback. write('\u001B[2J\u001B[3J\u001B[H'); return; case 'info': push({ kind: 'user', text: chosen.trim() }); setPanel({ title: 'commands', hint: 'type / for the menu', body: action.text }); return; case 'unknown': push({ kind: 'user', text: chosen.trim() }); push({ kind: 'error', text: `unknown command /${action.name} - try /help` }); return; case 'tools': push({ kind: 'user', text: chosen.trim() }); setPanel(toolsPanel(session)); return; case 'cost': push({ kind: 'user', text: chosen.trim() }); setPanel( costPanel(session, { sessionId: hooks.sessionId, model: hooks.config().model, agent: hooks.agentName(), thinking: hooks.thinkingLevel(), ...(hooks.config().subagentModel ? { subagentModel: hooks.config().subagentModel! } : {}), }), ); return; case 'context': push({ kind: 'user', text: chosen.trim() }); setPanel(contextPanel(hooks.instructionFiles())); return; case 'todos': push({ kind: 'user', text: chosen.trim() }); setPanel(todosPanel(session)); return; case 'notes': { push({ kind: 'user', text: chosen.trim() }); setPanel({ title: 'project memory', body: await hooks.listMemory() }); return; } case 'agent': { push({ kind: 'user', text: chosen.trim() }); if (action.agent) { try { push({ kind: 'info', text: hooks.switchAgent(action.agent) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } return; } setAgentPicker(true); return; } case 'think': { push({ kind: 'user', text: chosen.trim() }); if (action.level) { try { push({ kind: 'info', text: hooks.switchThinking(action.level) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } return; } setThinkPicker(true); return; } case 'skills': push({ kind: 'user', text: chosen.trim() }); setPanel({ title: 'skills', hint: 'the agent loads one with the skill tool', body: hooks.listSkills() }); return; case 'plugins': push({ kind: 'user', text: chosen.trim() }); setPanel({ title: 'plugins', body: hooks.listPlugins() }); return; case 'registry': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); try { if (action.action === 'add') { // Staged, not installed: nothing is written until the prompt is answered. setInstalling(await hooks.registry.stage(action.arg!)); return; } if (action.action === 'remove') { push({ kind: 'info', text: await hooks.registry.remove(action.arg!) }); return; } if (action.action === 'installed') { const rows = await hooks.registry.installed(); setRegistry({ title: 'installed', hint: rows.length === 0 ? 'nothing installed yet' : `${rows.length} from the registry`, rows, }); return; } const all = await hooks.registry.list(); const rows = action.action === 'search' && action.arg ? all.filter( (r) => r.name.includes(action.arg!.toLowerCase()) || r.description.toLowerCase().includes(action.arg!.toLowerCase()), ) : all; setRegistry({ title: action.arg ? `registry: ${action.arg}` : 'registry', hint: `${rows.length} of ${all.length} available`, rows, }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } finally { setWorking(false); } return; } case 'mcp': { push({ kind: 'user', text: chosen.trim() }); if (action.action === 'add') { setAddingMcp(true); return; } if (action.action === 'remove') { try { push({ kind: 'info', text: await hooks.mcp.remove(action.arg!) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } return; } setPanel({ title: 'mcp servers', hint: '/mcp add to add one', body: hooks.mcp.list() }); return; } case 'memory': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); try { push({ kind: 'info', text: await hooks.summarizeMemory() }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); return; } case 'init': { push({ kind: 'user', text: chosen.trim() }); const written = hooks.scaffoldWorkflow(); if (written.length > 0) push({ kind: 'info', text: `scaffolded ${written.join(', ')}` }); await runTurn(hooks.initPrompt); return; } case 'model': push({ kind: 'user', text: chosen.trim() }); try { push({ kind: 'info', text: hooks.switchModel(action.model) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } return; case 'sessions': push({ kind: 'user', text: chosen.trim() }); push({ kind: 'info', text: await hooks.listSessions() }); return; case 'save': push({ kind: 'user', text: chosen.trim() }); push({ kind: 'info', text: await hooks.saveSession() }); return; case 'resume': push({ kind: 'user', text: chosen.trim() }); try { const msg = await hooks.resumeSession(action.id); // Reflect the freshly loaded history: session.messages now holds the // restored wire messages, and the transcript must show them again. setHistory( session.messages.length === 0 ? [] : historyFromMessages(session.messages as { role?: string; content?: unknown }[]), ); push({ kind: 'info', text: msg }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } return; case 'changes': { push({ kind: 'user', text: chosen.trim() }); setPanel(changesPanel(session)); return; } case 'diff': { push({ kind: 'user', text: chosen.trim() }); setPanel(action.action === 'review' ? diffReviewPanel(session) : diffPanel(session)); return; } case 'search': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); try { const results = await hooks.searchSessions(action.query); if (results === '') { push({ kind: 'info', text: 'no sessions match that phrase' }); } else { setPanel({ title: `sessions: ${action.query}`, body: results }); } } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); return; } case 'fork': { push({ kind: 'user', text: chosen.trim() }); try { push({ kind: 'info', text: await hooks.forkSession() }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } return; } case 'workflow': { push({ kind: 'user', text: chosen.trim() }); setPanel(workflowPanel(session)); return; } case 'bash': { push({ kind: 'user', text: chosen.trim() }); if (action.action === 'list') { push({ kind: 'info', text: hooks.backgroundCommands.list() }); return; } setWorking(true); try { const text = action.action === 'stop-all' ? await hooks.backgroundCommands.stopAll() : await hooks.backgroundCommands.stop(Number(action.arg)); push({ kind: 'info', text }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); return; } case 'diagnostics': { push({ kind: 'user', text: chosen.trim() }); if (action.action === 'start') { const command = action.command!; try { const res = diagStart(command); setDiag({ command, startedAt: Date.now() }); push({ kind: 'info', text: `diagnostics: ${res.command} (UI only, not in model context)` }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } return; } if (action.action === 'stop') { const res = diagStop(); setDiag(undefined); push({ kind: 'info', text: res.stopped ? `diagnostics stopped: ${res.command}` : 'no diagnostics running' }); return; } const snap = diagStatus(); if (!snap.running && snap.exit === null && !snap.command) { push({ kind: 'info', text: 'no diagnostics running — /diagnostics start ' }); return; } push({ kind: 'info', text: snap.command ? `${snap.command}: ${snap.running ? 'running' : `exit ${snap.exit}`}` : 'no diagnostics running' }); return; } case 'provider': push({ kind: 'user', text: chosen.trim() }); setOnboarding(true); return; case 'models': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); const { models, warning } = await hooks.listModels(); setWorking(false); if (warning) push({ kind: 'info', text: `could not list models: ${warning}` }); if (models.length === 0) { push({ kind: 'error', text: 'no models to choose from - use /model or /provider' }); return; } setModelPicker(models); return; } case 'undo': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); try { push({ kind: 'info', text: await session.undo() }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); return; } case 'redo': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); try { push({ kind: 'info', text: await session.redo() }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); return; } case 'compact': { push({ kind: 'user', text: chosen.trim() }); setWorking(true); try { const { before, after } = await session.summarize(); push({ kind: 'info', text: `compacted ${before} messages into ${after}` }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); return; } case 'prompt': push({ kind: 'user', text: action.text }); hooks.recordPrompt(action.text); setRecall((h) => (h.at(-1) === action.text ? h : [...h, action.text])); await runTurn(action.text); return; case 'custom': { const typed = chosen.trim(); push({ kind: 'user', text: typed }); setWorking(true); try { // A command may pin an agent; it runs the prompt under that variant // and restores afterwards, so one command does not leak its agent into // the rest of the session. const previous = hooks.agentName(); if (action.command.agent && action.command.agent !== previous) { try { hooks.switchAgent(action.command.agent); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } } const prompt = await expandCommand(action.command, action.args); await runTurn(prompt); if (action.command.agent && action.command.agent !== previous) { try { hooks.switchAgent(previous); } catch { // Restoring the agent is best-effort; the next /agent sets it explicitly. } } } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } setWorking(false); return; } } }, [exit, highlighted, hooks, menuOpen, push, runTurn, session, setWorking, unconfigured, write], ); useEffect(() => { submitRef.current = submit; }, [submit]); return ( {(line) => ( {line.kind === 'user' && ( {`${glyph.user} ${line.text}`} )} {line.kind === 'assistant' && ( {`${glyph.assistant} `} )} {line.kind === 'tool' && ( {line.ok ? glyph.toolOk : glyph.toolErr} {line.name} {line.detail[0] !== undefined && {` ${line.detail[0]}`}} {line.detail.slice(1).map((d, i) => ( {` ${d}`} ))} {line.result !== undefined && line.result.length > 0 && ( {` ${line.ok ? glyph.result : glyph.err} ${line.result}`} )} )} {line.kind === 'info' && {`${glyph.info} ${line.text}`}} {line.kind === 'error' && {`${glyph.err} ${line.text}`}} )} {history.length === 0 && (headerNode !== undefined ? ( headerNode ) : ( {header} ))} {agents.length > 0 && } {diag && } {notebook.todos.length > 0 && } {live.length > 0 && ( )} {panel && ( )} {registry && ( )} {installing && ( { const staged = installing; setInstalling(undefined); if (!yes) { push({ kind: 'info', text: `install cancelled: ${staged.row.name}` }); return; } try { push({ kind: 'info', text: await hooks.registry.install(staged.row.name) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } }} /> )} {asking && } {addingMcp && ( { setAddingMcp(false); push({ kind: 'info', text: 'mcp setup cancelled' }); }} onDone={async (result) => { setAddingMcp(false); try { push({ kind: 'info', text: await hooks.mcp.add(result) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } }} /> )} {pending && } {onboarding && ( { setOnboarding(false); push({ kind: 'info', text: 'provider setup cancelled' }); }} onDone={async (result) => { setOnboarding(false); try { push({ kind: 'info', text: await hooks.applyProvider(result) }); setUnconfigured(false); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } }} /> )} {modelPicker && ( ({ value: m, label: m }))} current={hooks.config().model} onSelect={(value) => { setModelPicker(undefined); try { push({ kind: 'info', text: hooks.switchModel(value) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } }} /> )} {agentPicker && ( ({ value: v.name, label: `${v.name.padEnd(8)} ${v.summary}` }))} current={hooks.agentName()} limit={8} onSelect={(value) => { setAgentPicker(false); try { push({ kind: 'info', text: hooks.switchAgent(value) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } }} /> )} {thinkPicker && ( ({ value: l, label: l }))} current={hooks.thinkingLevel()} limit={8} onSelect={(value) => { setThinkPicker(false); try { push({ kind: 'info', text: hooks.switchThinking(value) }); } catch (e) { push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); } }} /> )} {busy && !modal && ( {active && } )} {!modal && !anyPicker && ( {`${glyph.user} `} {fileOpen ? ( ) : ( menuOpen && )}