Files
flynn/docs/plans/2026-02-05-tui-redesign.md
T
William Valentin 42d1175b02 docs: add TUI redesign plan
- Streaming responses (chunk-based)
- Rich markdown rendering with syntax highlighting
- Model switching via /model command
- Scrollable message history
- Enhanced status bar with token usage
- Inline error handling with auto-retry
- Feature parity between minimal and fullscreen modes
2026-02-05 10:41:20 -08:00

11 KiB

Flynn TUI Redesign

Date: 2026-02-05
Status: Approved
Author: Will + Claude

Overview

Redesign Flynn's TUI to provide a polished chat experience with streaming responses, rich markdown rendering, and model switching. Both minimal and fullscreen modes get the same core features.

Goals

  • Streaming responses (chunk-based, as API delivers)
  • Markdown rendering (bold, italic, code blocks with syntax highlighting, tables, links, lists)
  • Model switching via /model <name> command
  • Scrollable message history with keyboard navigation
  • Enhanced status bar with token usage
  • Inline error handling with auto-retry
  • Feature parity between minimal and fullscreen modes

User Documentation

Modes

Minimal mode (pnpm tui)

  • Readline-based interface for quick queries
  • Streaming text output in terminal
  • Markdown rendered inline
  • Type /fs to switch to fullscreen

Fullscreen mode (pnpm tui -f or /fs)

  • Panel-based UI with status bar, message list, input bar
  • Scrollable message history
  • Press Esc to exit

Commands

Command Description
/help Show available commands
/model <name> Switch model (e.g., /model local, /model opus)
/model Show current model and available options
/reset Clear conversation history
/status Show session info and token usage
/quit or /exit Exit TUI
/fs or /fullscreen Switch to fullscreen mode (minimal only)

Keybindings (Fullscreen)

Key Action
Esc Exit (or cancel streaming)
↑/↓ Scroll messages
PgUp/PgDn Scroll full page
Home/End Jump to start/end of history

Streaming

  • Responses appear chunk-by-chunk as they arrive from the API
  • Press Esc during streaming to cancel (partial response is kept)
  • Input is disabled while streaming

Error Handling

Errors appear inline in the chat:

You: Tell me about quantum computing

⚠ Connection error: Request timed out. Retrying (1/3)...

Flynn: Quantum computing uses quantum mechanical phenomena...
  • Network/timeout errors auto-retry up to 3 times
  • Rate limits show wait time and auto-retry
  • Auth errors suggest checking config

Technical Design

Architecture

src/
  models/
    anthropic.ts      # Add streaming method: chatStream()
    types.ts          # Add ChatStreamResponse type
    router.ts         # Add stream routing
  
  frontends/tui/
    markdown.ts       # NEW: Markdown rendering utilities
    streaming.ts      # NEW: Stream handling & display
    commands.ts       # NEW: Unified command parsing & handling
    minimal.ts        # Update: streaming + markdown
    fullscreen.ts     # Update: streaming + markdown
    components/
      App.tsx         # Update: streaming state, scroll handling
      MessageList.tsx # Update: markdown rendering, scroll indicator
      StatusBar.tsx   # Update: token usage display
      InputBar.tsx    # Update: streaming state
      StreamingText.tsx  # NEW: animated streaming display

Streaming Implementation

New types in types.ts:

export interface ChatStreamEvent {
  type: 'content' | 'done' | 'error';
  content?: string;      // For 'content' events
  usage?: TokenUsage;    // For 'done' events
  error?: Error;         // For 'error' events
}

export interface ModelClient {
  chat(request: ChatRequest): Promise<ChatResponse>;
  chatStream?(request: ChatRequest): AsyncIterable<ChatStreamEvent>;
}

AnthropicClient.chatStream():

async *chatStream(request: ChatRequest): AsyncIterable<ChatStreamEvent> {
  const stream = await 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,
    })),
  });

  for await (const event of stream) {
    if (event.type === 'content_block_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,
    },
  };
}

Data flow:

  1. User submits message
  2. modelClient.chatStream() returns async iterator
  3. Chunks arrive → update display incrementally
  4. On completion → finalize message, apply markdown, update token count
  5. Session saves complete message

Markdown Rendering

Dependencies:

  • marked - Markdown parser
  • marked-terminal - Terminal renderer for marked
  • cli-highlight - Syntax highlighting for code blocks

markdown.ts:

import { marked } from 'marked';
import TerminalRenderer from 'marked-terminal';
import { highlight } from 'cli-highlight';

marked.setOptions({
  renderer: new TerminalRenderer({
    code: (code, lang) => highlight(code, { language: lang || 'plaintext' }),
    // ... other options
  }),
});

export function renderMarkdown(text: string): string {
  return marked.parse(text);
}

Rendering strategy:

  • During streaming: show raw text (no markdown processing)
  • On completion: render full message with markdown
  • This avoids incomplete markdown artifacts during streaming

Fullscreen Layout

┌─────────────────────────────────────────────────────────────────┐
│ Flynn | Model: opus-4.5 | Messages: 12 | Tokens: 2.4k/8k in/out │  ← StatusBar
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│ You: How do I parse JSON in Rust?                              │
│                                                                 │
│ Flynn: Use the **serde** crate with `serde_json`:              │  ← MessageList
│                                                                 │     (scrollable)
│ ```rust                                                        │
│ use serde_json::Value;                                         │
│ let v: Value = serde_json::from_str(data)?;                    │
│ ```                                                            │
│                                                        [85%] ↓ │  ← scroll indicator
├─────────────────────────────────────────────────────────────────┤
│ > Type a message... (Esc=exit, /help)                          │  ← InputBar
└─────────────────────────────────────────────────────────────────┘

StatusBar Updates

export interface StatusBarProps {
  sessionId: string;
  messageCount: number;
  model: string;
  tokenUsage?: {
    input: number;
    output: number;
  };
  isStreaming?: boolean;
}

Display format: Flynn | Model: opus-4.5 | Messages: 12 | Tokens: 2.4k/8k in/out

Model Switching

Command parsing:

  • /model - List available models and current selection
  • /model <name> - Switch to named model

Model names map to config tiers:

  • default, opusmodels.default
  • fast, sonnetmodels.fast
  • complexmodels.complex
  • local, ollamamodels.local

Implementation:

  • ModelRouter exposes setTier(tier: ModelTier) method
  • Commands update router tier
  • StatusBar reflects current model

Unified Command Handling

New commands.ts:

export type Command =
  | { type: 'quit' }
  | { type: 'reset' }
  | { type: 'help' }
  | { type: 'status' }
  | { type: 'fullscreen' }
  | { type: 'model'; name?: string }
  | { type: 'message'; content: string };

export function parseCommand(input: string): Command | null;
export function getHelpText(): string;
export function getAvailableModels(config: Config): string[];

Both minimal and fullscreen modes use the same command parser.

Error Handling

Error types and behavior:

Error Type Behavior
Network/timeout Auto-retry 3x with exponential backoff, show progress
Auth (401/403) Show message, suggest checking config
Rate limit (429) Show wait time, auto-retry after delay
Model error Show error, suggest /model to switch
Stream interrupted Keep partial response, mark as [interrupted]

Inline error display:

interface SystemMessage {
  type: 'error' | 'info';
  content: string;
  timestamp: Date;
}

Rendered with warning icon: ⚠ Connection error: Request timed out. Retrying (1/3)...

Scroll Implementation (Fullscreen)

State:

const [scrollOffset, setScrollOffset] = useState(0);
const [autoScroll, setAutoScroll] = useState(true);

Behavior:

  • Auto-scroll to bottom on new messages (if already at bottom)
  • Manual scroll disables auto-scroll
  • Pressing End re-enables auto-scroll
  • Scroll indicator shows position: [85%] ↓

Keybindings:

useInput((input, key) => {
  if (key.upArrow) scrollUp();
  if (key.downArrow) scrollDown();
  if (key.pageUp) scrollPageUp();
  if (key.pageDown) scrollPageDown();
  if (input === 'Home') scrollToTop();
  if (input === 'End') scrollToBottom();
});

Dependencies

Add to package.json:

{
  "dependencies": {
    "marked": "^12.0.0",
    "marked-terminal": "^7.0.0",
    "cli-highlight": "^2.1.11"
  }
}

Implementation Order

  1. Streaming foundation

    • Add ChatStreamEvent type
    • Implement AnthropicClient.chatStream()
    • Add ModelRouter.chatStream() with fallback handling
  2. Markdown rendering

    • Add dependencies
    • Create markdown.ts utilities
    • Unit test rendering
  3. Unified commands

    • Create commands.ts
    • Add /model command
    • Update minimal.ts to use shared parser
  4. Minimal mode updates

    • Integrate streaming display
    • Add markdown rendering
    • Update error handling
  5. Fullscreen mode updates

    • Add StreamingText component
    • Update MessageList with markdown + scroll
    • Update StatusBar with token usage
    • Add keyboard navigation
    • Update App with scroll state
  6. Polish

    • Error retry logic
    • Cancellation handling
    • Testing & edge cases

Future Improvements

Deferred for later iterations:

  • Vim-like keybindings (j/k scroll, / search, y yank)
  • Command palette (Ctrl+P)
  • Multiple panes (sessions/models sidebar)
  • Message-based navigation (n/p jump between messages)
  • Detailed status bar (response time, cost estimate, connection status)
  • Named sessions (save/load/list)
  • Session browser UI

Testing

  • Unit tests for markdown rendering
  • Unit tests for command parsing
  • Integration tests for streaming (mock API)
  • Manual testing for keyboard navigation
  • Test both modes for feature parity