31 KiB
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
- Crate Versions & Dependencies
- Event Loop Architecture
- Streaming Assistant Response Rendering
- ChatWidget / Message List
- Input Composer
- Tool Call Execution & Inline Display
- Alternate Screen Enter/Exit
- Output Bleeding Prevention
- Patterns to Adopt
- 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):
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:
- Draw events — from a
broadcast::Receiver<()>(one draw notification per scheduled frame) - Crossterm events — from a shared
EventBrokerwrappingcrossterm::EventStream
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::KeyEvent::Resize→TuiEvent::ResizeEvent::Paste→TuiEvent::PasteEvent::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>>:
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)
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):
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 accumulatorVecDeque<QueuedLine>— FIFO queue of committed render lines withenqueued_at: Instanttimestamps
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:
- Stable region — committed to scrollback via the animation queue. These lines are immutable.
- Tail region — mutable, displayed in the
active_cellslot. 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:
- Collect
QueueSnapshot(total queued lines + oldest age) - Ask
AdaptiveChunkingPolicyfor aChunkingDecision - Apply the
DrainPlan(Single or Batch) to bothStreamControllerandPlanStreamController - Return
CommitTickOutputwith emittedHistoryCells
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:
StreamController::finalize()drains remaining lines, returns raw sourceApp::handle_consolidate_agent_message()replaces the trailing run of streamingAgentMessageCells with a single source-backedAgentMarkdownCell- 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
HistoryCells - Drives rendering via the
Renderabletrait
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):
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:
- Active cell (flex=1, grows to fill)
- Active hook cell (flex=0)
- Pending token activity output (flex=1)
- 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:
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
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 HistoryCells 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 —
WrapCachestores wrapped lines keyed by width, avoiding re-wrap on every keystroke - Kill buffer —
KillBufferKind::{Characterwise, Linewise}for clipboard-like kill/yank - Vim mode — Full
VimModewithVimNormalKeymap,VimOperatorKeymap,VimTextObjectKeymap - Element placeholders — Special rendering for
@mentionsand other inline elements - Keymap indirection —
RuntimeKeymap→EditorKeymapallows different keybinding schemes - Word boundaries — Uses
unicode_segmentationfor proper Unicode word movement
ChatComposer (bottom_pane/chat_composer.rs)
The composer wraps TextArea and adds:
- Popup routing — Slash commands, file search,
@mentionseach 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:
- Active view (popups like slash-command picker, file search) → if consumed, stop
- Composer → text editing, submission
- ChatWidget → interrupt (Ctrl+C), quit (Ctrl+D)
Key handling:
Ctrl+Cwhile composing → clear input; during agent turn → interruptCtrl+Don 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 stdinUnifiedExecProcessesCell— lists of running background terminals with recent output chunksMcpToolCallCell(history_cell/mcp.rs) — MCP (Model Context Protocol) tool invocationsMcpInvocation— individual MCP tool call cells- Approval cells (
history_cell/approvals.rs) —ApprovalDecisionCellwith 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
- Agent requests approval (exec command, file write, network access)
AppEvent::ApprovalRequestarrives- Bottom pane shows approval UI with key hints
- User approves/denies →
ReviewDecisioncell added to transcript - Display shows:
✓ User approved codex to run: npm testor✗ 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)
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)
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.:
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:
SuspendContextstores cursor position viaArc<AtomicU16>- On SIGTSTP, leaves alt screen, restores terminal modes, sends SIGSTOP to self
- On SIGCONT (resume), checks
ResumeAction:Inline— restore to inline mode (no alt screen)AltScreen— re-enter alternate screen
- 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)
#![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:
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
#![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
Paragraphis 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 retrievalStreamManager— streaming controllers and commit ticksChatView— renderable compositionEventHandler— 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
- Must have: EventBroker pause/resume, stderr dup2 suppression,
#![deny(print_stdout/print_stderr)], SynchronizedUpdate - Must have: Two-region streaming (stable + mutable tail) with table holdback
- Must have: Transcript consolidation (streaming → canonical source-backed cell)
- Should have: Frame scheduler actor with coalescing
- Should have: Custom FlexRenderable layout system
- Should have: HistoryCell trait with width-dependent height for resize reflow
- Improve on: Add viewport clipping/virtualization, bidirectional scrolling, mouse wheel support
- Improve on: Split ChatWidget into focused modules
- Improve on: Use bounded app event channel
- Improve on: Minimize ratatui/crossterm forking — try upstream features first