Files
flynn/src/frontends/tui/components/App.tsx
T

615 lines
22 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 } 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<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;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
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<Message[]>(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 toolLinesRef = useRef<string[]>([]);
const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null);
const [confirmation, setConfirmation] = useState<{ tool: string; args: Record<string, unknown> } | 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<HookResult>((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 <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':
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 <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':
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 (
<Box flexDirection="column" height="100%">
<MessageList
messages={messages}
scrollOffset={scrollOffset}
streamingContent={isStreaming ? streamingContent : undefined}
/>
{confirmation ? (
<Box paddingX={1} paddingY={0} borderStyle="round" borderColor="yellow">
<Text color="yellow">
Confirmation required: {confirmation.tool}{' '}
{Object.keys(confirmation.args).length > 0 ? JSON.stringify(confirmation.args) : ''}
</Text>
<Text color="yellow">Press y to approve, n to deny.</Text>
</Box>
) : null}
<InputBar
value={input}
onChange={setInput}
onSubmit={handleSubmit}
isLoading={isStreaming || !!confirmation}
placeholder={confirmation
? 'Confirmation required (press y/n)'
: (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>
);
}