import * as readline from 'node:readline'; import type { ManagedSession } from '../../session/index.js'; import type { ModelClient, TokenUsage } from '../../models/types.js'; import type { ModelRouter, ModelTier } from '../../models/router.js'; import type { NativeAgent } from '../../backends/native/agent.js'; import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js'; import { renderMarkdown } from './markdown.js'; import type { ModelConfig, ModelProvider } from '../../config/schema.js'; import { MODEL_PROVIDERS } from '../../config/schema.js'; import { OllamaClient, LlamaCppClient } from '../../models/index.js'; import { createClientFromConfig } from '../../daemon/index.js'; import { loadStoredAnthropicAuth, loadStoredAnthropicAuthToken, loadStoredOpenAIApiKey, loadStoredOpenAIAuth, loadStoredZaiAuth, loginGitHub, loginOpenAI, storeAnthropicAuth, storeAnthropicAuthToken, storeOpenAIApiKey, storeZaiAuth, } from '../../auth/index.js'; import type { PairingManager } from '../../channels/pairing.js'; import { getColoredBanner } from './banner.js'; import type { HookEngine } from '../../hooks/index.js'; export { parseCommand, type Command }; // ANSI color codes const colors = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', blue: '\x1b[34m', orange: '\x1b[38;5;208m', gray: '\x1b[90m', bgDark: '\x1b[48;5;234m', }; export function formatPrompt(state: 'default' | 'thinking'): string { if (state === 'thinking') { return `${colors.orange}flynn...${colors.reset} `; } return `${colors.orange}${colors.bold}flynn>${colors.reset} `; } export interface MinimalTuiConfig { session: ManagedSession; modelClient: ModelClient; modelRouter?: ModelRouter; systemPrompt: string; agent?: NativeAgent; onFullscreen?: () => void; onTransfer?: (target: string) => void; localProviders?: Record; modelProviderConfigs?: Partial>; currentLocalProvider?: string; pairingManager?: PairingManager; hookEngine?: HookEngine; } export class MinimalTui { private rl: readline.Interface | null = null; private running = false; private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 }; private currentHint = ''; private lastLine = ''; constructor(private config: MinimalTuiConfig) {} private showHint(line: string): void { if (!line.startsWith('/')) { this.clearHint(); return; } const completions = getCommandCompletions(line); const tooltip = getCommandTooltip(line); let hint = ''; if (completions.length === 1 && completions[0] !== line) { // Show the remaining part of the completion as a hint hint = completions[0].slice(line.length); } // Add tooltip if available if (tooltip) { hint += ` ${colors.gray}— ${tooltip}${colors.reset}`; } else if (completions.length > 1) { hint += ` ${colors.gray}[${completions.length} options, Tab to complete]${colors.reset}`; } if (hint && hint !== this.currentHint) { this.clearHint(); this.currentHint = hint; // Save cursor, write dim hint, restore cursor process.stdout.write(`\x1b[s${colors.dim}${hint}${colors.reset}\x1b[u`); } else if (!hint) { this.clearHint(); } } private clearHint(): void { if (this.currentHint) { // Clear from cursor to end of line process.stdout.write('\x1b[K'); this.currentHint = ''; } } async start(): Promise { this.running = true; if (this.config.agent && this.config.modelRouter) { this.config.agent.setModelTier(this.config.modelRouter.getTier()); } this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer: (line: string) => { const completions = getCommandCompletions(line); return [completions, line]; }, }); // In minimal TUI we can prompt inline for tool confirmations. // This avoids deadlocks when hooks are configured to require confirmation // (e.g. shell.exec) and the tool loop is awaiting a decision. if (this.config.hookEngine) { this.config.hookEngine.setInteractiveConfirmer(async (pending) => { const tool = pending.tool; const args = pending.args; const argsStr = Object.keys(args).length > 0 ? ` ${JSON.stringify(args)}` : ''; console.log(`\n${colors.bold}Confirmation required${colors.reset}`); console.log(`${colors.gray}${tool}${colors.reset}${argsStr}`); const answer = (await this.prompt(`${colors.orange}${colors.bold}Approve?${colors.reset} ${colors.gray}(y/N)${colors.reset} `)) .trim() .toLowerCase(); const approved = answer === 'y' || answer === 'yes'; console.log(approved ? `${colors.gray}Approved.${colors.reset}\n` : `${colors.gray}Denied.${colors.reset}\n`); return approved ? { approved: true } : { approved: false, reason: 'Denied by user' }; }); } // Listen for line changes to show hints process.stdin.on('keypress', () => { // Small delay to let readline update the line setImmediate(() => { if (this.rl) { const line = (this.rl as readline.Interface & { line?: string }).line || ''; if (line !== this.lastLine) { this.lastLine = line; this.showHint(line); } } }); }); // Enable keypress events if (process.stdin.isTTY) { readline.emitKeypressEvents(process.stdin); } console.log(getColoredBanner()); console.log(`\n${colors.orange}${colors.bold}Flynn TUI${colors.reset} ${colors.dim}(minimal mode)${colors.reset}`); console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode${colors.reset}\n`); await this.promptLoop(); } private async promptLoop(): Promise { while (this.running && this.rl) { this.lastLine = ''; this.currentHint = ''; const input = await this.prompt(formatPrompt('default')); this.clearHint(); const command = parseCommand(input); if (!command) { continue; } await this.handleCommand(command); } } private prompt(promptText: string): Promise { return new Promise((resolve) => { if (!this.rl) { resolve(''); return; } const onClose = () => resolve(''); this.rl.once('close', onClose); this.rl.question(promptText, (answer) => { this.rl?.removeListener('close', onClose); resolve(answer); }); }); } private async handleCommand(command: Command): Promise { switch (command.type) { case 'quit': this.stop(); break; case 'reset': this.config.session.clear(); this.totalUsage = { inputTokens: 0, outputTokens: 0 }; console.log(`${colors.gray}Session cleared.${colors.reset}\n`); break; case 'help': console.log(getHelpText() + '\n'); break; case 'status': this.printStatus(); break; case 'fullscreen': this.config.onFullscreen?.(); break; case 'model': this.handleModelCommand(command.name, command.providerModel); break; case 'backend': await this.handleBackendCommand(command.provider); break; case 'login': await this.handleLoginCommand(command.provider); break; case 'pair': this.handlePairCommand(command.action, command.args); break; case 'transfer': this.config.onTransfer?.(command.target); break; case 'message': await this.handleMessage(command.content); break; } } private handleModelCommand(name?: string, providerModel?: string): void { const router = this.config.modelRouter; if (!router) { console.log(`${colors.gray}Model switching not available.${colors.reset}\n`); return; } // /model — change a tier's provider and model if (name && providerModel) { const tier = resolveModelAlias(name); const slashIdx = providerModel.indexOf('/'); if (slashIdx === -1) { console.log(`${colors.gray}Invalid format. Use provider/model (e.g. anthropic/claude-sonnet-4)${colors.reset}\n`); return; } const provider = providerModel.slice(0, slashIdx); const model = providerModel.slice(slashIdx + 1); if (!MODEL_PROVIDERS.includes(provider as ModelProvider)) { console.log(`${colors.gray}Unknown provider "${provider}". Known providers: ${MODEL_PROVIDERS.join(', ')}${colors.reset}\n`); return; } try { const providerType = provider as ModelProvider; const template = this.config.modelProviderConfigs?.[providerType]; const client = createClientFromConfig({ ...(template ?? {}), provider: providerType, model, }); router.setClient(tier, client, providerModel); router.setTierStrict(tier, true); router.setTier(tier); if (this.config.agent) { this.config.agent.setModelTier(tier); } console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}`); console.log(`${colors.gray}Switched to model:${colors.reset} ${tier}`); console.log(`${colors.gray}Fallbacks for ${tier} disabled (strict tier mode).${colors.reset}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`); } return; } // /model — show all tiers with labels if (!name) { const current = router.getTier(); const available = router.getAvailableTiers(); const labels = router.getAllLabels(); console.log(`${colors.gray}Active tier:${colors.reset} ${current}`); for (const tier of available) { const label = labels[tier] ?? 'unknown'; const marker = tier === current ? ' ←' : ''; console.log(` ${tier}: ${label}${marker}`); } console.log(); return; } // /model — switch active tier const tier = resolveModelAlias(name); if (router.setTier(tier)) { // Also update the agent tier so chatWithRouter uses the correct client if (this.config.agent) { this.config.agent.setModelTier(tier); } console.log(`${colors.gray}Switched to model:${colors.reset} ${tier}\n`); } else { console.log(`${colors.gray}Model not available:${colors.reset} ${name}\n`); } } private async handleBackendCommand(provider?: string): Promise { const router = this.config.modelRouter; if (!router) { console.log('Backend switching not available.\n'); return; } if (!provider) { const current = router.getLocalProviderName() ?? this.config.currentLocalProvider ?? 'unknown'; const available = this.getAvailableBackends(); console.log(`Current local backend: ${current}`); console.log(`Available: ${available.join(', ')}\n`); return; } const providerConfig = this.config.localProviders?.[provider]; if (!providerConfig) { const available = this.getAvailableBackends(); console.log(`Backend '${provider}' not configured.`); console.log(`Available: ${available.join(', ')}\n`); return; } // Stop current daemon if running const currentBackend = router.getLocalProviderName(); if (currentBackend && currentBackend !== provider) { console.log(`${colors.gray}Stopping ${currentBackend}...${colors.reset}`); await this.stopBackend(currentBackend); } // Start new daemon console.log(`${colors.gray}Starting ${provider}...${colors.reset}`); await this.startBackend(provider, providerConfig); const client = this.createLocalClient(providerConfig); if (!client) { console.log(`Failed to create client for '${provider}'.\n`); return; } router.setLocalClient(client, provider); console.log(`${colors.gray}Switched to backend: ${provider}${colors.reset}\n`); } private async stopBackend(provider: string): Promise { try { const { exec } = await import('child_process'); let serviceName: string; switch (provider) { case 'ollama': serviceName = 'ollama.service'; break; case 'llamacpp': serviceName = 'llama-server.service'; break; default: return; } await new Promise((resolve, reject) => { exec(`systemctl --user stop ${serviceName}`, (error) => { if (error) { reject(error); } else { resolve(); } }); }); } catch (error) { // Service might not exist or already stopped, ignore console.log(`${colors.gray}Note: ${provider} service not managed by systemd${colors.reset}\n`); } } private async startBackend(provider: string, config: ModelConfig): Promise { try { const { exec } = await import('child_process'); let serviceName: string; switch (provider) { case 'ollama': serviceName = 'ollama.service'; break; case 'llamacpp': serviceName = 'llama-server.service'; break; default: return; } await new Promise((resolve, reject) => { exec(`systemctl --user start ${serviceName}`, (error) => { if (error) { reject(error); } else { resolve(); } }); }); // Wait briefly for daemon to start await new Promise(resolve => setTimeout(resolve, 500)); } catch (error) { console.log(`${colors.gray}Warning: Failed to start ${provider} via systemd: ${error instanceof Error ? error.message : String(error)}${colors.reset}\n`); } } private async handleLoginCommand(provider?: string): Promise { const target = provider ?? 'github'; const promptHidden = async (question: string): Promise => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); const rlAny = rl as any; rlAny.stdoutMuted = true; rlAny._writeToOutput = (s: string) => { if (!rlAny.stdoutMuted) { process.stdout.write(s); return; } if (s.includes('\n')) { process.stdout.write('\n'); } else { process.stdout.write('*'); } }; const answer = await new Promise((resolve) => rl.question(question, resolve)); rlAny.stdoutMuted = false; rl.close(); process.stdout.write('\n'); return answer.trim(); }; if (!this.rl) { console.log(`${colors.gray}TUI not ready for login prompt. Use the CLI auth commands instead.${colors.reset}\n`); return; } if (target === 'github') { console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`); try { await loginGitHub((userCode, verificationUri) => { console.log(''); console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`); console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`); console.log(''); console.log(`${colors.gray}Waiting for authorization...${colors.reset}`); }); console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`); } return; } if (target === 'openai') { console.log(`${colors.gray}OpenAI login:${colors.reset}`); console.log(`${colors.gray} 1) OAuth device flow 2) Paste API key${colors.reset}`); const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim(); // 2) API key if (choice === '2') { const existing = loadStoredOpenAIApiKey(); if (existing) { console.log(`${colors.gray}OpenAI API key already exists.${colors.reset}`); console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.api_key entry to re-authenticate.${colors.reset}\n`); return; } console.log(`${colors.gray}OpenAI uses API keys for standard API access.${colors.reset}`); console.log(`${colors.gray}Create a key at:${colors.reset} https://platform.openai.com/api-keys`); console.log(''); try { this.rl.pause(); const apiKey = await promptHidden('Enter OpenAI API key: '); storeOpenAIApiKey(apiKey); console.log(''); console.log(`${colors.gray}OpenAI API key stored in ~/.config/flynn/auth.json${colors.reset}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}OpenAI API key storage failed:${colors.reset} ${message}\n`); } finally { this.rl.resume(); } return; } // 1) OAuth device flow (default) const existing = loadStoredOpenAIAuth(); if (existing) { console.log(`${colors.gray}OpenAI OAuth token already exists.${colors.reset}`); console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.oauth entry (or legacy openai entry) to re-authenticate.${colors.reset}\n`); return; } console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`); try { await loginOpenAI((userCode, verificationUri) => { console.log(''); console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`); console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`); console.log(''); console.log(`${colors.gray}Waiting for authorization...${colors.reset}`); }); console.log(`${colors.gray}OpenAI authentication successful! Token stored.${colors.reset}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`); } return; } if (target === 'anthropic') { console.log(`${colors.gray}Anthropic login:${colors.reset}`); console.log(`${colors.gray} 1) Paste API key 2) Paste auth token${colors.reset}`); const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim(); const existing = loadStoredAnthropicAuth(); const hasApiKey = Boolean(existing?.api_key); const hasToken = Boolean(loadStoredAnthropicAuthToken()); // 2) Auth token if (choice === '2') { if (hasToken) { console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`); console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.auth_token entry to re-authenticate.${colors.reset}\n`); return; } console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`); console.log(''); try { this.rl.pause(); const token = await promptHidden('Enter Anthropic auth token: '); storeAnthropicAuthToken(token); console.log(''); console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`); } finally { this.rl.resume(); } return; } // 1) API key (default) if (hasApiKey) { console.log(`${colors.gray}Anthropic API key already exists.${colors.reset}`); console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.api_key entry to re-authenticate.${colors.reset}\n`); return; } console.log(`${colors.gray}Anthropic uses API keys for authentication.${colors.reset}`); console.log(`${colors.gray}Create a key at:${colors.reset} https://console.anthropic.com/settings/keys`); console.log(''); try { this.rl.pause(); const apiKey = await promptHidden('Enter Anthropic API key: '); storeAnthropicAuth(apiKey); console.log(''); console.log(`${colors.gray}Anthropic API key stored in ~/.config/flynn/auth.json${colors.reset}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`); } finally { this.rl.resume(); } return; } if (target === 'zai' || target === 'zhipuai') { const existing = loadStoredZaiAuth(); if (existing) { console.log(`${colors.gray}Z.AI credential already exists.${colors.reset}`); console.log(`${colors.gray}Delete ~/.config/flynn/auth.json zai/zhipuai entry to re-authenticate.${colors.reset}\n`); return; } console.log(`${colors.gray}Z.AI uses API keys (HTTP Bearer), not an OAuth device flow.${colors.reset}`); console.log(`${colors.gray}Create a key at:${colors.reset} https://z.ai/manage-apikey/apikey-list`); console.log(`${colors.gray}Choose mode: 1) API 2) Coding Plan${colors.reset}`); const choice = (await this.prompt(`${colors.orange}Select [1-2] (default 1):${colors.reset} `)).trim().toLowerCase(); const mode = (choice === '2' || choice === 'plan') ? 'plan' : 'api'; console.log(''); try { this.rl.pause(); const apiKey = await promptHidden('Enter Z.AI API key: '); storeZaiAuth(apiKey); console.log(''); console.log(`${colors.gray}Z.AI credential stored in ~/.config/flynn/auth.json${colors.reset}`); if (mode === 'plan') { console.log(`${colors.gray}Mode: Coding Plan${colors.reset}`); console.log(`${colors.gray}Set endpoint to https://api.z.ai/api/coding/paas/v4${colors.reset}\n`); } else { console.log(`${colors.gray}Mode: API${colors.reset}`); console.log(`${colors.gray}Set endpoint to https://api.z.ai/api/paas/v4${colors.reset}\n`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}Z.AI auth failed:${colors.reset} ${message}\n`); } finally { this.rl.resume(); } return; } console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai, anthropic, zai\n`); } private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void { const pm = this.config.pairingManager; if (!pm) { console.log(`${colors.gray}Pairing not enabled. Set pairing.enabled: true in config.${colors.reset}\n`); return; } switch (action) { case 'generate': { const code = pm.generateCode(args); const pending = pm.listPendingCodes().find(p => p.code === code); const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?'; console.log(`${colors.bold}Pairing code: ${code}${colors.reset}`); console.log(`${colors.gray}Expires in ${expiresIn}s${args ? ` (label: ${args})` : ''}${colors.reset}\n`); break; } case 'revoke': { if (!args) { console.log(`${colors.gray}Usage: /pair revoke ${colors.reset}\n`); return; } const parts = args.split(/\s+/); if (parts.length < 2) { console.log(`${colors.gray}Usage: /pair revoke ${colors.reset}\n`); return; } const [channel, senderId] = parts; const revoked = pm.revokeApproval(channel, senderId); if (revoked) { console.log(`${colors.bold}Revoked approval for ${channel}:${senderId}${colors.reset}\n`); } else { console.log(`${colors.gray}No approval found for ${channel}:${senderId}${colors.reset}\n`); } break; } case 'list': default: { const pending = pm.listPendingCodes(); const approved = pm.listApproved(); if (pending.length === 0 && approved.length === 0) { console.log(`${colors.gray}No pending codes or approved senders.${colors.reset}\n`); return; } if (pending.length > 0) { console.log(`${colors.bold}Pending codes:${colors.reset}`); for (const p of pending) { const expiresIn = Math.round((p.expiresAt - Date.now()) / 1000); console.log(` ${p.code} expires in ${expiresIn}s${p.label ? ` (${p.label})` : ''}`); } } if (approved.length > 0) { console.log(`${colors.bold}Approved senders:${colors.reset}`); for (const a of approved) { const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' '); console.log(` ${a.channel}:${a.senderId} since ${date} (code: ${a.codeUsed})`); } } console.log(''); break; } } } private getAvailableBackends(): string[] { const backends: string[] = []; if (this.config.currentLocalProvider) { backends.push(this.config.currentLocalProvider); } if (this.config.localProviders) { backends.push(...Object.keys(this.config.localProviders)); } return [...new Set(backends)]; } private createLocalClient(config: ModelConfig): ModelClient | null { if (config.provider === 'ollama') { return new OllamaClient({ model: config.model, host: config.endpoint, }); } if (config.provider === 'llamacpp') { return new LlamaCppClient({ endpoint: config.endpoint ?? 'http://localhost:8080', model: config.model, authToken: config.auth_token, }); } return null; } private printStatus(): void { console.log(`${colors.gray}Session:${colors.reset} ${this.config.session.id}`); console.log(`${colors.gray}Messages:${colors.reset} ${this.config.session.getHistory().length}`); console.log(`${colors.gray}Tokens:${colors.reset} ${this.totalUsage.inputTokens} in / ${this.totalUsage.outputTokens} out\n`); } private async handleMessage(content: string): Promise { // Print Flynn label before response process.stdout.write(`\n${colors.orange}${colors.bold}Flynn:${colors.reset}\n`); try { // Use agent if available (supports tool loop) if (this.config.agent) { const response = await this.config.agent.process(content); const rendered = renderMarkdown(response); console.log(rendered); console.log(); return; } // Fallback: direct model client (no tool support) this.config.session.addMessage({ role: 'user', content }); // Try streaming if available if (this.config.modelClient.chatStream) { let fullContent = ''; for await (const event of this.config.modelClient.chatStream({ messages: this.config.session.getHistory(), system: this.config.systemPrompt, })) { if (event.type === 'content' && event.content) { process.stdout.write(event.content); fullContent += event.content; } if (event.type === 'fallback_warning' && event.fallbackReason) { console.warn('\n⚠ Using fallback model'); } if (event.type === 'done' && event.usage) { this.totalUsage.inputTokens += event.usage.inputTokens; this.totalUsage.outputTokens += event.usage.outputTokens; } if (event.type === 'error') { throw event.error ?? new Error('Stream error'); } } console.log('\n'); this.config.session.addMessage({ role: 'assistant', content: fullContent }); } else { // Fallback to non-streaming const response = await this.config.modelClient.chat({ messages: this.config.session.getHistory(), system: this.config.systemPrompt, }); const rendered = renderMarkdown(response.content); console.log(rendered); console.log(); this.totalUsage.inputTokens += response.usage.inputTokens; this.totalUsage.outputTokens += response.usage.outputTokens; this.config.session.addMessage({ role: 'assistant', content: response.content }); } } catch (error) { console.error('Error:', error instanceof Error ? error.message : error); console.log(); } } stop(preserveStdin = false): void { this.running = false; if (this.rl) { if (preserveStdin) { // Remove readline listeners but don't close stdin this.rl.removeAllListeners(); process.stdin.removeAllListeners('keypress'); // Pause stdin so readline releases it process.stdin.pause(); } this.rl.close(); this.rl = null; } // Clean up keypress listener process.stdin.removeAllListeners('keypress'); } }