From f792f8407a0a6e60c2e914148abf55ed705e2d21 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 00:36:16 -0800 Subject: [PATCH] feat: add minimal TUI with readline interface --- src/frontends/tui/index.ts | 7 ++ src/frontends/tui/minimal.test.ts | 46 ++++++++ src/frontends/tui/minimal.ts | 174 ++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 src/frontends/tui/index.ts create mode 100644 src/frontends/tui/minimal.test.ts create mode 100644 src/frontends/tui/minimal.ts diff --git a/src/frontends/tui/index.ts b/src/frontends/tui/index.ts new file mode 100644 index 0000000..6687693 --- /dev/null +++ b/src/frontends/tui/index.ts @@ -0,0 +1,7 @@ +export { + MinimalTui, + formatPrompt, + parseCommand, + type TuiCommand, + type MinimalTuiConfig, +} from './minimal.js'; diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts new file mode 100644 index 0000000..f6b1dbc --- /dev/null +++ b/src/frontends/tui/minimal.test.ts @@ -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(); + }); +}); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts new file mode 100644 index 0000000..11062fb --- /dev/null +++ b/src/frontends/tui/minimal.ts @@ -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 { + 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 { + 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 { + return new Promise((resolve) => { + this.rl?.question(promptText, resolve); + }); + } + + private async handleCommand(command: TuiCommand): Promise { + 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 { + 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 Transfer session to another frontend + /quit, /exit Exit TUI +`); + } + + stop(): void { + this.running = false; + this.rl?.close(); + this.rl = null; + } +}