Files
oh-my-pi/docs/tui-core-renderer.md
T
can1357 22bb6b9927 feat(tui): widened sync-output defaults with runtime DECRQM upgrade
- 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.
2026-06-06 23:57:34 +02:00

390 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-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)` → `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, 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_FEATURES` advertises
`Sy` → `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 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.