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).
This commit is contained in:
TheArchitectit 2026-06-12 21:21:42 -05:00
parent 6e787c2b0c
commit f6597f1935
10 changed files with 6777 additions and 0 deletions

1005
docs/tui/ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

43
docs/tui/README.md Normal file
View File

@ -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)

View File

@ -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 14 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 13 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 16) — 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 13 merge checkpoint:** `main.rs` is <200 lines. All tests pass. No behavioral changes.
---
### Phase B: Core TUI Shell (Weeks 712) — 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<MessageCell>` 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 46 merge checkpoint:** `claw tui` runs. It looks like a chat app with fake data. It exits cleanly. CI passes.
---
### Phase C: Live Conversation (Weeks 1320) — 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 710 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 2128) — 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 1114 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 2938) — 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 1519 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 3946) — 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 2023 merge checkpoint:** The TUI is production-ready. Tests cover rendering, large sessions, and cross-platform compatibility. Docs are complete.
---
### Phase G: Advanced Features (Weeks 4752) — 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 16 (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 712 (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*

File diff suppressed because it is too large Load Diff

196
docs/tui/TUI.md Normal file
View File

@ -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<MessageCell>,
scroll: ScrollState,
input: InputState,
streaming: Option<StreamingCell>,
},
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<MessageCell>` 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

607
docs/tui/moa-aichat.md Normal file
View File

@ -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<SseEvent>) -> Vec<SseEvent> {
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<SseEvent>,
abort_signal: AbortSignal,
buffer: String, // Full text accumulated
tool_calls: Vec<ToolCall>,
}
```
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<F>(builder: RequestBuilder, mut handle: F) -> Result<()>
// JSON stream — AWS Bedrock, etc.
pub async fn json_stream<S, F, E>(mut stream: S, mut handle: F) -> Result<()>
```
Both funnel through the same `SseHandler``UnboundedReceiver<SseEvent>` 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<SpinnerEvent>`.
**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 |

667
docs/tui/moa-codex-rs.md Normal file
View File

@ -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<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

1051
docs/tui/moa-ecosystem.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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<W: Write>(input, &mut out, emit_output)` that routes all explicit `println!``writeln!(out, ...)` calls through a custom writer. In TUI mode, `out` is a `Vec<u8>` 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<RenderCache>` to allow cache rebuilding inside `render(&self)`. The cache stores `wrapped_lines: Vec<Line<'static>>` 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<Vec<u8>>` 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 |

File diff suppressed because it is too large Load Diff