Architecture map
The renderer is the last stage, not the composition root
ACP updates + Client plugin snapshots
│ semantic, validated data
▼
┌──────────────── App state ────────────────┐
│ transcript · composer · overlay · docks │
│ focus · selection · scroll anchors │
└────────────────────┬──────────────────────┘
│ snapshot + Rect
▼
┌────────────── Ratatui render pass ──────────────┐
│ measure → allocate → wrap → clip → paint cells │
└────────────────────┬────────────────────────────┘
▼
terminal back bufferRead the implementation
Source map
These repository paths are pinned to the verified revision shown above, so each claim and excerpt can be checked against the implementation that the article describes.
| Path | Responsibility |
|---|---|
src/input/composer.rs | Textarea adapter and Unicode cell-to-character map |
src/app.rs | Application state, input routing, scroll, and overlays |
src/ui.rs | Frame layout and semantic surface rendering |
src/events.rs | ACP update normalization into UI events |
src/cordis.rs | Private compositor method constants |
docs/composer-input.md | Composer behavior and verification contract |
Render complete frames from stable state
Ratatui writes into a back buffer and diffs it against the previous frame. That model fits an agent UI only if the application owns stable semantic state. Transcript nodes, running tools, permission cards, the composer, overlays, navigation docks, and status are updated first; the render pass then measures and paints all visible regions from a snapshot.
The alternative—printing transport fragments as they arrive—cannot reflow on resize, replace a pending card, or keep scroll position anchored. It also makes tests depend on event batching. Martty's event loop can drain several updates before a normal frame, but it explicitly interrupts that drain for transitions whose intermediate visual state matters, such as the beginning of a tool call.
Tool calls are mutable nodes with a frame guarantee
A tool call is not three log lines labeled start, output, and done. It is one node whose status, body, and timing change. Stable identity lets the reducer update that node in place and lets collapsed or expanded state survive later results. The transcript therefore remains a model of the agent turn rather than a chronology of wire packets.
Fast tools reveal why frame scheduling belongs in the application contract. Start and completion may arrive in one receive burst. Martty's event_requires_immediate_frame detects a tool-call transition in either the typed UI event or ACP session/update, stops draining, and renders the pending card once. Completion can then replace it in the next frame without an artificial timeout.
src/main.rsfn event_requires_immediate_frame(event: &AppEvent) -> bool {
match event {
AppEvent::Ui(events::UiEvent::ToolCall { .. }) => true,
AppEvent::Rpc { method, params } if method == "session/update" => {
params
.get("update")
.unwrap_or(params)
.get("sessionUpdate")
.and_then(serde_json::Value::as_str)
== Some("tool_call")
}
AppEvent::Rpc { method, params } if method == "session.event" => {
params
.get("event")
.unwrap_or(params)
.get("type")
.and_then(serde_json::Value::as_str)
== Some("tool/call")
}
_ => false,
}
}The predicate recognizes both the current ACP shape and a compatibility event shape, but both become the same frame-order guarantee.
It does not sleep or animate. It changes queue-draining policy so the semantic pending state is observable for one real frame.
The composer needs an editor model and a layout model
Martty delegates editing operations to ratatui-textarea: insertion, cursor movement, selection, undo-friendly buffer semantics, and glyph wrapping. The widget does not expose the full screen-cell map required for mouse hit testing and drag selection, so Martty builds a LayoutMap using the same wrap rules. That is a deliberate adapter, not a second editor implementation.
LayoutMap records each soft-wrapped row and every grapheme's character range, starting display column, and display width. A click on the left half of a wide grapheme maps before it; the right half maps after it. Blank cells snap to row end, and rows beyond the buffer snap to total character count. The map is invalidated whenever the textarea mutates and rebuilt for the current wrap width.
Bytes, characters, graphemes, and cells are four different units
Rust string indices are byte offsets, editor cursors commonly use character offsets, user-perceived clusters are graphemes, and terminal layout uses display cells. ASCII hides the difference. CJK characters normally occupy two cells; combining marks may add characters without adding width; emoji sequences can contain several code points; tabs advance to the next tab stop instead of having a fixed width.
Martty iterates grapheme clusters with unicode-segmentation and computes cell width with unicode-width. Wrapping happens before a grapheme that would overflow the row, except that a grapheme wider than the entire row stands alone. Tests cover CJK, emoji, combining marks, tabs, narrow widths, midpoint hit testing, and drag endpoints. Without those cases, mouse selection can appear correct in English while slicing or jumping inside real user text.
Scroll position must be anchored to content, not stale rows
Long agent turns continuously change document height. If the user is following the tail, new content should keep the viewport at the bottom. If the user has scrolled upward to inspect an earlier tool, new updates should not steal the viewport. Martty models that distinction explicitly instead of always setting scroll to the latest row after every event.
Resize makes raw row offsets unstable because Markdown, code, and wide glyphs rewrap. The renderer measures content for the new width, clamps offsets, and preserves the user's semantic position as closely as possible. Overlays and docks are allocated before transcript width is finalized, so opening a right navigation rail can reflow the conversation without writing over it.
Input routing is a priority stack
The same key can mean different things depending on state. Escape may close an overlay, clear a selection, leave a menu, cancel a running turn, or do nothing. Enter may confirm a permission, choose a completion, insert a newline, or submit a prompt. Handling keys as a flat match table causes background surfaces to react underneath a modal.
Martty routes input from the most specific active surface outward: terminal-auth handoff, approvals, overlays and menus, composer selection and completion, transcript navigation, then global commands. Mouse coordinates are tested against the rectangles produced by the latest frame. This makes focus and hit testing products of layout state rather than duplicated constants in event handlers.
Plugins contribute semantic nodes, never Ratatui widgets
Martty's Client plugins run in Node and inject services such as tuiTheme, tuiSlots, tuiCommands, and tuiOverlay. A slot contribution is a validated TuiNode tree with text, grouping, emphasis, and actions. The Rust side receives monotonic-revision snapshots and decides how those nodes fit the terminal. The plugin does not receive the Frame, Rect, terminal size, or raw-mode handle.
This is more restrictive than exposing a widget trait, and that is the point. A JavaScript package can add a right rail or composer dock without controlling cursor state, escape sequences, or the global render loop. Conversation slots remain closed so plugins cannot fabricate durable session history. The transport methods in src/cordis.rs are compositor-private plumbing, not a public imperative drawing API.
Ratatui is the crate; Ratatouille is the typo
The Rust terminal UI library is Ratatui. Search queries sometimes contain “Ratatouille,” the film title and a common autocorrect result. Martty does not use a separate Ratatouille UI framework. The relevant dependencies and APIs are Ratatui plus supporting crates such as ratatui-textarea, unicode-segmentation, and unicode-width.
The naming correction matters when debugging. Searching crate documentation or compiler errors for Ratatouille produces unrelated results, while Ratatui documentation explains buffers, layouts, widgets, and terminal backends. In this codebase, the higher-level design still belongs to Martty: Ratatui provides rendering primitives, not ACP sessions or plugin lifecycle.
Verify semantics before visual polish
Unit tests should first prove state transitions and layout math: tool replacement, immediate frames, cancellation, Unicode mapping, history, selection, and resize clamping. Snapshot or frame-dump tests then prove that the same state renders correctly at known terminal sizes. A live terminal test is still necessary for raw mode, mouse capture, clipboard behavior, and shutdown restoration.
Martty exposes --dump-frame WIDTHxHEIGHT for deterministic noninteractive rendering and --demo for scripted terminal behavior. The final acceptance is a real ACP session: submit a prompt, observe a pending tool frame, resize during streaming, scroll away from the tail, answer a permission request, and exit. A green Rust build alone cannot prove those interactions.
cargo test --lockedmartty --dump-frame 120x36martty --demoDiagnosis
Failure modes worth distinguishing
Start with the first check in the same row. Each symptom can look similar on screen, but it belongs to a different ownership or state boundary.
| Symptom | Likely cause | First check |
|---|---|---|
| A fast tool appears only after completion | The event loop drains start and result before rendering | Trace event_requires_immediate_frame and frame scheduling |
| Clicking after a CJK glyph moves the cursor backward | Hit testing uses character count instead of display-cell width | Run LayoutMap CJK and wide-grapheme midpoint tests |
| Streaming output snaps the user back to the bottom | Scroll state does not distinguish follow-tail from manual inspection | Inspect the anchor flag before applying transcript growth |
| A plugin corrupts the terminal or overlaps the composer | The plugin received imperative drawing or terminal coordinates | Require validated TuiNode snapshots and keep layout in Rust |
| Resize duplicates or loses transcript text | Rendered rows were stored as source state | Rebuild rows from semantic nodes at the new width |
Reproduce it
Exercise the renderer at three levels
cargo test --locked composermartty --dump-frame 120x36martty --demoExpected evidence
- Composer tests preserve grapheme boundaries and map CJK, emoji, combining marks, tabs, and narrow rows to correct character offsets.
- Frame dumps are deterministic at fixed dimensions and do not write outside allocated regions when docks or overlays are present.
- A live run shows a pending tool state before completion, preserves manual scroll during streaming, reflows on resize, and restores the terminal on exit.
Questions
Common implementation questions
Why use Rust and Ratatui for an agent TUI?
They provide explicit frame rendering, predictable memory and process behavior, strong Unicode tooling, and direct control over terminal lifecycle. Agent and plugin ownership can still remain outside Rust.
Is Ratatouille a Rust TUI framework?
No. The crate is Ratatui. Ratatouille is a common typo or reference to the film.
Why not let plugins return Ratatui widgets?
That would couple untrusted package code to renderer internals and terminal authority. Semantic node snapshots preserve validation, layout ownership, and cross-process compatibility.
Why mirror the textarea layout?
The editor owns text mutation, but Martty also needs cell-accurate mouse hit testing. LayoutMap mirrors the widget's glyph-wrap rules without reimplementing editing behavior.