import React, { useState, useCallback, useRef, useEffect } from 'react'; import { Box, Text, useApp, useInput } from 'ink'; import { StatusBar } from './StatusBar.js'; import { MessageList } from './MessageList.js'; import { InputBar } from './InputBar.js'; import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions } from '../commands.js'; import type { Message, ModelClient, TokenUsage } from '../../../models/types.js'; import type { ModelRouter } from '../../../models/router.js'; import type { ManagedSession } from '../../../session/index.js'; import type { NativeAgent, ToolUseEvent } from '../../../backends/native/agent.js'; import type { HookEngine, HookResult } from '../../../hooks/index.js'; import type { ModelConfig, ModelProvider } from '../../../config/schema.js'; import { MODEL_PROVIDERS } from '../../../config/schema.js'; import { createClientFromConfig } from '../../../daemon/index.js'; import { estimateMessageTokens, getContextWindow } from '../../../context/tokens.js'; /** Format a tool name like "gmail.list" -> "Gmail: List" */ function formatToolName(name: string): string { const parts = name.split('.'); return parts.map((p, i) => { const capitalized = p.charAt(0).toUpperCase() + p.slice(1); return i === 0 && parts.length > 1 ? capitalized + ':' : capitalized; }).join(' '); } /** Format tool args as a compact, readable summary. */ function formatToolArgs(args: unknown): string { if (!args || typeof args !== 'object') {return '';} const entries = Object.entries(args as Record); if (entries.length === 0) {return '';} const parts = entries.map(([key, value]) => { if (typeof value === 'string') { const display = value.length > 50 ? value.slice(0, 47) + '...' : value; return `${key}: "${display}"`; } if (typeof value === 'number' || typeof value === 'boolean') { return `${key}: ${value}`; } return `${key}: ${JSON.stringify(value)}`; }); return parts.join(', '); } export interface AppProps { session: ManagedSession; modelClient: ModelClient; modelRouter?: ModelRouter; systemPrompt: string; model: string; agent?: NativeAgent; hookEngine?: HookEngine; modelProviderConfigs?: Partial>; contextThresholdPct?: number; onExit?: () => void; } export function App({ session, modelClient, modelRouter, systemPrompt, model, agent, hookEngine, modelProviderConfigs, contextThresholdPct, onExit, }: AppProps): React.ReactElement { const { exit } = useApp(); const [input, setInput] = useState(''); const [messages, setMessages] = useState(session.getHistory()); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(''); const [scrollOffset, setScrollOffset] = useState(0); const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0 }); const [verbose, setVerbose] = useState(false); const [currentModel, setCurrentModel] = useState(() => { if (!modelRouter) {return model;} return modelRouter.getLabel(modelRouter.getTier()); }); const abortRef = useRef(false); const toolLinesRef = useRef([]); const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null); const [confirmation, setConfirmation] = useState<{ tool: string; args: Record } | null>(null); // Set up an Ink-compatible onToolUse callback for the agent. // This replaces process.stdout writes (which corrupt Ink rendering) // with one that updates React state to show tool activity. useEffect(() => { if (!agent) {return;} const handleToolEvent = (event: ToolUseEvent) => { if (event.type === 'start') { const label = formatToolName(event.tool); const argsStr = event.args ? ` (${formatToolArgs(event.args)})` : ''; toolLinesRef.current = [...toolLinesRef.current, `> ${label}${argsStr}`]; setStreamingContent(toolLinesRef.current.join('\n')); return; } if (event.type === 'end' && event.result) { const icon = event.result.success ? 'done' : 'error'; const detail = event.result.success ? `(${event.result.output.split('\n').length} lines)` : (event.result.error ?? 'unknown error'); toolLinesRef.current = [...toolLinesRef.current, ` ${icon} ${detail}`]; setStreamingContent(toolLinesRef.current.join('\n')); } }; agent.setOnToolUse(handleToolEvent); return () => { agent.setOnToolUse(undefined); }; }, [agent]); // Inline confirmations for dangerous tools (e.g. shell.exec) in fullscreen mode. useEffect(() => { if (!hookEngine) {return;} hookEngine.setInteractiveConfirmer(async (pending) => { return new Promise((resolve) => { confirmResolveRef.current = resolve; setConfirmation({ tool: pending.tool, args: pending.args }); }); }); return () => { hookEngine.setInteractiveConfirmer(undefined); confirmResolveRef.current = null; setConfirmation(null); }; }, [hookEngine]); useInput((inputChar, key) => { // Confirmation prompt mode: capture y/n and ignore everything else. if (confirmation && confirmResolveRef.current) { const c = inputChar.toLowerCase(); if (c === 'y') { confirmResolveRef.current({ approved: true }); confirmResolveRef.current = null; setConfirmation(null); return; } if (c === 'n') { confirmResolveRef.current({ approved: false, reason: 'Denied by user' }); confirmResolveRef.current = null; setConfirmation(null); return; } return; } if (key.escape) { if (isStreaming) { abortRef.current = true; } else { onExit?.(); exit(); } return; } // Tab completion for slash commands if (key.tab && !isStreaming) { const completions = getCommandCompletions(input); if (completions.length > 0) { setInput(completions[0]); return; } } // Only handle scroll keys, ignore regular typing if (!key.upArrow && !key.downArrow && !key.pageUp && !key.pageDown && !key.tab) { return; } if (key.upArrow && scrollOffset > 0) { setScrollOffset(prev => Math.max(0, prev - 1)); } if (key.downArrow) { setScrollOffset(prev => Math.min(messages.length - 1, prev + 1)); } if (key.pageUp) { setScrollOffset(prev => Math.max(0, prev - 10)); } if (key.pageDown) { setScrollOffset(prev => Math.min(messages.length - 1, prev + 10)); } }); const handleSubmit = useCallback(async (value: string) => { if (confirmation) { return; } const command = parseCommand(value); if (!command) {return;} setInput(''); switch (command.type) { case 'quit': onExit?.(); exit(); return; case 'reset': if (agent) { agent.reset(); } else { session.clear(); } setMessages([]); setTokenUsage({ inputTokens: 0, outputTokens: 0 }); setScrollOffset(0); return; case 'help': { const helpMsg: Message = { role: 'assistant', content: getHelpText() }; setMessages(prev => [...prev, session.addMessage(helpMsg)]); return; } case 'status': { const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`; const statusMsg: Message = { role: 'assistant', content: status }; setMessages(prev => [...prev, session.addMessage(statusMsg)]); return; } case 'compact': { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Compact command is not available in fullscreen TUI mode.' })]); return; } case 'usage': { const text = `Token Usage\n\nTotal: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`; setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: text })]); return; } case 'context': { const history = session.getHistory(); const estimated = estimateMessageTokens(history); const tier = modelRouter?.getTier() ?? 'default'; const modelName = modelRouter?.getLabel(tier) ?? model; const window = getContextWindow(modelName); const usagePct = window > 0 ? (estimated / window) * 100 : 0; const thresholdPct = contextThresholdPct ?? 80; const thresholdTokens = Math.floor((thresholdPct / 100) * window); const remaining = Math.max(0, window - estimated); const text = [ 'Context Usage (estimated)', '', `Model: ${modelName}`, `Used: ${estimated.toLocaleString()} / ${window.toLocaleString()} tokens (${usagePct.toFixed(1)}%)`, `Remaining: ${remaining.toLocaleString()} tokens`, `Compaction threshold: ${thresholdPct}% (${thresholdTokens.toLocaleString()} tokens)`, `Should compact: ${estimated > thresholdTokens ? 'yes' : 'no'}`, ].join('\n'); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: text })]); return; } case 'verbose': { const next = !verbose; setVerbose(next); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Verbose mode: ${next ? 'on' : 'off'}` })]); return; } case 'model': { if (!modelRouter) { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Model switching not available.' })]); return; } // /model if (!command.name) { const current = modelRouter.getTier(); const available = modelRouter.getAvailableTiers(); const labels = modelRouter.getAllLabels(); const lines: string[] = []; lines.push(`Active tier: ${current}`); for (const t of available) { const label = labels[t] ?? 'unknown'; const strict = modelRouter.isTierStrict(t) ? ' (strict)' : ''; lines.push(` ${t}: ${label}${strict}${t === current ? ' ←' : ''}`); } setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: lines.join('\n') })]); return; } // /model if (command.providerModel) { const tier = resolveModelAlias(command.name); const providerModel = command.providerModel; const slashIdx = providerModel.indexOf('/'); if (slashIdx === -1) { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid format. Use provider/model (e.g. anthropic/claude-sonnet-4)', })]); return; } const provider = providerModel.slice(0, slashIdx); const modelName = providerModel.slice(slashIdx + 1); if (!MODEL_PROVIDERS.includes(provider as ModelProvider)) { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Unknown provider "${provider}". Known providers: ${MODEL_PROVIDERS.join(', ')}`, })]); return; } try { const providerType = provider as ModelProvider; const template = modelProviderConfigs?.[providerType]; const client = createClientFromConfig({ ...(template ?? {}), provider: providerType, model: modelName, }); modelRouter.setClient(tier, client, providerModel); modelRouter.setTierStrict(tier, true); if (agent && tier === modelRouter.getTier()) { agent.setModelTier(tier); setCurrentModel(modelRouter.getLabel(tier)); } setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set ${tier} to: ${providerModel}\nFallbacks for ${tier} disabled (strict tier mode).`, })]); } catch (error) { const msg = error instanceof Error ? error.message : String(error); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Failed to create client: ${msg}` })]); } return; } // /model const tier = resolveModelAlias(command.name); if (modelRouter.setTier(tier)) { if (agent) { agent.setModelTier(tier); } setCurrentModel(modelRouter.getLabel(tier)); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Switched to model: ${tier}` })]); } else { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Model not available: ${command.name}` })]); } return; } case 'fullscreen': return; case 'transfer': setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer not supported in fullscreen mode.' })]); return; case 'queue': { if (!command.action || command.action === 'show') { const mode = session.getConfig('queue.mode') ?? 'collect'; const cap = session.getConfig('queue.cap') ?? '50'; const overflow = session.getConfig('queue.overflow') ?? 'drop_old'; const debounceMs = session.getConfig('queue.debounce_ms') ?? '0'; const summarizeOverflow = session.getConfig('queue.summarize_overflow') ?? 'true'; const text = [ 'Queue policy:', `mode: ${mode}`, `cap: ${cap}`, `overflow: ${overflow}`, `debounce_ms: ${debounceMs}`, `summarize_overflow: ${summarizeOverflow}`, ].join('\n'); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: text })]); return; } if (command.action === 'reset') { session.deleteConfig('queue.mode'); session.deleteConfig('queue.cap'); session.deleteConfig('queue.overflow'); session.deleteConfig('queue.debounce_ms'); session.deleteConfig('queue.summarize_overflow'); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Reset session queue overrides.' })]); return; } const raw = (command.args ?? '').trim(); const [rawKey, ...rest] = raw.split(/\s+/); const value = rest.join(' ').trim(); if (!rawKey || !value) { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Usage: /queue set ' })]); return; } const key = rawKey.toLowerCase(); if (key === 'mode') { if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(value)) { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid mode. Use: collect, followup, steer, steer_backlog, interrupt' })]); return; } session.setConfig('queue.mode', value); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.mode=${value}` })]); return; } if (key === 'cap') { const cap = Number.parseInt(value, 10); if (!Number.isFinite(cap) || cap < 1 || cap > 1000) { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid cap. Use an integer between 1 and 1000.' })]); return; } session.setConfig('queue.cap', String(cap)); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.cap=${cap}` })]); return; } if (key === 'overflow') { if (value !== 'drop_old' && value !== 'drop_new') { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid overflow. Use drop_old or drop_new.' })]); return; } session.setConfig('queue.overflow', value); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.overflow=${value}` })]); return; } if (key === 'debounce_ms') { const debounceMs = Number.parseInt(value, 10); if (!Number.isFinite(debounceMs) || debounceMs < 0 || debounceMs > 60_000) { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid debounce_ms. Use an integer between 0 and 60000.' })]); return; } session.setConfig('queue.debounce_ms', String(debounceMs)); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.debounce_ms=${debounceMs}` })]); return; } if (key === 'summarize_overflow') { const normalized = value.toLowerCase(); if (normalized !== 'true' && normalized !== 'false') { setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid summarize_overflow. Use true or false.' })]); return; } session.setConfig('queue.summarize_overflow', normalized); setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.summarize_overflow=${normalized}` })]); return; } setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Unknown queue key. Use mode, cap, overflow, debounce_ms, summarize_overflow.' })]); return; } case 'backend': case 'login': case 'pair': case 'elevate': setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `/${command.type} is not supported in fullscreen mode.` })]); return; case 'message': break; default: { const exhaustive: never = command; throw new Error(`Unhandled command: ${JSON.stringify(exhaustive)}`); } } if (command.type !== 'message' || isStreaming) { return; } // Add user message to UI (and session if no agent — agent adds it internally) const userMessage: Message = { role: 'user', content: command.content }; if (!agent) { const messageWithTimestamp = session.addMessage(userMessage); setMessages(prev => [...prev, messageWithTimestamp]); } else { setMessages(prev => [...prev, { ...userMessage, timestamp: Date.now() }]); } setScrollOffset(0); setIsStreaming(true); setStreamingContent(''); toolLinesRef.current = []; abortRef.current = false; try { if (agent) { await agent.process(command.content); const usage = agent.getUsage(); setTokenUsage({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }); setMessages(session.getHistory()); } else if (modelClient.chatStream) { let fullContent = ''; for await (const event of modelClient.chatStream({ messages: session.getHistory(), system: systemPrompt, })) { if (abortRef.current) { fullContent += '\n\n[interrupted]'; break; } if (event.type === 'content' && event.content) { fullContent += event.content; setStreamingContent(fullContent); } if (event.type === 'done' && event.usage) { const usage = event.usage; setTokenUsage(prev => ({ inputTokens: prev.inputTokens + usage.inputTokens, outputTokens: prev.outputTokens + usage.outputTokens, })); } if (event.type === 'error') { throw event.error ?? new Error('Stream error'); } } const assistantMessage: Message = { role: 'assistant', content: fullContent }; setMessages(prev => [...prev, session.addMessage(assistantMessage)]); } else { const response = await modelClient.chat({ messages: session.getHistory(), system: systemPrompt, }); setTokenUsage(prev => ({ inputTokens: prev.inputTokens + response.usage.inputTokens, outputTokens: prev.outputTokens + response.usage.outputTokens, })); const assistantMessage: Message = { role: 'assistant', content: response.content }; setMessages(prev => [...prev, session.addMessage(assistantMessage)]); } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Error: ${msg}` })]); } finally { setIsStreaming(false); setStreamingContent(''); } }, [ confirmation, session, agent, modelClient, modelRouter, systemPrompt, exit, onExit, isStreaming, messages.length, tokenUsage.inputTokens, tokenUsage.outputTokens, modelProviderConfigs, ]); return ( {confirmation ? ( Confirmation required: {confirmation.tool}{' '} {Object.keys(confirmation.args).length > 0 ? JSON.stringify(confirmation.args) : ''} Press y to approve, n to deny. ) : null} ); }