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

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:

  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