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 { describe, it, expect } from 'vitest';
|
||||||
import { formatPrompt, parseCommand, type TuiCommand } from './minimal.js';
|
import { formatPrompt, parseCommand } from './minimal.js';
|
||||||
|
|
||||||
describe('formatPrompt', () => {
|
describe('formatPrompt', () => {
|
||||||
it('formats default prompt', () => {
|
it('formats default prompt', () => {
|
||||||
@@ -13,25 +13,15 @@ describe('formatPrompt', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseCommand', () => {
|
describe('parseCommand (re-exported)', () => {
|
||||||
it('parses /quit command', () => {
|
it('parses /quit command', () => {
|
||||||
const result = parseCommand('/quit');
|
const result = parseCommand('/quit');
|
||||||
expect(result).toEqual({ type: 'quit' });
|
expect(result).toEqual({ type: 'quit' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses /reset command', () => {
|
it('parses /model command', () => {
|
||||||
const result = parseCommand('/reset');
|
const result = parseCommand('/model local');
|
||||||
expect(result).toEqual({ type: 'reset' });
|
expect(result).toEqual({ type: 'model', name: 'local' });
|
||||||
});
|
|
||||||
|
|
||||||
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 regular message', () => {
|
it('parses regular message', () => {
|
||||||
|
|||||||
+104
-74
@@ -1,15 +1,11 @@
|
|||||||
import * as readline from 'node:readline';
|
import * as readline from 'node:readline';
|
||||||
import type { ManagedSession } from '../../session/index.js';
|
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 =
|
export { parseCommand, type Command };
|
||||||
| { type: 'quit' }
|
|
||||||
| { type: 'reset' }
|
|
||||||
| { type: 'transfer'; target: string }
|
|
||||||
| { type: 'fullscreen' }
|
|
||||||
| { type: 'status' }
|
|
||||||
| { type: 'help' }
|
|
||||||
| { type: 'message'; content: string };
|
|
||||||
|
|
||||||
export function formatPrompt(state: 'default' | 'thinking'): string {
|
export function formatPrompt(state: 'default' | 'thinking'): string {
|
||||||
if (state === 'thinking') {
|
if (state === 'thinking') {
|
||||||
@@ -18,43 +14,10 @@ export function formatPrompt(state: 'default' | 'thinking'): string {
|
|||||||
return 'flynn> ';
|
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 {
|
export interface MinimalTuiConfig {
|
||||||
session: ManagedSession;
|
session: ManagedSession;
|
||||||
modelClient: ModelClient;
|
modelClient: ModelClient;
|
||||||
|
modelRouter?: ModelRouter;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
onFullscreen?: () => void;
|
onFullscreen?: () => void;
|
||||||
onTransfer?: (target: string) => void;
|
onTransfer?: (target: string) => void;
|
||||||
@@ -63,6 +26,7 @@ export interface MinimalTuiConfig {
|
|||||||
export class MinimalTui {
|
export class MinimalTui {
|
||||||
private rl: readline.Interface | null = null;
|
private rl: readline.Interface | null = null;
|
||||||
private running = false;
|
private running = false;
|
||||||
|
private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
||||||
|
|
||||||
constructor(private config: MinimalTuiConfig) {}
|
constructor(private config: MinimalTuiConfig) {}
|
||||||
|
|
||||||
@@ -95,11 +59,16 @@ export class MinimalTui {
|
|||||||
|
|
||||||
private prompt(promptText: string): Promise<string> {
|
private prompt(promptText: string): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
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) {
|
switch (command.type) {
|
||||||
case 'quit':
|
case 'quit':
|
||||||
this.stop();
|
this.stop();
|
||||||
@@ -107,24 +76,28 @@ export class MinimalTui {
|
|||||||
|
|
||||||
case 'reset':
|
case 'reset':
|
||||||
this.config.session.clear();
|
this.config.session.clear();
|
||||||
|
this.totalUsage = { inputTokens: 0, outputTokens: 0 };
|
||||||
console.log('Session cleared.\n');
|
console.log('Session cleared.\n');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'transfer':
|
case 'help':
|
||||||
this.config.onTransfer?.(command.target);
|
console.log(getHelpText() + '\n');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
this.printStatus();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'fullscreen':
|
case 'fullscreen':
|
||||||
this.config.onFullscreen?.();
|
this.config.onFullscreen?.();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'status':
|
case 'model':
|
||||||
console.log(`Session: ${this.config.session.id}`);
|
this.handleModelCommand(command.name);
|
||||||
console.log(`Messages: ${this.config.session.getHistory().length}\n`);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'help':
|
case 'transfer':
|
||||||
this.printHelp();
|
this.config.onTransfer?.(command.target);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message':
|
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> {
|
private async handleMessage(content: string): Promise<void> {
|
||||||
this.config.session.addMessage({ role: 'user', content });
|
this.config.session.addMessage({ role: 'user', content });
|
||||||
|
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.config.modelClient.chat({
|
// Try streaming if available
|
||||||
messages: this.config.session.getHistory(),
|
if (this.config.modelClient.chatStream) {
|
||||||
system: this.config.systemPrompt,
|
let fullContent = '';
|
||||||
});
|
|
||||||
|
|
||||||
console.log(response.content);
|
for await (const event of this.config.modelClient.chatStream({
|
||||||
console.log();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error:', error instanceof Error ? error.message : error);
|
console.error('Error:', error instanceof Error ? error.message : error);
|
||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private printHelp(): void {
|
stop(preserveStdin = false): 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 {
|
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.rl?.close();
|
if (this.rl) {
|
||||||
this.rl = null;
|
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