860 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|