- Enabled DEC 2026 for Alacritty/VS Code and via TERM_FEATURES Sy token. - Stopped blanket-disabling SSH for recognized direct terminals. - Made the DECRQM probe enable sync on a positive report, not just disable. - Extracted synchronizedOutputUserOverride so opt-out beats force-on.
22 KiB
TUI core renderer — invariants & failure modes
What you are dealing with before you touch the rendering engine. This is the
companion to tui-runtime-internals.md: that doc
maps the flow (input → component tree → render); this doc explains what
does not work, why it keeps breaking, and the invariants you must not
violate. Scope is the core engine only:
packages/tui/src/tui.ts— render planner, intent emitters, native-scrollback bookkeeping, cursor placement.packages/tui/src/terminal.ts—ProcessTerminal, capability probes, private-CSI reassembly.packages/tui/src/terminal-capabilities.ts—TERMINALprofile, ED3 risk / sync-output / DECCARA / image detection.packages/tui/src/stdin-buffer.ts— escape-sequence reassembly.packages/tui/src/utils.ts— width/slice/wrap (the width model).packages/tui/src/kitty-graphics.ts+components/image.ts— inline images.packages/tui/src/deccara.ts— rectangular-fill optimizer.
Application-layer renderers (transcript, tool calls, session tree, editor,
widgets) are out of scope — they live in packages/coding-agent.
1. The one thing to understand first
The renderer cannot observe the terminal's scroll position on most hosts it runs on. Every decision about rewriting native scrollback is therefore a guess, and the guess has two opposite failure modes that cannot both be avoided by a single policy.
We keep our transcript on the normal screen. We deliberately have not moved
the engine to the alternate screen: alt-screen would make the terminal handle
viewport isolation, but the transcript/resume affordances would disappear with
the alternate buffer. Keeping the normal screen means
we own native scrollback, which means we must decide, per frame, whether it is
safe to rebuild it. To rebuild history we emit xterm ED3 (CSI 3 J, erase
saved lines). Deciding when ED3 is safe requires knowing whether the user has
scrolled up — and we usually can't:
- ConPTY hosts (Windows Terminal, Tabby, Hyper, VS Code, conhost): the pseudo-console buffer is pinned to the visible grid, so any "am I at the bottom?" console query answers "yes" even when the reader scrolled up. The probe lies.
- POSIX terminals: there is no scroll-position API at all. The probe is absent.
So Terminal.isNativeViewportAtBottom() returns true / false / undefined,
and undefined ("unknown") is the common case. The whole renderer is built
around not trusting undefined.
The two-way bind
| If you guess… | …and you're wrong | Symptom |
|---|---|---|
Eager (rebuild now → emit CSI 3 J) |
reader was scrolled up | YANK to top + FLASH on terminals that snap scroll on ED3 |
| Defer (emit nothing, reconcile later) | viewport really was at the bottom | CORRUPTION (stale/duplicated rows) + invisible-until-resize |
Yank, flash, and buffer corruption are the same bug wearing three masks. Historically, every fix that suppressed one mask for one terminal class re-enabled the opposite mask for a neighbouring class, and the follow-on complaint landed within a day. If you "fix flashing" by making rebuilds more eager, you will reintroduce yank. If you "fix yank" by deferring more, you will reintroduce corruption / invisibility. Do not move this lever without the fidelity harness (§9) green.
2. The render-intent planner (what you are editing)
#doRender is split into a planner (#planRender) that classifies a frame
into exactly one RenderIntent, and one #emit* method per intent that owns
the bytes written and the state update. All state flows through a single
#commit checkpoint at the end of every emitter. The intent union
(tui.ts, search type RenderIntent):
| Intent | Emits | When |
|---|---|---|
noop |
cursor only | nothing visible changed |
initial |
clear viewport, paint transcript, keep prior shell scrollback | first paint after start() |
sessionReplace |
clear viewport + ED3 (outside multiplexers) | caller forced { clearScrollback: true } (switch/branch/reload/resume) |
historyRebuild |
clear viewport + ED3 (outside multiplexers) | geometry change rewrapped history, or a proven-at-tail rebuild |
overlayRebuild |
rebuild viewport with overlay composite | overlay visibility changed |
liveRegionPinned |
relative moves + per-row rewrite/suffix-clear + \r\n |
foreground streaming on an ED3-risk host, commit-as-you-go |
viewportRepaint |
rewrite the visible viewport in place (optional appendFrom tail first) |
safe non-destructive repaint |
deferredShrink |
padded viewport repaint, history left dirty | bottom-anchored shrink, viewport unobservable |
deferredMutation |
zero bytes, history left dirty | row-reindexing edit while possibly scrolled |
shrink / diff |
trailing-row clear / changed-line diff | ordinary in-place updates |
ED3 (CSI 3 J) is emitted in exactly one place — #emitFullPaint when
clearScrollback: true (\x1b[2J\x1b[H\x1b[3J). The ordinary clear is
non-destructive: \x1b[22J (copy-screen-to-scrollback, only when
TERMINAL.supportsScreenToScrollback) then \x1b[2J\x1b[H, no 3J. ED3 is
reached only by sessionReplace/historyRebuild/overlayRebuild, and those
suppress the scrollback clear inside multiplexers (isMultiplexerSession() =
TMUX || STY || ZELLIJ).
The predicate gates
Three private predicates encode the guessing policy. Do not "simplify" them — each branch is load-bearing:
#canReplayNativeScrollbackAtCheckpoint(atBottom)→atBottom === true. A rebuild at a keystroke checkpoint (prompt submit) is allowed only with a positive at-tail proof. A prompt submit is no longer treated as implicit proof for an unobservable host.#canRebuildNativeScrollbackLive(atBottom, allowUnknown)→trueiffatBottom === true, or (atBottom === undefined && allowUnknown && platform !== "win32"). i.e. live ED3 during streaming requires either proof or an explicit direct-user-input opt-in, and never on win32.#nativeViewportIsScrolled(atBottom, allowUnknown)→trueifatBottom === false, or (undefined && win32 && !allowUnknown). Used to decide deferral.
allowUnknownViewportMutation is the direct-user-input opt-in (autocomplete
/ IME / a keystroke the user just typed). A keystroke pins the host viewport to
the bottom, so it is safe to repaint live then. It is not set by passive
streaming. setEagerNativeScrollbackRebuild(true) is the streaming opt-in; on
ED3-risk hosts it is downgraded so it never promotes to a live ED3 clear.
Deferral + checkpoint discipline
When the viewport is unobservable during passive streaming, the planner
defers (deferredMutation/deferredShrink/viewportRepaint) and marks native
scrollback dirty (#markNativeScrollbackDirty()). Reconciliation happens later
at a checkpoint via refreshNativeScrollbackIfDirty() — and only if
#canReplayNativeScrollbackAtCheckpoint proves at-tail. The streaming-defer +
live-region-pin seam (NativeScrollbackLiveRegion,
getNativeScrollbackLiveRegionStart / getNativeScrollbackCommitSafeEnd) is the
actively-churning part of the engine; if you change how transient rows are
committed, every structural-mutation branch (shrink and grow/offscreen-edit)
must defer symmetrically, or you reopen the corruption family.
3. The five fault families
YANK — viewport snapped to top — NOT fully converged
- Mechanism: a live
historyRebuildfiresCSI 3 Jwhile the reader is scrolled up; ED3-snap terminals reset the visible viewport to the top of the (now-erased) scrollback. - Trigger to avoid: treating an unobservable probe as "at bottom" during passive streaming, or OR-ing an eager-streaming flag into the live ED3 path.
- Current stance: never emit ED3 on an unobservable host during passive streaming; defer and reconcile at a keystroke checkpoint. ConPTY/win32 never trust the probe at all.
CORRUPTION — duplicated / stale rows — NOT fully converged
- Mechanism: the flip side of the yank fix. A deferred/repainted frame leaves rows already committed to native scrollback out of sync with the live viewport; the scrollback↔viewport seam duplicates (e.g. a 2-row dup, a streaming-tail dup, or an async-expansion dup).
- Trigger to avoid: repainting the viewport over scrollback that still holds the old copy; a frozen/deferred block whose snapshot no longer matches after the region above it reflowed; one mutation branch deferring while its mirror branch repaints.
- Current stance: commit only the stable prefix line-count to native history; keep unstable rows out; reconcile drift at the checkpoint; park the hardware cursor at real content bottom, not padded bottom.
FLASH (and invisible-until-resize) — NOT fully converged
- Two distinct causes, one symptom:
- Flash = eager ED3 rebuild wrapped in DEC 2026 BSU/ESU fired per streaming frame on a terminal that clamps scroll on ED3 (VTE/GNOME family).
- Invisible-until-resize = the defer fix over-firing, so a structural frame
emits zero bytes (
deferredMutationreturns nothing) until a resize forces a repaint.
- Trigger to avoid: env-detection that misses a flashing terminal (SSH
strips
VTE_VERSION; some hosts set no distinguishing var); collapsing anundefinedprobe into a definite scrolled/at-bottom verdict. - Current stance: confine ED3 to the destructive path; auto-disable DEC 2026
at runtime when the terminal reports it unsupported (DECRQM), with
PI_NO_SYNC_OUTPUTas a manual hatch; keep autowrap discipline regardless.
WIDTH — measurement crashes / fidelity — crash class dead, accuracy unproven
- Mechanism: the measured column width of a line disagreed with the terminal's painted cells (emoji, wide graphemes, combining marks, Hangul jamo), and the old render loop threw on any mismatch — a 1-cell cosmetic error became a fatal whole-agent crash.
- Current stance: never throw in the render hot path — clamp. The loop
truncates over-wide lines with
truncateToWidth/sliceByColumnand logs (under debug) instead of dying. Width is owned end-to-end by one native UAX#11 engine shared by measure/slice/wrap (see §6). Accuracy across all scripts (e.g. RTL/combining marks) is still not proven by a green gate.
PROBE — stray bytes injected as keystrokes — RESOLVED
- Mechanism: a private-CSI probe reply (DA1 / kitty / mode 2031) split across a stdin flush; the unmatched prefix was dropped and the continuation bytes were forwarded as keystrokes.
- Current stance: buffer-and-reassemble partial CSI responses; give each probe a typed sentinel owner. This is the one cleanly-closed family — because its contract is bounded and observable (bytes in = bytes out), unlike the unobservable-viewport families. See §7.
4. Invariants — MUST / NEVER
These are the rules the recurrence taught us. Treat them as load-bearing.
- NEVER add a new
CSI 3 J(ED3) callsite. ED3 must flow only through#emitFullPaint({ clearScrollback: true }), for the existing destructive intents (sessionReplace, proven/safehistoryRebuild,overlayRebuild). Ordinary redraws use the non-destructive\x1b[22J+\x1b[2J\x1b[Hclear. - NEVER trust an unobservable viewport probe (
undefined) for passive streaming. Only a positive at-tail proof, or a direct-user-input opt-in (allowUnknownViewportMutation), authorizes a live rebuild — and never on win32/ConPTY. - NEVER throw in the render hot path. Clamp over-wide lines; a width mismatch is cosmetic, not fatal.
- NEVER let a defer path emit a structurally-changed frame as zero bytes
while at the bottom — that is invisible-until-resize.
deferredMutation/deferredShrinkare only safe when the viewport is (or may be) scrolled. - Defer symmetrically. If one structural-mutation branch (shrink) defers on an unobservable ED3-risk host, the mirror branch (grow / offscreen-edit) must too. Asymmetry reopens corruption.
- Commit only the stable prefix to native history. Transient/unsettled rows stay out of scrollback until a checkpoint; reconcile drift at the checkpoint.
- Park the hardware cursor at real content bottom, not the padded viewport bottom, or height shrinks scroll live rows into scrollback and duplicate them per resize step.
- Cursor writes live inside the synchronized-output frame, before ESU — never as a second frame after it (that teleports/blinks the caret).
- Detect terminal risk, not terminal brand, and default unknown to risky. Env sniffing is necessarily incomplete (see §5); never assume an un-enumerated host is safe.
- Multiplexers (tmux/screen/zellij) get no destructive scrollback clear and no viewport probe. ED3 is a no-op there and a full replay duplicates the transcript; repaint in place and rely on the pinned/commit-as-you-go path.
- Any change to the eager/defer lever, the predicates, or the live-region
seam must be validated by the render-stress fidelity harness (§9) across
{win32, POSIX} × {unknown, scrolled, at-bottom}, not by a single-terminal smoke test.
5. Terminal capability detection (and why it is fragile)
TERMINAL (terminal-capabilities.ts) is resolved once at import from
TERMINAL_ID plus environment sniffing. The detection helpers are pure and
parameterized over (env, platform) so they are unit-testable:
detectTerminalEagerEraseScrollbackRisk(env, platform)→ is a live ED3 rebuild unsafe here? Current policy:falseon win32 (dedicated ConPTY deferral paths handle it) and whenPI_TUI_ED3_SAFE=1; otherwisetrueforWT_SESSION(WT fronting WSL), SSH/tmux/screen/zellij, known ED3-snap/scrollback-clearing terminals (WezTerm, kitty, ghostty, alacritty, VTE, iTerm2, Apple Terminal, GNOME Terminal, Ptyxis, xfce4-terminal), Linux truecolor, and every other unknown POSIX terminal. The default is risky on purpose.shouldEnableSynchronizedOutputByDefault(env, id)→ DEC 2026 default. Precedence: user opt-out (PI_NO_SYNC_OUTPUT/PI_TUI_SYNC_OUTPUT=0) → user force-on (PI_FORCE_SYNC_OUTPUT=1/PI_TUI_SYNC_OUTPUT=1) →TERM_FEATURESadvertisesSy→WT_SESSION(WT/WSL) → known direct terminals (kitty/ghostty/wezterm/iterm2/alacritty/vscode; SSH passes through) → off for risky multiplexers and everything else (VTE-family, GNU screen, Apple Terminal, legacy conhost, unknown). Reconciled at runtime by the DECRQM mode-2026 report: a positive report enables sync (upgrading default-off muxes like zellij/tmux-master), a negative one disables it; a user override still wins.synchronizedOutputUserOverride(env)is the shared opt-out/force resolver.detectRectangularSgrSupport(id, env)→ DECCARA fills: kitty only (ghostty does not implement the SGR-background extension), off in multiplexers and underPI_NO_DECCARA.
Why this keeps leaking: terminal class is inferred from env vars that are
not durable. VTE_VERSION is stripped by sshd (default AcceptEnv);
COLORTERM is also not in default AcceptEnv; some hosts (Tabby) set no
distinguishing var; WSL-fronting-WT is neither pure win32 nor pure POSIX. Every
missed env var is a missed terminal class is a new complaint. The mitigations
are: (a) default unknown to risky rather than safe, and (b) detect by
behavior/handshake (DECRQM) where possible rather than a host allow-list. When
you add a terminal, add it to the pure detector and add the SSH-stripped env
shape to the test, not just the env-present shape.
6. Width model
visibleWidth / truncateToWidth / sliceByColumn / wrapTextWithAnsi
(utils.ts) all route through one native UAX#11 engine (@oh-my-pi/pi-natives,
Rust unicode-width). We deliberately dropped Bun.stringWidth because it
disagreed with the engine on combining marks and jamo, and mixing two width
models in measure-vs-slice produced the crashes.
- Fast path: printable ASCII is one cell per code unit.
- ZWJ pictographic emoji take the
visibleWidthByGraphemeoverride (ANSI spans excised first, thenIntl.Segmenter), because the native scanner double-counts SGR bytes when a sequence is split by the segmenter. - OSC 66 sized text (
\x1b]66;…) takes the native path.
Rule: if you add a code path that measures width, route it through these
helpers. Never reintroduce Bun.stringWidth or a parallel width table — the
measure model and the slice/wrap model must agree, or you get over-wide lines
that the hot-path clamp silently truncates (cosmetic loss) or, worse, seam
duplication.
7. Capability probes & stdin reassembly
ProcessTerminal fuses capability queries with a bare DA1 (CSI c) sentinel so
a non-answering terminal is detected when DA1 returns first. Replies can arrive
split across a stdin flush, so:
#privateCsiResponseBufferaccumulates\x1b[?…partials while a sentinel is outstanding, rejoins on the terminator byte (0x40–0x7e), then runs the DA1/kitty/mode-2031 handlers on the complete reply. A new\x1bmid-reassembly or >256 bytes abandons the partial so real keys (e.g. arrow\x1b[A) still reach input.#da1SentinelOwnersis a typed FIFO discriminated bykind(keyboard,osc11,privateMode,kittyGraphicsProbe,osc99Probe) so a keyboard DA1 cannot be mistaken for an OSC 11 / DECRQM / graphics-probe sentinel.- DECRQM probes (
#queryPrivateMode(2026/2048/2031)) record support via DECRPM and drive runtime feature gating (e.g. auto-disabling DEC 2026 sync output).
Rule: any new probe must own a typed sentinel and survive a split reply. The contract is bytes-in = bytes-out; it is testable, so test it (feed the reply byte-by-byte and assert nothing leaks to the input handler).
8. Inline images & memory
Kitty images are transmit-once, place-many (kitty-graphics.ts):
encodeKittyTransmit (a=t, keyed by a stable i=) writes the base64 a single
time; repaints emit only encodeKittyPlacement (a=p). Text clears
(CSI 2 J / CSI 3 J) do not purge the terminal's image store — only
encodeKittyDeleteImage (a=d,d=I) does. ImageBudget (components/image.ts)
keeps only the most-recent N images live; demoted images render their text
fallback and are explicitly purged.
Rule: never re-emit full base64 per frame (it pegged RAM and pinned the UI
thread). Kitty Unicode placeholders are default-on only for kitty/ghostty
(PI_NO_KITTY_PLACEHOLDERS / PI_KITTY_PLACEHOLDERS); other Kitty-protocol
hosts render placeholder cells as literal PUA glyphs, so they fall back to
direct a=p placement.
9. The fidelity gate (use it)
packages/tui/test/render-stress-harness.ts renders the renderer's real emitted ANSI into
a ghostty-web VirtualTerminal and asserts viewport fidelity (a scrolled reader
stays put), background-column fidelity, and scrollback-buffer fidelity, across
parameterized terminal shapes and randomized op sequences.
This harness is the structural fix for the whole recurrence: every guess-flip and
sniffing-gap regression historically shipped blind and was caught by a user,
because no automated "a scrolled-up reader stays pinned across kitty/WT/WSL/
ConPTY" assertion gated CI. Before you change the eager/defer lever, a
predicate, the live-region seam, or width math, run the stress harness and the
targeted repro tests (packages/tui/test/render-regressions.test.ts,
packages/tui/test/streaming-scrollback-defer.test.ts, the issue-*-repro.test.ts files).
A change that passes one terminal and one seed is not verified.
10. Escape hatches (env vars)
| Var | Effect |
|---|---|
PI_NO_SYNC_OUTPUT=1 |
Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on). For terminals that advertise but mishandle mode 2026. |
PI_TUI_SYNC_OUTPUT=0|1 / PI_FORCE_SYNC_OUTPUT=1 |
Force sync output off / on. |
PI_TUI_ED3_SAFE=1 |
Declare the terminal safe for live ED3 (disables eagerEraseScrollbackRisk). |
PI_NO_DECCARA |
Disable Kitty DECCARA rectangular-fill optimization (force padded-string fills). |
PI_FORCE_IMAGE_PROTOCOL=kitty|iterm2|sixel|off |
Override image protocol detection. |
PI_NO_KITTY_PLACEHOLDERS=1 / PI_KITTY_PLACEHOLDERS=1 |
Force Kitty Unicode placeholders off / on. |
PI_CLEAR_ON_SHRINK=1 |
Clear empty rows when content shrinks (default off). |
PI_HARDWARE_CURSOR=1 |
Show the real hardware cursor instead of a rendered one. |
PI_NOTIFICATIONS=off|0|false |
Suppress terminal notifications. |
PI_DEBUG_REDRAW=1 |
Log the chosen render intent per frame to the debug log. |
PI_TUI_DEBUG=1 |
Dump per-render diff state under /tmp/tui. |
11. Before you touch the render core — checklist
- Are you about to emit
CSI 3 Janywhere other than the destructiveclearScrollbackpath? Stop. - Does your change trust
isNativeViewportAtBottom() === undefinedas "at bottom" during passive streaming? Stop. - Did you change one structural-mutation branch without mirroring its sibling (shrink ↔ grow)? Defer symmetrically.
- Could any frame now emit zero bytes while the viewport is at the bottom? That's invisible-until-resize.
- Did you add a terminal by brand instead of by behavior, or skip the SSH-stripped env shape in the test?
- Did you run
packages/tui/test/render-stress-harness.ts+ the repro suite across win32/POSIX × unknown/scrolled/at-bottom — not just one terminal? - New probe? Typed sentinel owner + split-reply test.
- New width path? Routed through the shared native engine, clamped (never thrown) in the hot path.