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:
@@ -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.
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This document maps the non-theme runtime path from terminal input to rendered output in interactive mode. It focuses on behavior in `packages/tui` and its integration from `packages/coding-agent` controllers.
|
||||
|
||||
> **Editing the rendering engine itself?** Read
|
||||
> [`tui-core-renderer.md`](./tui-core-renderer.md) first — it documents the
|
||||
> failure modes (yank / corruption / flash / width crashes) and the invariants
|
||||
> the render planner, native-scrollback bookkeeping, and capability detection
|
||||
> must not violate.
|
||||
|
||||
## Runtime layers and ownership
|
||||
|
||||
- **`packages/tui` engine**: terminal lifecycle, stdin normalization, focus routing, render scheduling, differential painting, overlay composition, hardware cursor placement.
|
||||
@@ -11,44 +17,49 @@ Boundary rule: the TUI engine is message-agnostic. It only knows `Component.rend
|
||||
|
||||
## Implementation files
|
||||
|
||||
- [`../src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts)
|
||||
- [`../src/modes/controllers/event-controller.ts`](../packages/coding-agent/src/modes/controllers/event-controller.ts)
|
||||
- [`../src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts)
|
||||
- [`../src/modes/components/custom-editor.ts`](../packages/coding-agent/src/modes/components/custom-editor.ts)
|
||||
- [`../../tui/src/tui.ts`](../packages/tui/src/tui.ts)
|
||||
- [`../../tui/src/terminal.ts`](../packages/tui/src/terminal.ts)
|
||||
- [`../../tui/src/editor-component.ts`](../packages/tui/src/editor-component.ts)
|
||||
- [`../../tui/src/stdin-buffer.ts`](../packages/tui/src/stdin-buffer.ts)
|
||||
- [`../../tui/src/components/loader.ts`](../packages/tui/src/components/loader.ts)
|
||||
- [`packages/coding-agent/src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts)
|
||||
- [`packages/coding-agent/src/modes/controllers/event-controller.ts`](../packages/coding-agent/src/modes/controllers/event-controller.ts)
|
||||
- [`packages/coding-agent/src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts)
|
||||
- [`packages/coding-agent/src/modes/components/custom-editor.ts`](../packages/coding-agent/src/modes/components/custom-editor.ts)
|
||||
- [`packages/tui/src/tui.ts`](../packages/tui/src/tui.ts)
|
||||
- [`packages/tui/src/terminal.ts`](../packages/tui/src/terminal.ts)
|
||||
- [`packages/tui/src/editor-component.ts`](../packages/tui/src/editor-component.ts)
|
||||
- [`packages/tui/src/stdin-buffer.ts`](../packages/tui/src/stdin-buffer.ts)
|
||||
- [`packages/tui/src/components/loader.ts`](../packages/tui/src/components/loader.ts)
|
||||
|
||||
## Boot and component tree assembly
|
||||
|
||||
`InteractiveMode` constructs `TUI(new ProcessTerminal(), settings.get("showHardwareCursor"))`, applies `settings.get("clearOnShrink")`, and creates persistent containers:
|
||||
`InteractiveMode` constructs `TUI(new ProcessTerminal(), settings.get("showHardwareCursor"))`, applies `clearOnShrink`, `tui.maxInlineImages`, and Kitty text-sizing settings, then creates persistent containers:
|
||||
|
||||
- `chatContainer`
|
||||
- `pendingMessagesContainer`
|
||||
- `statusContainer`
|
||||
- `todoContainer`
|
||||
- `btwContainer`
|
||||
- `omfgContainer`
|
||||
- `errorBannerContainer`
|
||||
- `statusLine`
|
||||
- `hookWidgetContainerAbove`
|
||||
- `editorContainer` (holds `CustomEditor`)
|
||||
- `hookWidgetContainerBelow`
|
||||
|
||||
`init()` wires the tree in that order, focuses the editor, registers input handlers via `InputController`, subscribes terminal appearance changes into theme auto-detection, starts TUI, and requests a forced render.
|
||||
A forced render (`requestRender(true)`) resets previous-line caches and cursor bookkeeping before repainting.
|
||||
`init()` wires the tree in that order after any startup warnings/welcome/changelog, focuses the editor, registers input handlers via `InputController`, starts TUI, pushes terminal title state, updates the editor border, and requests a forced render.
|
||||
A forced render (`requestRender(true)`) queues a viewport repaint or explicit session replacement; it does **not** throw away previous-line history by default.
|
||||
|
||||
## Terminal lifecycle and stdin normalization
|
||||
|
||||
`ProcessTerminal.start()`:
|
||||
|
||||
1. Enables raw mode and bracketed paste.
|
||||
2. Attaches resize handler.
|
||||
3. Creates a `StdinBuffer` to split partial escape chunks into complete sequences.
|
||||
4. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported; otherwise enables modifyOtherKeys fallback after a short timeout.
|
||||
5. Queries OSC 11 background color and enables Mode 2031 appearance notifications for dark/light theme detection.
|
||||
6. On Windows, attempts VT input enablement via `kernel32` mode flags.
|
||||
`StdinBuffer` behavior:
|
||||
2. Attaches resize handler and refreshes dimensions.
|
||||
3. Enables Windows VT input mode when running on win32.
|
||||
4. Creates a `StdinBuffer` to split partial escape chunks into complete sequences.
|
||||
5. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported; otherwise enables modifyOtherKeys fallback after a short timeout.
|
||||
6. Queries OSC 11 background color and Mode 2031 appearance notifications for dark/light theme detection.
|
||||
7. Queries OSC 99 notification capabilities and Kitty temp-file graphics support.
|
||||
8. Starts periodic OSC 11 polling only where safe, then probes DEC private modes 2026/2048/2031 via DECRQM.
|
||||
|
||||
`StdinBuffer` behavior:
|
||||
|
||||
- Buffers fragmented escape sequences (CSI/OSC/DCS/APC/SS3).
|
||||
- Emits `data` only when a sequence is complete or timeout-flushed.
|
||||
@@ -90,7 +101,7 @@ This keeps key parsing/editor mechanics in `packages/tui` and mode semantics in
|
||||
|
||||
`TUI.requestRender()` coalesces render requests and rate-limits ordinary frames:
|
||||
|
||||
- forced renders (`requestRender(true, ...)`) reset cached frame/viewport state and run on `process.nextTick`
|
||||
- forced renders (`requestRender(true, ...)`) schedule an immediate frame and set `#forceViewportRepaintOnNextRender`; with `clearScrollback`, they also queue `sessionReplace`
|
||||
- ordinary renders schedule through `#scheduleRender()` and respect `TUI.#MIN_RENDER_INTERVAL_MS`
|
||||
- repeated requests while a render is pending collapse into the same scheduled frame
|
||||
|
||||
@@ -110,7 +121,7 @@ This keeps key parsing/editor mechanics in `packages/tui` and mode semantics in
|
||||
- noop
|
||||
6. Emit only the bytes required by the intent and commit cached frame/cursor/viewport state.
|
||||
|
||||
Render writes use synchronized output mode (`CSI ? 2026 h/l`) to reduce flicker/tearing.
|
||||
Render writes use synchronized output mode (`CSI ? 2026 h/l`) when enabled; capability detection, DECRQM, or `PI_NO_SYNC_OUTPUT` can disable the wrappers while leaving autowrap discipline on.
|
||||
|
||||
## Render safety constraints
|
||||
|
||||
@@ -123,14 +134,19 @@ Critical safety checks in `TUI`:
|
||||
|
||||
These constraints are runtime guards plus component conventions; renderers should still return width-safe lines rather than rely on truncation.
|
||||
|
||||
The deeper reasons these guards exist — why the renderer cannot observe scroll
|
||||
position, why ED3 (`CSI 3 J`) is confined to one path, and why the hot path
|
||||
clamps instead of throwing — are documented in
|
||||
[`tui-core-renderer.md`](./tui-core-renderer.md).
|
||||
|
||||
## Resize handling
|
||||
|
||||
Resize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`.
|
||||
|
||||
Effects:
|
||||
|
||||
- Width changes repaint or rebuild because wrapping semantics change.
|
||||
- Height-only changes repaint the viewport when needed, but skip repaint in Termux and terminal multiplexers where replays are scrollback-hostile.
|
||||
- Width or height changes repaint or rebuild because terminal reflow invalidates wrapping, viewport, and cursor anchors.
|
||||
- Inside terminal multiplexers, resize uses viewport repaint instead of destructive native-scrollback replay; pane history cannot be erased safely and a full replay duplicates transcript rows.
|
||||
- Viewport/top tracking (`#viewportTopRow`, `#maxLinesRendered`, scrollback high-water state) avoids invalid relative cursor math and defers destructive native scrollback rewrites while the user is scrolled into history.
|
||||
- Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize.
|
||||
|
||||
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
/**
|
||||
* Minimal TUI implementation with differential rendering
|
||||
* Minimal TUI implementation with differential rendering.
|
||||
*
|
||||
* Before changing the render planner, native-scrollback bookkeeping, capability
|
||||
* detection, or width math, read `docs/tui-core-renderer.md`: it documents the
|
||||
* failure modes (yank / corruption / flash / width crashes) and the invariants
|
||||
* this engine must not violate. The short version: the renderer cannot observe
|
||||
* the terminal's scroll position on most hosts, so ED3 (`CSI 3 J`) is confined
|
||||
* to the destructive `clearScrollback` path, an unobservable viewport probe is
|
||||
* never trusted for passive streaming, and the hot path clamps over-wide lines
|
||||
* instead of throwing.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
Reference in New Issue
Block a user