diff --git a/.guardrails b/.guardrails new file mode 160000 index 00000000..13911895 --- /dev/null +++ b/.guardrails @@ -0,0 +1 @@ +Subproject commit 139118958cba9c083bf78c29f3efdc0034b24d14 diff --git a/.sprints/TUI-PARITY-ROADMAP.md b/.sprints/TUI-PARITY-ROADMAP.md new file mode 100644 index 00000000..d1114cc9 --- /dev/null +++ b/.sprints/TUI-PARITY-ROADMAP.md @@ -0,0 +1,100 @@ +# Claw Code TUI — Feature Parity Roadmap + +> **Branch:** `feat-tui` | **Stack:** ratatui 0.29 + crossterm 0.28 + tui-textarea 0.7 +> **Goal:** Parity with Claude Code, OpenCode, Codex CLI, Aider terminal interfaces. +> **Guardrails:** See `.guardrails/` — Four Laws mandatory on every story. + +--- + +## Guardrails Integration + +### Four Laws (Applied to Every Sprint) + +| Law | Application | +|-----|-------------| +| **Read Before Editing** | Read every file before modifying. No exceptions. | +| **Stay in Scope** | Each story lists IN/OUT scope. Only touch listed files. | +| **Verify Before Committing** | `cargo build` + `cargo test` must pass before each commit. | +| **Halt When Uncertain** | If syntax fails, test fails, or behavior unclear — stop and ask. | + +### Commit Format (Every Story) +``` +(tui): + + + +Authored by TheArchitectit +``` + +### Three Strikes Rule +- MAX 3 attempts per task +- After 3 failures → HALT, report full history, recommend fresh session +- Rollback between attempts: `git checkout HEAD -- ` + +### Pre-Execution Checklist (Every File Edit) +``` +[ ] File read before editing +[ ] File is IN scope for this story +[ ] Rollback command known (git checkout HEAD -- ) +[ ] No feature creep (only changes listed in story) +[ ] Previous sprint fixes not being undone +[ ] cargo build passes after edit +[ ] cargo test passes after edit +``` + +--- + +## Sprint Summary + +| Sprint | Title | Duration | Stories | Goal | +|--------|-------|----------|---------|------| +| S0 | TUI Extraction & Architecture | 4 days | 5 | Extract from 20K-line main.rs | +| S1 | Foundation & Bug Fixes | 4 days | 7 | Make TUI reliable for daily use | +| S2 | Streaming & Markdown Rendering | 5 days | 6 | Real-time display + rich markdown | +| S3 | Theme System | 3 days | 5 | User customization | +| S4 | Keybindings & Command Palette | 4 days | 5 | UX parity | +| S5 | Chat Modes & Diff Viewer | 2 days | 4 | Aider-style workflows | +| S6 | Agent View & Multi-Session | 4 days | 5 | Multi-agent monitoring | +| S7 | Polish & Ship | 3 days | 7 | Mouse, CJK, sidebar, final QA | +| **Total** | | **29 days** | **44 stories** | | + +--- + +## Architecture Decisions + +### ADR-1: Conversation content model +```rust +pub enum ConversationContent { + Plain { text: String, color: Color, bold: bool }, + Markdown { source: String }, + CodeDiff { diff: String }, +} +``` + +### ADR-2: Theme architecture +`TuiTheme` struct with named color fields. Loaded from JSON, built-in defaults compiled in. + +### ADR-3: Keybinding abstraction +`Action` enum decoupled from `KeyEvent`. Enables Vim/Emacs/Windows presets. + +### ADR-4: Rendering context +Replace 10-parameter `draw_frame()` with `RenderContext` struct. + +### ADR-5: Markdown rendering — CACHE PER ENTRY +Cache rendered `Vec` per `ConversationLine`. Re-render only on resize. Syntect assets loaded once via `once_cell::Lazy`. + +### ADR-6: TUI module extraction +Extract all TUI code from `main.rs` before feature work begins. + +--- + +## Sprint Details + +→ [Sprint 0: TUI Extraction](./sprint-0-extraction.md) +→ [Sprint 1: Foundation](./sprint-1-foundation.md) +→ [Sprint 2: Streaming & Markdown](./sprint-2-markdown.md) +→ [Sprint 3: Themes](./sprint-3-themes.md) +→ [Sprint 4: Keybindings](./sprint-4-keybindings.md) +→ [Sprint 5: Chat Modes](./sprint-5-chat-modes.md) +→ [Sprint 6: Agent View](./sprint-6-agent-view.md) +→ [Sprint 7: Polish](./sprint-7-polish.md) diff --git a/.sprints/sprint-0-extraction.md b/.sprints/sprint-0-extraction.md new file mode 100644 index 00000000..7adec0b0 --- /dev/null +++ b/.sprints/sprint-0-extraction.md @@ -0,0 +1,344 @@ +# Sprint 0: TUI Extraction & Architecture + +> **Duration:** 4 days | **Stories:** 5 | **Goal:** Extract TUI from 20K-line main.rs, establish panic recovery, clean module boundaries + +--- + +## Why This Sprint Exists + +`main.rs` is **20,020 lines**. TUI code is embedded at line 7146. Every subsequent sprint adds slash commands and event handling to this file. Without extraction first: +- Merge conflicts between sprints are guaranteed +- Code review is impossible (who can review a 20K file?) +- Testing individual modules is blocked + +This sprint creates the foundation all other sprints build on. + +--- + +## S0-1: Extract TUI integration from main.rs + +**Priority:** P0 — Critical +**Assignee:** — +**Estimate:** 1.5 days + +### Description +Move all TUI-specific code from `main.rs` into dedicated modules. Create a clean interface boundary. + +### New Files + +**`src/tui_repl.rs`** — TUI event loop and integration: +```rust +use crate::tui::{self, TuiApp, TuiReadOutcome, SharedDashboardState}; +use crate::tui_commands::TuiCommand; + +pub struct TuiRepl; + +impl TuiRepl { + /// Entry point called from main.rs when `/tui` is typed. + pub fn run(cli: &mut LiveCli) -> Result<(), Box> { + let dashboard_state = SharedDashboardState::default(); + let mut app = TuiApp::init(dashboard_state.clone())?; + + app.push_banner(&[ + tui::BannerLine { text: "🦀 Claw Code".to_string(), color: ratatui::style::Color::Cyan }, + ]); + app.push_banner(&[ + tui::BannerLine { text: format_connected_line(&cli.model), color: ratatui::style::Color::DarkGray }, + ]); + + let mut pending_input: Option = None; + + loop { + // ... event loop (currently at main.rs:7159-7260) + // ... + } + + app.restore_terminal()?; + Ok(()) + } +} +``` + +**`src/tui_commands.rs`** — Slash command dispatch for TUI mode: +```rust +pub enum TuiCommand { + Theme(String), // /theme + Keys(String), // /keys + Code, // /code + Ask, // /ask + Architect, // /architect + Diff { staged: bool },// /diff [--staged] + Undo { confirm: bool },// /undo [--confirm] + Ls { path: Option }, // /ls [path] + Context, // /context + Help, // /help +} + +impl TuiCommand { + pub fn parse(input: &str) -> Option { ... } + pub fn execute(&self, app: &mut TuiApp, cli: &mut LiveCli) -> Result<(), Error> { ... } +} +``` + +**`src/tui_update.rs`** — Dashboard state updates: +```rust +pub fn update_dashboard(state: &SharedDashboardState, cli: &LiveCli) { ... } +pub fn format_connected_line(model: &str) -> String { ... } +pub fn strip_ansi(text: &str) -> String { ... } // moved from main.rs +``` + +### Implementation Steps + +1. Create `src/tui_repl.rs` — move `run_tui_repl()` body (main.rs:7146-7262) +2. Create `src/tui_commands.rs` — extract slash command parsing and execution +3. Create `src/tui_update.rs` — move `update_dashboard()`, `strip_ansi()`, `format_connected_line()` +4. Update `main.rs` — replace `run_tui_repl()` with delegation: + ```rust + fn run_tui_repl(cli: LiveCli) -> Result<(), Box> { + tui_repl::TuiRepl::run(cli) + } + ``` + Or remove entirely and call `TuiRepl::run()` directly. +5. Add `mod` declarations to `main.rs`: + ```rust + mod tui_repl; + mod tui_commands; + mod tui_update; + ``` +6. Verify `cargo build` succeeds +7. Verify `cargo test` still passes + +### Acceptance Criteria +- [ ] `run_tui_repl()` body moved to `src/tui_repl.rs` +- [ ] Slash command handling moved to `src/tui_commands.rs` +- [ ] `update_dashboard()` and helpers moved to `src/tui_update.rs` +- [ ] `main.rs` reduced by ~200+ lines +- [ ] `cargo build --release` succeeds +- [ ] `cargo test -p rusty-claude-cli` passes +- [ ] `/tui` command still works in the REPL +- [ ] No behavioral changes (pure extraction) + +--- + +## S0-2: Add panic hook for terminal cleanup + +**Priority:** P0 — Critical +**Assignee:** — +**Estimate:** 0.5 day + +### Description +If any TUI code panics, the terminal is left in raw mode with hidden cursor and alternate screen. The user sees a broken terminal and must manually run `reset`. Add a panic hook that restores terminal state before printing the panic message. + +### Implementation + +**File:** `src/tui_repl.rs` + +```rust +use std::panic; +use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; +use crossterm::execute; + +/// Install a panic hook that restores terminal state before printing the panic. +/// Returns the previous hook so it can be restored on clean exit. +pub fn install_panic_hook() -> Box) + Send + Sync + 'static> { + let prev_hook = panic::take_hook(); + + panic::set_hook(Box::new(|info| { + // Best-effort terminal cleanup — ignore errors since we're panicking + let _ = disable_raw_mode(); + let _ = execute!(std::io::stdout(), LeaveAlternateScreen); + let _ = execute!(std::io::stdout(), crossterm::cursor::Show); + + // Print the panic message + let payload = if let Some(s) = info.payload().downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "Box".to_string() + }; + + let location = info.location() + .map(|l| format!(" at {}:{}", l.file(), l.line())) + .unwrap_or_default(); + + eprintln!("\n🦀 Claw TUI crashed{location}: {payload}"); + eprintln!("Terminal has been restored to normal mode.\n"); + })); + + prev_hook +} + +/// Restore the previous panic hook on clean exit. +pub fn restore_panic_hook(hook: Box) + Send + Sync + 'static>) { + panic::set_hook(hook); +} +``` + +Usage in `TuiRepl::run()`: +```rust +pub fn run(cli: &mut LiveCli) -> Result<(), Box> { + let prev_hook = install_panic_hook(); + + // ... TUI event loop ... + + restore_panic_hook(prev_hook); + app.restore_terminal()?; + Ok(()) +} +``` + +### Acceptance Criteria +- [ ] Panic hook installed at TUI startup +- [ ] Previous hook restored on clean exit +- [ ] If TUI panics: terminal is restored, panic message printed, terminal usable +- [ ] No double-panic (hook itself doesn't panic) +- [ ] `disable_raw_mode()` errors are silently ignored (best-effort) + +### Tests +```rust +#[test] +fn test_panic_hook_installs_and_restores() { + // Verify hook can be installed and restored without panic + let prev = install_panic_hook(); + restore_panic_hook(prev); +} +``` + +Manual test: induce a panic in TUI code (e.g., `panic!("test")`), verify terminal is usable after. + +--- + +## S0-3: Add terminal resize handling + +**Priority:** P1 — High +**Assignee:** — +**Estimate:** 0.5 day + +### Description +When the terminal is resized, the TUI must re-render with the new dimensions. Currently, resize events are ignored, causing layout corruption. + +### Implementation + +**File:** `src/tui_repl.rs` — in the event loop: + +```rust +Event::Resize(width, height) => { + // ratatui::Terminal automatically picks up the new size on next + // draw call via `f.area()`. We just need to force a redraw and + // re-wrap the conversation. + app.mark_resize(width, height); +} +``` + +**File:** `src/tui.rs` — add to `TuiApp`: + +```rust +pub fn mark_resize(&mut self, _width: u16, _height: u16) { + // Clear cached wrapped lines so they get re-wrapped at new width + self.invalidate_render_cache(); + self.needs_redraw = true; +} +``` + +### Acceptance Criteria +- [ ] Resizing terminal triggers re-render +- [ ] Word-wrapping adjusts to new width +- [ ] Dashboard panel stays correctly sized +- [ ] Input area resizes appropriately +- [ ] No layout corruption after resize + +### Tests +Manual: launch TUI, resize terminal repeatedly, verify layout adapts. + +--- + +## S0-4: Add `unicode-width` dependency + +**Priority:** P1 — High +**Assignee:** — +**Estimate:** 0.1 day + +### Description +Add the `unicode-width` crate to `Cargo.toml` for CJK-aware character width calculations. Needed by Sprint 7's word-wrapping fix, but adding now avoids a dependency change mid-sprint. + +### Implementation + +**File:** `rust/crates/rusty-claude-cli/Cargo.toml` + +```toml +[dependencies] +# ... existing deps ... +unicode-width = "0.2" +``` + +### Acceptance Criteria +- [ ] `cargo build` succeeds with new dependency +- [ ] `unicode_width::UnicodeWidthChar` available in the crate + +--- + +## S0-5: Define shared TUI error type + +**Priority:** P2 — Medium +**Assignee:** — +**Estimate:** 0.25 day + +### Description +Currently, TUI functions return `Box`. Define a proper error type for better error handling across modules. + +### Implementation + +**New file:** `src/tui_error.rs` + +```rust +use std::fmt; + +#[derive(Debug)] +pub enum TuiError { + Io(std::io::Error), + Terminal(String), + Runtime(String), + Config(String), +} + +impl fmt::Display for TuiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TuiError::Io(e) => write!(f, "TUI I/O error: {e}"), + TuiError::Terminal(msg) => write!(f, "Terminal error: {msg}"), + TuiError::Runtime(msg) => write!(f, "Runtime error: {msg}"), + TuiError::Config(msg) => write!(f, "Config error: {msg}"), + } + } +} + +impl std::error::Error for TuiError {} + +impl From for TuiError { + fn from(e: std::io::Error) -> Self { + TuiError::Io(e) + } +} +``` + +### Acceptance Criteria +- [ ] `TuiError` enum compiles +- [ ] `From` conversion works +- [ ] `Display` produces readable messages +- [ ] All new TUI modules use `TuiError` instead of `Box` + +--- + +## Sprint 0 Definition of Done + +- [ ] All 5 stories completed +- [ ] TUI code extracted from `main.rs` into dedicated modules +- [ ] Panic hook restores terminal on crash +- [ ] Terminal resize handled gracefully +- [ ] `unicode-width` in Cargo.toml +- [ ] `cargo build --release` succeeds +- [ ] `cargo test -p rusty-claude-cli` passes +- [ ] `cargo clippy -p rusty-claude-cli` has no new warnings +- [ ] `/tui` command works identically to before extraction +- [ ] `main.rs` line count reduced (target: -200 lines) diff --git a/.sprints/sprint-1-foundation.md b/.sprints/sprint-1-foundation.md new file mode 100644 index 00000000..99a02ef2 --- /dev/null +++ b/.sprints/sprint-1-foundation.md @@ -0,0 +1,486 @@ +# Sprint 1: Foundation & Bug Fixes + +> **Duration:** 4 days | **Stories:** 7 | **Goal:** Make TUI reliable for daily use +> **Depends on:** Sprint 0 (extraction must be complete) + +--- + +## S1-1: Fix ProviderSwap terminal breakage + +**Priority:** P0 — Critical +**Estimate:** 0.5 day + +### Description +After `Ctrl+P` triggers ProviderSwap, the setup wizard runs but the terminal never returns to TUI mode. The user is stuck in a broken plain terminal. + +### Root Cause +`src/main.rs` (now `src/tui_repl.rs` after S0) calls `app.restore_terminal()` (leaves alt screen), runs wizard, then calls `app.suspend()` which disables raw mode but never re-enters the TUI. + +### Implementation + +**File:** `src/tui_repl.rs` + +```rust +TuiReadOutcome::ProviderSwap => { + // Stay in alternate screen, just disable raw mode for the wizard + app.suspend()?; + + setup_wizard::run_setup_wizard()?; + + // Reload config and update model + let cwd = std::env::current_dir().unwrap_or_default(); + if let Ok(config) = runtime::ConfigLoader::default_for(&cwd).load() { + if let Some(new_model) = config.provider().model().map(str::to_string) { + let _ = cli.set_model(Some(new_model)); + } + } + + // Re-enter TUI + app.resume()?; + app.push_system_message("Provider updated"); +} +``` + +**Note:** `suspend()` stays in alternate screen (just disables raw mode). `resume()` re-enables raw mode and redraws. The wizard runs on the alternate screen with echoing stdin, which is fine for text prompts. If the wizard needs a full normal terminal, use `app.restore_terminal()` → wizard → `TuiApp::init()` to fully reinitialize. Test both approaches. + +### Acceptance Criteria +- [ ] `Ctrl+P` runs setup wizard +- [ ] After wizard completes, TUI resumes automatically +- [ ] Provider/model updated in dashboard +- [ ] No terminal corruption after repeated ProviderSwap cycles (test 5x) + +--- + +## S1-2: Fix status message race condition + +**Priority:** P0 — Critical +**Estimate:** 0.1 day + +### Description +"Done" status message is set and immediately cleared in the same event loop tick, so it's never visible. + +### Root Cause +```rust +app.set_status("Done"); +if let Ok(mut ds) = dashboard_state.write() { + ds.status_message.clear(); // immediately clears — never rendered +} +``` + +### Implementation +Remove the `ds.status_message.clear()` block. Status will be naturally overwritten when the next turn starts with `set_status("Thinking...")`. + +### Acceptance Criteria +- [ ] "✓ Done" visible in dashboard for at least 1 second after turn completes +- [ ] Next turn's "Thinking..." overwrites it + +--- + +## S1-3: Deduplicate ANSI strippers + +**Priority:** P1 — High +**Estimate:** 0.5 day + +### Description +Two independent ANSI escape strippers: +- `strip_ansi_escapes()` in `src/tui.rs` +- `strip_ansi()` in `src/main.rs` (now `src/tui_update.rs` after S0) + +Neither handles OSC sequences (`ESC ]...BEL` or `ESC ]...ST`). + +### Implementation + +**File:** `src/tui_update.rs` (after S0 extraction) + +Keep one canonical implementation, exported as `pub`: + +```rust +/// Strip ANSI escape sequences from text. +/// Handles CSI (ESC [), OSC (ESC ]), and single-char escapes. +pub fn strip_ansi(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + match chars.peek() { + Some('[') => { + // CSI sequence: ESC [ ... final_char + chars.next(); // consume '[' + while let Some(&c) = chars.peek() { + chars.next(); + if c.is_ascii_alphabetic() || c == '~' { + break; // final character + } + } + } + Some(']') => { + // OSC sequence: ESC ] ... (BEL | ESC \) + chars.next(); // consume ']' + while let Some(&c) = chars.peek() { + chars.next(); + if c == '\x07' { + break; // BEL terminator + } + if c == '\x1b' { + // Could be ESC \ (ST) + if chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + } + Some('P') | Some('_') | Some('^') => { + // DCS, APC, PM — terminated by ESC \ + chars.next(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + _ => { + // Single-char escape (e.g., ESC c for reset) + chars.next(); + } + } + } else { + result.push(ch); + } + } + result +} +``` + +Delete `strip_ansi_escapes()` from `tui.rs`. Update all callers. + +### Tests +```rust +#[test] +fn test_strip_basic_csi() { + assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red"); +} + +#[test] +fn test_strip_osc_hyperlink() { + assert_eq!( + strip_ansi("\x1b]8;;http://example.com\x07link\x1b]8;;\x07"), + "link" + ); +} + +#[test] +fn test_strip_osc_with_st_terminator() { + assert_eq!( + strip_ansi("\x1b]8;;http://example.com\x1b\\link\x1b]8;;\x1b\\"), + "link" + ); +} + +#[test] +fn test_strip_nested_csi() { + assert_eq!(strip_ansi("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red"); +} + +#[test] +fn test_strip_256_color() { + assert_eq!(strip_ansi("\x1b[38;5;196mred256\x1b[0m"), "red256"); +} + +#[test] +fn test_strip_rgb_color() { + assert_eq!(strip_ansi("\x1b[38;2;255;0;0mrgb\x1b[0m"), "rgb"); +} + +#[test] +fn test_strip_no_escapes() { + assert_eq!(strip_ansi("plain text"), "plain text"); +} + +#[test] +fn test_strip_cursor_movement() { + assert_eq!(strip_ansi("\x1b[2J\x1b[Hclear"), "clear"); +} + +#[test] +fn test_strip_dcs_sequence() { + assert_eq!(strip_ansi("\x1bPq$d\x1b\\"), ""); +} + +#[test] +fn test_strip_empty_string() { + assert_eq!(strip_ansi(""), ""); +} + +#[test] +fn test_strip_malformed_no_final_char() { + // Unterminated CSI — should still consume until end + assert_eq!(strip_ansi("\x1b[31m"), ""); +} +``` + +### Acceptance Criteria +- [ ] Single ANSI stripper implementation in `tui_update.rs` +- [ ] Handles CSI, OSC (BEL + ST), DCS, APC, PM sequences +- [ ] All 11 test cases pass +- [ ] No ANSI artifacts visible in TUI conversation pane + +--- + +## S1-4: Bound conversation memory + +**Priority:** P1 — High +**Estimate:** 0.5 day + +### Description +`Vec` grows unbounded. Long sessions consume increasing memory and slow `build_wrapped_conversation()`. + +### Implementation + +**File:** `src/tui.rs` + +```rust +const MAX_CONVERSATION_LINES: usize = 10_000; + +fn auto_scroll(&mut self) { + if self.conversation.len() > MAX_CONVERSATION_LINES { + let drain_count = self.conversation.len() - MAX_CONVERSATION_LINES; + self.conversation.drain(..drain_count); + // Also drain cached render results for removed entries + self.render_cache.drain(..drain_count); + + // Insert trim notice as first line + self.conversation.insert(0, ConversationLine { + content: ConversationContent::Plain { + text: "... (earlier messages trimmed)".to_string(), + color: Color::DarkGray, + bold: false, + }, + }); + } + self.conversation_scroll = 0; + self.needs_redraw = true; +} +``` + +### Acceptance Criteria +- [ ] Conversation never exceeds MAX_CONVERSATION_LINES +- [ ] Oldest messages trimmed first +- [ ] Trim notice appears as first line +- [ ] Render cache also trimmed (prevents desync) +- [ ] Memory stays flat in long sessions + +### Test +```rust +#[test] +fn test_conversation_memory_bound() { + let mut app = create_test_app(); + for i in 0..12_000 { + app.push_output(&format!("line {i}"), false); + } + assert!(app.conversation_len() <= MAX_CONVERSATION_LINES); + // First line should be trim notice + assert!(app.conversation_text(0).contains("trimmed")); +} +``` + +--- + +## S1-5: Wire real token tracking + +**Priority:** P1 — High +**Estimate:** 1 day + +### Description +Dashboard shows `output_tokens: 0`, `cost_usd: 0.0`, `cache_read_tokens: 0` because `update_dashboard()` uses a crude char÷4 estimate. The runtime tracks real values. + +### Implementation + +**File:** `src/tui_update.rs` + +The runtime's `TokenUsage` struct (from `runtime/src/usage.rs`): +```rust +// ACTUAL runtime struct — use these field names: +pub struct TokenUsage { + pub input_tokens: u32, + pub output_tokens: u32, + pub cache_creation_input_tokens: u32, // NOT "cache_creation_tokens" + pub cache_read_input_tokens: u32, // NOT "cache_read_tokens" +} +``` + +Cost is computed separately via `UsageCostEstimate::total_cost_usd()`. + +Update `update_dashboard()`: +```rust +pub fn update_dashboard(state: &SharedDashboardState, cli: &LiveCli) { + if let Ok(mut ds) = state.write() { + ds.model = cli.model.clone(); + ds.permission_mode = format!("{:?}", cli.permission_mode); + + if let Some(rt) = cli.runtime.runtime.as_ref() { + let session = rt.session(); + ds.session_id = Some(session.session_id.clone()); + ds.turn_count = session.messages.len() as u32; + + // Use REAL token counts from runtime + if let Some(usage) = session.total_usage() { + ds.input_tokens = usage.input_tokens; + ds.output_tokens = usage.output_tokens; + ds.cache_creation_tokens = usage.cache_creation_input_tokens; + ds.cache_read_tokens = usage.cache_read_input_tokens; + } + + // Compute cost using runtime's cost estimator + if let Some(cost) = session.estimate_cost() { + ds.cost_usd = cost.total_cost_usd(); + } + } + } +} +``` + +**Note:** Verify the actual method names on the runtime session object (`total_usage()`, `estimate_cost()`, etc.) by reading `runtime/src/`. Adjust to match the real API. + +### Acceptance Criteria +- [ ] Dashboard shows real input/output token counts after each turn +- [ ] Cache read and cache creation tokens displayed +- [ ] Cost estimate shows non-zero value for paid models +- [ ] Field names match runtime's `TokenUsage` struct exactly + +### Test +Manual: send a message, verify dashboard numbers are non-zero and match API response logs. + +--- + +## S1-6: Wire terminal resize to conversation re-wrap + +**Priority:** P1 — High +**Estimate:** 0.25 day + +### Description +S0-3 handles resize events. This story ensures the conversation rendering cache is invalidated so word-wrapping adjusts to the new width. + +### Implementation + +**File:** `src/tui.rs` + +```rust +pub fn mark_resize(&mut self, _width: u16, _height: u16) { + // Invalidate cached wrapped lines — they'll be re-wrapped at new width + self.invalidate_render_cache(); + self.needs_redraw = true; +} + +fn invalidate_render_cache(&mut self) { + // Clear all cached Vec — next frame re-renders everything + // This is fine: resize is infrequent and a single re-render is fast + self.render_cache.clear(); +} +``` + +### Acceptance Criteria +- [ ] Terminal resize triggers full re-render +- [ ] Word-wrapping adjusts to new width +- [ ] No layout corruption + +--- + +## S1-7: Write TUI unit tests + +**Priority:** P1 — High +**Estimate:** 1 day + +### Description +Zero test coverage for TUI logic. The rendering code doesn't need terminal tests, but the data transformations do. + +### Implementation + +**New file:** `tests/tui_unit_tests.rs` + +```rust +// 1. ANSI stripping — see S1-3 for full test list + +// 2. Word wrapping +#[test] +fn test_wrap_empty() { + assert_eq!(wrap_line("", 80), vec![""]); +} + +#[test] +fn test_wrap_exact_width() { + assert_eq!(wrap_line("hello", 5), vec!["hello"]); +} + +#[test] +fn test_wrap_overflow_with_break() { + let wrapped = wrap_line("hello world foo", 10); + assert!(wrapped.len() >= 2); + assert!(wrapped[0].len() <= 10); +} + +#[test] +fn test_wrap_hyphen_break() { + let wrapped = wrap_line("well-known-author", 10); + assert!(wrapped.len() >= 2); +} + +#[test] +fn test_wrap_no_break_point() { + // Hard break mid-word when no space/hyphen available + let wrapped = wrap_line("abcdefghijklmnop", 5); + assert!(wrapped.len() >= 3); +} + +// 3. Dashboard state +#[test] +fn test_dashboard_default_values() { + let state = DashboardState::default(); + assert_eq!(state.input_tokens, 0); + assert_eq!(state.output_tokens, 0); + assert_eq!(state.cost_usd, 0.0); +} + +// 4. Conversation management +#[test] +fn test_push_user_input() { + let mut app = create_test_app(); + app.push_user_input("hello"); + assert!(app.conversation_len() >= 1); +} + +#[test] +fn test_push_output_strips_ansi() { + let mut app = create_test_app(); + app.push_output("\x1b[31mred\x1b[0m", false); + // Should not contain escape characters + let text = app.conversation_text(app.conversation_len() - 1); + assert!(!text.contains('\x1b')); +} + +#[test] +fn test_auto_scroll_resets_offset() { + let mut app = create_test_app(); + app.conversation_scroll = 100; + app.push_user_input("new message"); + assert_eq!(app.conversation_scroll, 0); +} +``` + +### Acceptance Criteria +- [ ] All tests pass with `cargo test -p rusty-claude-cli` +- [ ] Tests cover: ANSI stripping, word wrapping, dashboard state, conversation management +- [ ] No terminal or ratatui dependencies in tests (pure data logic only) + +--- + +## Sprint 1 Definition of Done + +- [ ] All 7 stories completed and tested +- [ ] `cargo test -p rusty-claude-cli` passes +- [ ] `cargo clippy -p rusty-claude-cli` has no new warnings +- [ ] Manual smoke test: launch TUI, send 3 messages, Ctrl+P provider swap, verify dashboard updates, resize terminal +- [ ] Commit: `fix(tui): sprint 1 — foundation and bug fixes` diff --git a/.sprints/sprint-2-markdown.md b/.sprints/sprint-2-markdown.md new file mode 100644 index 00000000..9c12d603 --- /dev/null +++ b/.sprints/sprint-2-markdown.md @@ -0,0 +1,883 @@ +# Sprint 2: Streaming & Markdown Rendering + +> **Duration:** 5 days | **Stories:** 6 | **Goal:** Real-time response display + rich markdown rendering +> **Depends on:** Sprint 0 (extraction), Sprint 1 (bug fixes) + +--- + +## Why This Sprint Is 5 Days (Not 3) + +Opus review correctly identified: +1. **Streaming rendering is missing entirely** — table stakes for any TUI competitor +2. **Syntect API errors** — `HighlightLines::new()` returns `Result`, code won't compile +3. **Syntect initialization is expensive** (10-50ms) — needs lazy loading +4. **Markdown rendering on every frame is too slow** — needs per-entry caching +5. **`looks_like_markdown()` heuristics** need careful handling + +--- + +## S2-1: Add streaming response rendering + +**Priority:** P0 — Critical +**Estimate:** 1.5 days + +### Description +Currently, assistant responses are captured into a `Vec` buffer and rendered only after the turn completes. All competitors (Claude Code, OpenCode, Codex CLI) stream responses in real-time. The user should see tokens appear as they're generated. + +### Implementation + +**File:** `src/tui.rs` — add streaming state to `TuiApp`: + +```rust +pub struct TuiApp { + // ... existing fields + + /// Streaming state for current assistant response + stream_buffer: String, + stream_is_active: bool, + stream_last_update: std::time::Instant, +} + +impl TuiApp { + /// Called by the streaming callback as new tokens arrive. + /// Appends to the streaming buffer and triggers a redraw. + pub fn push_stream_token(&mut self, token: &str) { + self.stream_buffer.push_str(token); + self.stream_is_active = true; + self.stream_last_update = std::time::Instant::now(); + self.needs_redraw = true; + } + + /// Called when the assistant's response is complete. + /// Moves stream buffer into permanent conversation and resets. + pub fn finish_stream(&mut self) { + if !self.stream_buffer.is_empty() { + // Check if the complete response looks like markdown + if looks_like_markdown(&self.stream_buffer) { + self.conversation.push(ConversationLine { + content: ConversationContent::Markdown { + source: self.stream_buffer.clone(), + }, + rendered_cache: None, // Will be rendered on next frame + }); + } else { + for line in self.stream_buffer.lines() { + self.conversation.push(ConversationLine { + content: ConversationContent::Plain { + text: line.to_string(), + color: Color::White, + bold: false, + }, + rendered_cache: None, + }); + } + } + } + self.stream_buffer.clear(); + self.stream_is_active = false; + self.auto_scroll(); + } +} +``` + +**File:** `src/tui_repl.rs` — modify turn execution: + +```rust +// In the turn execution loop, instead of buffering to Vec: +let stream_state = app.stream_state_ref(); +let result = cli.run_turn_streaming(&trimmed, |token: &str| { + // Called by the runtime as each token arrives + stream_state.push_stream_token(token); +}); +app.finish_stream(); +``` + +**File:** `src/tui.rs` — render streaming content in conversation pane: + +```rust +fn build_conversation_lines(&self, width: u16) -> Vec> { + let mut all_lines = Vec::new(); + + // Render completed conversation entries (cached) + for entry in &self.conversation { + if let Some(cached) = &entry.rendered_cache { + all_lines.extend(cached.iter().cloned()); + } else { + let rendered = self.render_entry(entry, width); + all_lines.extend(rendered.iter().cloned()); + } + } + + // Render in-progress streaming content + if self.stream_is_active && !self.stream_buffer.is_empty() { + // Simple rendering for streaming — full markdown render happens on finish + for line in self.stream_buffer.lines() { + all_lines.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(Color::White), + ))); + } + // Show cursor at end of stream + all_lines.push(Line::from(Span::styled( + "▊", + Style::default().fg(Color::Cyan), + ))); + } + + all_lines +} +``` + +### Architecture Note + +The streaming callback approach requires the runtime to support streaming. If the runtime doesn't have a streaming callback API, implement it as: +1. Background thread runs the turn +2. Token sender channel sends tokens to TUI thread +3. TUI polls the channel in its 16ms event loop + +```rust +// Channel-based streaming if callback API isn't available: +let (tx, rx) = std::sync::mpsc::channel::(); + +std::thread::spawn(move || { + // Run turn, send tokens to channel + cli.run_turn_with_callback(&input, |token| { + let _ = tx.send(token.to_string()); + }); +}); + +// In TUI event loop: +while let Ok(token) = rx.try_recv() { + app.push_stream_token(&token); +} +``` + +### Acceptance Criteria +- [ ] Assistant tokens appear in conversation as they arrive +- [ ] Blinking cursor shows at end of streaming content +- [ ] Markdown rendering happens only after stream completes (not per-token) +- [ ] Streaming doesn't block TUI event loop (UI stays responsive) +- [ ] Fast streams (e.g., Haiku) don't flicker +- [ ] Slow streams (e.g., Opus thinking) show smooth character-by-character display + +--- + +## S2-2: Create ConversationContent enum with render cache + +**Priority:** P1 — High +**Estimate:** 0.25 day + +### Description +Replace flat `ConversationLine` with enum + per-entry render cache. This enables markdown rendering without per-frame re-parsing. + +### Implementation + +**File:** `src/tui.rs` + +```rust +#[derive(Debug, Clone)] +pub enum ConversationContent { + Plain { + text: String, + color: Color, + bold: bool, + }, + Markdown { + source: String, + }, + CodeDiff { + diff: String, + }, +} + +#[derive(Debug, Clone)] +pub struct ConversationLine { + pub content: ConversationContent, + /// Cached rendered output. None = needs rendering. + /// Stored as Vec to avoid re-parsing markdown every frame. + pub rendered_cache: Option>>, +} + +impl ConversationLine { + pub fn plain(text: String, color: Color, bold: bool) -> Self { + Self { + content: ConversationContent::Plain { text, color, bold }, + rendered_cache: None, + } + } + + pub fn markdown(source: String) -> Self { + Self { + content: ConversationContent::Markdown { source }, + rendered_cache: None, + } + } + + pub fn diff(diff: String) -> Self { + Self { + content: ConversationContent::CodeDiff { diff }, + rendered_cache: None, + } + } +} +``` + +### Acceptance Criteria +- [ ] `ConversationContent` enum compiles +- [ ] `rendered_cache` field exists on `ConversationLine` +- [ ] All existing push methods migrated to use enum +- [ ] Constructor methods (`plain`, `markdown`, `diff`) work + +--- + +## S2-3: Create MarkdownRenderer with lazy syntect init + +**Priority:** P1 — High +**Estimate:** 1 day + +### Description +Create the markdown renderer. **Fix the syntect compilation errors** identified by Opus. Use `once_cell::Lazy` for expensive syntax/theme set loading. + +### Dependencies + +Add to `Cargo.toml`: +```toml +once_cell = "1" +``` + +### Implementation + +**New file:** `src/markdown.rs` + +```rust +use once_cell::sync::Lazy; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use syntect::highlighting::ThemeSet; +use syntect::parsing::SyntaxSet; + +// Lazy-loaded — initialized once on first use (~10-50ms), then cached for process lifetime +static SYNTAX_SET: Lazy = Lazy::new(|| SyntaxSet::load_defaults_newlines()); +static THEME_SET: Lazy = Lazy::new(|| ThemeSet::load_defaults()); + +pub struct MarkdownRenderer { + code_theme_name: String, +} + +impl MarkdownRenderer { + pub fn new() -> Self { + Self { + code_theme_name: "base16-ocean.dark".to_string(), + } + } + + pub fn set_code_theme(&mut self, name: &str) { + self.code_theme_name = name.to_string(); + } + + pub fn render(&self, markdown: &str, width: u16) -> Vec> { + let parser = Parser::new(markdown); + let events: Vec = parser.collect(); + self.render_events(&events, width) + } + + fn render_events(&self, events: &[Event], width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut style_stack: Vec