diff --git a/src/cli/tui.ts b/src/cli/tui.ts index ec34b69..6dbc930 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -1,4 +1,31 @@ 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'; + +// ANSI color codes for tool status display +const toolColors = { + reset: '\x1b[0m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + red: '\x1b[31m', +}; + +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 @@ -6,8 +33,180 @@ export function registerTuiCommand(program: Command): void { .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 }) => { - console.error('Not yet implemented'); - process.exit(1); + .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'); + const { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, ModelRouter } = await import('../models/index.js'); + const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); + const { NativeAgent } = await import('../backends/index.js'); + const { ToolRegistry, ToolExecutor, allBuiltinTools } = await import('../tools/index.js'); + const { HookEngine } = await import('../hooks/index.js'); + + const dataDir = resolve(homedir(), '.local/share/flynn'); + mkdirSync(dataDir, { recursive: true }); + + const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); + const sessionManager = new SessionManager(sessionStore); + const models = config.models; + + // Build model router + const defaultClient = new AnthropicClient({ + model: models.default.model, + apiKey: models.default.api_key, + authToken: models.default.auth_token, + }); + + let fastClient; + let complexClient; + let localClient; + + if (models.fast) { + fastClient = new AnthropicClient({ + model: models.fast.model, + apiKey: models.fast.api_key, + authToken: models.fast.auth_token, + }); + } + + if (models.complex) { + complexClient = new AnthropicClient({ + model: models.complex.model, + apiKey: models.complex.api_key, + authToken: models.complex.auth_token, + }); + } + + if (models.local) { + if (models.local.provider === 'ollama') { + localClient = new OllamaClient({ + model: models.local.model, + host: models.local.endpoint, + numGpu: models.local.num_gpu, + }); + } else if (models.local.provider === 'llamacpp') { + localClient = new LlamaCppClient({ + endpoint: models.local.endpoint ?? 'http://localhost:8080', + model: models.local.model, + authToken: models.local.auth_token, + }); + } + } + + const fallbackChain = []; + for (const providerName of models.fallback_chain) { + if (providerName === 'openai') { + fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' })); + } else if (providerName === 'local' && localClient) { + fallbackChain.push(localClient); + } + } + + const modelRouter = new ModelRouter({ + default: defaultClient, + fast: fastClient, + complex: complexClient, + local: localClient, + fallbackChain, + }); + + const systemPrompt = loadSystemPrompt(); + + const hookEngine = new HookEngine(config.hooks); + const toolRegistry = new ToolRegistry(); + for (const tool of allBuiltinTools) { + 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 argsStr = event.args ? ` ${toolColors.dim}${JSON.stringify(event.args)}${toolColors.reset}` : ''; + process.stdout.write(`${toolColors.cyan}> ${event.tool}${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 = () => { + 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, + localProviders: config.models.local_providers, + currentLocalProvider: config.models.local?.provider, + onTransfer: (target) => { + if (target === 'telegram') { + 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(`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(); }); }