diff --git a/docs/plans/2026-02-05-tui-redesign-implementation.md b/docs/plans/2026-02-05-tui-redesign-implementation.md new file mode 100644 index 0000000..d160eca --- /dev/null +++ b/docs/plans/2026-02-05-tui-redesign-implementation.md @@ -0,0 +1,1834 @@ +# TUI Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add streaming responses, markdown rendering, model switching, and scrollable message history to Flynn TUI. + +**Architecture:** Extend ModelClient interface with optional streaming method. Create shared utilities for markdown rendering and command parsing. Update both minimal and fullscreen modes to use streaming with markdown post-processing. + +**Tech Stack:** TypeScript, Vitest, Ink (React), marked, marked-terminal, cli-highlight + +--- + +## Task 1: Add Streaming Types + +**Files:** +- Modify: `src/models/types.ts` +- Test: `src/models/types.test.ts` (create) + +**Step 1: Write the type definitions** + +Edit `src/models/types.ts` to add streaming types: + +```typescript +export interface Message { + role: 'user' | 'assistant'; + content: string; +} + +export interface ChatRequest { + messages: Message[]; + system?: string; + maxTokens?: number; +} + +export interface ChatResponse { + content: string; + stopReason: 'end_turn' | 'max_tokens' | 'stop_sequence' | string; + usage: TokenUsage; +} + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; +} + +export interface ChatStreamEvent { + type: 'content' | 'done' | 'error'; + content?: string; + usage?: TokenUsage; + error?: Error; +} + +export interface StreamingModelClient { + chatStream(request: ChatRequest): AsyncIterable; +} + +export interface ModelClient { + chat(request: ChatRequest): Promise; + chatStream?(request: ChatRequest): AsyncIterable; +} +``` + +**Step 2: Run build to verify types compile** + +Run: `pnpm build` +Expected: Success, no type errors + +**Step 3: Commit** + +```bash +git add src/models/types.ts +git commit -m "feat(models): add streaming types for chat responses" +``` + +--- + +## Task 2: Implement AnthropicClient Streaming + +**Files:** +- Modify: `src/models/anthropic.ts` +- Test: `src/models/anthropic.test.ts` + +**Step 1: Write the failing test** + +Add to `src/models/anthropic.test.ts`: + +```typescript +describe('AnthropicClient streaming', () => { + it('streams messages chunk by chunk', async () => { + const client = new AnthropicClient({ + apiKey: 'test-key', + model: 'claude-sonnet-4-20250514', + }); + + const chunks: string[] = []; + let finalUsage: { inputTokens: number; outputTokens: number } | undefined; + + for await (const event of client.chatStream({ + messages: [{ role: 'user', content: 'Hello' }], + })) { + if (event.type === 'content' && event.content) { + chunks.push(event.content); + } + if (event.type === 'done' && event.usage) { + finalUsage = event.usage; + } + } + + expect(chunks.length).toBeGreaterThan(0); + expect(chunks.join('')).toBe('Hello from Claude!'); + expect(finalUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + }); +}); +``` + +**Step 2: Update mock to support streaming** + +Update the mock at the top of `src/models/anthropic.test.ts`: + +```typescript +vi.mock('@anthropic-ai/sdk', () => ({ + default: vi.fn().mockImplementation(() => ({ + messages: { + create: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Hello from Claude!' }], + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }), + stream: vi.fn().mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello ' } }; + yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'from ' } }; + yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Claude!' } }; + }, + finalMessage: vi.fn().mockResolvedValue({ + usage: { input_tokens: 10, output_tokens: 5 }, + }), + }), + }, + })), +})); +``` + +**Step 3: Run test to verify it fails** + +Run: `pnpm test:run src/models/anthropic.test.ts` +Expected: FAIL - chatStream is not a function + +**Step 4: Implement chatStream method** + +Update `src/models/anthropic.ts`: + +```typescript +import Anthropic from '@anthropic-ai/sdk'; +import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js'; + +export interface AnthropicClientConfig { + apiKey?: string; + authToken?: string; + model: string; + maxTokens?: number; +} + +export class AnthropicClient implements ModelClient { + private client: Anthropic; + private model: string; + private defaultMaxTokens: number; + + constructor(config: AnthropicClientConfig) { + this.client = new Anthropic({ + apiKey: config.apiKey, + authToken: config.authToken, + }); + this.model = config.model; + this.defaultMaxTokens = config.maxTokens ?? 4096; + } + + async chat(request: ChatRequest): Promise { + const response = await this.client.messages.create({ + model: this.model, + max_tokens: request.maxTokens ?? this.defaultMaxTokens, + system: request.system, + messages: request.messages.map((m) => ({ + role: m.role, + content: m.content, + })), + }); + + const textContent = response.content.find((c) => c.type === 'text'); + const content = textContent?.type === 'text' ? textContent.text : ''; + + return { + content, + stopReason: response.stop_reason ?? 'end_turn', + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } + + async *chatStream(request: ChatRequest): AsyncIterable { + const stream = this.client.messages.stream({ + model: this.model, + max_tokens: request.maxTokens ?? this.defaultMaxTokens, + system: request.system, + messages: request.messages.map((m) => ({ + role: m.role, + content: m.content, + })), + }); + + try { + for await (const event of stream) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + yield { type: 'content', content: event.delta.text }; + } + } + + const finalMessage = await stream.finalMessage(); + yield { + type: 'done', + usage: { + inputTokens: finalMessage.usage.input_tokens, + outputTokens: finalMessage.usage.output_tokens, + }, + }; + } catch (error) { + yield { + type: 'error', + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } +} +``` + +**Step 5: Run test to verify it passes** + +Run: `pnpm test:run src/models/anthropic.test.ts` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/models/anthropic.ts src/models/anthropic.test.ts +git commit -m "feat(models): add streaming support to AnthropicClient" +``` + +--- + +## Task 3: Add Streaming to ModelRouter + +**Files:** +- Modify: `src/models/router.ts` +- Test: `src/models/router.test.ts` + +**Step 1: Write the failing test** + +Add to `src/models/router.test.ts`: + +```typescript +describe('ModelRouter streaming', () => { + it('streams from primary client', async () => { + const mockStream = async function* (): AsyncIterable { + yield { type: 'content', content: 'Hello' }; + yield { type: 'done', usage: { inputTokens: 5, outputTokens: 3 } }; + }; + + const mockClient = { + chat: vi.fn(), + chatStream: vi.fn().mockReturnValue(mockStream()), + }; + + const router = new ModelRouter({ + default: mockClient, + fallbackChain: [], + }); + + const chunks: string[] = []; + for await (const event of router.chatStream({ messages: [] })) { + if (event.type === 'content' && event.content) { + chunks.push(event.content); + } + } + + expect(chunks).toEqual(['Hello']); + }); + + it('falls back when primary stream fails', async () => { + const failingStream = async function* (): AsyncIterable { + yield { type: 'error', error: new Error('Primary failed') }; + }; + + const fallbackStream = async function* (): AsyncIterable { + yield { type: 'content', content: 'Fallback' }; + yield { type: 'done', usage: { inputTokens: 5, outputTokens: 3 } }; + }; + + const primaryClient = { + chat: vi.fn(), + chatStream: vi.fn().mockReturnValue(failingStream()), + }; + + const fallbackClient = { + chat: vi.fn(), + chatStream: vi.fn().mockReturnValue(fallbackStream()), + }; + + const router = new ModelRouter({ + default: primaryClient, + fallbackChain: [fallbackClient], + }); + + const chunks: string[] = []; + for await (const event of router.chatStream({ messages: [] })) { + if (event.type === 'content' && event.content) { + chunks.push(event.content); + } + } + + expect(chunks).toEqual(['Fallback']); + }); +}); +``` + +**Step 2: Add import for ChatStreamEvent** + +At top of `src/models/router.test.ts`, update import: + +```typescript +import type { ChatStreamEvent } from './types.js'; +``` + +**Step 3: Run test to verify it fails** + +Run: `pnpm test:run src/models/router.test.ts` +Expected: FAIL - chatStream is not a function + +**Step 4: Implement chatStream in ModelRouter** + +Update `src/models/router.ts`: + +```typescript +import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js'; + +export type ModelTier = 'fast' | 'default' | 'complex' | 'local'; + +export interface ModelRouterConfig { + default: ModelClient; + fast?: ModelClient; + complex?: ModelClient; + local?: ModelClient; + fallbackChain: ModelClient[]; +} + +export class ModelRouter implements ModelClient { + private clients: Map; + private defaultClient: ModelClient; + private fallbackChain: ModelClient[]; + private currentTier: ModelTier = 'default'; + + constructor(config: ModelRouterConfig) { + this.clients = new Map(); + this.defaultClient = config.default; + this.fallbackChain = config.fallbackChain; + + this.clients.set('default', config.default); + if (config.fast) this.clients.set('fast', config.fast); + if (config.complex) this.clients.set('complex', config.complex); + if (config.local) this.clients.set('local', config.local); + } + + setTier(tier: ModelTier): boolean { + if (this.clients.has(tier)) { + this.currentTier = tier; + return true; + } + return false; + } + + getTier(): ModelTier { + return this.currentTier; + } + + getAvailableTiers(): ModelTier[] { + return Array.from(this.clients.keys()); + } + + async chat(request: ChatRequest, tier?: ModelTier): Promise { + const useTier = tier ?? this.currentTier; + const primaryClient = this.clients.get(useTier) ?? this.defaultClient; + const errors: Error[] = []; + + try { + return await primaryClient.chat(request); + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + console.warn(`Primary model failed: ${errors[0].message}`); + } + + for (const fallbackClient of this.fallbackChain) { + try { + console.log('Trying fallback model...'); + return await fallbackClient.chat(request); + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + console.warn(`Fallback model failed: ${errors[errors.length - 1].message}`); + } + } + + throw new Error(`All model providers failed: ${errors.map(e => e.message).join(', ')}`); + } + + async *chatStream(request: ChatRequest, tier?: ModelTier): AsyncIterable { + const useTier = tier ?? this.currentTier; + const primaryClient = this.clients.get(useTier) ?? this.defaultClient; + + if (primaryClient.chatStream) { + let hasError = false; + for await (const event of primaryClient.chatStream(request)) { + if (event.type === 'error') { + hasError = true; + console.warn(`Primary stream failed: ${event.error?.message}`); + break; + } + yield event; + } + + if (!hasError) return; + } + + // Try fallback chain + for (const fallbackClient of this.fallbackChain) { + if (!fallbackClient.chatStream) continue; + + let hasError = false; + for await (const event of fallbackClient.chatStream(request)) { + if (event.type === 'error') { + hasError = true; + console.warn(`Fallback stream failed: ${event.error?.message}`); + break; + } + yield event; + } + + if (!hasError) return; + } + + yield { type: 'error', error: new Error('All streaming providers failed') }; + } + + getClient(tier: ModelTier): ModelClient | undefined { + return this.clients.get(tier); + } +} +``` + +**Step 5: Run test to verify it passes** + +Run: `pnpm test:run src/models/router.test.ts` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/models/router.ts src/models/router.test.ts +git commit -m "feat(models): add streaming and tier switching to ModelRouter" +``` + +--- + +## Task 4: Install Markdown Dependencies + +**Files:** +- Modify: `package.json` + +**Step 1: Install dependencies** + +Run: `pnpm add marked marked-terminal cli-highlight` + +**Step 2: Install types** + +Run: `pnpm add -D @types/marked-terminal` + +**Step 3: Verify installation** + +Run: `pnpm build` +Expected: Success + +**Step 4: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "chore: add markdown rendering dependencies" +``` + +--- + +## Task 5: Create Markdown Rendering Utility + +**Files:** +- Create: `src/frontends/tui/markdown.ts` +- Create: `src/frontends/tui/markdown.test.ts` + +**Step 1: Write the failing tests** + +Create `src/frontends/tui/markdown.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { renderMarkdown } from './markdown.js'; + +describe('renderMarkdown', () => { + it('renders plain text unchanged', () => { + const result = renderMarkdown('Hello world'); + expect(result).toContain('Hello world'); + }); + + it('renders bold text', () => { + const result = renderMarkdown('This is **bold** text'); + expect(result).toContain('bold'); + }); + + it('renders code blocks', () => { + const result = renderMarkdown('```javascript\nconst x = 1;\n```'); + expect(result).toContain('const'); + expect(result).toContain('x'); + }); + + it('renders inline code', () => { + const result = renderMarkdown('Use `console.log()` for debugging'); + expect(result).toContain('console.log()'); + }); + + it('renders lists', () => { + const result = renderMarkdown('- Item 1\n- Item 2'); + expect(result).toContain('Item 1'); + expect(result).toContain('Item 2'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/frontends/tui/markdown.test.ts` +Expected: FAIL - Cannot find module + +**Step 3: Implement markdown utility** + +Create `src/frontends/tui/markdown.ts`: + +```typescript +import { marked } from 'marked'; +import TerminalRenderer from 'marked-terminal'; +import { highlight } from 'cli-highlight'; + +// Configure marked with terminal renderer +marked.use({ + renderer: new TerminalRenderer({ + code: (code: string, language?: string) => { + try { + return highlight(code, { language: language || 'plaintext' }); + } catch { + return code; + } + }, + codespan: (text: string) => `\x1b[36m${text}\x1b[0m`, // Cyan for inline code + strong: (text: string) => `\x1b[1m${text}\x1b[0m`, // Bold + em: (text: string) => `\x1b[3m${text}\x1b[0m`, // Italic + }), +}); + +export function renderMarkdown(text: string): string { + try { + const rendered = marked.parse(text); + // marked.parse can return string | Promise, we only use sync + if (typeof rendered === 'string') { + return rendered.trim(); + } + return text; + } catch { + return text; + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test:run src/frontends/tui/markdown.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/frontends/tui/markdown.ts src/frontends/tui/markdown.test.ts +git commit -m "feat(tui): add markdown rendering utility" +``` + +--- + +## Task 6: Create Unified Command Parser + +**Files:** +- Create: `src/frontends/tui/commands.ts` +- Create: `src/frontends/tui/commands.test.ts` + +**Step 1: Write the failing tests** + +Create `src/frontends/tui/commands.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { parseCommand, getHelpText } from './commands.js'; + +describe('parseCommand', () => { + it('parses /quit command', () => { + expect(parseCommand('/quit')).toEqual({ type: 'quit' }); + expect(parseCommand('/exit')).toEqual({ type: 'quit' }); + }); + + it('parses /reset command', () => { + expect(parseCommand('/reset')).toEqual({ type: 'reset' }); + expect(parseCommand('/clear')).toEqual({ type: 'reset' }); + }); + + it('parses /help command', () => { + expect(parseCommand('/help')).toEqual({ type: 'help' }); + expect(parseCommand('/?')).toEqual({ type: 'help' }); + }); + + it('parses /status command', () => { + expect(parseCommand('/status')).toEqual({ type: 'status' }); + }); + + it('parses /fullscreen command', () => { + expect(parseCommand('/fullscreen')).toEqual({ type: 'fullscreen' }); + expect(parseCommand('/fs')).toEqual({ type: 'fullscreen' }); + }); + + it('parses /model command without argument', () => { + expect(parseCommand('/model')).toEqual({ type: 'model' }); + }); + + it('parses /model command with argument', () => { + expect(parseCommand('/model local')).toEqual({ type: 'model', name: 'local' }); + expect(parseCommand('/model opus')).toEqual({ type: 'model', name: 'opus' }); + }); + + it('parses /transfer command', () => { + expect(parseCommand('/transfer telegram')).toEqual({ type: 'transfer', target: 'telegram' }); + }); + + it('parses regular message', () => { + expect(parseCommand('Hello Flynn')).toEqual({ type: 'message', content: 'Hello Flynn' }); + }); + + it('returns null for empty input', () => { + expect(parseCommand('')).toBeNull(); + expect(parseCommand(' ')).toBeNull(); + }); +}); + +describe('getHelpText', () => { + it('returns help text with all commands', () => { + const help = getHelpText(); + expect(help).toContain('/help'); + expect(help).toContain('/model'); + expect(help).toContain('/reset'); + expect(help).toContain('/quit'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/frontends/tui/commands.test.ts` +Expected: FAIL - Cannot find module + +**Step 3: Implement commands utility** + +Create `src/frontends/tui/commands.ts`: + +```typescript +export type Command = + | { type: 'quit' } + | { type: 'reset' } + | { type: 'help' } + | { type: 'status' } + | { type: 'fullscreen' } + | { type: 'model'; name?: string } + | { type: 'transfer'; target: string } + | { type: 'message'; content: string }; + +export function parseCommand(input: string): Command | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + // Quit + if (trimmed === '/quit' || trimmed === '/exit') { + return { type: 'quit' }; + } + + // Reset + if (trimmed === '/reset' || trimmed === '/clear') { + return { type: 'reset' }; + } + + // Help + if (trimmed === '/help' || trimmed === '/?') { + return { type: 'help' }; + } + + // Status + if (trimmed === '/status') { + return { type: 'status' }; + } + + // Fullscreen + if (trimmed === '/fullscreen' || trimmed === '/fs') { + return { type: 'fullscreen' }; + } + + // Model (with optional argument) + if (trimmed === '/model') { + return { type: 'model' }; + } + if (trimmed.startsWith('/model ')) { + const name = trimmed.slice('/model '.length).trim(); + return { type: 'model', name }; + } + + // Transfer + if (trimmed.startsWith('/transfer ')) { + const target = trimmed.slice('/transfer '.length).trim(); + return { type: 'transfer', target }; + } + + // Regular message + return { type: 'message', content: trimmed }; +} + +export function getHelpText(): string { + return ` +Commands: + /help, /? Show this help + /model [name] Show or switch model (local, default, fast, complex) + /reset, /clear Clear conversation history + /status Show session info and token usage + /fullscreen, /fs Switch to fullscreen mode + /transfer Transfer session to another frontend + /quit, /exit Exit TUI +`.trim(); +} + +export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'sonnet' | 'ollama'; + +export function resolveModelAlias(alias: string): 'local' | 'default' | 'fast' | 'complex' { + const map: Record = { + local: 'local', + ollama: 'local', + default: 'default', + opus: 'default', + fast: 'fast', + sonnet: 'fast', + complex: 'complex', + }; + return map[alias.toLowerCase()] ?? 'default'; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test:run src/frontends/tui/commands.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/frontends/tui/commands.ts src/frontends/tui/commands.test.ts +git commit -m "feat(tui): add unified command parser with model switching" +``` + +--- + +## Task 7: Update Minimal TUI with Streaming + +**Files:** +- Modify: `src/frontends/tui/minimal.ts` +- Modify: `src/frontends/tui/minimal.test.ts` + +**Step 1: Update minimal.ts imports and types** + +Update `src/frontends/tui/minimal.ts` with the new implementation: + +```typescript +import * as readline from 'node:readline'; +import type { ManagedSession } from '../../session/index.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 { parseCommand, type Command }; + +export function formatPrompt(state: 'default' | 'thinking'): string { + if (state === 'thinking') { + return 'flynn... '; + } + return 'flynn> '; +} + +export interface MinimalTuiConfig { + session: ManagedSession; + modelClient: ModelClient; + modelRouter?: ModelRouter; + systemPrompt: string; + onFullscreen?: () => void; + onTransfer?: (target: string) => void; +} + +export class MinimalTui { + private rl: readline.Interface | null = null; + private running = false; + private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 }; + + 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) => { + if (!this.rl) { + resolve(''); + return; + } + this.rl.question(promptText, resolve); + this.rl.once('close', () => resolve('')); + }); + } + + private async handleCommand(command: Command): Promise { + switch (command.type) { + case 'quit': + this.stop(); + break; + + case 'reset': + this.config.session.clear(); + this.totalUsage = { inputTokens: 0, outputTokens: 0 }; + console.log('Session cleared.\n'); + break; + + case 'help': + console.log(getHelpText() + '\n'); + break; + + case 'status': + this.printStatus(); + break; + + case 'fullscreen': + this.config.onFullscreen?.(); + break; + + case 'model': + this.handleModelCommand(command.name); + break; + + case 'transfer': + this.config.onTransfer?.(command.target); + break; + + case 'message': + await this.handleMessage(command.content); + break; + } + } + + 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 { + this.config.session.addMessage({ role: 'user', content }); + + process.stdout.write('\n'); + + try { + // Try streaming if available + if (this.config.modelClient.chatStream) { + let fullContent = ''; + + 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'); + } + } + + console.log('\n'); + + // Render markdown for the complete response + const rendered = renderMarkdown(fullContent); + // Clear and reprint with markdown (optional: skip if terminal doesn't support) + // For now, just save the raw content + 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(); + } + } + + stop(preserveStdin = false): void { + this.running = false; + if (this.rl) { + if (preserveStdin) { + this.rl.removeAllListeners(); + process.stdin.removeAllListeners('keypress'); + process.stdin.pause(); + } + this.rl.close(); + this.rl = null; + } + } +} +``` + +**Step 2: Run tests to verify nothing broke** + +Run: `pnpm test:run src/frontends/tui/minimal.test.ts` +Expected: Some tests may need updating + +**Step 3: Update minimal.test.ts for new imports** + +Update `src/frontends/tui/minimal.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { formatPrompt, parseCommand } 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 (re-exported)', () => { + it('parses /quit command', () => { + const result = parseCommand('/quit'); + expect(result).toEqual({ type: 'quit' }); + }); + + it('parses /model command', () => { + const result = parseCommand('/model local'); + expect(result).toEqual({ type: 'model', name: 'local' }); + }); + + 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(); + }); +}); +``` + +**Step 4: Run tests to verify they pass** + +Run: `pnpm test:run src/frontends/tui/minimal.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/frontends/tui/minimal.ts src/frontends/tui/minimal.test.ts +git commit -m "feat(tui): add streaming and model switching to minimal mode" +``` + +--- + +## Task 8: Update TUI Entry Point + +**Files:** +- Modify: `src/tui.ts` + +**Step 1: Update tui.ts to pass modelRouter** + +Update `src/tui.ts`: + +```typescript +import { loadConfig } from './config/index.js'; +import { SessionStore, SessionManager } from './session/index.js'; +import { AnthropicClient, OpenAIClient, OllamaClient, ModelRouter } from './models/index.js'; +import { MinimalTui, startFullscreenTui } from './frontends/tui/index.js'; +import type { Config } from './config/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import { existsSync, mkdirSync } from 'fs'; + +const CONFIG_PATH = process.env.FLYNN_CONFIG + ?? resolve(homedir(), '.config/flynn/config.yaml'); + +const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations. + +Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`; + +function createModelRouter(config: Config): ModelRouter { + const models = config.models; + + const defaultClient = new AnthropicClient({ + model: models.default.model, + apiKey: models.default.api_key, + authToken: models.default.auth_token, + }); + + let fastClient; + let complexClient; + let localClient; + + if (models.fast) { + fastClient = new AnthropicClient({ + model: models.fast.model, + apiKey: models.fast.api_key, + authToken: models.fast.auth_token, + }); + } + + if (models.complex) { + complexClient = new AnthropicClient({ + model: models.complex.model, + apiKey: models.complex.api_key, + authToken: models.complex.auth_token, + }); + } + + if (models.local) { + if (models.local.provider === 'ollama') { + localClient = new OllamaClient({ + model: models.local.model, + host: models.local.endpoint, + }); + } + } + + const fallbackChain = []; + for (const providerName of models.fallback_chain) { + if (providerName === 'openai') { + fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' })); + } else if (providerName === 'local' && localClient) { + fallbackChain.push(localClient); + } + } + + return new ModelRouter({ + default: defaultClient, + fast: fastClient, + complex: complexClient, + local: localClient, + fallbackChain, + }); +} + +async function main() { + const args = process.argv.slice(2); + const fullscreenMode = args.includes('--fullscreen') || args.includes('-f'); + + console.log('Flynn TUI starting...'); + + if (!existsSync(CONFIG_PATH)) { + console.error(`Config file not found: ${CONFIG_PATH}`); + console.error('Copy config/default.yaml to ~/.config/flynn/config.yaml and configure it.'); + process.exit(1); + } + + const config = loadConfig(CONFIG_PATH); + + // Ensure data directory exists + const dataDir = resolve(homedir(), '.local/share/flynn'); + mkdirSync(dataDir, { recursive: true }); + + // Initialize components + const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); + const sessionManager = new SessionManager(sessionStore); + const modelRouter = createModelRouter(config); + + // Get TUI session + const session = sessionManager.getSession('tui', 'local'); + + const cleanup = () => { + sessionStore.close(); + }; + + process.on('SIGINT', () => { + cleanup(); + process.exit(0); + }); + + if (fullscreenMode) { + // Start fullscreen Ink UI + await startFullscreenTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt: SYSTEM_PROMPT, + model: config.models.default.model, + onExit: cleanup, + }); + } else { + // Start minimal readline UI + let switchingToFullscreen = false; + + const tui = new MinimalTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt: SYSTEM_PROMPT, + onTransfer: (target) => { + if (target === 'telegram') { + const telegramUserId = String(config.telegram.allowed_chat_ids[0]); + sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId); + console.log(`Session transferred to Telegram (${telegramUserId})\n`); + } else { + console.log(`Unknown transfer target: ${target}\n`); + } + }, + onFullscreen: () => { + switchingToFullscreen = true; + tui.stop(true); + }, + }); + + await tui.start(); + + if (switchingToFullscreen) { + console.clear(); + await startFullscreenTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt: SYSTEM_PROMPT, + model: config.models.default.model, + onExit: cleanup, + }); + return; + } + } + + cleanup(); +} + +main().catch((error) => { + console.error('Failed to start TUI:', error); + process.exit(1); +}); +``` + +**Step 2: Build to verify** + +Run: `pnpm build` +Expected: May fail - need to update fullscreen types + +**Step 3: Commit (if build passes)** + +```bash +git add src/tui.ts +git commit -m "feat(tui): pass modelRouter to both TUI modes" +``` + +--- + +## Task 9: Update Fullscreen TUI Config + +**Files:** +- Modify: `src/frontends/tui/fullscreen.ts` +- Modify: `src/frontends/tui/index.ts` + +**Step 1: Update fullscreen.ts types** + +Update `src/frontends/tui/fullscreen.ts`: + +```typescript +import React from 'react'; +import { render } from 'ink'; +import { App } from './components/index.js'; +import type { ManagedSession } from '../../session/index.js'; +import type { ModelClient } from '../../models/types.js'; +import type { ModelRouter } from '../../models/router.js'; + +export interface FullscreenTuiConfig { + session: ManagedSession; + modelClient: ModelClient; + modelRouter?: ModelRouter; + systemPrompt: string; + model: string; + onExit?: () => void; +} + +export async function startFullscreenTui(config: FullscreenTuiConfig): Promise { + // Ensure stdin is in a clean state for Ink + if (process.stdin.isPaused()) { + process.stdin.resume(); + } + + const { waitUntilExit } = render( + React.createElement(App, { + session: config.session, + modelClient: config.modelClient, + modelRouter: config.modelRouter, + systemPrompt: config.systemPrompt, + model: config.model, + onExit: config.onExit, + }) + ); + + await waitUntilExit(); +} +``` + +**Step 2: Update index.ts exports** + +Update `src/frontends/tui/index.ts`: + +```typescript +export { MinimalTui, formatPrompt, parseCommand } from './minimal.js'; +export { startFullscreenTui, type FullscreenTuiConfig } from './fullscreen.js'; +export { renderMarkdown } from './markdown.js'; +export { parseCommand as parseCommandUtil, getHelpText, resolveModelAlias } from './commands.js'; +``` + +**Step 3: Build to verify** + +Run: `pnpm build` +Expected: May need App.tsx updates + +**Step 4: Commit (if passes)** + +```bash +git add src/frontends/tui/fullscreen.ts src/frontends/tui/index.ts +git commit -m "feat(tui): update fullscreen config for model router" +``` + +--- + +## Task 10: Update StatusBar Component + +**Files:** +- Modify: `src/frontends/tui/components/StatusBar.tsx` + +**Step 1: Update StatusBar with token usage** + +Update `src/frontends/tui/components/StatusBar.tsx`: + +```typescript +import React from 'react'; +import { Box, Text } from 'ink'; + +export interface StatusBarProps { + sessionId: string; + messageCount: number; + model: string; + tokenUsage?: { + input: number; + output: number; + }; + isStreaming?: boolean; +} + +function formatTokens(n: number): string { + if (n >= 1000) { + return `${(n / 1000).toFixed(1)}k`; + } + return String(n); +} + +export function StatusBar({ + sessionId, + messageCount, + model, + tokenUsage, + isStreaming, +}: StatusBarProps): React.ReactElement { + // Extract short model name (e.g., "claude-opus-4-5-20251101" -> "opus-4.5") + const shortModel = model + .replace('claude-', '') + .replace(/-\d{8}$/, '') + .replace('-4-5', '-4.5'); + + return ( + + + Flynn + {isStreaming && ( + <> + | + streaming... + + )} + + + Model: + {shortModel} + | + Msgs: + {messageCount} + {tokenUsage && ( + <> + | + Tokens: + {formatTokens(tokenUsage.input)}/{formatTokens(tokenUsage.output)} + + )} + + + ); +} +``` + +**Step 2: Build to verify** + +Run: `pnpm build` +Expected: Success + +**Step 3: Commit** + +```bash +git add src/frontends/tui/components/StatusBar.tsx +git commit -m "feat(tui): enhance StatusBar with token usage and streaming indicator" +``` + +--- + +## Task 11: Update MessageList with Markdown and Scroll + +**Files:** +- Modify: `src/frontends/tui/components/MessageList.tsx` + +**Step 1: Update MessageList** + +Update `src/frontends/tui/components/MessageList.tsx`: + +```typescript +import React from 'react'; +import { Box, Text } from 'ink'; +import type { Message } from '../../../models/types.js'; +import { renderMarkdown } from '../markdown.js'; + +export interface MessageListProps { + messages: Message[]; + scrollOffset?: number; + streamingContent?: string; +} + +export function MessageList({ + messages, + scrollOffset = 0, + streamingContent, +}: MessageListProps): React.ReactElement { + // Calculate visible area (approximate, Ink handles overflow) + const visibleMessages = messages.slice(scrollOffset); + + return ( + + {visibleMessages.length === 0 && !streamingContent ? ( + No messages yet. Start typing to chat with Flynn. + ) : ( + <> + {visibleMessages.map((message, index) => ( + + + {message.role === 'user' ? 'You:' : 'Flynn:'} + + + + {message.role === 'assistant' + ? renderMarkdown(message.content) + : message.content} + + + + ))} + {streamingContent && ( + + Flynn: + + {streamingContent} + + + + )} + + )} + {messages.length > 0 && scrollOffset > 0 && ( + + ↑ {scrollOffset} more + + )} + + ); +} +``` + +**Step 2: Build to verify** + +Run: `pnpm build` +Expected: Success + +**Step 3: Commit** + +```bash +git add src/frontends/tui/components/MessageList.tsx +git commit -m "feat(tui): add markdown rendering and scroll support to MessageList" +``` + +--- + +## Task 12: Update App Component with Streaming and Scroll + +**Files:** +- Modify: `src/frontends/tui/components/App.tsx` + +**Step 1: Update App.tsx with full streaming support** + +Update `src/frontends/tui/components/App.tsx`: + +```typescript +import React, { useState, useCallback, useRef } from 'react'; +import { Box, useApp, useInput } from 'ink'; +import { StatusBar } from './StatusBar.js'; +import { MessageList } from './MessageList.js'; +import { InputBar } from './InputBar.js'; +import { parseCommand, getHelpText, resolveModelAlias } from '../commands.js'; +import type { Message, ModelClient, TokenUsage } from '../../../models/types.js'; +import type { ModelRouter } from '../../../models/router.js'; +import type { ManagedSession } from '../../../session/index.js'; + +export interface AppProps { + session: ManagedSession; + modelClient: ModelClient; + modelRouter?: ModelRouter; + systemPrompt: string; + model: string; + onExit?: () => void; +} + +export function App({ + session, + modelClient, + modelRouter, + systemPrompt, + model, + onExit, +}: AppProps): React.ReactElement { + const { exit } = useApp(); + const [input, setInput] = useState(''); + const [messages, setMessages] = useState(session.getHistory()); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [scrollOffset, setScrollOffset] = useState(0); + const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0 }); + const [currentModel, setCurrentModel] = useState(model); + const abortRef = useRef(false); + + useInput((inputChar, key) => { + if (key.escape) { + if (isStreaming) { + abortRef.current = true; + } else { + onExit?.(); + exit(); + } + } + + // Scroll handling + if (key.upArrow && scrollOffset > 0) { + setScrollOffset(prev => Math.max(0, prev - 1)); + } + if (key.downArrow) { + setScrollOffset(prev => Math.min(messages.length - 1, prev + 1)); + } + if (key.pageUp) { + setScrollOffset(prev => Math.max(0, prev - 10)); + } + if (key.pageDown) { + setScrollOffset(prev => Math.min(messages.length - 1, prev + 10)); + } + }); + + const handleSubmit = useCallback(async (value: string) => { + const command = parseCommand(value); + if (!command) return; + + setInput(''); + + // Handle commands + switch (command.type) { + case 'quit': + onExit?.(); + exit(); + return; + + case 'reset': + session.clear(); + setMessages([]); + setTokenUsage({ inputTokens: 0, outputTokens: 0 }); + setScrollOffset(0); + return; + + case 'help': + // Show help as system message + setMessages(prev => [...prev, { role: 'assistant', content: getHelpText() }]); + return; + + case 'status': + const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`; + setMessages(prev => [...prev, { role: 'assistant', content: status }]); + return; + + case 'model': + if (!modelRouter) { + setMessages(prev => [...prev, { role: 'assistant', content: 'Model switching not available.' }]); + return; + } + if (!command.name) { + const info = `Current: ${modelRouter.getTier()}\nAvailable: ${modelRouter.getAvailableTiers().join(', ')}`; + setMessages(prev => [...prev, { role: 'assistant', content: info }]); + return; + } + const tier = resolveModelAlias(command.name); + if (modelRouter.setTier(tier)) { + setCurrentModel(tier); + setMessages(prev => [...prev, { role: 'assistant', content: `Switched to model: ${tier}` }]); + } else { + setMessages(prev => [...prev, { role: 'assistant', content: `Model not available: ${command.name}` }]); + } + return; + + case 'fullscreen': + // Already in fullscreen + return; + + case 'transfer': + setMessages(prev => [...prev, { role: 'assistant', content: `Transfer not supported in fullscreen mode.` }]); + return; + + case 'message': + break; // Continue to message handling + } + + if (command.type !== 'message' || isStreaming) return; + + // Add user message + const userMessage: Message = { role: 'user', content: command.content }; + session.addMessage(userMessage); + setMessages(prev => [...prev, userMessage]); + setScrollOffset(0); // Auto-scroll to bottom + + // Stream response + setIsStreaming(true); + setStreamingContent(''); + abortRef.current = false; + + try { + if (modelClient.chatStream) { + let fullContent = ''; + + for await (const event of modelClient.chatStream({ + messages: session.getHistory(), + system: systemPrompt, + })) { + if (abortRef.current) { + fullContent += '\n\n[interrupted]'; + break; + } + + if (event.type === 'content' && event.content) { + fullContent += event.content; + setStreamingContent(fullContent); + } + if (event.type === 'done' && event.usage) { + setTokenUsage(prev => ({ + inputTokens: prev.inputTokens + event.usage!.inputTokens, + outputTokens: prev.outputTokens + event.usage!.outputTokens, + })); + } + if (event.type === 'error') { + throw event.error ?? new Error('Stream error'); + } + } + + const assistantMessage: Message = { role: 'assistant', content: fullContent }; + session.addMessage(assistantMessage); + setMessages(prev => [...prev, assistantMessage]); + } else { + // Fallback to non-streaming + const response = await modelClient.chat({ + messages: session.getHistory(), + system: systemPrompt, + }); + + setTokenUsage(prev => ({ + inputTokens: prev.inputTokens + response.usage.inputTokens, + outputTokens: prev.outputTokens + response.usage.outputTokens, + })); + + const assistantMessage: Message = { role: 'assistant', content: response.content }; + session.addMessage(assistantMessage); + setMessages(prev => [...prev, assistantMessage]); + } + } catch (error) { + const errorMessage: Message = { + role: 'assistant', + content: `⚠ Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + setMessages(prev => [...prev, errorMessage]); + } finally { + setIsStreaming(false); + setStreamingContent(''); + } + }, [isStreaming, session, modelClient, modelRouter, systemPrompt, exit, onExit, messages.length, tokenUsage]); + + return ( + + + + + + ); +} +``` + +**Step 2: Build to verify** + +Run: `pnpm build` +Expected: Success + +**Step 3: Commit** + +```bash +git add src/frontends/tui/components/App.tsx +git commit -m "feat(tui): add streaming, scroll, and model switching to fullscreen App" +``` + +--- + +## Task 13: Update Component Exports + +**Files:** +- Modify: `src/frontends/tui/components/index.ts` + +**Step 1: Verify exports are correct** + +Check `src/frontends/tui/components/index.ts`: + +```typescript +export { App, type AppProps } from './App.js'; +export { StatusBar, type StatusBarProps } from './StatusBar.js'; +export { MessageList, type MessageListProps } from './MessageList.js'; +export { InputBar, type InputBarProps } from './InputBar.js'; +``` + +**Step 2: Build and run full test suite** + +Run: `pnpm test:run && pnpm build` +Expected: All tests pass, build succeeds + +**Step 3: Commit if needed** + +```bash +git add src/frontends/tui/components/index.ts +git commit -m "chore(tui): update component exports" +``` + +--- + +## Task 14: Manual Integration Testing + +**Step 1: Test minimal mode streaming** + +Run: `pnpm tui` + +Test: +- Type "Hello" - should see streaming response +- Type `/model` - should show available models +- Type `/model local` - should switch model +- Type `/status` - should show token count +- Type `/help` - should show commands +- Type `/fs` - should switch to fullscreen + +**Step 2: Test fullscreen mode** + +Run: `pnpm tui -f` + +Test: +- Type "Hello" - should see streaming with cursor +- Press Up/Down arrows - should scroll +- Type `/model` - should show models +- Press Esc during streaming - should cancel +- Press Esc when idle - should exit + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "feat(tui): complete TUI redesign with streaming and markdown" +``` + +--- + +## Summary + +**Tasks completed:** +1. Streaming types added +2. AnthropicClient streaming implemented +3. ModelRouter streaming + tier switching +4. Markdown dependencies installed +5. Markdown rendering utility +6. Unified command parser +7. Minimal TUI with streaming +8. TUI entry point updated +9. Fullscreen config updated +10. StatusBar enhanced +11. MessageList with markdown/scroll +12. App with full streaming/scroll +13. Exports verified +14. Integration tested + +**Future tasks (from design doc):** +- Vim-like keybindings +- Command palette +- Multiple panes +- Message-based navigation +- Named sessions