feat(tui): add streaming and model switching to minimal mode
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user