Files
flynn/src/frontends/tui/components/App.tsx
T
2026-02-22 23:30:28 -08:00

860 lines
31 KiB
TypeScript

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, isToolInventoryQuery } 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 } 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';
import type { PairingManager } from '../../../channels/pairing.js';
import { loginGitHub, loginOpenAI } from '../../../auth/index.js';
import { OllamaClient, LlamaCppClient } from '../../../models/index.js';
import { getElevationStatusMessage, setElevationFromInput } from '../../../security/elevation.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<string, unknown>);
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;
pairingManager?: PairingManager;
localProviders?: Record<string, ModelConfig>;
currentLocalProvider?: string;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
contextThresholdPct?: number;
onTransfer?: (target: string) => string | void;
onTools?: () => string;
onResearch?: (task: string) => Promise<string> | string;
onCouncil?: (task: string) => Promise<string> | string;
onExit?: () => void;
}
export function App({
session,
modelClient,
modelRouter,
systemPrompt,
model,
agent,
hookEngine,
pairingManager,
localProviders,
currentLocalProvider,
modelProviderConfigs,
contextThresholdPct,
onTransfer,
onTools,
onResearch,
onCouncil,
onExit,
}: AppProps): React.ReactElement {
const ensureTimestamp = useCallback((message: Message): Message => ({
...message,
timestamp: message.timestamp ?? Date.now(),
}), []);
const ensureTimestamps = useCallback((history: Message[]): Message[] => (
history.map(ensureTimestamp)
), [ensureTimestamp]);
const ctrlCExitWindowMs = 1_500;
const { exit } = useApp();
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>(ensureTimestamps(session.getHistory()));
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [scrollOffset, setScrollOffset] = useState(0);
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ 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 lastCtrlCAtRef = useRef(0);
const toolLinesRef = useRef<string[]>([]);
// 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 (!verbose) {
return;
}
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, verbose]);
// Fullscreen TUI runs in non-interactive confirmation mode:
// confirmation hooks are auto-approved so flows never block on a y/n prompt.
useEffect(() => {
if (!hookEngine) {return;}
hookEngine.setInteractiveConfirmer(async () => ({ approved: true }));
return () => {
hookEngine.setInteractiveConfirmer(undefined);
};
}, [hookEngine]);
useInput((inputChar, key) => {
if (key.escape) {
if (isStreaming) {
abortRef.current = true;
} else {
onExit?.();
exit();
}
return;
}
if (key.ctrl && inputChar.toLowerCase() === 'c') {
const now = Date.now();
const shouldExit = lastCtrlCAtRef.current > 0 && (now - lastCtrlCAtRef.current) <= ctrlCExitWindowMs;
lastCtrlCAtRef.current = now;
if (shouldExit) {
onExit?.();
exit();
return;
}
if (input.length > 0) {
setInput('');
}
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 pushAssistantMessage = useCallback((content: string) => {
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
}, [session]);
const getAvailableBackends = useCallback((): string[] => {
const backends: string[] = [];
if (currentLocalProvider) {
backends.push(currentLocalProvider);
}
if (localProviders) {
backends.push(...Object.keys(localProviders));
}
return [...new Set(backends)];
}, [currentLocalProvider, localProviders]);
const createLocalClient = useCallback((cfg: ModelConfig): ModelClient | null => {
if (cfg.provider === 'ollama') {
return new OllamaClient({
model: cfg.model,
host: cfg.endpoint,
});
}
if (cfg.provider === 'llamacpp') {
return new LlamaCppClient({
endpoint: cfg.endpoint ?? 'http://localhost:8080',
model: cfg.model,
authToken: cfg.auth_token,
});
}
return null;
}, []);
const handleSubmit = useCallback(async (value: string) => {
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);
let content = `Verbose mode: ${next ? 'on' : 'off'}`;
if (next && agent) {
const snapshot = agent.getToolInventorySnapshot();
content += `\n[Agent] tool-inventory session=${snapshot.sessionId} agent=${snapshot.agent} provider=${snapshot.provider} skill=${snapshot.skill} internal=${snapshot.internalCount} exposed=${snapshot.exposedCount} internal_browser=[${snapshot.internalBrowser.join(', ') || 'none'}] exposed_browser=[${snapshot.exposedBrowser.join(', ') || 'none'}]`;
}
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
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 <tier> <provider/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 <tier>
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': {
if (!onTransfer) {
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer target is not available in fullscreen mode.' })]);
return;
}
const result = onTransfer(command.target);
const content = typeof result === 'string' && result.trim()
? result
: `Transfer requested: ${command.target}`;
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content })]);
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 <mode|cap|overflow|debounce_ms|summarize_overflow> <value>' })]);
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': {
if (!modelRouter) {
pushAssistantMessage('Backend switching not available.');
return;
}
if (!command.provider) {
const current = modelRouter.getLocalProviderName() ?? currentLocalProvider ?? 'unknown';
const available = getAvailableBackends();
pushAssistantMessage(`Current local backend: ${current}\nAvailable: ${available.join(', ')}`);
return;
}
const providerConfig = localProviders?.[command.provider];
if (!providerConfig) {
const available = getAvailableBackends();
pushAssistantMessage(`Backend '${command.provider}' not configured.\nAvailable: ${available.join(', ')}`);
return;
}
const client = createLocalClient(providerConfig);
if (!client) {
pushAssistantMessage(`Unsupported backend provider '${providerConfig.provider}'.`);
return;
}
modelRouter.setLocalClient(client, command.provider);
modelRouter.setTier('local');
if (agent) {
agent.setModelTier('local');
}
setCurrentModel(modelRouter.getLabel('local'));
pushAssistantMessage(`Switched backend to ${command.provider}`);
return;
}
case 'login': {
const provider = (command.provider ?? '').trim().toLowerCase();
if (!provider) {
pushAssistantMessage('Usage: /login <provider>\nSupported: github, openai, anthropic, zai');
return;
}
if (provider === 'github') {
pushAssistantMessage('Starting GitHub OAuth device login...');
try {
await loginGitHub((userCode, verificationUri) => {
pushAssistantMessage(`GitHub login required:\nCode: ${userCode}\nURL: ${verificationUri}`);
});
pushAssistantMessage('GitHub login complete. Token stored in ~/.config/flynn/auth.json');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
pushAssistantMessage(`GitHub login failed: ${msg}`);
}
return;
}
if (provider === 'openai') {
pushAssistantMessage('Starting OpenAI OAuth device login...');
try {
await loginOpenAI((userCode, verificationUri) => {
pushAssistantMessage(`OpenAI login required:\nCode: ${userCode}\nURL: ${verificationUri}`);
});
pushAssistantMessage('OpenAI login complete. Credentials stored in ~/.config/flynn/auth.json');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
pushAssistantMessage(`OpenAI login failed: ${msg}`);
}
return;
}
if (provider === 'anthropic' || provider === 'zai' || provider === 'zhipuai') {
pushAssistantMessage(
`/${command.type} ${provider} requires key entry, which fullscreen mode does not mask.\nUse minimal mode (pnpm tui) for interactive key setup.`,
);
return;
}
pushAssistantMessage(`Unknown login provider: ${provider}. Supported: github, openai, anthropic, zai`);
return;
}
case 'tools': {
if (!onTools) {
pushAssistantMessage('Tools command is not available in this TUI mode.');
return;
}
pushAssistantMessage(onTools());
return;
}
case 'research': {
if (!command.task.trim()) {
pushAssistantMessage('Usage: /research <question or task>');
return;
}
if (!onResearch) {
pushAssistantMessage('Research command is not available in this TUI mode.');
return;
}
pushAssistantMessage(await onResearch(command.task));
return;
}
case 'council': {
if (!command.task.trim()) {
pushAssistantMessage('Usage: /council <question or task> | /council preflight');
return;
}
if (!onCouncil) {
pushAssistantMessage('Council command is not available in this TUI mode.');
return;
}
pushAssistantMessage(await onCouncil(command.task));
return;
}
case 'pair': {
if (!pairingManager) {
pushAssistantMessage('Pairing not enabled. Set pairing.enabled: true in config.');
return;
}
if (command.action === 'generate') {
const code = pairingManager.generateCode(command.args);
const pending = pairingManager.listPendingCodes().find(p => p.code === code);
const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?';
pushAssistantMessage(`Pairing code: ${code}\nExpires in ${expiresIn}s${command.args ? ` (label: ${command.args})` : ''}`);
return;
}
if (command.action === 'revoke') {
const args = (command.args ?? '').trim();
const parts = args.split(/\s+/);
if (parts.length < 2) {
pushAssistantMessage('Usage: /pair revoke <channel> <senderId>');
return;
}
const [channel, senderId] = parts;
const revoked = pairingManager.revokeApproval(channel, senderId);
pushAssistantMessage(revoked ? `Revoked approval for ${channel}:${senderId}` : `No approval found for ${channel}:${senderId}`);
return;
}
const pending = pairingManager.listPendingCodes();
const approved = pairingManager.listApproved();
if (pending.length === 0 && approved.length === 0) {
pushAssistantMessage('No pending codes or approved senders.');
return;
}
const lines: string[] = [];
if (pending.length > 0) {
lines.push('Pending codes:');
for (const p of pending) {
const ttl = Math.max(0, Math.round((p.expiresAt - Date.now()) / 1000));
lines.push(` ${p.code} expires in ${ttl}s${p.label ? ` (label: ${p.label})` : ''}`);
}
}
if (approved.length > 0) {
lines.push('Approved senders:');
for (const a of approved) {
const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' ');
lines.push(` ${a.channel}:${a.senderId} since ${date} (code: ${a.codeUsed})`);
}
}
pushAssistantMessage(lines.join('\n'));
return;
}
case 'elevate': {
const store = {
get: (key: string) => session.getConfig(key),
set: (key: string, value: string) => session.setConfig(key, value),
delete: (key: string) => session.deleteConfig(key),
};
const raw = (command.args ?? '').trim();
if (!raw) {
pushAssistantMessage(getElevationStatusMessage(store, { reasonSeparator: ' - ' }));
return;
}
pushAssistantMessage(setElevationFromInput(store, raw));
return;
}
case 'multiline':
pushAssistantMessage('Multiline compose mode is currently available in minimal TUI only. In fullscreen mode, submit as a single message block.');
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, ensureTimestamp(userMessage)]);
}
setScrollOffset(0);
if (onTools && isToolInventoryQuery(command.content)) {
pushAssistantMessage(onTools());
return;
}
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(ensureTimestamps(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('');
}
}, [
session,
agent,
modelClient,
modelRouter,
systemPrompt,
exit,
onExit,
isStreaming,
ensureTimestamp,
ensureTimestamps,
messages.length,
tokenUsage.inputTokens,
tokenUsage.outputTokens,
pushAssistantMessage,
getAvailableBackends,
createLocalClient,
localProviders,
currentLocalProvider,
pairingManager,
modelProviderConfigs,
onTransfer,
]);
return (
<Box flexDirection="column" height="100%">
<MessageList
messages={messages}
scrollOffset={scrollOffset}
streamingContent={isStreaming ? streamingContent : undefined}
/>
<InputBar
value={input}
onChange={setInput}
onSubmit={handleSubmit}
isLoading={isStreaming}
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'}
/>
<StatusBar
sessionId={session.id}
messageCount={messages.length}
model={currentModel}
tokenUsage={{ input: tokenUsage.inputTokens, output: tokenUsage.outputTokens }}
isStreaming={isStreaming}
/>
</Box>
);
}