0020 — Layered render: ratatui chrome over libghostty pane interiors
0020 — Layered render: ratatui chrome over libghostty pane interiors
TL;DR. phux-client uses two disjoint renderers in one process: ratatui for chrome (status bar, dividers, borders, overlays) and libghostty for pane interiors. ratatui marks pane rectangles skip=true so they carve out holes; pane bytes layer into those holes from a client-side Terminal mirror. SGR resets at every transition, one renderer positions the cursor, and ratatui imports are confined to render/.
Status: Accepted Date: 2026-05-27
Context
phux-client paints its frames as raw VT bytes to stdout. Today every
piece of UI — pane interiors, dividers, the status bar — is hand-rolled
cell positioning under crates/phux-client/src/attach/. The hot path
lives in paint.rs (paint_full_frame, paint_focused_pane),
multi-pane composition lives in multi_pane.rs, divider drawing lives
alongside it, and the bottom row is owned by status_bar.rs. Two recent
commits made the friction explicit: 34bfc07 collapsed four
near-duplicate paint paths down to paint_full_frame +
paint_focused_pane (the bespoke geometry was already at the duplication
threshold a year before any overlay shipped); ed84431 reserved a row
for the status bar after multi-pane output stomped the bar and the
cursor (a regression that only existed because the bar and the panes
were sharing the same renderer’s idea of where the cursor was supposed
to live).
What’s coming makes the friction worse, not better: a help screen overlay, a command palette, a session/window picker, eventually a tab strip — all chrome the substrate now expects the client to grow. Every one of those, under the current model, is a new hand-rolled cell budget, new cursor handoff code, and a new place where SGR state can leak. The cost of “one more piece of chrome” is linear in chrome features, and we’re about to grow chrome features.
This is a client-internal decision. The wire is untouched, byte content is untouched, ADR-0013 stands; the TUI’s design space stays the TUI’s per ADR-0017. What changes is how phux-client composes its own frames.
Decision
phux-client adopts a hybrid layered render. Two renderers, disjoint regions, layered (not interleaved):
-
Chrome layer — ratatui. The status bar, dividers between panes, pane borders/focus indicators, and all overlays (help screen, command palette, session picker, future modals) are drawn by ratatui widgets composed against ratatui’s
Layout. ratatui’sBufferandcrosstermbackend produce the chrome’s bytes. -
Pane interiors — libghostty. Each pane’s cells continue to come from a client-side
libghostty_vt::Terminalmirror fed by serverPANE_OUTPUTbytes (the ADR-0013 path). Pane interiors are emitted as VT bytes positioned directly to stdout, exactly aspaint.rsdoes today; theRenderStateper-row dirty tracking that powerspaint_focused_panestays intact. -
Boundary — ratatui
Cell::skipcarve-outs over pane rectangles. ratatui draws the chrome around the panes; pane rectangles in ratatui’sBufferare markedskip = trueso ratatui emits no bytes for those cells. Pane interiors are layered into those holes by the libghostty side after ratatui flushes the chrome.
The invariants that hold the two renderers together:
-
Dependency boundary.
ratatuiandcrosstermimports are allowed only undercrates/phux-client/src/render/. The attach loop (attach/), the pane mirror, predictive echo, layout math (layout.rs), and the server-frame handler stay ratatui-free. Enforced byscripts/check-ratatui-boundary.sh(added byphux-5ke.1), wired intojust ci. -
Region disjointness. In any rendered frame, the set of cells ratatui writes to and the set of cells libghostty pane renderers write to are disjoint. Chrome over pane interior is not allowed in the steady state; overlays are a special case handled by invariant 5.
-
SGR reset at every transition. Before chrome emits its first byte and before pane bytes resume after chrome finishes, an
\e[0mreset is emitted. Neither renderer assumes the other’s active style. -
Cursor ownership. Exactly one renderer positions the cursor per frame. The chrome layer parks the cursor at a known sink position at end-of-frame; the pane layer (after
paint_focused_panecompletes) positions the cursor at the focused pane’s logical cursor. There is no “shared” cursor state. -
Overlay-active state. While an overlay is up (help screen modal, command palette, etc.), pane-stdout flushing pauses — the client does not emit VT bytes for pane interiors. Server
PANE_OUTPUTbyte consumption into the client-sideTerminals continues normally; the mirror stays current. On overlay dismiss, the client triggers a full repaint (paint_full_frame-equivalent over the freshly-uncovered region) and pane stdout flushing resumes.
Consequences
Positive
-
libghostty feature parity stays automatic on the pane hot path. Kitty graphics, sixel, OSC 8 hyperlinks, modern key protocol, and whatever Ghostty merges next continue to pass through cell-for-cell via
vt_writeon the clientTerminal. None of it touches ratatui. -
RenderStateper-row dirty tracking survives. The local-side incremental redraw that ADR-0013 buys us continues to power pane painting; only the chrome region is touched by ratatui, and chrome dirty regions are ratatui’s own problem. -
Chrome velocity goes up. A new overlay is a ratatui widget plus a region carve-out — minutes, not days. The command palette, session picker, and help screen all become composable widgets in the same module, not three separate cell-positioning rewrites.
-
The wire does not move. ADR-0013 bytes-on-the-wire is preserved; ADR-0017’s “TUI is one consumer among several” stays unbroken; an agent SDK consumer or future native GUI sees no change at all. Nothing about layered render leaks across the substrate seam.
-
Layout math collapses. ratatui’s
Layout::splitreplaces the divider arithmetic inmulti_pane.rs. Pane rectangles are computed once, fed both to the chrome layer (as carve-out regions) and to the libghostty pane painters (as their target rects). -
The boundary is mechanically extractable. If a future headless client (recorder, smoke-test driver) or a GUI client ever ships, pulling them out is a matter of replacing
render/with a different chrome implementation. Everything outsiderender/already doesn’t know ratatui exists.
Negative
-
Two renderers in one process. Chrome and pane interiors are drawn by two different paths emitting to the same stdout. The cursor / SGR handoff invariants in the Decision section are real invariants — get them wrong and the status bar inherits the focused pane’s bold red, or the cursor blinks under the divider.
ed84431is the cautionary precedent. -
ratatui dep weight. ratatui itself is ~30k LOC; it pulls crossterm. Compile time grows; binary size grows. Acceptable: the alternative is hand-rolling the equivalent layout, widget, and buffer-diff code, and ratatui has years of polish over what we would write.
-
Overlay-active state machine. Invariant 5 introduces a small state machine in the attach loop (“are we currently flushing pane bytes to stdout?”) that the single-renderer model didn’t need. Mitigated by the consume-but-don’t-flush split: byte ingestion into the local
Terminalmirror is decoupled from byte emission to stdout, which we already wanted for predictive echo anyway. -
Two cursor authorities to keep coordinated. Cursor blinking, shape, and visibility are libghostty-pane state; chrome may want to hide the cursor outright (modal overlays). Reconciling on every frame is mechanical but a new responsibility.
-
A subtle perf cost: the chrome layer’s
Buffer::diffruns every frame even if only one pane scrolled. The cost is bounded by the chrome cell budget (typically one status row + dividers << total cells), so well under the dominant pane-redraw cost — but it’s not free.
Rejected alternatives
1. ratatui-ghostty for pane interiors (lossy widget bridge)
ratatui-ghostty exists; it bridges a parsed VT buffer into a
ratatui widget. Using it for pane interiors would unify the renderer
to one layer and remove the boundary invariants entirely. We
rejected it.
The cost is fidelity. The bridge collapses libghostty’s cell model
into ratatui Cells — and ratatui Cell has no place for Kitty
graphics, sixel, OSC 8 hyperlinks, or the modern key protocol’s
keyboard flags. Those features are exactly the ones
ADR-0013 preserved on the wire
so they would automatically appear on the client. Routing pane
content through ratatui-ghostty re-introduces the same translation
treadmill ADR-0013 walked away from, in the renderer instead of the
wire. The bridge is the right tool for pane thumbnails — a
session picker showing 8×24 previews of every pane — where fidelity
genuinely doesn’t matter and what we want is a ratatui-composable
mini-grid. We will use it there. Not on the hot path.
2. rmux-style structured cell snapshots on the wire
Some multiplexers ship structured cell snapshots from server to
client and let each client compose its own renderer freely on top.
We rejected this in ADR-0013
and re-rejecting it here is mechanical: it directly contradicts the
bytes-on-the-wire decision and would re-introduce the
protocol-evolution treadmill where every libghostty feature
(graphics, sixel, hyperlinks, key flags) needs a structural
representation in phux-protocol before it can reach a client. The
cost-model argument from ADR-0013 §“The cost-model correction”
applies unchanged. Out of scope for this ADR; preserved here as a
back-pointer for future readers wondering why phux didn’t take the
“obvious” path.
3. All-ratatui with libghostty as a buffer source
A milder version of (1): keep libghostty parsing VT into a grid
server-side and client-side, but have ratatui pull cells out of the
client Terminal via grid_ref() and draw the entire frame
through ratatui. One renderer, one cursor authority, no boundary
invariants.
Rejected because the lossy bridge is still the cost — the moment a
cell carrying a Kitty graphics blit or a sixel attachment passes
into ratatui’s Cell, the blit is gone. The fidelity loss is the
same as alternative 1, just relocated. Worse, we’d also forfeit
RenderState::update’s per-row dirty tracking, because ratatui’s
Buffer::diff is a different incremental model that doesn’t know
which libghostty rows are clean. The result would be a slower,
lossier render than the status quo.
4. Status quo — hand-rolled cell positioning forever
Keep paint.rs, multi_pane.rs, status_bar.rs as the model.
Build overlays the same way: bespoke cursor math, bespoke SGR
discipline, bespoke region tracking. We rejected this because it
doesn’t address the motivating problem. Overlay velocity is the
chrome we want to build over the next two milestones; the status
quo makes each new piece of chrome a fresh exercise in the same
geometry primitives. 34bfc07 already collapsed the four paint
paths once; the next overlay would re-introduce a fifth, and so
on. The cost of bespoke chrome math grows linearly in chrome
features, exactly when we want it to grow sub-linearly.
Boundary enforcement
scripts/check-ratatui-boundary.sh (introduced by phux-5ke.1)
greps the workspace for ratatui:: and use ratatui outside of
crates/phux-client/src/render/ and exits non-zero on any match.
It runs in just ci and gates merges.
The reason this matters is not aesthetic. The multiplexer logic (attach loop, server frame handling, pane mirror, predictive echo, layout math) needs to remain portable to renderers that aren’t ratatui — a future GUI consumer, a headless smoke-test client, or an inspection tool. If ratatui types leak into those modules, the extraction stops being mechanical and becomes a rewrite. Holding the line in CI keeps the extraction price flat as the client grows.
Implementation epic
Tracked under bd epic phux-5ke — phux-client
layered render. Children:
phux-5ke.1—render/module scaffolding, workspaceratatuidependency gated tophux-client::render, CI grep guard, ARCHITECTURE.md note.phux-5ke.2— Migrateattach/status_bar.rsto a ratatui widget underrender/chrome/. First end-to-end exercise of the boundary; replaces a self-contained module.phux-5ke.3— Dividers and pane chrome to ratatui; introduces theCell::skipcarve-out over pane rects. Touchesmulti_pane.rsand the paint plumbing; coordinates with any in-flightdriver.rsrefactor.phux-5ke.4— Overlay framework underrender/overlay/; first overlay is the help screen. Lights up invariant 5 (the overlay-active state machine).phux-5ke.5— This ADR.
References
- ADR-0013 — libghostty bytes on the wire. Preserved unchanged; this ADR is downstream of it and changes nothing it owns.
- ADR-0017 — the reference TUI is one consumer among several with no protocol privileges. Honored: chrome composition is a TUI-internal concern; no vocabulary leaks into the wire.
- ADR-0019 — multi-pane TUI rendering. This ADR makes explicit the chrome/pane-interior split that ADR-0019 implicitly relied on (its “borders between panes” decision presumed a renderer for the borders; this ADR names that renderer ratatui).
crates/phux-client/src/attach/paint.rs— currentpaint_full_frame/paint_focused_pane. The pane-interior side of the boundary stays here; the chrome side migrates tocrates/phux-client/src/render/overphux-5ke.2..4.crates/phux-client/src/attach/multi_pane.rs— current divider drawing; migrates underphux-5ke.3.crates/phux-client/src/attach/status_bar.rs— current status bar; migrates underphux-5ke.2.- Commits
34bfc07(paint-path collapse) anded84431(status-bar row reservation fix) — the precedents that motivated this ADR. scripts/check-ratatui-boundary.sh— the CI guard (added byphux-5ke.1).