49 KiB
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:
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<ChatStreamEvent>;
}
export interface ModelClient {
chat(request: ChatRequest): Promise<ChatResponse>;
chatStream?(request: ChatRequest): AsyncIterable<ChatStreamEvent>;
}
Step 2: Run build to verify types compile
Run: pnpm build
Expected: Success, no type errors
Step 3: Commit
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:
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:
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:
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<ChatResponse> {
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<ChatStreamEvent> {
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
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:
describe('ModelRouter streaming', () => {
it('streams from primary client', async () => {
const mockStream = async function* (): AsyncIterable<ChatStreamEvent> {
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<ChatStreamEvent> {
yield { type: 'error', error: new Error('Primary failed') };
};
const fallbackStream = async function* (): AsyncIterable<ChatStreamEvent> {
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:
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:
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<ModelTier, ModelClient>;
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<ChatResponse> {
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<ChatStreamEvent> {
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
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
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:
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:
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<string>, 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
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:
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:
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 <dest> 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<string, 'local' | 'default' | 'fast' | 'complex'> = {
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
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:
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<void> {
this.running = true;
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('Flynn TUI (minimal mode)');
console.log('Type /help for commands, /fullscreen for panel mode\n');
await this.promptLoop();
}
private async promptLoop(): Promise<void> {
while (this.running && this.rl) {
const input = await this.prompt(formatPrompt('default'));
const command = parseCommand(input);
if (!command) {
continue;
}
await this.handleCommand(command);
}
}
private prompt(promptText: string): Promise<string> {
return new Promise((resolve) => {
if (!this.rl) {
resolve('');
return;
}
this.rl.question(promptText, resolve);
this.rl.once('close', () => resolve(''));
});
}
private async handleCommand(command: Command): Promise<void> {
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<void> {
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:
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
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:
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)
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:
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<void> {
// 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:
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)
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:
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 (
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Box flexGrow={1}>
<Text color="cyan">Flynn</Text>
{isStreaming && (
<>
<Text color="gray"> | </Text>
<Text color="yellow">streaming...</Text>
</>
)}
</Box>
<Box>
<Text color="gray">Model: </Text>
<Text color="green">{shortModel}</Text>
<Text color="gray"> | </Text>
<Text color="gray">Msgs: </Text>
<Text>{messageCount}</Text>
{tokenUsage && (
<>
<Text color="gray"> | </Text>
<Text color="gray">Tokens: </Text>
<Text>{formatTokens(tokenUsage.input)}/{formatTokens(tokenUsage.output)}</Text>
</>
)}
</Box>
</Box>
);
}
Step 2: Build to verify
Run: pnpm build
Expected: Success
Step 3: Commit
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:
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 (
<Box flexDirection="column" flexGrow={1} paddingX={1} overflowY="hidden">
{visibleMessages.length === 0 && !streamingContent ? (
<Text color="gray">No messages yet. Start typing to chat with Flynn.</Text>
) : (
<>
{visibleMessages.map((message, index) => (
<Box key={`${scrollOffset + index}-${message.role}`} marginBottom={1} flexDirection="column">
<Text color={message.role === 'user' ? 'blue' : 'green'} bold>
{message.role === 'user' ? 'You:' : 'Flynn:'}
</Text>
<Box marginLeft={1}>
<Text wrap="wrap">
{message.role === 'assistant'
? renderMarkdown(message.content)
: message.content}
</Text>
</Box>
</Box>
))}
{streamingContent && (
<Box marginBottom={1} flexDirection="column">
<Text color="green" bold>Flynn:</Text>
<Box marginLeft={1}>
<Text wrap="wrap">{streamingContent}</Text>
<Text color="yellow">▌</Text>
</Box>
</Box>
)}
</>
)}
{messages.length > 0 && scrollOffset > 0 && (
<Box position="absolute" marginTop={-1}>
<Text color="gray">↑ {scrollOffset} more</Text>
</Box>
)}
</Box>
);
}
Step 2: Build to verify
Run: pnpm build
Expected: Success
Step 3: Commit
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:
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<Message[]>(session.getHistory());
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [scrollOffset, setScrollOffset] = useState(0);
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ 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 (
<Box flexDirection="column" height="100%">
<StatusBar
sessionId={session.id}
messageCount={messages.length}
model={currentModel}
tokenUsage={{ input: tokenUsage.inputTokens, output: tokenUsage.outputTokens }}
isStreaming={isStreaming}
/>
<MessageList
messages={messages}
scrollOffset={scrollOffset}
streamingContent={isStreaming ? streamingContent : undefined}
/>
<InputBar
value={input}
onChange={setInput}
onSubmit={handleSubmit}
isLoading={isStreaming}
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'}
/>
</Box>
);
}
Step 2: Build to verify
Run: pnpm build
Expected: Success
Step 3: Commit
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:
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
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
git add -A
git commit -m "feat(tui): complete TUI redesign with streaming and markdown"
Summary
Tasks completed:
- Streaming types added
- AnthropicClient streaming implemented
- ModelRouter streaming + tier switching
- Markdown dependencies installed
- Markdown rendering utility
- Unified command parser
- Minimal TUI with streaming
- TUI entry point updated
- Fullscreen config updated
- StatusBar enhanced
- MessageList with markdown/scroll
- App with full streaming/scroll
- Exports verified
- Integration tested
Future tasks (from design doc):
- Vim-like keybindings
- Command palette
- Multiple panes
- Message-based navigation
- Named sessions