chore: update stale docs
This commit is contained in:
+89
-151
@@ -2,7 +2,7 @@
|
||||
|
||||
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 the
|
||||
maps the _flow_ (input → component tree → render); this doc explains the
|
||||
**render contract, why it is shaped this way, and the invariants you must not
|
||||
violate**. Scope is the core engine only:
|
||||
|
||||
@@ -25,7 +25,7 @@ which implements the commit-boundary seam described below.
|
||||
## 1. The one thing to understand first
|
||||
|
||||
> **The renderer cannot observe the terminal's scroll position** (ConPTY's
|
||||
> probe lies; POSIX has no API at all). The previous engine tried to *guess*
|
||||
> probe lies; POSIX has no API at all). The previous engine tried to _guess_
|
||||
> when it was safe to rewrite native scrollback, and every policy choice over
|
||||
> that unobservable variable traded one failure family for another (yank ↔
|
||||
> flash ↔ corruption ↔ invisible-until-resize — see the git history of this
|
||||
@@ -35,40 +35,30 @@ which implements the commit-boundary seam described below.
|
||||
We keep the transcript on the **normal screen** (native scrollback, native
|
||||
selection, transcript persists after exit). The engine maintains one ledger:
|
||||
|
||||
- **`committedRows` (C)** — frame rows `[0, C)` have been physically scrolled
|
||||
into terminal history. They are **immutable**: the engine never rewrites
|
||||
them, and components must never change them.
|
||||
- **`committedRows` (C)** — frame rows `[0, C)` have entered terminal history.
|
||||
They are immutable: emitters never rewrite them.
|
||||
- **`windowTopRow` (W)** — the frame row mapped to grid row 0. The visible
|
||||
window is frame rows `[W, W + height)`, repainted in place with relative
|
||||
cursor moves.
|
||||
- **commit boundary** — reported by the component tree per frame
|
||||
(`NativeScrollbackLiveRegion`) as two nested ends:
|
||||
- **byte-stable end (B)** — `commitSafeEnd ?? liveRegionStart ?? frame.length`.
|
||||
Rows below B are asserted never to re-layout and stay under the
|
||||
committed-prefix audit.
|
||||
- **durable end (D)** — `max(B, snapshotSafeEnd ?? B)`. Rows in `[B, D)` may
|
||||
still drift bytes later (a streaming markdown table re-aligning columns) but
|
||||
are *durable* — their current snapshot is permanent content, so dropping them
|
||||
when they scroll off is forbidden. They commit **audit-exempt**: later drift
|
||||
becomes a frozen stale row in history, never a re-anchor.
|
||||
window is frame rows `[W, W + height)`, repainted with relative cursor moves.
|
||||
- **live-region boundary (B)** — the first row that may still mutate, reported
|
||||
by `NativeScrollbackLiveRegion`. Rows before B are exact and audited.
|
||||
Unpinned mutable rows that leave the window commit as frozen visual
|
||||
snapshots. A pinned live region instead keeps its mutable suffix
|
||||
viewport-local until the boundary advances.
|
||||
|
||||
Per ordinary frame: `W = max(C, L − height)`, `C' = max(C, min(D, W))`, and the
|
||||
only bytes that ever touch history are the **chunk** `frame[C, C')` written at
|
||||
the scrollback seam. The engine also tracks **`auditRows` (A ≤ C)** — the
|
||||
byte-stable leading prefix `[0, A)`; the committed-prefix audit (§2) samples only
|
||||
that prefix, so the durable suffix `[A, C)` drifting never triggers a re-anchor.
|
||||
Scrollback therefore equals `frame[0..C)` — every row exactly once, in order,
|
||||
with its content at commit time. There is nothing to guess, nothing to defer,
|
||||
and nothing to reconcile: the scroll position is irrelevant because ordinary
|
||||
updates never rewrite anything a scrolled reader could be looking at.
|
||||
For an ordinary unpinned frame, `W = max(C, L - height)` and the new commit end
|
||||
is `max(C, W)`, clamped to the frame. The only bytes that enter history are the
|
||||
chunk between the old and new commit indices. Exact rows remain subject to the
|
||||
committed-prefix audit; frozen mutable snapshots are deliberately outside the
|
||||
exactness claim. Scrollback therefore records every committed row once, in
|
||||
order, with its bytes at commit time. The renderer never needs to know whether
|
||||
the user has scrolled away from the tail.
|
||||
|
||||
### What this costs (the accepted tradeoffs)
|
||||
|
||||
- A block that has scrolled past the window top cannot reflow in place. A
|
||||
byte-stable block stays in the live region (below B) until final; a durable
|
||||
block (below D) commits its scroll-off snapshot, so a late layout change of an
|
||||
already-committed row is a frozen stale row in history (duplication never loss),
|
||||
not a dropped row.
|
||||
- A block that has scrolled past the window top cannot reflow in place. Exact
|
||||
settled rows commit with their final bytes; an unpinned mutable row commits
|
||||
the snapshot visible when it scrolls off, so a later layout change leaves a
|
||||
stale historical row rather than rewriting native scrollback.
|
||||
- A component tree that reports **no seam** gets shell semantics: whatever
|
||||
scrolls off is final. Shrinking such a frame into its committed prefix
|
||||
re-anchors the window and leaves the stale copy in history (§3).
|
||||
@@ -81,39 +71,26 @@ updates never rewrite anything a scrolled reader could be looking at.
|
||||
|
||||
`#doRender` per frame:
|
||||
|
||||
1. Compose the frame (`render(width)`), collecting `liveRegionStart` /
|
||||
`commitSafeEnd` from the root children (absolute row indices).
|
||||
2. **Audit the committed prefix** (`findCommittedPrefixResync`, skipped on
|
||||
geometry frames). Components must never re-layout rows below C, but real
|
||||
flows violate it (a TTSR rewind truncating a streamed block, an image-cap
|
||||
demotion shrinking a committed image) and the violation must not become
|
||||
content loss. The detector samples the prefix *tail* (up to 8 non-blank
|
||||
rows in the last 24, SGR-stripped): an in-place edit or restyle disturbs
|
||||
only the touched rows (≤1 mismatch ⇒ aligned ⇒ ignored — stale styling in
|
||||
history is the accepted artifact), while any insertion/deletion shifts
|
||||
every row below it including the tail (⇒ re-anchor C at the first changed
|
||||
row and recommit from there: history keeps the stale copy and gains a
|
||||
fresh one — **duplication, never loss**).
|
||||
3. Classify: **fullPaint** (first paint, `clearScrollback` session replace, or
|
||||
geometry change outside a multiplexer — all user gestures) or **update**.
|
||||
4. Window math as in §1. Two special rules:
|
||||
- **Overlays freeze commits** (`C' = C`): composited rows must never enter
|
||||
history; the hidden gap backfills via the chunk after the overlay closes.
|
||||
- **Shrink into the committed prefix** (`L ≤ C`): re-anchor
|
||||
`W = max(0, L − height)`, reset `C = min(B, W)`, keep the stale history
|
||||
above (no gesture, no erase).
|
||||
5. Extract the cursor marker (strip-first: markers never reach the terminal,
|
||||
the prefix ledger, or the audit), prepare lines (width fitting), slice the
|
||||
window, composite overlays **into the window slice only** (screen
|
||||
coordinates — an overlay never touches the frame or the ledger).
|
||||
6. Emit:
|
||||
1. Compose the frame, collecting the first root child's
|
||||
`getNativeScrollbackLiveRegionStart()` and optional pinning policy.
|
||||
2. Audit the committed exact prefix (`findCommittedPrefixResync`, skipped on
|
||||
geometry frames). The detector samples the prefix tail (up to 8 non-blank
|
||||
rows in the last 24, SGR-stripped). A single in-place mismatch is accepted
|
||||
as stale history; a structural shift re-anchors at the first changed row,
|
||||
favoring duplication over content loss.
|
||||
3. Classify the frame as a gesture-driven full paint or ordinary update and
|
||||
calculate the window/commit chunk. Overlays freeze commits. A pinned live
|
||||
region clips its offscreen mutable suffix instead of snapshotting it.
|
||||
4. Extract cursor markers, prepare width-safe lines, slice the window, and
|
||||
composite overlays into the screen-coordinate window only.
|
||||
5. Emit:
|
||||
|
||||
| Emitter | Bytes | When |
|
||||
|---|---|---|
|
||||
| `#emitFullPaint` | home + `frame[0, C')` + window rows; with `clearScrollback`, ED3 clears history without an ED2 viewport blank | gestures only |
|
||||
| `#emitUpdate` scroll-append | `\r\n` + new bottom rows + changed-row range | the rows leaving the screen are exactly the chunk, content untouched since painted |
|
||||
| `#emitUpdate` in-window diff | relative move + changed-row range rewrite | nothing scrolls, nothing commits (cursor-only when nothing changed) |
|
||||
| `#emitUpdate` seam rewrite | chunk rows + full window rewrite | commit advance, window re-anchor, hidden-gap backfill, mux resize |
|
||||
| Emitter | Bytes | When |
|
||||
| ---------------------------- | -------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `#emitFullPaint` | home + committed chunk + window rows; optional ED3 | initial paint and explicit geometry/session/reset gestures |
|
||||
| `#emitUpdate` scroll-append | new bottom rows plus changed-row range | rows leaving the screen are exactly the commit chunk |
|
||||
| `#emitUpdate` in-window diff | relative move plus changed-row rewrite | nothing scrolls or commits |
|
||||
| `#emitUpdate` seam rewrite | commit chunk plus full window rewrite | commit/window re-anchor, hidden-gap backfill, or mux resize |
|
||||
|
||||
**ED3 (`CSI 3 J`) is emitted in exactly one place** — `#emitFullPaint` with
|
||||
`clearScrollback: true` — and is reached only by user gestures: session
|
||||
@@ -130,69 +107,34 @@ several terminal families snap a scrolled reader to the bottom on those.
|
||||
|
||||
### The commit-boundary seam (the load-bearing app contract)
|
||||
|
||||
`NativeScrollbackLiveRegion` (tui.ts) is how a component keeps mutable rows out
|
||||
of history:
|
||||
`NativeScrollbackLiveRegion` has one boundary and one optional policy:
|
||||
|
||||
- `getNativeScrollbackLiveRegionStart()` — first row that may still mutate
|
||||
(everything below it, including root chrome rendered after it, stays in the
|
||||
window).
|
||||
- `isNativeScrollbackLiveRegionPinned()` — optional policy for replacing
|
||||
dashboards: rows at/after the live boundary stay viewport-local instead of
|
||||
entering history as frozen snapshots. When the boundary advances or
|
||||
disappears, newly final rows commit in order.
|
||||
- `getNativeScrollbackCommitSafeEnd()` — optional **byte-stable** deeper boundary
|
||||
(B): the append-only prefix of the live region (a streaming assistant message's
|
||||
settled rows), asserted never to re-layout, so it stays under the audit.
|
||||
- `getNativeScrollbackSnapshotSafeEnd()` — optional **durable** deeper boundary
|
||||
(D ≥ B): rows whose current snapshot is permanent but may still drift bytes
|
||||
(a streaming markdown table whose columns keep re-aligning). They commit on
|
||||
scroll-off (never dropped) but **audit-exempt** — drift after commit freezes a
|
||||
stale row in history rather than re-anchoring the audit and spraying duplicate
|
||||
snapshots. Without it, a commit-stable block that perpetually re-lays-out an
|
||||
interior row (a table taller than the window) had no byte-stable prefix past
|
||||
the table head, so its scrolled-off rows were committed nowhere and repainted
|
||||
nowhere — silent content loss as the reply streamed.
|
||||
- `getNativeScrollbackLiveRegionStart()` returns the first local row that may
|
||||
still mutate. Rows before it are declared byte-stable at the current width.
|
||||
- `isNativeScrollbackLiveRegionPinned()` keeps the mutable suffix
|
||||
viewport-local rather than recording frozen snapshots as it scrolls off.
|
||||
This is for replacing dashboards, not append-shaped transcript content.
|
||||
- Reporting no seam gives shell semantics: rows commit as they scroll.
|
||||
|
||||
`TranscriptContainer` implements this for the coding agent: finalized blocks
|
||||
freeze (their render is snapshotted, so their content can never drift after
|
||||
the engine may have committed it), still-mutating blocks
|
||||
(`isTranscriptBlockFinalized?.() === false`) anchor the live region, and
|
||||
`deriveLiveCommitState` derives the byte-stable commit-safe end of the first
|
||||
live block from two independent signals:
|
||||
When multiple root children report a seam, the topmost seam wins because
|
||||
commits are prefix-only. `NativeScrollbackCommittedRows` lets containers pass
|
||||
the committed count down to children, and `NativeScrollbackReplay` lets
|
||||
components release layout locks before a destructive replay.
|
||||
|
||||
- **append-only detection** — a block observed growing without visibly
|
||||
rewriting an interior row commits its full body; a rewrite suspends this
|
||||
for `VOLATILE_REARM_FRAMES` clean frames.
|
||||
- **stable-prefix ratchet** — rows that stayed visibly identical for a full
|
||||
`STABLE_PREFIX_COMMIT_FRAMES` window commit even while the block's tail
|
||||
keeps rewriting (a task tool's static prompt above a ticking progress
|
||||
tree). Without it, one perpetually animating row holds the whole block out
|
||||
of history, so a block taller than the window reads as cut off (head
|
||||
neither committed nor on screen) for the entire run. The ratchet tracks the
|
||||
window-minimum common prefix; a rewrite above the promoted run retreats it
|
||||
to the divergence, and rows that already committed are the engine audit's
|
||||
problem (recommit → duplication, never loss). That retreat also arms a
|
||||
permanent **rewrite floor** at the divergence: a row that mutates *after*
|
||||
surviving a full promotion window is a slow ticker (an agent row's tool/cost
|
||||
counter updating every few seconds), not settling content — without the
|
||||
floor, every quiet stretch re-promoted it and every later tick forced an
|
||||
audit recommit, spraying stale snapshots of the block into scrollback for
|
||||
the whole run. Rows at/after the floor never re-promote while the block
|
||||
lives (the floor index travels with append-shaped insertions above it);
|
||||
one-off re-layouts before any promotion never arm it, and the append-only
|
||||
path commits the full block regardless.
|
||||
`TranscriptContainer` implements the application seam. It scans for the first
|
||||
unfinalized transcript block. Finalized blocks before it are exact; that live
|
||||
block may extend the exact boundary through
|
||||
`getTranscriptBlockSettledRows()`. Assistant messages derive those settled
|
||||
rows from completed content blocks and markdown's frozen-token prefix, while
|
||||
constructs that can re-layout asynchronously (for example Mermaid) defer
|
||||
settling. Pinning is propagated from the first live block; tool execution uses
|
||||
it for replacing preview/dashboard states.
|
||||
|
||||
The byte-stable end gates audited commits; the **durable snapshot end** is the
|
||||
separate floor that guarantees no loss. `TranscriptContainer` reports the whole
|
||||
body of a still-live **commit-stable** block (`isTranscriptBlockCommitStable?.()
|
||||
!== false`) as the snapshot-safe end, so its scrolled-off rows always reach
|
||||
history even while its interior re-lays-out. Provisional blocks
|
||||
(`isTranscriptBlockCommitStable?.() === false`: a collapsing tool/edit preview
|
||||
whose head is a throwaway tail window) report no snapshot-safe end, so their
|
||||
head is correctly dropped rather than stranded as stale history.
|
||||
|
||||
Freezing is unconditional — it is the engine's required guarantee, not a
|
||||
per-terminal optimization.
|
||||
Transcript assembly also reports `RenderStablePrefix`: unchanged component
|
||||
array references at unchanged offsets let the engine skip work over the
|
||||
byte-identical prefix. Components that discard or lock committed material must
|
||||
honor the committed-row and replay hooks. Freezing/settling is a correctness
|
||||
contract, not a terminal-specific optimization.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,20 +143,18 @@ per-terminal optimization.
|
||||
1. **NEVER add a new `CSI 3 J` (ED3) callsite.** ED3 flows only through
|
||||
`#emitFullPaint({ clearScrollback: true })`, only for gestures, never inside
|
||||
multiplexers.
|
||||
2. **NEVER rewrite a committed row.** No emitter may touch frame rows `< C`,
|
||||
and `W ≥ C` always (re-showing a committed row on the grid duplicates it
|
||||
for a scrolling reader — the historical corruption family). When a
|
||||
*component* violates immutability, the audit (§2) degrades to duplication —
|
||||
never silently skip rows, never erase history.
|
||||
2. **NEVER rewrite a committed row.** Emitters treat frame rows `< C` as
|
||||
immutable. A shrink or structural resync may re-anchor below the old commit
|
||||
point, but stale history remains and new bytes are appended; it is never
|
||||
erased or silently skipped.
|
||||
3. **Commits are exactly the chunk.** Any byte shape that scrolls the screen
|
||||
must scroll *only* rows accounted for by `C' − C` — that is what makes
|
||||
scrollback provably `frame[0..C)`.
|
||||
must scroll only rows accounted for by the commit advance.
|
||||
4. **NEVER probe the viewport position or fork on platform in the update
|
||||
path.** win32 behaves like POSIX. The probe APIs are gone; do not
|
||||
reintroduce them.
|
||||
5. **Mutable content stays below the commit boundary.** App-layer renderers
|
||||
must finalize-before-commit; the engine trusts B and clamps, it does not
|
||||
verify content.
|
||||
5. **Only declare rows exact when their bytes are stable.** Mutable transcript
|
||||
content may commit as an unpinned frozen snapshot, but rows before the seam
|
||||
remain under the exact-prefix audit.
|
||||
6. **Park the hardware cursor at real content bottom**, not the padded window
|
||||
bottom, or height shrinks scroll live rows into history and duplicate them
|
||||
per resize step.
|
||||
@@ -250,7 +190,7 @@ per-terminal optimization.
|
||||
The old ED3-risk classifier (`eagerEraseScrollbackRisk`, `PI_TUI_ED3_SAFE`,
|
||||
`submitPinsViewportToTail`) is gone: behavior no longer depends on which
|
||||
terminal is rendering, so there is no risk class to detect. Env sniffing now
|
||||
only selects *optimizations* (sync output, DECCARA, images), where a miss is
|
||||
only selects _optimizations_ (sync output, DECCARA, images), where a miss is
|
||||
cosmetic, not corrupting.
|
||||
|
||||
---
|
||||
@@ -344,22 +284,21 @@ default-on only for kitty/ghostty (`PI_NO_KITTY_PLACEHOLDERS` /
|
||||
|
||||
## 9. Escape hatches (env vars)
|
||||
|
||||
| Var | Effect |
|
||||
|---|---|
|
||||
| `PI_NO_SYNC_OUTPUT=1` | Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on). |
|
||||
| `PI_TUI_SYNC_OUTPUT=0\|1` / `PI_FORCE_SYNC_OUTPUT=1` | Force sync output off / on. |
|
||||
| `PI_NO_DECCARA` | Disable Kitty DECCARA rectangular-fill optimization. |
|
||||
| `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_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 + ledger state per frame to the debug log. |
|
||||
| `PI_TUI_RESIZE_IN_PLACE=1\|0` | Force resize to repaint in place (no alt-screen borrow, no ED3 rewrap) on / off. Default-on for terminals that re-report size on alt-screen toggles (Warp). |
|
||||
| Var | Effect |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PI_NO_SYNC_OUTPUT=1` | Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on). |
|
||||
| `PI_TUI_SYNC_OUTPUT=0\|1` / `PI_FORCE_SYNC_OUTPUT=1` | Force sync output off / on. |
|
||||
| `PI_NO_DECCARA` | Disable Kitty DECCARA rectangular-fill optimization. |
|
||||
| `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_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 + ledger state per frame to the debug log. |
|
||||
| `PI_TUI_RESIZE_IN_PLACE=1\|0` | Force resize to repaint in place (no alt-screen borrow, no ED3 rewrap) on / off. Default-on for terminals that re-report size on alt-screen toggles (Warp). |
|
||||
|
||||
Removed with the old engine: `PI_TUI_ED3_SAFE` (no ED3-risk lever exists),
|
||||
`PI_CLEAR_ON_SHRINK` (shrinks always clear exactly), `PI_TUI_DEBUG` (per-render
|
||||
dump superseded by `PI_DEBUG_REDRAW` ledger logging and the stress harness
|
||||
replay/reduce tooling).
|
||||
`PI_CLEAR_ON_SHRINK`, and `PI_TUI_DEBUG` (per-render dump superseded by
|
||||
`PI_DEBUG_REDRAW` ledger logging and the stress-harness replay/reduce tooling).
|
||||
|
||||
---
|
||||
|
||||
@@ -367,10 +306,9 @@ replay/reduce tooling).
|
||||
|
||||
- [ ] Are you about to emit `CSI 3 J` anywhere other than the gesture-driven
|
||||
`clearScrollback` full paint? **Stop.**
|
||||
- [ ] Could any code path rewrite, or re-show on the grid, a frame row below
|
||||
`committedRows`? **Stop.**
|
||||
- [ ] Does your byte shape scroll rows that are not the commit chunk? That
|
||||
breaks `scrollback == frame[0..C)`.
|
||||
- [ ] Could an ordinary emitter rewrite a row below `committedRows`? **Stop.**
|
||||
- [ ] Does your byte shape scroll rows not accounted for by the commit chunk?
|
||||
That breaks the append-only ledger.
|
||||
- [ ] Are you adding a viewport probe, a platform fork, or a terminal-brand
|
||||
branch to the update path? The contract exists so none are needed.
|
||||
- [ ] New mutable UI above the editor? It must report (or live inside) the
|
||||
|
||||
Reference in New Issue
Block a user