docs(tui): add sprint plans and agent guardrails for TUI parity

7 sprints (S0-S7), 44 stories, 29-day plan to achieve feature parity
with Claude Code, OpenCode, Codex CLI, and Aider terminal interfaces.
Includes Opus code review fixes and Four Laws guardrails integration.

Authored by TheArchitectit
This commit is contained in:
Claude 2026-06-11 18:29:20 -05:00
parent 58e095a0c0
commit a347a2f0c5
10 changed files with 3922 additions and 0 deletions

1
.guardrails Submodule

@ -0,0 +1 @@
Subproject commit 139118958cba9c083bf78c29f3efdc0034b24d14

View File

@ -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)
```
<type>(tui): <story description>
<why this change>
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 -- <file>`
### Pre-Execution Checklist (Every File Edit)
```
[ ] File read before editing
[ ] File is IN scope for this story
[ ] Rollback command known (git checkout HEAD -- <file>)
[ ] 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<Line>` 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)

View File

@ -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<dyn std::error::Error>> {
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<String> = 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 <name>
Keys(String), // /keys <preset>
Code, // /code
Ask, // /ask
Architect, // /architect
Diff { staged: bool },// /diff [--staged]
Undo { confirm: bool },// /undo [--confirm]
Ls { path: Option<String> }, // /ls [path]
Context, // /context
Help, // /help
}
impl TuiCommand {
pub fn parse(input: &str) -> Option<Self> { ... }
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<dyn std::error::Error>> {
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<dyn Fn(&panic::PanicInfo<'_>) + 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::<String>() {
s.clone()
} else {
"Box<dyn Any>".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<dyn Fn(&panic::PanicInfo<'_>) + Send + Sync + 'static>) {
panic::set_hook(hook);
}
```
Usage in `TuiRepl::run()`:
```rust
pub fn run(cli: &mut LiveCli) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>`. 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<std::io::Error> for TuiError {
fn from(e: std::io::Error) -> Self {
TuiError::Io(e)
}
}
```
### Acceptance Criteria
- [ ] `TuiError` enum compiles
- [ ] `From<std::io::Error>` conversion works
- [ ] `Display` produces readable messages
- [ ] All new TUI modules use `TuiError` instead of `Box<dyn Error>`
---
## 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)

View File

@ -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<ConversationLine>` 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<Line> — 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`

View File

@ -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<u8>` 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<u8>:
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<Line<'static>> {
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::<String>();
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<Line> to avoid re-parsing markdown every frame.
pub rendered_cache: Option<Vec<Line<'static>>>,
}
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<SyntaxSet> = Lazy::new(|| SyntaxSet::load_defaults_newlines());
static THEME_SET: Lazy<ThemeSet> = 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<Line<'static>> {
let parser = Parser::new(markdown);
let events: Vec<Event> = parser.collect();
self.render_events(&events, width)
}
fn render_events(&self, events: &[Event], width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut style_stack: Vec<Style> = Vec::new();
let mut in_code_block = false;
let mut code_block_lang: Option<String> = None;
let mut code_block_content = String::new();
let mut list_depth: usize = 0;
for event in events {
match event {
Event::Start(tag) => {
match tag {
Tag::Heading { .. } => {
flush_line(&mut lines, &mut current_spans);
style_stack.push(Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD));
}
Tag::Paragraph => {
style_stack.push(Style::default());
}
Tag::CodeBlock(kind) => {
flush_line(&mut lines, &mut current_spans);
in_code_block = true;
code_block_content.clear();
code_block_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => {
let lang_str = lang.to_string();
if lang_str.is_empty() { None } else { Some(lang_str) }
}
_ => None,
};
}
Tag::List(_) => {
list_depth += 1;
}
Tag::Item => {
let indent = " ".repeat(list_depth.saturating_sub(1));
current_spans.push(Span::raw(format!("{indent}• ")));
}
Tag::BlockQuote => {
current_spans.push(Span::styled(
"│ ",
Style::default().fg(Color::DarkGray),
));
style_stack.push(Style::default().fg(Color::DarkGray));
}
Tag::Emphasis => {
style_stack.push(Style::default().add_modifier(Modifier::ITALIC));
}
Tag::Strong => {
style_stack.push(Style::default().add_modifier(Modifier::BOLD));
}
Tag::Link { .. } => {
style_stack.push(Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED));
}
_ => {}
}
}
Event::End(tag_end) => {
match tag_end {
TagEnd::Heading(_) => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(""));
style_stack.pop();
}
TagEnd::Paragraph => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(""));
style_stack.pop();
}
TagEnd::CodeBlock => {
let code_lines = self.render_code_block(
&code_block_content,
code_block_lang.as_deref(),
);
lines.extend(code_lines);
in_code_block = false;
code_block_lang = None;
}
TagEnd::List(_) => {
list_depth = list_depth.saturating_sub(1);
}
TagEnd::Item => {
flush_line(&mut lines, &mut current_spans);
}
TagEnd::BlockQuote => {
flush_line(&mut lines, &mut current_spans);
style_stack.pop();
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link => {
style_stack.pop();
}
_ => {}
}
}
Event::Text(text) => {
if in_code_block {
code_block_content.push_str(&text);
} else {
let style = style_stack.last().copied().unwrap_or_default();
current_spans.push(Span::styled(text.to_string(), style));
}
}
Event::Code(code) => {
let style = Style::default()
.fg(Color::Yellow)
.bg(Color::DarkGray);
current_spans.push(Span::styled(
format!(" {code} "),
style,
));
}
Event::SoftBreak | Event::HardBreak => {
if in_code_block {
code_block_content.push('\n');
} else {
flush_line(&mut lines, &mut current_spans);
}
}
Event::Rule => {
flush_line(&mut lines, &mut current_spans);
lines.push(Line::from(Span::styled(
"─".repeat(width as usize),
Style::default().fg(Color::DarkGray),
)));
}
_ => {}
}
}
flush_line(&mut lines, &mut current_spans);
lines
}
fn render_code_block(&self, code: &str, language: Option<&str>) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let syntax = language
.and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
// FIX: ThemeSet::load_defaults() may not contain our theme name.
// Use .get() with fallback instead of [] indexing (which panics).
let theme = THEME_SET.themes
.get(&self.code_theme_name)
.or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
.unwrap_or_else(|| THEME_SET.themes.values().next().expect("no themes available"));
// Top border
let lang_label = language.unwrap_or("text");
lines.push(Line::from(Span::styled(
format!("╭─ {lang_label} ─"),
Style::default().fg(Color::DarkGray),
)));
// FIX: syntect 5.x HighlightLines::new() returns Result, not direct value.
let mut highlighter = match syntect::easy::HighlightLines::new(syntax, theme) {
Ok(h) => h,
Err(_) => {
// Fallback: render without highlighting
for line in code.lines() {
lines.push(Line::from(vec![
Span::styled("│ ", Style::default().fg(Color::DarkGray)),
Span::raw(line.to_string()),
]));
}
lines.push(Line::from(Span::styled("╰─", Style::default().fg(Color::DarkGray))));
return lines;
}
};
for line in code.lines() {
match highlighter.highlight_line(line, &SYNTAX_SET) {
Ok(ranges) => {
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(style, text)| {
let fg = style.foreground;
Span::styled(
text.to_string(),
Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)),
)
})
.collect();
let mut line_spans = vec![
Span::styled("│ ", Style::default().fg(Color::DarkGray)),
];
line_spans.extend(spans);
lines.push(Line::from(line_spans));
}
Err(_) => {
lines.push(Line::from(vec![
Span::styled("│ ", Style::default().fg(Color::DarkGray)),
Span::raw(line.to_string()),
]));
}
}
}
lines.push(Line::from(Span::styled(
"╰─",
Style::default().fg(Color::DarkGray),
)));
lines
}
}
/// Flush accumulated spans into a new line.
fn flush_line(lines: &mut Vec<Line<'static>>, spans: &mut Vec<Span<'static>>) {
if !spans.is_empty() {
lines.push(Line::from(spans.clone()));
spans.clear();
}
}
/// Heuristic: does this text look like markdown?
pub fn looks_like_markdown(text: &str) -> bool {
let lines: Vec<&str> = text.lines().collect();
let has_header = lines.iter().any(|l| l.starts_with('#'));
let has_code_block = text.contains("```");
let has_list = lines.iter().any(|l| l.starts_with("- ") || l.starts_with("* "));
let has_bold = text.contains("**");
let has_inline_code = text.matches('`').count() >= 2; // At least one pair
let multi_line = lines.len() > 3;
has_code_block
|| (has_header && multi_line)
|| (has_list && multi_line)
|| (has_bold && has_inline_code)
}
```
### Syntect Fixes Applied
1. **`HighlightLines::new()` returns `Result`** — now handled with `match` and fallback to plain text
2. **Theme name may not exist**`.get()` with fallback chain instead of `[]` indexing
3. **Lazy initialization**`SYNTAX_SET` and `THEME_SET` loaded once via `once_cell::Lazy`, not per-call
### Acceptance Criteria
- [ ] `MarkdownRenderer::new()` initializes without panic
- [ ] `render()` returns `Vec<Line>` for basic markdown input
- [ ] Headers, code blocks, inline code, bold, lists, blockquotes, rules render correctly
- [ ] Syntect compilation succeeds (no `Result` type mismatch)
- [ ] Theme fallback works when requested theme doesn't exist
- [ ] `looks_like_markdown()` has reasonable precision
---
## S2-4: Integrate markdown rendering with caching
**Priority:** P1 — High
**Estimate:** 1 day
### Description
Wire the markdown renderer into the conversation pane. **Cache rendered output per entry** so we don't re-parse markdown every frame.
### Implementation
**File:** `src/tui.rs`
1. Add renderer to `TuiApp`:
```rust
pub struct TuiApp {
// ... existing fields
markdown_renderer: MarkdownRenderer,
}
```
2. Add rendering method that populates cache:
```rust
fn ensure_rendered(&self, entry: &mut ConversationLine, width: u16) {
if entry.rendered_cache.is_some() {
return; // Already cached
}
let rendered = match &entry.content {
ConversationContent::Plain { text, color, bold } => {
let wrapped = wrap_line(text, width as usize);
let style = if *bold {
Style::default().fg(*color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(*color)
};
wrapped.into_iter()
.map(|line| Line::from(Span::styled(line, style)))
.collect()
}
ConversationContent::Markdown { source } => {
self.markdown_renderer.render(source, width)
}
ConversationContent::CodeDiff { diff } => {
render_diff(diff)
}
};
// Note: we can't mutate through &self. See approach below.
}
```
3. **Cache invalidation strategy:**
- Invalidate all caches on terminal resize
- Invalidate specific entry when it's first rendered
- Never invalidate during normal scrolling (re-render is cheap with cache hit)
```rust
fn build_conversation_lines(&mut self, width: u16) -> Vec<Line<'static>> {
let mut all_lines = Vec::new();
for entry in &mut self.conversation {
if entry.rendered_cache.is_none() {
let rendered = match &entry.content {
ConversationContent::Plain { text, color, bold } => {
render_plain(text, *color, *bold, width)
}
ConversationContent::Markdown { source } => {
self.markdown_renderer.render(source, width)
}
ConversationContent::CodeDiff { diff } => {
render_diff(diff)
}
};
entry.rendered_cache = Some(rendered);
}
if let Some(cached) = &entry.rendered_cache {
all_lines.extend(cached.iter().cloned());
}
}
all_lines
}
```
4. `push_output()` routes through `looks_like_markdown()`:
```rust
pub fn push_output(&mut self, text: &str, is_error: bool) {
let clean = strip_ansi(text);
if is_error {
self.conversation.push(ConversationLine::plain(
clean, Color::Red, false,
));
} else if looks_like_markdown(&clean) {
self.conversation.push(ConversationLine::markdown(clean));
} else {
for line in clean.lines() {
self.conversation.push(ConversationLine::plain(
line.to_string(), Color::White, false,
));
}
}
self.auto_scroll();
}
```
### Acceptance Criteria
- [ ] Markdown content rendered once, cached per entry
- [ ] Cache invalidated on resize
- [ ] `looks_like_markdown()` correctly identifies assistant responses
- [ ] Plain text responses still render correctly
- [ ] Error messages still render in red
- [ ] No per-frame markdown re-parsing (verify with timer/logging)
---
## S2-5: Add CodeDiff renderer
**Priority:** P2 — Medium
**Estimate:** 0.25 day
### Description
Render diff content with `+`/`-` coloring.
### Implementation
**File:** `src/markdown.rs`
```rust
pub fn render_diff(diff: &str) -> Vec<Line<'static>> {
diff.lines().map(|raw_line| {
let (text, color) = if raw_line.starts_with("+++") || raw_line.starts_with("---") {
(raw_line.to_string(), Color::White)
} else if raw_line.starts_with("@@") {
(raw_line.to_string(), Color::Cyan)
} else if raw_line.starts_with('+') {
(raw_line.to_string(), Color::Green)
} else if raw_line.starts_with('-') {
(raw_line.to_string(), Color::Red)
} else {
(raw_line.to_string(), Color::DarkGray)
};
Line::from(Span::styled(text, Style::default().fg(color)))
}).collect()
}
```
### Acceptance Criteria
- [ ] Additions in green, deletions in red
- [ ] Hunk headers in cyan, file headers in white
- [ ] Context lines in dark gray
---
## S2-6: Write markdown and streaming tests
**Priority:** P1 — High
**Estimate:** 0.5 day
### Description
Comprehensive test coverage for markdown rendering, streaming, and caching.
### Implementation
**New file:** `tests/markdown_tests.rs`
```rust
use rusty_claude_cli::markdown::{MarkdownRenderer, looks_like_markdown, render_diff};
// --- Markdown rendering tests ---
#[test]
fn test_plain_paragraph() {
let r = MarkdownRenderer::new();
let lines = r.render("Just a paragraph.", 80);
assert_eq!(lines.len(), 2); // text + blank
}
#[test]
fn test_h1_rendering() {
let r = MarkdownRenderer::new();
let lines = r.render("# Hello", 80);
assert!(lines[0].spans.iter().any(|s| s.style.fg == Some(Color::Cyan)));
}
#[test]
fn test_rust_code_block() {
let r = MarkdownRenderer::new();
let lines = r.render("```rust\nfn main() {\n println!(\"hi\");\n}\n```", 80);
assert!(lines.len() >= 6); // top + 4 code lines + bottom
}
#[test]
fn test_python_code_block() {
let r = MarkdownRenderer::new();
let lines = r.render("```python\ndef hello():\n pass\n```", 80);
assert!(lines.len() >= 5);
}
#[test]
fn test_inline_code() {
let r = MarkdownRenderer::new();
let lines = r.render("Use `git status` to check", 80);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_bold_and_italic() {
let r = MarkdownRenderer::new();
let lines = r.render("**bold** and *italic*", 80);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_nested_list() {
let r = MarkdownRenderer::new();
let lines = r.render("- item 1\n- item 2\n - nested", 80);
assert_eq!(lines.len(), 3);
}
#[test]
fn test_blockquote() {
let r = MarkdownRenderer::new();
let lines = r.render("> quoted\n> text", 80);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_horizontal_rule() {
let r = MarkdownRenderer::new();
let lines = r.render("---", 80);
assert_eq!(lines.len(), 1);
}
#[test]
fn test_mixed_content() {
let r = MarkdownRenderer::new();
let md = "# Title\n\nSome text with `code`.\n\n```rust\nfn main() {}\n```\n\n- list item";
let lines = r.render(md, 80);
assert!(lines.len() >= 10);
}
#[test]
fn test_empty_input() {
let r = MarkdownRenderer::new();
let lines = r.render("", 80);
assert!(lines.is_empty());
}
#[test]
fn test_link_rendering() {
let r = MarkdownRenderer::new();
let lines = r.render("[click here](https://example.com)", 80);
assert_eq!(lines.len(), 2);
}
// --- looks_like_markdown tests ---
#[test]
fn test_markdown_detection_code_block() {
assert!(looks_like_markdown("some text\n```rust\ncode\n```\nmore text"));
}
#[test]
fn test_markdown_detection_header() {
assert!(looks_like_markdown("# Title\n\nParagraph one.\n\nParagraph two."));
}
#[test]
fn test_markdown_detection_list() {
assert!(looks_like_markdown("Items:\n\n- one\n- two\n- three"));
}
#[test]
fn test_markdown_detection_plain() {
assert!(!looks_like_markdown("Just a simple response without markdown."));
}
#[test]
fn test_markdown_detection_short() {
assert!(!looks_like_markdown("Short"));
}
#[test]
fn test_markdown_detection_inline_code_only() {
assert!(!looks_like_markdown("Use `foo` and `bar`"));
// Needs bold too for inline-only detection
assert!(looks_like_markdown("Use `foo` and **bar**"));
}
// --- Diff renderer tests ---
#[test]
fn test_diff_additions_green() {
let lines = render_diff("+new line");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Green));
}
#[test]
fn test_diff_deletions_red() {
let lines = render_diff("-old line");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red));
}
#[test]
fn test_diff_hunk_header() {
let lines = render_diff("@@ -1,3 +1,4 @@");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
}
#[test]
fn test_diff_file_header() {
let lines = render_diff("--- a/file.rs\n+++ b/file.rs");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::White));
assert_eq!(lines[1].spans[0].style.fg, Some(Color::White));
}
// --- Syntect safety tests ---
#[test]
fn test_theme_fallback() {
let mut r = MarkdownRenderer::new();
r.set_code_theme("nonexistent_theme_name");
// Should not panic — falls back gracefully
let lines = r.render("```rust\nfn main() {}\n```", 80);
assert!(lines.len() >= 3);
}
```
### Acceptance Criteria
- [ ] All 20+ test cases pass
- [ ] Syntect theme fallback doesn't panic
- [ ] `looks_like_markdown()` has reasonable precision (no false positives on short text)
- [ ] Diff renderer colors correct
---
## Sprint 2 Definition of Done
- [ ] All 6 stories completed
- [ ] Streaming rendering works for assistant responses
- [ ] Markdown rendering with per-entry caching
- [ ] Syntect compilation errors fixed (`Result` handling, theme fallback)
- [ ] Lazy initialization with `once_cell`
- [ ] `cargo test -p rusty-claude-cli` passes
- [ ] `cargo clippy -p rusty-claude-cli` has no new warnings
- [ ] Manual test: send prompt that returns markdown, verify rich rendering appears incrementally

447
.sprints/sprint-3-themes.md Normal file
View File

@ -0,0 +1,447 @@
# Sprint 3: Theme System
> **Duration:** 3 days | **Stories:** 5 | **Goal:** Built-in themes with runtime switching, matching OpenCode's ecosystem
> **Depends on:** Sprint 0, Sprint 1, Sprint 2
---
## S3-1: Create theme module with TuiTheme struct
**Priority:** P1 — High
**Estimate:** 0.5 day
### Description
Define the `TuiTheme` struct with named color roles for every TUI element.
### Implementation
**New file:** `src/theme.rs`
```rust
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TuiTheme {
pub name: String,
// Conversation pane
pub conversation_text: ColorDef,
pub conversation_user: ColorDef,
pub conversation_system: ColorDef,
pub conversation_error: ColorDef,
pub conversation_assistant: ColorDef,
pub conversation_dim: ColorDef,
// Code blocks
pub code_fg: ColorDef,
pub code_bg: ColorDef,
pub code_border: ColorDef,
pub code_language_label: ColorDef,
// Dashboard
pub dashboard_header: ColorDef,
pub dashboard_key: ColorDef,
pub dashboard_value: ColorDef,
pub dashboard_separator: ColorDef,
pub dashboard_keys_hint: ColorDef,
// Context gauge
pub gauge_fill_green: ColorDef,
pub gauge_fill_yellow: ColorDef,
pub gauge_fill_red: ColorDef,
pub gauge_bg: ColorDef,
pub gauge_label: ColorDef,
// Input area
pub input_fg: ColorDef,
pub input_cursor_fg: ColorDef,
pub input_cursor_bg: ColorDef,
pub input_border: ColorDef,
pub input_border_active: ColorDef,
// UI chrome
pub border: ColorDef,
pub border_active: ColorDef,
pub scrollbar: ColorDef,
pub spinner: ColorDef,
pub status_message: ColorDef,
pub key_hint: ColorDef,
// Completions
pub completion_fg: ColorDef,
pub completion_bg: ColorDef,
pub completion_selected_fg: ColorDef,
pub completion_selected_bg: ColorDef,
// Agent View
pub agent_running: ColorDef,
pub agent_waiting: ColorDef,
pub agent_done: ColorDef,
pub agent_failed: ColorDef,
pub agent_cancelled: ColorDef,
// Syntax highlighting theme name
pub syntax_theme: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ColorDef {
Named(String),
Ansi256(u8),
Rgb { r: u8, g: u8, b: u8 },
}
impl ColorDef {
pub fn to_color(&self) -> Color {
match self {
ColorDef::Named(name) => match name.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" | "purple" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
"gray" | "grey" | "darkgray" => Color::DarkGray,
"lightgray" | "lightgrey" => Color::Gray,
_ => Color::White,
},
ColorDef::Ansi256(n) => Color::Indexed(*n),
ColorDef::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
}
}
}
impl TuiTheme {
pub fn builtin(name: &str) -> Option<Self> {
match name {
"default" => Some(Self::theme_default()),
"tokyonight" => Some(Self::theme_tokyonight()),
"catppuccin-mocha" => Some(Self::theme_catppuccin_mocha()),
"catppuccin-latte" => Some(Self::theme_catppuccin_latte()),
"nord" => Some(Self::theme_nord()),
"gruvbox" => Some(Self::theme_gruvbox()),
"dracula" => Some(Self::theme_dracula()),
"solarized-dark" => Some(Self::theme_solarized_dark()),
"solarized-light" => Some(Self::theme_solarized_light()),
"monokai" => Some(Self::theme_monokai()),
"system" => Some(Self::theme_system()),
_ => None,
}
}
pub fn all_builtin_names() -> Vec<&'static str> {
vec![
"default", "tokyonight", "catppuccin-mocha", "catppuccin-latte",
"nord", "gruvbox", "dracula", "solarized-dark", "solarized-light",
"monokai", "system",
]
}
/// Deserialize from JSON with validation.
/// Returns Err if required fields are missing or invalid.
pub fn from_json(json: &str) -> Result<Self, ThemeError> {
let theme: Self = serde_json::from_str(json)
.map_err(|e| ThemeError::ParseError(e.to_string()))?;
// Validate syntax theme exists in syntect (or warn)
// We don't fail — just log a warning if theme is unknown
Ok(theme)
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
/// Validate that the syntax_theme name exists in syntect's ThemeSet.
/// Returns true if found, false if will fall back to default.
pub fn validate_syntax_theme(&self) -> bool {
use crate::markdown::THEME_SET;
THEME_SET.themes.contains_key(&self.syntax_theme)
}
}
#[derive(Debug)]
pub enum ThemeError {
ParseError(String),
}
```
### Acceptance Criteria
- [ ] `TuiTheme` struct with all color roles (including Agent View colors)
- [ ] `ColorDef` enum with named/256/RGB
- [ ] `from_json()` validates without panicking
- [ ] `validate_syntax_theme()` checks syntect availability
- [ ] `builtin()` returns `Some` for all 11 names
---
## S3-2: Implement all 11 built-in themes
**Priority:** P1 — High
**Estimate:** 1.5 days (up from 0.5 — Opus review noted this is design work, not just coding)
### Description
Define color palettes for all 11 themes. Each needs ~40 color values that look good together.
### Implementation
**File:** `src/theme.rs` — implement private methods
```rust
impl TuiTheme {
fn theme_default() -> Self {
Self {
name: "default".into(),
conversation_text: ColorDef::Named("white".into()),
conversation_user: ColorDef::Named("cyan".into()),
conversation_system: ColorDef::Named("yellow".into()),
conversation_error: ColorDef::Named("red".into()),
conversation_assistant: ColorDef::Named("white".into()),
conversation_dim: ColorDef::Named("darkgray".into()),
code_fg: ColorDef::Named("white".into()),
code_bg: ColorDef::Named("black".into()),
code_border: ColorDef::Named("darkgray".into()),
code_language_label: ColorDef::Named("darkgray".into()),
dashboard_header: ColorDef::Named("cyan".into()),
dashboard_key: ColorDef::Named("darkgray".into()),
dashboard_value: ColorDef::Named("white".into()),
dashboard_separator: ColorDef::Named("darkgray".into()),
dashboard_keys_hint: ColorDef::Named("darkgray".into()),
gauge_fill_green: ColorDef::Named("green".into()),
gauge_fill_yellow: ColorDef::Named("yellow".into()),
gauge_fill_red: ColorDef::Named("red".into()),
gauge_bg: ColorDef::Named("darkgray".into()),
gauge_label: ColorDef::Named("white".into()),
input_fg: ColorDef::Named("white".into()),
input_cursor_fg: ColorDef::Named("black".into()),
input_cursor_bg: ColorDef::Named("cyan".into()),
input_border: ColorDef::Named("cyan".into()),
input_border_active: ColorDef::Named("cyan".into()),
border: ColorDef::Named("darkgray".into()),
border_active: ColorDef::Named("cyan".into()),
scrollbar: ColorDef::Named("darkgray".into()),
spinner: ColorDef::Named("blue".into()),
status_message: ColorDef::Named("blue".into()),
key_hint: ColorDef::Named("darkgray".into()),
completion_fg: ColorDef::Named("white".into()),
completion_bg: ColorDef::Rgb { r: 40, g: 40, b: 40 },
completion_selected_fg: ColorDef::Named("black".into()),
completion_selected_bg: ColorDef::Named("cyan".into()),
agent_running: ColorDef::Named("cyan".into()),
agent_waiting: ColorDef::Named("yellow".into()),
agent_done: ColorDef::Named("green".into()),
agent_failed: ColorDef::Named("red".into()),
agent_cancelled: ColorDef::Named("darkgray".into()),
syntax_theme: "base16-ocean.dark".into(),
}
}
fn theme_tokyonight() -> Self {
Self {
name: "tokyonight".into(),
conversation_text: ColorDef::Rgb { r: 192, g: 202, b: 245 },
conversation_user: ColorDef::Rgb { r: 125, g: 207, b: 255 },
conversation_system: ColorDef::Rgb { r: 224, g: 175, b: 104 },
conversation_error: ColorDef::Rgb { r: 247, g: 118, b: 142 },
conversation_assistant: ColorDef::Rgb { r: 192, g: 202, b: 245 },
conversation_dim: ColorDef::Rgb { r: 86, g: 95, b: 137 },
code_fg: ColorDef::Rgb { r: 192, g: 202, b: 245 },
code_bg: ColorDef::Rgb { r: 26, g: 27, b: 48 },
code_border: ColorDef::Rgb { r: 86, g: 95, b: 137 },
code_language_label: ColorDef::Rgb { r: 86, g: 95, b: 137 },
dashboard_header: ColorDef::Rgb { r: 125, g: 207, b: 255 },
dashboard_key: ColorDef::Rgb { r: 86, g: 95, b: 137 },
dashboard_value: ColorDef::Rgb { r: 192, g: 202, b: 245 },
dashboard_separator: ColorDef::Rgb { r: 59, g: 66, b: 97 },
dashboard_keys_hint: ColorDef::Rgb { r: 86, g: 95, b: 137 },
gauge_fill_green: ColorDef::Rgb { r: 158, g: 206, b: 106 },
gauge_fill_yellow: ColorDef::Rgb { r: 224, g: 175, b: 104 },
gauge_fill_red: ColorDef::Rgb { r: 247, g: 118, b: 142 },
gauge_bg: ColorDef::Rgb { r: 59, g: 66, b: 97 },
gauge_label: ColorDef::Rgb { r: 192, g: 202, b: 245 },
input_fg: ColorDef::Rgb { r: 192, g: 202, b: 245 },
input_cursor_fg: ColorDef::Rgb { r: 26, g: 27, b: 48 },
input_cursor_bg: ColorDef::Rgb { r: 125, g: 207, b: 255 },
input_border: ColorDef::Rgb { r: 59, g: 66, b: 97 },
input_border_active: ColorDef::Rgb { r: 125, g: 207, b: 255 },
border: ColorDef::Rgb { r: 59, g: 66, b: 97 },
border_active: ColorDef::Rgb { r: 125, g: 207, b: 255 },
scrollbar: ColorDef::Rgb { r: 59, g: 66, b: 97 },
spinner: ColorDef::Rgb { r: 125, g: 207, b: 255 },
status_message: ColorDef::Rgb { r: 125, g: 207, b: 255 },
key_hint: ColorDef::Rgb { r: 86, g: 95, b: 137 },
completion_fg: ColorDef::Rgb { r: 192, g: 202, b: 245 },
completion_bg: ColorDef::Rgb { r: 35, g: 40, b: 65 },
completion_selected_fg: ColorDef::Rgb { r: 26, g: 27, b: 48 },
completion_selected_bg: ColorDef::Rgb { r: 125, g: 207, b: 255 },
agent_running: ColorDef::Rgb { r: 125, g: 207, b: 255 },
agent_waiting: ColorDef::Rgb { r: 224, g: 175, b: 104 },
agent_done: ColorDef::Rgb { r: 158, g: 206, b: 106 },
agent_failed: ColorDef::Rgb { r: 247, g: 118, b: 142 },
agent_cancelled: ColorDef::Rgb { r: 86, g: 95, b: 137 },
syntax_theme: "base16-ocean.dark".into(),
}
}
// catppuccin-mocha, catppuccin-latte, nord, gruvbox, dracula,
// solarized-dark, solarized-light, monokai follow the same pattern
// with their respective color palettes.
fn theme_system() -> Self {
// Uses only ANSI named colors — works on any terminal
let mut theme = Self::theme_default();
theme.name = "system".into();
theme.syntax_theme = "InspiredGitHub".into();
theme
}
}
```
### Acceptance Criteria
- [ ] All 11 themes defined with distinct palettes
- [ ] Each theme has all ~40 color roles filled
- [ ] `system` theme uses only ANSI named colors
- [ ] All themes use syntect theme names that exist in defaults
---
## S3-3: Wire theme into TuiApp — NO HARDCODED COLORS
**Priority:** P1 — High
**Estimate:** 0.5 day
### Description
Replace ALL hardcoded `Color::X` references with `self.theme.field.to_color()`. This is critical — Sprint 6 and 7 must also use theme colors, not introduce new hardcoded values.
### Implementation
**File:** `src/tui.rs`
1. Add theme field to `TuiApp`:
```rust
pub struct TuiApp {
theme: TuiTheme,
markdown_renderer: MarkdownRenderer,
// ...
}
```
2. Add helper to reduce boilerplate:
```rust
impl TuiApp {
fn tc(&self, color: &ColorDef) -> Color {
color.to_color()
}
}
```
3. Replace every `Color::X` in rendering with theme lookup:
```rust
// BEFORE:
Style::default().fg(Color::Cyan)
// AFTER:
Style::default().fg(self.tc(&self.theme.dashboard_header))
```
4. Files to update:
- `draw_frame()` → borders, chrome
- `draw_left_pane()` → conversation colors
- `draw_right_pane()` → dashboard colors
- `push_user_input()``self.theme.conversation_user`
- `push_system_message()``self.theme.conversation_system`
- `push_output()``self.theme.conversation_error` / `self.theme.conversation_assistant`
- Input area setup → `self.theme.input_*`
- Spinner → `self.theme.spinner`
- Status → `self.theme.status_message`
### Acceptance Criteria
- [ ] `grep -r "Color::" src/tui.rs` returns zero matches in rendering code
- [ ] All colors come from `self.theme`
- [ ] Changing theme at runtime updates all colors on next frame
- [ ] Add `#[cfg(test)]` assertion that no hardcoded colors remain
---
## S3-4: Implement `/theme` slash command
**Priority:** P1 — High
**Estimate:** 0.25 day
### Implementation
```rust
// In src/tui_commands.rs:
TuiCommand::Theme(args) => {
if args.is_empty() {
let names = TuiTheme::all_builtin_names();
app.push_system_message(&format!(
"Available themes: {}\nUsage: /theme <name>",
names.join(", ")
));
} else if let Some(theme) = TuiTheme::builtin(args) {
app.set_theme(theme);
app.push_system_message(&format!("Theme: {args}"));
} else {
app.push_system_message(&format!("Unknown theme: {args}"));
}
}
```
### Acceptance Criteria
- [ ] `/theme` lists all 11 themes
- [ ] `/theme tokyonight` switches immediately
- [ ] Theme persists for the session
---
## S3-5: Add theme persistence and custom JSON loading
**Priority:** P2 — Medium
**Estimate:** 0.5 day
### Implementation
1. Save selected theme name to runtime config
2. Load on TUI startup
3. Custom theme loading: `/theme --custom <path>`
4. **Validation:** reject JSON with missing required fields or invalid color values
```rust
TuiCommand::Theme(args) if args.starts_with("--custom ") => {
let path = args.strip_prefix("--custom ").unwrap().trim();
match std::fs::read_to_string(path) {
Ok(content) => match TuiTheme::from_json(&content) {
Ok(theme) => {
if !theme.validate_syntax_theme() {
app.push_system_message(&format!(
"Warning: syntax theme '{}' not found, using fallback",
theme.syntax_theme
));
}
app.set_theme(theme);
app.push_system_message("Custom theme loaded");
}
Err(e) => {
app.push_system_message(&format!("Invalid theme: {e:?}"));
}
},
Err(e) => {
app.push_system_message(&format!("Cannot read {path}: {e}"));
}
}
}
```
### Acceptance Criteria
- [ ] Selected theme persists in config
- [ ] Custom JSON themes load correctly
- [ ] Malformed JSON shows error (doesn't panic)
- [ ] Invalid syntax theme name shows warning but loads
---
## Sprint 3 Definition of Done
- [ ] All 5 stories completed
- [ ] 11 built-in themes implemented
- [ ] `/theme` command works at runtime
- [ ] Zero hardcoded `Color::` in rendering code
- [ ] `cargo test -p rusty-claude-cli` passes
- [ ] Manual test: switch between 3 themes, verify all colors change

View File

@ -0,0 +1,504 @@
# Sprint 4: Keybindings & Command Palette
> **Duration:** 4 days (up from 3) | **Stories:** 5 | **Goal:** UX parity with Claude Code and OpenCode
> **Depends on:** Sprint 03
---
## S4-1: Create Action enum and KeyMap module
**Priority:** P1 — High
**Estimate:** 0.5 day
### Implementation
**New file:** `src/keybindings.rs`
```rust
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
// Input
Submit,
Cancel,
Newline,
// Navigation
ScrollUp,
ScrollDown,
ScrollHalfUp,
ScrollHalfDown,
ScrollTop,
ScrollBottom,
// TUI controls
Exit,
ProviderSwap,
TeamToggle,
CommandPalette,
ToggleSidebar,
ToggleAgentView,
Help,
// Session
NewSession,
ClearConversation,
CopyLastOutput,
// Focus
FocusInput,
FocusConversation,
// Chat mode
CycleChatMode,
// Completion
AcceptCompletion,
NextCompletion,
PrevCompletion,
DismissCompletion,
// Slash commands (for palette dispatch)
ThemeCommand,
DiffCommand,
UndoCommand,
LsCommand,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyPreset {
Emacs,
Vim,
Windows,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VimMode {
Normal,
Insert,
}
pub struct KeyMap {
preset: KeyPreset,
bindings: HashMap<(KeyModifiers, KeyCode), Action>,
vim_mode: VimMode,
}
impl KeyMap {
pub fn new(preset: KeyPreset) -> Self {
let mut map = Self {
preset,
bindings: HashMap::new(),
vim_mode: VimMode::Insert,
};
map.load_preset(preset);
map
}
fn load_preset(&mut self, preset: KeyPreset) {
self.bindings.clear();
match preset {
KeyPreset::Emacs => self.load_emacs(),
KeyPreset::Vim => self.load_vim(),
KeyPreset::Windows => self.load_windows(),
}
}
fn load_emacs(&mut self) {
self.bind(KeyModifiers::NONE, KeyCode::Enter, Action::Submit);
self.bind(KeyModifiers::SHIFT, KeyCode::Enter, Action::Newline);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('c'), Action::Cancel);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('d'), Action::Exit);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('p'), Action::ProviderSwap);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('t'), Action::TeamToggle);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('k'), Action::CommandPalette);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('b'), Action::ToggleSidebar);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('a'), Action::ToggleAgentView);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('l'), Action::ClearConversation);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('h'), Action::Help);
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
self.bind(KeyModifiers::NONE, KeyCode::PageDown, Action::ScrollHalfDown);
self.bind(KeyModifiers::CONTROL, KeyCode::Home, Action::ScrollTop);
self.bind(KeyModifiers::CONTROL, KeyCode::End, Action::ScrollBottom);
// FIX: bind() takes 3 args — (modifiers, code, action)
self.bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help);
}
fn load_vim(&mut self) {
self.load_emacs(); // Base layer — Vim normal mode overrides in resolve()
}
fn load_windows(&mut self) {
self.bind(KeyModifiers::CONTROL, KeyCode::Enter, Action::Submit);
self.bind(KeyModifiers::NONE, KeyCode::Enter, Action::Newline);
self.bind(KeyModifiers::NONE, KeyCode::Esc, Action::Cancel);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('d'), Action::Exit);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('p'), Action::ProviderSwap);
self.bind(KeyModifiers::CONTROL, KeyCode::Char('k'), Action::CommandPalette);
self.bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help);
// ... same pattern for remaining bindings
}
fn bind(&mut self, modifiers: KeyModifiers, code: KeyCode, action: Action) {
self.bindings.insert((modifiers, code), action);
}
pub fn resolve(&self, key: KeyEvent) -> Option<Action> {
if self.preset == KeyPreset::Vim && self.vim_mode == VimMode::Normal {
return self.resolve_vim_normal(key);
}
self.bindings.get(&(key.modifiers, key.code)).copied()
}
fn resolve_vim_normal(&self, key: KeyEvent) -> Option<Action> {
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Char('j')) => Some(Action::ScrollDown),
(KeyModifiers::NONE, KeyCode::Char('k')) => Some(Action::ScrollUp),
(KeyModifiers::NONE, KeyCode::Char('g')) => Some(Action::ScrollTop),
(KeyModifiers::NONE, KeyCode::Char('G')) => Some(Action::ScrollBottom),
(KeyModifiers::NONE, KeyCode::Char('i')) => Some(Action::FocusInput),
(KeyModifiers::NONE, KeyCode::Char(':')) => Some(Action::CommandPalette),
(KeyModifiers::NONE, KeyCode::Char('q')) => Some(Action::Exit),
(KeyModifiers::NONE, KeyCode::Esc) => Some(Action::Cancel),
_ => self.bindings.get(&(key.modifiers, key.code)).copied(),
}
}
pub fn set_vim_mode(&mut self, mode: VimMode) { self.vim_mode = mode; }
pub fn vim_mode(&self) -> VimMode { self.vim_mode }
pub fn preset(&self) -> KeyPreset { self.preset }
pub fn set_preset(&mut self, preset: KeyPreset) {
self.preset = preset;
self.load_preset(preset);
self.vim_mode = VimMode::Insert;
}
}
```
### Acceptance Criteria
- [ ] `Action` enum covers all actions including `ThemeCommand`, `DiffCommand`, etc.
- [ ] `bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help)` — correct 3-arg signature
- [ ] Vim normal mode: j/k/g/G/i/:/q work
- [ ] Esc enters Vim normal mode from insert
---
## S4-2: Replace hardcoded key handling with Action dispatch
**Priority:** P1 — High
**Estimate:** 0.5 day
### Implementation
**File:** `src/tui.rs`
```rust
fn handle_key(&mut self, key: KeyEvent) -> io::Result<TuiReadOutcome> {
// Vim mode transition: Esc → Normal mode
if self.keymap.preset() == KeyPreset::Vim && key.code == KeyCode::Esc {
if self.keymap.vim_mode() == VimMode::Insert {
self.keymap.set_vim_mode(VimMode::Normal);
return Ok(TuiReadOutcome::Pending);
}
}
// Resolve key → action
let action = self.keymap.resolve(key);
// In insert mode, many keys go to the text area
if self.input_focused && self.keymap.vim_mode() == VimMode::Insert {
match action {
Some(Action::Submit) => {
let text: String = self.input.lines().join("\n");
if text.trim().is_empty() { return Ok(TuiReadOutcome::Pending); }
self.input = TextArea::new(vec![String::new()]);
return Ok(TuiReadOutcome::Submit(text));
}
Some(Action::Cancel) => return Ok(TuiReadOutcome::Cancel),
Some(Action::Newline) => {
self.input.insert_newline();
return Ok(TuiReadOutcome::Pending);
}
Some(Action::Exit) => {
self.should_exit = true;
return Ok(TuiReadOutcome::Exit);
}
Some(action) => return self.dispatch_action(action),
None => {
// Vim i key enters insert mode
if self.keymap.preset() == KeyPreset::Vim && key.code == KeyCode::Char('i') {
self.keymap.set_vim_mode(VimMode::Insert);
return Ok(TuiReadOutcome::Pending);
}
// Unbound key → pass to text area for editing
self.input.input(key);
return Ok(TuiReadOutcome::Pending);
}
}
}
match action {
Some(action) => self.dispatch_action(action),
None => Ok(TuiReadOutcome::Pending),
}
}
fn dispatch_action(&mut self, action: Action) -> io::Result<TuiReadOutcome> {
match action {
Action::ScrollUp => { self.scroll_up(1); }
Action::ScrollDown => { self.scroll_down(1); }
Action::ScrollHalfUp => { self.scroll_up(self.visible_lines() / 2); }
Action::ScrollHalfDown => { self.scroll_down(self.visible_lines() / 2); }
Action::ScrollTop => { self.scroll_to_top(); }
Action::ScrollBottom => { self.scroll_to_bottom(); }
Action::Exit => { self.should_exit = true; return Ok(TuiReadOutcome::Exit); }
Action::ProviderSwap => return Ok(TuiReadOutcome::ProviderSwap),
Action::TeamToggle => return Ok(TuiReadOutcome::TeamToggle),
Action::CommandPalette => { self.toggle_command_palette(); }
Action::ToggleSidebar => { self.toggle_sidebar(); }
Action::ToggleAgentView => return Ok(TuiReadOutcome::AgentView),
Action::Help => { self.show_help(); }
Action::ClearConversation => {
self.conversation.clear();
self.render_cache.clear();
self.conversation_scroll = 0;
}
Action::CycleChatMode => { self.cycle_chat_mode(); }
Action::FocusInput => { self.input_focused = true; self.keymap.set_vim_mode(VimMode::Insert); }
Action::FocusConversation => { self.input_focused = false; }
// Slash command actions dispatched to TuiCommand
Action::ThemeCommand | Action::DiffCommand | Action::UndoCommand | Action::LsCommand => {
// These are handled via slash commands, not direct key dispatch
}
_ => {}
}
Ok(TuiReadOutcome::Pending)
}
```
### Acceptance Criteria
- [ ] All existing keybindings still work
- [ ] `handle_key()` dispatches through `KeyMap`
- [ ] No `KeyCode::Char('x')` matches remain in `handle_key()`
- [ ] Vim mode: Esc → Normal, i → Insert
---
## S4-3: Implement Command Palette (Ctrl+K)
**Priority:** P1 — High
**Estimate:** 1.5 days (up from 1 — Opus noted modal overlay + fuzzy filter complexity)
### Implementation
**New file:** `src/command_palette.rs`
```rust
use crate::keybindings::Action;
pub struct CommandPalette {
pub active: bool,
pub query: String,
pub entries: Vec<PaletteEntry>,
pub filtered: Vec<usize>,
pub selected: usize,
}
#[derive(Debug, Clone)]
pub struct PaletteEntry {
pub label: String,
pub description: String,
pub action: Action,
pub key_hint: String,
pub category: String,
}
impl CommandPalette {
pub fn new() -> Self {
let entries = vec![
PaletteEntry { label: "Submit".into(), description: "Send message".into(),
action: Action::Submit, key_hint: "Enter".into(), category: "Input".into() },
PaletteEntry { label: "Swap Provider".into(), description: "Change AI provider".into(),
action: Action::ProviderSwap, key_hint: "Ctrl+P".into(), category: "Settings".into() },
PaletteEntry { label: "Toggle Team".into(), description: "Show/hide team info".into(),
action: Action::TeamToggle, key_hint: "Ctrl+T".into(), category: "View".into() },
PaletteEntry { label: "Agent View".into(), description: "Multi-session dashboard".into(),
action: Action::ToggleAgentView, key_hint: "Ctrl+A".into(), category: "View".into() },
PaletteEntry { label: "Toggle Sidebar".into(), description: "Show/hide file sidebar".into(),
action: Action::ToggleSidebar, key_hint: "Ctrl+B".into(), category: "View".into() },
PaletteEntry { label: "Clear Conversation".into(), description: "Clear all messages".into(),
action: Action::ClearConversation, key_hint: "Ctrl+L".into(), category: "Session".into() },
PaletteEntry { label: "Help".into(), description: "Keyboard shortcuts".into(),
action: Action::Help, key_hint: "F1".into(), category: "Help".into() },
PaletteEntry { label: "Exit".into(), description: "Exit TUI mode".into(),
action: Action::Exit, key_hint: "Ctrl+D".into(), category: "Session".into() },
// FIX: Use correct Action variants for slash commands
PaletteEntry { label: "/theme".into(), description: "Change color theme".into(),
action: Action::ThemeCommand, key_hint: "".into(), category: "Commands".into() },
PaletteEntry { label: "/diff".into(), description: "Show uncommitted changes".into(),
action: Action::DiffCommand, key_hint: "".into(), category: "Commands".into() },
PaletteEntry { label: "/undo".into(), description: "Revert last changes".into(),
action: Action::UndoCommand, key_hint: "".into(), category: "Commands".into() },
PaletteEntry { label: "/code".into(), description: "Code mode (full access)".into(),
action: Action::CycleChatMode, key_hint: "Tab".into(), category: "Modes".into() },
];
let filtered = (0..entries.len()).collect();
Self { active: false, query: String::new(), entries, filtered, selected: 0 }
}
pub fn open(&mut self) { self.active = true; self.query.clear(); self.filtered = (0..self.entries.len()).collect(); self.selected = 0; }
pub fn close(&mut self) { self.active = false; self.query.clear(); }
pub fn input(&mut self, c: char) { self.query.push(c); self.update_filter(); }
pub fn backspace(&mut self) { self.query.pop(); self.update_filter(); }
pub fn select_next(&mut self) { if !self.filtered.is_empty() { self.selected = (self.selected + 1) % self.filtered.len(); } }
pub fn select_prev(&mut self) { if !self.filtered.is_empty() { self.selected = if self.selected == 0 { self.filtered.len() - 1 } else { self.selected - 1 }; } }
pub fn selected_action(&self) -> Option<Action> { self.filtered.get(self.selected).map(|&i| self.entries[i].action) }
fn update_filter(&mut self) {
if self.query.is_empty() {
self.filtered = (0..self.entries.len()).collect();
} else {
let q = self.query.to_lowercase();
self.filtered = self.entries.iter().enumerate()
.filter(|(_, e)| e.label.to_lowercase().contains(&q) || e.description.to_lowercase().contains(&q))
.map(|(i, _)| i)
.collect();
}
self.selected = self.selected.min(self.filtered.len().saturating_sub(1));
}
}
```
### Rendering
**File:** `src/tui.rs`
```rust
fn draw_command_palette(&self, f: &mut Frame, area: Rect) {
if !self.command_palette.active { return; }
let popup_w = (area.width * 60 / 100).min(60);
let popup_h = (area.height * 50 / 100).min(20);
let popup = Rect::new(
(area.width - popup_w) / 2,
(area.height - popup_h) / 2,
popup_w, popup_h,
);
f.render_widget(ratatui::widgets::Clear, popup);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled("🔍 ", Style::default().fg(self.tc(&self.theme.key_hint))),
Span::styled(&self.command_palette.query, Style::default().fg(self.tc(&self.theme.input_fg))),
Span::styled("█", Style::default().fg(self.tc(&self.theme.input_cursor_bg))),
]));
lines.push(Line::from(""));
for (i, &idx) in self.command_palette.filtered.iter().enumerate() {
let entry = &self.command_palette.entries[idx];
let is_sel = i == self.command_palette.selected;
let (fg, bg) = if is_sel {
(self.tc(&self.theme.completion_selected_fg), self.tc(&self.theme.completion_selected_bg))
} else {
(self.tc(&self.theme.completion_fg), self.tc(&self.theme.completion_bg))
};
lines.push(Line::from(vec![
Span::styled(format!(" {} ", entry.label), Style::default().fg(fg).bg(bg)),
Span::styled(format!(" {} ", entry.description), Style::default().fg(self.tc(&self.theme.key_hint))),
Span::styled(&entry.key_hint, Style::default().fg(self.tc(&self.theme.key_hint))),
]));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.tc(&self.theme.border_active)))
.title(" Command Palette ");
f.render_widget(Paragraph::new(lines).block(block), popup);
}
```
### Acceptance Criteria
- [ ] `Ctrl+K` opens command palette
- [ ] Typing filters commands in real-time
- [ ] Arrow keys navigate, Enter executes
- [ ] Escape closes palette
- [ ] All palette entries use correct `Action` variants (not `CycleChatMode` for everything)
- [ ] Palette uses theme colors, not hardcoded `Color::`
---
## S4-4: Add `/keybindings` command and preset switching
**Priority:** P2 — Medium
**Estimate:** 0.25 day
### Implementation
```rust
TuiCommand::Keys(preset_name) => {
match preset_name.as_str() {
"emacs" => { app.set_key_preset(KeyPreset::Emacs); app.push_system_message("Keys: Emacs"); }
"vim" => { app.set_key_preset(KeyPreset::Vim); app.push_system_message("Keys: Vim — i for insert, Esc for normal"); }
"windows" => { app.set_key_preset(KeyPreset::Windows); app.push_system_message("Keys: Windows"); }
"" => {
let cur = app.key_preset_name();
app.push_system_message(&format!("Current: {cur}\nAvailable: emacs, vim, windows\nUsage: /keys <preset>"));
}
_ => { app.push_system_message(&format!("Unknown: {preset_name}. Available: emacs, vim, windows")); }
}
}
```
### Acceptance Criteria
- [ ] `/keys vim` switches to Vim
- [ ] `/keys emacs` switches to Emacs
- [ ] `/keys` shows current preset and options
---
## S4-5: Implement Help overlay (F1)
**Priority:** P2 — Medium
**Estimate:** 0.25 day
### Implementation
```rust
fn show_help(&mut self) {
let preset = self.keymap.preset();
let mut msg = format!("Keybindings ({preset:?}):\n\n");
msg += "Enter Submit\n";
msg += "Shift+Enter Newline\n";
msg += "Ctrl+C Cancel\n";
msg += "Ctrl+D Exit TUI\n";
msg += "Ctrl+P Swap provider\n";
msg += "Ctrl+K Command palette\n";
msg += "Ctrl+A Agent view\n";
msg += "Ctrl+B Toggle sidebar\n";
msg += "Ctrl+L Clear conversation\n";
msg += "F1 This help\n";
msg += "PageUp/Down Scroll\n";
if preset == KeyPreset::Vim {
msg += "\nVim mode:\n i Insert\n Esc Normal\n j/k Scroll\n g/G Top/Bottom\n : Command palette\n";
}
msg += "\nSlash commands:\n";
msg += "/tui /theme /keys /code /ask /architect\n";
msg += "/diff /undo /ls /help\n";
self.push_system_message(&msg);
}
```
### Acceptance Criteria
- [ ] F1 shows help with current preset's bindings
- [ ] Vim-specific help only in Vim mode
- [ ] Slash command reference included
---
## Sprint 4 Definition of Done
- [ ] All 5 stories completed
- [ ] `Action` enum + `KeyMap` module with correct signatures
- [ ] Command palette works with correct `Action` dispatch
- [ ] 3 keybinding presets available
- [ ] All palette entries use correct Action variants
- [ ] All colors from theme (no hardcoded `Color::`)
- [ ] `cargo test -p rusty-claude-cli` passes

View File

@ -0,0 +1,292 @@
# Sprint 5: Chat Modes & Diff Viewer
> **Duration:** 2 days | **Stories:** 4 | **Goal:** Aider-style workflow features
> **Depends on:** Sprint 04
> **Revised:** Fixed `/diff --color=always` issue, added theme colors, fixed git commands
---
## S5-1: Implement ChatMode enum and mode switching
**Priority:** P1 — High
**Estimate:** 0.5 day
### Implementation
**File:** `src/tui.rs`
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatMode {
Code,
Ask,
Architect,
}
impl ChatMode {
pub fn label(&self) -> &'static str {
match self {
ChatMode::Code => "code",
ChatMode::Ask => "ask",
ChatMode::Architect => "arch",
}
}
pub fn description(&self) -> &'static str {
match self {
ChatMode::Code => "Full access — edits and runs code",
ChatMode::Ask => "Discussion only — no changes made",
ChatMode::Architect => "Plan first, then implement",
}
}
pub fn system_prompt_suffix(&self) -> &'static str {
match self {
ChatMode::Code => "",
ChatMode::Ask => "\n\nIMPORTANT: Do NOT modify any files. Only discuss and explain.",
ChatMode::Architect => "\n\nIMPORTANT: First create a plan. After the user approves, implement it.",
}
}
pub fn next(self) -> Self {
match self {
ChatMode::Code => ChatMode::Ask,
ChatMode::Ask => ChatMode::Architect,
ChatMode::Architect => ChatMode::Code,
}
}
}
```
### Input prompt integration
```rust
fn update_input_prompt(&mut self) {
let mode_label = self.chat_mode.label();
self.input.set_block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.tc(&self.theme.input_border)))
.title(format!(" {mode_label} > ")),
);
}
```
### Dashboard integration
Add `chat_mode` to `DashboardState`:
```rust
pub chat_mode: String,
```
Update `update_dashboard()` to include it.
### Slash commands in `tui_commands.rs`
```rust
TuiCommand::Code => { app.set_chat_mode(ChatMode::Code); app.push_system_message("Mode: Code"); }
TuiCommand::Ask => { app.set_chat_mode(ChatMode::Ask); app.push_system_message("Mode: Ask — no changes"); }
TuiCommand::Architect => { app.set_chat_mode(ChatMode::Architect); app.push_system_message("Mode: Architect — plan first"); }
```
### System prompt integration
When starting a turn, append the mode's suffix to the system prompt:
```rust
fn get_effective_system_prompt(&self) -> String {
let base = self.base_system_prompt.clone();
let suffix = self.chat_mode.system_prompt_suffix();
format!("{base}{suffix}")
}
```
### Acceptance Criteria
- [ ] Input area shows `[code] >`, `[ask] >`, or `[arch] >`
- [ ] `/code`, `/ask`, `/architect` switch modes
- [ ] `Tab` cycles through modes
- [ ] Mode shown in dashboard
- [ ] System prompt suffix applied when mode changes
---
## S5-2: Implement `/diff` command
**Priority:** P1 — High
**Estimate:** 0.5 day
### Implementation
**FIX from Opus review:** Do NOT use `--color=always` — it injects ANSI codes into the diff output, which corrupts the rendering. We handle coloring ourselves via `CodeDiff` content type.
```rust
TuiCommand::Diff { staged } => {
let mut args = vec!["diff"];
if staged {
args.push("--staged");
}
// Do NOT pass --color=always — we color the diff ourselves
let output = std::process::Command::new("git")
.args(&args)
.current_dir(&cwd)
.output();
match output {
Ok(out) if out.status.success() => {
let diff = String::from_utf8_lossy(&out.stdout);
if diff.is_empty() {
app.push_system_message(if staged {
"No staged changes."
} else {
"No uncommitted changes."
});
} else {
app.push_diff(&diff);
}
}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr);
app.push_system_message(&format!("git diff failed: {err}"));
}
Err(e) => {
app.push_system_message(&format!("Failed to run git: {e}"));
}
}
}
```
### `/diff --stat` variant
Also add a compact stat view:
```rust
TuiCommand::DiffStat => {
let output = std::process::Command::new("git")
.args(["diff", "--stat", "--color=never"]) // --color=never for --stat is fine
.current_dir(&cwd)
.output();
// ... render as Plain content
}
```
### Acceptance Criteria
- [ ] `/diff` shows unstaged changes with our own coloring (green/red)
- [ ] `/diff --staged` shows staged changes
- [ ] No ANSI codes from git in the output
- [ ] Empty diff shows message
- [ ] Error handling for non-git repos
---
## S5-3: Implement `/undo` command
**Priority:** P1 — High
**Estimate:** 0.25 day
### Implementation
```rust
TuiCommand::Undo { confirm } => {
if confirm {
let output = std::process::Command::new("git")
.args(["checkout", "--", "."])
.current_dir(&cwd)
.output();
match output {
Ok(out) if out.status.success() => {
app.push_system_message("✓ Reverted all uncommitted changes.");
}
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr);
app.push_system_message(&format!("Undo failed: {err}"));
}
Err(e) => {
app.push_system_message(&format!("Failed to run git: {e}"));
}
}
} else {
// Show what would be reverted
let output = std::process::Command::new("git")
.args(["diff", "--stat", "--color=never"])
.current_dir(&cwd)
.output();
let stat = output.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
if stat.is_empty() {
app.push_system_message("Nothing to undo — no uncommitted changes.");
} else {
app.push_system_message(&format!(
"This will revert:\n{stat}\nType /undo --confirm to proceed."
));
}
}
}
```
### Acceptance Criteria
- [ ] `/undo` shows what will be reverted
- [ ] `/undo --confirm` reverts
- [ ] No changes shows "Nothing to undo"
---
## S5-4: Implement `/ls` and `/context` commands
**Priority:** P2 — Medium
**Estimate:** 0.25 day
### Implementation
```rust
TuiCommand::Ls { path } => {
match path {
Some(p) => {
match std::fs::read_to_string(&p) {
Ok(content) => {
app.push_system_message(&format!("── {p} ──"));
app.push_output(&content, false);
}
Err(e) => app.push_system_message(&format!("Cannot read {p}: {e}")),
}
}
None => {
let files = cli.get_context_files();
if files.is_empty() {
app.push_system_message("No files in context.");
} else {
let mut msg = format!("Files in context ({}):\n", files.len());
for f in &files {
msg += &format!(" {f}\n");
}
app.push_system_message(&msg);
}
}
}
}
TuiCommand::Context => {
// Alias for /ls with no path
// Same as Ls { path: None }
}
```
### Acceptance Criteria
- [ ] `/ls` lists context files
- [ ] `/ls <path>` shows file contents
- [ ] `/context` is alias for `/ls`
---
## Sprint 5 Definition of Done
- [ ] All 4 stories completed
- [ ] Chat modes with system prompt integration
- [ ] `/diff` uses own coloring (no git --color)
- [ ] `/undo` with confirmation
- [ ] `/ls` shows context
- [ ] All rendering uses theme colors
- [ ] `cargo test -p rusty-claude-cli` passes

View File

@ -0,0 +1,397 @@
# Sprint 6: Agent View & Multi-Session
> **Duration:** 4 days | **Stories:** 5 | **Goal:** Multi-agent monitoring dashboard
> **Depends on:** Sprint 05
> **Guardrails:** Four Laws enforced. Commit after each story.
---
## S6-1: Create AgentView data model
**Priority:** P1 — High
**Estimate:** 0.5 day
**Scope:** IN: `src/agent_view.rs` (new). OUT: `tui.rs`, `main.rs`
### Description
Model for tracking multiple agent sessions — status, progress, metadata.
### Pre-Execution Checklist
```
[ ] Read src/tui.rs for existing SharedDashboardState patterns
[ ] Read src/theme.rs for Agent View color fields
[ ] Scope locked: only src/agent_view.rs
[ ] Rollback: rm src/agent_view.rs
```
### Implementation
**New file:** `src/agent_view.rs`
```rust
use std::time::Instant;
use ratatui::style::Color;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentStatus {
Running,
WaitingForInput,
Done,
Failed,
Cancelled,
}
impl AgentStatus {
pub fn icon(&self) -> &'static str {
match self {
AgentStatus::Running => "⬢",
AgentStatus::WaitingForInput => "◐",
AgentStatus::Done => "✓",
AgentStatus::Failed => "✗",
AgentStatus::Cancelled => "⊘",
}
}
}
#[derive(Debug, Clone)]
pub struct AgentSession {
pub id: String,
pub name: String,
pub status: AgentStatus,
pub model: String,
pub turn_count: u32,
pub last_message: String,
pub working_dir: String,
pub started_at: Instant,
pub task_subject: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortField { Status, Name, Started, TurnCount }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterState { All, Running, Done, Failed }
pub struct AgentView {
pub sessions: Vec<AgentSession>,
pub sort_by: SortField,
pub filter: FilterState,
pub selected: usize,
pub active: bool,
}
impl AgentView {
pub fn new() -> Self {
Self {
sessions: Vec::new(),
sort_by: SortField::Status,
filter: FilterState::All,
selected: 0,
active: false,
}
}
pub fn open(&mut self) { self.active = true; self.selected = 0; }
pub fn close(&mut self) { self.active = false; }
pub fn update_session(&mut self, session: AgentSession) {
if let Some(existing) = self.sessions.iter_mut().find(|s| s.id == session.id) {
*existing = session;
} else {
self.sessions.push(session);
}
}
pub fn remove_session(&mut self, id: &str) {
self.sessions.retain(|s| s.id != id);
}
pub fn filtered_sessions(&self) -> Vec<&AgentSession> {
let mut sessions: Vec<&AgentSession> = self.sessions
.iter()
.filter(|s| match self.filter {
FilterState::All => true,
FilterState::Running => s.status == AgentStatus::Running,
FilterState::Done => s.status == AgentStatus::Done,
FilterState::Failed => s.status == AgentStatus::Failed,
})
.collect();
sessions.sort_by(|a, b| match self.sort_by {
SortField::Status => a.status.icon().cmp(b.status.icon()),
SortField::Name => a.name.cmp(&b.name),
SortField::Started => a.started_at.cmp(&b.started_at),
SortField::TurnCount => a.turn_count.cmp(&b.turn_count),
});
sessions
}
pub fn select_next(&mut self) {
let count = self.filtered_sessions().len();
if count > 0 { self.selected = (self.selected + 1) % count; }
}
pub fn select_prev(&mut self) {
let count = self.filtered_sessions().len();
if count > 0 { self.selected = if self.selected == 0 { count - 1 } else { self.selected - 1 }; }
}
pub fn cycle_filter(&mut self) {
self.filter = match self.filter {
FilterState::All => FilterState::Running,
FilterState::Running => FilterState::Done,
FilterState::Done => FilterState::Failed,
FilterState::Failed => FilterState::All,
};
self.selected = 0;
}
pub fn cycle_sort(&mut self) {
self.sort_by = match self.sort_by {
SortField::Status => SortField::Name,
SortField::Name => SortField::Started,
SortField::Started => SortField::TurnCount,
SortField::TurnCount => SortField::Status,
};
}
}
```
### Verification
```bash
cargo build -p rusty-claude-cli
```
### Commit
```
feat(tui): add AgentView data model with session tracking
Authored by TheArchitectit
```
---
## S6-2: Implement Agent View rendering
**Priority:** P1 — High
**Estimate:** 1 day
**Scope:** IN: `src/tui.rs` (add draw_agent_view method). OUT: theme.rs, main.rs
### Pre-Execution Checklist
```
[ ] Read src/tui.rs for draw_frame(), draw_left_pane(), draw_right_pane() patterns
[ ] Read src/theme.rs for agent_* color fields
[ ] Scope locked: only tui.rs draw_agent_view method + TuiApp field
[ ] Rollback: git checkout HEAD -- src/tui.rs
```
### Implementation
Add `AgentView` to `TuiApp`:
```rust
pub struct TuiApp {
// ... existing fields
pub agent_view: AgentView,
}
```
Add rendering method. **All colors from `self.theme`** — NO hardcoded `Color::`:
```rust
fn draw_agent_view(&self, f: &mut Frame, area: Rect) {
if !self.agent_view.active { return; }
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(8),
])
.split(area);
// Header
let filter_label = format!("Filter: {:?}", self.agent_view.filter);
let sort_label = format!("Sort: {:?}", self.agent_view.sort_by);
let header = Paragraph::new(Line::from(vec![
Span::styled(" Agent View ",
Style::default().fg(self.tc(&self.theme.dashboard_header)).add_modifier(Modifier::BOLD)),
Span::styled(format!(" [{filter_label}] [{sort_label}]"),
Style::default().fg(self.tc(&self.theme.dashboard_key))),
]))
.block(Block::default().borders(Borders::ALL)
.border_style(Style::default().fg(self.tc(&self.theme.border_active))));
f.render_widget(header, chunks[0]);
// Session list
let sessions = self.agent_view.filtered_sessions();
let items: Vec<ListItem> = sessions.iter().enumerate().map(|(i, s)| {
let is_selected = i == self.agent_view.selected;
let status_color = match s.status {
AgentStatus::Running => self.tc(&self.theme.agent_running),
AgentStatus::WaitingForInput => self.tc(&self.theme.agent_waiting),
AgentStatus::Done => self.tc(&self.theme.agent_done),
AgentStatus::Failed => self.tc(&self.theme.agent_failed),
AgentStatus::Cancelled => self.tc(&self.theme.agent_cancelled),
};
let elapsed = s.started_at.elapsed().as_secs();
let elapsed_str = if elapsed < 60 { format!("{elapsed}s") } else { format!("{}m{}s", elapsed/60, elapsed%60) };
let line = Line::from(vec![
Span::styled(format!(" {} ", s.status.icon()), Style::default().fg(status_color)),
Span::styled(format!("{:<20}", s.name), Style::default().fg(self.tc(&self.theme.conversation_text))),
Span::styled(format!("{:<16}", s.model), Style::default().fg(self.tc(&self.theme.dashboard_key))),
Span::styled(format!("{} turns ", s.turn_count), Style::default().fg(self.tc(&self.theme.dashboard_value))),
Span::styled(elapsed_str, Style::default().fg(self.tc(&self.theme.key_hint))),
]);
let style = if is_selected { Style::default().bg(self.tc(&self.theme.completion_selected_bg)) } else { Style::default() };
ListItem::new(line).style(style)
}).collect();
let list = List::new(items).block(
Block::default().borders(Borders::ALL)
.border_style(Style::default().fg(self.tc(&self.theme.border)))
.title(" Sessions "));
f.render_widget(list, chunks[1]);
// Detail panel
if let Some(session) = sessions.get(self.agent_view.selected) {
let detail = Paragraph::new(vec![
Line::from(vec![
Span::styled("ID: ", Style::default().fg(self.tc(&self.theme.dashboard_key))),
Span::styled(&session.id, Style::default().fg(self.tc(&self.theme.dashboard_value))),
]),
Line::from(vec![
Span::styled("Task: ", Style::default().fg(self.tc(&self.theme.dashboard_key))),
Span::styled(session.task_subject.as_deref().unwrap_or("(none)"),
Style::default().fg(self.tc(&self.theme.dashboard_value))),
]),
])
.block(Block::default().borders(Borders::ALL)
.border_style(Style::default().fg(self.tc(&self.theme.border)))
.title(" Details "))
.wrap(Wrap { trim: false });
f.render_widget(detail, chunks[2]);
}
}
```
### Verification
```bash
cargo build -p rusty-claude-cli
cargo clippy -p rusty-claude-cli
```
### Commit
```
feat(tui): render Agent View with theme colors
Authored by TheArchitectit
```
---
## S6-3: Wire Agent View keybindings
**Priority:** P1 — High
**Estimate:** 0.25 day
**Scope:** IN: `src/tui.rs` (dispatch_action), `src/keybindings.rs` (Action enum). OUT: main.rs
### Implementation
In `dispatch_action()`:
```rust
Action::ToggleAgentView => {
if self.agent_view.active {
self.agent_view.close();
} else {
self.agent_view.open();
}
}
```
In `handle_key()` — when agent view is active, intercept keys:
```rust
if self.agent_view.active {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => { self.agent_view.close(); }
KeyCode::Tab => { self.agent_view.cycle_filter(); }
KeyCode::Char('s') => { self.agent_view.cycle_sort(); }
KeyCode::Down | KeyCode::Char('j') => { self.agent_view.select_next(); }
KeyCode::Up | KeyCode::Char('k') => { self.agent_view.select_prev(); }
_ => {}
}
self.needs_redraw = true;
return Ok(TuiReadOutcome::Pending);
}
```
### Commit
```
feat(tui): wire Agent View keybindings (Ctrl+A, j/k, Tab, s)
Authored by TheArchitectit
```
---
## S6-4: Integrate with runtime session registry
**Priority:** P2 — Medium
**Estimate:** 1 day
**Scope:** IN: `src/tui_repl.rs`, `src/tui_update.rs`. OUT: runtime crate internals
### Implementation
1. Add `SessionRegistry` shared state
2. Hook into team/spawn lifecycle:
- `TeamCreate` → register
- `Agent::spawn` → register
- Turn completion → update count
- Shutdown → mark Done/Failed
3. Share via `Arc<RwLock<SessionRegistry>>`
### Commit
```
feat(tui): integrate Agent View with runtime session lifecycle
Authored by TheArchitectit
```
---
## S6-5: Write Agent View tests
**Priority:** P1 — High
**Estimate:** 0.25 day
**Scope:** IN: `tests/agent_view_tests.rs` (new). OUT: all source files
### Tests
```rust
use rusty_claude_cli::agent_view::*;
#[test]
fn test_update_session() { ... }
#[test]
fn test_remove_session() { ... }
#[test]
fn test_filter_by_status() { ... }
#[test]
fn test_sort_by_name() { ... }
#[test]
fn test_select_navigation() { ... }
#[test]
fn test_cycle_filter() { ... }
```
### Commit
```
test(tui): add Agent View data model tests
Authored by TheArchitectit
```
---
## Sprint 6 Definition of Done
- [ ] All 5 stories completed and committed
- [ ] Agent View shows sessions with theme colors (zero hardcoded Color::)
- [ ] `cargo test -p rusty-claude-cli` passes
- [ ] `cargo clippy -p rusty-claude-cli` clean
- [ ] Manual: spawn agents, open Agent View, verify live updates

468
.sprints/sprint-7-polish.md Normal file
View File

@ -0,0 +1,468 @@
# Sprint 7: Polish & Ship
> **Duration:** 3 days | **Stories:** 7 | **Goal:** Mouse, CJK, sidebar, input history, search, final QA
> **Depends on:** Sprint 06
> **Guardrails:** Four Laws enforced. Commit after each story. Three Strikes per task.
---
## S7-1: Enable mouse support
**Priority:** P2 — Medium
**Estimate:** 0.5 day
**Scope:** IN: `src/tui.rs` (init/restore + event handling). OUT: all other files
### Pre-Execution Checklist
```
[ ] Read src/tui.rs init_terminal() and restore_terminal()
[ ] Read src/tui_repl.rs event loop for Event:: handling
[ ] Scope locked: init/restore + mouse event handler
[ ] Rollback: git checkout HEAD -- src/tui.rs src/tui_repl.rs
```
### Implementation
**File:** `src/tui.rs` — init:
```rust
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
crossterm::execute!(
stdout,
EnableMouseCapture,
crossterm::terminal::EnterAlternateScreen,
)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
```
Restore:
```rust
pub fn restore_terminal(&mut self) -> io::Result<()> {
crossterm::execute!(
self.terminal.backend_mut(),
DisableMouseCapture,
crossterm::terminal::LeaveAlternateScreen,
)?;
disable_raw_mode()?;
Ok(())
}
```
**File:** `src/tui_repl.rs` — event handling:
```rust
Event::Mouse(mouse) => {
match mouse.kind {
MouseEventKind::ScrollUp => { app.scroll_up(3); }
MouseEventKind::ScrollDown => { app.scroll_down(3); }
MouseEventKind::Down(MouseButton::Left) => {
// Check if click is in input area bounds
if let Some(input_area) = app.input_area_rect {
if mouse.column >= input_area.x
&& mouse.column < input_area.x + input_area.width
&& mouse.row >= input_area.y
&& mouse.row < input_area.y + input_area.height
{
app.input_focused = true;
} else {
app.input_focused = false;
}
}
}
_ => {}
}
app.needs_redraw = true;
}
```
### Verification
```bash
cargo build -p rusty-claude-cli
# Manual: launch TUI, scroll with mouse wheel, click to focus input
```
### Commit
```
feat(tui): enable mouse support for scroll and click-to-focus
Authored by TheArchitectit
```
---
## S7-2: CJK-aware word wrapping
**Priority:** P2 — Medium
**Estimate:** 0.25 day
**Scope:** IN: `src/tui.rs` (wrap_line function). OUT: all other files
### Implementation
`unicode-width` already added in Sprint 0 (S0-4).
```rust
use unicode_width::UnicodeWidthChar;
fn wrap_line(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 || text.is_empty() {
return vec![text.to_string()];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width: usize = 0;
let mut last_break_byte_pos: usize = 0;
for ch in text.chars() {
let char_width = ch.width().unwrap_or(0);
if current_width + char_width > max_width {
if last_break_byte_pos > 0 {
let (keep, rest) = current_line.split_at(last_break_byte_pos);
result.push(keep.trim_end().to_string());
current_line = rest.to_string();
current_width = rest.chars().map(|c| c.width().unwrap_or(0)).sum();
last_break_byte_pos = 0;
} else {
result.push(current_line.clone());
current_line.clear();
current_width = 0;
last_break_byte_pos = 0;
}
}
let byte_pos_before = current_line.len();
current_line.push(ch);
current_width += char_width;
if ch == ' ' || ch == '-' || ch == '_' || ch == '/' {
last_break_byte_pos = current_line.len();
}
}
if !current_line.is_empty() {
result.push(current_line);
}
if result.is_empty() {
result.push(String::new());
}
result
}
```
### Tests
```rust
#[test]
fn test_wrap_cjk_double_width() {
let wrapped = wrap_line("你好世界你好世界你好世界", 10);
for line in &wrapped {
let width: usize = line.chars().map(|c| c.width().unwrap_or(0)).sum();
assert!(width <= 10, "line '{line}' has width {width}");
}
}
#[test]
fn test_wrap_mixed_cjk_ascii() {
let wrapped = wrap_line("Hello你好World test", 10);
assert!(!wrapped.is_empty());
}
#[test]
fn test_wrap_emoji() {
let wrapped = wrap_line("🦀🔨🔧", 6);
assert!(!wrapped.is_empty());
}
```
### Commit
```
fix(tui): CJK-aware word wrapping with unicode-width
Authored by TheArchitectit
```
---
## S7-3: Refactor draw_frame() into RenderContext
**Priority:** P2 — Medium
**Estimate:** 0.5 day
**Scope:** IN: `src/tui.rs` (draw_frame signature). OUT: tui_repl.rs, main.rs
### Pre-Execution Checklist
```
[ ] Read src/tui.rs draw_frame() signature (currently 10 params)
[ ] Read all callers of draw_frame() in tui.rs and tui_repl.rs
[ ] Scope locked: only draw_frame signature + callers
[ ] Rollback: git checkout HEAD -- src/tui.rs
```
### Implementation
```rust
struct RenderContext<'a> {
theme: &'a TuiTheme,
dashboard: &'a DashboardState,
conversation: &'a [ConversationLine],
conversation_scroll: u16,
input: &'a TextArea<'static>,
slash_completions: &'a [String],
completion_index: usize,
showing_completions: bool,
spinner_frame: usize,
chat_mode: ChatMode,
agent_view_active: bool,
command_palette_active: bool,
}
impl<'a> RenderContext<'a> {
fn from_app(app: &'a TuiApp, dashboard: &'a DashboardState) -> Self {
Self {
theme: &app.theme,
dashboard,
conversation: &app.conversation,
conversation_scroll: app.conversation_scroll,
input: &app.input,
slash_completions: &app.slash_completions,
completion_index: app.completion_index,
showing_completions: app.showing_completions,
spinner_frame: app.spinner_frame,
chat_mode: app.chat_mode,
agent_view_active: app.agent_view.active,
command_palette_active: app.command_palette.active,
}
}
}
// Before:
fn draw_frame(theme: &TuiTheme, dashboard: &DashboardState, conversation: &[ConversationLine],
scroll: u16, input: &TextArea, completions: &[String], comp_idx: usize,
showing_comp: bool, spinner: usize, chat_mode: ChatMode,
f: &mut Frame)
{ ... }
// After:
fn draw_frame(ctx: &RenderContext, f: &mut Frame)
{ ... }
```
### Verification
```bash
cargo build -p rusty-claude-cli
cargo clippy -p rusty-claude-cli # no more too_many_arguments suppression
```
### Commit
```
refactor(tui): replace 10-param draw_frame with RenderContext struct
Authored by TheArchitectit
```
---
## S7-4: Add input history (Up/Down arrows)
**Priority:** P2 — Medium
**Estimate:** 0.25 day
**Scope:** IN: `src/tui.rs` (TuiApp fields + handle_key). OUT: tui_repl.rs
### Implementation
```rust
pub struct TuiApp {
// ... existing fields
input_history: Vec<String>,
history_index: Option<usize>, // None = not browsing history
}
impl TuiApp {
fn push_history(&mut self, text: &str) {
if !text.trim().is_empty() {
self.input_history.push(text.to_string());
// Cap at 500 entries
if self.input_history.len() > 500 {
self.input_history.remove(0);
}
}
self.history_index = None;
}
fn history_up(&mut self) {
if self.input_history.is_empty() { return; }
let new_idx = match self.history_index {
Some(i) => i.saturating_add(1).min(self.input_history.len() - 1),
None => self.input_history.len() - 1,
};
self.history_index = Some(new_idx);
let entry = &self.input_history[self.input_history.len() - 1 - new_idx];
self.input = TextArea::new(vec![entry.clone()]);
}
fn history_down(&mut self) {
match self.history_index {
Some(0) => {
self.history_index = None;
self.input = TextArea::new(vec![String::new()]);
}
Some(i) => {
let new_idx = i - 1;
self.history_index = Some(new_idx);
let entry = &self.input_history[self.input_history.len() - 1 - new_idx];
self.input = TextArea::new(vec![entry.clone()]);
}
None => {}
}
}
}
```
Wire into `handle_key()` when input is focused and not showing completions:
```rust
KeyCode::Up if !self.showing_completions => { self.history_up(); }
KeyCode::Down if !self.showing_completions => { self.history_down(); }
```
Save history on submit in `tui_repl.rs`:
```rust
TuiReadOutcome::Submit(text) => {
app.push_history(&text);
// ... existing turn execution
}
```
### Commit
```
feat(tui): add input history with Up/Down arrow navigation
Authored by TheArchitectit
```
---
## S7-5: Add search in conversation (Ctrl+F)
**Priority:** P3 — Low
**Estimate:** 0.25 day
**Scope:** IN: `src/tui.rs` (new search state + handle_key). OUT: tui_repl.rs
### Implementation
```rust
pub struct ConversationSearch {
pub active: bool,
pub query: String,
pub matches: Vec<usize>, // indices into conversation
pub current_match: usize,
}
```
When `Ctrl+F`:
- Show search input at top of conversation pane
- Type to filter — jump to matching line
- Enter / F3 = next match, Shift+F3 = previous
- Escape = close search
### Commit
```
feat(tui): add conversation search with Ctrl+F
Authored by TheArchitectit
```
---
## S7-6: Final integration testing
**Priority:** P1 — High
**Estimate:** 0.5 day
**Scope:** IN: No file changes. Manual testing only.
### Test Matrix
| Feature | Test | Pass? |
|---------|------|-------|
| TUI launch | `/tui` enters TUI mode | |
| Message flow | Send message, receive streaming response | |
| Markdown rendering | Headers, code blocks, lists render | |
| Theme switching | `/theme tokyonight` changes all colors | |
| Keybindings | All Emacs actions work | |
| Vim mode | `/keys vim` + normal/insert modes | |
| Command palette | `Ctrl+K` opens, type to filter, Enter selects | |
| Chat modes | `/code`, `/ask`, `/architect` switch | |
| Diff viewer | `/diff` shows changes | |
| Undo | `/undo --confirm` reverts | |
| Agent View | `Ctrl+A` shows sessions | |
| Provider swap | `Ctrl+P` runs wizard, returns to TUI | |
| Token tracking | Dashboard shows real counts | |
| Memory bound | Long session stays bounded | |
| CJK wrapping | Chinese text wraps at double-width | |
| Mouse scroll | Wheel scrolls conversation | |
| Input history | Up/Down recalls previous inputs | |
| Search | `Ctrl+F` finds text in conversation | |
| Help | `F1` shows keybinding help | |
| Resize | Terminal resize re-renders correctly | |
| Panic recovery | Induced panic restores terminal | |
### Verification
```bash
cargo test -p rusty-claude-cli
cargo clippy -p rusty-claude-cli
cargo build --release -p rusty-claude-cli
```
### Commit (if any test fixes needed)
```
fix(tui): integration test fixes from QA pass
Authored by TheArchitectit
```
---
## S7-7: Update documentation
**Priority:** P2 — Medium
**Estimate:** 0.25 day
**Scope:** IN: README.md, help text in tui.rs. OUT: guardrails docs
### Deliverables
1. README section for TUI
2. In-app help (F1) updated with all commands
3. Slash command reference:
```
/tui Enter TUI mode
/theme <name> Change color theme
/keys <preset> Change keybinding preset
/code Code mode (default)
/ask Ask mode (no edits)
/architect Architect mode (plan first)
/diff Show uncommitted changes
/undo Revert last changes
/ls List context files
```
### Commit
```
docs(tui): add TUI mode documentation and command reference
Authored by TheArchitectit
```
---
## Sprint 7 Definition of Done
- [ ] All 7 stories completed and committed individually
- [ ] All test matrix items pass
- [ ] Zero hardcoded `Color::` in rendering code
- [ ] `cargo test -p rusty-claude-cli` passes
- [ ] `cargo clippy -p rusty-claude-cli` clean
- [ ] `cargo build --release -p rusty-claude-cli` succeeds
- [ ] Manual smoke test: full workflow end-to-end