ADR-005: Capsule single render path
Status: Accepted
Current state: every frame is one Terminal::draw of the full widget tree encoded by SocketBackend and written by ClientWriter inside ?2026 synchronized-output brackets; handlers only mutate state and bump a frame generation; a real screen erase precedes a frame only on first attach and resize.
Date: 2026-06-10
Deciders: Operator + agent
Context
The capsule compositor accumulated three independent writers to the attach socket: Ratatui diff frames, a direct grid-patch tier for the focused live pane, and raw appends for the bottom chrome, the spawn-failure banner, dialog hyperlinks, cursor placement, and mode re-assertion. All three shared one Ratatui previous-buffer baseline that only the first updated. Any cell changed and reverted between Ratatui frames was skipped forever — the structural cause of the stale/interleaved-cell defect class (body─from─the─template, duplicated transcript blocks, the wheel-to-live empty frame), plus related cursor-over-history, black-flash, and divergent-scrollbar failures. Repaint decisions lived in scattered request flags (pending_full_redraw, pending_diff_redraw, dirty_panes, per-session repaint/chrome flags), producing the "state changed but nobody requested the right repaint" class.
The defects were symptoms of two deeper design mistakes:
- The physical terminal had become an untracked fourth model.
DamageGridknew the pane state, Ratatui knew only frames it had emitted, the patch tier knew recently dirtied pane rows, and raw append sites knew about local chrome or mode assertions. None of those models could prove what the operator's terminal actually held after interleaved writes. - Rendering policy leaked into input and event handlers. Wheel movement, focus swaps, dialogs, PTY feeds, selection, resize, and spawn failures each had to request the right repaint tier. Missing one flag could leave body, footer, cursor, and mode state disagreeing even when the underlying pane model was correct.
This is why the accepted fix is structural. The problem was not that the dirty-patch tier was poorly optimized or that one append site forgot a reset; the problem was that multiple emit paths made the client screen impossible to reason about. jackin' is a single-operator, in-container multiplexer, so correctness and debuggability matter more than minimizing bytes for the common 80x24 stream.
Decision
Collapse to one render path, derived rendering, and one writer:
- Single render path. Every frame is
Terminal::drawof the full widget tree — status bar, pane bodies (PaneBodyWidgetover borrowedGridViews), borders, shared scrollbars, bottom chrome widgets, dialogs, selection, spawn-failure banner. No grid-patch tier, no raw cell appends. Until the agent CSI passthrough is gated (PR 4 of the plan), each frame forces a full re-emit by filling the about-to-become-previous buffer with a sentinel; with the passthrough gated, the previous buffer is the true client model by construction. - Derived rendering. Handlers only mutate state; every mutation records an invalidation that bumps
frame_generation. The loop composes when the generation moved, immediately after an idle gap and at most once per cadence cap during bursts.FullRedrawReasonsurvives only as the wipe policy (\x1b[2Jprecedes the frame forFirstAttach/Resizeonly) and as the telemetry label. - One
ClientWriter. The only socket writer.write_framewraps every non-empty frame in?2026brackets so the outer terminal applies it atomically; out-of-band bytes (OSC passthrough, clipboard, pointer shapes) queue and flush only at frame boundaries. - Frame model beyond cells. Cursor visibility/position and the focused pane's modes (bracketed paste, application cursor, kitty keyboard level) are derived per frame and reconciled against the last asserted state; dialog hyperlinks are per-rect frame data the encoder brackets with
OSC 8during cell emission.
Consequences
- The stale-cell class is structurally impossible: one writer, one model, atomic frames, enforced by the echo-back conformance harness in
crates/jackin-capsule/src/daemon/tests/render_conformance.rs. - Tab switch, zoom, and dialog close repaint in place — the black flash is gone everywhere outside first attach and resize.
- Compose cost rose from the patch tier's ~20 µs to ~130 µs p95 at 80×24 under a streaming fixture (~5.8 KB/frame vs ~2.3 KB) — still far below one frame budget. Documented escape hatch if a pathological terminal size measures hot: per-pane damage may skip re-rendering clean pane widgets into the buffer — never a second emit path or writer.
- ADR-004's grid-patch encoder is retired;
PaneBodyWidgetis the only pane-body renderer.
Invariants
- Screen equals model. After a composed frame, feeding the emitted bytes into a virtual client terminal yields the same cells, attributes, wide-cell flags, and cursor contract as the focused pane model inside the pane rectangle.
- One writer.
ClientWriterowns the attach socket. New output classes must be represented as frame bytes or queued out-of-band bytes flushed at frame boundaries; no module may hold its own sender to the client. - Atomic frames. Non-empty frames are synchronized-output bracketed. Out-of-band OSC/CSI bytes do not appear in the middle of a composed frame.
- No ordinary wipe.
\x1b[2Jis legal only for first attach and resize; all other redraws repaint in place. - Modes and cursor are frame state. Cursor visibility/position/shape and focused-pane modes are derived from
DamageGridand reconciled by the encoder each frame, not asserted by focus-swap or input-handler side paths. - CSI is default-deny. Unknown agent CSI cannot repair the outer terminal behind the compositor's back. Additions to the forward allowlist require a documented sequence and reason in Multiplexer design rules.
- Shared components stay shared. Pane scrollbars render through the same
scrollable_panelfunctions as the host console and dialogs.