# 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