feat: add minimal TUI with readline interface

This commit is contained in:
William Valentin
2026-02-05 00:36:16 -08:00
parent 2f1c302d85
commit f792f8407a
3 changed files with 227 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
export {
MinimalTui,
formatPrompt,
parseCommand,
type TuiCommand,
type MinimalTuiConfig,
} from './minimal.js';
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { formatPrompt, parseCommand, type TuiCommand } from './minimal.js';
describe('formatPrompt', () => {
it('formats default prompt', () => {
const prompt = formatPrompt('default');
expect(prompt).toBe('flynn> ');
});
it('formats thinking prompt', () => {
const prompt = formatPrompt('thinking');
expect(prompt).toContain('...');
});
});
describe('parseCommand', () => {
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 regular message', () => {
const result = parseCommand('Hello, Flynn!');
expect(result).toEqual({ type: 'message', content: 'Hello, Flynn!' });
});
it('returns null for empty input', () => {
const result = parseCommand('');
expect(result).toBeNull();
});
});
+174
View File
@@ -0,0 +1,174 @@
import * as readline from 'node:readline';
import type { ManagedSession } from '../../session/index.js';
import type { ModelClient } from '../../models/types.js';
export type TuiCommand =
| { 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 {
if (state === 'thinking') {
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 {
session: ManagedSession;
modelClient: ModelClient;
systemPrompt: string;
onFullscreen?: () => void;
onTransfer?: (target: string) => void;
}
export class MinimalTui {
private rl: readline.Interface | null = null;
private running = false;
constructor(private config: MinimalTuiConfig) {}
async start(): Promise<void> {
this.running = true;
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('Flynn TUI (minimal mode)');
console.log('Type /help for commands, /fullscreen for panel mode\n');
await this.promptLoop();
}
private async promptLoop(): Promise<void> {
while (this.running && this.rl) {
const input = await this.prompt(formatPrompt('default'));
const command = parseCommand(input);
if (!command) {
continue;
}
await this.handleCommand(command);
}
}
private prompt(promptText: string): Promise<string> {
return new Promise((resolve) => {
this.rl?.question(promptText, resolve);
});
}
private async handleCommand(command: TuiCommand): Promise<void> {
switch (command.type) {
case 'quit':
this.stop();
break;
case 'reset':
this.config.session.clear();
console.log('Session cleared.\n');
break;
case 'transfer':
this.config.onTransfer?.(command.target);
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`);
break;
case 'help':
this.printHelp();
break;
case 'message':
await this.handleMessage(command.content);
break;
}
}
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,
});
console.log(response.content);
console.log();
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 {
this.running = false;
this.rl?.close();
this.rl = null;
}
}