# ADR-005: Capsule single render path (https://jackin.tailrocks.com/reference/adrs/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 [#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.** `DamageGrid` knew 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 [#decision]

Collapse to one render path, derived rendering, and one writer:

* **Single render path.** Every frame is `Terminal::draw` of the full widget tree — status bar, pane bodies (`PaneBodyWidget` over borrowed `GridView`s), 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. `FullRedrawReason` survives only as the wipe policy (`\x1b[2J` precedes the frame for `FirstAttach`/`Resize` only) and as the telemetry label.
* **One `ClientWriter`.** The only socket writer. `write_frame` wraps every non-empty frame in `?2026` brackets 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 8` during cell emission.

## Consequences [#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; `PaneBodyWidget` is the only pane-body renderer.

## Invariants [#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.** `ClientWriter` owns 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[2J` is 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 `DamageGrid` and 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](/reference/capsule/multiplexer-design-rules/).
* **Shared components stay shared.** Pane scrollbars render through the same `scrollable_panel` functions as the host console and dialogs.
