# 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