diff --git a/package.json b/package.json index 0b39c20..a965a9c 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build": "tsc", "dev": "tsx watch src/cli/index.ts -- start", "start": "node dist/cli/index.js start", - "tui": "tsx src/cli/index.ts tui", - "tui:fs": "tsx src/cli/index.ts tui --fullscreen", + "tui": "node --no-deprecation --import tsx/esm src/cli/index.ts tui", + "tui:fs": "node --no-deprecation --import tsx/esm src/cli/index.ts tui --fullscreen", "tui:dev": "tsx watch src/cli/index.ts -- tui", "test": "vitest", "test:run": "vitest run", diff --git a/src/cli/tui.ts b/src/cli/tui.ts index 6e36f93..50e1133 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -75,7 +75,11 @@ export function registerTuiCommand(program: Command): void { // Dynamic imports to keep CLI startup fast const { SessionStore, SessionManager } = await import('../session/index.js'); - setLogLevel(config.log_level); + // 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'); @@ -90,8 +94,16 @@ export function registerTuiCommand(program: Command): void { // 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); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index f0594d9..9cdfeb4 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -97,6 +97,15 @@ export async function startDaemon(config: Config): Promise { model: config.audio.transcription_model, }; const modelRouter = createModelRouter(config); + + // Restore persisted model tier + const { loadPreferences, savePreference } = await import('../preferences.js'); + 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 systemPrompt = loadSystemPrompt(config, skillRegistry); // ── Gateway & Channels ── diff --git a/src/frontends/tui/markdown.test.ts b/src/frontends/tui/markdown.test.ts index bc1b9a6..80bfe25 100644 --- a/src/frontends/tui/markdown.test.ts +++ b/src/frontends/tui/markdown.test.ts @@ -109,4 +109,10 @@ describe('renderMarkdown', () => { expect(result).toContain('\x1b[34m'); expect(result).toContain('https://example.com'); }); + + it('decodes HTML entities like apostrophes', () => { + const result = renderMarkdown("I'm here. What's up?"); + expect(result).toContain("I'm here"); + expect(result).not.toContain('''); + }); }); diff --git a/src/frontends/tui/markdown.ts b/src/frontends/tui/markdown.ts index 3c5c913..4f1404c 100644 --- a/src/frontends/tui/markdown.ts +++ b/src/frontends/tui/markdown.ts @@ -27,14 +27,7 @@ function convertHtmlToTerminal(text: string): string { // Horizontal rules .replace(//gi, '─'.repeat(40) + '\n') // Strip any remaining HTML tags - .replace(/<[^>]+>/g, '') - // Decode common HTML entities - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/ /g, ' '); + .replace(/<[^>]+>/g, ''); } // Custom renderer as an object for marked.use() @@ -147,19 +140,29 @@ const terminalRenderer: RendererObject = { }, html({ text }: Tokens.HTML | Tokens.Tag): string { - return convertHtmlToTerminal(text); + return decodeHtmlEntities(convertHtmlToTerminal(text)); }, }; // Configure marked with our renderer marked.use({ renderer: terminalRenderer }); +function decodeHtmlEntities(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' '); +} + export function renderMarkdown(text: string): string { try { const rendered = marked.parse(text); // marked.parse can return string | Promise, we only use sync if (typeof rendered === 'string') { - return rendered.trim(); + return decodeHtmlEntities(rendered).trim(); } return text; } catch { diff --git a/src/models/router.ts b/src/models/router.ts index 9f80cea..9be6c0d 100644 --- a/src/models/router.ts +++ b/src/models/router.ts @@ -14,6 +14,7 @@ export interface ModelRouterConfig { tierFallbacks?: Map; retryConfig?: RetryConfig; labels?: Partial>; + onTierChange?: (tier: ModelTier) => void; } export class ModelRouter implements ModelClient { @@ -25,6 +26,7 @@ export class ModelRouter implements ModelClient { private currentTier: ModelTier = 'default'; private localProviderName?: string; private retryConfig?: RetryConfig; + private onTierChange?: (tier: ModelTier) => void; constructor(config: ModelRouterConfig) { this.clients = new Map(); @@ -33,6 +35,7 @@ export class ModelRouter implements ModelClient { this.fallbackChain = config.fallbackChain; this.tierFallbacks = config.tierFallbacks ?? new Map(); this.retryConfig = config.retryConfig; + this.onTierChange = config.onTierChange; this.clients.set('default', config.default); if (config.fast) this.clients.set('fast', config.fast); @@ -51,11 +54,16 @@ export class ModelRouter implements ModelClient { setTier(tier: ModelTier): boolean { if (this.clients.has(tier)) { this.currentTier = tier; + this.onTierChange?.(tier); return true; } return false; } + setOnTierChange(callback: (tier: ModelTier) => void): void { + this.onTierChange = callback; + } + getTier(): ModelTier { return this.currentTier; } diff --git a/src/preferences.test.ts b/src/preferences.test.ts new file mode 100644 index 0000000..d1da112 --- /dev/null +++ b/src/preferences.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { resolve } from 'path'; +import { tmpdir } from 'os'; +import { loadPreferences, savePreference } from './preferences.js'; + +describe('preferences', () => { + let dataDir: string; + + beforeEach(() => { + dataDir = mkdtempSync(resolve(tmpdir(), 'flynn-prefs-')); + }); + + afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns empty object when file is missing', () => { + expect(loadPreferences(dataDir)).toEqual({}); + }); + + it('returns empty object when file is corrupt', () => { + writeFileSync(resolve(dataDir, 'preferences.json'), 'not json!!!'); + expect(loadPreferences(dataDir)).toEqual({}); + }); + + it('round-trips a saved preference', () => { + savePreference(dataDir, 'modelTier', 'local'); + const prefs = loadPreferences(dataDir); + expect(prefs.modelTier).toBe('local'); + }); + + it('merges preferences without overwriting other keys', () => { + savePreference(dataDir, 'modelTier', 'fast'); + savePreference(dataDir, 'otherKey', 42); + const raw = JSON.parse(readFileSync(resolve(dataDir, 'preferences.json'), 'utf-8')); + expect(raw.modelTier).toBe('fast'); + expect(raw.otherKey).toBe(42); + }); + + it('creates parent directories if needed', () => { + const nested = resolve(dataDir, 'sub', 'dir'); + savePreference(nested, 'modelTier', 'default'); + expect(loadPreferences(nested).modelTier).toBe('default'); + }); +}); diff --git a/src/preferences.ts b/src/preferences.ts new file mode 100644 index 0000000..5e7d612 --- /dev/null +++ b/src/preferences.ts @@ -0,0 +1,29 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { dirname, resolve } from 'path'; + +export interface Preferences { + modelTier?: string; +} + +export function loadPreferences(dataDir: string): Preferences { + const filePath = resolve(dataDir, 'preferences.json'); + try { + const raw = readFileSync(filePath, 'utf-8'); + return JSON.parse(raw) as Preferences; + } catch { + return {}; + } +} + +export function savePreference(dataDir: string, key: string, value: unknown): void { + const filePath = resolve(dataDir, 'preferences.json'); + let prefs: Record = {}; + try { + prefs = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch { + // start fresh + } + prefs[key] = value; + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(prefs, null, 2) + '\n'); +}