From 99a4e2933ebd5673e4240b457deba09b1e48c286 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 14 Jun 2026 17:03:11 +0200 Subject: [PATCH] fix(tui): fixed native scrollback loss for streaming assistant rows - Tracked snapshot-safe boundaries to retain commit-stable streamed rows in scrollback. - Computed durableBoundary from commit and snapshot ends to prevent row drop regressions. - Updated audit-row handling so drifting durable rows were excluded from resync checks. - Added regression tests for commit-stable and commit-unstable relayout streaming cases. --- docs/tui-core-renderer.md | 64 ++++++--- packages/coding-agent/CHANGELOG.md | 1 + .../modes/components/transcript-container.ts | 23 ++++ .../test/tool-live-region-scrollback.test.ts | 102 +++++++++++++- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 125 ++++++++++++++++-- .../test/streaming-scrollback-defer.test.ts | 90 +++++++++++++ 7 files changed, 372 insertions(+), 34 deletions(-) diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index f95ed3ee2..cce279c59 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -41,23 +41,34 @@ selection, transcript persists after exit). The engine maintains one ledger: - **`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 (B)** — reported by the component tree per frame - (`NativeScrollbackLiveRegion`): `B = commitSafeEnd ?? liveRegionStart ?? - frame.length`. Rows below B may still re-layout and must not enter history. +- **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. -Per ordinary frame: `W = max(C, L − height)`, `C' = max(C, min(B, W))`, and the +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. 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. +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. ### What this costs (the accepted tradeoffs) -- A block that has scrolled past the window top cannot reflow in place. Blocks - stay in the live region (below B) until they are final; a late mutation of - committed content is ignored (the stale committed copy stays in history). +- 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 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). @@ -122,17 +133,25 @@ of history: - `getNativeScrollbackLiveRegionStart()` — first row that may still mutate (everything below it, including root chrome rendered after it, stays in the window). -- `getNativeScrollbackCommitSafeEnd()` — optional deeper boundary: the - append-only prefix of the live region (a streaming assistant message's - settled rows). Without it, a single live block taller than the window would - hold its head out of history until it finalizes. +- `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. `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 commit-safe end of the first live block -from two independent signals: +`deriveLiveCommitState` derives the byte-stable commit-safe end of the first +live block from two independent signals: - **append-only detection** — a block observed growing without visibly rewriting an interior row commits its full body; a rewrite suspends this @@ -156,6 +175,15 @@ from two independent signals: one-off re-layouts before any promotion never arm it, and the append-only path commits the full block regardless. +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. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 27d2f7495..0b0069a7a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -71,6 +71,7 @@ - Fixed eager todo initialization prompting GPT-5.5 to emit unsupported task metadata fields, which could leave fresh sessions stuck on the forced first `todo` call ([#2561](https://github.com/can1357/oh-my-pi/issues/2561)). - Fixed Windows plan-mode task fan-out crashing the TUI when nested async task progress formed a cycle; task rendering now cuts recursive snapshots and long Windows `local://` roots are shortened under temp storage ([#2551](https://github.com/can1357/oh-my-pi/issues/2551)). +- Fixed a band of streaming assistant output being lost from native scrollback — committed nowhere, repainted nowhere — once a reply grew taller than the viewport. Markdown whose layout keeps changing above the streaming tail (most visibly a table whose columns re-align as rows arrive) never earns a byte-stable commit-safe end, so as its head scrolled above the window the rows fell into the gap between the commit boundary and the window top and vanished. `TranscriptContainer` now reports a `getNativeScrollbackSnapshotSafeEnd()` for commit-stable live blocks (their whole body is durable content), and the renderer commits those scrolled-off rows audit-exempt — a later layout change of an already-committed row freezes a slightly-stale row in scrollback (duplication never loss) instead of dropping it. Provisional blocks (collapsing tool/edit previews) are unaffected. ### Changed diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 39b526875..a4abc2379 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -435,6 +435,14 @@ export class TranscriptContainer // until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames; // the engine then backfills the stalled gap. #nativeScrollbackCommitSafeEnd: number | undefined; + // Local line index up to which the leading run of live blocks is DURABLE: a + // commit-stable block's full body is permanent content even while its interior + // rows re-lay-out (a streaming markdown table re-aligning columns), so the + // engine must append their scroll-off snapshot rather than drop it. Reported + // separately from the byte-stable commit-safe end because these rows may still + // drift after commit; the engine commits them audit-exempt. Provisional + // (commit-unstable) blocks never extend it. + #nativeScrollbackSnapshotSafeEnd: number | undefined; // Persistent assembled transcript rows. Rows before the stable floor are // byte-identical to the previous render; rows at/after it were re-pushed. #lines: string[] = []; @@ -479,6 +487,10 @@ export class TranscriptContainer return this.#nativeScrollbackCommitSafeEnd; } + getNativeScrollbackSnapshotSafeEnd(): number | undefined { + return this.#nativeScrollbackSnapshotSafeEnd; + } + /** * Whether `component` sits below a still-mutating block — i.e. inside the * live region, where its rows cannot have been committed to native @@ -570,6 +582,7 @@ export class TranscriptContainer width = Math.max(1, width); this.#nativeScrollbackLiveRegionStart = undefined; this.#nativeScrollbackCommitSafeEnd = undefined; + this.#nativeScrollbackSnapshotSafeEnd = undefined; const count = this.children.length; @@ -742,6 +755,16 @@ export class TranscriptContainer if (safeLength > 0) { this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength; } + // Durable snapshot end: a commit-stable block's whole body is durable + // content — its scrolled-off rows are permanent even while interior + // rows re-lay-out (a streaming table re-aligning columns), so the + // engine must commit their snapshot on scroll-off rather than drop it. + // Finalized blocks are wholly durable; provisional (commit-unstable) + // blocks offer nothing beyond their byte-stable safe length. + const snapshotLength = finalized || isBlockCommitStable(child) ? contribution.length : safeLength; + if (snapshotLength > 0) { + this.#nativeScrollbackSnapshotSafeEnd = blockStart + snapshotLength; + } // A finalized, fully safe block may let the contiguous safe run extend // into blocks rendered below it. A still-live block keeps pushing lower // rows around as it grows, so the run closes there. diff --git a/packages/coding-agent/test/tool-live-region-scrollback.test.ts b/packages/coding-agent/test/tool-live-region-scrollback.test.ts index f0d4facc4..72d97871a 100644 --- a/packages/coding-agent/test/tool-live-region-scrollback.test.ts +++ b/packages/coding-agent/test/tool-live-region-scrollback.test.ts @@ -11,10 +11,12 @@ import { VirtualTerminal } from "../../tui/test/virtual-terminal"; class MutableLiveBlock implements Component { #lines: string[]; #finalized: boolean; + #commitStable: boolean | undefined; - constructor(lines: string[], finalized = false) { + constructor(lines: string[], finalized = false, commitStable?: boolean) { this.#lines = [...lines]; this.#finalized = finalized; + this.#commitStable = commitStable; } render(width: number): string[] { @@ -28,6 +30,13 @@ class MutableLiveBlock implements Component { isTranscriptBlockFinalized(): boolean { return this.#finalized; } + + // Defaults to commit-stable (matches a block that omits the method). Pass + // false to model a provisional block (a collapsing tool preview) whose live + // rows must never reach native scrollback. + isTranscriptBlockCommitStable(): boolean { + return this.#commitStable ?? true; + } } function markerLines(prefix: string, count: number): string[] { @@ -941,14 +950,58 @@ describe("tool live-region scrollback", () => { } }); - it("keeps a re-layouting live block's changed head out of scrollback", async () => { + it("commits a re-layouting commit-stable live block's durable head to scrollback (no loss)", async () => { if (process.platform === "win32") return; + // A commit-stable block (a streaming assistant reply) whose interior rows + // re-lay-out as it grows — the markdown-table shape: every previous row + // changes when the block swaps to a taller render. Its current snapshot is + // durable content, so the rows that scroll above the viewport MUST reach + // native scrollback (frozen snapshot) rather than vanish — committed + // nowhere, repainted nowhere. const term = new VirtualTerminal(120, 12); const tui = new TUI(term); const chat = new TranscriptContainer(); const block = new MutableLiveBlock(markerLines("OLD-", 8)); + try { + chat.addChild(block); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); + + block.setLines(markerLines("NEW-", 40)); + tui.requestRender(); + await term.waitForRender(); + + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); + + // The head scrolled above the viewport but is durable: it lives in + // native scrollback, not nowhere. + expect(viewportText).not.toContain("NEW-0"); + expect(scrollText).toContain("NEW-0"); + expect(scrollText).toContain("NEW-20"); + expect(viewportText).toContain("NEW-39"); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("keeps a re-layouting commit-UNSTABLE live block's changed head out of scrollback", async () => { + if (process.platform === "win32") return; + + // A provisional block (a collapsing tool/edit preview) reports + // isTranscriptBlockCommitStable() === false: its head is a throwaway tail + // window that the result render replaces wholesale, so committing it would + // strand a stale fragment in history. Its re-laid-out head must stay out of + // scrollback (the provisional-defer contract behind #402/#351). + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const block = new MutableLiveBlock(markerLines("OLD-", 8), false, false); + try { chat.addChild(block); tui.addChild(chat); @@ -1228,4 +1281,49 @@ describe("assistant live-region scrollback", () => { await term.flush(); } }); + + it("commits the scrolled-off head of a streamed markdown table whose columns keep re-aligning", async () => { + if (process.platform === "win32") return; + + // The reported content-loss shape: a streaming reply with a markdown table + // whose column widths grow as rows arrive, so every already-rendered row + // re-lays-out each frame (perpetual interior re-layout, never byte-stable + // append-only). The block is commit-stable, so its scrolled-off head is + // durable and must reach native scrollback rather than vanish. + const term = new VirtualTerminal(70, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const component = new AssistantMessageComponent(undefined, false); + + const lines: string[] = ["Here is a summary of the MARK files:", ""]; + for (let i = 0; i < 6; i++) lines.push(`Paragraph PARA-${i} with some descriptive prose about the topic.`); + lines.push("", "| Name | Description |", "|------|-------------|"); + for (let i = 0; i < 16; i++) { + lines.push(`| ITEM-${i} | description number ${i} growing wider and wider ${"x".repeat(i)} |`); + } + + try { + chat.addChild(component); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); + + const acc: string[] = []; + for (const line of lines) { + acc.push(line); + component.updateContent(makeAssistantMessage(acc.join("\n"))); + tui.requestRender(); + await term.waitForRender(); + } + + const scrollText = stripRows(term.getScrollBuffer()); + // No row may vanish: every paragraph and table row reaches the tape, + // even the band that scrolled off while the table was re-aligning. + for (let i = 0; i < 6; i++) expect(scrollText).toContain(`PARA-${i}`); + for (let i = 0; i < 16; i++) expect(scrollText).toContain(`ITEM-${i}`); + } finally { + tui.stop(); + await term.flush(); + } + }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4eb13dbd6..7f6d6db3d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `ctrl+j` as a second default binding for the `tui.input.newLine` action alongside `shift+enter`, so terminals that cannot emit `shift+enter` still have a newline key. On terminals with Kitty-protocol / `modifyOtherKeys` disambiguation `ctrl+j` inserts a newline while `Enter` still submits; on legacy terminals where `ctrl+j` and `Enter` are both byte-identical `LF` it submits (documented limitation). User keybinding overrides still take precedence ([#2473](https://github.com/can1357/oh-my-pi/issues/2473)) - Added an `Editor.onLargePaste(text, lineCount)` hook, fired for a "marker-sized" paste (the point where the editor would otherwise collapse it into a `[Paste #N]` token). Returning `true` lets the host intercept the paste — e.g. to offer wrap-in-code-block / wrap-in-XML / attach-as-file choices — and suppresses the default marker (no undo state is recorded). Added `Editor.insertPaste(content)` so the host can re-insert a (possibly transformed) collapsed paste marker without re-triggering the hook. - Added `Editor.deleteBeforeCursor(count)`, which removes up to `count` characters immediately before the cursor on the current line (capped at the cursor column, single line, records one undo state). Hosts use it to "track back" optimistically-inserted characters — e.g. the coding-agent hold-`Space` push-to-talk gesture deleting the space-bar auto-repeat burst. +- Added an optional `getNativeScrollbackSnapshotSafeEnd()` to the `NativeScrollbackLiveRegion` contract: a *durable* commit boundary (D ≥ the byte-stable `commitSafeEnd`) for live rows whose current snapshot is permanent content but may still drift bytes later (a streaming markdown table re-aligning its columns). The engine commits these rows when they scroll above the window — never dropping them — but **audit-exempt** (tracked via a new byte-stable `auditRows` prefix), so a later layout change of an already-committed row freezes a stale row in history (duplication never loss) instead of re-anchoring the committed-prefix audit and spraying duplicate snapshots. Components that omit it are unchanged: `durableBoundary === byteStableBoundary` and `auditRows === committedRows`, so the ledger math is byte-identical. ### Fixed diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 92cdb0682..4ae93663b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -188,14 +188,29 @@ export interface Component { * of history until it finalizes. Volatile live blocks (tool previews that * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that * reports no seam at all commits everything that scrolls (shell semantics). + * `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper + * boundary: the line index up to which the live region is *durable* — its rows + * may still change bytes later (a streaming markdown table re-aligning its + * columns every row), but their CURRENT snapshot is permanent content, so + * dropping them when they scroll above the window is forbidden. Unlike + * `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and + * stay under the committed-prefix audit), rows committed under the snapshot end + * are audit-EXEMPT once they pass the window top — the engine appends their + * scroll-off snapshot and never recommits them, so later layout drift becomes a + * frozen stale row in history (duplication never loss) instead of either a + * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing + * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to + * `commitSafeEnd ?? liveRegionStart` when absent. * * When several root children report a seam in the same frame, the topmost - * one (and its commit-safe extension) defines the boundary: commits are - * prefix-only, so everything below the first seam is already excluded. + * one (and its commit-safe / snapshot-safe extension) defines the boundary: + * commits are prefix-only, so everything below the first seam is already + * excluded. */ export interface NativeScrollbackLiveRegion { getNativeScrollbackLiveRegionStart(): number | undefined; getNativeScrollbackCommitSafeEnd?(): number | undefined; + getNativeScrollbackSnapshotSafeEnd?(): number | undefined; } export interface NativeScrollbackCommittedRows { @@ -214,6 +229,10 @@ function getNativeScrollbackCommitSafeEnd(component: Component): number | undefi return (component as Component & Partial).getNativeScrollbackCommitSafeEnd?.(); } +function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined { + return (component as Component & Partial).getNativeScrollbackSnapshotSafeEnd?.(); +} + /** * Opt-in stability report for components that mutate their returned render * array in place across frames (instead of returning a fresh array per @@ -553,6 +572,7 @@ interface FrameSegment { rowCount: number; liveLocalStart?: number; commitLocalEnd?: number; + snapshotLocalEnd?: number; } /** Depth-first identity search through `Container`-shaped children. */ @@ -609,8 +629,15 @@ const RESYNC_TAIL_SAMPLES = 8; * observationally harmless. Exported for the render-stress harness, whose * shadow commit ledger must mirror the engine's law exactly. */ -export function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[]): number { - const committed = prefix.length; +export function findCommittedPrefixResync( + frame: readonly string[], + prefix: readonly string[], + auditLimit: number = prefix.length, +): number { + // Audit only the byte-stable leading prefix [0, auditLimit); rows committed + // under a durable snapshot end (beyond auditLimit) may drift legitimately and + // are exempt, so their drift never triggers a re-anchor. + const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditLimit))); if (committed === 0) return -1; if (frame.length >= committed) { let samples = 0; @@ -733,6 +760,15 @@ export class TUI extends Container { // #auditCommittedPrefix). Holds references to component-cached strings, so // the audit is a pointer walk in the common case. #committedPrefix: string[] = []; + // Length of the leading committed prefix [0, #committedPrefixAuditRows) that + // is BYTE-STABLE and therefore audited. Rows [auditRows, committedRows) were + // committed under a component's snapshot-safe (durable, non-byte-stable) end: + // their scroll-off snapshot is permanent so dropping them is forbidden, but + // they may drift afterward (a streaming table widening), so re-auditing them + // would re-anchor on every drift and spray duplicate snapshots. Once a + // snapshot row commits (auditRows < committedRows) the cap is permanent until + // a wholesale re-slice (full paint / shrink / geometry) re-bases it. + #committedPrefixAuditRows = 0; // Frame row currently mapped to screen row 0. Monotonic between full // paints: a shrink never re-exposes scrolled-off rows (they cannot be // un-scrolled without rewriting history); live rows repaint at fixed @@ -742,6 +778,7 @@ export class TUI extends Container { #previousWindow: string[] = []; #nativeScrollbackLiveRegionStart: number | undefined; #nativeScrollbackCommitSafeEnd: number | undefined; + #nativeScrollbackSnapshotSafeEnd: number | undefined; #fullRedrawCount = 0; // Caps how many inline images render as live graphics; older ones fall back // to text via a purge + full redraw. Cap is configured by the host app. @@ -865,6 +902,7 @@ export class TUI extends Container { width = Math.max(1, width); this.#nativeScrollbackLiveRegionStart = undefined; this.#nativeScrollbackCommitSafeEnd = undefined; + this.#nativeScrollbackSnapshotSafeEnd = undefined; const children = this.children; const previousSegments = this.#frameSegments; const segments: FrameSegment[] = new Array(children.length); @@ -886,11 +924,13 @@ export class TUI extends Container { let childLines: readonly string[]; let liveLocalStart: number | undefined; let commitLocalEnd: number | undefined; + let snapshotLocalEnd: number | undefined; let reported: number | undefined; if (reuse) { childLines = previous.lines; liveLocalStart = previous.liveLocalStart; commitLocalEnd = previous.commitLocalEnd; + snapshotLocalEnd = previous.snapshotLocalEnd; } else { // Feed the engine's committed-row claim (from the previous frame's // emit) before rendering so the child can skip re-deriving blocks @@ -909,6 +949,16 @@ export class TUI extends Container { ? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd))) : childLines.length; } + // Durable snapshot end: clamped at/above the byte-stable end (or + // the live-region start when none) so a child can never report a + // shallower durable boundary than its byte-stable one. + const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child); + if (snapshotSafeEnd !== undefined) { + const snapshotFloor = commitLocalEnd ?? liveLocalStart; + snapshotLocalEnd = Number.isFinite(snapshotSafeEnd) + ? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd))) + : childLines.length; + } } // Consume the stability report unconditionally for implementers: // reading re-bases the component's baseline to the state this @@ -929,6 +979,9 @@ export class TUI extends Container { if (commitLocalEnd !== undefined) { this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd; } + if (snapshotLocalEnd !== undefined) { + this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd; + } } if (chainStable) { if (previous !== undefined && previous.component === child && previous.start === offset) { @@ -958,6 +1011,7 @@ export class TUI extends Container { rowCount: childLines.length, liveLocalStart, commitLocalEnd, + snapshotLocalEnd, }; offset += childLines.length; } @@ -2255,6 +2309,7 @@ export class TUI extends Container { const cursorMarkers = this.#frameCursorMarkers; const liveRegionStart = this.#nativeScrollbackLiveRegionStart; const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd; + const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd; // 2. Transition state captured before any emitter runs. const prevWindowTop = this.#windowTopRow; @@ -2289,12 +2344,16 @@ export class TUI extends Container { this.#hasEverRendered && !geometryChanged && !this.#clearScrollbackOnNextRender && - this.#renderStablePrefixRows < this.#committedRows + this.#renderStablePrefixRows < this.#committedPrefixAuditRows ) { const committedRowsBeforeAudit = this.#committedRows; this.#auditCommittedPrefix(rawFrame); committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit; } + // Committed-prefix state this frame's commit math extends from (post-audit). + // Drives the byte-stable audit-rows cap recomputed after the emit. + const preCommitRows = this.#committedRows; + const preCommitAuditRows = this.#committedPrefixAuditRows; // 3. Window and commit math (lengths only; content prepared below). const frameLength = rawFrame.length; @@ -2305,11 +2364,18 @@ export class TUI extends Container { break; } } - // The commit boundary: rows below it may still re-layout and must never - // enter native history. Finalized prefix (live-region start), deepened - // by an append-only block's sealed prefix; the whole frame when the - // root reports no seam (shell semantics: whatever scrolls is final). - const commitBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength)); + // Two commit boundaries. byteStableBoundary: rows below it are byte-stable + // (asserted never to re-layout) and stay under the committed-prefix audit. + // durableBoundary: rows below it are durable — their scroll-off snapshot is + // permanent (dropping them is forbidden) but may still drift afterward, so + // they commit audit-EXEMPT. Both build on the finalized prefix (live-region + // start); the whole frame when the root reports no seam (shell semantics: + // whatever scrolls is final). + const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength)); + const durableBoundary = Math.max( + byteStableBoundary, + Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary), + ); // 4. Classify. A resize is an explicit user gesture: outside a // multiplexer it erases and replays so history rewraps at the new @@ -2321,9 +2387,11 @@ export class TUI extends Container { const fullPaint = firstPaint || replaceRequested || geometryRebuild; let windowTop: number; let chunkTo: number; + let committedPrefixResliced = false; if (fullPaint) { + committedPrefixResliced = true; windowTop = Math.max(0, frameLength - height); - chunkTo = Math.min(commitBoundary, windowTop); + chunkTo = Math.min(durableBoundary, windowTop); } else if ( frameLength <= this.#committedRows || (committedRowsResynced && @@ -2341,7 +2409,8 @@ export class TUI extends Container { // is preferable to a live editor gap and matches the existing // "duplication, never loss" resync contract. windowTop = Math.max(0, frameLength - height); - chunkTo = Math.min(commitBoundary, windowTop); + chunkTo = Math.min(durableBoundary, windowTop); + committedPrefixResliced = true; this.#committedRows = chunkTo; this.#committedPrefix = rawFrame.slice(0, chunkTo); } else { @@ -2361,8 +2430,9 @@ export class TUI extends Container { chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows - : Math.max(this.#committedRows, Math.min(commitBoundary, windowTop)); + : Math.max(this.#committedRows, Math.min(durableBoundary, windowTop)); if (geometryChanged) { + committedPrefixResliced = true; this.#committedPrefix = rawFrame.slice(0, this.#committedRows); } } @@ -2423,6 +2493,7 @@ export class TUI extends Container { windowTop, }); this.#committedPrefix = rawFrame.slice(0, chunkTo); + this.#updateCommittedAuditRows(true, preCommitRows, preCommitAuditRows, byteStableBoundary); this.#clearScrollbackOnNextRender = false; this.#hasEverRendered = true; if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle(); @@ -2438,6 +2509,7 @@ export class TUI extends Container { for (let i = this.#committedPrefix.length; i < chunkTo; i++) { this.#committedPrefix.push(rawFrame[i] ?? ""); } + this.#updateCommittedAuditRows(committedPrefixResliced, preCommitRows, preCommitAuditRows, byteStableBoundary); } /** @@ -2450,9 +2522,10 @@ export class TUI extends Container { #auditCommittedPrefix(rawFrame: readonly string[]): void { const prefix = this.#committedPrefix; if (prefix.length === 0) return; - const resyncTo = findCommittedPrefixResync(rawFrame, prefix); + const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows); if (resyncTo < 0) return; this.#committedRows = resyncTo; + this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo); prefix.length = resyncTo; if ($flag("PI_DEBUG_REDRAW")) { const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`; @@ -2460,6 +2533,30 @@ export class TUI extends Container { } } + /** + * Recompute the byte-stable audit-rows cap after a commit. The audited prefix + * [0, auditRows) holds rows committed while byte-stable; rows committed under a + * durable snapshot end (beyond byteStableBoundary) are excluded so the audit + * never re-anchors on their expected drift (a streaming table widening). A + * wholesale re-slice (full paint / shrink / geometry) re-bases the prefix from + * the current frame, so the cap is just min(committed, byteStableBoundary). An + * incremental extend keeps the cap once any snapshot row has committed + * (auditRows < committedRows): a later rise in byteStableBoundary (a table + * finalizing) must not pull already-committed stale snapshots back under audit. + */ + #updateCommittedAuditRows( + resliced: boolean, + preCommittedRows: number, + preAuditRows: number, + byteStableBoundary: number, + ): void { + const committed = this.#committedRows; + this.#committedPrefixAuditRows = + resliced || preAuditRows >= preCommittedRows + ? Math.min(committed, byteStableBoundary) + : Math.min(preAuditRows, committed); + } + /** * Prepare the composed frame for emission, in place. Rows below * `#preparedValidRows` are already prepared against the current frame (the diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index 075e31803..9e6c8294b 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -63,6 +63,24 @@ class CommittedRowsProbe extends AppendOnlyLiveLineList implements NativeScrollb } } +/** + * A live block that is DURABLE but not byte-stable: it reports a snapshot-safe + * end (its whole body is permanent content) but no commit-safe end, and it + * re-lays-out an interior row on every render (a streaming markdown table whose + * columns re-align as rows arrive). Its scrolled-off head must still reach + * native scrollback — frozen at its scroll-off snapshot — instead of being + * dropped, and the later drift of an already-committed row must NOT spray + * duplicate snapshots into history. + */ +class SnapshotLiveLineList extends LineList implements NativeScrollbackLiveRegion { + getNativeScrollbackLiveRegionStart(): number | undefined { + return 0; + } + getNativeScrollbackSnapshotSafeEnd(): number | undefined { + return Number.POSITIVE_INFINITY; + } +} + async function settle(term: VirtualTerminal): Promise { const nextTick = Promise.withResolvers(); process.nextTick(nextTick.resolve); @@ -516,4 +534,76 @@ describe("streaming scrollback defer", () => { tui.stop(); } }); + + it("commits the scrolled-off head of a durable snapshot block even while it re-lays-out", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + // Durable but volatile: an interior row re-lays-out every frame (a table + // re-aligning), so it never earns a byte-stable commit-safe end. The block + // alone overflows the 4-row viewport. Its scrolled-off head must reach + // native scrollback (snapshot-safe end), not vanish like a volatile block. + const live = new SnapshotLiveLineList([]); + + try { + tui.addChild(live); + tui.start(); + await settle(term); + + const writes = capture(term); + + for (let n = 4; n <= 12; n++) { + const lines = rows("tbl-", n); + lines[1] = `tbl-1 [w${n}]`; // interior row re-lays-out every frame + live.setLines(lines); + tui.requestRender(); + await settle(term); + } + + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + const joined = buffer.join("\n"); + // No ED3, and every logical row reached the tape (scrollback or window). + expect(eraseScrollbackCount(writes)).toBe(0); + for (let i = 2; i < 12; i++) expect(joined).toContain(`tbl-${i}`); + // The interior row's snapshot is frozen (committed once); it is not lost. + expect(joined).toContain("tbl-1"); + } finally { + tui.stop(); + } + }); + + it("does not spray duplicate snapshots when an already-committed durable row drifts", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + const live = new SnapshotLiveLineList(rows("row-", 12)); + + try { + tui.addChild(live); + tui.start(); + await settle(term); + + // row-0 has long scrolled off and committed. Keep rewriting it (a + // scrolled-off table row re-aligning) while appending new rows. The + // committed-prefix audit must treat it as a durable snapshot and NOT + // re-anchor + recommit the whole prefix on every drift (a spray storm). + for (let n = 12; n <= 40; n++) { + const lines = rows("row-", n); + lines[0] = `row-0 [drift ${n}]`; + live.setLines(lines); + tui.requestRender(); + await settle(term); + } + + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + // Without audit-exemption every drift frame recommits the whole prefix, + // so the tape would balloon far past the ~40 logical rows. Bound it. + expect(buffer.length).toBeLessThan(60); + expect(buffer.join("\n")).toContain("row-39"); + } finally { + tui.stop(); + } + }); });