Files
flynn/src/cli/tui.ts
T
William Valentin 411c6d84a2 feat(tui): persist model tier selection and fix formatting
Persist /model tier choice to ~/.local/share/flynn/preferences.json so
it survives restarts. Decode HTML entities (e.g. ') in markdown
renderer output. Suppress noisy logger.info and punycode deprecation
warnings in TUI startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:23:12 -08:00

250 lines
9.0 KiB
TypeScript

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<string, unknown>);
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 <path>', '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();
});
}