claw-code/docs/tui/moa-codex-rs.md

668 lines
31 KiB
Markdown

# 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<Option<Self::Item>> {
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<EventBrokerState<S>>`:
```rust
enum EventBrokerState<S: EventSource> {
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<Instant>)
→ 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<AppEvent>`. 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<QueuedLine>` — 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<Arc<dyn HistoryCell>>`) — finalized, stored in scrollback
- **Active cell** (`transcript.active_cell: Option<Box<dyn HistoryCell>>`) — 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<Line<'static>>;
fn raw_lines(&self) -> Vec<Line<'static>>;
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine>;
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine>;
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<R, F, Fut>(&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<AtomicU16>`
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<StderrState> = ...;
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<AppEvent>` 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<StderrState>` 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<Arc<dyn │ │ │ StreamController │ │ │ │
│ │ │ HistoryCell>> │ │ │ ┌──────────────┐ │ │ │ │
│ │ │ │ │ │ │ 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