feat(tui): add streaming and model switching to minimal mode

This commit is contained in:
William Valentin
2026-02-05 10:53:41 -08:00
parent 435146344e
commit f115407af3
2 changed files with 110 additions and 90 deletions
+6 -16
View File
@@ -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', () => {
+104 -74
View File
@@ -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<string> {
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<void> {
private async handleCommand(command: Command): Promise<void> {
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<void> {
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 <dest> 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;
}
}
}