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
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user