feat: add minimal TUI with readline interface
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
MinimalTui,
|
||||
formatPrompt,
|
||||
parseCommand,
|
||||
type TuiCommand,
|
||||
type MinimalTuiConfig,
|
||||
} from './minimal.js';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user