import type { Command } from 'commander'; import type { Config } from '../config/index.js'; import { loadConfigSafe, getConfigPath } from './shared.js'; import { existsSync, mkdirSync, readFileSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; import { setLogLevel } from '../logger.js'; // ANSI color codes for tool status display const toolColors = { reset: '\x1b[0m', dim: '\x1b[2m', cyan: '\x1b[36m', green: '\x1b[32m', red: '\x1b[31m', bold: '\x1b[1m', }; /** 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 instead of raw JSON. */ function formatToolArgs(args: unknown): string { if (!args || typeof args !== 'object') return ''; const entries = Object.entries(args as Record); if (entries.length === 0) return ''; const parts = entries.map(([key, value]) => { if (typeof value === 'string') { const display = value.length > 60 ? value.slice(0, 57) + '...' : value; return `${key}: "${display}"`; } if (typeof value === 'number' || typeof value === 'boolean') { return `${key}: ${value}`; } return `${key}: ${JSON.stringify(value)}`; }); return parts.join(', '); } function loadSystemPrompt(): string { const paths = [ resolve(process.cwd(), 'SOUL.md'), resolve(import.meta.dirname, '../../SOUL.md'), ]; for (const soulPath of paths) { if (existsSync(soulPath)) { return readFileSync(soulPath, 'utf-8'); } } return 'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.'; } export function registerTuiCommand(program: Command): void { program .command('tui') .description('Launch the interactive TUI') .option('-f, --fullscreen', 'Start in fullscreen mode') .option('-c, --config ', 'Config file path') .action(async (opts: { fullscreen?: boolean; config?: string }) => { const configPath = opts.config ?? getConfigPath(); const { config, error } = loadConfigSafe(configPath); if (!config) { console.error(error); process.exit(1); } // Dynamic imports to keep CLI startup fast const { SessionStore, SessionManager } = await import('../session/index.js'); // In the TUI, default to 'warn' so model-router and other info messages // don't clutter the interactive terminal. Honour the user's explicit // choice if they set log_level to something more verbose. const tuiLogLevel = config.log_level === 'debug' ? 'debug' : 'warn'; setLogLevel(tuiLogLevel); const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); const { NativeAgent } = await import('../backends/index.js'); const { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, createGmailTools, createGcalTools } = await import('../tools/index.js'); const { HookEngine } = await import('../hooks/index.js'); const { createModelRouter } = await import('../daemon/index.js'); const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn'); mkdirSync(dataDir, { recursive: true }); const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); const sessionManager = new SessionManager(sessionStore); // Reuse the daemon's model router factory — includes auto-fallback, // local_providers, retry config, and per-tier fallback logic. const { loadPreferences, savePreference } = await import('../preferences.js'); const modelRouter = createModelRouter(config); // Restore persisted model tier and save future changes const prefs = loadPreferences(dataDir); if (prefs.modelTier) { modelRouter.setTier(prefs.modelTier as import('../models/router.js').ModelTier); } modelRouter.setOnTierChange((tier) => savePreference(dataDir, 'modelTier', tier)); const { initPairingManager } = await import('../daemon/services.js'); const pairingStore = config.pairing.enabled ? sessionStore.getPairingStore() : undefined; const pairingManager = initPairingManager(config, pairingStore); const systemPrompt = loadSystemPrompt(); const hookEngine = new HookEngine(config.hooks); const toolRegistry = new ToolRegistry(); for (const tool of allBuiltinTools) { toolRegistry.register(tool); } // Register web search tools if configured with credentials if (config.web_search.api_key || config.web_search.endpoint) { for (const tool of createWebSearchTools({ provider: config.web_search.provider, apiKey: config.web_search.api_key, endpoint: config.web_search.endpoint, maxResults: config.web_search.max_results, })) { toolRegistry.register(tool); } } // Initialize process manager and register process tools const processManager = new ProcessManager({ maxConcurrent: config.process.max_concurrent, maxRuntimeMinutes: config.process.max_runtime_minutes, bufferSize: config.process.buffer_size, }); for (const tool of createProcessTools(processManager)) { toolRegistry.register(tool); } // Register Gmail tools if configured if (config.automation.gmail?.enabled) { for (const tool of createGmailTools(config.automation.gmail)) { toolRegistry.register(tool); } } // Register Google Calendar tools if configured if (config.automation.gcal?.enabled) { for (const tool of createGcalTools(config.automation.gcal)) { toolRegistry.register(tool); } } const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); const session = sessionManager.getSession('tui', 'local'); const agent = new NativeAgent({ modelClient: modelRouter, systemPrompt, session, toolRegistry, toolExecutor, onToolUse: (event) => { if (event.type === 'start') { const label = formatToolName(event.tool); const argsStr = event.args ? ` ${toolColors.dim}(${formatToolArgs(event.args)})${toolColors.reset}` : ''; process.stdout.write(`${toolColors.cyan}> ${toolColors.bold}${label}${toolColors.reset}${argsStr}\n`); } else if (event.type === 'end' && event.result) { const icon = event.result.success ? `${toolColors.green}done` : `${toolColors.red}error`; const detail = event.result.success ? `${toolColors.dim}(${event.result.output.split('\n').length} lines)${toolColors.reset}` : `${toolColors.dim}${event.result.error ?? 'unknown error'}${toolColors.reset}`; process.stdout.write(` ${icon}${toolColors.reset} ${detail}\n`); } }, }); const cleanup = () => { processManager.shutdown(); sessionStore.close(); }; process.on('SIGINT', () => { cleanup(); process.exit(0); }); if (opts.fullscreen) { await startFullscreenTui({ session, modelClient: modelRouter, modelRouter, systemPrompt, model: config.models.default.model, onExit: cleanup, }); } else { let switchingToFullscreen = false; const tui = new MinimalTui({ session, modelClient: modelRouter, modelRouter, systemPrompt, agent, pairingManager, localProviders: config.models.local_providers, currentLocalProvider: config.models.local?.provider, onTransfer: (target) => { if (target === 'telegram') { if (config.telegram && config.telegram.allowed_chat_ids.length > 0) { const telegramUserId = String(config.telegram.allowed_chat_ids[0]); sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId); console.log(`Session transferred to Telegram (${telegramUserId})\n`); } else { console.log('Telegram not configured\n'); } } else { console.log(`Unknown transfer target: ${target}\n`); } }, onFullscreen: () => { switchingToFullscreen = true; tui.stop(true); }, }); await tui.start(); if (switchingToFullscreen) { console.clear(); await startFullscreenTui({ session, modelClient: modelRouter, modelRouter, systemPrompt, model: config.models.default.model, onExit: cleanup, }); return; } } cleanup(); }); }