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>
This commit is contained in:
+13
-1
@@ -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);
|
||||
|
||||
@@ -97,6 +97,15 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
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 ──
|
||||
|
||||
@@ -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(''');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,14 +27,7 @@ function convertHtmlToTerminal(text: string): string {
|
||||
// Horizontal rules
|
||||
.replace(/<hr\s*\/?>/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<string>, we only use sync
|
||||
if (typeof rendered === 'string') {
|
||||
return rendered.trim();
|
||||
return decodeHtmlEntities(rendered).trim();
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ModelRouterConfig {
|
||||
tierFallbacks?: Map<ModelTier, ModelClient[]>;
|
||||
retryConfig?: RetryConfig;
|
||||
labels?: Partial<Record<ModelTier, string>>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user