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

385 lines
11 KiB
Markdown

# 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`:**
```typescript
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():**
```typescript
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:**
```typescript
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
```typescript
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.default`
- `fast`, `sonnet``models.fast`
- `complex``models.complex`
- `local`, `ollama``models.local`
**Implementation:**
- ModelRouter exposes `setTier(tier: ModelTier)` method
- Commands update router tier
- StatusBar reflects current model
### Unified Command Handling
**New `commands.ts`:**
```typescript
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:**
```typescript
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:**
```typescript
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:**
```typescript
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:**
```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