docs(tui): documented core renderer invariants and failure modes

- Added tui-core-renderer.md covering yank/corruption/flash/width faults.
- Linked the new doc from runtime internals and the tui.ts header.
- Refreshed runtime-internals to match current boot and render-intent flow.
This commit is contained in:
can1357
2026-06-06 14:13:26 +02:00
parent 36b6f0e9b5
commit dafc6e9e50
3 changed files with 430 additions and 23 deletions
+382
View File
@@ -0,0 +1,382 @@
# 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`](./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`](../packages/tui/src/tui.ts) — render planner, intent emitters, native-scrollback bookkeeping, cursor placement.
- [`packages/tui/src/terminal.ts`](../packages/tui/src/terminal.ts) — `ProcessTerminal`, capability probes, private-CSI reassembly.
- [`packages/tui/src/terminal-capabilities.ts`](../packages/tui/src/terminal-capabilities.ts) — `TERMINAL` profile, ED3 risk / sync-output / DECCARA / image detection.
- [`packages/tui/src/stdin-buffer.ts`](../packages/tui/src/stdin-buffer.ts) — escape-sequence reassembly.
- [`packages/tui/src/utils.ts`](../packages/tui/src/utils.ts) — width/slice/wrap (the width model).
- [`packages/tui/src/kitty-graphics.ts`](../packages/tui/src/kitty-graphics.ts) + [`components/image.ts`](../packages/tui/src/components/image.ts) — inline images.
- [`packages/tui/src/deccara.ts`](../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-line `\x1b[2K` + `\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)` → `true` iff
`atBottom === 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)` → `true` if
`atBottom === 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 `historyRebuild` fires `CSI 3 J` while 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** (`deferredMutation` returns 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 an
`undefined` probe 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_OUTPUT` as 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`/`sliceByColumn` and 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.
1. **NEVER add a new `CSI 3 J` (ED3) callsite.** ED3 must flow only through
`#emitFullPaint({ clearScrollback: true })`, for the existing destructive
intents (`sessionReplace`, proven/safe `historyRebuild`, `overlayRebuild`).
Ordinary redraws use the non-destructive `\x1b[22J` + `\x1b[2J\x1b[H` clear.
2. **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.
3. **NEVER throw in the render hot path.** Clamp over-wide lines; a width
mismatch is cosmetic, not fatal.
4. **NEVER let a defer path emit a structurally-changed frame as zero bytes
while at the bottom** — that is invisible-until-resize. `deferredMutation`/
`deferredShrink` are only safe when the viewport is (or may be) scrolled.
5. **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.
6. **Commit only the stable prefix to native history.** Transient/unsettled rows
stay out of scrollback until a checkpoint; reconcile drift at the checkpoint.
7. **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.
8. **Cursor writes live *inside* the synchronized-output frame**, before ESU —
never as a second frame after it (that teleports/blinks the caret).
9. **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.
10. **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.
11. **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: `false` on win32 (dedicated ConPTY
deferral paths handle it) and when `PI_TUI_ED3_SAFE=1`; otherwise **`true`**
for `WT_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, platform, id)` → DEC 2026 on by
default only for kitty/ghostty/wezterm/iterm2, off for win32 / SSH /
multiplexers / VTE-family. Layered with a runtime DECRQM auto-disable.
- `detectRectangularSgrSupport(id, env)` → DECCARA fills: **kitty only**
(ghostty does not implement the SGR-background extension), off in multiplexers
and under `PI_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 `visibleWidthByGrapheme` override (ANSI spans
excised first, then `Intl.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:
- `#privateCsiResponseBuffer` accumulates `\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 `\x1b`
mid-reassembly or >256 bytes abandons the partial so real keys (e.g. arrow
`\x1b[A`) still reach input.
- `#da1SentinelOwners` is a **typed FIFO** discriminated by `kind` (`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 J` anywhere other than the destructive
`clearScrollback` path? **Stop.**
- [ ] Does your change trust `isNativeViewportAtBottom() === undefined` as
"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.