phux docs
architecture

Render layering: ratatui chrome over libghostty pane interiors

The phux TUI uses two renderers on disjoint screen regions. libghostty paints pane interiors on the hot path so kitty graphics, sixel, OSC 8, and the Kitty...

evolving document
Full source summary

The phux TUI uses two renderers on disjoint screen regions. libghostty paints pane interiors on the hot path so kitty graphics, sixel, OSC 8, and the Kitty key protocol pass through unchanged. ratatui paints the chrome (status bar, dividers, modals); the layers composite rather than interleave. Crate splits make both boundaries compiler-enforced: ratatui lives only in phux-tui.

Per ADR-0020, the TUI uses two renderers for disjoint screen regions. libghostty paints pane interiors on the hot path — kitty graphics, sixel, OSC 8 hyperlinks, and the Kitty key protocol all pass through unchanged. ratatui paints the chrome: status bar, pane dividers, borders, modals, future tab bar. The layers composite rather than interleave; chrome carves skip-cell rectangles for pane rects so libghostty owns those cells exclusively.

The ratatui dependency is scoped to a single crate, phux-tui (under src/render/, submodules chrome and overlay). The pane-interior substrate — pane mirror, predict layer, layout math, and multi-pane composition — lives in a separate crate, phux-client-core, which carries no ratatui dependency; the headless control-plane client — connection, transports, the agent verbs — lives in phux-client, which carries none either (ADR-0100). Both boundaries are therefore enforced by the compiler: a use ratatui in the substrate or in the headless library fails to build because the crate cannot name it. This replaced the original scripts/check-ratatui-boundary.sh grep guard. The attach loop lives in phux-tui (it composites chrome over panes, so it legitimately depends on the chrome, the substrate, and the headless client).

Pane interiors are cell-diffed

The pane painter (attach/render.rs) visits the rows libghostty reports dirty, but it does not rewrite them whole. Each pane keeps a front buffer: the cluster and resolved pen it last wrote to every cell of the outer terminal. A dirty row is compared against it and only the changed spans are emitted, each positioned with a CUP (or bridged by rewriting a short unchanged gap when that is fewer bytes). A full-screen animation that dirties every row every frame therefore costs about what actually changed, not a full-screen repaint (phux-esge).

The front buffer is a claim about the outer terminal, so it holds only while nothing else writes over pane cells. The disjointness invariant above keeps the steady-state chrome out of pane rects. Every exception must invalidate the front buffer through TerminalRenderer::invalidate_front (all rows) or invalidate_front_rows (some rows):

  • screen clears (the full-frame clear, the SIGWINCH clear, the full-screen overlay path), an incremental frame that failed to reach the terminal, and a frame the stdout writer dropped;
  • modal overlays and the copy-mode status strip;
  • the predictive-echo overlay, for the rows its guesses cover.

The renderer invalidates on its own for a forced paint (the full-frame path after its ED2), a moved origin or clipped extent, a replica generation change, an alternate-screen switch, a selection change, and kitty graphics replayed over the pane. An invalidated row is repainted whole the next time it is dirty, exactly as before the diff, so a missing invalidation shows up as stale cells and an extra one only costs bytes.

Status

No remaining target-versus-shipped gaps in the render split this document owns. ratatui is fenced to phux-tui; pane interiors live in phux-client-core; cell-diff painting is the hot path.

GapTodayOwnerTracked
View exact source