From f6597f1935db553ac8639773d190346c9ad1b3e0 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Fri, 12 Jun 2026 21:21:42 -0500 Subject: [PATCH] docs(tui): add MoA research reports + synthesized architecture plan 5 expert reports (MoA review): - moa-codex-rs: OpenAI's codex-rs/TUI architecture analysis - moa-aichat: sigoden/aichat streaming markdown rendering - moa-ecosystem: broader Rust TUI ecosystem survey - moa-failure-analysis: root-cause of 3 failed TUI attempts - moa-runtime-integration: claw-code runtime integration points Plus the synthesized architecture plan (ARCHITECTURE.md) and original planning docs (TUI.md, 52-week plan, sprint plan). --- docs/tui/ARCHITECTURE.md | 1005 ++++++++++++++++ docs/tui/README.md | 43 + docs/tui/TUI-52-WEEK-PLAN.md | 288 +++++ docs/tui/TUI-52-WEEK-SPRINT.md | 1676 +++++++++++++++++++++++++++ docs/tui/TUI.md | 196 ++++ docs/tui/moa-aichat.md | 607 ++++++++++ docs/tui/moa-codex-rs.md | 667 +++++++++++ docs/tui/moa-ecosystem.md | 1051 +++++++++++++++++ docs/tui/moa-failure-analysis.md | 165 +++ docs/tui/moa-runtime-integration.md | 1079 +++++++++++++++++ 10 files changed, 6777 insertions(+) create mode 100644 docs/tui/ARCHITECTURE.md create mode 100644 docs/tui/README.md create mode 100644 docs/tui/TUI-52-WEEK-PLAN.md create mode 100644 docs/tui/TUI-52-WEEK-SPRINT.md create mode 100644 docs/tui/TUI.md create mode 100644 docs/tui/moa-aichat.md create mode 100644 docs/tui/moa-codex-rs.md create mode 100644 docs/tui/moa-ecosystem.md create mode 100644 docs/tui/moa-failure-analysis.md create mode 100644 docs/tui/moa-runtime-integration.md diff --git a/docs/tui/ARCHITECTURE.md b/docs/tui/ARCHITECTURE.md new file mode 100644 index 00000000..d2eced9f --- /dev/null +++ b/docs/tui/ARCHITECTURE.md @@ -0,0 +1,1005 @@ +# Claw-Code TUI Architecture Plan + +**Status:** MoA-synthesized design — 5 expert reports consolidated +**Target:** `origin/feat/tui` branch → upstream PR +**Precedent:** codex-rs (OpenAI), aichat (sigoden) + +--- + +## 0. Executive Summary + +The three previous TUI attempts all failed for the same root reason: **the runtime was built to print to stdout, and the TUI was grafted on top.** Every output-bleeding fix (suspend/resume, buffer capture, libc::dup, gag::BufferRedirect, leave-for-turn) was a workaround for this architectural mismatch. Every scroll bug stemmed from an inverted offset model that fought ratatui's `Paragraph::scroll()` semantics. + +The correct architecture — proven by codex-rs — is: + +1. **The runtime emits structured events, not stdout bytes** +2. **The TUI consumes events via a channel and renders them as cells** +3. **Scroll operates on cells with forward offsets (0 = top)** +4. **The TUI renders to stderr, keeping stdout free for child process capture** + +--- + +## 1. Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Main Event Loop │ +│ tokio::select! { ... } │ +│ │ +│ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ TuiEvents │ │AppEvents │ │TurnEvents│ │PermissionRx │ │ +│ │(crossterm)│ │(bounded) │ │(bounded) │ │(oneshot resp)│ │ +│ └─────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +│ └────────────┴────────────┴───────────────┘ │ +│ │ │ +│ App::handle() │ +│ │ │ +│ ┌────────────────┼────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ChatWidget BottomPane PermissionOverlay │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │Committed │ │Composer │ │[Y/N/A] │ │ +│ │Cells[] │ │(textarea)│ │prompt │ │ +│ │+Active │ │+History │ └──────────┘ │ +│ │ Cell │ └──────────┘ │ +│ └──────────┘ │ +│ │ │ +│ ▼ │ +│ FrameRequester → rate-limited draw │ +│ │ │ +│ ▼ │ +│ ratatui render → crossterm → stderr │ +└─────────────────────────────────────────────────────────────┘ + + │ ▲ + │ │ TuiTurnEvent channel + │ │ +┌───────────────────▼─┴──────────────────────────────────────┐ +│ TurnWorker (tokio::spawn) │ +│ │ +│ ConversationRuntime::run_turn() │ +│ │ │ +│ ├── ApiClient::stream() → Vec │ +│ │ └── Forward each event via tx.send() │ +│ │ before collecting for build_assistant_message │ +│ │ │ +│ ├── PermissionPrompter::decide() │ +│ │ └── Send PermissionRequired via channel │ +│ │ with oneshot::Sender for response │ +│ │ │ +│ └── TurnProgressReporter::on_tool_result() │ +│ └── Forward as TuiTurnEvent::ToolResultReady │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Crate Stack + +| Crate | Version | Purpose | +|-------|---------|---------| +| `ratatui` | 0.29+ | Framework — features: `unstable-rendered-line-info`, `unstable-widget-ref` | +| `crossterm` | 0.28+ | Terminal backend — features: `bracketed-paste`, `event-stream` | +| `tokio` | 1 | Async runtime — features: `rt-multi-thread`, `macros`, `sync`, `time`, `process`, `signal` | +| `tui-textarea` | 0.7+ | Input composer (proven in codex-rs) | +| `pulldown-cmark` | 0.12+ | Markdown → ratatui `Text` rendering | +| `syntect` | 5+ | Syntax highlighting for code blocks | +| `textwrap` | 0.18+ | Word-wrap with CJK support | +| `unicode-width` | 0.2+ | Character width calculation (CJK = 2) | +| `insta` | 1+ (dev) | Snapshot testing for widget rendering | +| `ansi-to-tui` | 0.5+ (opt) | Child process ANSI output → ratatui Text | + +**Critical:** Render the TUI to **stderr**, not stdout: +```rust +let backend = CrosstermBackend::new(std::io::stderr()); +let mut terminal = Terminal::new(backend)?; +``` +This frees stdout for child process capture AND prevents output bleeding by design. + +--- + +## 3. Event Architecture + +### 3.1 TuiTurnEvent — Runtime → TUI Channel + +```rust +#[derive(Debug)] +pub enum TuiTurnEvent { + // === Streaming === + TurnStarted { user_input: String }, + ThinkingDelta { text: String }, + TextDelta { text: String }, + ToolUseStarted { id: String, name: String, input_preview: String }, + ToolResultReady { tool_name: String, result: Result }, + + // === Permission (interactive — blocks turn worker) === + PermissionRequired { + request: PermissionRequest, + response_tx: oneshot::Sender, + }, + + // === Lifecycle === + TurnCompleted { summary: TurnSummary }, + TurnFailed { error: String }, + + // === Progress === + IterationProgress { iteration: usize, max_iterations: usize }, +} +``` + +### 3.2 AppEvent — Internal TUI Channel + +```rust +#[derive(Debug)] +pub enum AppEvent { + // From turn worker + TurnEvent(TuiTurnEvent), + + // From crossterm + Key(KeyEvent), + Paste(String), + Resize { width: u16, height: u16 }, + + // Frame scheduling + ScheduleFrame, + + // Streaming animation tick (~4Hz for UI updates) + StreamTick, +} +``` + +### 3.3 Channel Setup + +```rust +// Bounded channels with backpressure +let (app_tx, app_rx) = tokio::sync::mpsc::channel::(256); +let (turn_tx, turn_rx) = tokio::sync::mpsc::channel::(256); + +// Turn worker receives turn_tx, sends events +// Main loop multiplexes turn_rx → app_tx, crossterm events → app_tx +``` + +--- + +## 4. Key Components + +### 4.1 App (State Machine) + +```rust +pub struct App { + // Layout + screen: Screen, + + // Chat state + cells: Vec>, // committed cells + active_cell: Option>, // streaming cell + + // Input + composer: Composer, + + // Scroll + scroll_offset: usize, // FORWARD: 0 = top, max = bottom + user_scrolled: bool, // true = user scrolled up, disable auto-scroll + + // Turn worker + turn_handle: Option>, + turn_tx: Sender, + + // Frame scheduling + frame_requester: FrameRequester, +} +``` + +Screen states: +```rust +pub enum Screen { + Chat, // Normal chat mode + PermissionPrompt, // Modal overlay for [Y/N/A] + Help, // Keybinding help overlay +} +``` + +### 4.2 MessageCell Trait (Codex-rs Pattern) + +```rust +pub trait MessageCell: Send { + /// Push streaming content into this cell + fn push_delta(&mut self, delta: &str); + + /// Compute desired height at given width + fn desired_height(&self, width: u16) -> u16; + + /// Render into ratatui area + fn render(&self, area: Rect, buf: &mut Buffer); + + /// Whether this cell is still streaming + fn is_streaming(&self) -> bool; + + /// Finalize — consolidate from fragmented stream state to + /// a single re-renderable markdown cell (post-stream consolidation) + fn consolidate(self: Box) -> Box; +} +``` + +Cell types: +- `UserMessageCell` — user input + timestamp +- `AssistantMarkdownCell` — completed markdown (re-renderable from source on resize) +- `AssistantStreamingCell` — active streaming cell (mutable, flush on completion) +- `ToolResultCell` — tool name + collapsed/expanded output +- `ThinkingCell` — collapsible thinking/reasoning block +- `SystemCell` — status messages, errors + +### 4.3 Scroll State (Forward Offset) + +```rust +pub struct ScrollState { + /// Forward offset: 0 = top of conversation, max = pinned to bottom + offset: usize, + /// Maximum valid offset (recomputed on content change) + max_offset: usize, + /// Whether user has manually scrolled (disables auto-scroll) + user_scrolled: bool, +} + +impl ScrollState { + /// Auto-scroll to bottom (on new content while not user-scrolled) + pub fn auto_scroll_to_bottom(&mut self, total_lines: usize, viewport_height: usize) { + if !self.user_scrolled { + self.offset = total_lines.saturating_sub(viewport_height); + self.max_offset = self.offset; + } else { + self.max_offset = total_lines.saturating_sub(viewport_height); + self.offset = self.offset.min(self.max_offset); + } + } + + pub fn scroll_up(&mut self, amount: usize) { + self.offset = self.offset.saturating_sub(amount); + self.user_scrolled = true; + } + + pub fn scroll_down(&mut self, amount: usize) { + self.offset = (self.offset + amount).min(self.max_offset); + if self.offset >= self.max_offset { + self.user_scrolled = false; // Reached bottom, re-enable auto-scroll + } + } +} +``` + +**Key rule:** `PageUp` = `scroll_up` (decrease offset = see older content). `PageDown` = `scroll_down` (increase offset = see newer content). This is **forward offset** — matches `Paragraph::scroll()` and user intuition. No more inverted model. + +### 4.4 Composer (Input Area) + +```rust +pub struct Composer { + textarea: TextArea<'static>, // tui-textarea + history: Vec, + history_index: usize, + submitting: bool, +} +``` + +- `tui-textarea` for multi-line editing with proper CJK/selection support +- `Enter` = submit (configurable: ctrl+enter for newline) +- `Up/Down` when at top/bottom of textarea = history navigation +- `Tab` = autocomplete (slash commands, file paths) +- `Ctrl+C` when empty = interrupt turn + +### 4.5 TurnWorker (Runtime Bridge) + +```rust +pub struct TurnWorker { + runtime: ConversationRuntime, + tx: Sender, +} + +impl TurnWorker { + pub async fn run_turn( + &mut self, + user_input: String, + mut runtime: ConversationRuntime, + ) -> Result<(), Box> { + // 1. Notify TUI that turn started + self.tx.send(TuiTurnEvent::TurnStarted { user_input: user_input.clone() }).await?; + + // 2. Wrap the API client to intercept streaming events + // BEFORE they're collected into Vec + let streaming_tx = self.tx.clone(); + + // 3. Create a TUI-aware PermissionPrompter + let prompter = TuiPermissionPrompter { + tx: self.tx.clone(), + }; + + // 4. Run the turn + // PROBLEM: run_turn() is synchronous and internally calls + // api_client.stream() which returns Vec. + // We need to intercept events as they arrive. + + // Option A: Wrap ApiClient with a streaming interceptor + // Option B: Modify ApiClient::stream() to accept a callback + // Option C: Run on a separate thread with captured stdout (see §6) + + let result = runtime.run_turn(user_input, Some(&mut prompter)); + + match result { + Ok(summary) => { + self.tx.send(TuiTurnEvent::TurnCompleted { summary }).await?; + } + Err(e) => { + self.tx.send(TuiTurnEvent::TurnFailed { error: e.to_string() }).await?; + } + } + + Ok(()) + } +} +``` + +### 4.6 TuiPermissionPrompter (Channel Bridge) + +```rust +pub struct TuiPermissionPrompter { + tx: Sender, +} + +impl PermissionPrompter for TuiPermissionPrompter { + fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { + let (response_tx, response_rx) = std::sync::mpsc::channel(); + + // Send permission request to TUI event loop + let event = TuiTurnEvent::PermissionRequired { + request: request.clone(), + response_tx, + }; + + // Use try_send or blocking send (we're on the worker thread) + if self.tx.blocking_send(event).is_err() { + return PermissionPromptDecision::Deny { + reason: "TUI disconnected".into(), + }; + } + + // Block until TUI responds (this pauses the turn worker) + response_rx.recv().unwrap_or(PermissionPromptDecision::Deny { + reason: "No response".into(), + }) + } +} +``` + +--- + +## 5. Event Loop + +```rust +pub async fn run_app(mut app: App, mut terminal: Terminal>) -> Result<()> { + let (app_tx, mut app_rx) = tokio::sync::mpsc::channel::(256); + + // Spawn crossterm event reader + let crossterm_tx = app_tx.clone(); + tokio::spawn(async move { + loop { + if crossterm::event::poll(Duration::from_millis(100)).unwrap_or(false) { + if let Ok(event) = crossterm::event::read() { + match event { + CrosstermEvent::Key(k) => { let _ = crossterm_tx.send(AppEvent::Key(k)).await; } + CrosstermEvent::Paste(s) => { let _ = crossterm_tx.send(AppEvent::Paste(s)).await; } + CrosstermEvent::Resize(w, h) => { let _ = crossterm_tx.send(AppEvent::Resize { width: w, height: h }).await; } + _ => {} + } + } + } + } + }); + + // Streaming tick (4Hz — smooth enough, not wasteful) + let tick_tx = app_tx.clone(); + let tick_interval = tokio::time::interval(Duration::from_millis(250)); + tokio::spawn(async move { + let mut interval = tick_interval; + loop { + interval.tick().await; + let _ = tick_tx.send(AppEvent::StreamTick).await; + } + }); + + // Frame rate limiter (cap at 30 FPS for terminal) + let mut last_frame = Instant::now(); + const MIN_FRAME_INTERVAL: Duration = Duration::from_millis(33); + + loop { + let event = app_rx.recv().await; + + match event { + Some(AppEvent::Key(key)) => app.handle_key(key), + Some(AppEvent::Paste(text)) => app.handle_paste(text), + Some(AppEvent::Resize { width, height }) => { + terminal.resize(Rect::new(0, 0, width, height))?; + app.request_frame(); + } + Some(AppEvent::TurnEvent(te)) => { + app.handle_turn_event(te); + app.request_frame(); + } + Some(AppEvent::StreamTick) => { + if app.is_streaming() { + app.request_frame(); + } + } + Some(AppEvent::ScheduleFrame) => { + let now = Instant::now(); + if now.duration_since(last_frame) >= MIN_FRAME_INTERVAL { + terminal.draw(|f| app.render(f))?; + last_frame = now; + } + } + None => break, + } + } + + Ok(()) +} +``` + +**Why poll-based crossterm instead of EventStream:** +- `EventStream` requires `crossterm/event-stream` feature + `tokio-stream` +- Poll-based is simpler, no lifetime issues, works on all platforms +- 100ms poll interval means ≤100ms latency for keypresses — imperceptible + +**Why 4Hz stream tick instead of 120Hz:** +- codex-rs uses 120Hz for smooth typing animation — luxury for a terminal +- 4Hz (250ms) is sufficient for visible streaming progress and MUCH cheaper +- Can increase to 8-10Hz if users want smoother animation +- Tokens arrive from API at ~5-50 tokens/sec anyway — 4Hz catches most chunks + +--- + +## 6. The Streaming Problem: How to Get Live Tokens into the TUI + +This is the **hardest architectural decision**. `ApiClient::stream()` returns `Vec` (collected), not a live stream. We need tokens as they arrive. + +### Option A: Wrap `ApiClient` with a Streaming Interceptor + +Create a `TuiApiClient` that wraps the real client: + +```rust +struct TuiApiClient { + inner: C, + tx: Sender, +} + +impl ApiClient for TuiApiClient { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + // Call the real stream() — but we can't intercept mid-call + // because it returns Vec, not an iterator + + // UNLESS we modify the trait to accept a callback: + let events = self.inner.stream(request)?; + // This arrives as a complete Vec — too late for live rendering + Ok(events) + } +} +``` + +**Problem:** We can't intercept events mid-stream because `stream()` is synchronous and returns a collected `Vec`. The events are already all arrived by the time we get the return value. We'd need to modify the `ApiClient` trait. + +### Option B: Modify `ApiClient::stream()` to Return an Async Stream + +```rust +pub trait ApiClient { + fn stream(&mut self, request: ApiRequest) + -> Pin> + '_>>; +} +``` + +Then the `TurnWorker` can iterate events as they arrive and forward each one to the TUI before collecting for `build_assistant_message()`. + +**Pros:** Clean, correct, future-proof +**Cons:** Requires modifying the `ApiClient` trait — more invasive, affects CLI mode too + +### Option C: Run `ConversationRuntime::run_turn()` on a Thread with Captured stdout + +```rust +// In TurnWorker: +let runtime = self.runtime.take().unwrap(); +let tx = self.tx.clone(); + +std::thread::spawn(move || { + // Capture stdout at the OS level + let _guard = gag::BufferRedirect::stdout().unwrap(); + + // Run the turn — runtime prints to captured stdout + let result = runtime.run_turn(user_input, Some(&mut prompter)); + + // Read captured output and send to TUI + let mut output = String::new(); + _guard.into_inner().read_to_string(&mut output).unwrap(); + // Parse output into cells... +}); +``` + +**Problem:** We get the output as a blob after the turn, not streaming. Same fundamental issue. + +### Decision: Option B — Modify `ApiClient::stream()` to Return an Async Stream + +This is the only approach that gives us live streaming. The modification is: + +1. Change the `ApiClient` trait to return `impl Stream>` +2. In CLI mode, `.collect::>()` at the call site — behavior unchanged +3. In TUI mode, iterate the stream and forward each event via the channel before collecting + +**Migration path:** + +```rust +// New trait +pub trait ApiClient { + fn stream_events( + &mut self, + request: ApiRequest, + ) -> Pin> + Send + '_>>; + + // Convenience for CLI — backward compat + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + let stream = self.stream_events(request); + // Block on collection + futures::executor::block_on(async { + stream.try_collect().await + }) + } +} +``` + +This is a breaking change to the trait, but it's contained to the `runtime` crate. The CLI call site already handles the collected `Vec`, so adding `stream_events()` as the primary method with `stream()` as a backward-compat wrapper minimizes disruption. + +**Alternative if trait change is rejected:** Run `run_turn()` on a blocking task in `tokio::spawn_blocking()`. Accept that we won't see streaming tokens — the TUI will show a spinner during thinking and update cells after each tool result (via `TurnProgressReporter`). Text rendering updates only after the full response is collected. This is acceptable for v1 but means no live typing animation. + +--- + +## 7. Streaming Markdown Rendering + +### 7.1 Active Cell + Committed Cells (Codex-rs Pattern) + +During streaming, there is one **mutable active cell** (`AssistantStreamingCell`) that receives `push_delta()` calls. When streaming ends, the active cell is **consolidated** into an `AssistantMarkdownCell` and moved to the committed cells list. + +```rust +pub struct AssistantStreamingCell { + raw_markdown: String, // Accumulating source markdown + cached_text: Option>, // Rendered text (dirty flag) + cached_width: Option, // Width at which cache was built + dirty: bool, +} + +impl MessageCell for AssistantStreamingCell { + fn push_delta(&mut self, delta: &str) { + self.raw_markdown.push_str(delta); + self.dirty = true; + } + + fn desired_height(&self, width: u16) -> u16 { + let text = self.get_or_rebuild_text(width); + text.height() as u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let text = self.get_or_rebuild_text(area.width); + Paragraph::new(text).render(area, buf); + } + + fn is_streaming(&self) -> bool { true } + + fn consolidate(self: Box) -> Box { + Box::new(AssistantMarkdownCell { + raw_markdown: self.raw_markdown, + cached_text: None, + cached_width: None, + }) + } +} +``` + +### 7.2 Markdown → ratatui Text Conversion + +Use `pulldown-cmark` → custom `Text` builder (same approach as `tui-markdown` but with syntect highlighting): + +```rust +pub fn markdown_to_text(md: &str, theme: &ColorTheme) -> Text<'static> { + let mut lines = vec![]; + let mut in_code_block = false; + let mut code_lang = None; + + for event in pulldown_cmark::Parser::new_ext(md, Options::all()) { + match event { + Event::Start(Tag::Heading(level)) => { /* push styled heading line */ } + Event::Start(Tag::CodeBlock(lang)) => { + in_code_block = true; + code_lang = Some(lang.to_string()); + } + Event::End(Tag::CodeBlock(_)) => { in_code_block = false; } + Event::Text(text) => { + if in_code_block { + // syntect highlight → ansi-to-tui → Line + } else { + // Style with bold/italic/code spans + } + } + _ => {} + } + } + + Text::from(lines) +} +``` + +### 7.3 Adaptive Chunking (From Codex-rs) + +During streaming, batch tokens on a 250ms tick (4Hz). On each tick: +- If `< 3 new lines` → smooth mode: render 1 line (typing animation) +- If `≥ 3 new lines` → catchup mode: render all pending lines immediately +- On stream end: consolidate active cell → committed cell + +--- + +## 8. Scroll Implementation + +### 8.1 Cell-Based Scroll with Prefix-Sum Heights + +```rust +pub struct ChatScroll { + /// Per-cell visual heights (recomputed on resize) + cell_heights: Vec, + /// Prefix sums of cell_heights (for O(log n) offset → cell lookup) + height_offsets: Vec, + /// Forward offset from top (0 = top, max = pinned to bottom) + offset: usize, + /// Whether user has scrolled (disables auto-scroll) + user_scrolled: bool, +} + +impl ChatScroll { + /// Recompute when cells change or terminal resizes + pub fn rebuild(&mut self, cells: &[Box], width: u16) { + self.cell_heights = cells.iter().map(|c| c.desired_height(width)).collect(); + self.height_offsets = Vec::with_capacity(self.cell_heights.len() + 1); + self.height_offsets.push(0); + let mut acc = 0usize; + for &h in &self.cell_heights { + acc += h as usize; + self.height_offsets.push(acc); + } + // Clamp offset + let total = *self.height_offsets.last().unwrap_or(&0); + let viewport = ???; // needs viewport height + self.offset = self.offset.min(total.saturating_sub(viewport)); + } + + /// Find which cell is at the top of the viewport + pub fn top_cell_index(&self) -> usize { + self.height_offsets.partition_point(|&h| h <= self.offset) - 1 + } +} +``` + +### 8.2 Auto-Scroll Logic + +```rust +// On new content (streaming delta, tool result, etc.) +fn on_content_changed(&mut self) { + if !self.scroll.user_scrolled { + // Auto-scroll to bottom + let total = self.scroll.height_offsets.last().copied().unwrap_or(0); + self.scroll.offset = total.saturating_sub(self.viewport_height); + } + // Re-render + self.request_frame(); +} + +// On user keypress +fn handle_scroll_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::PageUp => { + self.scroll.user_scrolled = true; + self.scroll.offset = self.scroll.offset.saturating_sub(self.viewport_height / 2); + } + KeyCode::PageDown => { + self.scroll.offset += self.viewport_height / 2; + let max = self.scroll.max_offset(self.viewport_height); + if self.scroll.offset >= max { + self.scroll.offset = max; + self.scroll.user_scrolled = false; // Reached bottom + } + } + KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => { + self.scroll.user_scrolled = true; + self.scroll.offset = self.scroll.offset.saturating_sub(1); + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => { + self.scroll.offset += 1; + // Auto-reenable at bottom + } + _ => {} + } +} +``` + +--- + +## 9. Permission Prompts + +### 9.1 Inline Overlay (Recommended for v1) + +When `PermissionRequired` event arrives: + +1. Pause the turn worker (it's blocked on the `oneshot::Receiver`) +2. Render a styled panel at the bottom of the chat area: + ``` + ┌─ Permission Required ─────────────────────────┐ + │ Tool: bash │ + │ Input: rm -rf /tmp/test │ + │ │ + │ [Y] Allow [N] Deny [A] Allow for session │ + └─────────────────────────────────────────────────┘ + ``` +3. On `[Y/N/A]` keypress → send response via `oneshot::Sender` +4. Turn worker unblocks and continues + +### 9.2 Mouse Support + +Not for v1. Focus on keyboard-only interaction. + +--- + +## 10. Layout + +``` +┌──────────────────────────────────────────────────────────┐ +│ ╔════════════════════════════════════════════════════╗ │ +│ ║ [Chat Area — scrolls] ║ │ +│ ║ ║ │ +│ ║ User: What does this function do? ║ │ +│ ║ ║ │ +│ ║ Assistant: This function processes...█ ║ │ ← active cell (streaming) +│ ║ ║ │ +│ ╚════════════════════════════════════════════════════╝ │ +│ │ +│ ┌─ Input ─────────────────────────────────────────────┐ │ +│ │ > Ask me anything... │ │ ← tui-textarea +│ └──────────────────────────────────────────────────────┘ │ +│ │ Model: glm-5 │ Tokens: 1.2k/3.4k │ Status: streaming│ ← status bar +└──────────────────────────────────────────────────────────┘ +``` + +Constraint-based layout: +- Chat area: flex=1 (takes remaining space) +- Input area: natural height (1-5 lines based on textarea content) +- Status bar: fixed 1 line + +--- + +## 11. Output Bleeding Prevention (The Complete Solution) + +All three layers must work together: + +### Layer 1: Render to stderr +```rust +let backend = CrosstermBackend::new(std::io::stderr()); +``` +Stdout is now free — child processes can write to it without corrupting the TUI. The TUI never reads from fd 1 for rendering. + +### Layer 2: Capture child process stdout +When executing tools that spawn processes (bash, git, etc.): +```rust +// In tool executor: +let output = Command::new("bash") + .arg("-c") + .arg(command) + .stdout(Stdio::piped()) // Capture, don't inherit + .stderr(Stdio::piped()) + .output() + .await?; +``` +Tool output goes to `ToolResultCell` via the channel, not to the terminal. + +### Layer 3: Suppress internal runtime prints +The runtime's `TerminalRenderer` / `LiveCli` prints go through the `Write` trait. In TUI mode: +- Replace `io::stdout()` with a no-op writer for the turn duration, OR +- Better: implement `TurnProgressReporter` that forwards events instead of printing + +### Layer 4: Alternate screen + synchronized updates +```rust +execute!(stderr, EnterAlternateScreen)?; +enable_raw_mode()?; +// ... TUI loop ... +execute!(stderr, LeaveAlternateScreen)?; +disable_raw_mode()?; +``` + +Use `crossterm`'s `SynchronizedUpdate` to batch terminal writes and prevent partial frames. + +### Layer 5: TerminalStderrGuard (macOS) +On macOS, system frameworks may write to fd 2. Redirect fd 2 to `/dev/null` during TUI ownership (codex-rs pattern). On Linux, this is typically not needed. + +--- + +## 12. Testing Strategy + +### 12.1 Widget Unit Tests (ratatui TestBackend) +```rust +#[test] +fn renders_user_message() { + let cell = UserMessageCell::new("Hello world"); + let area = Rect::new(0, 0, 80, 5); + let mut buf = Buffer::empty(area); + cell.render(area, &mut buf); + + // Assert on buffer contents + assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "H"); +} +``` + +### 12.2 Snapshot Tests (insta) +```rust +#[test] +fn snapshot_chat_widget() { + let mut app = App::new_test(); + app.add_user_message("Hello"); + app.add_assistant_message("Hi there!"); + + let area = Rect::new(0, 0, 80, 24); + let mut buf = Buffer::empty(area); + app.render_chat(area, &mut buf); + + insta::assert_snapshot!(format!("{:?}", buf)); +} +``` + +### 12.3 Event-Based Integration Tests +```rust +#[tokio::test] +async fn test_scroll_after_streaming() { + let (app_tx, app_rx) = mpsc::channel(256); + let mut app = App::new_with_channel(app_tx.clone()); + + // Simulate streaming events + app_tx.send(AppEvent::TurnEvent(TuiTurnEvent::TurnStarted { user_input: "test".into() })).await.unwrap(); + app_tx.send(AppEvent::TurnEvent(TuiTurnEvent::TextDelta { text: "Hello world".into() })).await.unwrap(); + app_tx.send(AppEvent::TurnEvent(TuiTurnEvent::TurnCompleted { summary: /* ... */ })).await.unwrap(); + + // Process events + while let Ok(event) = app_rx.try_recv() { + app.handle_event(event); + } + + assert_eq!(app.cells.len(), 2); // user + assistant + assert!(!app.scroll_state.user_scrolled); +} +``` + +### 12.4 OpenClaw Integration (Future PR) +```bash +# clawcode --tui-stdio mode +# Spawns TUI, exposes JSON-over-stdio protocol: +# - Input: {"type":"key","key":"Enter"} or {"type":"prompt","text":"hello"} +# - Output: {"type":"frame","cells":[...]} or {"type":"state","scroll":0,"cells":2} +# OpenClaw can drive the TUI like a user, verify screen state, catch regressions +``` + +--- + +## 13. File Structure + +``` +rust/crates/rusty-claude-cli/src/tui/ +├── mod.rs // TUI module entry + `claw tui` command +├── app.rs // App state machine + event loop +├── event.rs // AppEvent, TuiTurnEvent enums +├── frame.rs // FrameRequester + rate limiter +├── scroll.rs // ScrollState + ChatScroll with prefix sums +├── run.rs // TurnWorker + TuiPermissionPrompter +├── render/ +│ ├── mod.rs // markdown → Text conversion +│ ├── cells.rs // MessageCell trait + all cell types +│ └── theme.rs // ColorTheme ported from render.rs +├── components/ +│ ├── mod.rs +│ ├── chat.rs // ChatWidget (renders cells + scroll) +│ ├── composer.rs // Input composer wrapping tui-textarea +│ ├── status_bar.rs // Model, tokens, status display +│ └── permission.rs // Permission prompt overlay +└── test_utils.rs // Test helpers, mock event sources +``` + +--- + +## 14. Implementation Phases + +### Phase 0: Prerequisites (1-2 days) ✅ +- [x] Streaming error fixes (reasoning_content, optional delta, JSON/HTML errors) +- [x] CLI module extraction (agent.rs, team.rs, permission.rs, etc.) +- [ ] Modify `ApiClient::stream()` to return async stream (Option B from §6) +- [ ] Add `TuiTurnEvent` enum to runtime crate + +### Phase 1: Skeleton (3-4 days) +- [ ] `tui/mod.rs` — `claw tui` subcommand entry point +- [ ] `app.rs` — App struct, event loop, stderr backend +- [ ] `event.rs` — All event types +- [ ] `frame.rs` — FrameRequester + 30 FPS cap +- [ ] Alternate screen enter/exit with cleanup on panic +- [ ] Basic layout: chat area + input area + status bar +- [ ] Composer with tui-textarea +- [ ] Exit on Ctrl+C / `/exit` + +**Milestone:** Can type a prompt, see it echoed, and exit cleanly. + +### Phase 2: Live Streaming (5-7 days) — THE CRITICAL PHASE +- [ ] `run.rs` — TurnWorker with channel bridge +- [ ] `render/cells.rs` — MessageCell trait + AssistantStreamingCell +- [ ] `render/mod.rs` — Markdown → Text with syntect highlighting +- [ ] `render/theme.rs` — ColorTheme from render.rs +- [ ] Streaming event handling: TextDelta → push_delta → render on tick +- [ ] ThinkingCell with collapsible display (default: collapsed) +- [ ] ToolResultCell with collapsed/expanded toggle +- [ ] Post-stream consolidation (streaming → markdown cell) +- [ ] Permission prompts with oneshot channel bridge + +**Milestone:** Can submit a prompt, see the response stream live in the TUI, handle tool calls and permissions. + +### Phase 3: Scroll & Polish (3-4 days) +- [ ] `scroll.rs` — Forward-offset ScrollState with prefix sums +- [ ] `components/chat.rs` — ChatWidget with cell-based scroll +- [ ] Auto-scroll with user-scroll detection +- [ ] Mouse wheel scroll (crossterm ScrollUp/ScrollDown events) +- [ ] Shift+Up/Down for fine scroll +- [ ] Status bar with model name, token count, spinner + +**Milestone:** Smooth scrolling, auto-scroll during streaming, manual scroll to review history. + +### Phase 4: Polish & Testing (2-3 days) +- [ ] Resize handling (rebuild cell heights, clamp scroll) +- [ ] CJK rendering test cases +- [ ] Widget unit tests + insta snapshots +- [ ] Event-based integration tests +- [ ] Help overlay (`?` key) +- [ ] Chat history persistence across sessions +- [ ] Slash command dispatch +- [ ] `cargo clippy` + `cargo test` green + +**Milestone:** PR-ready. All tests passing, no warnings. + +### Phase 5: OpenClaw Integration (Separate PR) +- [ ] `--tui-stdio` mode for programmatic driving +- [ ] OpenClaw `clawcode` tool extension +- [ ] Automated TUI regression tests in CI + +--- + +## 15. Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| `ApiClient` trait change rejected by upstream | Medium | High | Fallback: v1 without live streaming, spinner-only during thinking | +| `tui-textarea` conflicts with crossterm event handling | Low | Medium | Vendored copy (codex-rs does this) with patches | +| Terminal emulators with broken alternate screen | Low | Medium | Test on: kitty, alacritty, iterm2, windows terminal, foot | +| `gag::BufferRedirect` not available on Windows | Medium | Low | Feature-gate; Windows uses `Stdio::piped()` for child processes | +| Token streaming too fast for 4Hz tick | Low | Low | Adaptive chunking: catchup mode drains all pending on tick | +| `RefCell` borrow conflicts in render | Medium | Medium | Use interior mutability via `Cell>` instead of `RefCell` | + +--- + +## 16. Key Decisions Summary + +| Decision | Choice | Reason | +|----------|--------|--------| +| Render to stdout or stderr? | **stderr** | Frees stdout for child capture, prevents bleeding | +| Event loop: poll or EventStream? | **Poll** | Simpler, no lifetime issues, cross-platform | +| Frame rate | **30 FPS cap** | Terminal doesn't need 120Hz; saves CPU | +| Stream tick rate | **4 Hz (250ms)** | Smooth enough for streaming text; can increase | +| Scroll model | **Forward offset (0=top)** | Matches Paragraph::scroll(), matches user intuition | +| Scroll unit | **Cells, not lines** | Stable on resize; each cell handles its own wrapping | +| Live streaming | **Modify ApiClient trait** | Only way to get live tokens; fallback: spinner-only v1 | +| Input widget | **tui-textarea** | Proven in codex-rs; CJK, selection, multi-line | +| Markdown rendering | **Custom pulldown-cmark → Text** | tui-markdown is experimental; we need syntect | +| Permission prompts | **Inline overlay** | Simpler than popup; blocks turn worker via oneshot | +| Testing | **insta snapshots + event injection** | Proven pattern; no real terminal needed | diff --git a/docs/tui/README.md b/docs/tui/README.md new file mode 100644 index 00000000..982aaa0b --- /dev/null +++ b/docs/tui/README.md @@ -0,0 +1,43 @@ +# TUI Documentation for Claw Code + +This directory contains all research, analysis, and architecture documentation for the TUI mode (`claw tui`). + +## Architecture Plan + +- **[ARCHITECTURE.md](./ARCHITECTURE.md)** — The definitive architecture plan, synthesized from 5 expert MoA (Mixture of Agents) reports. This is the implementation blueprint. + +## Original Plans + +- **[TUI.md](./TUI.md)** — Initial research & build plan +- **[TUI-52-WEEK-PLAN.md](./TUI-52-WEEK-PLAN.md)** — 52-week roadmap +- **[TUI-52-WEEK-SPRINT.md](./TUI-52-WEEK-SPRINT.md)** — Sprint-level breakdown + +## MoA Research Reports + +Five independent expert analyses were conducted before writing the architecture plan: + +| Report | Focus | Key Takeaway | +|--------|-------|--------------| +| [moa-codex-rs.md](./moa-codex-rs.md) | OpenAI's codex-rs/tui — closest prior art | `tokio::select!` event loop, demand-driven frames, active/committed cell split, adaptive 2-gear chunking | +| [moa-aichat.md](./moa-aichat.md) | sigoden/aichat streaming markdown rendering | "Erase-and-redraw" with 50ms batch, LineType state machine, textwrap for CJK | +| [moa-ecosystem.md](./moa-ecosystem.md) | Broader Rust TUI ecosystem survey | **Render to stderr** to solve output bleeding; tui-markdown, ansi-to-tui, virtual scroll, testing patterns | +| [moa-failure-analysis.md](./moa-failure-analysis.md) | Root-cause analysis of 3 failed TUI attempts | All failures share one root: runtime prints to stdout; TUI grafted on top | +| [moa-runtime-integration.md](./moa-runtime-integration.md) | claw-code runtime integration points | `TurnProgressReporter` only fires post-tool; `ApiClient::stream()` returns collected Vec — no live streaming channel | + +## Reading Order + +1. **ARCHITECTURE.md** — The plan (start here) +2. **moa-failure-analysis.md** — Why the previous 3 attempts failed +3. **moa-codex-rs.md** — The closest working reference implementation +4. **moa-ecosystem.md** — Broader patterns and crate recommendations +5. **moa-aichat.md** — Streaming markdown rendering deep-dive +6. **moa-runtime-integration.md** — How to hook into claw-code's runtime + +## Key Decisions + +- **Render to stderr** — stdout freed for child processes, eliminates output bleeding +- **Event-driven architecture** — runtime emits `TuiTurnEvent` via channel, TUI consumes +- **Forward-offset scroll** — 0=top, max=bottom (kills the inverted-scroll bugs) +- **Active cell + committed cells** — streaming cell flushed to markdown cell on completion +- **Modify `ApiClient::stream()`** — return async stream for live token rendering +- **`--tui-stdio` mode** — JSON-over-stdio for programmatic driving (separate PR) diff --git a/docs/tui/TUI-52-WEEK-PLAN.md b/docs/tui/TUI-52-WEEK-PLAN.md new file mode 100644 index 00000000..fa421af2 --- /dev/null +++ b/docs/tui/TUI-52-WEEK-PLAN.md @@ -0,0 +1,288 @@ +# TUI Failure Analysis & 52-Week Delivery Plan + +**Status:** Draft | **Date:** 2026-06-12 | **Branch:** `feat/tui` + +This document is a post-mortem of every prior TUI attempt in this repository, followed by a week-by-week plan designed specifically to avoid those failure modes. It is meant to be read as a working agreement: if you deviate from this plan, you should write down why. + +--- + +## Part 1: Post-Mortem — Every Prior TUI Branch + +### Branch: `feat/ui-hardening` (earliest attempt) + +**What happened:** +- Built on top of a 3,159-line `main.rs` that combined REPL loop, arg parsing, slash command handlers, streaming output, tool call display, permission prompting, and session management. +- Tried to make the REPL "feel more reliable and discoverable" by patching behavior inside the monolith. +- No structural extraction happened. The REPL code grew fatter, not cleaner. + +**Why it failed:** +- Hardening a monolith is a treadmill: every fix adds more code to the same file, increasing the surface area of the next bug. +- No alternate-screen mode was attempted; the work was constrained to inline scrolling, which limits what a "TUI" can even mean. +- The branch produced useful commits (`a13b1c2`) but they were absorbed into `fix/ui-parity` rather than shipping independently. + +**Lesson:** Do not patch the monolith. Extract first, then improve. + +--- + +### Branch: `fix/ui-parity` (the "parity pass") + +**What happened:** +- Explicitly scoped DOWN from a real TUI. Commit `bcaf6e0` literally says: *"Rejected: Rework the REPL into a full multi-pane typeahead overlay | too large for this UI-only parity slice."* +- Instead, it ported REPL affordances to match TypeScript patterns: ranked slash suggestions, alias completion, trailing-space acceptance, argument hints. +- This was good work (merged), but it was a REPL polish pass, not a TUI. + +**Why the TUI part failed:** +- The author correctly identified that a full TUI needed structural extraction first, but that extraction was never done. +- Without Phase 0 (breaking `main.rs` into modules), any TUI work would have meant adding ratatui code directly into `main.rs`, creating an unmaintainable 4,000+ line file. +- The plan became "ship the parity pass now, do TUI later" — and later never came because the monolith became even harder to refactor. + +**Lesson:** If you need extraction first, do the extraction first. Do not defer it. + +--- + +### Branch: `feat/uiux-redesign` (the big swing) + +**What happened:** +- The most ambitious branch. It added: + - Vim keybinding mode (normal/insert/visual/command) + - LSP client integration (diagnostics, definitions, references) + - Git slash commands (branch, commit, commit-push-pr, worktree) + - An HTTP/SSE server crate with axum +- But it also produced **18 commits** that were purely README credit warfare: adding, removing, and re-adding `oh-my-opencode` and `Jobdori` credits. + +**Why it failed:** +- Credit wars consumed more branch history than feature work. This is a classic community/coordination failure: when contributors can't agree on attribution, the actual product stalls. +- The LSP integration and vim mode were large features that required review cycles, but the branch was mired in docs disputes. +- The branch never merged cleanly. Its useful features (git slash commands, LSP) were absorbed piecemeal into other branches, while the TUI part was abandoned. + +**Lesson:** Establish a CONTRIBUTING.md attribution policy upfront and enforce it. Do not let credit bikeshedding block the build. + +--- + +### Branch: `rcc/ui-polish` + +**What happened:** +- Empty diff against `main`. This was likely a tracking or experiment branch that never got code. + +**Why it failed:** +- Branch created, no work pushed, then abandoned. Probably because the author saw how much was already on `feat/uiux-redesign` and decided to wait. +- Waiting for another branch is a form of coordination failure. + +**Lesson:** If you start a branch, write a singleline commit to it within 24 hours, or delete it. + +--- + +### Document: `rust/TUI-ENHANCEMENT-PLAN.md` + +**What happened:** +- A very good, comprehensive plan written 2026-03-31 by someone who clearly understood the codebase. +- It correctly identified the 3,159-line `main.rs` as the #1 risk. +- It proposed 6 phases (0=structural cleanup, 1=status bar, 2=streaming, 3=tool viz, 4=slash commands, 5=themes, 6=full-screen). + +**Why it failed:** +- It was a *plan*, not a *commitment*. No branch was ever created to execute it. +- Phases 1–4 were all "moderate effort" or "large effort" tasks bundled together. Phase 6 (ratatui) was explicitly labeled a "stretch" that requires Phase 0 first — but Phase 0 itself was a multi-file refactor that no one signed up to do. +- The plan said "Feature-gate heavy dependencies — `ratatui` should be behind a `full-tui` feature flag." This is a premature optimization: adding a feature flag before you even know if the code works adds build-system friction that kills momentum. + +**Lesson:** A 6-phase plan with no owner and no timeline is a wishlist. Convert it into weekly tasks with a single owner. + +--- + +### Root Failure Modes (Summary) + +| # | Failure Mode | How We Avoid It | +|---|---|---| +| 1 | **The Monolith Trap** | Week 1–3 of the plan below are *exclusively* `main.rs` extraction. No TUI code is written until the monolith is gone. | +| 2 | **Scope Panic** | "Too large for this slice" is the killer phrase. Every slice below is ≤200 lines and ships independently. | +| 3 | **Credit Wars** | Attribution is handled by the single AUTHOR/CO-AUTHOR commit trailers. No README edits during TUI work. | +| 4 | **Placeholder Branches** | No empty branches. Every branch has at least one passing test and one visible change. | +| 5 | **Plan Without Execution** | Each week has a concrete deliverable, a merge point, and a rollback strategy. | +| 6 | **Feature Flags Before Function** | No `--tui` feature flag until Week 12. Build the code first; gate it later. | +| 7 | **REPL Replacement Fear** | The TUI is `claw tui`, a separate binary entrypoint. The REPL (`claw` with no args) is untouched forever. | +| 8 | **Streaming-Not-First** | The TUI's first real conversation feature is streaming rendering. Static screens come later. | +| 9 | **Dependency Bloat Fear** | Only 3 new crates: `ratatui`, `tui-textarea`, `crossterm` event-stream feature. No optional deps until Week 20. | +| 10 | **No Test Coverage for UI** | Every widget module has a `#[cfg(test)]` block that renders to a `TestBackend` (ratatui's in-memory buffer). | + +--- + +## Part 2: The 52-Week Plan + +### Sprint Structure + +- **Sprints are 2 weeks** (26 sprints total). +- **Every sprint ships.** If a sprint can't ship, you cut scope, not delay. +- **Merge target:** `feat/tui` → `main` via PR at end of each sprint. +- **Review rule:** No PR >400 lines. If a task is bigger, split it. + +--- + +### Phase A: Foundation (Weeks 1–6) — Extract or Die + +**Goal:** Kill the monolith. No TUI code is written until `main.rs` is <200 lines. + +| Week | Deliverable | Lines (est) | How It Ships | +|---|---|---|---| +| **1** | Extract `app.rs` from `main.rs`: move `LiveCli` struct and its `impl` to `app.rs`. `main.rs` now only imports and calls `LiveCli::run()`. | ~300 | PR: `refactor: extract LiveCli to app.rs` | +| **2** | Extract `format.rs`: move all `format_*` functions (status, cost, model, permissions, compact, etc.) out of `main.rs` and `app.rs`. | ~400 | PR: `refactor: extract report formatting to format.rs` | +| **3** | Extract `session_mgr.rs`: move session CRUD (create, resume, list, switch, persist, compaction) out of `app.rs`. | ~350 | PR: `refactor: extract session management to session_mgr.rs` | +| **4** | Create `tui/mod.rs` as an empty namespace with a `pub fn run() -> io::Result<()>` stub. Add `claw tui` CLI dispatch that calls it and prints "TUI mode coming soon." | ~50 | PR: `feat: add tui module stub and claw tui command` | +| **5** | Wire `ConversationRuntime` into `app.rs` through a trait boundary. This is prep for sharing the runtime between REPL and TUI without duplication. | ~200 | PR: `refactor: runtime access through trait boundary` | +| **6** | Add ratatui dependency, `crossterm` event-stream feature. Create a minimal alternate-screen demo in `tui/mod.rs` that shows a "Hello, claw" `Paragraph` and exits on `q`. Tests use `TestBackend`. | ~150 | PR: `feat: ratatui skeleton with TestBackend tests` | + +**Sprint 1–3 merge checkpoint:** `main.rs` is <200 lines. All tests pass. No behavioral changes. + +--- + +### Phase B: Core TUI Shell (Weeks 7–12) — Make It Run + +**Goal:** The TUI opens, renders, and exits cleanly. No conversation yet. + +| Week | Deliverable | Lines (est) | How It Ships | +|---|---|---|---| +| **7** | `tui/event.rs`: `EventBroker` that bridges `crossterm::event::EventStream` → `AppEvent` enum (`Tick`, `Key`, `Resize`, `Quit`). Unit-test key translation. | ~180 | PR: `feat: tui event broker with async event stream` | +| **8** | `tui/app.rs`: `App` state machine with one screen. Renders a static list of mock messages (using `Paragraph`). Handles `q` to quit, arrow keys to scroll. | ~200 | PR: `feat: tui app state machine with mock messages` | +| **9** | `tui/widgets/input.rs`: Composer widget wrapping `tui_textarea::TextArea`. Enter sends, Shift+Enter inserts newline. Mock: prints submitted text to a debug overlay. | ~150 | PR: `feat: tui composer input widget` | +| **10** | `tui/widgets/chat.rs`: `ChatWidget` renders a scrollable `Vec` as vertical `Layout` of `Paragraph`s. Supports PgUp/PgDn. | ~200 | PR: `feat: tui chat message list widget` | +| **11** | Terminal resize handling. Status bar at bottom showing model name (static), permission mode (static), terminal dimensions. | ~120 | PR: `feat: terminal resize handling and static status bar` | +| **12** | Integration week: `claw tui` opens a full-screen app with mock chat + input + status bar. Add smoke test in `tests/` that runs in CI (skips if no TTY). Add `--tui` feature flag? **No.** Feature flag deferred until we have a reason. | ~100 | PR: `feat: claw tui opens full-screen TUI with mock data` | + +**Sprint 4–6 merge checkpoint:** `claw tui` runs. It looks like a chat app with fake data. It exits cleanly. CI passes. + +--- + +### Phase C: Live Conversation (Weeks 13–20) — Make It Talk + +**Goal:** The TUI has real conversations with the model, streaming text, tool calls, and session persistence. + +| Week | Deliverable | Lines (est) | How It Ships | +|---|---|---|---| +| **13** | Wire `ConversationRuntime` into TUI event loop. Create `TuiProgressReporter` that sends `AssistantEvent` deltas into the TUI channel. | ~250 | PR: `feat: wire ConversationRuntime into tui event loop` | +| **14** | Streaming assistant text: `AssistantEvent::TextDelta` appends to the active message cell in real time. No markdown rendering yet — raw text. | ~180 | PR: `feat: real-time streaming assistant text in tui` | +| **15** | Message persistence: on exit, save the session to the same JSONL format as the REPL. On `claw tui --resume`, load it. | ~200 | PR: `feat: tui session save and resume` | +| **16** | Tool call rendering: when a tool call starts, show a spinner + tool name inline. When it finishes, show ✓/✗ with truncated result (first 5 lines). | ~220 | PR: `feat: inline tool call spinner and result summary` | +| **17** | Permission prompt overlay: modal widget (centered block) for Y/N approval when a tool needs permission. Does not block the event loop — shows up as a `Screen::PermissionPrompt` state. | ~200 | PR: `feat: permission prompt modal overlay in tui` | +| **18** | Error handling: network errors, API rate limits, and runtime errors render as styled error messages in the chat, not panics. | ~150 | PR: `feat: graceful error rendering in tui chat` | +| **19** | Multi-turn conversation: send follow-up messages. Scrollback preserves full history. Input clears after send. | ~100 | PR: `feat: multi-turn conversation in tui` | +| **20** | **Feature flag week:** Add `tui` feature to `rusty-claude-cli/Cargo.toml`. Default OFF. CI builds with `--features tui`. Binary size documented. | ~50 | PR: `feat: gate tui behind optional feature flag` | + +**Sprint 7–10 merge checkpoint:** `cargo run -p rusty-claude-cli -- tui --resume` loads a session and chats with the model. Tool calls show inline. Errors are graceful. CI passes. + +--- + +### Phase D: Rich Rendering (Weeks 21–28) — Make It Beautiful + +**Goal:** Markdown rendering, syntax highlighting, themes, and tool visualization. + +| Week | Deliverable | Lines (est) | How It Ships | +|---|---|---|---| +| **21** | Port existing `render.rs` (`pulldown-cmark` + `syntect`) to a `tui/widgets/markdown.rs` widget. Renders headings, lists, inline code, code blocks with syntax highlighting in ratatui `Paragraph`s. | ~300 | PR: `feat: markdown widget with syntax highlighting` | +| **22** | Table rendering: `pulldown-cmark` table events → ratatui `Table` widget with borders. | ~150 | PR: `feat: table rendering in markdown widget` | +| **23** | Blockquote rendering: styled indentation + left border. Link rendering: underlined + clickable (if mouse support enabled later). | ~100 | PR: `feat: blockquote and link rendering` | +| **24** | Streaming markdown: incremental parse of markdown as text deltas arrive. Don't re-render from scratch every frame — append to parser state. | ~200 | PR: `feat: incremental markdown streaming render` | +| **25** | Dark/light theme system. Respect `NO_COLOR`. Detect truecolor vs. 256-color vs. 16-color terminals and degrade gracefully. | ~250 | PR: `feat: color theme system with terminal capability detection` | +| **26** | Tool result expansion: press `Enter` on a tool result to expand/collapse full output. Use `tui-textarea` or custom widget for scrollable code view. | ~180 | PR: `feat: collapsible/expandable tool results` | +| **27** | Colored `/diff` display: when `edit_file` or `/diff` produces output, render unified diff with green additions and red removals in the chat. | ~200 | PR: `feat: colored diff rendering in tui` | +| **28** | Performance pass: profile rendering with `cargo flamegraph`. Ensure 60fps on a 100-message session. Optimize if below 30fps. | ~100 | PR: `perf: rendering performance pass` | + +**Sprint 11–14 merge checkpoint:** The TUI renders markdown, tables, code blocks with syntax highlighting, and colored diffs. Themes work. Performance is smooth. + +--- + +### Phase E: Navigation & Power Features (Weeks 29–38) — Make It Fast to Use + +**Goal:** Keyboard shortcuts, search, session picker, and slash command integration. + +| Week | Deliverable | Lines (est) | How It Ships | +|---|---|---|---| +| **29** | `/` search within conversation: incremental search highlighting matching text. `n`/`N` for next/prev match. | ~200 | PR: `feat: conversation search with incremental highlighting` | +| **30** | `?` help overlay: keyboard shortcuts panel showing all keybindings. | ~150 | PR: `feat: keyboard shortcuts help overlay` | +| **31** | Session picker: `Ctrl+S` opens a fuzzy-filterable list of saved sessions (up/down arrows, Enter to switch). | ~200 | PR: `feat: interactive session picker` | +| **32** | Slash command integration: type `/` in the input to trigger slash command picker (like the REPL). Commands execute within the TUI (e.g., `/model`, `/permissions`). | ~250 | PR: `feat: slash command picker and execution in tui` | +| **33** | `/compact` in TUI: runs compaction, shows a toast notification with tokens saved. | ~100 | PR: `feat: compact command with toast notification` | +| **34** | Copy to clipboard: `y` on a message or code block copies it to clipboard via `arboard`. | ~120 | PR: `feat: copy message to clipboard` | +| **35** | Mouse support: click to expand tool results, scroll wheel on conversation, click to focus input. | ~150 | PR: `feat: mouse support for tool expansion and scrolling` | +| **36** | Pager mode: for very long outputs (e.g., `/status`, `/config`), open a full-screen pager with `j`/`k`/`q`. | ~180 | PR: `feat: internal pager for long outputs` | +| **37** | Multi-pane layout (optional): `Ctrl+P` toggles a right sidebar showing active tool status or todo list. This is the first "full TUI" power feature. | ~250 | PR: `feat: optional right sidebar for tool status` | +| **38** | Custom keybindings: read keybindings from `.claw.json` (e.g., remap `Ctrl+S` to something else). | ~150 | PR: `feat: user-configurable keybindings` | + +**Sprint 15–19 merge checkpoint:** The TUI is a power-user tool. Keyboard shortcuts, search, mouse, session picker, and slash commands all work. + +--- + +### Phase F: Polish & Hardening (Weeks 39–46) — Make It Production-Ready + +**Goal:** Stable, tested, accessible, and well-documented. + +| Week | Deliverable | Lines (est) | How It Ships | +|---|---|---|---| +| **39** | Accessibility: screen reader support via `crossterm` title updates and status announcements. | ~100 | PR: `feat: screen reader accessibility annotations` | +| **40** | Small-terminal graceful degradation: if terminal is <30 rows or <80 cols, show a warning banner and disable sidebar. | ~120 | PR: `feat: small terminal graceful degradation` | +| **41** | Unicode width handling: use `unicode-width` to prevent CJK and emoji from breaking layouts. | ~100 | PR: `fix: unicode width for layout correctness` | +| **42** | Snapshot testing: use `insta` to snapshot-render complex conversations (tables, code blocks, diffs) and catch regressions. | ~150 | PR: `test: snapshot tests for tui rendering` | +| **43** | Stress testing: 1,000-message session. Ensure no memory leaks, OOM, or frame drops. | ~50 (test-only) | PR: `test: stress test for large sessions` | +| **44** | Windows testing: verify in PowerShell and Windows Terminal. Fix cursor/resize issues. | ~100 | PR: `fix: windows terminal compatibility` | +| **45** | Documentation: `docs/TUI-USAGE.md` — comprehensive user guide with screenshots (ASCII art), keybindings, tips. | ~200 (docs only) | PR: `docs: tui user guide` | +| **46** | `claw doctor` TUI diagnostic: `claw doctor --tui` runs a self-test that opens the TUI, verifies rendering, and reports any issues. | ~150 | PR: `feat: tui diagnostic mode in doctor` | + +**Sprint 20–23 merge checkpoint:** The TUI is production-ready. Tests cover rendering, large sessions, and cross-platform compatibility. Docs are complete. + +--- + +### Phase G: Advanced Features (Weeks 47–52) — Make It Best-in-Class + +**Goal:** Features that differentiate claw from other AI CLI TUIs. + +| Week | Deliverable | Lines (est) | How It Ships | +|---|---|---|---| +| **47** | Image support: when vision input lands in the API (see ROADMAP #220), the TUI renders image attachments inline using `sixel` or `iTerm2` image protocols, with a text fallback. | ~200 | PR: `feat: inline image rendering in tui` | +| **48** | Notebook editing: `/notebook` opens the notebook editor in a split pane (like VS Code's notebook view). | ~250 | PR: `feat: notebook editing in tui` | +| **49** | Team/agent dashboard: `Ctrl+A` shows a real-time view of running agents, their status, and their outputs (like `htop` for agents). | ~250 | PR: `feat: agent team dashboard in tui` | +| **50** | MCP server inspector: `Ctrl+M` opens a pane showing connected MCP servers, their tools, and recent calls. | ~200 | PR: `feat: mcp server inspector sidebar` | +| **51** | Teleport mode: `Ctrl+G` opens a fuzzy file finder (like `fzf`) to jump to any file in the workspace, then sends a `/read` tool call. | ~180 | PR: `feat: fuzzy file finder teleport in tui` | +| **52** | Release polish: final pass of the whole plan. Update ROADMAP.md. Cut a release note. The TUI is now the default for `claw` when a TTY is detected (with `--no-tui` to opt out). | ~100 | PR: `feat: make tui the default interactive mode` | + +**Final merge checkpoint:** `claw` with no args opens the TUI by default (when TTY detected). The TUI supports images, notebooks, agent dashboards, MCP inspection, and fuzzy file search. This is a 52-week execution of the vision. + +--- + +## Part 3: Rules of Engagement + +To avoid repeating the failures above, these rules are binding for anyone working on the TUI: + +1. **No README edits during TUI work.** Attribution is via `Co-Authored-By` trailers in commits only. README credit updates happen in separate PRs, never mixed with TUI feature PRs. + +2. **No empty branches.** If you create a branch, commit something testable within 24 hours. If you haven't, delete the branch. + +3. **No PR >400 lines.** Any task that produces more than 400 lines must be split. This is enforced by reviewer policy, not tooling. + +4. **No feature flags before Week 20.** The `tui` feature flag is added only after the TUI is demonstrably working. Before that, `ratatui` is a normal dependency. This removes the "feature flag complexity" tax that killed momentum in prior plans. + +5. **Every widget has a `#[cfg(test)]` block.** Use `ratatui::backend::TestBackend` to assert rendered buffer state. No widget ships without at least one buffer assertion. + +6. **Every sprint ends with `cargo test --workspace` passing.** Not "mostly passing." Green CI is the definition of done. + +7. **The REPL is sacred.** `claw` with no args may eventually default to TUI (Week 52), but the REPL mode (`claw --repl` or piped input) is supported forever. No TUI change may break the REPL. + +8. **Streaming is the default UX.** The TUI should never buffer an entire response before rendering it. Every message cell supports incremental appending from the first week it exists. + +9. **Runtime types are reused directly.** No wrapper structs around `ConversationRuntime`, `Session`, or `ToolExecutor`. If the runtime API is awkward for the TUI, fix the runtime API, not the TUI. + +10. **Defer, don't abandon.** If a week is running long, cut scope and merge what works. Move the cut items to a "stretch" backlog. Never let a week slip into the next week. + +--- + +## Part 4: Emergency Rollback Strategy + +If the TUI work introduces instability: + +1. **Week 1–6 (extraction):** Each extraction is a pure refactor. `git revert` of any single PR restores the old code in `main.rs` because the old code was moved, not deleted, during the transition. + +2. **Week 7–12 (skeleton):** The TUI is opt-in via `claw tui`. Reverting the `tui/` directory and the CLI dispatch removes the feature entirely without affecting the REPL. + +3. **Week 13+ (live conversation):** If the streaming integration breaks runtime stability, the `TuiProgressReporter` can be swapped for a no-op reporter in a single-line revert. + +4. **Nuclear option:** `git checkout main -- rust/crates/rusty-claude-cli/src/tui/` and remove the `claw tui` dispatch. The REPL and all other functionality are untouched. + +--- + +*Generated: 2026-06-12 | Based on post-mortem of `feat/ui-hardening`, `fix/ui-parity`, `feat/uiux-redesign`, `rcc/ui-polish`, and `rust/TUI-ENHANCEMENT-PLAN.md` | Author: TheArchitectit* diff --git a/docs/tui/TUI-52-WEEK-SPRINT.md b/docs/tui/TUI-52-WEEK-SPRINT.md new file mode 100644 index 00000000..2d448076 --- /dev/null +++ b/docs/tui/TUI-52-WEEK-SPRINT.md @@ -0,0 +1,1676 @@ +# TUI 52-Week Delivery Sprint Plan — Extreme Detail + +**Repository:** `ultraworkers/claw-code` +**Branch:** `feat/tui` → `main` +**Status:** Draft for upstream PR review +**Date:** 2026-06-12 +**Authors:** TheArchitectit, Claude + +--- + +## 1. Executive Summary & Prior Failure Analysis + +### 1.1 The Monolith Is Bigger Than We Thought + +The `rusty-claude-cli/src/main.rs` file is **11,282 lines** (as of 2026-06-12), not the 3,159 lines documented in the March 2026 plan. It contains: + +- `LiveCli` struct and its `impl` block (~4,500 lines) +- All slash command handlers (~2,800 lines) +- All `format_*` report functions (~1,200 lines) +- Session management (create, resume, list, switch, persist) (~1,100 lines) +- Streaming output handling (~800 lines) +- Tool call display and permission prompting (~600 lines) +- Arg parsing and CLI dispatch (~282 lines) + +This means **every prior TUI attempt was trying to build on quicksand**. The monolith grew while we were planning. + +### 1.2 Post-Mortem of Every Prior Attempt + +| Branch | Period | What Happened | Root Cause | +|---|---|---|---| +| `feat/ui-hardening` | Earliest | Patched REPL reliability inside monolith. Produced commit `a13b1c2` but was absorbed into `fix/ui-parity`. | Hardening a monolith is a treadmill — every fix adds more code to the same file. | +| `fix/ui-parity` | Apr 2026 | Scoped DOWN from real TUI. Commit message: *"Rejected: full multi-pane typeahead overlay — too large for this UI-only parity slice."* Ported slash command completion and hints. | Correctly identified extraction was needed first, but extraction was deferred and never done. | +| `feat/uiux-redesign` | Mar–Apr 2026 | Big swing: vim mode, LSP integration, git slash commands, HTTP server. 18 commits were README credit wars (`oh-my-opencode` add/remove/re-add). | Credit bikeshedding consumed more history than feature work. The branch never merged cleanly. | +| `rcc/ui-polish` | Mar 2026 | Empty branch. No code pushed. | Created, then abandoned when `feat/uiux-redesign` looked like it would subsume it. Waiting became permanent. | +| `rust/TUI-ENHANCEMENT-PLAN.md` | Mar 31 2026 | Excellent 6-phase plan identifying monolith as #1 risk. Proposed Phase 0 extraction. | Was a plan without a branch. No owner, no timeline, no commits. Phase 0 was labeled "essential" but no one signed up. | + +### 1.3 The Ten Failure Modes & Our Guardrails + +| # | Failure Mode | Guardrail | +|---|---|---| +| 1 | **The Monolith Trap** | Weeks 1–6 are *exclusively* extraction. No TUI code until `main.rs` <200 lines. | +| 2 | **Scope Panic** | Every slice ≤400 lines. The phrase "too large for this slice" triggers an immediate scope cut. | +| 3 | **Credit Wars** | No README edits during TUI work. Attribution is via `Co-Authored-By` commit trailers only. | +| 4 | **Placeholder Branches** | Every branch has ≥1 passing test and ≥1 visible change within 24 hours of creation. | +| 5 | **Plan Without Execution** | Each week has a concrete deliverable, a merge point, and a rollback strategy. | +| 6 | **Feature Flags Before Function** | No `--tui` feature flag until Week 20. Build it first; gate it later. | +| 7 | **REPL Replacement Fear** | TUI is `claw tui`, a separate entrypoint. REPL is untouched forever. | +| 8 | **Streaming-Not-First** | First real conversation feature is streaming rendering. Static screens come later. | +| 9 | **Dependency Bloat Fear** | Only 3 new crates: `ratatui`, `tui-textarea`, `unicode-width`. No optional deps until Week 20. | +| 10 | **No Test Coverage for UI** | Every widget has `#[cfg(test)]` with `TestBackend` assertions. No widget ships without one. | + +### 1.4 Confirmation From Peer Projects + +Research into `codex-rs/tui`, `aichat`, `crush`, and ecosystem guides confirms: + +- **`ratatui` + `crossterm`** is the standard Rust TUI stack (`codex-rs` uses this). +- **Extraction-first** is mandatory — `codex-rs/tui` has `app.rs`, `chat_widget.rs`, `composer.rs`, `event_broker.rs` as separate modules. +- **Thin TUI shell, shared core** — `crush` separates shell/parser; we separate TUI/`ConversationRuntime`. +- **Streaming-first** — `aichat` renders markdown incrementally from SSE deltas. + +--- + +## 2. Architecture Target State + +### 2.1 Module Structure (End of Week 6) + +```text +rust/crates/rusty-claude-cli/src/ +├── main.rs # Entrypoint only. Calls LiveCli::run() or tui::run(). +│ # Target: <150 lines. +│ +├── cli.rs # CLI argument parsing + CliAction enum. +│ # Target: <300 lines. +│ +├── app.rs # LiveCli struct, REPL loop, turn execution. +│ # Moves from main.rs. Target: <800 lines. +│ +├── format.rs # All report formatting functions. +│ # Moves from main.rs. Target: <600 lines. +│ +├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist, compact. +│ # Moves from main.rs. Target: <500 lines. +│ +├── init.rs # Repo initialization (unchanged). +│ +├── input.rs # Line editor with rustyline (unchanged, minor extensions). +│ +├── render.rs # TerminalRenderer, Spinner, MarkdownStreamState. +│ # Extended but mostly unchanged. Target: <700 lines. +│ +├── setup_wizard.rs # Interactive provider wizard (already exists, extracted in recent commits). +│ +└── tui/ # NEW: All TUI components. + ├── mod.rs # Public entry: run(env) -> io::Result<()> + │ # Terminal init (alternate screen, raw mode), + │ # event loop runner, cleanup on exit. + │ + ├── app.rs # App state machine. + │ # Screen enum, message state, input state, theme. + │ # Target: <400 lines. + │ + ├── event.rs # EventBroker. + │ # Bridges crossterm::event::EventStream → AppEvent. + │ # Target: <250 lines. + │ + ├── theme.rs # Color themes, terminal capability detection. + │ # Target: <200 lines. + │ + └── widgets/ + ├── chat.rs # ChatWidget: scrollable message list. + │ # Renders Vec as vertical Layout of Paragraphs. + │ # Target: <300 lines. + │ + ├── input.rs # ComposerWidget: wraps tui_textarea::TextArea. + │ # Handles Enter-to-send, Shift+Enter newline, history. + │ # Target: <200 lines. + │ + ├── markdown.rs # MarkdownWidget: pulldown-cmark + syntect → ratatui Paragraph. + │ # Adapted from render.rs. Target: <400 lines. + │ + ├── status_bar.rs # Bottom status line: model, tokens, cost, session. + │ # Target: <150 lines. + │ + ├── tool_call.rs # ToolCallWidget: inline spinner + result display. + │ # Target: <200 lines. + │ + ├── permission.rs # PermissionPromptWidget: modal Y/N overlay. + │ # Target: <150 lines. + │ + └── sidebar.rs # (Week 37+) Optional right sidebar. + # Target: <250 lines. +``` + +### 2.2 Runtime Integration (Zero Wrappers) + +The TUI consumes these existing types **directly**: + +``` +tui::app::App + ├─ uses runtime::ConversationRuntime (via shared Arc) + ├─ uses runtime::Session (for persistence) + ├─ uses runtime::SessionStore (for resume) + ├─ uses runtime::ToolExecutor (for tool call dispatch) + ├─ uses runtime::PermissionMode (for status display) + ├─ uses runtime::TurnProgressReporter (implemented as TuiProgressReporter) + └─ uses render.rs logic (adapted to MarkdownWidget) +``` + +If the `ConversationRuntime` API is awkward for the TUI, we fix the runtime API — **never** create a wrapper. + +### 2.3 Event Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EVENT FLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐ │ +│ │ Crossterm │────▶│ EventBroker │────▶│ App │ │ +│ │ (keyboard, │ │ (tokio::select!) │ │ (state machine) │ │ +│ │ resize) │ │ │ │ │ │ +│ └──────────────┘ └──────────────────────┘ └───────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐ │ +│ │ API Client │────▶│ TuiProgressReporter │────▶│ AppEvent:: │ │ +│ │ (SSE stream)│ │ (mpsc channel) │ │ AssistantDelta │ │ +│ └──────────────┘ └──────────────────────┘ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ ratatui::Frame::render() │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │ +│ │ │ ChatWidget │ │ Sidebar │ │ StatusBarWidget │ │ │ +│ │ │ (top/left) │ │ (right, opt)│ │ (bottom) │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.4 State Machine + +```rust +// tui/app.rs + +pub struct App { + pub screen: Screen, + pub messages: Vec, + pub scroll: ScrollState, + pub input: ComposerState, + pub status: StatusState, + pub theme: Theme, + pub session_id: Option, +} + +pub enum Screen { + Chat, + PermissionPrompt(PermissionRequest), + SessionPicker(Vec), + Help, + Error(String), + Quit, +} + +pub struct MessageCell { + pub role: MessageRole, + pub content: Vec, // completed blocks + pub streaming: Option, // in-flight block + pub timestamp: chrono::DateTime, + pub tool_calls: Vec, +} + +pub struct StreamingCell { + pub text_buffer: String, + pub parser_state: StreamingMarkdownState, // incremental parse state +} + +pub struct ToolCallCell { + pub name: String, + pub status: ToolCallStatus, + pub result_summary: String, + pub full_result: Option, + pub expanded: bool, +} + +pub enum ToolCallStatus { + Pending, + Running { start_time: Instant }, + Succeeded { duration: Duration }, + Failed { duration: Duration, error: String }, +} +``` + +--- + +## 3. Sprint-by-Sprint Breakdown (Weeks 1–52) + +### Sprint Structure + +- **1 sprint = 2 weeks** (26 sprints total). +- **Every sprint ships.** If scope is too large, cut features — never delay. +- **Merge target:** `feat/tui` via ≤400-line PRs. +- **Review rule:** No PR >400 lines. Split or cut. + +--- + +### Phase A: Foundation (Weeks 1–6) — Extract or Die + +**Goal:** `main.rs` <150 lines. No TUI code written until then. + +#### Week 1: Extract `app.rs` + +| Field | Value | +|---|---| +| **Deliverable** | `LiveCli` struct and its `impl` block moved from `main.rs` to `app.rs`. `main.rs` now only imports `LiveCli` and calls `run()`. | +| **Files touched** | `main.rs (-4500/+20)`, `app.rs (+4520/-0)` | +| **New files** | `app.rs` (extracted from `main.rs`) | +| **Test strategy** | All existing tests in `main.rs` move to `app.rs`. Add `#[test] fn test_app_module_compiles()` as a smoke test. | +| **Merge criteria** | `cargo test --workspace` passes. `cargo clippy --workspace` clean. `main.rs` <200 lines. | +| **Rollback strategy** | `git checkout HEAD~1 -- main.rs` restores the monolith in one command. | +| **Dependencies** | None — pure file move. | +| **PR title** | `refactor: extract LiveCli to app.rs` | +| **PR description** | • Moves `LiveCli` struct and impl from `main.rs` to new `app.rs`. • No behavioral changes — pure extraction. • All tests preserved. • `main.rs` reduced from ~11,000 to ~200 lines. | +| **Review checklist** | ① No logic changes — only moves. ② All `main.rs` tests still exist in `app.rs`. ③ `cargo test --workspace` passes. ④ `main.rs` <200 lines. ⑤ No new dependencies. | +| **Risk** | Medium — large file move can lose tests. Mitigated by `git diff --stat` verification. | +| **Docs** | Update `rust/README.md` workspace layout diagram. | +| **Harness impact** | None — pure refactor, no behavioral change. | + +**Verify this week:** +```bash +cd rust +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all --check +wc -l crates/rusty-claude-cli/src/main.rs # should be <200 +``` + +--- + +#### Week 2: Extract `format.rs` + +| Field | Value | +|---|---| +| **Deliverable** | All `format_*` functions moved from `app.rs`/`main.rs` to `format.rs`. | +| **Files touched** | `app.rs (-1200/+50)`, `format.rs (+1200/-0)`, `main.rs (-0/+10)` | +| **New files** | `format.rs` | +| **Functions moved** | `format_status_report`, `format_cost_report`, `format_model_report`, `format_permissions_report`, `format_compact_report`, `format_resume_report`, `format_sandbox_report`, `format_commit_preflight_report`, `format_commit_skipped_report`, `format_pr_report`, `format_issue_report`, `format_ultraplan_report`, `format_bughunter_report`, `format_doctor_report`, `format_auto_compaction_notice` | +| **Test strategy** | Move all `format_*` tests from `app.rs` to `format.rs`. Add `#[test] fn test_format_module_exports()` smoke test. | +| **Merge criteria** | All format tests pass. No changes to output strings. | +| **Rollback strategy** | `git checkout HEAD~1 -- app.rs` — but format functions are pure, so rollback is trivial. | +| **Dependencies** | None. | +| **PR title** | `refactor: extract report formatting to format.rs` | +| **PR description** | • Extracts 15 `format_*` functions from `app.rs` to `format.rs`. • No output changes — pure move. • All unit tests preserved. | +| **Review checklist** | ① Every `format_*` function has a test. ② Output strings unchanged. ③ `cargo test` passes. ④ `cargo clippy` clean. ⑤ No new deps. | +| **Risk** | Low — pure code move with test coverage. | +| **Docs** | Update `rust/README.md` crate responsibilities. | +| **Harness impact** | None. | + +--- + +#### Week 3: Extract `session_mgr.rs` + +| Field | Value | +|---|---| +| **Deliverable** | Session CRUD (create, resume, list, switch, persist, compaction) moved from `app.rs` to `session_mgr.rs`. | +| **Files touched** | `app.rs (-1100/+80)`, `session_mgr.rs (+1100/-0)` | +| **New files** | `session_mgr.rs` | +| **Functions moved** | `create_session`, `resume_session`, `list_sessions`, `switch_session`, `persist_session`, `compact_session`, `load_latest_session`, `resolve_session_path` | +| **Test strategy** | Move session tests. Add `SessionManager` struct with `new() -> Self` and `list() -> Vec` methods. Unit-test with temp dirs. | +| **Merge criteria** | Session persistence round-trip tested. `/session` slash commands still work in REPL. | +| **Rollback strategy** | `git checkout HEAD~1 -- app.rs` restores session functions. | +| **Dependencies** | None. | +| **PR title** | `refactor: extract session management to session_mgr.rs` | +| **PR description** | • Extracts session lifecycle from `app.rs` to `session_mgr.rs`. • Introduces `SessionManager` struct for centralized session operations. • Tests use temp dirs for isolation. | +| **Review checklist** | ① `/session list`, `/resume`, `/compact` work in REPL. ② Session files written to correct path. ③ Tests use `tempdir`. ④ `cargo test` passes. ⑤ No behavioral changes. | +| **Risk** | Low — session code is well-tested. | +| **Docs** | Update `USAGE.md` session section if `SessionManager` adds new CLI surfaces. | +| **Harness impact** | Session save/resume is harness-tested; verify no regressions. | + +--- + +#### Week 4: Create `tui/mod.rs` Stub + +| Field | Value | +|---|---| +| **Deliverable** | `tui/mod.rs` created with `pub fn run(env: CliEnvironment) -> io::Result<()>` stub. `claw tui` CLI dispatch added. Prints "TUI mode coming soon." and exits. | +| **Files touched** | `cli.rs (+30)`, `main.rs (+15)` | +| **New files** | `tui/mod.rs` (40 lines) | +| **Test strategy** | Integration test: `#[test] fn test_tui_subcommand_exists()` that spawns `claw tui --help` and asserts exit code 0. | +| **Merge criteria** | `claw tui --help` works. `claw tui` exits cleanly with status 0. | +| **Rollback strategy** | Remove `tui/` directory and revert `cli.rs`/`main.rs` changes. | +| **Dependencies** | None — stub only. | +| **PR title** | `feat: add tui module stub and `claw tui` command` | +| **PR description** | • Adds empty `tui/` namespace. • Adds `claw tui` CLI subcommand as stub. • Integration test verifies CLI surface. | +| **Review checklist** | ① `claw tui --help` prints help. ② `claw tui` exits 0. ③ No new dependencies yet. ④ `cargo test` passes. ⑤ Stub is clearly marked with `// TODO` comments. | +| **Risk** | Very low — no runtime code. | +| **Docs** | Update `rust/README.md` with `claw tui` in CLI flags. | +| **Harness impact** | Add `tui_stub_smoke` scenario to mock parity harness. | + +**Code skeleton:** +```rust +// tui/mod.rs +use std::io; + +pub fn run() -> io::Result<()> { + println!("TUI mode coming soon."); + Ok(()) +} +``` + +--- + +#### Week 5: Runtime Trait Boundary + +| Field | Value | +|---|---| +| **Deliverable** | `ConversationRuntime` access is abstracted through a trait so REPL and TUI can share it without coupling. | +| **Files touched** | `app.rs (+80)`, `tui/mod.rs (+30)` | +| **New files** | None | +| **Trait defined** | `trait RuntimeHandle { fn run_turn(&mut self, prompt: &str) -> Result; fn session(&self) -> &Session; }` | +| **Test strategy** | Mock implementation of `RuntimeHandle` for testing. | +| **Merge criteria** | `LiveCli` compiles with new trait. No behavioral changes to REPL. | +| **Rollback strategy** | Inline the trait methods back into direct `ConversationRuntime` calls. | +| **Dependencies** | None. | +| **PR title** | `refactor: runtime access through trait boundary` | +| **PR description** | • Introduces `RuntimeHandle` trait for shared runtime access. • Enables REPL/TUI duality without duplication. • No behavioral changes. | +| **Review checklist** | ① Trait has all methods REPL needs. ② Mock impl works in tests. ③ `cargo test` passes. ④ REPL unaffected. ⑤ Documentation on trait purpose. | +| **Risk** | Low — adds abstraction without changing behavior. | +| **Docs** | Document the trait in code comments. | +| **Harness impact** | None — trait is compile-time only. | + +--- + +#### Week 6: Ratatui Skeleton with TestBackend + +| Field | Value | +|---|---| +| **Deliverable** | `ratatui` and `crossterm` (event-stream feature) added as dependencies. Minimal alternate-screen demo: shows "Hello, claw" `Paragraph`, exits on `q`. Uses `TestBackend` in tests. | +| **Files touched** | `Cargo.toml (+3)`, `tui/mod.rs (+120)` | +| **New files** | `tui/event.rs` (stub, 30 lines) | +| **Dependencies added** | `ratatui = "0.29"`, `crossterm = { version = "0.28", features = ["event-stream"] }` (crossterm already present, added feature) | +| **Test strategy** | `#[test] fn test_tui_init_buffer()` renders to `TestBackend` and asserts buffer contains "Hello, claw". | +| **Merge criteria** | `cargo test --workspace` passes. `cargo check` with `--features tui` passes (but feature flag not added yet). `claw tui` opens alternate screen and exits on `q`. | +| **Rollback strategy** | Remove ratatui from `Cargo.toml`, delete `tui/` changes. | +| **PR title** | `feat: ratatui skeleton with TestBackend tests` | +| **PR description** | • Adds `ratatui` dependency. • `claw tui` opens alternate screen, renders "Hello, claw", exits on `q`. • TestBackend verifies rendered output in CI. | +| **Review checklist** | ① Terminal restored on exit (no ANSI leak). ② `TestBackend` test passes. ③ No REPL changes. ④ `q` key exits cleanly. ⑤ Cargo.lock updated correctly. | +| **Risk** | Low — minimal code, well-tested dependency. | +| **Docs** | Add ratatui to `rust/README.md` dependencies list. | +| **Harness impact** | Add `tui_smoke_open_close` to mock parity harness. | + +**Code skeleton:** +```rust +// tui/mod.rs +use std::io; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use ratatui::{ + backend::{Backend, CrosstermBackend, TestBackend}, + layout::{Constraint, Direction, Layout}, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; + +pub fn run() -> io::Result<()> { + let mut terminal = ratatui::init(); + let result = run_app(&mut terminal); + ratatui::restore(); + result +} + +fn run_app(terminal: &mut Terminal) -> io::Result<()> { + loop { + terminal.draw(|f| ui(f))?; + if let Event::Key(KeyEvent { code: KeyCode::Char('q'), .. }) = event::read()? { + return Ok(()); + } + } +} + +fn ui(frame: &mut Frame) { + let paragraph = Paragraph::new("Hello, claw") + .block(Block::default().borders(Borders::ALL).title("claw")); + frame.render_widget(paragraph, frame.area()); +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::buffer::Buffer; + + #[test] + fn test_tui_init_buffer() { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| ui(f)).unwrap(); + let buffer = terminal.backend().buffer(); + assert!(buffer.content.iter().any(|c| c.symbol() == "H")); + } +} +``` + +--- + +### Phase B: Core TUI Shell (Weeks 7–12) — Make It Run + +**Goal:** `claw tui` opens, renders a mock chat, accepts input, scrolls, and exits cleanly. + +#### Week 7: Event Broker + +| Field | Value | +|---|---| +| **Deliverable** | `tui/event.rs`: `EventBroker` bridges `crossterm::event::EventStream` → `AppEvent` enum. Unit-test key translation. | +| **Files touched** | `tui/mod.rs (+40)`, `tui/app.rs` (created, stub) | +| **New files** | `tui/event.rs` (180 lines) | +| **Types defined** | `enum AppEvent { Tick, Key(KeyEvent), Resize(u16, u16), AssistantDelta(String), ToolStart(String), ToolResult(String, String), Error(String), Quit }` | +| **Test strategy** | `#[test] fn test_key_translation()` maps `Event::Key(KeyCode::Char('q'))` → `AppEvent::Key(...)`. `#[test] fn test_resize_event()`. | +| **Merge criteria** | `EventBroker` runs in tests without a real terminal. All key events translate correctly. | +| **Rollback strategy** | Revert `tui/event.rs` to stub. | +| **PR title** | `feat: tui event broker with async event stream` | +| **PR description** | • `EventBroker` consumes `crossterm::event::EventStream` and produces `AppEvent`. • Supports key, resize, and custom backend events. • Unit tests for key translation. | +| **Review checklist** | ① All key variants handled. ② Resize produces correct dimensions. ③ `AppEvent` is `Clone + Send`. ④ Tests use `TestBackend`. ⑤ No blocking in test. | +| **Risk** | Low — crossterm event handling is well-documented. | +| **Docs** | Add event flow diagram to `docs/TUI.md`. | +| **Harness impact** | None — event layer doesn't touch the runtime. | + +--- + +#### Week 8: App State Machine + +| Field | Value | +|---|---| +| **Deliverable** | `tui/app.rs`: `App` struct with `Screen::Chat`, renders static mock messages as `Paragraph` list. Handles `q` quit, arrows scroll. | +| **Files touched** | `tui/mod.rs (+60)`, `tui/app.rs` (created, 200 lines) | +| **New files** | `tui/app.rs` | +| **Types defined** | `struct App { messages: Vec, scroll: ScrollState, should_quit: bool }`, `struct MockMessage { role: String, text: String }`, `struct ScrollState { offset: usize }` | +| **Test strategy** | `#[test] fn test_app_scroll()` — add 100 messages, scroll down 5, assert offset == 5. `#[test] fn test_app_quit_on_q()` — process `KeyCode::Char('q')`, assert `should_quit`. | +| **Merge criteria** | App renders mock chat. Arrow keys scroll. `q` quits. Tests pass without TTY. | +| **Rollback strategy** | Revert `tui/app.rs` to stub. | +| **PR title** | `feat: tui app state machine with mock messages` | +| **PR description** | • `App` state machine with `Screen::Chat`. • Renders static mock messages. • Supports scroll via arrow keys. • `q` to quit. | +| **Review checklist** | ① Mock messages visible in TestBackend. ② Scroll offset correct. ③ `q` quits. ④ 100 messages don't panic. ⑤ Tests headless. | +| **Risk** | Low — no runtime integration yet. | +| **Docs** | Update `docs/TUI.md` state machine section. | +| **Harness impact** | None. | + +--- + +#### Week 9: Composer Input Widget + +| Field | Value | +|---|---| +| **Deliverable** | `tui/widgets/input.rs`: Composer widget wrapping `tui_textarea::TextArea`. Enter sends, Shift+Enter newline. Mock: prints submitted text to debug overlay. | +| **Files touched** | `Cargo.toml (+1)`, `tui/app.rs (+60)`, `tui/widgets/input.rs` (created, 150 lines) | +| **New files** | `tui/widgets/input.rs` | +| **Dependencies added** | `tui-textarea = "0.7"` | +| **Test strategy** | `#[test] fn test_composer_enter_sends()` — simulate Enter, assert `Submit("hello")` event. `#[test] fn test_composer_shift_enter_newline()` — assert cursor moved down. | +| **Merge criteria** | Text input works. Enter submits. Shift+Enter inserts newline. History (up arrow) cycles past inputs. | +| **Rollback strategy** | Remove `tui-textarea` from `Cargo.toml`, revert input widget. | +| **PR title** | `feat: tui composer input widget` | +| **PR description** | • Adds `ComposerWidget` using `tui-textarea`. • Enter submits, Shift+Enter inserts newline. • Up/down arrow for history. • Tests verify input events. | +| **Review checklist** | ① Enter produces submit event. ② Shift+Enter doesn't submit. ③ History works. ④ Unicode input handled. ⑤ TestBackend verifies buffer. | +| **Risk** | Low — `tui-textarea` is mature, ~2,000 GitHub stars. | +| **Docs** | Add keybindings table to `docs/TUI.md`. | +| **Harness impact** | None. | + +--- + +#### Week 10: Chat Message List Widget + +| Field | Value | +|---|---| +| **Deliverable** | `tui/widgets/chat.rs`: `ChatWidget` renders `Vec` as vertical `Layout` of `Paragraph`s. Supports PgUp/PgDn. | +| **Files touched** | `tui/app.rs (+80)`, `tui/widgets/chat.rs` (created, 200 lines) | +| **New files** | `tui/widgets/chat.rs` | +| **Test strategy** | `#[test] fn test_chat_renders_messages()` — 5 messages, assert all visible. `#[test] fn test_chat_scroll_pagination()` — 50 messages in 24-line terminal, PgDn advances by page size. | +| **Merge criteria** | Messages render with roles (user/assistant) styled differently. Scroll works. PgUp/PgDn jump by viewport height. | +| **Rollback strategy** | Revert `chat.rs` to stub. | +| **PR title** | `feat: tui chat message list widget` | +| **PR description** | • `ChatWidget` renders message history with role-based styling. • Scrollable with arrows and PgUp/PgDn. • Supports 100+ messages without frame drops. | +| **Review checklist** | ① User messages right-aligned or differently colored. ② Assistant messages left-aligned. ③ Scroll offset clamped to message count. ④ PgUp/PgDn jump correctly. ⑤ Tests headless. | +| **Risk** | Low — pure rendering, no external state. | +| **Docs** | Document chat layout in `docs/TUI.md`. | +| **Harness impact** | None. | + +--- + +#### Week 11: Terminal Resize & Static Status Bar + +| Field | Value | +|---|---| +| **Deliverable** | Terminal resize handling. Status bar at bottom showing model name (static string), permission mode (static), terminal dimensions. | +| **Files touched** | `tui/app.rs (+60)`, `tui/widgets/status_bar.rs` (created, 120 lines) | +| **New files** | `tui/widgets/status_bar.rs` | +| **Test strategy** | `#[test] fn test_status_bar_renders()` — assert model name visible. `#[test] fn test_resize_updates_layout()` — assert layout recalculates on `Resize` event. | +| **Merge criteria** | Resize event updates layout. Status bar always visible at bottom. Content area adjusts. | +| **Rollback strategy** | Remove status bar widget, revert resize handler. | +| **PR title** | `feat: terminal resize handling and static status bar` | +| **PR description** | • Status bar shows model, permission mode, terminal size. • Resize events recalculate layout. • Content area shrinks/grows correctly. | +| **Review checklist** | ① Status bar at bottom in TestBackend. ② Resize doesn't panic. ③ Layout constraints correct (status bar height = 3). ④ Content area height = total - 3. ⑤ Tests pass. | +| **Risk** | Low — layout math with `ratatui::Layout`. | +| **Docs** | Document layout constraints. | +| **Harness impact** | None. | + +--- + +#### Week 12: Integration — Mock Chat + Input + Status Bar + +| Field | Value | +|---|---| +| **Deliverable** | `claw tui` opens a full-screen app with mock chat + input + status bar. Smoke test runs in CI (skips if no TTY). | +| **Files touched** | `tui/mod.rs (+40)`, `tui/app.rs (+80)` | +| **New files** | `tests/tui_smoke.rs` (integration test, 80 lines) | +| **Test strategy** | Integration test with `TestBackend`: send mock messages, assert rendered. CI test that spawns `claw tui` with `timeout 2` and asserts clean exit. | +| **Merge criteria** | `claw tui` runs for 2 seconds and exits cleanly. Full layout visible in TestBackend. CI passes. | +| **Rollback strategy** | Revert `tui/` changes, keep `app.rs`/`format.rs`/`session_mgr.rs` extractions. | +| **PR title** | `feat: claw tui opens full-screen TUI with mock data` | +| **PR description** | • Full TUI layout: chat area, input composer, status bar. • Mock conversation data. • Integration test with TestBackend and CI smoke test. | +| **Review checklist** | ① Full layout visible in test render. ② `claw tui` exits on `q`. ③ CI test passes. ④ No REPL changes. ⑤ Memory usage flat over 10-second run. | +| **Risk** | Low — integration of already-tested components. | +| **Docs** | Add TUI screenshot instructions to `USAGE.md` (ASCII art or capture). | +| **Harness impact** | Add `tui_full_layout_smoke` to mock parity harness. | + +--- + +### Phase C: Live Conversation (Weeks 13–20) — Make It Talk + +**Goal:** Real conversations with the model. Streaming text, tool calls, session save/resume, permission modal. + +#### Week 13: Wire ConversationRuntime + +| Field | Value | +|---|---| +| **Deliverable** | `ConversationRuntime` wired into TUI event loop. `TuiProgressReporter` implements `TurnProgressReporter`, sends `AppEvent::AssistantDelta` via `tokio::sync::mpsc`. | +| **Files touched** | `tui/app.rs (+120)`, `tui/event.rs (+40)`, `tui/mod.rs (+60)` | +| **New files** | None | +| **Types defined** | `struct TuiProgressReporter { tx: mpsc::UnboundedSender } impl TurnProgressReporter for TuiProgressReporter { ... }` | +| **Test strategy** | Mock `ConversationRuntime` that emits fake deltas. `#[test] fn test_progress_reporter_sends_delta()` — assert `AssistantDelta` received. | +| **Merge criteria** | TUI receives fake streaming events. Events route to App state. No panics. | +| **Rollback strategy** | Replace `TuiProgressReporter` with no-op. | +| **PR title** | `feat: wire ConversationRuntime into tui event loop` | +| **PR description** | • `TuiProgressReporter` bridges runtime events to TUI channel. • Tokio async event loop processes backend + user events. • Mock runtime tests verify event flow. | +| **Review checklist** | ① `AssistantDelta` events reach App. ② No deadlocks. ③ Channel bounded correctly. ④ Tests use mock runtime. ⑤ Runtime trait boundary clean. | +| **Risk** | Medium — async event loop is first real complexity. Mitigated by bounded channels. | +| **Docs** | Update event flow diagram in `docs/TUI.md`. | +| **Harness impact** | Add `tui_streaming_mock` scenario. | + +--- + +#### Week 14: Streaming Assistant Text + +| Field | Value | +|---|---| +| **Deliverable** | `AssistantEvent::TextDelta` appends to active message cell in real time. No markdown rendering yet — raw text. | +| **Files touched** | `tui/app.rs (+80)`, `tui/widgets/chat.rs (+60)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_streaming_appends_text()` — send 5 deltas, assert message text == concatenated deltas. `#[test] fn test_streaming_cursor_position()` — last line visible. | +| **Merge criteria** | Text appears character-by-character in chat widget. Auto-scroll to bottom. No flicker. | +| **Rollback strategy** | Buffer deltas instead of displaying incrementally. | +| **PR title** | `feat: real-time streaming assistant text in tui` | +| **PR description** | • Assistant text streams into chat in real time. • Auto-scroll to latest delta. • Raw text (no markdown yet). | +| **Review checklist** | ① Text visible within 50ms of delta. ② No duplicate text. ③ Auto-scroll works. ④ 1000-character stream doesn't OOM. ⑤ Tests headless. | +| **Risk** | Medium — rendering performance on fast streams. | +| **Docs** | Document streaming behavior and latency targets. | +| **Harness impact** | Add `tui_streaming_text` scenario. | + +--- + +#### Week 15: Session Save & Resume + +| Field | Value | +|---|---| +| **Deliverable** | On TUI exit, save session to JSONL (same format as REPL). `claw tui --resume` loads it. | +| **Files touched** | `tui/app.rs (+80)`, `tui/mod.rs (+40)`, `session_mgr.rs (+20)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_session_save_roundtrip()` — create messages, save, load, assert equal. | +| **Merge criteria** | Session persists across `claw tui` invocations. Messages load with correct timestamps. | +| **Rollback strategy** | Don't save on exit (lose history). | +| **PR title** | `feat: tui session save and resume` | +| **PR description** | • TUI auto-saves session on exit. • `--resume` flag loads last session. • Same JSONL format as REPL sessions. | +| **Review checklist** | ① Session file written on exit. ② `--resume` loads it. ③ Format compatible with REPL sessions. ④ No data loss. ⑤ Tests verify round-trip. | +| **Risk** | Low — session persistence already works in REPL. | +| **Docs** | Update `USAGE.md` with `claw tui --resume`. | +| **Harness impact** | Add `tui_session_save_resume` scenario. | + +--- + +#### Week 16: Inline Tool Call Rendering + +| Field | Value | +|---|---| +| **Deliverable** | Tool call start shows spinner + name inline. Finish shows ✓/✗ with truncated result (first 5 lines). | +| **Files touched** | `tui/app.rs (+60)`, `tui/widgets/tool_call.rs` (created, 220 lines) | +| **New files** | `tui/widgets/tool_call.rs` | +| **Test strategy** | `#[test] fn test_tool_call_lifecycle()` — `ToolStart("bash")` → spinner → `ToolResult("bash", "ok")` → ✓. | +| **Merge criteria** | Tool calls animate inline. Results truncated with `[+]` expand hint. | +| **Rollback strategy** | Show plain text tool names instead of widgets. | +| **PR title** | `feat: inline tool call spinner and result summary` | +| **PR description** | • Tool calls show animated spinner. • Results show ✓/✗ with truncated preview. • Expandable full output. | +| **Review checklist** | ① Spinner visible during tool call. ② ✓/✗ correct based on result. ③ Truncation at 5 lines. ④ 10 concurrent tools don't break layout. ⑤ Tests headless. | +| **Risk** | Low — tool execution already works in runtime. | +| **Docs** | Document tool visualization in `docs/TUI.md`. | +| **Harness impact** | Add `tui_tool_call_display` scenario. | + +--- + +#### Week 17: Permission Prompt Modal + +| Field | Value | +|---|---| +| **Deliverable** | Permission prompt overlay (centered block) for Y/N approval. Does not block event loop — shows as `Screen::PermissionPrompt` state. | +| **Files touched** | `tui/app.rs (+60)`, `tui/widgets/permission.rs` (created, 200 lines) | +| **New files** | `tui/widgets/permission.rs` | +| **Test strategy** | `#[test] fn test_permission_modal_renders()` — assert centered block visible. `#[test] fn test_permission_y_approves()` — assert approval event sent. | +| **Merge criteria** | Modal renders over chat. `y` approves, `n` denies. Other keys ignored. Event loop continues. | +| **Rollback strategy** | Always deny or always approve (degraded mode). | +| **PR title** | `feat: permission prompt modal overlay in tui` | +| **PR description** | • Modal overlay for tool approval. • `y` to approve, `n` to deny. • Non-blocking event loop. • Styled with box drawing. | +| **Review checklist** | ① Modal centered and visible. ② `y`/`n` responses correct. ③ Background dimmed. ④ Escape cancels. ⑤ Tests headless. | +| **Risk** | Medium — modal state transitions must not deadlock. | +| **Docs** | Document permission flow in `docs/TUI.md`. | +| **Harness impact** | Add `tui_permission_prompt` scenario. | + +--- + +#### Week 18: Error Handling + +| Field | Value | +|---|---| +| **Deliverable** | Network errors, API rate limits, runtime errors render as styled error messages in chat. No panics. | +| **Files touched** | `tui/app.rs (+40)`, `tui/widgets/chat.rs (+30)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_error_rendered_in_chat()` — inject error, assert red text visible. | +| **Merge criteria** | All error paths show user-friendly messages. Panic-free. Stack traces suppressed (logged to file only). | +| **Rollback strategy** | Panic on error (ugly but functional). | +| **PR title** | `feat: graceful error rendering in tui chat` | +| **PR description** | • Errors rendered as styled messages in chat. • No panics. • Stack traces logged, not displayed. • Auto-scroll to error message. | +| **Review checklist** | ① Network error → "Connection failed" visible. ② API error → "API error: ..." visible. ③ No panic. ④ Stack trace in log file. ⑤ Tests verify all error paths. | +| **Risk** | Low — error types already exist in runtime. | +| **Docs** | Document error handling strategy. | +| **Harness impact** | Add `tui_error_handling` scenario. | + +--- + +#### Week 19: Multi-Turn Conversation + +| Field | Value | +|---|---| +| **Deliverable** | Send follow-up messages. Scrollback preserves full history. Input clears after send. | +| **Files touched** | `tui/app.rs (+40)`, `tui/widgets/input.rs (+20)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_multi_turn_history()` — 3 turns, assert 6 messages. `#[test] fn test_input_cleared_after_send()`. | +| **Merge criteria** | Each turn starts a new streaming cycle. History is complete. Input field resets. | +| **Rollback strategy** | Single-turn only (restart TUI for next turn). | +| **PR title** | `feat: multi-turn conversation in tui` | +| **PR description** | • Follow-up messages work. • Full scrollback history. • Input cleared after send. • Session auto-saves between turns. | +| **Review checklist** | ① New turn triggers streaming. ② Old turns frozen (not re-streamed). ③ Input empty after send. ④ History scrollable. ⑤ 10-turn session doesn't OOM. | +| **Risk** | Low — builds on Weeks 13–18. | +| **Docs** | Update `USAGE.md` with multi-turn examples. | +| **Harness impact** | Add `tui_multi_turn` scenario. | + +--- + +#### Week 20: Feature Flag + +| Field | Value | +|---|---| +| **Deliverable** | `tui` feature flag added to `rusty-claude-cli/Cargo.toml`. Default OFF. CI builds with `--features tui`. Binary size documented. | +| **Files touched** | `Cargo.toml (+5)`, `main.rs (+10)`, `.github/workflows/rust-ci.yml (+10)` | +| **New files** | `docs/TUI-BINARY-SIZE.md` | +| **Test strategy** | `#[test] fn test_tui_feature_compiles()` — compile with feature flag. `#[test] fn test_without_tui_feature()` — verify `claw tui` is absent without flag. | +| **Merge criteria** | `cargo build --workspace` works without feature. `cargo build --workspace --features tui` works with feature. Binary size documented. | +| **Rollback strategy** | Make `tui` feature default ON (effectively removing the flag). | +| **PR title** | `feat: gate tui behind optional feature flag` | +| **PR description** | • `tui` feature flag in `Cargo.toml`. • Default OFF to avoid binary bloat for REPL-only users. • CI builds both configurations. • Binary size impact documented. | +| **Review checklist** | ① Build without feature passes. ② Build with feature passes. ③ Binary size documented. ④ CI matrix includes both. ⑤ Flag not on by default. | +| **Risk** | Low — mechanical change. | +| **Docs** | Document feature flag usage in `USAGE.md`. | +| **Harness impact** | CI builds with `--features tui`. | + +--- + +### Phase D: Rich Rendering (Weeks 21–28) — Make It Beautiful + +**Goal:** Markdown rendering, syntax highlighting, themes, diffs, performance. + +#### Week 21: Markdown Widget (Headings, Lists, Code Blocks) + +| Field | Value | +|---|---| +| **Deliverable** | `tui/widgets/markdown.rs`: Port `render.rs` (`pulldown-cmark` + `syntect`) to ratatui `Paragraph`. Renders headings, lists, inline code, code blocks with syntax highlighting. | +| **Files touched** | `render.rs (+0, read-only reference)`, `tui/widgets/markdown.rs` (created, 300 lines) | +| **New files** | `tui/widgets/markdown.rs` | +| **Test strategy** | `#[test] fn test_heading_renders_bold()` — assert bold style. `#[test] fn test_code_block_highlighted()` — assert syntax colors. | +| **Merge criteria** | Markdown renders in chat. Code blocks have syntax highlighting. Lists are indented. | +| **Rollback strategy** | Use plain text rendering (ugly but readable). | +| **PR title** | `feat: markdown widget with syntax highlighting` | +| **PR description** | • `MarkdownWidget` renders pulldown-cmark events to ratatui `Paragraph`. • Code blocks with syntect highlighting. • Headings, lists, inline code, blockquotes. | +| **Review checklist** | ① H1/H2 bold and colored. ② Code blocks highlighted. ③ Lists indented. ④ Inline code colored. ⑤ 1000-line markdown renders in <50ms. | +| **Risk** | Medium — markdown parsing + rendering is complex. | +| **Docs** | Document supported markdown elements. | +| **Harness impact** | Add `tui_markdown_render` scenario. | + +--- + +#### Week 22: Table Rendering + +| Field | Value | +|---|---| +| **Deliverable** | `pulldown-cmark` table events → ratatui `Table` widget with borders. | +| **Files touched** | `tui/widgets/markdown.rs (+100)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_table_renders_with_borders()` — assert `Table` widget visible. | +| **Merge criteria** | Tables render with borders and alignment. Fit within terminal width. | +| **Rollback strategy** | Render tables as plain text. | +| **PR title** | `feat: table rendering in markdown widget` | +| **PR description** | • Markdown tables render as ratatui `Table`. • Borders, headers, cell alignment. • Truncation for narrow terminals. | +| **Review checklist** | ① Borders visible. ② Headers bold. ③ Cells aligned. ④ Wide table truncates gracefully. ⑤ Tests verify. | +| **Risk** | Low — `Table` widget is standard in ratatui. | +| **Docs** | Add table examples to docs. | +| **Harness impact** | None. | + +--- + +#### Week 23: Blockquote & Link Rendering + +| Field | Value | +|---|---| +| **Deliverable** | Blockquote: styled indentation + left border. Links: underlined + clickable (placeholder for mouse week). | +| **Files touched** | `tui/widgets/markdown.rs (+80)`, `tui/theme.rs (+20)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_blockquote_has_left_border()`. `#[test] fn test_link_is_underlined()`. | +| **Merge criteria** | Blockquotes visually distinct. Links underlined. | +| **Rollback strategy** | Render as plain text. | +| **PR title** | `feat: blockquote and link rendering` | +| **PR description** | • Blockquotes with left border and muted color. • Links underlined. • Mouse click support deferred to Week 35. | +| **Review checklist** | ① Blockquote left border visible. ② Muted text color. ③ Links underlined. ④ URL displayed on hover or inline. ⑤ Tests verify. | +| **Risk** | Low — style changes only. | +| **Docs** | Document styling choices. | +| **Harness impact** | None. | + +--- + +#### Week 24: Incremental Markdown Streaming + +| Field | Value | +|---|---| +| **Deliverable** | Incremental parse of markdown as text deltas arrive. Don't re-render from scratch every frame — append to parser state. | +| **Files touched** | `tui/widgets/markdown.rs (+120)`, `tui/app.rs (+40)` | +| **New files** | `tui/widgets/markdown.rs` adds `StreamingMarkdownState` struct | +| **Test strategy** | `#[test] fn test_incremental_parse()` — feed "# He" then "llo", assert heading recognized. | +| **Merge criteria** | Streaming text parses incrementally. No re-render flicker. Performance: <16ms per delta. | +| **Rollback strategy** | Buffer full response, then render once (slower but correct). | +| **PR title** | `feat: incremental markdown streaming render` | +| **PR description** | • Markdown parser state persists across deltas. • Incremental render avoids full re-parse. • No flicker. • Sub-16ms per delta. | +| **Review checklist** | ① Heading detected mid-stream. ② Code block fenced detected. ③ No flicker. ④ Performance <16ms. ⑤ Tests incremental state. | +| **Risk** | High — incremental parsing is tricky. Mitigated by fallback to full re-parse on parse error. | +| **Docs** | Document incremental parsing strategy. | +| **Harness impact** | None — optimization only. | + +--- + +#### Week 25: Dark/Light Theme System + +| Field | Value | +|---|---| +| **Deliverable** | Dark/light theme system. Respect `NO_COLOR`. Detect truecolor vs. 256-color vs. 16-color terminals and degrade gracefully. | +| **Files touched** | `tui/theme.rs` (created, 250 lines), `tui/app.rs (+20)` | +| **New files** | `tui/theme.rs` | +| **Test strategy** | `#[test] fn test_theme_detects_no_color()`. `#[test] fn test_theme_16_color_fallback()`. | +| **Merge criteria** | `NO_COLOR=1` disables colors. Truecolor terminal shows full palette. 16-color terminal degrades correctly. | +| **Rollback strategy** | Hardcode dark theme (no customization). | +| **PR title** | `feat: color theme system with terminal capability detection` | +| **PR description** | • Dark, light, solarized themes. • `NO_COLOR` support. • Truecolor → 256 → 16 fallback. • Configurable via `.claw.json`. | +| **Review checklist** | ① `NO_COLOR=1` → no ANSI. ② Truecolor → 24-bit colors. ③ 256-color → approximated. ④ 16-color → nearest. ⑤ Configurable. | +| **Risk** | Low — color detection is well-understood. | +| **Docs** | Document theme configuration. | +| **Harness impact** | None — cosmetic only. | + +--- + +#### Week 26: Collapsible/Expandable Tool Results + +| Field | Value | +|---|---| +| **Deliverable** | Press `Enter` on a tool result to expand/collapse full output. Scrollable code view for expanded results. | +| **Files touched** | `tui/widgets/tool_call.rs (+120)`, `tui/app.rs (+40)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_tool_expand_toggle()`. `#[test] fn test_expanded_scrollable()`. | +| **Merge criteria** | Toggle works. Expanded result scrollable. Collapsed result shows summary. | +| **Rollback strategy** | Always show full result (no collapsing). | +| **PR title** | `feat: collapsible/expandable tool results` | +| **PR description** | • Tool results toggle expand/collapse on Enter. • Expanded view scrollable. • Summary visible when collapsed. | +| **Review checklist** | ① Enter toggles state. ② Expanded view scrolls. ③ Collapsed shows summary. ④ 10 expanded tools don't break layout. ⑤ Tests verify. | +| **Risk** | Low — state toggle + scroll. | +| **Docs** | Document tool interaction in `docs/TUI.md`. | +| **Harness impact** | None. | + +--- + +#### Week 27: Colored Diff Rendering + +| Field | Value | +|---|---| +| **Deliverable** | When `edit_file` or `/diff` produces output, render unified diff with green additions and red removals. | +| **Files touched** | `tui/widgets/diff.rs` (created, 200 lines), `tui/app.rs (+20)` | +| **New files** | `tui/widgets/diff.rs` | +| **Test strategy** | `#[test] fn test_diff_green_additions()`. `#[test] fn test_diff_red_removals()`. | +| **Merge criteria** | Diff lines colored correctly. Context lines neutral. Header lines dimmed. | +| **Rollback strategy** | Render diff as plain text. | +| **PR title** | `feat: colored diff rendering in tui` | +| **PR description** | • Unified diff with green additions, red removals. • Context lines in default color. • Header dimmed. | +| **Review checklist** | ① `+` lines green. ② `-` lines red. ③ Context lines neutral. ④ Headers dimmed. ⑤ Tests verify all line types. | +| **Risk** | Low — diff parsing is straightforward. | +| **Docs** | Add diff rendering to feature list. | +| **Harness impact** | None. | + +--- + +#### Week 28: Performance Pass + +| Field | Value | +|---|---| +| **Deliverable** | Profile with `cargo flamegraph`. Ensure 60fps on 100-message session. Optimize if below 30fps. | +| **Files touched** | Various — depends on profiler output | +| **New files** | `benches/tui_render.rs` (criterion bench) | +| **Test strategy** | Criterion benchmark: render 100 messages, measure frame time. Target: p99 <16ms. | +| **Merge criteria** | `cargo bench` shows p99 frame time <16ms. No regressions in existing tests. | +| **Rollback strategy** | Accept 30fps (degraded but functional). | +| **PR title** | `perf: rendering performance pass` | +| **PR description** | • Criterion benchmark for 100-message render. • Flamegraph analysis. • Optimizations: reduced allocations, cached widget state. • Target: 60fps. | +| **Review checklist** | ① Benchmark exists. ② p99 <16ms. ③ No allocations in hot path. ④ Flamegraph reviewed. ⑤ No test regressions. | +| **Risk** | Medium — performance tuning can introduce subtle bugs. | +| **Docs** | Document performance targets. | +| **Harness impact** | None — performance only. | + +--- + +### Phase E: Navigation & Power Features (Weeks 29–38) — Make It Fast to Use + +**Goal:** Keyboard shortcuts, search, session picker, slash commands, mouse, sidebar. + +#### Week 29: Conversation Search (`/`) + +| Field | Value | +|---|---| +| **Deliverable** | Incremental search highlighting matching text. `n`/`N` for next/prev match. | +| **Files touched** | `tui/app.rs (+80)`, `tui/widgets/search.rs` (created, 200 lines) | +| **New files** | `tui/widgets/search.rs` | +| **Test strategy** | `#[test] fn test_search_finds_text()`. `#[test] fn test_search_highlight()`. | +| **Merge criteria** | `/` opens search. Type filters. `n`/`N` navigates. `Escape` closes. | +| **Rollback strategy** | No search (use external grep). | +| **PR title** | `feat: conversation search with incremental highlighting` | +| **PR description** | • `/` opens search overlay. • Incremental filtering. • Match highlighting. • `n`/`N` navigation. | +| **Review checklist** | ① `/` opens search. ② Typing filters. ③ Matches highlighted. ④ `n`/`N` cycles. ⑤ Escape closes. | +| **Risk** | Low — string search + highlight. | +| **Docs** | Add search keybindings. | +| **Harness impact** | None. | + +#### Week 30: Help Overlay (`?`) + +| Field | Value | +|---|---| +| **Deliverable** | `?` help overlay: keyboard shortcuts panel showing all keybindings. | +| **Files touched** | `tui/widgets/help.rs` (created, 150 lines), `tui/app.rs (+20)` | +| **New files** | `tui/widgets/help.rs` | +| **Test strategy** | `#[test] fn test_help_overlay_renders()`. | +| **Merge criteria** | `?` toggles overlay. All keybindings listed. `Escape` or `?` closes. | +| **Rollback strategy** | No help overlay (document keybindings in README only). | +| **PR title** | `feat: keyboard shortcuts help overlay` | +| **PR description** | • `?` toggles help overlay. • All keybindings listed with descriptions. • Styled with boxes. | +| **Review checklist** | ① `?` opens. ② All keys listed. ③ Escape closes. ④ Doesn't block input. ⑤ Tests verify. | +| **Risk** | Low — static overlay. | +| **Docs** | Update keybindings table. | +| **Harness impact** | None. | + +#### Week 31: Session Picker (`Ctrl+S`) + +| Field | Value | +|---|---| +| **Deliverable** | `Ctrl+S` opens fuzzy-filterable list of saved sessions. Up/down arrows, Enter to switch. | +| **Files touched** | `tui/widgets/session_picker.rs` (created, 200 lines), `tui/app.rs (+40)` | +| **New files** | `tui/widgets/session_picker.rs` | +| **Test strategy** | `#[test] fn test_session_picker_lists_sessions()`. `#[test] fn test_session_picker_filters()`. | +| **Merge criteria** | `Ctrl+S` opens picker. Typing filters. Enter loads session. Escape cancels. | +| **Rollback strategy** | Use `/session list` slash command within TUI. | +| **PR title** | `feat: interactive session picker` | +| **PR description** | • `Ctrl+S` opens session list. • Fuzzy filter. • Up/down to navigate. • Enter to load. | +| **Review checklist** | ① Sessions listed. ② Filter works. ③ Enter loads. ④ Escape cancels. ⑤ Tests verify. | +| **Risk** | Low — list + filter pattern. | +| **Docs** | Document session picker. | +| **Harness impact** | None. | + +#### Week 32: Slash Command Picker + +| Field | Value | +|---|---| +| **Deliverable** | Type `/` in input to trigger slash command picker (like REPL). Commands execute within TUI (e.g., `/model`, `/permissions`). | +| **Files touched** | `tui/widgets/input.rs (+80)`, `tui/app.rs (+60)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_slash_picker_opens()`. `#[test] fn test_slash_command_executes()`. | +| **Merge criteria** | `/` opens picker. Tab completion. Enter executes. Commands like `/model` work. | +| **Rollback strategy** | No slash commands in TUI (use REPL for config changes). | +| **PR title** | `feat: slash command picker and execution in tui` | +| **PR description** | • `/` triggers slash command picker. • Tab completion. • In-TUI execution. • Share logic with REPL. | +| **Review checklist** | ① `/` opens picker. ② Tab completes. ③ Enter executes. ④ `/model` switches model. ⑤ Tests verify. | +| **Risk** | Medium — command execution can affect runtime state. | +| **Docs** | Document TUI slash commands. | +| **Harness impact** | None. | + +#### Week 33: `/compact` with Toast + +| Field | Value | +|---|---| +| **Deliverable** | `/compact` in TUI runs compaction, shows toast notification with tokens saved. | +| **Files touched** | `tui/app.rs (+40)`, `tui/widgets/toast.rs` (created, 80 lines) | +| **New files** | `tui/widgets/toast.rs` | +| **Test strategy** | `#[test] fn test_toast_shows_message()`. | +| **Merge criteria** | Toast appears for 3 seconds. Shows tokens saved. Does not block input. | +| **Rollback strategy** | Print compaction result in chat (no toast). | +| **PR title** | `feat: compact command with toast notification` | +| **PR description** | • `/compact` shows toast. • Auto-dismisses after 3s. • Non-blocking. | +| **Review checklist** | ① Toast visible. ② Message correct. ③ Auto-dismisses. ④ Input not blocked. ⑤ Tests verify. | +| **Risk** | Low — timed notification. | +| **Docs** | Document toast behavior. | +| **Harness impact** | None. | + +#### Week 34: Copy to Clipboard + +| Field | Value | +|---|---| +| **Deliverable** | `y` on a message or code block copies to clipboard via `arboard`. | +| **Files touched** | `Cargo.toml (+1)`, `tui/app.rs (+40)`, `tui/widgets/chat.rs (+30)` | +| **New files** | None | +| **Dependencies added** | `arboard = "3"` | +| **Test strategy** | `#[test] fn test_copy_event_sent()`. Full clipboard test is platform-dependent; mock the clipboard backend. | +| **Merge criteria** | `y` copies selected message. Toast confirms. Works on Linux (wlroots/X11), macOS, Windows. | +| **Rollback strategy** | No copy feature (use terminal selection). | +| **PR title** | `feat: copy message to clipboard` | +| **PR description** | • `y` copies message to clipboard. • `arboard` handles cross-platform paste. • Toast confirms. | +| **Review checklist** | ① `y` copies. ② Toast confirms. ③ Linux (X11/Wayland). ④ macOS. ⑤ Windows. | +| **Risk** | Low — `arboard` is mature. | +| **Docs** | Document copy keybinding. | +| **Harness impact** | None. | + +#### Week 35: Mouse Support + +| Field | Value | +|---|---| +| **Deliverable** | Click to expand tool results. Scroll wheel on conversation. Click to focus input. | +| **Files touched** | `tui/event.rs (+60)`, `tui/app.rs (+40)`, `tui/widgets/chat.rs (+20)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_mouse_scroll_updates_offset()`. Mouse click tests use `TestBackend` with synthetic mouse events. | +| **Merge criteria** | Mouse scroll works. Click on tool result toggles expand. Click on input focuses. | +| **Rollback strategy** | Keyboard-only (no mouse). | +| **PR title** | `feat: mouse support for tool expansion and scrolling` | +| **PR description** | • Scroll wheel scrolls conversation. • Click toggles tool expand. • Click focuses input. | +| **Review checklist** | ① Scroll wheel works. ② Click toggles expand. ③ Click focuses input. ④ Keyboard still works. ⑤ Tests verify. | +| **Risk** | Low — crossterm mouse events are standard. | +| **Docs** | Document mouse support. | +| **Harness impact** | None. | + +#### Week 36: Internal Pager + +| Field | Value | +|---|---| +| **Deliverable** | For long outputs (`/status`, `/config`), open full-screen pager with `j`/`k`/`q`. | +| **Files touched** | `tui/widgets/pager.rs` (created, 180 lines), `tui/app.rs (+40)` | +| **New files** | `tui/widgets/pager.rs` | +| **Test strategy** | `#[test] fn test_pager_scrolls()`. `#[test] fn test_pager_quits_on_q()`. | +| **Merge criteria** | Long outputs open pager. `j`/`k` scroll. `q` quits back to chat. | +| **Rollback strategy** | Dump long output directly in chat (scrolling mess). | +| **PR title** | `feat: internal pager for long outputs` | +| **PR description** | • Long outputs open pager overlay. • `j`/`k` scroll. • `q` returns to chat. | +| **Review checklist** | ① Pager opens for long output. ② `j`/`k` scroll. ③ `q` quits. ④ Line numbers optional. ⑤ Tests verify. | +| **Risk** | Low — scrollable text view. | +| **Docs** | Document pager keybindings. | +| **Harness impact** | None. | + +#### Week 37: Optional Right Sidebar + +| Field | Value | +|---|---| +| **Deliverable** | `Ctrl+P` toggles right sidebar showing active tool status or todo list. | +| **Files touched** | `tui/widgets/sidebar.rs` (created, 250 lines), `tui/app.rs (+60)` | +| **New files** | `tui/widgets/sidebar.rs` | +| **Test strategy** | `#[test] fn test_sidebar_toggles()`. `#[test] fn test_sidebar_shows_tools()`. | +| **Merge criteria** | Sidebar toggles on/off. Shows tool list. Doesn't break chat layout. | +| **Rollback strategy** | No sidebar (single-pane only). | +| **PR title** | `feat: optional right sidebar for tool status` | +| **PR description** | • `Ctrl+P` toggles sidebar. • Shows active tools and status. • Resizes chat area. | +| **Review checklist** | ① Toggle works. ② Tool status visible. ③ Chat area shrinks. ④ No layout breaks. ⑤ Tests verify. | +| **Risk** | Low — layout toggle. | +| **Docs** | Document sidebar. | +| **Harness impact** | None. | + +#### Week 38: Custom Keybindings + +| Field | Value | +|---|---| +| **Deliverable** | Read keybindings from `.claw.json`. Remap any action to any key chord. | +| **Files touched** | `tui/event.rs (+60)`, `tui/app.rs (+20)` | +| **New files** | `tui/keybindings.rs` (created, 150 lines) | +| **Test strategy** | `#[test] fn test_custom_keybinding_loaded()`. `#[test] fn test_keybinding_override()`. | +| **Merge criteria** | Config file read on startup. Overrides apply. Invalid config shows error in chat. | +| **Rollback strategy** | Hardcoded keybindings (ignore config). | +| **PR title** | `feat: user-configurable keybindings` | +| **PR description** | • Keybindings from `.claw.json`. • Override any action. • Validation with error messages. | +| **Review checklist** | ① Config loaded. ② Overrides apply. ③ Invalid config errors. ④ Defaults documented. ⑤ Tests verify. | +| **Risk** | Low — config parsing. | +| **Docs** | Document keybinding config format. | +| **Harness impact** | None. | + +--- + +### Phase F: Polish & Hardening (Weeks 39–46) — Make It Production-Ready + +**Goal:** Stable, tested, accessible, well-documented. + +#### Week 39: Screen Reader Accessibility + +| Field | Value | +|---|---| +| **Deliverable** | Screen reader support: `crossterm` title updates, status announcements via `SetTitle`. | +| **Files touched** | `tui/app.rs (+40)`, `tui/event.rs (+20)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_title_updates_on_message()`. | +| **Merge criteria** | Terminal title reflects app state. Status messages announced. | +| **Rollback strategy** | No accessibility features. | +| **PR title** | `feat: screen reader accessibility annotations` | +| **PR description** | • Terminal title updates. • Status announcements. • ARIA-like labels on widgets. | +| **Review checklist** | ① Title updates. ② Status announced. ③ No visual changes. ④ Tested with `orca` or `NVDA`. ⑤ Tests verify. | +| **Risk** | Low — title updates only. | +| **Docs** | Document accessibility features. | +| **Harness impact** | None. | + +#### Week 40: Small Terminal Degradation + +| Field | Value | +|---|---| +| **Deliverable** | If terminal <30 rows or <80 cols, show warning banner and disable sidebar. | +| **Files touched** | `tui/app.rs (+80)`, `tui/theme.rs (+20)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_small_terminal_warning()`. `#[test] fn test_sidebar_disabled_narrow()`. | +| **Merge criteria** | Warning visible on small terminals. Sidebar auto-disabled. Chat still functional. | +| **Rollback strategy** | No degradation (layout may break on small terminals). | +| **PR title** | `feat: small terminal graceful degradation` | +| **PR description** | • Warning on <30 rows or <80 cols. • Sidebar disabled. • Layout adjusted. | +| **Review checklist** | ① Warning visible. ② Sidebar disabled. ③ Chat scrollable. ④ No panic. ⑤ Tests verify. | +| **Risk** | Low — conditional layout. | +| **Docs** | Document minimum terminal size. | +| **Harness impact** | None. | + +#### Week 41: Unicode Width Handling + +| Field | Value | +|---|---| +| **Deliverable** | Use `unicode-width` for CJK and emoji so layouts don't break. | +| **Files touched** | `Cargo.toml (+1)`, `tui/widgets/chat.rs (+30)`, `tui/widgets/markdown.rs (+30)` | +| **New files** | None | +| **Dependencies added** | `unicode-width = "0.2"` | +| **Test strategy** | `#[test] fn test_cjk_width()`. `#[test] fn test_emoji_width()`. | +| **Merge criteria** | CJK characters take 2 columns. Emoji take 2 columns (on supported terminals). Layouts don't break. | +| **Rollback strategy** | Assume ASCII width (breaks on CJK/emoji). | +| **PR title** | `fix: unicode width for layout correctness` | +| **PR description** | • `unicode-width` for correct column counting. • CJK and emoji handled. • Layout integrity. | +| **Review checklist** | ① CJK correct. ② Emoji correct. ③ Layout intact. ④ No truncation. ⑤ Tests verify. | +| **Risk** | Low — well-tested crate. | +| **Docs** | Document unicode support. | +| **Harness impact** | None. | + +#### Week 42: Snapshot Testing with `insta` + +| Field | Value | +|---|---| +| **Deliverable** | `insta` snapshot tests for complex conversations: tables, code blocks, diffs. | +| **Files touched** | `Cargo.toml (+1, dev-dependency)`, `tests/snapshots/` (new dir) | +| **New files** | `tests/tui_snapshots.rs` (150 lines) | +| **Dependencies added** | `insta = "1"` (dev-dependency) | +| **Test strategy** | Render complex markdown to `TestBackend`, snapshot with `insta::assert_snapshot`. | +| **Merge criteria** | Snapshots committed. CI checks them. Any rendering change updates snapshot intentionally. | +| **Rollback strategy** | Remove snapshot tests (more manual QA needed). | +| **PR title** | `test: snapshot tests for tui rendering` | +| **PR description** | • `insta` snapshot tests for rendering. • Tables, code blocks, diffs covered. • CI enforces no drift. | +| **Review checklist** | ① Snapshots committed. ② CI checks. ③ `cargo insta review` documented. ④ Coverage: table, code, diff. ⑤ No test regressions. | +| **Risk** | Low — adds tests only. | +| **Docs** | Document snapshot workflow in `CONTRIBUTING.md`. | +| **Harness impact** | None. | + +#### Week 43: Stress Testing + +| Field | Value | +|---|---| +| **Deliverable** | 1,000-message session. No memory leaks, OOM, or frame drops. | +| **Files touched** | `benches/tui_stress.rs` (new) | +| **New files** | `benches/tui_stress.rs` | +| **Test strategy** | Criterion benchmark: create 1000 messages, render, measure memory and time. | +| **Merge criteria** | Memory stable (<100MB). Render time <100ms for 1000 messages. | +| **Rollback strategy** | Document performance limits. | +| **PR title** | `test: stress test for large sessions` | +| **PR description** | • 1000-message stress test. • Memory and time measured. • No leaks. • Documented limits. | +| **Review checklist** | ① 1000 messages created. ② Memory <100MB. ③ Render <100ms. ④ No leaks. ⑤ Documented. | +| **Risk** | Low — test only. | +| **Docs** | Document performance limits. | +| **Harness impact** | None. | + +#### Week 44: Windows Testing + +| Field | Value | +|---|---| +| **Deliverable** | Verified in PowerShell and Windows Terminal. Fix cursor/resize issues. | +| **Files touched** | `tui/event.rs (+20)`, `tui/mod.rs (+20)` | +| **New files** | None | +| **Test strategy** | Manual QA on Windows. Automated tests already run in CI (GitHub Actions `windows-latest`). | +| **Merge criteria** | `claw tui` works on Windows. Resize handled. No cursor glitches. | +| **Rollback strategy** | Document Windows as best-effort. | +| **PR title** | `fix: windows terminal compatibility` | +| **PR description** | • Windows Terminal compatibility verified. • Resize fixes. • Cursor fixes. • PowerShell tested. | +| **Review checklist** | ① Opens on Windows. ② Resize works. ③ No cursor artifacts. ④ CI passes. ⑤ PowerShell smoke test. | +| **Risk** | Medium — Windows terminal quirks are unpredictable. | +| **Docs** | Document Windows setup. | +| **Harness impact** | None. | + +#### Week 45: TUI User Guide + +| Field | Value | +|---|---| +| **Deliverable** | `docs/TUI-USAGE.md` — comprehensive user guide with ASCII art screenshots, keybindings, tips. | +| **Files touched** | `docs/TUI-USAGE.md` (created, 300 lines) | +| **New files** | `docs/TUI-USAGE.md` | +| **Test strategy** | None — documentation only. | +| **Merge criteria** | Document covers: installation, first run, keybindings, slash commands, tips, troubleshooting. | +| **Rollback strategy** | N/A — docs. | +| **PR title** | `docs: tui user guide` | +| **PR description** | • Comprehensive TUI guide. • ASCII art screenshots. • Keybindings table. • Tips and tricks. | +| **Review checklist** | ① All keys documented. ② ASCII art accurate. ③ Troubleshooting section. ④ Installation steps. ⑤ Proofread. | +| **Risk** | None — docs only. | +| **Docs** | Self-documenting. | +| **Harness impact** | None. | + +#### Week 46: `claw doctor --tui` Diagnostic + +| Field | Value | +|---|---| +| **Deliverable** | `claw doctor --tui` runs a self-test: opens TUI, verifies rendering, reports issues. | +| **Files touched** | `cli.rs (+30)`, `tui/mod.rs (+60)` | +| **New files** | None | +| **Test strategy** | `#[test] fn test_doctor_tui_smoke()`. | +| **Merge criteria** | `claw doctor --tui` reports TUI health. Useful for bug reports. | +| **Rollback strategy** | No `--tui` flag for doctor. | +| **PR title** | `feat: tui diagnostic mode in doctor` | +| **PR description** | • `--tui` flag for `claw doctor`. • Self-test rendering. • Reports terminal capabilities, colors, size. | +| **Review checklist** | ① `--tui` flag works. ② Reports terminal info. ③ Detects issues. ④ Useful output. ⑤ Tests verify. | +| **Risk** | Low — diagnostic only. | +| **Docs** | Document doctor TUI mode. | +| **Harness impact** | Add `doctor_tui` scenario. | + +--- + +### Phase G: Advanced Features (Weeks 47–52) — Make It Best-in-Class + +**Goal:** Features that differentiate claw from other AI CLI TUIs. + +#### Week 47: Image Support (Vision Input) + +| Field | Value | +|---|---| +| **Deliverable** | When vision input lands in the API (ROADMAP #220), render image attachments inline using `sixel` or `iTerm2` image protocols. Text fallback for unsupported terminals. | +| **Files touched** | `tui/widgets/image.rs` (created, 200 lines), `tui/app.rs (+20)` | +| **New files** | `tui/widgets/image.rs` | +| **Test strategy** | `#[test] fn test_image_fallback_text()`. `#[test] fn test_sixel_detection()`. | +| **Merge criteria** | Images render on iTerm2/kitty. Fallback text on other terminals. | +| **Rollback strategy** | Text placeholder: `[image: filename.png]`. | +| **PR title** | `feat: inline image rendering in tui` | +| **PR description** | • Sixel and iTerm2 image protocols. • Inline rendering. • Text fallback. | +| **Review checklist** | ① iTerm2 renders. ② kitty renders. ③ Fallback on standard terminal. ④ No panic. ⑤ Tests verify. | +| **Risk** | High — image protocols are complex and terminal-specific. | +| **Docs** | Document image support. | +| **Harness impact** | Deferred until vision API lands. | + +#### Week 48: Notebook Editing + +| Field | Value | +|---|---| +| **Deliverable** | `/notebook` opens notebook editor in split pane (VS Code-like notebook view). | +| **Files touched** | `tui/widgets/notebook.rs` (created, 250 lines), `tui/app.rs (+40)` | +| **New files** | `tui/widgets/notebook.rs` | +| **Test strategy** | `#[test] fn test_notebook_renders_cells()`. `#[test] fn test_notebook_cell_edit()`. | +| **Merge criteria** | Notebook cells render. Cells editable. Save/execute work. | +| **Rollback strategy** | No notebook editing in TUI (use REPL or external editor). | +| **PR title** | `feat: notebook editing in tui` | +| **PR description** | • Notebook cell view. • Edit cells in TUI. • Save back to file. | +| **Review checklist** | ① Cells render. ② Editable. ③ Save works. ④ Execute works. ⑤ Tests verify. | +| **Risk** | Medium — notebook format is complex. | +| **Docs** | Document notebook editing. | +| **Harness impact** | None. | + +#### Week 49: Agent Team Dashboard + +| Field | Value | +|---|---| +| **Deliverable** | `Ctrl+A` shows real-time view of running agents, their status, outputs — like `htop` for agents. | +| **Files touched** | `tui/widgets/agent_dashboard.rs` (created, 250 lines), `tui/app.rs (+40)` | +| **New files** | `tui/widgets/agent_dashboard.rs` | +| **Test strategy** | `#[test] fn test_dashboard_shows_agents()`. `#[test] fn test_dashboard_updates()`. | +| **Merge criteria** | Agents listed with status. Output streams visible. Sortable by status. | +| **Rollback strategy** | No dashboard (use `/agents` slash command). | +| **PR title** | `feat: agent team dashboard in tui` | +| **PR description** | • Real-time agent monitoring. • Like `htop` for agents. • Output streams. • Sortable. | +| **Review checklist** | ① Agents listed. ② Status visible. ③ Output streams. ④ Sort works. ⑤ Tests verify. | +| **Risk** | Medium — requires agent runtime integration. | +| **Docs** | Document dashboard. | +| **Harness impact** | None. | + +#### Week 50: MCP Server Inspector + +| Field | Value | +|---|---| +| **Deliverable** | `Ctrl+M` opens pane showing connected MCP servers, their tools, recent calls. | +| **Files touched** | `tui/widgets/mcp_inspector.rs` (created, 200 lines), `tui/app.rs (+20)` | +| **New files** | `tui/widgets/mcp_inspector.rs` | +| **Test strategy** | `#[test] fn test_mcp_inspector_shows_servers()`. | +| **Merge criteria** | MCP servers listed. Tools visible. Recent calls scrollable. | +| **Rollback strategy** | No inspector (use `/mcp` slash command). | +| **PR title** | `feat: mcp server inspector sidebar` | +| **PR description** | • MCP server list. • Tool inventory. • Recent call log. | +| **Review checklist** | ① Servers listed. ② Tools visible. ③ Calls logged. ④ Scrollable. ⑤ Tests verify. | +| **Risk** | Low — MCP data is already available in runtime. | +| **Docs** | Document MCP inspector. | +| **Harness impact** | None. | + +#### Week 51: Teleport Mode (Fuzzy File Finder) + +| Field | Value | +|---|---| +| **Deliverable** | `Ctrl+G` opens fuzzy file finder (like `fzf`). Jump to file, send `/read` tool call. | +| **Files touched** | `tui/widgets/teleport.rs` (created, 180 lines), `tui/app.rs (+40)` | +| **New files** | `tui/widgets/teleport.rs` | +| **Test strategy** | `#[test] fn test_teleport_lists_files()`. `#[test] fn test_teleport_selects_file()`. | +| **Merge criteria** | `Ctrl+G` opens finder. Typing filters. Enter selects file. `/read` sent automatically. | +| **Rollback strategy** | No teleport (use `/read` manually). | +| **PR title** | `feat: fuzzy file finder teleport in tui` | +| **PR description** | • `Ctrl+G` opens file finder. • Fuzzy filter. • Enter sends `/read`. | +| **Review checklist** | ① Files listed. ② Filter works. ③ Enter selects. ④ `/read` sent. ⑤ Tests verify. | +| **Risk** | Low — file listing + filter. | +| **Docs** | Document teleport mode. | +| **Harness impact** | None. | + +#### Week 52: TUI as Default + +| Field | Value | +|---|---| +| **Deliverable** | TUI is the default for `claw` when a TTY is detected. `--no-tui` or piped input uses REPL. Update ROADMAP.md. Cut release notes. | +| **Files touched** | `main.rs (+20)`, `cli.rs (+15)`, `ROADMAP.md (+5)`, `docs/RELEASE-NOTES.md` (new) | +| **New files** | `docs/RELEASE-NOTES.md` | +| **Test strategy** | `#[test] fn test_default_tui_on_tty()`. `#[test] fn test_repl_on_pipe()`. | +| **Merge criteria** | `claw` opens TUI on TTY. `claw --no-tui` opens REPL. Piped input uses REPL. | +| **Rollback strategy** | Make REPL default again (one-line change in `main.rs`). | +| **PR title** | `feat: make tui the default interactive mode` | +| **PR description** | • TUI is default when TTY detected. • `--no-tui` for REPL. • Piped input uses REPL. • Release notes. | +| **Review checklist** | ① TTY → TUI. ② `--no-tui` → REPL. ③ Pipe → REPL. ④ Release notes written. ⑤ ROADMAP updated. | +| **Risk** | Low — behavioral toggle only. | +| **Docs** | Update all docs, release notes. | +| **Harness impact** | Update harness for default TUI. | + +--- + +## 4. Implementation Specs (Weeks 1–12 in Detail) + +### Week 1: Extract `app.rs` — Detailed Spec + +**Function signatures to preserve:** + +```rust +// app.rs +pub struct LiveCli { + pub client: Arc, + pub session: Session, + pub config: RuntimeConfig, + pub permission_mode: PermissionMode, + pub tool_executor: Arc, +} + +impl LiveCli { + pub fn new(/* ... */) -> Self; + pub fn run(&mut self) -> Result<(), RuntimeError>; + pub fn run_turn(&mut self, prompt: &str) -> Result; +} +``` + +**Extraction steps:** +1. Copy `main.rs` to `app.rs`. +2. In `app.rs`, remove `fn main()` and arg parsing. +3. In `main.rs`, remove `LiveCli` struct and impl, keep only `fn main()` and `let mut cli = LiveCli::new(...); cli.run();`. +4. Add `mod app;` and `use app::LiveCli;` in `main.rs`. +5. Verify `cargo check`. +6. Run tests. +7. Verify `main.rs` <200 lines. + +**Test preservation checklist:** +- [ ] `test_repl_smoke` → still in `app.rs` +- [ ] `test_streaming_output` → still in `app.rs` +- [ ] `test_tool_call_display` → still in `app.rs` +- [ ] `test_permission_prompt` → still in `app.rs` +- [ ] `test_session_save` → still in `app.rs` + +--- + +### Week 4: TUI Stub — Detailed Spec + +```rust +// cli.rs +pub enum CliAction { + // ... existing variants ... + Tui, +} + +// In main.rs match on CliAction::Tui: +CliAction::Tui => { + tui::run()?; +} +``` + +```rust +// tui/mod.rs +use std::io; + +pub fn run() -> io::Result<()> { + eprintln!("TUI mode coming soon."); + Ok(()) +} +``` + +**Integration test:** +```rust +// tests/tui_smoke.rs +use std::process::Command; + +#[test] +fn test_tui_subcommand_exists() { + let output = Command::new("cargo") + .args(["run", "-p", "rusty-claude-cli", "--", "tui", "--help"]) + .output() + .expect("cargo run failed"); + assert!(output.status.success(), "claw tui --help should succeed"); +} +``` + +--- + +### Week 6: Ratatui Skeleton — Detailed Spec + +```rust +// tui/mod.rs +use std::io; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + widgets::{Block, Borders, Paragraph}, + Terminal, Frame, +}; + +pub fn run() -> io::Result<()> { + ratatui::init(); + let result = run_app(); + ratatui::restore(); + result +} + +fn run_app() -> io::Result<()> { + let mut terminal = ratatui::try_init()?; + loop { + terminal.draw(|f| ui(f))?; + if let Event::Key(KeyEvent { code: KeyCode::Char('q'), .. }) = event::read()? { + return Ok(()); + } + } +} + +fn ui(frame: &mut Frame) { + let area = frame.area(); + let paragraph = Paragraph::new("Hello, claw") + .block(Block::default().borders(Borders::ALL).title("claw TUI")); + frame.render_widget(paragraph, area); +} +``` + +**Cargo.toml changes:** +```toml +[dependencies] +# existing deps ... +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +``` + +**TestBackend test:** +```rust +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + + #[test] + fn test_tui_init_buffer() { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| ui(f)).unwrap(); + let buffer = terminal.backend().buffer(); + // "Hello, claw" is at least 11 chars + let content: String = buffer.content.iter().map(|c| c.symbol()).collect(); + assert!(content.contains("Hello, claw")); + } +} +``` + +--- + +### Week 9: Composer Widget — Detailed Spec + +```rust +// tui/widgets/input.rs +use tui_textarea::TextArea; + +pub struct ComposerWidget { + textarea: TextArea<'static>, + history: Vec, + history_index: Option, +} + +impl ComposerWidget { + pub fn new() -> Self; + pub fn handle_event(&mut self, key: KeyEvent) -> ComposerAction; + pub fn text(&self) -> &str; + pub fn clear(&mut self); +} + +pub enum ComposerAction { + None, + Submit(String), + HistoryPrev, + HistoryNext, +} +``` + +**Key mappings:** +- `Enter` → `Submit(text)` +- `Shift+Enter` → insert newline (handled by `TextArea`) +- `Ctrl+C` → clear +- `Up` → `HistoryPrev` +- `Down` → `HistoryNext` + +--- + +## 5. Quality Assurance & Testing + +### 5.1 Unit Test Strategy + +| Module | Test Approach | Backend | +|---|---|---| +| `tui/event.rs` | Key event translation | No backend (pure functions) | +| `tui/app.rs` | State transitions | `TestBackend` | +| `tui/widgets/chat.rs` | Message rendering, scroll | `TestBackend` | +| `tui/widgets/input.rs` | Key handling, history | `TestBackend` | +| `tui/widgets/markdown.rs` | Markdown parsing → buffer | `TestBackend` | +| `tui/widgets/status_bar.rs` | Layout, content | `TestBackend` | +| `tui/widgets/tool_call.rs` | Lifecycle, expand | `TestBackend` | +| `tui/widgets/permission.rs` | Modal rendering | `TestBackend` | + +### 5.2 Integration Test Strategy + +```rust +// tests/tui_integration.rs +#[tokio::test] +async fn test_full_conversation() { + let mut app = App::new(mock_runtime()); + // Simulate user typing "hello" + app.handle_event(AppEvent::Key(KeyCode::Char('h'))); + // ... simulate Enter ... + app.handle_event(AppEvent::AssistantDelta("Hi!".to_string())); + // Assert message count == 2 + assert_eq!(app.messages.len(), 2); +} +``` + +### 5.3 Mock Parity Harness — New Scenarios + +| Scenario | Week Added | What It Tests | +|---|---|---| +| `tui_stub_smoke` | 4 | `claw tui` exits cleanly | +| `tui_smoke_open_close` | 6 | Alternate screen opens and closes | +| `tui_full_layout_smoke` | 12 | Full layout renders with mock data | +| `tui_streaming_mock` | 13 | Event loop processes streaming events | +| `tui_streaming_text` | 14 | Text appears in chat | +| `tui_session_save_resume` | 15 | Session persistence | +| `tui_tool_call_display` | 16 | Tool call inline rendering | +| `tui_permission_prompt` | 17 | Modal overlay | +| `tui_error_handling` | 18 | Error messages in chat | +| `tui_multi_turn` | 19 | Multiple conversation turns | +| `tui_markdown_render` | 21 | Markdown widget | +| `doctor_tui` | 46 | `claw doctor --tui` | + +### 5.4 CI Strategy + +Add to `.github/workflows/rust-ci.yml`: + +```yaml +- name: Build with TUI feature + run: cargo build --workspace --features tui + +- name: Build without TUI feature + run: cargo build --workspace + +- name: Run TUI tests + run: cargo test --workspace --features tui + +- name: Run benchmarks + run: cargo bench --workspace --features tui + if: github.ref == 'refs/heads/main' +``` + +--- + +## 6. Risk Management & Rollback + +### 6.1 Phase Rollback Matrix + +| Phase | Rollback Command | Functionality Lost | Time to Rollback | +|---|---|---|---| +| A (Weeks 1–6) | `git revert ` | TUI stub, `claw tui` command | 5 min | +| B (Weeks 7–12) | `git checkout HEAD~6 -- tui/` | Full-screen TUI | 10 min | +| C (Weeks 13–20) | `git revert ` | Live conversation, streaming | 15 min | +| D (Weeks 21–28) | Disable markdown widget, use plain text | Rich rendering | 20 min | +| E (Weeks 29–38) | Disable search, sidebar, mouse | Power features | 30 min | +| F (Weeks 39–46) | Remove snapshot tests, docs | Tests, docs | 10 min | +| G (Weeks 47–52) | `--no-tui` default | Default-on TUI | 5 min | + +### 6.2 Nuclear Option + +If the TUI introduces critical instability: + +```bash +# Remove TUI code, keep extractions +git checkout main -- rust/crates/rusty-claude-cli/src/tui/ +git revert HEAD~N..HEAD # where N = weeks since Week 4 + +# The REPL and all other functionality are untouched +# `claw tui` command is removed +# `app.rs`, `format.rs`, `session_mgr.rs` remain (pure wins) +``` + +--- + +## 7. Reviewer Guide & PR Template + +### 7.1 How to Review a TUI PR + +1. **Check the week number** — does this PR match the week's deliverable? If not, ask for scope justification. +2. **Verify line count** — is it ≤400 lines? If not, ask for a split. +3. **Check tests** — does every new file have at least one `#[cfg(test)]` block with a `TestBackend` assertion? +4. **Check no REPL changes** — does the PR touch `input.rs`, `render.rs`, or the REPL loop in ways that could break existing behavior? +5. **Run `cargo test --workspace`** — does it pass? + +### 7.2 PR Template for TUI PRs + +```markdown +## Week X: [Deliverable Name] + +### What changed +- [One-line summary] + +### Files +- `tui/foo.rs` (+N/-M) +- `app.rs` (+N/-M) + +### Tests +- [ ] Every new module has a `TestBackend` test +- [ ] `cargo test --workspace` passes +- [ ] `cargo clippy --workspace` clean +- [ ] `cargo fmt --all --check` passes + +### Verification +```bash +cd rust +cargo test --workspace +cargo run -p rusty-claude-cli -- tui --help +``` + +### Rollback +If this PR causes issues: `git revert HEAD` + +### Risk +[low/medium/high] — [one sentence] +``` + +--- + +## 8. Appendices + +### A. Dependency Matrix + +| Crate | Version | License | Purpose | Added Week | +|---|---|---|---|---| +| `ratatui` | `0.29` | MIT | TUI framework, layout engine, widgets | 6 | +| `crossterm` (event-stream) | `0.28` | MIT | Already present; event-stream feature added for async events | 6 | +| `tui-textarea` | `0.7` | MIT | Multi-line text input widget | 9 | +| `unicode-width` | `0.2` | MIT | Correct column counting for CJK/emoji | 41 | +| `arboard` | `3` | MIT | Cross-platform clipboard | 34 | +| `insta` (dev) | `1` | Apache-2.0 | Snapshot testing | 42 | + +**MSRV impact:** None. All crates support Rust 1.70+. + +### B. Performance Budget + +| Metric | Target | Measured At | +|---|---|---| +| Frame rate | 60 FPS (p99 <16ms) | Week 28 | +| Memory per 1000 messages | <100 MB | Week 43 | +| Binary size (without TUI) | No change | Week 20 | +| Binary size (with TUI) | +2–3 MB | Week 20 | +| Clean build time | <5 min | Week 6 | +| Incremental build time | <30 sec | Week 6 | + +### C. Glossary + +| Term | Definition | +|---|---| +| **TUI** | Terminal User Interface — full-screen, alternate-buffer terminal application | +| **REPL** | Read-Eval-Print Loop — the existing line-based interactive shell | +| **Monolith** | The 11,282-line `main.rs` containing all CLI logic | +| **TestBackend** | ratatui's in-memory terminal buffer for headless testing | +| **EventBroker** | Bridges crossterm events to TUI application events | +| **Composer** | The text input widget at the bottom of the chat | +| **Streaming cell** | The in-progress assistant message being rendered incrementally | + +--- + +*Generated: 2026-06-12 | Author: TheArchitectit | Review status: Draft for upstream PR* +*Based on post-mortem of `feat/ui-hardening`, `fix/ui-parity`, `feat/uiux-redesign`, `rcc/ui-polish`, and `rust/TUI-ENHANCEMENT-PLAN.md`* diff --git a/docs/tui/TUI.md b/docs/tui/TUI.md new file mode 100644 index 00000000..fab5ea74 --- /dev/null +++ b/docs/tui/TUI.md @@ -0,0 +1,196 @@ +# TUI Research & Build Plan for Claw Code + +**Status:** Draft | **Date:** 2026-06-12 | **Branch:** `feat/tui` + +## Executive Summary + +This document synthesizes research on how similar tools build terminal UIs and proposes a phased plan for adding a first-class TUI to `claw`. The existing CLI already embeds `crossterm`, `rustyline`, `pulldown-cmark`, and `syntect` — giving us a strong foundation. The TUI should be a **new mode** (`claw tui` or `claw --tui`) rather than a replacement, sharing the `ConversationRuntime`, `Session`, and tool surfaces already in `crates/runtime`. + +## Prior Art: How Peers Build TUIs + +### 1. OpenAI Codex CLI (`codex-rs/tui`) +The closest direct prior art — a Rust TUI for an AI coding assistant. + +| Component | Crate / Pattern | +|---|---| +| Framework | `ratatui` + `crossterm` | +| Event loop | `tokio`-async with `EventBroker` and frame-rate limiter | +| Layout | `App` (top-level state machine) → `ChatWidget` (history cells + in-flight streaming cell) → `bottom_pane` (composer/input) | +| Markdown | `pulldown-cmark` | +| Syntax highlight | `syntect` | +| Clipboard | `arboard` | +| Testing | `insta` snapshot testing | + +Key architectural decision: **top-level state machine** (`App`) that owns widget dispatch. Chat history is a scrollable list of "cells" (completed turns + one in-progress streaming cell). Input is a separate widget. This cleanly separates streaming concerns from input handling. + +### 2. `aichat` (sigoden) +A popular all-in-one LLM CLI. + +| Component | Crate / Pattern | +|---|---| +| REPL | `reedline` (nushell line editor) + `crossterm` | +| Prompts | `inquire` for interactive dialogs | +| Streaming | `render/` module with `MarkdownRender`, `markdown_stream`, `raw_stream` | +| Syntax highlight | `syntect` | +| Color | `ansi_colours` | + +Key insight: `aichat` shows that `reedline` is a stronger modern alternative to `rustyline` for rich REPLs, but the existing `rustyline` integration in `claw` can stay untouched if the TUI is a separate surface. The TUI should be a **new surface**, not a REPL replacement. + +### 3. `crush` (liljencrantz) +An advanced Rust shell. + +| Component | Crate / Pattern | +|---|---| +| REPL | `rustyline` (with file history) | +| Terminal control | `termion` | +| Layout | `unicode-width` for proper text measurement | + +**Key insight for `claw`:** `crush` demonstrates shell/parser separation — the REPL is thin, and the heavy lifting (parsing, execution) lives in shared modules. This mirrors how `claw` should build its TUI: thin TUI shell, shared `runtime` core. + +### 4. Other Rust TUI Patterns (from ecosystem research) +- **Pimalaya** (`himalaya` mail client): TUI as an add-on to existing CLI using feature flags; core logic is model-driven, UI is view-only. +- **Vuls** (security scanner): Terminal viewer uses vim-like keybindings; scan results shared between JSON/CLI/TUI outputs. +- **halp**: Rust CLI tool with optional TUI mode via feature flags or runtime detection. +- **TUIfying guides** (Jack Bisceglia, Isakdl): Show layering a TUI atop CLI logic without regressing headless usage — confirming the dual-surface strategy. + +## Crate Ecosystem: Recommended Stack + +| Layer | Crate | Rationale | +|---|---|---| +| **TUI Framework** | `ratatui` | De-facto standard; layout engine, widget system, backend abstraction over `crossterm`/`termion`. Already the choice of `codex-rs`. | +| **Backend** | `crossterm` (already in use) | Cross-platform; `claw` already depends on it. | +| **Async bridge** | `tokio` (already in use) | `ratatui` + `crossterm` + `tokio` via `crossterm::event::EventStream` for async event handling. | +| **Input widget** | `tui-textarea` or `tui-input` | Pre-built text input widget with scrolling, wrapping, key handling. `tui-textarea` is mature and widely used. | +| **Markdown render** | `pulldown-cmark` → custom widget (already in use) | `claw` already has `render.rs` using `pulldown-cmark` + `syntect`. Port/adapt to a `ratatui::widgets::Widget`. | +| **Syntax highlight** | `syntect` (already in use) | No change needed — wire existing `HighlightLines` into a ratatui Paragraph widget. | +| **Clipboard** | `arboard` (optional) | For "copy code block" or "copy response" — nice-to-have for MVP. | + +Crate **not** recommended: +- `tui-rs` — deprecated, succeeded by `ratatui`. +- `termion` — `crossterm` is already in the dependency tree; doubling backends adds pain. +- `reedline` — great for REPL, but the TUI is a full-screen app; `ratatui` owns input. + +## Architecture: Proposed Design + +### Guiding Principles +1. **Non-breaking**: `claw prompt`, `claw --help`, and the REPL stay exactly as they are. +2. **Shared core**: The TUI consumes `ConversationRuntime`, `Session`, `ToolExecutor` — no duplication. +3. **Alt-screen only**: TUI runs in the alternate screen buffer; exiting returns to the shell cleanly. +4. **Feature-flag friendly**: Start as always-on dependency, gate behind `tui` feature if binary bloat becomes an issue. + +### Module Layout + +```text +rust/crates/rusty-claude-cli/src/ + main.rs # existing — add `claw tui` dispatch + cli.rs # existing — add `Tui` to CliAction + tui/ + mod.rs # public entry: run(env) -> Result + app.rs # App: top-level state machine (Screen enum) + event.rs # EventBroker: bridges crossterm events → App events + widgets/ + chat.rs # ChatWidget: scrollable message list + input.rs # ComposerWidget: multi-line input + send + sidebar.rs # Optional: session list / tool status + render.rs # Markdown-to-ratatui Paragraph rendering (adapts existing render.rs) +``` + +### State Machine: `App` + +```rust +enum Screen { + Chat { // primary chat view + messages: Vec, + scroll: ScrollState, + input: InputState, + streaming: Option, + }, + SessionPicker, // list saved sessions (nice-to-have) + Help, // keybindings overlay + Quit, // shutdown signal +} +``` + +### Event Flow + +``` +┌─────────────┐ ┌─────────────────┐ ┌──────────┐ +│ Crossterm │────▶│ EventBroker │────▶│ App │ +│ (keyboard) │ │ (tokio stream) │ │ (update) │ +└─────────────┘ └─────────────────┘ └────┬─────┘ + │ + ┌────────────────┘ + ▼ + ┌───────────────┐ + │ ratatui::Frame│────▶ terminal draw + └───────────────┘ +``` + +### Integration with Existing Runtime + +The TUI reuses these existing types directly (no wrappers): + +| Existing Type | TUI Usage | +|---|---| +| `ConversationRuntime` | Drive turns; receive `AssistantEvent` / `TurnProgressReporter` | +| `Session` / `SessionStore` | Persist and resume chat history | +| `ToolExecutor` / `execute_tool` | Execute tool calls from streaming responses | +| `render::TerminalRenderer` | Adapt to ratatui `Paragraph` / `Text` | +| `input::LineEditor` | Not used; TUI has its own `tui-textarea` input | + +The runtime emits events via `TurnProgressReporter` — the TUI provides its own reporter that writes into a `tokio::sync::mpsc` channel consumed by the event loop. + +## Implementation Roadmap + +### Phase 0: Prep (this PR) +- Create `feat/tui` branch ✓ +- Add `ratatui`, `tui-textarea` (and optionally `crossterm` with `event-stream` feature) to `rusty-claude-cli/Cargo.toml` +- Ensure `cargo check --workspace` passes + +### Phase 1: Skeleton (MVP) +1. **`tui/mod.rs`** — `run_tui()` entrypoint: init terminal (alternate screen, raw mode), run event loop, restore on exit. +2. **`tui/event.rs`** — `EventBroker`: `crossterm::event::EventStream` → `AppEvent` (`Tick`, `Key`, `Resize`, `Backend(AssistantEvent)`). +3. **`tui/app.rs`** — Minimal `App` with one `Screen::Chat`. State: message list (mock data) + input field. +4. **`tui/widgets/chat.rs`** — `ChatWidget`: renders `Vec` as `List` or vertical `Layout` of `Paragraph`s. +5. **`tui/widgets/input.rs`** — `ComposerWidget`: wraps `tui_textarea::TextArea`, handles Enter-to-send, Shift+Enter for newline. +6. **Navigation** — `Ctrl+C` or `q` to quit; `Ctrl+L` to clear; arrow keys to scroll. +7. **CLI hook** — Add `claw tui` command that calls `run_tui()`. + +### Phase 2: Live Conversation +1. Wire `ConversationRuntime::run_turn()` into the TUI event loop. +2. Create a `TuiProgressReporter` implementing `TurnProgressReporter` that sends `AppEvent::AssistantDelta` into the channel. +3. Render streaming assistant responses in real-time (append to `StreamingCell`, convert to `MessageCell` on finish). +4. Render tool calls (spinner + result) inline in the chat. +5. Session persistence: auto-save on exit, `/resume` integration. + +### Phase 3: Polish +1. **Markdown widget** — Port `render.rs` (`pulldown-cmark` + `syntect`) to a ratatui widget with proper soft-wrap, code blocks, and inline formatting. +2. **Sidebar** — Show session list or active tool status in a third pane. +3. **Search / filter** — Slash command picker, message search. +4. **Copy / clipboard** — `arboard` integration for copying code blocks. +5. **Theme** — Respect `NO_COLOR`, dark/light palette, user config. + +### Phase 4: Hardening +1. Graceful degradation when terminal is too small. +2. Mouse support (optional, via `crossterm`). +3. `cargo test` — unit tests for widgets, snapshot tests for render output. +4. Clippy clean, format check, docs. + +## Open Questions + +1. **Should the TUI replace the REPL?** No — keep both. The REPL is lightweight for quick queries; the TUI is immersive for long sessions. +2. **Should we add a `tui` feature flag?** Start without one (simpler). Add if binary size becomes a concern. +3. **How to handle permission prompts in the TUI?** Modal overlay (`ratatui-popup` or custom overlay widget) rather than spawning a subprocess. +4. **Should the TUI support multiple conversation tabs?** Nice-to-have; defer to Phase 3+. + +## Sources + +- [OpenAI Codex CLI (codex-rs/tui)](https://github.com/openai/codex/tree/main/codex-rs/tui) — primary prior art +- [sigoden/aichat](https://github.com/sigoden/aichat) — REPL + streaming patterns +- [liljencrantz/crush](https://github.com/liljencrantz/crush) — shell/parser separation +- [Ratatui](https://github.com/ratatui/ratatui) — TUI framework +- [Pimalaya blog: Designing a TUI](https://pimalaya.org/blog/designing-a-tui-to-preview-markdown-files/) — architectural separation +- [Jack Bisceglia: TUIfying Rust CLIs](https://dev.jackbisceglia.com/posts/tuify/) — layering TUI on CLI +- [Isakdl: How I TUI-fy my Rust CLIs](https://www.isakdl.com/TUIfy) — practical guide +- [halp](https://halp.cli.rs/) — optional TUI mode +- [Vuls](https://vuls.io/en/) — terminal viewer patterns diff --git a/docs/tui/moa-aichat.md b/docs/tui/moa-aichat.md new file mode 100644 index 00000000..4a4b1cc5 --- /dev/null +++ b/docs/tui/moa-aichat.md @@ -0,0 +1,607 @@ +# TUI Architecture Analysis: aichat (sigoden/aichat) + +> **Date:** 2025-06-12 +> **Version analyzed:** 0.30.0 (main branch, commit 82976d3) +> **Purpose:** Extract streaming markdown rendering patterns for claw-code's TUI + +--- + +## 1. Executive Summary + +aichat is a Rust LLM CLI that implements a **line-oriented REPL** (via `reedline`) with **incremental terminal rendering** (via `crossterm` raw mode). It is **not a full TUI** — there is no event loop, no ratatui, no widget tree. Instead, it takes over stdout in raw mode during streaming to perform surgical "erase-and-redraw" of the current rendering buffer. This approach is remarkably effective: it provides flicker-free streaming markdown with syntax highlighting, word-wrap, and CJK support at ~20 FPS while keeping the architecture simple. + +--- + +## 2. Architecture Overview + +### 2.1 REPL vs Streaming Boundary + +``` +┌─────────────────────────────────────────────────────────┐ +│ Repl (reedline-based) │ +│ - Read line via reedline (with completer, highlighter) │ +│ - Dispatch to run_repl_command() / ask() │ +│ - After ask() returns, reedline resumes │ +│ │ +│ During streaming: │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ raw mode ON → crossterm direct writes │ │ +│ │ MarkdownRender + stream.rs markdown_stream │ │ +│ │ raw mode OFF → back to reedline │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +**Key insight:** aichat does NOT use a persistent TUI framework. The REPL (reedline) owns the terminal normally. When streaming starts, it enters `crossterm::raw_mode`, takes over stdout, and when done, exits raw mode and returns control to reedline. This is a **modal design**: REPL mode ↔ streaming mode. + +### 2.2 Call Graph for Streaming + +``` +ask() + → call_chat_completions_streaming(input, client, abort_signal) + → tokio::join!( + client.chat_completions_streaming(input, &mut handler), // SSE producer + render_stream(rx, config, abort_signal) // Render consumer + ) + +render_stream() + → if IS_STDOUT_TERMINAL && config.highlight: + MarkdownRender::init(render_options) + markdown_stream(rx, &mut render, abort_signal) // ★ Main rendering pipeline + → else: + raw_stream(rx, abort_signal) // Plain text passthrough +``` + +The `tokio::join!` means SSE parsing/rendering runs concurrently — the render side processes buffered events on a 50ms tick. + +--- + +## 3. Streaming Markdown Rendering Pipeline (Deep Dive) + +### 3.1 The `markdown_stream_inner` Function + +This is the core ~80-line function in `src/render/stream.rs`. Here's the algorithm: + +``` +1. Enable raw mode +2. Read terminal columns +3. Initialize: buffer = "", buffer_rows = 1 +4. Show spinner until first event +5. Loop: + a. gather_events() — collect all pending events with 50ms timeout + b. For each Text event: + i. Replace tabs with 4 spaces + ii. Get cursor position (with retry, up to 3 attempts) + iii. Fix kitty dup-line bug (if col==0 && row>0 && buffer fills columns, row--) + iv. Reposition cursor: + - If cursor row+1 >= buffer_rows → MoveTo(0, row+1-buffer_rows) + - If cursor behind → ScrollUp + MoveTo(0,0) + v. Clear from cursor down (terminal::Clear::FromCursorDown) + vi. Append text to buffer + vii. If text contains newlines: + - Split buffer at last newline → (head, tail) + - render(head) → print_block (complete lines) + - buffer = tail + viii. Else: buffer = buffer + text + ix. render_line(&buffer) → render current incomplete line + x. If rendered output contains newlines (from wrapping): + - print_block the head portion + - Print the tail portion + - Recalculate buffer_rows + xi. Else: Print the rendered output, calculate buffer_rows + xii. Flush stdout + c. For Done event: break + d. Poll abort signal (Ctrl+C / Ctrl+D with 25ms timeout) +6. Disable raw mode +``` + +### 3.2 Event Batching: `gather_events()` + +```rust +async fn gather_events(rx: &mut UnboundedReceiver) -> Vec { + let mut texts = vec![]; + let mut done = false; + tokio::select! { + _ = async { + while let Some(reply_event) = rx.recv().await { + match reply_event { + SseEvent::Text(v) => texts.push(v), + SseEvent::Done => { done = true; break; } + } + } + } => {} + _ = tokio::time::sleep(Duration::from_millis(50)) => {} + }; + // Merge all text chunks into one, preserving order + if !texts.is_empty() { + events.push(SseEvent::Text(texts.join(""))) + } + ... +} +``` + +**Design:** Collect all pending text events, join them into a single string, and process in one render pass. The 50ms timeout prevents blocking indefinitely. This means the render loop runs at approximately **20 FPS max** (1000/50), which is smooth enough for terminal output. + +**Why this matters:** Batching reduces the number of full re-render cycles. Instead of re-rendering for every SSE chunk (which can arrive every few ms), it batches them every 50ms. This is crucial for avoiding visual glitching. + +### 3.3 The "Erase and Redraw" Strategy + +This is the most important pattern. Instead of trying to do incremental diffs on the terminal, aichat: + +1. **Tracks how many rows the current buffer occupies** (`buffer_rows`) +2. **Moves cursor back to the start of the buffer** (using the stored row position) +3. **Clears everything below cursor** (`Clear::FromCursorDown`) +4. **Re-renders the full buffer** through MarkdownRender +5. **Prints and tracks** the new row count + +This is simple but effective because: +- Terminal writes are fast (ANSI escape sequences) +- The buffer is typically small (a few KB of text) +- `Clear::FromCursorDown` is a single escape sequence — very fast + +### 3.4 Buffer Management + +``` +buffer: String // The raw, un-rendered text of the current "paragraph" being streamed +buffer_rows: u16 // How many terminal rows the rendered buffer currently occupies + +After each text event: + - Newlines that appear → split into "complete lines" (rendered+printed) and "tail" (new buffer) + - The buffer always holds the incomplete last line + - render_line() is called on the buffer for the in-progress line +``` + +**The split pattern:** +```rust +if text.contains('\n') { + let text = format!("{buffer}{text}"); + let (head, tail) = split_line_tail(&text); + let output = render.render(head); // render complete lines + print_block(writer, &output, columns)?; + buffer = tail.to_string(); // keep incomplete line as buffer +} else { + buffer = format!("{buffer}{text}"); +} +``` + +`split_line_tail` splits at the last newline — everything before is "committed" lines, everything after is the incomplete line. + +### 3.5 Cursor Position Recovery + +```rust +let mut attempts = 0; +let (col, mut row) = loop { + match cursor::position() { + Ok(pos) => break pos, + Err(_) if attempts < 3 => attempts += 1, + Err(e) => return Err(e.into()), + } +}; + +// Fix kitty terminal duplicate line bug +if col == 0 && row > 0 && display_width(&buffer) == columns as usize { + row -= 1; +} +``` + +**Defensive:** Cursor position queries can fail — retries up to 3 times. The kitty fix handles a specific terminal quirk where the auto-wrap creates an extra row. + +### 3.6 Scroll Handling + +```rust +if row + 1 >= buffer_rows { + // Normal case: cursor is at or below buffer start + queue!(writer, cursor::MoveTo(0, row + 1 - buffer_rows))?; +} else { + // Edge case: cursor is above buffer start (scroll happened) + let scroll_rows = buffer_rows - row - 1; + queue!( + writer, + terminal::ScrollUp(scroll_rows), + cursor::MoveTo(0, 0), + )?; +} +``` + +**No user-scroll distinction.** aichat always auto-scrolls. There's no scrollback buffer or user-scroll detection. This is a limitation of the line-oriented approach — once text scrolls off-screen, it's gone from the render buffer (though still in the terminal scrollback). + +--- + +## 4. MarkdownRender — Syntax Highlighting & Wrapping + +### 4.1 State Machine: LineType + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineType { + Normal, // Outside code block + CodeBegin, // ```lang line + CodeInner, // Inside code block + CodeEnd, // Closing ``` line +} +``` + +The render state machine tracks whether each line is inside a code block or not. This is critical because: +- Code blocks get special syntax highlighting +- Code blocks have different wrapping rules +- The code language (from ` ```lang `) determines which syntect syntax to use + +### 4.2 Per-Line Rendering + +```rust +fn render_line_mut(&mut self, line: &str) -> String { + let (line_type, code_syntax, is_code) = self.check_line(line); + let output = if is_code { + self.highlight_code_line(line, &code_syntax) + } else { + self.highlight_line(line, &self.md_syntax, false) + }; + self.prev_line_type = line_type; + self.code_syntax = code_syntax; + output +} +``` + +**Two render modes:** +1. **`render(&mut self, text)`** — Stateful, tracks LineType across lines. Used for full re-renders. +2. **`render_line(&self, line)`** — Stateless snapshot, used for the in-progress buffer line during streaming. + +### 4.3 Syntax Highlighting (syntect) + +```rust +fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String { + // Strip leading whitespace, highlight trimmed part, re-add whitespace + let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let trimmed_line: &str = &line[ws.len()..]; + + if let Some(theme) = &self.options.theme { + let mut highlighter = HighlightLines::new(syntax, theme); + if let Ok(ranges) = highlighter.highlight_line(trimmed_line, &self.syntax_set) { + return format!("{ws}{}", as_terminal_escaped(&ranges, self.options.truecolor)); + } + } + // Fallback: no theme → plain text + self.wrap_line(line.into(), is_code) +} +``` + +**Key pattern:** aichat uses syntect's `HighlightLines` which is designed for streaming — you create a highlighter per line and it maintains state across lines (for multi-line strings, comments, etc.). The `as_terminal_escaped` function converts syntect style ranges to ANSI escape codes. + +### 4.4 Truecolor vs 256-Color + +```rust +fn convert_color(c: SyntectColor, truecolor: bool) -> Color { + if truecolor { + Color::Rgb { r: c.r, g: c.g, b: c.b } + } else { + let value = (c.r, c.g, c.b).to_ansi256(); + let value = match value { + 7 | 15 | 231 | 252..=255 => 252, // Lower contrast for readability + _ => value, + }; + Color::AnsiValue(value) + } +} +``` + +Checks `COLORTERM=truecolor` env var. If not available, falls back to 256-color with contrast adjustments for readability. + +### 4.5 Alpha Blending for Foreground + +```rust +fn blend_fg_color(fg: SyntectColor, bg: SyntectColor) -> SyntectColor { + if fg.a == 0xff { return fg; } + let ratio = u32::from(fg.a); + let r = (u32::from(fg.r) * ratio + u32::from(bg.r) * (255 - ratio)) / 255; + // ... same for g, b + SyntectColor { r, g, b, a: 255 } +} +``` + +Handles syntect themes that specify semi-transparent foreground colors by alpha-blending with the theme background. This prevents washed-out colors on dark backgrounds. + +### 4.6 Word Wrapping + +```rust +fn wrap_line(&self, line: String, is_code: bool) -> String { + if let Some(width) = self.wrap_width { + if is_code && !self.options.wrap_code { + return line; // Don't wrap code unless explicitly configured + } + wrap(&line, width as usize) + } else { + line + } +} + +fn wrap(text: &str, width: usize) -> String { + let indent: usize = text.chars().take_while(|c| *c == ' ').count(); + let wrap_options = textwrap::Options::new(width) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit) + .initial_indent(&text[0..indent]); + textwrap::wrap(&text[indent..], wrap_options).join("\n") +} +``` + +**Uses `textwrap` crate** with `FirstFit` algorithm (fast, greedy). Preserves leading whitespace as initial indent. The `wrap_width` is computed at init time from `terminal::size()` (or config value). + +**CJK support:** `textwrap` handles CJK characters correctly via `unicode-width` (a dependency of textwrap) — CJK characters are width 2, which is respected in wrapping calculations. + +### 4.7 Row Calculation + +```rust +fn need_rows(text: &str, columns: u16) -> u16 { + let buffer_width = display_width(text).max(1) as u16; + buffer_width.div_ceil(columns) +} +``` + +Uses `textwrap::core::display_width` for Unicode-aware width calculation, then divides by columns to get row count. This correctly handles CJK (2-width chars), emojis (2-width), and combining characters. + +--- + +## 5. SSE Stream Pipeline + +### 5.1 SseHandler (Producer) + +```rust +pub struct SseHandler { + sender: UnboundedSender, + abort_signal: AbortSignal, + buffer: String, // Full text accumulated + tool_calls: Vec, +} +``` + +The handler receives SSE events from the HTTP client and: +1. Appends text to both `self.buffer` (full accumulation) and sends `SseEvent::Text` to the render channel +2. Sends `SseEvent::Done` when the stream completes +3. Accumulates tool calls separately + +**Dual purpose of `buffer`:** The `SseHandler.buffer` accumulates the **complete** response text (for session storage/tool-call context), while `SseEvent::Text` sends just the **incremental** chunk to the renderer. This separation is important — the render pipeline only needs the delta. + +### 5.2 Two Stream Protocols + +```rust +// SSE (Server-Sent Events) — OpenAI-compatible +pub async fn sse_stream(builder: RequestBuilder, mut handle: F) -> Result<()> + +// JSON stream — AWS Bedrock, etc. +pub async fn json_stream(mut stream: S, mut handle: F) -> Result<()> +``` + +Both funnel through the same `SseHandler` → `UnboundedReceiver` pipeline. + +### 5.3 JSON Stream Parser + +The `JsonStreamParser` is a hand-written incremental JSON parser that: +1. Buffers incoming characters +2. Tracks `{`/`}` and `[`/`]` balance +3. Respects string quoting (with escape handling) +4. Extracts complete JSON objects as they're balanced +5. Handles both NDJSON and JSON array formats +6. Handles partial UTF-8 at chunk boundaries + +This is notably robust — it doesn't depend on newlines for framing, and handles arbitrary chunk splits from the HTTP layer. + +--- + +## 6. Error Recovery & Abort Handling + +### 6.1 AbortSignal + +```rust +pub struct AbortSignalInner { + ctrlc: AtomicBool, + ctrld: AtomicBool, +} +``` + +Shared via `Arc`, using `AtomicBool` with `SeqCst` ordering. Both the REPL and the streaming loop check for abort: +- **REPL:** Sets `ctrlc`/`ctrld` on keypress via reedline's Signal handling +- **Streaming:** Checks via `poll_abort_signal` which uses `crossterm::event::poll` with 25ms timeout + +### 6.2 Error During Stream + +```rust +// In call_chat_completions_streaming: +let (send_ret, render_ret) = tokio::join!( + client.chat_completions_streaming(input, &mut handler), + render_stream(rx, client.global_config(), abort_signal.clone()), +); + +// After join: +if handler.abort().aborted() { + bail!("Aborted."); +} +render_ret?; // Propagate render errors + +match send_ret { + Ok(_) => { + if !text.is_empty() && !text.ends_with('\n') { + println!(); // Ensure trailing newline on clean completion + } + Ok((text, eval_tool_calls(client.global_config(), tool_calls)?)) + } + Err(err) => { + if !text.is_empty() { + println!(); // Ensure separation before error display + } + Err(err) + } +} +``` + +**Partial output preserved:** If the stream errors, the accumulated `buffer` from `SseHandler.take()` still contains whatever was received. The error is reported, but partial text is not thrown away. The `markdown_stream_inner` also ensures raw mode is disabled on error path via the `disable_raw_mode()` call after `markdown_stream_inner` returns. + +### 6.3 Connection Drops + +The SSE stream handler (`sse_stream`) maps `EventSourceError` variants: +- `StreamEnded` → Normal completion, no error +- `InvalidStatusCode` → Parse error response body and surface the error message +- `InvalidContentType` → Surface the unexpected content type +- Other errors → Generic error propagation + +There's no automatic retry. Connection drops result in partial output + error message. + +--- + +## 7. Spinner + +```rust +pub struct SpinnerInner { + index: usize, + message: String, +} +``` + +Runs as a separate tokio task, stepping at 50ms intervals. Uses braille pattern animation (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏). Hides cursor while active, restores on stop. Communicates via `mpsc::UnboundedSender`. + +**Key detail:** When the first `SseEvent::Text` arrives during streaming, the spinner is explicitly stopped before rendering begins. This prevents the spinner from interfering with the render output. + +--- + +## 8. Dependency Stack + +| Crate | Purpose | +|-------|---------| +| `crossterm` 0.28 | Raw mode, cursor control, terminal clearing, color output | +| `syntect` 5.0 | Syntax highlighting (regex-onig backend, parsing + plist-load) | +| `textwrap` 0.16 | Unicode-aware word wrapping (CJK-width aware) | +| `unicode-width` 0.2 | Character width calculation (CJK=2, emoji=2) | +| `ansi_colours` 1.2 | Truecolor → 256-color conversion | +| `reedline` 0.40 | REPL line editing (not used during streaming) | +| `nu-ansi-term` 0.50 | ANSI color/styling output | +| `terminal_colorsaurus` 0.4 | Detect terminal light/dark color scheme | +| `reqwest-eventsource` 0.6 | SSE event parsing | + +--- + +## 9. Patterns to Adopt for claw-code's TUI + +### 9.1 ✅ ADOPT: The Erase-and-Redraw Pattern + +aichat's approach of "move cursor to buffer start → clear below → re-render buffer" is the simplest correct solution for terminal streaming. It avoids the complexity of diffing rendered output while being fast enough for typical LLM output rates. + +**For claw-code:** If using ratatui, this maps to a `LineArea` widget that knows its row count and can be fully re-rendered. If not using ratatui, directly adopt the crossterm queue pattern. + +### 9.2 ✅ ADOPT: Event Batching with 50ms Tick + +The `gather_events` pattern is critical for smooth rendering. Don't render on every SSE chunk — batch for 50ms. This gives: +- Reduced render cycles (from hundreds per second to ~20) +- Better visual consistency (no partial-word renders) +- Lower CPU usage + +**For claw-code:** Use the same `tokio::select!` + `sleep(50ms)` pattern for event batching. + +### 9.3 ✅ ADOPT: Buffer Tracking (buffer + buffer_rows) + +The `buffer`/`buffer_rows` pattern for tracking the in-progress rendering state is clean and correct. The `buffer_rows` variable is essential for cursor repositioning after the clear. + +**For claw-code:** Track the current rendering extent (rows) so you know where to start the next clear-and-redraw cycle. + +### 9.4 ✅ ADOPT: State Machine for Code Block Detection + +The `LineType` state machine (Normal → CodeBegin → CodeInner → CodeEnd) is the right way to handle streaming code blocks. You can't do this with a single pass parser because the closing ````` hasn't arrived yet during streaming. + +**For claw-code:** Must implement a similar state machine. During streaming, you're rendering a partial markdown document — the code block status of the current line depends on whether an opening ````` was seen but no closing one. + +### 9.5 ✅ ADOPT: Split-at-Last-Newline Buffer Pattern + +Rendering complete lines (those ended by `\n`) immediately and keeping only the incomplete last line in the buffer reduces the amount of text that needs to be re-rendered each cycle. + +**For claw-code:** Same pattern — commit rendered output for complete lines, only re-render the in-progress line. + +### 9.6 ✅ ADOPT: Defensive Cursor Position Queries + +The retry pattern for `cursor::position()` and the kitty terminal workaround are essential for production reliability. + +**For claw-code:** Always retry cursor position queries (terminals can return errors during resize, etc.). + +### 9.7 ✅ ADOPT: Truecolor Fallback Chain + +`COLORTERM=truecolor` → RGB colors, else → 256-color with contrast adjustments. The contrast adjustment (replacing near-white values with 252) prevents readability issues on dark backgrounds. + +**For claw-code:** Implement the same color depth detection and contrast adjustment. + +### 9.8 ✅ ADOPT: textwrap for Word Wrapping + +`textwrap` with `FirstFit` + `unicode-width` is battle-tested for CJK and emoji. Don't roll your own wrapping. + +**For claw-code:** Use `textwrap` directly, with the same indent-preserving pattern. + +### 9.9 ⚠️ CONSIDER: Full TUI vs Line-Oriented Approach + +aichat's line-oriented approach works because it's a **chat interface** — input at the bottom, output scrolling up. For claw-code, which likely needs: +- Split pane (conversation + code preview) +- Interactive elements (file tree, diff view) +- Scroll position management (preserve scroll while streaming) + +...a full TUI framework (ratatui) would be more appropriate. However, the **rendering principles** from aichat should be preserved: +- Event batching (don't re-render on every chunk) +- Buffer tracking (know your rendered extent) +- Erase-and-redraw within the streaming area + +### 9.10 ⚠️ IMPROVE: Auto-Scroll vs User-Scroll + +aichat has **NO user-scroll detection**. Once text scrolls off the visible area, it's gone from the render buffer. For claw-code: +- Detect user scroll (e.g., via mouse wheel or Page Up) +- Pause auto-scroll when user has scrolled up +- Resume auto-scroll when user scrolls to bottom +- This is only feasible with a TUI framework that manages a scrollable view + +### 9.11 ⚠️ IMPROVE: Terminal Resize Handling + +aichat captures `columns` once at the start of streaming. If the terminal is resized mid-stream, the wrap width will be wrong and rendering will break. For claw-code: +- Listen for SIGWINCH / crossterm resize events +- Recalculate wrap width on resize +- Re-render the full buffer with new width + +### 9.12 ⚠️ IMPROVE: Incremental Highlighting + +aichat re-highlights the entire current line on each render. For very long lines, this could be slow. For claw-code: +- Consider caching the syntect highlighter state per line +- Only re-highlight from the change point forward (though this is hard with markdown) + +### 9.13 ❌ DON'T ADOPT: Raw Mode Takeover + +aichat's approach of entering raw mode and doing direct cursor manipulation works for its REPL model but would conflict with a TUI framework like ratatui, which needs to own the terminal state. If using ratatui: +- Use ratatui's terminal abstraction +- Apply the **logical patterns** (erase-and-redraw, buffering, batching) but through the framework's widget lifecycle + +--- + +## 10. Summary: Key Architectural Patterns + +| Pattern | aichat's Implementation | Applicability to claw-code | +|---------|------------------------|---------------------------| +| Streaming render | Erase-and-redraw in raw mode | Adopt logic, adapt to ratatui widget | +| Event batching | 50ms `gather_events` | ✅ Direct adopt | +| Buffer state | `buffer` + `buffer_rows` tracking | ✅ Direct adopt | +| Code block state machine | `LineType` enum | ✅ Direct adopt | +| Syntax highlighting | syntect `HighlightLines` per line | ✅ Direct adopt | +| Word wrapping | textwrap + unicode-width | ✅ Direct adopt | +| Color depth | truecolor → 256 fallback | ✅ Direct adopt | +| Scroll management | None (auto-only) | ❌ Must improve for claw-code | +| Resize handling | None (one-time size) | ❌ Must improve for claw-code | +| Error recovery | Partial output preserved | ✅ Adopt the pattern | +| Cursor recovery | Retry + kitty workaround | ✅ Direct adopt | +| REPL boundary | Modal (reedline ↔ raw mode) | Adapt to TUI event loop | + +--- + +## 11. Code References + +All source from: `https://github.com/sigoden/aichat` (main branch) + +| File | Purpose | +|------|---------| +| `src/render/mod.rs` | Render module entry, `render_stream()` dispatcher | +| `src/render/markdown.rs` | `MarkdownRender` — stateful markdown→ANSI renderer | +| `src/render/stream.rs` | `markdown_stream` — core streaming render loop ★ | +| `src/client/stream.rs` | SSE/JSON stream parsing, `SseHandler`, `SseEvent` | +| `src/client/common.rs` | `call_chat_completions_streaming()` — tokio::join of send+render | +| `src/repl/mod.rs` | REPL loop, `ask()` function | +| `src/config/mod.rs` | `render_options()`, `print_markdown()`, theme loading | +| `src/utils/abort_signal.rs` | `AbortSignal` — atomic Ctrl+C/D tracking | +| `src/utils/spinner.rs` | Spinner animation during generation wait | diff --git a/docs/tui/moa-codex-rs.md b/docs/tui/moa-codex-rs.md new file mode 100644 index 00000000..127642b3 --- /dev/null +++ b/docs/tui/moa-codex-rs.md @@ -0,0 +1,667 @@ +# codex-rs/tui Architecture Deep-Dive + +**Source:** `https://github.com/openai/codex` — `codex-rs/tui/` +**Date analyzed:** 2026-06-12 +**Purpose:** Extract patterns for claw-code CLI (Rust TUI AI coding assistant) + +--- + +## Table of Contents + +1. [Crate Versions & Dependencies](#1-crate-versions--dependencies) +2. [Event Loop Architecture](#2-event-loop-architecture) +3. [Streaming Assistant Response Rendering](#3-streaming-assistant-response-rendering) +4. [ChatWidget / Message List](#4-chatwidget--message-list) +5. [Input Composer](#5-input-composer) +6. [Tool Call Execution & Inline Display](#6-tool-call-execution--inline-display) +7. [Alternate Screen Enter/Exit](#7-alternate-screen-enterexit) +8. [Output Bleeding Prevention](#8-output-bleeding-prevention) +9. [Patterns to Adopt](#9-patterns-to-adopt) +10. [Anti-Patterns & Limitations to Avoid](#10-anti-patterns--limitations-to-avoid) + +--- + +## 1. Crate Versions & Dependencies + +| Crate | Version | Notes | +|-------|---------|-------| +| **ratatui** | Forked: `nornagon/ratatui` rev `9b2ad12` | Based on 0.29.0 with custom patches; uses `unstable-backend-writer`, `unstable-rendered-line-info`, `unstable-widget-ref`, `scrolling-regions` features | +| **crossterm** | Forked: `nornagon/crossterm` rev `87db8bf` | Based on 0.28.1 with custom patches; uses `bracketed-paste`, `event-stream` features | +| **tokio** | `1` (workspace) | `rt-multi-thread`, `io-std`, `macros`, `process`, `signal`, `test-util`, `time` | +| **tokio-stream** | `0.1.18` | `sync` feature | +| **textwrap** | `0.16.2` | Word wrapping | +| **unicode-segmentation** | `1.12.0` | Word boundary detection for textarea | +| **unicode-width** | `0.2` | CJK-aware width calculations | +| **pulldown-cmark** | `0.10` | Markdown rendering | +| **syntect** | `5` | Syntax highlighting | +| **two-face** | `0.5` | Syntect theme support | +| **image** | workspace | `jpeg`, `png`, `gif`, `webp` features (for ambient pet images) | +| **arboard** | workspace | Clipboard (not on Android) | + +**Critical observation:** codex-rs forks both ratatui and crossterm. The ratatui fork adds `unstable-backend-writer` for direct buffer access and `unstable-rendered-line-info` for paragraph line-count inspection. The crossterm fork likely adds `SynchronizedUpdate` support or fixes stdin-stealing bugs. **claw-code should plan to vendor or fork these crates too**, or wait for upstream stabilization. + +--- + +## 2. Event Loop Architecture + +### Top-Level Loop + +The main event loop lives in `App::run()` (`app.rs` ~line 1148): + +```rust +tokio::select! { + tui_events = tui_events.next() => { /* handle key/resize/paste/draw */ }, + app_events = self.app_event_rx.recv() => { /* handle protocol/app events */ }, +} +``` + +This is a standard `tokio::select!` over two async streams: **TUI events** (from crossterm + draw notifications) and **app events** (protocol messages, streaming deltas, approval requests, etc.). + +### TUI Event Stream (`tui/event_stream.rs`) + +The `TuiEventStream` implements `tokio_stream::Stream` with a **round-robin** polling strategy between: +1. **Draw events** — from a `broadcast::Receiver<()>` (one draw notification per scheduled frame) +2. **Crossterm events** — from a shared `EventBroker` wrapping `crossterm::EventStream` + +```rust +fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let draw_first = self.poll_draw_first; + self.poll_draw_first = !self.poll_draw_first; // Round-robin fairness + // ... poll both streams alternating priority +} +``` + +Key mappings (`map_crossterm_event`): +- `Event::Key` → `TuiEvent::Key` +- `Event::Resize` → `TuiEvent::Resize` +- `Event::Paste` → `TuiEvent::Paste` +- `Event::FocusGained` → `TuiEvent::Draw` (triggers palette re-query) +- `Event::FocusLost` → `None` (dropped) +- Mouse events → `None` (dropped entirely!) +- SIGTSTP (Ctrl+Z) → suspend handled in-stream, returns `TuiEvent::Draw` + +### EventBroker — Pause/Resume Pattern + +The `EventBroker` wraps crossterm's `EventStream` in a `Mutex>`: + +```rust +enum EventBrokerState { + Paused, // Stream dropped — stdin fully released + Start, // Will create new EventStream on next poll + Running(S), // Active event source +} +``` + +**Why drop instead of just stop polling?** The doc comment explains clearly: crossterm's reader thread continues consuming stdin even when the stream is in a pending state, stealing input from child processes (like `vim`). Dropping the stream is the only safe way to fully relinquish stdin. + +### Frame Rate Limiting (`tui/frame_rate_limiter.rs`) + +```rust +const MIN_FRAME_INTERVAL: Duration = Duration::from_nanos(8_333_334); // ~120 FPS +``` + +The `FrameRateLimiter` enforces this ceiling — any draw request within `MIN_FRAME_INTERVAL` of the last draw is suppressed. This prevents wasted GPU/CPU on frames faster than the terminal can display. + +### Frame Scheduler Actor (`tui/frame_requester.rs`) + +An actor pattern (cf. [Alice Ryhl's actors-with-tokio](https://ryhl.io/blog/actors-with-tokio/)): + +``` +FrameRequester (mpsc::UnboundedSender) + → FrameScheduler (actor task) + → coalesces requests + → broadcast::Sender<()> (draw notification) +``` + +The `FrameScheduler` receives frame-request timestamps via an unbounded channel and **coalesces** them: if multiple components request a redraw within the same frame interval, only one draw notification is broadcast. This is effectively a software VSync. + +### AppEvent Channel + +`AppEventSender` wraps `tokio::sync::mpsc::UnboundedSender`. The app uses an **unbounded** channel for app events — no backpressure. This is acceptable because the consumer (the main loop) processes events as fast as it can render frames, and the producers are protocol handlers that shouldn't block. + +--- + +## 3. Streaming Assistant Response Rendering + +This is the most sophisticated part of codex-rs/tui. The streaming pipeline has four layers: + +### Layer 1: StreamState (queue primitives) + +`StreamState` (`streaming/mod.rs`) owns: +- `MarkdownStreamCollector` — newline-gated source accumulator +- `VecDeque` — FIFO queue of committed render lines with `enqueued_at: Instant` timestamps + +Key invariant: **all drains pop from the front**, and enqueue records arrival timestamps so policy code can reason about queue age. + +### Layer 2: StreamCore / StreamController (two-region model) + +`StreamController` (`streaming/controller.rs`) partitions rendered markdown into: + +1. **Stable region** — committed to scrollback via the animation queue. These lines are immutable. +2. **Tail region** — mutable, displayed in the `active_cell` slot. These lines can change on every delta. + +The boundary is tracked by `enqueued_stable_len` vs `emitted_stable_len` vs `rendered_lines.len()`: + +``` +Invariant: emitted_stable_len <= enqueued_stable_len <= rendered_lines.len() +``` + +The tail starts at `enqueued_stable_len` (NOT `emitted_stable_len`), which prevents duplicate content — lines queued but not yet emitted won't reappear in the active cell. + +### Layer 3: Chunking Policy (`streaming/chunking.rs`) + +`AdaptiveChunkingPolicy` decides how many lines to drain per commit tick: +- **Steady mode** — drain one line per tick (smooth animation) +- **CatchUp mode** — batch drain when queue pressure exceeds threshold (oldest queued age > target) + +This creates a typewriter-like animation that smoothly catches up if the stream outruns the display. + +### Layer 4: Commit Tick Orchestrator (`streaming/commit_tick.rs`) + +`run_commit_tick()` bridges policy → controller drains: +1. Collect `QueueSnapshot` (total queued lines + oldest age) +2. Ask `AdaptiveChunkingPolicy` for a `ChunkingDecision` +3. Apply the `DrainPlan` (Single or Batch) to both `StreamController` and `PlanStreamController` +4. Return `CommitTickOutput` with emitted `HistoryCell`s + +### Table Holdback + +A particularly clever mechanism: when a markdown pipe table is detected in the stream (header + delimiter pair), the **entire table region is withheld as mutable tail** until the stream finalizes. This prevents visual glitching where adding a row reshapes all prior columns. + +`TableHoldbackScanner` incrementally scans the raw source and transitions through states `None → PendingHeader → Confirmed`. The `active_tail_budget_lines()` method returns the number of rendered tail lines to withhold based on the detected table boundary. + +### Finalization & Consolidation + +When a stream finishes: +1. `StreamController::finalize()` drains remaining lines, returns raw source +2. `App::handle_consolidate_agent_message()` replaces the trailing run of streaming `AgentMessageCell`s with a single source-backed `AgentMarkdownCell` +3. This canonical cell owns the raw markdown and can re-render at any width for resize reflow + +--- + +## 4. ChatWidget / Message List + +### Architecture + +`ChatWidget` (`chatwidget.rs`, ~2030 lines) is the main chat surface. It: +- Consumes protocol events +- Builds and updates `HistoryCell`s +- Drives rendering via the `Renderable` trait + +### Committed vs Active Cells + +The UI has two kinds of cells: +- **Committed cells** (`transcript_cells: Vec>`) — finalized, stored in scrollback +- **Active cell** (`transcript.active_cell: Option>`) — mutable in-place, represents streaming output or a coalesced exec/tool group + +### Rendering Pipeline + +`ChatWidget` implements `Renderable` (in `chatwidget/rendering.rs`): + +```rust +impl Renderable for ChatWidget { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } +} +``` + +`as_renderable()` builds a `FlexRenderable`: +1. Active cell (flex=1, grows to fill) +2. Active hook cell (flex=0) +3. Pending token activity output (flex=1) +4. Bottom pane / composer (flex=0, fixed height) + +`FlexRenderable` is inspired by Flutter's Flex widget — children with `flex > 0` share remaining space proportionally. + +### Scrolling Strategy + +**No virtualization!** The `TranscriptAreaRenderable` renders a `Paragraph::scroll((y, 0))` where `y` is the overflow count: + +```rust +fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.child.display_lines(area.width); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + let y = paragraph.line_count(area.width).saturating_sub(area.height as usize); + Clear.render(area, buf); + paragraph.scroll((y as u16, 0)).render(area, buf); +} +``` + +This means **the entire transcript is re-rendered every frame**. The scroll position is always "pinned to bottom" — there's no arbitrary scroll offset. This is a significant limitation for long conversations. + +### Transcript Overlay (Ctrl+T) + +A separate overlay view shows committed cells plus a cached live-tail from the active cell. Cache invalidation uses `active_cell_transcript_key()` — a key that changes when the active cell mutates or its output is time-dependent. + +### HistoryCell Trait + +```rust +trait HistoryCell: Send + Sync { + fn display_lines(&self, width: u16) -> Vec>; + fn raw_lines(&self) -> Vec>; + fn display_hyperlink_lines(&self, width: u16) -> Vec; + fn transcript_hyperlink_lines(&self, width: u16) -> Vec; + fn desired_height(&self, width: u16) -> u16; + // ... +} +``` + +Every cell computes its own height as a function of width. This enables full resize reflow — each cell can re-wrap when the terminal width changes. + +### CompositeHistoryCell + +Cells can be composed: `CompositeHistoryCell` concatenates multiple `HistoryCell`s with blank-line separators. This allows building complex entries (header + body + footer) from simple parts. + +--- + +## 5. Input Composer + +### Custom TextArea (NOT tui-textarea!) + +codex-rs built their own `TextArea` in `bottom_pane/textarea.rs` with: + +- **Wrap cache** — `WrapCache` stores wrapped lines keyed by width, avoiding re-wrap on every keystroke +- **Kill buffer** — `KillBufferKind::{Characterwise, Linewise}` for clipboard-like kill/yank +- **Vim mode** — Full `VimMode` with `VimNormalKeymap`, `VimOperatorKeymap`, `VimTextObjectKeymap` +- **Element placeholders** — Special rendering for `@mentions` and other inline elements +- **Keymap indirection** — `RuntimeKeymap` → `EditorKeymap` allows different keybinding schemes +- **Word boundaries** — Uses `unicode_segmentation` for proper Unicode word movement + +### ChatComposer (`bottom_pane/chat_composer.rs`) + +The composer wraps `TextArea` and adds: +- **Popup routing** — Slash commands, file search, `@mentions` each have their own popup overlay +- **History navigation** — Persistent (across sessions) + local (within session) input history +- **Ctrl+R reverse search** — Interactive search through history +- **Multi-line input** — Enter submits (with Shift+Enter for newline) + +### Bottom Pane Layered Input Routing (`bottom_pane/mod.rs`) + +Input events route through a layered system: +1. **Active view** (popups like slash-command picker, file search) → if consumed, stop +2. **Composer** → text editing, submission +3. **ChatWidget** → interrupt (Ctrl+C), quit (Ctrl+D) + +Key handling: +- `Ctrl+C` while composing → clear input; during agent turn → interrupt +- `Ctrl+D` on empty input → quit +- Time-based redraw scheduling for transient views + +--- + +## 6. Tool Call Execution & Inline Display + +### Tool Call Representation + +Tool calls are represented as specialized `HistoryCell` implementations: + +- **`UnifiedExecInteractionCell`** (`history_cell/exec.rs`) — background terminal commands with command display and stdin +- **`UnifiedExecProcessesCell`** — lists of running background terminals with recent output chunks +- **`McpToolCallCell`** (`history_cell/mcp.rs`) — MCP (Model Context Protocol) tool invocations +- **`McpInvocation`** — individual MCP tool call cells +- **Approval cells** (`history_cell/approvals.rs`) — `ApprovalDecisionCell` with symbol + summary for approved/denied/timeout +- **Patch cells** (`history_cell/patches.rs`) — file change diff display + +### Coalescing + +Multiple exec tool calls within a single agent turn are **coalesced** into a single active cell that can mutate in place. This avoids visual noise from many small cells appearing sequentially. + +### Approval Flow + +1. Agent requests approval (exec command, file write, network access) +2. `AppEvent::ApprovalRequest` arrives +3. Bottom pane shows approval UI with key hints +4. User approves/denies → `ReviewDecision` cell added to transcript +5. Display shows: `✓ User approved codex to run: npm test` or `✗ User denied` + +### Diff Rendering + +`diff_render.rs` (2481 lines!) provides inline diff display for file changes with syntax highlighting via `syntect`/`two-face`. This is the largest single rendering module — a full diff viewer built from scratch. + +--- + +## 7. Alternate Screen Enter/Exit + +### Entry (`Tui::enter_alt_screen`) + +```rust +fn enter_alt_screen(&mut self) -> Result<()> { + if !self.alt_screen_enabled { return Ok(()); } + execute!(self.terminal.backend_mut(), EnterAlternateScreen); + execute!(self.terminal.backend_mut(), EnableAlternateScroll); + // Save inline viewport, expand to full terminal + self.alt_saved_viewport = Some(self.terminal.viewport_area); + self.terminal.set_viewport_area(Rect::new(0, 0, size.width, size.height)); + self.terminal.clear(); + self.alt_screen_active.store(true, Ordering::Relaxed); +} +``` + +### Exit (`Tui::leave_alt_screen`) + +```rust +fn leave_alt_screen(&mut self) -> Result<()> { + if !self.alt_screen_enabled { return Ok(()); } + execute!(self.terminal.backend_mut(), DisableAlternateScroll); + execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + // Restore previously saved inline viewport + if let Some(saved) = self.alt_saved_viewport.take() { + self.terminal.set_viewport_area(saved); + } + self.alt_screen_active.store(false, Ordering::Relaxed); +} +``` + +### External Program Handoff (`Tui::with_restored`) + +This is the critical code path for spawning editors, pagers, etc.: + +```rust +async fn with_restored(&mut self, mode: RestoreMode, f: F) -> R { + self.pause_events(); // Drop crossterm EventStream → release stdin + + let was_alt_screen = self.is_alt_screen_active(); + if was_alt_screen { + self.leave_alt_screen(); // Return to main screen + } + + mode.restore()?; // Restore terminal modes (disable raw mode, etc.) + terminal_stderr::pause()?; // Restore stderr (undo dup2 suppression) + + let output = f().await; // Run external program + + terminal_stderr::resume()?; // Re-suppress stderr + set_modes()?; // Re-enable raw mode, etc. + flush_terminal_input_buffer(); // Clear stale keypresses + + if was_alt_screen { + self.enter_alt_screen(); // Return to alt screen + } + + self.resume_events(); // Recreate crossterm EventStream + output +} +``` + +**Key insight:** The pause/resume of the EventBroker is essential. If you merely stop polling crossterm's stream, its internal reader thread continues consuming stdin, stealing input from the child process. + +### SIGTSTP (Ctrl+Z) Handling (`tui/job_control.rs`) + +On Unix: +1. `SuspendContext` stores cursor position via `Arc` +2. On SIGTSTP, leaves alt screen, restores terminal modes, sends SIGSTOP to self +3. On SIGCONT (resume), checks `ResumeAction`: + - `Inline` — restore to inline mode (no alt screen) + - `AltScreen` — re-enter alternate screen +4. Flushes terminal input buffer after resume + +--- + +## 8. Output Bleeding Prevention + +This is a **dual-layer defense** — the #1 problem for TUI apps: + +### Layer 1: Compile-Time Prevention (`lib.rs`) + +```rust +#![deny(clippy::print_stdout, clippy::print_stderr)] +``` + +This makes `print!()` / `eprint!()` a **compile error**. Any accidental stdout/stderr write from the TUI binary itself is caught at build time. + +### Layer 2: macOS stderr suppression (`tui/terminal_stderr.rs`) + +On macOS, system frameworks (Security framework, Keychain, WebKit) write to stderr without going through the app's logging framework. This causes output to appear in the middle of the TUI. + +The solution: **redirect fd 2 to `/dev/null`** via `dup2()` while the TUI is active: + +```rust +static STDERR_STATE: Mutex = ...; + +pub fn resume() -> Result<()> { // TUI is starting — suppress stderr + let saved = dup(2)?; // Save original stderr + let devnull = open("/dev/null", ...)?; + dup2(devnull, 2)?; // Redirect fd 2 → /dev/null + // ... store saved fd for later restoration +} + +pub fn pause() -> Result<()> { // Child process needs stderr — restore it + dup2(saved_fd, 2)?; // Restore original stderr +} +``` + +`TerminalStderrGuard` provides RAII semantics — the guard restores stderr on drop. + +### Layer 3: Synchronized Update (crossterm) + +The forked crossterm adds `SynchronizedUpdate` support — this wraps each frame's terminal writes in a synchronized update block, telling the terminal emulator to apply all changes atomically. This prevents partial-frame tearing. + +### Layer 4: Event Stream Drop/Recreate + +As described above, the `EventBroker` drops crossterm's `EventStream` before handing the terminal to child processes. This prevents crossterm's internal reader thread from writing escape sequences to stdout while the child process is running. + +--- + +## 9. Patterns to Adopt + +### ✅ 1. Frame Scheduler Actor with Coalescing + +The `FrameRequester` → `FrameScheduler` → `broadcast::Sender<()>` pattern is elegant: +- Multiple components can request redraws without coordination +- The scheduler coalesces requests within the same frame interval +- The 120 FPS ceiling prevents wasted work + +**Adopt this** for claw-code. It's cleaner than having every component call `terminal.draw()` directly. + +### ✅ 2. EventBroker Pause/Resume for External Programs + +The drop/recreate pattern for crossterm's EventStream is essential correctness: +- **Must adopt** — without this, spawned editors/pagers will have missing keystrokes +- The `watch::Sender<()>` for resumption notification is clean + +### ✅ 3. Compile-Time Output Bleeding Prevention + +```rust +#![deny(clippy::print_stdout, clippy::print_stderr)] +``` + +Zero-cost, high-value. **Must adopt.** + +### ✅ 4. stderr dup2 Suppression (macOS) + +The RAII guard pattern with `dup2()` is the only reliable way to handle system framework stderr on macOS. **Adopt for macOS builds.** + +### ✅ 5. Two-Region Streaming Model + +The stable/tail partition is the right abstraction for streaming AI responses: +- Committed lines are immutable → cache-friendly +- Tail is mutable → handles streaming updates without scrollback API +- Table holdback prevents visual glitching + +**Adopt this architecture** for claw-code's streaming pipeline. + +### ✅ 6. HistoryCell Trait with Width-Dependent Height + +Making every cell compute its own desired height as a function of width enables: +- Clean resize reflow without stored scroll offsets +- Each cell re-wraps independently +- `desired_height()` is cheap (often cached) + +**Adopt the trait pattern.** + +### ✅ 7. Custom FlexRenderable / ColumnRenderable Layout + +Building a Flutter-style flex layout system instead of using ratatui's built-in layout is more flexible for a chat-style TUI: +- Flex children grow/shrink proportionally +- Fixed children get their natural height +- No constraint solver overhead + +**Adopt the FlexRenderable pattern.** + +### ✅ 8. SynchronizedUpdate for Atomic Frame Writes + +**Must adopt** — prevents partial-frame visual tearing that users notice immediately. + +### ✅ 9. Flush Terminal Input Buffer After Resume + +After returning from an external program (editor, pager), stale keypresses in the terminal's input buffer can cause spurious events. codex-rs explicitly flushes this buffer. + +### ✅ 10. Transcript Consolidation + +Replacing streaming cells with a single source-backed canonical cell on finalization is critical: +- Reduces memory (transcript cells are deduplicated) +- Enables resize reflow from raw source +- Prevents stale partial renders + +**Must adopt.** + +--- + +## 10. Anti-Patterns & Limitations to Avoid + +### ❌ 1. No Virtualization — Full Re-render Every Frame + +codex-rs re-renders the **entire transcript** on every frame using `Paragraph::scroll()`. This works for short conversations but will degrade badly with 1000+ lines: +- `display_lines(width)` is called for every cell every frame +- The full `Paragraph` is constructed and laid out every frame +- No viewport culling — off-screen cells are still rendered into the buffer + +**For claw-code:** Implement **viewport-clipped rendering** — only render cells whose y-range intersects the visible area. Track cumulative heights to compute skip counts. + +### ❌ 2. No Bidirectional Scrolling + +The scroll position is always "pinned to bottom" via overflow calculation. There's no scroll offset, no scroll-up history, no PgUp/PgDn. Users cannot review earlier output while streaming. + +**For claw-code:** Implement proper scroll state with: +- Stored scroll offset (in rendered lines, not pixels) +- Auto-follow mode (scroll to bottom on new content) with user override +- PgUp/PgDn, arrow keys, and mouse wheel scrolling + +### ❌ 3. Mouse Events Dropped Entirely + +`map_crossterm_event` returns `None` for all mouse events. No mouse scrolling, no mouse selection, no click-to-focus. + +**For claw-code:** At minimum, support mouse wheel for scrolling. Click-to-focus for approval buttons would also be valuable. + +### ❌ 4. Forked ratatui + crossterm + +Both ratatui and crossterm use custom forks. This means: +- Can't update to upstream bug fixes without manual merging +- Binary size may include unneeded patches +- Community support doesn't apply to forked versions + +**For claw-code:** Try to use upstream crates first. If `unstable-backend-writer` or `SynchronizedUpdate` are needed, use feature flags or minimal patches that can be upstreamed. Track ratatui 0.30+ which may stabilize these features. + +### ❌ 5. Unbounded AppEvent Channel + +Using `mpsc::UnboundedSender` means no backpressure. If the protocol layer produces events faster than the UI can consume them, memory grows without bound. + +**For claw-code:** Use a bounded channel with a reasonable capacity (e.g., 1024). When full, drop the oldest low-priority events or apply coalescing (the FrameRequester pattern shows they already know how). + +### ❌ 6. Monolithic ChatWidget + +`ChatWidget` at ~2030 lines handles too many concerns: +- Protocol event handling +- Cell management (committed + active) +- Streaming animation +- Transcript overlay +- Slash command dispatch +- Approval routing +- MCP status +- Pet images / ambient features +- Terminal title management + +**For claw-code:** Split into: +- `Transcript` — cell storage and retrieval +- `StreamManager` — streaming controllers and commit ticks +- `ChatView` — renderable composition +- `EventHandler` — protocol event → state mutation + +### ❌ 7. No Diff Rendering Strategy for Large Files + +`diff_render.rs` is 2481 lines and renders full file diffs. For large files (1000+ lines), this could be extremely slow with no virtualization. + +**For claw-code:** Implement windowed diff rendering — only render the visible portion of the diff, with context lines above/below changes. + +### ❌ 8. Global Static for stderr Suppression + +`STDERR_STATE: Mutex` is a global static. This makes testing harder and prevents multiple TUI instances. + +**For claw-code:** If adopting stderr suppression, make it owned by the `Tui` struct. Pass it through the initialization chain rather than using a global. + +### ❌ 9. No Incremental Rendering for Markdown + +Every streaming delta triggers a full re-render of the accumulated markdown source (`recompute_streaming_render()`). For long responses, this means re-parsing and re-rendering the entire markdown on every chunk. + +**For claw-code:** Consider incremental markdown rendering — parse new content only, cache rendered fragments, and splice them into the line array. + +--- + +## Summary: Architecture at a Glance + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ tokio::select! │ +│ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ TUI Events │ │ App Events │ │ +│ │ (TuiEventStream)│ │ (mpsc UnboundedReceiver)│ │ +│ │ │ │ │ │ +│ │ ┌────────────┐ │ │ Protocol events │ │ +│ │ │EventBroker │ │ │ Streaming deltas │ │ +│ │ │(crossterm │ │ │ Approval requests │ │ +│ │ │ EventStream)│ │ │ Consolidation │ │ +│ │ └────────────┘ │ └──────────────────────────┘ │ +│ │ ┌────────────┐ │ │ │ +│ │ │Draw events │ │ │ │ +│ │ │(broadcast │ │ ▼ │ +│ │ │ channel) │ │ ┌───────────────────┐ │ +│ │ └────────────┘ │ │ App │ │ +│ └──────────────────┘ │ (event_dispatch) │ │ +│ │ └─────────┬─────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ChatWidget │ │ +│ │ ┌─────────────────┐ ┌─────────────────────────────┐ │ │ +│ │ │ transcript_cells│ │ active_cell (mutable) │ │ │ +│ │ │ (committed) │ │ ┌────────────────────┐ │ │ │ +│ │ │ Vec> │ │ │ ┌──────────────┐ │ │ │ │ +│ │ │ │ │ │ │ StreamCore │ │ │ │ │ +│ │ │ AgentMessage │ │ │ │ stable | tail│ │ │ │ │ +│ │ │ AgentMarkdown │ │ │ └──────────────┘ │ │ │ │ +│ │ │ ExecInteraction │ │ │ TableHoldback │ │ │ │ +│ │ │ Approval │ │ └────────────────────┘ │ │ │ +│ │ │ McpToolCall │ │ CommitTick (chunking) │ │ │ +│ │ │ Diff │ └─────────────────────────────┘ │ │ +│ │ └─────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────────┐│ │ +│ │ │ BottomPane (composer) ││ │ +│ │ │ TextArea (custom) → ChatComposer ││ │ +│ │ └─────────────────────────────────────────────────────┘│ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Render: ChatWidget.as_renderable() → FlexRenderable → terminal │ +│ Frame: FrameRequester → FrameScheduler (coalesce) → broadcast │ +│ Rate: FrameRateLimiter (120 FPS ceiling) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Key Takeaways for claw-code + +1. **Must have:** EventBroker pause/resume, stderr dup2 suppression, `#![deny(print_stdout/print_stderr)]`, SynchronizedUpdate +2. **Must have:** Two-region streaming (stable + mutable tail) with table holdback +3. **Must have:** Transcript consolidation (streaming → canonical source-backed cell) +4. **Should have:** Frame scheduler actor with coalescing +5. **Should have:** Custom FlexRenderable layout system +6. **Should have:** HistoryCell trait with width-dependent height for resize reflow +7. **Improve on:** Add viewport clipping/virtualization, bidirectional scrolling, mouse wheel support +8. **Improve on:** Split ChatWidget into focused modules +9. **Improve on:** Use bounded app event channel +10. **Improve on:** Minimize ratatui/crossterm forking — try upstream features first diff --git a/docs/tui/moa-ecosystem.md b/docs/tui/moa-ecosystem.md new file mode 100644 index 00000000..8662814a --- /dev/null +++ b/docs/tui/moa-ecosystem.md @@ -0,0 +1,1051 @@ +# TUI MOA Ecosystem Report: Rust TUI Patterns for claw-code + +**Date:** 2026-06-12 +**Purpose:** Survey the broader Rust TUI ecosystem for best-in-class solutions to claw-code's TUI architecture problems. + +--- + +## Table of Contents + +1. [Streaming Text Rendering in Ratatui](#1-streaming-text-rendering-in-ratatui) +2. [Output Isolation / stdout Capture in Rust](#2-output-isolation--stdout-capture-in-rust) +3. [Async Runtime + Ratatui Patterns](#3-async-runtime--ratatui-patterns) +4. [Scroll State Management](#4-scroll-state-management) +5. [Permission Prompts in TUI](#5-permission-prompts-in-tui) +6. [Testing TUIs in Rust](#6-testing-tuis-in-rust) + +--- + +## 1. Streaming Text Rendering in Ratatui + +### 1.1 tui-markdown: Live Markdown Rendering + +**Crate:** `tui-markdown` (by Josh McKinney / joshka, ratatui core maintainer) +**Repo:** https://github.com/joshka/tui-markdown +**Status:** Experimental proof-of-concept, actively maintained + +**How it works:** +- Uses `pulldown-cmark` to parse markdown into events +- Converts those events into a `ratatui::text::Text` value +- Renders the `Text` widget directly via `frame.render_widget(text, area)` + +**Key API:** +```rust +use tui_markdown::from_str; + +let markdown = "# Heading\n\n**bold** text with `code`"; +let text = from_str(markdown); +text.render(area, &mut buf); +``` + +**For streaming:** There is no built-in streaming/incremental mode. The entire markdown string is parsed each time. The pattern for live-updating is: + +```rust +// In your app state, accumulate streaming content +struct AppState { + stream_buffer: String, +} + +// On each token arrival, append and re-render +fn on_token(&mut self, token: &str) { + self.stream_buffer.push_str(token); + // Re-convert the entire buffer on each render + let text = tui_markdown::from_str(&self.stream_buffer); + // render... +} +``` + +**Performance consideration:** For long conversations, full re-parse each frame is O(n). Mitigations: +- Only re-parse when new tokens arrive (set a dirty flag) +- Cache the `Text` and only re-convert on change +- For very long histories, consider truncating the visible portion + +**Supported features:** Headings, bold, italic, strikethrough, ordered/unordered lists, code blocks, block quotes, task lists, metadata blocks, footnotes, links. **NOT supported:** Tables, images, HTML, math. + +**Code highlighting:** Optional `highlight-code` feature uses `syntect` for syntax-colored code blocks, which then get converted via `ansi-to-tui` back to ratatui `Text`. + +### 1.2 ansi-to-tui: Preserving ANSI Colors + +**Crate:** `ansi-to-tui` +**Docs:** https://docs.rs/ansi-to-tui +**Purpose:** Convert ANSI escape sequences to ratatui `Text` with proper `Color` and `Style` + +This is critical for displaying child process output that contains ANSI color codes (e.g., `git diff --color`, test runners, etc.). + +```rust +use ansi_to_tui::IntoText; + +let bytes = b"\x1b[38;2;225;192;203mcolored text\x1b[0m".to_vec(); +let text = bytes.into_text()?; +frame.render_widget(text, area); +``` + +**Supports:** 3/4-bit named colors, 8-bit indexed, 24-bit truecolor RGB, bold/italic/underline/strikethrough modifiers. Has optional SIMD acceleration via `simdutf8` feature. + +### 1.3 Virtual Scrolling / Viewport Management with Variable-Height Rows + +**No dedicated crate for virtual scrolling of variable-height rows exists in the ratatui ecosystem.** This is the hardest unsolved problem for chat-style UIs. + +**Available approaches:** + +#### tui-scrollview (by joshka, official ratatui org) + +**Crate:** `tui-scrollview` +**Docs:** https://docs.rs/tui-scrollview +**Repo:** Part of https://github.com/ratatui/tui-widgets + +Provides a `ScrollView` widget that renders content to an off-screen buffer and supports scrolling. However, it requires a **fixed content size** upfront: + +```rust +use tui_scrollview::{ScrollView, ScrollViewState}; + +let content_size = Size::new(100, 30); // Must know total size +let mut scroll_view = ScrollView::new(content_size); +scroll_view.render_widget(Paragraph::new(text), Rect::new(0, 0, 100, 30)); +scroll_view.render(buf.area, buf, state); +``` + +**Limitation for chat UIs:** You must know the total content height in advance. For word-wrapped variable-height rows, you'd need to pre-compute heights, which requires knowing the terminal width at layout time. + +#### DIY Virtual Scroll Pattern (recommended) + +For claw-code's chat history with variable-height rows, implement your own: + +```rust +struct VirtualScroll { + /// Each item has a computed height (in lines) + item_heights: Vec, + /// Cumulative height sums for binary search + height_offsets: Vec, + /// Current scroll offset (in total lines from top) + scroll_offset: usize, + /// Visible area height + viewport_height: u16, +} + +impl VirtualScroll { + /// Find which item is at the top of the viewport + fn top_item(&self) -> usize { + self.height_offsets.partition_point(|&h| h <= self.scroll_offset) + } + + /// Get all items visible in current viewport + fn visible_items(&self) -> impl Iterator { + let start = self.top_item(); + let mut y = 0u16; + self.item_heights[start..].iter().enumerate().take_while(|&(_, h)| { + y += h; y <= self.viewport_height + }).map(move |(i, &h)| (start + i, h)) + } + + /// Recalculate heights after resize (must be called on width change) + fn recalc_heights(&mut self, items: &[ChatItem], width: u16) { + self.item_heights = items.iter() + .map(|item| item.wrap_height(width)) + .collect(); + self.height_offsets = std::iter::once(0) + .chain(self.item_heights.iter().scan(0, |acc, &h| { + *acc += h as usize; Some(*acc) + })) + .collect(); + } +} +``` + +**Key insight:** Height computation must happen *after* you know the render width. On resize, recompute all heights. This is unavoidable for variable-height content. + +### 1.4 Lazy/Incremental Rendering of Large Chat Histories + +No existing crate. The pattern used by aichat and similar tools: + +```rust +struct ChatBuffer { + /// Ring buffer of recent messages (fixed-size) + messages: VecDeque, + /// Max messages to keep in memory + max_messages: usize, + /// Scroll state + scroll: VirtualScroll, +} + +impl ChatBuffer { + fn push(&mut self, msg: ChatMessage) { + if self.messages.len() >= self.max_messages { + self.messages.pop_front(); + // Adjust scroll offset + } + self.messages.push_back(msg); + // Mark scroll as needing recalculation + } +} +``` + +**aichat's approach:** Uses a `Vec` with custom scroll tracking. Messages are rendered as `Paragraph` widgets with `.wrap(Wrap { trim: false })`. Auto-scroll is maintained by tracking whether the user was at the bottom before the last update. + +--- + +## 2. Output Isolation / stdout Capture in Rust + +This is the critical problem: when tool execution (child processes, library code) writes to stdout, it must not corrupt the TUI rendering. + +### 2.1 The Problem + +When ratatui draws to the terminal via crossterm's alternate screen, any `println!` or child process stdout write goes to the *same* file descriptor (fd 1). Since crossterm writes raw escape sequences to fd 1, interleaved output corrupts the display. + +### 2.2 Approaches Compared + +#### Approach A: `gag` crate — RAII stdout/stderr suppression + +**Crate:** `gag` (by ipetkov) +**Docs:** https://docs.rs/gag +**How it works:** Uses `libc::dup` / `libc::dup2` to swap out fd 1/2 with `/dev/null`, restoring on drop. + +```rust +use gag::Gag; + +// Suppress stdout for the duration of this scope +let _gag = Gag::stdout().unwrap(); +// Everything printed here goes to /dev/null +println!("this is suppressed"); +// _gag dropped here, stdout restored +``` + +**Pros:** Simple RAII pattern, works for blocking the current thread. +**Cons:** Not thread-safe! If another thread writes to stdout while gagged, it's also suppressed. Global fd-level operation — affects entire process. + +**Verdict:** ❌ Not suitable for TUI apps with async runtimes. The global nature conflicts with other threads. + +#### Approach B: `capture-stdio` crate — pipe-then-dup + +**Crate:** `capture-stdio` +**Docs:** https://docs.rs/capture-stdio +**How it works:** Two methods: +1. `std::io::set_output_capture` — same mechanism as `cargo test` uses for capturing test output. Nightly-only internal API. +2. Pipe-then-dup — creates a pipe, replaces the fd, reads from the pipe. + +```rust +use capture_stdio::pipe::PipedStdout; + +let mut capture = PipedStdout::capture().unwrap(); +// stdout now goes to the pipe +println!("captured!"); +let output = capture.read_to_string().unwrap(); +capture.restore().unwrap(); // restore original stdout +``` + +**Pros:** Can actually capture and read the output. +**Cons:** Same global fd problem. `PipedStdout::read_to_string()` blocks until the pipe is closed. Not async-friendly. + +**Verdict:** ⚠️ Better than `gag` for testing, but still globally affects process fds. Not great for production TUI. + +#### Approach C: `os_pipe` — Low-level pipe primitives (now std::io::pipe) + +**Crate:** `os_pipe` +**Docs:** https://docs.rs/os_pipe +**Note:** Rust 1.87 added `std::io::pipe()`, making this crate unnecessary for new code. + +```rust +let (reader, writer) = os_pipe::pipe()?; +let writer_clone = writer.try_clone()?; + +let mut cmd = Command::new("some-tool"); +cmd.stdout(writer).stderr(writer_clone); + +let mut handle = cmd.spawn()?; +drop(cmd); // Close parent's copy of writer to avoid deadlock + +let mut output = String::new(); +reader.read_to_string(&mut output)?; +``` + +**Pros:** Clean, no fd swapping. Each child process gets its own pipe. +**Cons:** Requires you to spawn the process yourself (not just call a function that happens to print). + +**Verdict:** ✅ **Recommended approach for child processes.** Use `Command::stdout(Stdio::piped())` or `os_pipe` for child process output capture. This is the safe, non-global approach. + +#### Approach D: Per-thread stdout via thread-local override + +**Not a real crate, but the pattern:** + +```rust +use std::cell::RefCell; +use std::io::Write; + +thread_local! { + static STDOUT_OVERRIDE: RefCell>> = RefCell::new(None); +} + +// Override println-like behavior for your helpers +fn safe_println(s: &str) { + STDOUT_OVERRIDE.with(|ov| { + if let Some(ref mut w) = *ov.borrow_mut() { + let _ = w.write_all(s.as_bytes()); + let _ = w.write_all(b"\n"); + } else { + println!("{}", s); + } + }); +} +``` + +**Verdict:** ⚠️ Requires modifying all output paths. Not practical for third-party code. + +#### Approach E: Redirect stdout for tool threads using libc::dup2 (what claw-code likely has) + +```rust +fn with_captured_stdout(f: F) -> (R, String) +where F: FnOnce() -> R { + // Create pipe + let (read_fd, write_fd) = { + let mut fds = [0i32; 2]; + unsafe { libc::pipe(fds.as_mut_ptr()) }; + (fds[0], fds[1]) + }; + + // Save original stdout + let saved = unsafe { libc::dup(1) }; + + // Replace stdout with pipe writer + unsafe { libc::dup2(write_fd, 1) }; + unsafe { libc::close(write_fd) }; + + // Run the function + let result = f(); + + // Restore original stdout + unsafe { libc::dup2(saved, 1) }; + unsafe { libc::close(saved) }; + + // Read captured output + let mut output = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, 4096) }; + if n <= 0 { break; } + output.extend_from_slice(&buf[..n as usize]); + } + unsafe { libc::close(read_fd) }; + + (result, String::from_utf8_lossy(&output).to_string()) +} +``` + +**Verdict:** ⚠️ Works but has race conditions with other threads. The dup2 syscall is process-wide. + +### 2.3 Recommended Architecture for claw-code + +**The winning pattern is: don't fight fd 1 at all.** + +1. **For child process execution:** Use `Command::stdout(Stdio::piped())` + `Command::stderr(Stdio::piped())`. Read from the pipes on a tokio task and forward output to the TUI via an `mpsc` channel. This is clean, no fd manipulation needed. + +```rust +async fn run_tool(cmd: &mut Command, tx: &mpsc::Sender) -> Result { + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + + // Read in parallel using tokio + let stdout_reader = tokio::io::BufReader::new(stdout); + let stderr_reader = tokio::io::BufReader::new(stderr); + + // Spawn tasks to read lines and forward to TUI + let tx_clone = tx.clone(); + tokio::spawn(async move { + use tokio::io::AsyncBufReadExt; + let mut lines = stdout_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = tx_clone.send(AppEvent::ToolOutput { stream: "stdout", line }).await; + } + }); + // Similarly for stderr... + + child.wait().await +} +``` + +2. **For library code that prints to stdout:** Wrap it in `std::io::set_output_capture` (nightly/unstable) or accept that you need `dup2` with proper synchronization. The better long-term solution is to refactor such libraries to accept a `Write` sink. + +3. **For the TUI itself:** Always write to the alternate screen via crossterm. The crossterm backend writes directly to `std::io::stderr()` by default (since ratatui 0.26+ with `CrosstermBackend::new(std::io::stderr())`), which means stdout is free for capture without affecting TUI rendering. + +```rust +// Key insight: use stderr for the TUI backend, not stdout +let backend = CrosstermBackend::new(std::io::stderr()); +let mut terminal = Terminal::new(backend)?; +``` + +This is the **#1 most important fix**: if ratatui renders to stderr, then stdout capture for tools becomes trivial because they operate on different file descriptors. + +--- + +## 3. Async Runtime + Ratatui Patterns + +### 3.1 The Right Architecture + +Based on the ratatui FAQ and the official `async-template` (now `ratatui/templates`), the recommended architecture for a tokio + ratatui app is: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Tokio Runtime │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ Event Task │ │ App Logic │ │ Render Task │ │ +│ │ │ │ Tasks │ │ (optional) │ │ +│ │ crossterm │ │ │ │ │ │ +│ │ events → │──│→ mpsc ──│──│→ terminal.draw │ │ +│ │ tx │ │ channel │ │ │ │ +│ └─────────────┘ └──────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 Event Handling: Poll vs EventStream + +**Option A: `crossterm::event::poll()` + `read()` (recommended)** + +```rust +fn spawn_event_reader(tx: mpsc::Sender) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + // Poll with timeout to allow checking for shutdown + if crossterm::event::poll(Duration::from_millis(100)).unwrap() { + let event = crossterm::event::read().unwrap(); + if tx.send(AppEvent::Crossterm(event)).await.is_err() { + break; // Receiver dropped, shutdown + } + } + } + }) +} +``` + +**Option B: `crossterm::EventStream` (async-stream)** + +```rust +use crossterm::event::EventStream; +use futures::StreamExt; + +fn spawn_event_stream(tx: mpsc::Sender) -> JoinHandle<()> { + tokio::spawn(async move { + let mut reader = EventStream::new(); + while let Some(event) = reader.next().await { + match event { + Ok(event) => { + if tx.send(AppEvent::Crossterm(event)).await.is_err() { + break; + } + } + Err(e) => eprintln!("Error: {e}"), + } + } + }) +} +``` + +**Recommendation:** Use **Option A (poll-based)**. Here's why: +- `EventStream` pulls in `futures-core` and has had quirky edge cases around terminal teardown +- Poll-based is simpler and more reliable across platforms +- The 100ms poll timeout doubles as both event sampling and an idle timer +- Works identically on all platforms without the `event-stream` feature flag + +### 3.3 Main Loop Pattern + +```rust +async fn run_app(mut terminal: Terminal>, mut app: App) -> Result<()> { + let (tx, mut rx) = mpsc::channel::(100); + + // Spawn event reader + let event_task = spawn_event_reader(tx.clone()); + + // Spawn LLM streaming task + let llm_task = spawn_llm_handler(tx.clone()); + + // Tick rate: how often we get "tick" events for animations/updates + let tick_rate = Duration::from_millis(250); + let mut last_tick = Instant::now(); + + loop { + // Draw the UI + terminal.draw(|f| app.draw(f))?; + + // Wait for the next event, with a tick timeout + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + let event = tokio::select! { + event = rx.recv() => event, + _ = tokio::time::sleep(timeout) => Some(AppEvent::Tick), + }; + + match event { + Some(AppEvent::Crossterm(event)) => app.handle_event(event)?, + Some(AppEvent::Tick) => { + last_tick = Instant::now(); + app.on_tick(); + } + Some(AppEvent::LlmToken(token)) => app.on_token(token), + Some(AppEvent::ToolOutput { stream, line }) => app.on_tool_output(stream, line), + None => break, // Channel closed = shutdown + } + } + + Ok(()) +} +``` + +### 3.4 Tick Rate / Frame Rate Strategy + +**Key insight from the ratatui FAQ and async-template:** + +- **Tick rate** (app logic updates): 4-10 Hz (100-250ms). Controls how often animations, progress bars, and streaming text updates are pushed. +- **Frame rate** is effectively "render on every event + tick." Since ratatui uses double-buffering and only renders diffs, even 60fps rendering is cheap. +- **For streaming chat:** Don't render on every single token. Batch tokens and render on ticks. This reduces CPU usage with no visible degradation. + +```rust +struct App { + /// Accumulated but not-yet-rendered tokens + pending_tokens: String, + /// Whether we need to re-render + dirty: bool, +} + +impl App { + fn on_token(&mut self, token: &str) { + self.pending_tokens.push_str(token); + self.dirty = true; + // Don't render here — let the tick/render loop handle it + } + + fn on_tick(&mut self) { + if self.dirty { + // Flush pending tokens to the display buffer + self.flush_tokens(); + self.dirty = false; + } + } +} +``` + +### 3.5 Windows Key Event Gotcha + +From ratatui FAQ — on Windows, key events fire twice (Press + Release). Filter: + +```rust +CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + tx.send(AppEvent::Key(key)).await.unwrap(); + } +} +``` + +--- + +## 4. Scroll State Management + +### 4.1 The Core Problem + +Word-wrapped content in ratatui produces variable-height rows. `Paragraph::wrap()` handles rendering, but scroll state must be managed externally because: +- `Paragraph` with `.scroll((offset, 0))` scrolls by *lines*, not by *items* +- With variable-height items, line-based scroll doesn't map to item-based scroll +- The total number of lines is unknown until you render at a given width + +### 4.2 How tui-textarea Handles Scroll-to-Cursor + +**Crate:** `tui-textarea` (by rhysd) +**Repo:** https://github.com/rhysd/tui-textarea + +`tui-textarea` maintains its own scroll state with a sophisticated model: + +```rust +pub struct TextArea<'a> { + // Lines of text (each line is a Spans) + lines: Vec>, + // Cursor position (row, col in the logical text) + cursor: (usize, usize), + // Scroll offset (row, col in the viewport) + scroll: (u16, u16), + // Viewport size (updated on each render) + size: (u16, u16), +} +``` + +**Scroll-to-cursor logic:** +1. On each render, check if the cursor is within the visible viewport +2. If cursor is above the viewport → scroll up +3. If cursor is below the viewport → scroll down +4. The scroll offset is in *line* units, not character units +5. Soft wrapping is handled by breaking lines into visual lines and tracking visual line count + +**Key takeaway:** The textarea keeps a flat array of visual lines after wrapping, and scroll operates on visual-line indices. This is the model claw-code should adopt for chat history. + +### 4.3 Custom Virtual List / Lazy Rendering + +No existing crate provides this for variable-height content. Here's the recommended data structure: + +```rust +/// A chat history with efficient scrolling over variable-height items. +pub struct ChatHistory { + /// All messages in the conversation + messages: Vec, + /// Pre-computed visual line heights for each message at the current width + /// (message_index → number of visual lines) + visual_heights: Vec, + /// Prefix sums of visual_heights for O(log n) item→line and line→item lookups + line_offsets: Vec, + /// Current scroll position (visual line index from top) + scroll_line: usize, + /// Viewport height in lines + viewport_height: u16, + /// Last width used for height computation (to detect resize) + cached_width: u16, + /// Whether to auto-scroll to bottom on new content + auto_scroll: bool, +} + +impl ChatHistory { + /// Recompute visual heights after width change or new message + pub fn recompute(&mut self, width: u16) { + if width == self.cached_width && self.visual_heights.len() == self.messages.len() { + return; // Nothing changed + } + self.cached_width = width; + self.visual_heights = self.messages.iter() + .map(|msg| msg.visual_line_count(width)) + .collect(); + self.line_offsets = std::iter::once(0usize) + .chain(self.visual_heights.iter().scan(0, |acc, &h| { + *acc += h as usize; + Some(*acc) + })) + .collect(); + } + + /// Find the message index at a given visual line + pub fn message_at_line(&self, line: usize) -> usize { + self.line_offsets.partition_point(|&offset| offset <= line).saturating_sub(1) + } + + /// Get the range of messages visible in the current viewport + pub fn visible_range(&self) -> Range { + let start = self.message_at_line(self.scroll_line); + let end_line = self.scroll_line + self.viewport_height as usize; + let end = self.message_at_line(end_line.min(self.total_lines() - 1)) + 1; + start..end.min(self.messages.len()) + } + + /// Total visual lines + pub fn total_lines(&self) -> usize { + self.line_offsets.last().copied().unwrap_or(0) + } + + /// Add a message and update scroll + pub fn push(&mut self, msg: ChatMessage, width: u16) { + self.messages.push(msg); + self.recompute(width); + if self.auto_scroll { + self.scroll_to_bottom(); + } + } + + pub fn scroll_to_bottom(&mut self) { + let total = self.total_lines(); + self.scroll_line = total.saturating_sub(self.viewport_height as usize); + } +} +``` + +### 4.4 ratatui's Built-in Scroll + +`Paragraph` supports `.scroll((y_offset, x_offset))` where `y_offset` is in visual lines. For a single-paragraph chat view (flattening all messages into one `Text`), this works but loses per-message metadata. + +`List` widget supports `ListState` with `.scroll()` but operates on item indices, not visual lines — no help with word-wrapped items. + +--- + +## 5. Permission Prompts in TUI + +### 5.1 tui-popup: Modal Overlay Widget + +**Crate:** `tui-popup` (by joshka, official ratatui org) +**Docs:** https://docs.rs/tui-popup +**Repo:** Part of https://github.com/ratatui/tui-widgets + +The most mature popup/overlay widget for ratatui. Supports: + +```rust +use tui_popup::Popup; + +fn render_permission_popup(frame: &mut Frame) { + let popup = Popup::new("Allow file write to /etc/hosts?\n\n[y] Yes [n] No [a] Always allow") + .title("Permission Required") + .style(Style::new().white().on_red()); + frame.render_widget(popup, frame.area()); +} +``` + +**Features:** +- Auto-centers on the terminal area +- Auto-sizes to content +- Movable (via `PopupState` and arrow keys) +- Draggable by mouse (via `PopupState::mouse_down/up/drag()`) +- Supports wrapping arbitrary widgets (via `KnownSizeWrapper`) for scrollable popups +- Border style customization + +**Stateful usage for interactive popups:** + +```rust +use tui_popup::{Popup, PopupState}; + +fn render_stateful(frame: &mut Frame, state: &mut PopupState) { + let popup = Popup::new("Allow this action?") + .title("Permission") + .style(Style::new().white().on_blue()); + frame.render_stateful_widget(popup, frame.area(), state); +} +``` + +### 5.2 tui-prompts: Input/Prompt Widgets + +**Crate:** `tui-prompts` (by joshka, official ratatui org) +**Docs:** https://docs.rs/tui-prompts + +Note: This is for text input prompts (like readline), not permission dialogs. But its `Confirm` prompt type (planned) could be useful. + +Currently supports: +- `TextPrompt` — text input with emacs-style keybindings +- Password and invisible render styles +- Multi-line input with soft wrapping +- Prompt completion flow (Enter → complete, Escape → abort) + +```rust +use tui_prompts::{Prompt, TextPrompt, TextState}; + +struct App<'a> { + state: TextState<'a>, +} + +// In render: +TextPrompt::from("Allow? (y/n)").draw(frame, area, &mut app.state); + +// In event handler: +if app.state.is_completed() { + let answer = app.state.value(); + // Process answer +} +``` + +### 5.3 Custom Overlay Pattern (recommended for claw-code) + +For a permission prompt with [y/n/a] options, neither tui-popup nor tui-prompts is a perfect fit. The recommended approach is a custom overlay: + +```rust +struct PermissionDialog { + title: String, + message: String, + options: Vec, + selected: usize, + // Position for dragging + position: (u16, u16), +} + +enum PermissionOption { + Allow, + Deny, + AlwaysAllow, + AllowThisSession, +} + +impl Widget for &PermissionDialog { + fn render(self, area: Rect, buf: &mut Buffer) { + // 1. Dim the background + Block::default().style(Style::default().bg(Color::DarkGray)).render(area, buf); + + // 2. Calculate popup area (centered, sized to content) + let popup_area = centered_rect(area, 60, 30); + + // 3. Draw the popup with border + let block = Block::bordered() + .title(self.title.as_str()) + .style(Style::default().fg(Color::Yellow)); + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + // 4. Render the message and options + let text = Text::from(vec![ + Line::from(self.message.as_str()), + Line::from(""), + Line::from(self.options.iter().enumerate().map(|(i, opt)| { + let label = match opt { + PermissionOption::Allow => "[y] Yes", + PermissionOption::Deny => "[n] No", + PermissionOption::AlwaysAllow => "[a] Always", + PermissionOption::AllowThisSession => "[s] Session", + }; + if i == self.selected { + Span::styled(label, Style::default().fg(Color::Cyan).bold()) + } else { + Span::raw(label) + } + }).collect::>()), + ]); + Paragraph::new(text).render(inner, buf); + } +} + +/// Utility: calculate centered rect +fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} +``` + +This is exactly the pattern used in ratatui's own examples (`popup.rs`) and in codex-rs. + +### 5.4 Inline Permission Prompt (alternative) + +Instead of a modal overlay, some TUIs (like aichat) use an inline prompt at the bottom of the screen: + +``` +┌─ Chat ──────────────────────────────────────────────┐ +│ Assistant: I'll create the file config.toml ... │ +│ │ +├─ Permission ────────────────────────────────────────┤ +│ Allow write to config.toml? [y=Yes, n=No, a=Always] │ +└───────────────────────────────────────────────────────┘ +``` + +This is simpler (no overlay logic) and more accessible. Implementation: modify the layout to split off a bottom panel when a permission prompt is active. + +--- + +## 6. Testing TUIs in Rust + +### 6.1 How codex-rs Tests Their TUI + +codex-rs uses a layered approach (based on public source analysis): + +1. **Business logic**: Pure functions / state machines with standard `#[test]` unit tests +2. **Widget rendering**: Test the output of `Widget::render()` to a `Buffer`, then assert on buffer contents +3. **No snapshot testing of TUI output** (no insta for buffer comparison found in their public repo) + +### 6.2 Widget Unit Testing Pattern (recommended) + +The core pattern for testing ratatui widgets: + +```rust +#[test] +fn test_chat_message_render() { + let message = ChatMessage::assistant("Hello, world!"); + + // Create a buffer to render into + let area = Rect::new(0, 0, 40, 3); + let mut buf = Buffer::empty(area); + + // Render the widget + message.render(area, &mut buf); + + // Assert on buffer contents + assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "A"); + assert!(buf.cell((2, 0)).unwrap().symbol().starts_with("Hello")); + + // Or check a whole line + let line_content: String = (0..40) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!(line_content.contains("Hello, world!")); +} +``` + +### 6.3 Insta Snapshot Testing of Rendered Output + +**Crate:** `insta` +**Docs:** https://insta.rs + +Insta can be used to snapshot-test ratatui `Buffer` contents: + +```rust +use insta::assert_snapshot; + +#[test] +fn test_permission_dialog() { + let dialog = PermissionDialog::new("Allow write?", vec![ + PermissionOption::Allow, + PermissionOption::Deny, + ]); + + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + dialog.render(area, &mut buf); + + // Snapshot the buffer as a string + let rendered = buffer_to_string(&buf, area); + assert_snapshot!(rendered); +} + +/// Convert a buffer to a human-readable string representation +fn buffer_to_string(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in area.top..area.bottom { + for x in area.left..area.right { + out.push_str(buf.cell((x, y)).unwrap().symbol()); + } + out.push('\n'); + } + out +} +``` + +**Workflow:** +1. Run `cargo test` — new snapshots are written to `.snap.new` files +2. Run `cargo insta review` — interactively accept/reject snapshots +3. Accepted snapshots are saved as `.snap` files in `snapshots/` directory + +### 6.4 Mock Crossterm Events for Testing + +No dedicated crate exists for mocking crossterm events. The recommended patterns: + +#### Pattern A: Abstract the event source + +```rust +trait EventSource { + fn next_event(&mut self) -> Option; +} + +struct CrosstermEventSource; +impl EventSource for CrosstermEventSource { + fn next_event(&mut self) -> Option { + if crossterm::event::poll(Duration::from_millis(100)).ok()? { + let ev = crossterm::event::read().ok()?; + Some(AppEvent::from(ev)) + } else { + None + } + } +} + +struct MockEventSource { + events: Vec, + index: usize, +} +impl EventSource for MockEventSource { + fn next_event(&mut self) -> Option { + if self.index < self.events.len() { + let ev = self.events[self.index].clone(); + self.index += 1; + Some(ev) + } else { + None + } + } +} +``` + +#### Pattern B: Channel-based event injection + +```rust +#[cfg(test)] +fn test_app_with_events(events: Vec) { + let (tx, rx) = mpsc::channel(); + for event in events { + tx.send(event).unwrap(); + } + drop(tx); // Close channel to end test + + let mut app = App::new(); + while let Ok(event) = rx.recv() { + app.handle_event(event); + } + // Assert on final app state +} +``` + +#### Pattern C: VHS for integration testing + +**Tool:** VHS (by charmbracelet) +**Repo:** https://github.com/charmbracelet/vhs + +VHS lets you record terminal sessions from a script. Useful for end-to-end visual testing: + +```bash +# Record a demo / test +Output tui_demo.gif +Type "hello world" +Enter +Sleep 2s +``` + +This is more for documentation than CI testing, but can be automated. + +### 6.5 Testing Async TUIs + +For testing the integration between tokio and ratatui: + +```rust +#[tokio::test] +async fn test_streaming_display() { + let (tx, rx) = mpsc::channel(100); + + // Simulate streaming tokens + let tx_clone = tx.clone(); + tokio::spawn(async move { + for word in ["Hello", ", ", "world", "!"] { + tx_clone.send(AppEvent::LlmToken(word.to_string())).await.unwrap(); + } + }); + + // Run app logic without actual terminal + let mut app = App::new(); + while let Ok(event) = rx.recv().await { + app.handle_event(event); + } + + assert_eq!(app.chat_buffer, "Hello, world!"); +} +``` + +--- + +## Summary: Recommended Crate Stack for claw-code TUI + +| Problem | Recommended Solution | Crate | +|---------|---------------------|-------| +| Markdown rendering | `tui-markdown` | `tui-markdown = "0.x"` | +| ANSI color passthrough | `ansi-to-tui` | `ansi-to-tui = "4.x"` | +| Scrollable viewport | `tui-scrollview` | `tui-scrollview = "0.x"` | +| Popup/modal dialogs | `tui-popup` + custom overlay | `tui-popup = "0.x"` | +| Text input prompts | `tui-prompts` | `tui-prompts = "0.x"` | +| Child process output capture | `Stdio::piped()` + tokio async read | std + tokio | +| Event handling | `crossterm::event::poll()` | (crossterm, no event-stream feature) | +| Snapshot testing | `insta` | `insta = "1.x"` | +| TUI rendering backend | `CrosstermBackend::new(std::io::stderr())` | ratatui + crossterm | +| Logging inside TUI | `tui-logger` (optional) | `tui-logger = "0.x"` | +| Virtual scroll / chat history | Custom implementation | (no crate — see §4.3) | + +### Critical Architecture Decisions + +1. **Render to stderr, not stdout.** This solves 90% of the output isolation problem. Use `CrosstermBackend::new(std::io::stderr())`. + +2. **Use `Stdio::piped()` for child processes.** Don't use `dup2`/`gag`/fd capture. Read from pipes on tokio tasks. + +3. **Poll-based event handling** over `EventStream`. Simpler, more reliable, no extra deps. + +4. **Custom virtual scroll** for chat history. No existing crate handles variable-height items well. Build your own with prefix-sum height lookups. + +5. **Custom overlay for permission dialogs.** `tui-popup` is good for simple popups, but permission prompts with [y/n/a] options warrant a custom widget that integrates with the app's keybinding system. + +6. **Batch streaming tokens on tick.** Don't re-render on every token. Flush pending tokens on tick (4-10 Hz), which is imperceptibly different from per-token rendering but much more efficient. + +7. **Insta snapshots** for widget render tests. Combined with `Buffer`-based rendering assertions, this gives comprehensive coverage. diff --git a/docs/tui/moa-failure-analysis.md b/docs/tui/moa-failure-analysis.md new file mode 100644 index 00000000..85ba9141 --- /dev/null +++ b/docs/tui/moa-failure-analysis.md @@ -0,0 +1,165 @@ +# TUI Failure Mode Analysis: claw-code (feat-tui branch) + +**Date:** 2026-06-12 +**Branch:** feat-tui +**Scope:** Output bleeding and scroll rendering failures across 8 commits + +--- + +## Part 1: Output Bleeding + +Output bleeding occurs when bytes written to stdout/stderr during a model turn appear on the terminal in the middle of the TUI frame, corrupting the alternate-screen layout. + +### Failure 1: Suspend/Resume Pattern (eb717bc1) + +**Approach:** Before a `run_turn()`, call `app.suspend()` which leaves the alternate screen, disables raw mode, and clears the terminal. After the turn, `app.resume()` re-enters the alternate screen and redraws. + +**Root cause:** The TUI runs in crossterm's alternate screen buffer. When `suspend()` calls `LeaveAlternateScreen`, the terminal switches back to the primary buffer where normal stdout goes. But `run_turn()` invokes `ConversationRuntime::run_turn()` which internally streams tool output via `consume_stream` → `TerminalRenderer` → `println!` / `io::stdout()`. This output lands in the primary buffer, which the user sees as a flicker of raw text before the TUI re-enters alternate screen. + +**Why the fix didn't work:** The suspend/resume timing is inherently racy. Between `LeaveAlternateScreen` and the first byte of runtime output, there's a gap where the user sees an empty terminal. Between the last byte of runtime output and `EnterAlternateScreen`, there's another gap. Worse, if the turn panics or errors mid-stream, `resume()` may never execute, leaving the terminal in a broken state (raw mode disabled, no alternate screen). The suspends also destroy the alternate screen contents — every turn requires a full redraw from scratch. + +**The correct fix:** Never leave the alternate screen. Instead, route the runtime's output away from the real stdout fd. The runtime needs an abstraction layer where all output goes through a handle the TUI controls: either a `Write` trait object (as `run_turn_to` later attempts), or an fd-level redirect (as `libc::dup` later attempts), or by making the runtime emit structured events (deltas, tool calls) on a channel instead of printing directly. + +**How codex-rs avoids it:** codex-rs uses a `tokio`-async event loop where the runtime streams `AssistantEvent` / `TurnProgressReporter` events into an `mpsc` channel consumed by the TUI's `EventBroker`. The TUI renders these events as `ChatWidget` cells. The runtime never writes to stdout directly — it only emits structured events. + +--- + +### Failure 2: In-Place Turn Execution (cc191fd6) + +**Approach:** Remove `suspend()`/`resume()` entirely. Run the turn in-place while the TUI is still on the alternate screen. The comment says "output goes to the alternate screen buffer which ratatui owns — it will be overwritten on next redraw." + +**Root cause:** The alternate screen buffer is a terminal feature, not a ratatui-owned buffer. When the runtime writes ANSI escape sequences and text to stdout while the TUI's alternate screen is active, those bytes are rendered immediately by the terminal emulator into the alternate screen. But ratatui's `Terminal::draw()` only updates the regions it knows about. The runtime's output appears as garbage characters overlaid on the TUI frame, and because the runtime writes at the cursor position (which moves as it prints), the text appears at random screen positions — "full-width bleeding" across both panes. + +**Why the fix didn't work:** The assumption that "ratatui will overwrite on next redraw" is wrong for two reasons. First, the runtime's output is not constrained to any layout region — it writes to the entire screen at the cursor position. Second, ratatui's `draw()` uses a diff algorithm that only writes changed cells. If the runtime's output doesn't change any cell that ratatui considers "already correct," the garbage persists. There's no screen clear between the turn ending and the next draw. + +**The correct fix:** Same as Failure 1 — the runtime must not write to the real stdout while the TUI is active. The TUI must own the output path. Either capture/redirect the output, or restructure the runtime to emit events. + +**How codex-rs avoids it:** The runtime never prints; it sends events. The TUI's event loop renders them as structured cells, not raw text. + +--- + +### Failure 3: Buffer Output via `run_turn_to` (58e095a0) + +**Approach:** Add `run_turn_to(input, &mut out, emit_output)` that routes all explicit `println!` → `writeln!(out, ...)` calls through a custom writer. In TUI mode, `out` is a `Vec` buffer. Set `emit_output=false` to prevent the runtime from streaming tool output. + +**Root cause:** The `run_turn_to` function only captures output from the `LiveCli` layer — the `writeln!(out, ...)` calls, spinners, and status lines in `run_turn_to` itself. But the deeper runtime (`ConversationRuntime::run_turn()`) has its own internal stdout writes that bypass the `out` parameter. Specifically: `consume_stream` writes streaming token output directly to `io::stdout()`, the tool executor prints formatted results to stdout, and child processes (bash, git) inherit the TUI's stdout fd and write directly. The `emit_output=false` flag was supposed to suppress this, but the flag only controls whether the runtime's `ToolStreamEvent` emissions reach the TUI — it doesn't prevent the underlying FD writes from child processes or the internal renderer. + +**Why the fix didn't work:** There are too many code paths that write to stdout. The `out` parameter is a Rust-level abstraction, not an OS-level one. Any code that calls `io::stdout()` directly (including code in dependencies, tokio tasks, or child processes) bypasses it entirely. The buffer captures some output but not all — the uncaptured writes still bleed onto the alternate screen. + +**The correct fix:** Redirect at the fd level, not the Rust `Write` trait level. Either use `gag::BufferRedirect` (which redirects fd 1 to a pipe at the OS level), or use `libc::dup`/`dup2` to swap fd 1 to `/dev/null` during the turn. These operate at the kernel level and catch all writes regardless of which code path they come from. + +**How codex-rs avoids it:** It doesn't need fd tricks because the runtime never writes to stdout. Streams are consumed by the event loop and rendered as structured cells. + +--- + +### Failure 4: Post-Turn Screen Clear (99c4650a) + +**Approach:** Keep the `run_turn_to` buffer, but add a `crossterm::terminal::Clear(ClearType::All)` + `MoveTo(0,0)` after the turn to wipe any debris that leaked onto the alternate screen. Then force a full TUI redraw. + +**Root cause:** The clear+redraw is a degenerate form of the suspend/resume pattern. It acknowledges that some output will leak, and tries to fix it after the fact. But the damage is already visible to the user during the turn — they see flickering garbage text between the start of the turn and the clear. Additionally, the clear itself causes a visible flash (entire screen goes blank then redraws), which is jarring. + +**Why the fix didn't work:** The screen clear is applied after `run_turn_to()` returns, but during the turn the runtime's internal writes have already corrupted the screen. The user sees the corruption in real-time as the model is "thinking." The clear only fixes it after the fact — and even then, interleaved stdout writes during the clear sequence can leave partial artifacts if the timing is unlucky. It's a band-aid, not a cure. + +**The correct fix:** Prevent writes from reaching the terminal during the turn, don't try to clean them up afterward. + +**How codex-rs avoids it:** No stdout writes during streaming — events flow through a channel. + +--- + +### Failure 5: libc::dup FD Redirect (8f1ad0dc) + +**Approach:** Before the turn, `dup(1)` to save the real stdout fd, then `dup2(devnull_fd, 1)` to redirect fd 1 to `/dev/null`. Run the turn. Then `dup2(saved_fd, 1)` to restore. This catches ALL writes to fd 1 at the kernel level: runtime streaming, tool output, child processes, crossterm escape codes, everything. + +**Root cause:** This actually works for stdout suppression, but introduces new problems: (1) it requires `unsafe` code, which the workspace had set to `forbid`; (2) it blocks ALL stdout, including legitimate crossterm terminal control sequences that ratatui needs during the turn (e.g., resize handling); (3) stderr (fd 2) is not redirected, so error output from tools still bleeds; (4) the `run_turn_to` buffer is still in the code but now useless since fd 1 goes to `/dev/null` — the buffer captures nothing, so the conversation pane never gets the turn's output; (5) restoring the fd after a panic (if the turn panics mid-execution) requires a panic hook, otherwise fd 1 is permanently stuck on `/dev/null`. + +**Why the fix didn't work:** The approach is architecturally wrong — it treats the symptom (stdout bytes reaching the terminal) by silencing all stdout, rather than routing the output where the TUI can use it. The TUI needs to *receive* the output (to render it in the conversation pane), not just *suppress* it. With fd 1 pointing to `/dev/null`, the TUI loses access to all turn output. The conversation pane is populated only by reading the last assistant message from the session afterward, which loses tool output, compaction notices, and streaming tokens. + +**The correct fix:** Use `gag::BufferRedirect` (OS-level pipe redirect that captures rather than discards) or restructure the runtime to emit events. `gag` redirects fd 1 to a pipe — writes from any code path end up in the pipe buffer, which the TUI reads after the turn. This is the same OS-level interception as `dup`/`dup2`, but captures instead of discarding. This is what commit 4af649b4 eventually does. + +**How codex-rs avoids it:** Event-driven architecture — the runtime streams events, the TUI renders them. No fd manipulation needed. + +--- + +### Failure 6: Leave Alternate Screen During Turns (25610f2a) + +**Approach:** `leave_for_turn()` exits the alternate screen before running the turn. The turn's output goes to the normal terminal. After the turn, `wait_to_return()` prompts "Press any key to return to TUI..." then `reenter_after_turn()` re-enters the alternate screen and redraws. + +**Root cause:** This is the suspend/resume pattern reborn. The key improvement is that it deliberately shows the user the turn's output in the normal terminal (rather than letting it corrupt the TUI). But the UX is terrible — the user sees the TUI disappear, raw output appear, then has to press a key to get the TUI back. This is not a TUI experience; it's a REPL with extra steps. + +**Why the fix didn't work:** The fundamental problem is that the TUI and the runtime both want to own stdout. Leaving the alternate screen "works" (zero bleeding) but destroys the immersive TUI experience. The user context-switches between two different views on every turn. The "press any key" prompt adds friction. And the conversation pane only gets populated from the session afterward (reading the last assistant message), so tool output shown in the normal terminal is not rendered inside the TUI. + +**The correct fix:** The TUI must stay on the alternate screen. The runtime's output must be captured at the OS level (via `gag::BufferRedirect` or similar) and fed into the TUI's conversation pane. The `leave_for_turn` approach works as a last resort but defeats the purpose of having a TUI. + +**How codex-rs avoids it:** Async event loop — the runtime emits events, the TUI renders them inline. The user never leaves the TUI. + +--- + +## Part 2: Scroll Rendering + +Scroll is broken across all iterations because the code uses an inverted offset model that conflicts with ratatui's `Paragraph::scroll()` semantics. + +### Scroll Failure 1: Inverted Offset with `Paragraph::scroll()` (eb717bc1) + +**Root cause:** `auto_scroll()` sets `conversation_scroll = line_count - 20`, treating the value as "how far from the bottom." But `Paragraph::scroll((y, 0))` interprets `y` as "how far from the top." So a large scroll value shows the *top* of the conversation, not the bottom. The code then inverts the key bindings: PageUp *decreases* scroll (moves viewport down in `Paragraph::scroll` terms = moves toward bottom in user terms), PageDown *increases* scroll (moves toward top). This is backward from every terminal convention. + +**Why the fix didn't work:** The inverted model is confusing but internally consistent — if you squint, PageUp = "scroll toward newer" = decrease the bottom-offset. But the magic number `20` (hardcoded pane height) breaks when the terminal is resized. And `Paragraph::scroll()` combined with `.wrap(Wrap{trim:true})` produces visual glitches: ratatui re-wraps text on each render, changing the line count, which makes the scroll offset jump wildly. + +**The correct fix:** Compute scroll in terms of "lines from the top" (matching `Paragraph::scroll()` semantics). `auto_scroll` should set scroll to `max(0, total_wrapped_lines - visible_rows)`. PageUp adds to scroll (move viewport down = see older content), PageDown subtracts (see newer content). Recompute wrapped line count on every render to account for width changes. + +**How codex-rs avoids it:** codex-rs uses `ChatWidget` which maintains a `ScrollState` on a list of `MessageCell` objects. Each cell is a self-contained render unit. Scrolling operates on the cell list, not on flat wrapped lines, so re-wrapping doesn't cause offset jumps. + +--- + +### Scroll Failure 2: Custom Wrapped-Line Rendering (8f1ad0dc) + +**Root cause:** This commit introduces `build_wrapped_conversation()` which pre-computes wrapped lines and `expand_counts` (how many visual rows each logical line expands to). The scroll math computes `start = total_visual - (pane_rows + offset)` and `visible = wrapped.skip(start).take(pane_rows)`. The `conversation_scroll` value is still inverted: 0 = bottom, increasing = scrolling up. The `auto_scroll` sets it to 0, and `scroll_up` *adds* to it, `scroll_down` *subtracts*. The rendering inverts this back to show the correct window. + +**Why the fix didn't work:** The double-inversion is fragile and error-prone. The `expand_counts` vector tracks how many visual rows each logical line produces, but it's only used for the trim-notice insertion logic, not for scroll calculation. The scroll math recomputes `start` from `total_visual` every frame, which is correct, but the conversion from `conversation_scroll` (inverted offset) to `start` (forward offset) is: `start = total - pane_rows - scroll`. If `scroll` overflows (e.g., `u16::MAX` for "scroll to top"), then `pane_rows + scroll` overflows and `start` wraps to a garbage value, showing the wrong lines. + +**The correct fix:** The 6e787c2b version handles this correctly: `let offset = scroll.min(max_offset)` clamps the offset before computing `start`. But it still uses the inverted model. The correct approach is to use a forward offset (0 = top of conversation, max = bottom), which matches both `Paragraph::scroll()` semantics and user intuition. With the `RenderCache` pattern, the wrapped line count is precomputed and cached, making the forward-offset math trivial. + +**How codex-rs avoids it:** codex-rs maintains a `ScrollState` on its `ChatWidget` that tracks an absolute offset in the cell list. Scrolling adjusts this offset directly, clamped to `[0, total_cells - visible_cells]`. No inversion needed. + +--- + +### Scroll Failure 3: RenderCache with RefCell (6e787c2b — current) + +**Root cause:** The current `ConversationPane` introduces a `RenderCache` with `RefCell` to allow cache rebuilding inside `render(&self)`. The cache stores `wrapped_lines: Vec>` and `built_width: u16`. On render, if `dirty || built_width != area.width`, the cache is rebuilt. This correctly avoids recomputation when nothing changed. + +**Why the fix doesn't fully work:** The `auto_scroll()` still sets `scroll = 0` and `dirty = true`, meaning "show the bottom." But it then calls `rebuild_cache()` inside `render(&self)` which borrows `self.cache` via `RefCell`, and can't clear `self.dirty` because that would require `&mut self` while the cache is borrowed. The workaround is `mark_clean()` called after render, but this creates a subtle ordering dependency: if anything reads `is_dirty()` between `rebuild_cache` and `mark_clean`, it returns true. More critically, the scroll model is still inverted (0 = bottom, increasing = scroll up), and `scroll_up` adds while `scroll_down` subtracts, which still contradicts `Paragraph::scroll()` semantics even though the rendering manually inverts it via `start = total - visible - offset`. + +**The correct fix:** Flip to a forward-offset scroll model. `scroll` should mean "number of visual lines from the top." `auto_scroll` sets `scroll = max(0, total_wrapped - pane_rows)`. `scroll_up` subtracts, `scroll_down` adds. This matches `Paragraph::scroll()`, matches user intuition, and eliminates the double-inversion. The `RenderCache` pattern is sound and should be kept — it just needs the scroll semantics fixed. + +**How codex-rs avoids it:** codex-rs uses a forward-offset `ScrollState` on a list of message cells, not wrapped lines. Cells are the unit of scroll, which is coarser but more stable. Individual cells handle their own wrapping internally. + +--- + +## Part 3: The Architectural Root Cause + +All failures share a common root: **the runtime was built to print to stdout, and the TUI was grafted on top.** Every attempt to fix output bleeding is a workaround for the fact that `ConversationRuntime::run_turn()` writes to `io::stdout()` through multiple internal paths. + +The correct architectural fix — as documented in `docs/TUI.md` and practiced by codex-rs — is: + +1. **Make the runtime emit events, not print.** The TUI provides a `TurnProgressReporter` (or similar trait) that receives structured events: `AssistantDelta(text)`, `ToolCallStart(tool)`, `ToolCallResult(output)`, `TurnComplete(summary)`, `TurnError(error)`. +2. **The TUI event loop consumes these events** via an `mpsc` channel and renders them as `MessageCell` objects in a `ChatWidget`. +3. **No stdout writes during turns.** The runtime's internal renderer is either disabled (TUI mode) or replaced with a no-op reporter. +4. **Scroll operates on the cell list**, not on flat wrapped lines. Each cell manages its own wrapping. Scroll offset is forward (0 = top) and clamped to the cell count. + +This is the codex-rs architecture, and it's what `docs/TUI.md` prescribes for Phase 2 (Live Conversation). The current implementation is stuck in Phase 1 (Skeleton) — the TUI is a view-only shell that runs `run_turn()` and scrapes the result, rather than an active participant in the turn's event stream. + +--- + +## Summary Table + +| Commit | Approach | Type | Root Cause | Worked? | +|--------|----------|------|------------|---------| +| eb717bc1 | Suspend/resume (LeaveAltScreen) | Output | Runtime prints to stdout between Leave/EnterAltScreen | No — flicker, racy, loses TUI state on panic | +| cc191fd6 | In-place turn (no suspend) | Output | Runtime output renders on alternate screen; ratatui can't overwrite it | No — full-width bleeding | +| 58e095a0 | `run_turn_to>` buffer | Output | Only captures `LiveCli` layer output; runtime internals bypass the buffer | No — partial capture, child processes leak | +| 99c4650a | Post-turn screen clear | Output | Corruption visible during turn; clear causes flash | No — visible flicker; cosmetic only | +| 8f1ad0dc | `libc::dup` fd redirect to /dev/null | Output | Suppresses stdout but discards it; TUI can't read turn output | Partial — no bleeding, but no conversation content | +| 4af649b4 | `gag::BufferRedirect` | Output | Captures all fd 1 writes including child processes | Partial — works for capture, but loses ratatui control during turn | +| 25610f2a | Leave alternate screen during turns | Output | Works (zero bleeding) but destroys TUX UX | Functional but bad UX — REPL with extra steps | +| eb717bc1 | Inverted scroll + Paragraph::scroll() | Scroll | Inverted offset model; hardcoded pane height | No — wrong direction, resize breaks | +| 8f1ad0dc | Inverted scroll + custom wrap/render | Scroll | Double-inversion; u16 overflow on scroll-top | Partial — mostly works if scroll stays small | +| 6e787c2b | RenderCache + RefCell + inverted scroll | Scroll | Still inverted; dirty/clean ordering; RefCell borrow conflict | Partial — cache works, scroll semantics still inverted | diff --git a/docs/tui/moa-runtime-integration.md b/docs/tui/moa-runtime-integration.md new file mode 100644 index 00000000..6610ff70 --- /dev/null +++ b/docs/tui/moa-runtime-integration.md @@ -0,0 +1,1079 @@ +# TUI Runtime Integration Report: claw-code + +**Date:** 2026-06-12 +**Repo:** `/mnt/data/git/claw-code` (branch `feat-tui`) +**Scope:** Map every integration point a TUI layer must call and every event it must receive + +--- + +## Table of Contents + +1. [Architecture Overview](#1-architecture-overview) +2. [ConversationRuntime — the Core Loop](#2-conversationruntime--the-core-loop) +3. [TurnProgressReporter Trait](#3-turnprogressreporter-trait) +4. [run_turn() Streaming Model](#4-run_turn-streaming-model) +5. [Tool Execution Events & Lifecycle](#5-tool-execution-events--lifecycle) +6. [Permission Prompting](#6-permission-prompting) +7. [Compaction Flow](#7-compaction-flow) +8. [Reasoning / Thinking Content Streaming](#8-reasoning--thinking-content-streaming) +9. [AssistantEvent Enum — Full Event Catalog](#9-assistantevent-enum--full-event-catalog) +10. [Rendering Pipeline (Markdown → ANSI)](#10-rendering-pipeline-markdown--ansi) +11. [Input Layer (rustyline)](#11-input-layer-rustyline) +12. [Session Persistence](#12-session-persistence) +13. [Hook System](#13-hook-system) +14. [Integration Points Summary](#14-integration-points-summary) +15. [Proposed TUI Event Channel Design](#15-proposed-tui-event-channel-design) +16. [Key Risks & Open Questions](#16-key-risks--open-questions) + +--- + +## 1. Architecture Overview + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ CLI (rusty-claude-cli) │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ input.rs │ │ render.rs │ │ main.rs │ │ +│ │ rustyline │ │ pulldown- │ │ Cli struct │ │ +│ │ LineEditor │ │ cmark + │ │ ├── run_turn_to() │ │ +│ │ ReadOutcome │ │ syntect │ │ ├── CliPermission- │ │ +│ │ │ │ Terminal- │ │ │ Prompter │ │ +│ │ │ │ Renderer │ │ ├── AnthropicRuntime- │ │ +│ │ │ │ Markdown- │ │ │ Client (ApiClient) │ │ +│ │ │ │ StreamState │ │ └── BuiltRuntime │ │ +│ └──────────────┘ └──────────────┘ └────────┬───────────────┘ │ +│ │ │ +└───────────────────────────────────────────────┼──────────────────┘ + │ calls + ┌───────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Runtime Crate (runtime) │ +│ │ +│ ConversationRuntime │ +│ ├── session: Session │ +│ ├── api_client: C │ +│ ├── tool_executor: T │ +│ ├── permission_policy: PermissionPolicy │ +│ ├── hook_runner: HookRunner │ +│ ├── usage_tracker: UsageTracker │ +│ ├── turn_progress_reporter: Option>│ +│ ├── hook_progress_reporter: Option>│ +│ └── session_tracer: Option │ +│ │ +│ Key methods: │ +│ │ run_turn(input, prompter) → Result│ +│ │ compact(config) → CompactionResult │ +│ │ fork_session(branch) → Session │ +│ │ session() / session_mut() → &Session │ +│ │ usage() → &UsageTracker │ +│ └────────────────────────────────────────────────────────────────│ +└──────────────────────────────────────────────────────────────────┘ +``` + +The CLI's `Cli` struct owns a `BuiltRuntime` (which wraps `ConversationRuntime` plus plugin/MCP lifecycle). The TUI must either: + +- **(A)** Own a similar `BuiltRuntime` and call `run_turn()` the same way, but intercept I/O instead of using stdout/stdin, OR +- **(B)** Refactor the runtime to emit typed events through a channel instead of writing to an `io::Write` sink. + +--- + +## 2. ConversationRuntime — the Core Loop + +**File:** `runtime/src/conversation.rs` + +The runtime is generic over `C: ApiClient` and `T: ToolExecutor`: + +```rust +pub struct ConversationRuntime { + session: Session, + api_client: C, + tool_executor: T, + permission_policy: PermissionPolicy, + system_prompt: Vec, + max_iterations: usize, + usage_tracker: UsageTracker, + hook_runner: HookRunner, + auto_compaction_input_tokens_threshold: u32, + hook_abort_signal: HookAbortSignal, + hook_progress_reporter: Option>, + session_tracer: Option, + turn_progress_reporter: Option>, +} +``` + +**Builder-style setters:** +- `.with_max_iterations(n)` +- `.with_auto_compaction_input_tokens_threshold(t)` +- `.with_hook_abort_signal(signal)` +- `.with_hook_progress_reporter(reporter)` +- `.with_session_tracer(tracer)` +- `.with_turn_progress_reporter(reporter)` + +**Key public API:** +| Method | Returns | Notes | +|--------|---------|-------| +| `run_turn(input, prompter)` | `Result` | Core loop — blocks until turn completes | +| `compact(config)` | `CompactionResult` | Manual compaction | +| `estimated_tokens()` | `usize` | Current token footprint | +| `session()` / `session_mut()` | `&Session` | Read/write session state | +| `usage()` | `&UsageTracker` | Cumulative token usage | +| `api_client_mut()` | `&mut C` | Access underlying API client | +| `fork_session(branch)` | `Session` | Branch the conversation | +| `into_session(self)` | `Session` | Consume runtime, keep session | +| `set_auto_compaction_input_tokens_threshold(t)` | `()` | Adjust threshold at runtime | + +--- + +## 3. TurnProgressReporter Trait + +**File:** `runtime/src/conversation.rs:165-175` + +```rust +pub trait TurnProgressReporter: Send + Sync { + fn on_tool_result( + &self, + iteration: usize, + max_iterations: usize, + tool_name: &str, + input: &str, + result: Result<&str, &str>, + ); +} +``` + +### What events does it emit? + +**Only one:** `on_tool_result` — called after **each tool execution completes** (success or failure), inside the Phase 3 loop of `run_turn()`. + +### Parameters + +| Param | Meaning | +|-------|---------| +| `iteration` | 1-based index of the current loop iteration within the turn | +| `max_iterations` | The configured maximum (default: `usize::MAX`) | +| `tool_name` | e.g., `"read_file"`, `"bash"` | +| `input` | The effective tool input (after pre-hook mutation) | +| `result` | `Ok(output_str)` or `Err(error_str)` | + +### When is it called? + +After: +1. Tool pre-hooks run +2. Permission check passes +3. `tool_executor.execute_batch()` completes +4. Tool post-hooks run +5. Result message is pushed to session + +The reporter is called **once per tool** in a batch, in original call order. + +### Can a TUI version send events through a channel? + +**Yes, easily.** The trait is `Send + Sync`, so a channel-based implementation is straightforward: + +```rust +struct ChannelProgressReporter { + tx: mpsc::Sender, +} + +impl TurnProgressReporter for ChannelProgressReporter { + fn on_tool_result( + &self, + iteration: usize, + max_iterations: usize, + tool_name: &str, + input: &str, + result: Result<&str, &str>, + ) { + let _ = self.tx.send(TuiEvent::ToolResult { + iteration, + max_iterations, + tool_name: tool_name.to_string(), + input: input.to_string(), + result: result.map(str::to_string).map_err(str::to_string), + }); + } +} +``` + +### ⚠️ Limitation + +`TurnProgressReporter` does **NOT** emit: +- Tool-start events (when a tool begins execution) +- Streaming text deltas +- Thinking/reasoning events +- Token usage updates +- Auto-compaction events +- Permission prompts + +For those, the TUI needs additional hooks (see Section 15). + +--- + +## 4. run_turn() Streaming Model + +### How does run_turn() stream tokens? + +**It doesn't.** `run_turn()` is a **synchronous blocking call** that returns a `TurnSummary` after the entire turn completes. The streaming happens *inside* `ApiClient::stream()`, which is also blocking from the runtime's perspective — it returns `Result, RuntimeError>` (a collected Vec, not a stream). + +The current CLI architecture streams tokens to stdout **inside** `AnthropicRuntimeClient::stream()` (which implements `ApiClient::stream()`). The streaming is side-effectual: it writes ANSI-rendered markdown to an `io::Write` sink **while** collecting events. + +### The streaming flow + +``` +run_turn(input, prompter) + └─► api_client.stream(request) // blocks until stream ends + └─► tokio::Runtime::block_on(async { + └─► client.stream_message(request).await + └─► loop { + event = stream.next_event().await + match event { + ContentBlockStart → write to out; push to events + ContentBlockDelta::TextDelta → + MarkdownStreamState::push() → write ANSI to out + push AssistantEvent::TextDelta + ContentBlockDelta::ThinkingDelta → + accumulate into pending_thinking + ContentBlockDelta::InputJsonDelta → + accumulate into pending_tool input + ContentBlockDelta::SignatureDelta → + accumulate into pending_thinking signature + ContentBlockStop → + flush MarkdownStreamState + emit accumulated Thinking as AssistantEvent::Thinking + emit accumulated ToolUse as AssistantEvent::ToolUse + MessageDelta → push AssistantEvent::Usage + MessageStop → push AssistantEvent::MessageStop + } + }) + return Vec + }) +``` + +### Key insight for TUI + +The `consume_stream()` method determines the "out" writer at the top: + +```rust +let mut stdout = io::stdout(); +let mut sink = io::sink(); +let out: &mut dyn Write = if self.emit_output { + &mut stdout +} else { + &mut sink +}; +``` + +Currently, the TUI workaround (`run_turn_to`) uses a `Vec` buffer as the writer and reads it after the turn completes. **This means no live streaming in TUI mode** — the TUI gets the rendered output only after the entire turn finishes. + +### Is there a callback/channel pattern to hook into? + +**Not currently.** The three extension points are: + +1. **`TurnProgressReporter`** — only `on_tool_result` (post-tool, not during streaming) +2. **`HookProgressReporter`** — hook lifecycle events (Started/Completed/Cancelled) +3. **`InternalPromptProgressReporter`** — CLI-internal progress for ultraplan mode + +None of these fire during the token-by-token streaming inside `consume_stream()`. + +### What the TUI needs + +To get live streaming in the TUI, one of these approaches is needed: + +**Option A: Write to a channel instead of io::Write** + +Replace the `out: &mut dyn Write` in `consume_stream()` with an enum that can write to either stdout or an `mpsc::Sender`. Currently the renderer writes ANSI-escaped markdown incrementally, so the TUI could receive ANSI strings through the channel. + +**Option B: Emit typed events instead of raw ANSI** + +Refactor `consume_stream()` to accept an event sink trait: + +```rust +trait StreamSink { + fn on_text_delta(&mut self, text: &str); + fn on_thinking_start(&mut self); + fn on_thinking_delta(&mut self, text: &str); + fn on_thinking_end(&mut self, char_count: usize, redacted: bool); + fn on_tool_start(&mut self, name: &str, input: &str); + fn on_tool_result(&mut self, name: &str, output: &str, is_error: bool); + fn on_usage(&mut self, usage: &TokenUsage); + fn on_message_stop(&mut self); +} +``` + +**Option B is strongly recommended** because: +- The TUI needs to know event types (text vs thinking vs tool call) for layout +- Raw ANSI in a channel defeats the purpose of a structured TUI +- It allows the TUI to render each event type with its own widget style + +--- + +## 5. Tool Execution Events & Lifecycle + +### Within run_turn() + +The tool execution lifecycle inside `run_turn()` is: + +``` +1. API stream → assistant_message extracted +2. pending_tool_uses = filter ToolUse blocks from message + + ┌─── Phase 1: Pre-hooks + Permissions (sequential) ───┐ + │ For each (tool_use_id, tool_name, input): │ + │ a. run_pre_tool_use_hook(tool_name, input) │ + │ b. Apply updated_input from hook │ + │ c. Check if hook denied/cancelled/failed │ + │ d. permission_policy.authorize_with_context(...) │ + │ → may call PermissionPrompter::decide() │ + │ e. Record PendingTool { allowed, deny_reason } │ + └──────────────────────────────────────────────────────┘ + + ┌─── Phase 2: Execute allowed tools (batch/parallel) ─┐ + │ allowed_calls = filter pending where allowed │ + │ record_tool_started(iteration, tool_name) │ + │ batch_results = tool_executor.execute_batch(calls) │ + └───────────────────────────────────────────────────────┘ + + ┌─── Phase 3: Post-hooks + Session updates (sequential) ─┐ + │ For each pending tool: │ + │ a. Get batch_result (if allowed) │ + │ b. run_post_tool_use_hook / run_post_tool_use_failure│ + │ c. Merge hook feedback into output │ + │ d. session.push_message(tool_result) │ + │ e. record_tool_finished(iteration, result_message) │ + │ f. turn_progress_reporter.on_tool_result(...) │ + └──────────────────────────────────────────────────────────┘ + + Loop back to step 1 (next API call with tool results) +``` + +### What events exist? + +| Event | Source | Data | Currently Observable? | +|-------|--------|------|---------------------| +| `tool_pre_hook_started` | `HookProgressReporter::on_event(Started)` | event, tool_name, command | ✅ via HookProgressReporter | +| `tool_pre_hook_completed` | `HookProgressReporter::on_event(Completed)` | event, tool_name, command | ✅ via HookProgressReporter | +| `tool_pre_hook_cancelled` | `HookProgressReporter::on_event(Cancelled)` | event, tool_name, command | ✅ via HookProgressReporter | +| `permission_prompt` | `PermissionPrompter::decide()` | tool_name, input, modes | ✅ via PermissionPrompter | +| `tool_execution_started` | `record_tool_started()` (SessionTracer) | iteration, tool_name | ⚠️ only via SessionTracer | +| `tool_execution_finished` | `record_tool_finished()` (SessionTracer) | iteration, tool_name, is_error | ⚠️ only via SessionTracer | +| `tool_result` | `TurnProgressReporter::on_tool_result()` | iteration, tool_name, input, result | ✅ via TurnProgressReporter | + +### ⚠️ Tool execution start has no TUI-visible event + +The `record_tool_started()` method only emits to `SessionTracer` (not to `TurnProgressReporter`). There is **no `on_tool_start` callback**. The TUI currently has no way to show "Running tool X…" before the tool completes. + +**For the TUI**, you need either: +1. Add an `on_tool_start` method to `TurnProgressReporter` +2. Use `HookProgressReporter::on_event(Started)` which fires at pre-hook start (close to tool start) +3. Infer tool start from `AssistantEvent::ToolUse` in the stream (but this is inside `consume_stream()`, not `run_turn()`) + +--- + +## 6. Permission Prompting + +### Current CLI Implementation + +**File:** `main.rs:12766-12813` + +```rust +struct CliPermissionPrompter { + current_mode: PermissionMode, +} + +impl PermissionPrompter for CliPermissionPrompter { + fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { + println!(); + println!("Permission approval required"); + println!(" Tool {}", request.tool_name); + println!(" Current mode {}", self.current_mode.as_str()); + println!(" Required mode {}", request.required_mode.as_str()); + if let Some(reason) = &request.reason { + println!(" Reason {reason}"); + } + println!(" Input {}", request.input); + print!("Approve this tool call? [y/N]: "); + let _ = io::stdout().flush(); + + let mut response = String::new(); + match io::stdin().read_line(&mut response) { + Ok(_) => { + let normalized = response.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "y" | "yes") { + PermissionPromptDecision::Allow + } else { + PermissionPromptDecision::Deny { reason: ... } + } + } + Err(error) => PermissionPromptDecision::Deny { reason: ... }, + } + } +} +``` + +### PermissionRequest structure + +```rust +pub struct PermissionRequest { + pub tool_name: String, + pub input: String, + pub current_mode: PermissionMode, + pub required_mode: PermissionMode, + pub reason: Option, +} +``` + +### PermissionPromptDecision + +```rust +pub enum PermissionPromptDecision { + Allow, + Deny { reason: String }, +} +``` + +### PermissionMode hierarchy + +```rust +pub enum PermissionMode { + ReadOnly, // "read-only" + WorkspaceWrite, // "workspace-write" + DangerFullAccess,// "danger-full-access" + Prompt, // "prompt" (ask every time) + Allow, // "allow" (auto-approve) +} +``` + +### How should the TUI handle permissions? + +The `PermissionPrompter` trait is synchronous and blocking. For a TUI, the implementation should: + +1. **Send a permission request event** through a channel to the TUI event loop +2. **Block on a response channel** until the user selects Allow/Deny in the TUI +3. Use `std::sync::mpsc` or a oneshot channel for the response: + +```rust +struct TuiPermissionPrompter { + tx: mpsc::Sender, + rx: mpsc::Receiver, +} + +impl PermissionPrompter for TuiPermissionPrompter { + fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { + let _ = self.tx.send(TuiEvent::PermissionRequired { + tool_name: request.tool_name.clone(), + required_mode: request.required_mode, + input: request.input.clone(), + reason: request.reason.clone(), + }); + self.rx.recv().unwrap_or(PermissionPromptDecision::Deny { + reason: "TUI disconnected".into(), + }) + } +} +``` + +The TUI renders a modal/bottom-bar with the permission details and two buttons. On click, it sends the decision back through the channel. + +--- + +## 7. Compaction Flow + +### Auto-compaction in run_turn() + +Auto-compaction is checked **after each assistant message is pushed** (whether or not tools were used): + +```rust +// Inside the loop in run_turn(): +if let Some(compaction) = self.maybe_auto_compact() { + auto_compaction = Some(compaction); +} +``` + +### maybe_auto_compact() + +```rust +fn maybe_auto_compact(&mut self) -> Option { + if self.usage_tracker.cumulative_usage().input_tokens + < self.auto_compaction_input_tokens_threshold + { + return None; + } + + let result = compact_session( + &self.session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + + if result.removed_message_count == 0 { + return None; + } + + self.session = result.compacted_session; + Some(AutoCompactionEvent { + removed_message_count: result.removed_message_count, + }) +} +``` + +Threshold: default 100,000 input tokens (configurable via `CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS` env var). + +### AutoCompactionEvent + +```rust +pub struct AutoCompactionEvent { + pub removed_message_count: usize, +} +``` + +Returned in `TurnSummary.auto_compaction`. The CLI displays it: + +```rust +if let Some(event) = summary.auto_compaction { + writeln!(out, "{}", format_auto_compaction_notice(event.removed_message_count))?; +} +``` + +### Manual compaction + +Triggered by `/compact` command via `runtime.compact(config)` → returns `CompactionResult`: + +```rust +pub struct CompactionResult { + pub summary: String, + pub formatted_summary: String, + pub compacted_session: Session, + pub removed_message_count: usize, +} +``` + +### Context-window retry loop + +When the API returns a context window error, `run_turn_to()` enters a retry loop that: +1. Extracts context window size from the error +2. Auto-compacts with decreasing `preserve_recent_messages` (4 → 2 → 1 → 0) +3. Retries `run_turn()` with the compacted session +4. Up to 4 rounds + +**TUI must handle:** Displaying compaction progress and "auto-compacting…" messages during this retry loop. + +### Does compaction emit events the TUI needs? + +Currently, **no events are emitted during compaction itself**. The CLI writes directly to its `out` writer. The TUI would need: +- A `Compacting { round, max_rounds, removed_count }` event +- A `CompactionComplete { removed_message_count }` event + +--- + +## 8. Reasoning / Thinking Content Streaming + +### How thinking works in the protocol + +The Anthropic API and OpenAI-compatible APIs send thinking/reasoning as separate content blocks with deltas: + +1. `ContentBlockStart::Thinking { thinking, signature }` — initial thinking text (may be partial) +2. `ContentBlockDelta::ThinkingDelta { thinking }` — incremental thinking chunks +3. `ContentBlockDelta::SignatureDelta { signature }` — signing data for verified thinking +4. `ContentBlockStop` — marks end of the thinking block + +For OpenAI-compatible models (DeepSeek V4), `reasoning_content` is mapped to `ThinkingDelta`. + +### How the CLI handles it + +Inside `consume_stream()`: + +```rust +// Accumulate thinking into pending_thinking +let mut pending_thinking: Option<(String, Option)> = None; +let mut block_has_thinking_summary = false; + +// On ContentBlockStart::Thinking: +pending_thinking = Some((thinking.clone(), signature.clone())); + +// On ThinkingDelta: +if !block_has_thinking_summary { + render_thinking_block_summary(out, None, false)?; // "▶ Thinking hidden" + block_has_thinking_summary = true; +} +if let Some((t, _)) = &mut pending_thinking { + t.push_str(&thinking); +} + +// On SignatureDelta: +if let Some((_, sig)) = &mut pending_thinking { + sig.get_or_insert_with(String::new).push_str(&signature); +} + +// On ContentBlockStop: +block_has_thinking_summary = false; +if let Some((thinking, signature)) = pending_thinking.take() { + events.push(AssistantEvent::Thinking { thinking, signature }); +} +``` + +### `render_thinking_block_summary()` + +```rust +fn render_thinking_block_summary( + out: &mut (impl Write + ?Sized), + char_count: Option, + redacted: bool, +) -> Result<(), RuntimeError> { + let summary = if redacted { + "\n▶ Thinking block hidden by provider\n".to_string() + } else if let Some(char_count) = char_count { + format!("\n▶ Thinking ({char_count} chars hidden)\n") + } else { + "\n▶ Thinking hidden\n".to_string() + }; + write!(out, "{summary}")... +} +``` + +**Key observation:** The CLI **hides** the thinking content in the terminal — it only shows "▶ Thinking (N chars hidden)". The actual thinking text is preserved in `AssistantEvent::Thinking` for session persistence but not displayed. + +### For the TUI + +The TUI has more screen real estate and could: +- **Show a collapsible thinking block** with the actual content +- **Show a live streaming thinking indicator** that expands on click +- Stream `ThinkingDelta` chunks directly to a TUI panel + +But this requires the `StreamSink` approach (Option B from §4) since `ThinkingDelta` events are currently consumed inside `consume_stream()` and only the final accumulated `AssistantEvent::Thinking` is passed to the caller. + +--- + +## 9. AssistantEvent Enum — Full Event Catalog + +```rust +pub enum AssistantEvent { + Thinking { + thinking: String, + signature: Option, + }, + TextDelta(String), + ToolUse { + id: String, + name: String, + input: String, + }, + Usage(TokenUsage), + PromptCache(PromptCacheEvent), + MessageStop, +} +``` + +| Event | When | TUI needs? | +|-------|------|------------| +| `Thinking` | After thinking block completes | ✅ Show in collapsible section | +| `TextDelta` | Per text chunk | ✅ Append to response area | +| `ToolUse` | After tool input fully accumulated | ✅ Show "Running tool…" | +| `Usage` | Per message delta (token counts) | ✅ Token counter | +| `PromptCache` | Stream metadata | ❌ Internal only | +| `MessageStop` | Stream ends | ✅ Mark turn complete | + +**Note:** These events are collected inside `ApiClient::stream()` and returned as a `Vec`. They are NOT streamed to the caller one-by-one. This is the core architectural problem for live TUI updates. + +--- + +## 10. Rendering Pipeline (Markdown → ANSI) + +**File:** `rusty-claude-cli/src/render.rs` + +### TerminalRenderer + +```rust +pub struct TerminalRenderer { + syntax_set: SyntaxSet, + syntax_theme: Theme, + color_theme: ColorTheme, +} +``` + +Key methods: +- `render_markdown(markdown) → String` — full markdown → ANSI +- `markdown_to_ansi(markdown) → String` — alias +- `color_theme() → &ColorTheme` + +### MarkdownStreamState (incremental rendering) + +```rust +pub struct MarkdownStreamState { + pending: String, +} +``` + +- `push(renderer, delta) → Option` — append delta, render complete chunks +- `flush(renderer) → Option` — render remaining pending text + +This handles the problem that markdown can't be partially rendered (e.g., a `**` bold marker spans two chunks). It finds a "stream-safe boundary" and only renders complete blocks. + +### ColorTheme + +```rust +pub struct ColorTheme { + heading: Color, // Cyan + emphasis: Color, // Magenta + strong: Color, // Yellow + inline_code: Color, // Green + link: Color, // Blue + quote: Color, // DarkGrey + table_border: Color, // DarkCyan + code_block_border: Color, // DarkGrey + spinner_active: Color, // Blue + spinner_done: Color, // Green + spinner_failed: Color, // Red +} +``` + +### For the TUI + +The TUI should **NOT** use ANSI rendering. Instead: +- Parse the raw markdown with `pulldown-cmark` directly (same parser, different renderer) +- Map markdown events to TUI widget primitives (heading → styled text, code block → syntax-highlighted block, etc.) +- The `TerminalRenderer` stays available for fallback/legacy mode + +The incremental `MarkdownStreamState` logic (boundary detection) could be adapted for the TUI. + +--- + +## 11. Input Layer (rustyline) + +**File:** `rusty-claude-cli/src/input.rs` + +### LineEditor + +```rust +pub struct LineEditor { + prompt: String, + editor: Editor, +} +``` + +### ReadOutcome + +```rust +pub enum ReadOutcome { + Submit(String), + Cancel, + Exit, + ProviderSwap, + TeamToggle, +} +``` + +### Key bindings +- `Enter` — submit +- `Ctrl+J` / `Shift+Enter` — newline (multiline input) +- `Ctrl+P` — provider swap (inserts `\x01` sentinel) +- `Ctrl+T` — team toggle (inserts `\x02` sentinel) +- `Ctrl+C` — cancel current input (or exit if empty) +- `Ctrl+D` — exit + +### For the TUI + +The TUI replaces `LineEditor` entirely with its own text input widget. The `ReadOutcome` enum should still be used as the adapter interface, but the TUI input widget handles key events directly. + +Slash command completions come from the REPL loop's `SlashCommandHelper.completions` list. + +--- + +## 12. Session Persistence + +**File:** `runtime/src/session.rs` + +### Key types + +```rust +pub struct Session { + pub id: String, + pub messages: Vec, + pub compaction: Option, +} + +pub struct SessionCompaction { + pub count: u32, + pub removed_message_count: usize, + pub summary: String, +} + +pub struct ConversationMessage { + pub role: MessageRole, + pub blocks: Vec, + pub usage: Option, +} + +pub enum ContentBlock { + Text { text: String }, + Thinking { thinking: String, signature: Option }, + ToolUse { id: String, name: String, input: String }, + ToolResult { tool_use_id: String, tool_name: String, output: String, is_error: bool }, +} + +pub enum MessageRole { System, User, Assistant, Tool } +``` + +Session is serialized to JSONL (`session.jsonl`) with rotation (256KB max, 3 rotated files). Large fields are truncated at 16KB. + +### For the TUI + +The TUI reads `Session.messages` to render the conversation history. No changes to the persistence format are needed — the runtime handles all reads/writes. + +--- + +## 13. Hook System + +**File:** `runtime/src/hooks.rs` + +### HookEvent + +```rust +pub enum HookEvent { + PreToolUse, + PostToolUse, + PostToolUseFailure, +} +``` + +### HookProgressEvent + +```rust +pub enum HookProgressEvent { + Started { event: HookEvent, tool_name: String, command: String }, + Completed { event: HookEvent, tool_name: String, command: String }, + Cancelled { event: HookEvent, tool_name: String, command: String }, +} +``` + +### HookProgressReporter trait + +```rust +pub trait HookProgressReporter { + fn on_event(&mut self, event: &HookProgressEvent); +} +``` + +### For the TUI + +`HookProgressReporter` can be implemented with a channel sender to show hook activity in the TUI (e.g., "Running pre-tool hook for bash…"). + +--- + +## 14. Integration Points Summary + +### What the TUI Needs to CALL + +| Call | Method | Notes | +|------|--------|-------| +| Start a turn | `runtime.run_turn(input, prompter)` | Blocks until complete | +| Compact session | `runtime.compact(config)` | Manual `/compact` | +| Get session state | `runtime.session()` | For rendering history | +| Get usage stats | `runtime.usage()` | Token counter display | +| Get estimated tokens | `runtime.estimated_tokens()` | Status bar | +| Set compaction threshold | `runtime.set_auto_compaction_threshold(t)` | After context window error | +| Fork session | `runtime.fork_session(branch)` | `/session fork` | +| Prepare runtime | `cli.prepare_turn_runtime(emit_output)` | Rebuilds runtime from session | +| Replace runtime | `cli.replace_runtime(runtime)` | After compaction retry | + +### What the TUI Needs to RECEIVE + +| Event | Current Source | Proposed TUI Source | +|-------|---------------|-------------------| +| Text delta (streaming) | **Inside consume_stream()** | Refactor: StreamSink | +| Thinking delta (streaming) | **Inside consume_stream()** | Refactor: StreamSink | +| Tool use start | `HookProgressReporter::Started` | Channel from HookProgressReporter | +| Tool use complete | `TurnProgressReporter::on_tool_result` | Channel from TurnProgressReporter | +| Permission prompt | `PermissionPrompter::decide()` | Channel + response channel | +| Token usage update | `AssistantEvent::Usage` (post-stream) | StreamSink or TurnSummary | +| Auto-compaction happened | `TurnSummary.auto_compaction` | Post-turn, or event | +| Auto-compaction retry | `run_turn_to()` retry loop | Needs new event source | +| Message stop | `AssistantEvent::MessageStop` | StreamSink | +| Hook started/completed | `HookProgressReporter` | Channel from reporter | +| Spinner state | CLI writes directly | TUI manages its own | + +### Current Missing Events (Must Be Added for TUI) + +| Missing Event | Why Needed | +|---------------|-----------| +| `on_tool_start(iteration, tool_name, input)` | Show "Running X…" before result | +| `on_text_delta(text)` | Live streaming text to response area | +| `on_thinking_delta(text)` | Live thinking indicator / collapsible | +| `on_compacting(round, max_rounds, removed)` | Compaction retry progress | +| `on_auto_compaction(event)` | Mid-turn compaction notification | +| `on_permission_prompt(request)` | Delegated to PermissionPrompter (has channel solution) | + +--- + +## 15. Proposed TUI Event Channel Design + +### Core Idea + +Replace the `io::Write`-based output with a typed event channel that the TUI consumes in its render loop. + +### Event Enum + +```rust +enum TuiEvent { + // ── Streaming events (from consume_stream refactor) ── + TurnStarted { input: String }, + TextDelta { text: String }, + ThinkingStart, + ThinkingDelta { text: String }, + ThinkingEnd { char_count: usize, redacted: bool }, + ToolUseStart { id: String, name: String, input: String }, + ToolUseResult { id: String, name: String, output: String, is_error: bool }, + UsageUpdate { usage: TokenUsage }, + MessageStop, + + // ── Tool lifecycle (from TurnProgressReporter + new hooks) ── + ToolStarted { iteration: usize, tool_name: String }, + ToolResult { + iteration: usize, + tool_name: String, + result: Result + }, + + // ── Permission (from PermissionPrompter channel) ── + PermissionRequired { + tool_name: String, + required_mode: PermissionMode, + input: String, + reason: Option, + // Response sent back through separate oneshot channel + response_tx: oneshot::Sender, + }, + + // ── Compaction ── + Compacting { round: usize, max_rounds: usize, removed: usize }, + AutoCompaction { removed_message_count: usize }, + + // ── Hook progress ── + HookStarted { event: HookEvent, tool_name: String, command: String }, + HookCompleted { event: HookEvent, tool_name: String, command: String }, + HookCancelled { event: HookEvent, tool_name: String, command: String }, + + // ── Lifecycle ── + TurnCompleted { summary: TurnSummary }, + TurnFailed { error: RuntimeError }, +} +``` + +### Integration Architecture + +``` +┌───────────────────────┐ +│ TUI Loop │ +│ ┌─────────────────┐ │ +│ │ Event Receiver │◄─── mpsc::Receiver +│ │ (render loop) │ │ +│ └─────────────────┘ │ +│ ┌─────────────────┐ │ +│ │ Input Widget │───► on submit: send input to runtime thread +│ └─────────────────┘ │ +└───────────────────────┘ + ▲ + │ TuiEvent channel + │ +┌────────┴──────────────┐ +│ Runtime Thread │ +│ │ +│ ┌──────────────────┐ │ +│ │ ChannelSender │ │ ← implements StreamSink +│ │ + TurnProgress │ │ ← implements TurnProgressReporter +│ │ + HookProgress │ │ ← implements HookProgressReporter +│ │ + PermPrompter │ │ ← implements PermissionPrompter +│ └──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ConversationRuntime │ +│ .run_turn(input, ...) │ +│ │ +└────────────────────────┘ +``` + +### Implementation Sketch + +```rust +// --- StreamSink: replaces io::Write in consume_stream() --- +trait StreamSink { + fn text_delta(&mut self, text: &str); + fn thinking_start(&mut self); + fn thinking_delta(&mut self, text: &str); + fn thinking_end(&mut self, char_count: usize, redacted: bool); + fn tool_use(&mut self, id: &str, name: &str, input: &str); + fn usage(&mut self, usage: &TokenUsage); + fn message_stop(&mut self); +} + +// Channel-based implementation +struct ChannelStreamSink { + tx: mpsc::Sender, +} + +impl StreamSink for ChannelStreamSink { + fn text_delta(&mut self, text: &str) { + let _ = self.tx.send(TuiEvent::TextDelta { text: text.into() }); + } + // ... etc +} + +// --- Modified consume_stream signature --- +// BEFORE: +async fn consume_stream(&self, req: &MessageRequest, stall: bool) + -> Result, RuntimeError> + +// AFTER (or alongside): +async fn consume_stream_with_sink( + &self, + req: &MessageRequest, + stall: bool, + sink: &mut dyn StreamSink, +) -> Result, RuntimeError> +``` + +### Migration Strategy + +1. **Phase 1 (Minimal TUI):** Use `run_turn_to()` with `Vec` buffer — no live streaming, but functional +2. **Phase 2 (Channel events):** Add `StreamSink` trait, implement `ChannelStreamSink`, add `consume_stream_with_sink()` +3. **Phase 3 (Full TUI):** TUI consumes `Receiver`, renders each event with appropriate widget + +--- + +## 16. Key Risks & Open Questions + +### Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| `consume_stream()` is 200+ lines with deep coupling to `io::Write` | High — refactoring is invasive | Add parallel `consume_stream_with_sink()` rather than modifying existing | +| `run_turn()` is synchronous and blocks the runtime thread | Medium — TUI input can't be processed during turn | Accept this: runtime runs on a background thread, TUI sends input via channel | +| `PermissionPrompter` blocks during `decide()` | Medium — TUI must wait for user input | Implemented as channel round-trip (send event → receive decision) | +| Hook runner may deny tool calls silently (no TUI event) | Low — Denied tools appear as error results | `TurnProgressReporter.on_tool_result()` already receives `Err` for denials | +| Context window retry loop is inside `run_turn_to()` (CLI layer) | High — TUI must replicate or share this logic | Extract into runtime or shared utility | + +### Open Questions + +1. **Should the TUI own the `BuiltRuntime` or the `Cli` struct?** + Currently `Cli` holds state (session, model, permission_mode) and wraps `BuiltRuntime`. The TUI probably needs its own "TuiApp" struct with similar state management. + +2. **How to handle the context-window retry loop?** + This is currently 80+ lines in `run_turn_to()`. The TUI needs the same logic. Options: extract to shared function, or move it into the runtime. + +3. **Should `InternalPromptProgressReporter` be exposed for the TUI?** + It's CLI-internal (ultraplan mode). The TUI might want its own progress reporting. + +4. **What about MCP/plugin lifecycle?** + `BuiltRuntime` manages plugin and MCP startup/shutdown. The TUI must handle the same lifecycle (currently `prepare_turn_runtime()` handles this). + +5. **How does the TUI handle `/session`, `/compact`, `/model` etc.?** + These are all handled in the REPL loop in `main.rs` (thousands of lines of command dispatch). The TUI needs its own command handler that calls the same runtime methods. + +6. **Token streaming granularity for the TUI render loop?** + The TUI render loop (e.g., ratatui) typically redraws at 60fps. TextDeltas may arrive much faster. Need to batch/merge deltas between frames. + +--- + +*End of report. All source references point to `/mnt/data/git/claw-code` on branch `feat-tui`.*