Files
flynn/docs/plans/2026-02-05-tui-redesign-implementation.md
T
2026-02-05 10:44:49 -08:00

1835 lines
49 KiB
Markdown

# 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<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**
```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<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**
```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<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:
```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<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**
```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<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**
```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 <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**
```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<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`:
```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<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`:
```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 (
<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**
```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 (
<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**
```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<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**
```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