- 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
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
/fsto switch to fullscreen
Fullscreen mode (pnpm tui -f or /fs)
- Panel-based UI with status bar, message list, input bar
- Scrollable message history
- Press
Escto 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
Escduring 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:
- User submits message
modelClient.chatStream()returns async iterator- Chunks arrive → update display incrementally
- On completion → finalize message, apply markdown, update token count
- Session saves complete message
Markdown Rendering
Dependencies:
marked- Markdown parsermarked-terminal- Terminal renderer for markedcli-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,opus→models.defaultfast,sonnet→models.fastcomplex→models.complexlocal,ollama→models.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
Endre-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
-
Streaming foundation
- Add
ChatStreamEventtype - Implement
AnthropicClient.chatStream() - Add
ModelRouter.chatStream()with fallback handling
- Add
-
Markdown rendering
- Add dependencies
- Create
markdown.tsutilities - Unit test rendering
-
Unified commands
- Create
commands.ts - Add
/modelcommand - Update minimal.ts to use shared parser
- Create
-
Minimal mode updates
- Integrate streaming display
- Add markdown rendering
- Update error handling
-
Fullscreen mode updates
- Add
StreamingTextcomponent - Update
MessageListwith markdown + scroll - Update
StatusBarwith token usage - Add keyboard navigation
- Update
Appwith scroll state
- Add
-
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