diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md new file mode 100644 index 000000000..dd984a830 --- /dev/null +++ b/docs/tui-core-renderer.md @@ -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. diff --git a/docs/tui-runtime-internals.md b/docs/tui-runtime-internals.md index e5ef9e32d..c4fb02030 100644 --- a/docs/tui-runtime-internals.md +++ b/docs/tui-runtime-internals.md @@ -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. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 212c11838..e79e5b905 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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";