From f115407af379bdcc9570cc3bef826d7f1e8b97b2 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 10:53:41 -0800 Subject: [PATCH] feat(tui): add streaming and model switching to minimal mode --- src/frontends/tui/minimal.test.ts | 22 +--- src/frontends/tui/minimal.ts | 178 +++++++++++++++++------------- 2 files changed, 110 insertions(+), 90 deletions(-) diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index f6b1dbc..33d8980 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { formatPrompt, parseCommand, type TuiCommand } from './minimal.js'; +import { describe, it, expect } from 'vitest'; +import { formatPrompt, parseCommand } from './minimal.js'; describe('formatPrompt', () => { it('formats default prompt', () => { @@ -13,25 +13,15 @@ describe('formatPrompt', () => { }); }); -describe('parseCommand', () => { +describe('parseCommand (re-exported)', () => { it('parses /quit command', () => { const result = parseCommand('/quit'); expect(result).toEqual({ type: 'quit' }); }); - it('parses /reset command', () => { - const result = parseCommand('/reset'); - expect(result).toEqual({ type: 'reset' }); - }); - - it('parses /transfer command with target', () => { - const result = parseCommand('/transfer telegram'); - expect(result).toEqual({ type: 'transfer', target: 'telegram' }); - }); - - it('parses /fullscreen command', () => { - const result = parseCommand('/fullscreen'); - expect(result).toEqual({ type: 'fullscreen' }); + it('parses /model command', () => { + const result = parseCommand('/model local'); + expect(result).toEqual({ type: 'model', name: 'local' }); }); it('parses regular message', () => { diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 11062fb..48576cf 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -1,15 +1,11 @@ import * as readline from 'node:readline'; import type { ManagedSession } from '../../session/index.js'; -import type { ModelClient } from '../../models/types.js'; +import type { ModelClient, TokenUsage } from '../../models/types.js'; +import type { ModelRouter, ModelTier } from '../../models/router.js'; +import { parseCommand, getHelpText, resolveModelAlias, type Command } from './commands.js'; +import { renderMarkdown } from './markdown.js'; -export type TuiCommand = - | { type: 'quit' } - | { type: 'reset' } - | { type: 'transfer'; target: string } - | { type: 'fullscreen' } - | { type: 'status' } - | { type: 'help' } - | { type: 'message'; content: string }; +export { parseCommand, type Command }; export function formatPrompt(state: 'default' | 'thinking'): string { if (state === 'thinking') { @@ -18,43 +14,10 @@ export function formatPrompt(state: 'default' | 'thinking'): string { return 'flynn> '; } -export function parseCommand(input: string): TuiCommand | null { - const trimmed = input.trim(); - if (!trimmed) { - return null; - } - - if (trimmed === '/quit' || trimmed === '/exit') { - return { type: 'quit' }; - } - - if (trimmed === '/reset' || trimmed === '/clear') { - return { type: 'reset' }; - } - - if (trimmed.startsWith('/transfer ')) { - const target = trimmed.slice('/transfer '.length).trim(); - return { type: 'transfer', target }; - } - - if (trimmed === '/fullscreen' || trimmed === '/fs') { - return { type: 'fullscreen' }; - } - - if (trimmed === '/status') { - return { type: 'status' }; - } - - if (trimmed === '/help' || trimmed === '/?') { - return { type: 'help' }; - } - - return { type: 'message', content: trimmed }; -} - export interface MinimalTuiConfig { session: ManagedSession; modelClient: ModelClient; + modelRouter?: ModelRouter; systemPrompt: string; onFullscreen?: () => void; onTransfer?: (target: string) => void; @@ -63,6 +26,7 @@ export interface MinimalTuiConfig { export class MinimalTui { private rl: readline.Interface | null = null; private running = false; + private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 }; constructor(private config: MinimalTuiConfig) {} @@ -95,11 +59,16 @@ export class MinimalTui { private prompt(promptText: string): Promise { return new Promise((resolve) => { - this.rl?.question(promptText, resolve); + if (!this.rl) { + resolve(''); + return; + } + this.rl.question(promptText, resolve); + this.rl.once('close', () => resolve('')); }); } - private async handleCommand(command: TuiCommand): Promise { + private async handleCommand(command: Command): Promise { switch (command.type) { case 'quit': this.stop(); @@ -107,24 +76,28 @@ export class MinimalTui { case 'reset': this.config.session.clear(); + this.totalUsage = { inputTokens: 0, outputTokens: 0 }; console.log('Session cleared.\n'); break; - case 'transfer': - this.config.onTransfer?.(command.target); + case 'help': + console.log(getHelpText() + '\n'); + break; + + case 'status': + this.printStatus(); break; case 'fullscreen': this.config.onFullscreen?.(); break; - case 'status': - console.log(`Session: ${this.config.session.id}`); - console.log(`Messages: ${this.config.session.getHistory().length}\n`); + case 'model': + this.handleModelCommand(command.name); break; - case 'help': - this.printHelp(); + case 'transfer': + this.config.onTransfer?.(command.target); break; case 'message': @@ -133,42 +106,99 @@ export class MinimalTui { } } + private handleModelCommand(name?: string): void { + const router = this.config.modelRouter; + if (!router) { + console.log('Model switching not available.\n'); + return; + } + + if (!name) { + const current = router.getTier(); + const available = router.getAvailableTiers(); + console.log(`Current model: ${current}`); + console.log(`Available: ${available.join(', ')}\n`); + return; + } + + const tier = resolveModelAlias(name); + if (router.setTier(tier)) { + console.log(`Switched to model: ${tier}\n`); + } else { + console.log(`Model not available: ${name}\n`); + } + } + + private printStatus(): void { + console.log(`Session: ${this.config.session.id}`); + console.log(`Messages: ${this.config.session.getHistory().length}`); + console.log(`Tokens: ${this.totalUsage.inputTokens} in / ${this.totalUsage.outputTokens} out\n`); + } + private async handleMessage(content: string): Promise { this.config.session.addMessage({ role: 'user', content }); process.stdout.write('\n'); try { - const response = await this.config.modelClient.chat({ - messages: this.config.session.getHistory(), - system: this.config.systemPrompt, - }); + // Try streaming if available + if (this.config.modelClient.chatStream) { + let fullContent = ''; - console.log(response.content); - console.log(); + for await (const event of this.config.modelClient.chatStream({ + messages: this.config.session.getHistory(), + system: this.config.systemPrompt, + })) { + if (event.type === 'content' && event.content) { + process.stdout.write(event.content); + fullContent += event.content; + } + if (event.type === 'done' && event.usage) { + this.totalUsage.inputTokens += event.usage.inputTokens; + this.totalUsage.outputTokens += event.usage.outputTokens; + } + if (event.type === 'error') { + throw event.error ?? new Error('Stream error'); + } + } - this.config.session.addMessage({ role: 'assistant', content: response.content }); + console.log('\n'); + + this.config.session.addMessage({ role: 'assistant', content: fullContent }); + } else { + // Fallback to non-streaming + const response = await this.config.modelClient.chat({ + messages: this.config.session.getHistory(), + system: this.config.systemPrompt, + }); + + const rendered = renderMarkdown(response.content); + console.log(rendered); + console.log(); + + this.totalUsage.inputTokens += response.usage.inputTokens; + this.totalUsage.outputTokens += response.usage.outputTokens; + + this.config.session.addMessage({ role: 'assistant', content: response.content }); + } } catch (error) { console.error('Error:', error instanceof Error ? error.message : error); console.log(); } } - private printHelp(): void { - console.log(` -Commands: - /help, /? Show this help - /reset, /clear Clear conversation history - /status Show session info - /fullscreen, /fs Switch to fullscreen mode - /transfer Transfer session to another frontend - /quit, /exit Exit TUI -`); - } - - stop(): void { + stop(preserveStdin = false): void { this.running = false; - this.rl?.close(); - this.rl = null; + if (this.rl) { + if (preserveStdin) { + // Remove readline listeners but don't close stdin + this.rl.removeAllListeners(); + process.stdin.removeAllListeners('keypress'); + // Pause stdin so readline releases it + process.stdin.pause(); + } + this.rl.close(); + this.rl = null; + } } }