diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ad131275f..a317192f3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Fixed + +- Fixed an issue where output was lost when a commit-unstable barrier at the top of the viewport was removed or modified +- Fixed race conditions in the committed prefix audit that could cause content to be incorrectly dropped during streaming updates +- Improved the commit fidelity of forced-overflow rows to ensure they are consistently committed to the terminal scrollback buffer ## [16.1.8] - 2026-06-20 @@ -1766,4 +1771,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon ### Fixed -- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) +- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) \ No newline at end of file diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 23b22cde7..ce2bd07b3 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -812,52 +812,95 @@ const RESYNC_TAIL_SAMPLES = 8; * re-anchor the commit index when it does not. Returns the resync row index, * or -1 when no resync is needed. * - * The detector exploits the asymmetry between the two mutation classes: an - * in-place edit or restyle of committed rows disturbs only the touched rows - * (alignment below them is intact — the stale copy in history is the - * long-accepted artifact), while any insertion or deletion shifts EVERY row - * below it, including the rows just above the commit boundary. So the prefix - * *tail* is sampled (up to 8 non-blank rows within the last 24, compared - * SGR-stripped so theme changes stay quiet, tolerating one mismatch for a - * legitimate single-row edit): aligned ⇒ no resync; misaligned ⇒ resync at - * the first non-equivalent row, recommitting from there — duplication, never - * loss. Highly repetitive tails (identical filler rows) can mask a shift, in - * which case the skipped rows are content-identical to the committed ones — - * observationally harmless. Exported for the render-stress harness, whose + * Audits the committed prefix [0, auditTo) EXCEPT the exempt window + * [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming + * table re-aligning its columns) that may drift legitimately, so their drift + * never triggers a re-anchor. Rows below the window — including forced-overflow + * rows committed only because they scrolled above the viewport under a + * commit-unstable barrier — ARE audited. + * + * Two detectors run over the audited rows: + * + * 1. Hard scan of the forced suffix [exemptTo, byteStableEnd): forced-overflow + * rows that THIS frame asserts are now permanent (index < byteStableEnd — + * the barrier above them finalized or cleared). A content change there is + * real finalized content, so ANY mismatch re-anchors. Scanned in FULL, not + * sampled, so a single edit far above the commit boundary with an unchanged + * tail still re-anchors (duplication, never loss) instead of being committed + * nowhere and painted nowhere. + * 2. Tail sample (only when the hard scan is clean): exploits the asymmetry + * between the two mutation classes — an in-place edit/restyle of a committed + * row disturbs only the touched rows (alignment below intact; the stale copy + * in history is the long-accepted artifact), while an insertion/deletion + * shifts EVERY row below it. So up to 8 non-blank rows within the last 24 + * audited rows are compared SGR-stripped (theme changes stay quiet), + * tolerating one mismatch for a legitimate single-row edit of a row that is + * still volatile (index >= byteStableEnd): aligned ⇒ no resync; misaligned ⇒ + * resync at the first non-equivalent audited row. The tolerance keeps an + * offscreen still-live barrier (a ticking spinner) from spraying duplicate + * snapshots every frame; the hard scan above is what forbids it from + * swallowing a finalized row. + * + * Highly repetitive tails (identical filler rows) can mask a shift in the tail + * sample, in which case the skipped rows are content-identical to the committed + * ones — 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[], - auditLimit: number = prefix.length, + auditTo: number = prefix.length, + exemptFrom: number = auditTo, + exemptTo: number = exemptFrom, + byteStableEnd = 0, ): 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))); + const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo))); if (committed === 0) return -1; + // Exempt window [exFrom, exTo) clamped into the committed prefix. Rows there + // are durable-snapshot drift and skipped by both detectors and the scan. + const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom))); + const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo))); + const audited = (i: number): boolean => i < exFrom || i >= exTo; if (frame.length >= committed) { - let samples = 0; - let mismatches = 0; - const lookback = Math.min(RESYNC_TAIL_LOOKBACK, committed); - for (let j = 1; j <= lookback && samples < RESYNC_TAIL_SAMPLES; j++) { - const row = frame[committed - j]!; - const old = prefix[committed - j]!; - if (row === old) { - if (!isBlankRow(row)) samples++; - continue; + // 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no + // tolerance — a finalized row that changed must re-anchor. + const hardEnd = Math.min(committed, Math.max(0, Math.trunc(byteStableEnd))); + let hardMismatch = false; + for (let i = exTo; i < hardEnd; i++) { + if (!rowsEquivalent(frame[i]!, prefix[i]!)) { + hardMismatch = true; + break; } - if (isBlankRow(row) && isBlankRow(old)) continue; - samples++; - if (!rowsEquivalent(row, old)) mismatches++; } - // No signal (all-blank tail) or at most one edited row: aligned. - if (samples === 0 || mismatches <= 1) return -1; + if (!hardMismatch) { + // 2. Tail sample. Walk up from the commit boundary, skipping exempt + // rows, until LOOKBACK audited rows or SAMPLES non-blank comparisons. + let samples = 0; + let mismatches = 0; + let scanned = 0; + for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) { + const idx = committed - j; + if (!audited(idx)) continue; + scanned++; + const row = frame[idx]!; + const old = prefix[idx]!; + if (row === old) { + if (!isBlankRow(row)) samples++; + continue; + } + if (isBlankRow(row) && isBlankRow(old)) continue; + samples++; + if (!rowsEquivalent(row, old)) mismatches++; + } + // No signal (all-blank/all-exempt tail) or at most one edited row: aligned. + if (samples === 0 || mismatches <= 1) return -1; + } } - // Misaligned (or the frame no longer covers the prefix): re-anchor at the - // first row whose content actually changed. + // Misaligned (hard mismatch, tail-sample shift, or the frame no longer covers + // the prefix): re-anchor at the first audited row whose content changed. const limit = Math.min(committed, frame.length); for (let i = 0; i < limit; i++) { + if (!audited(i)) continue; if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i; } return limit < committed ? limit : -1; @@ -957,15 +1000,23 @@ 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. + // The committed prefix [0, committedRows) splits into three audit zones by + // two monotone marks auditRows ≤ durableRows ≤ committedRows: + // [0, auditRows) BYTE-STABLE — audited (re-anchor on any shift). + // [auditRows, durableRows) DURABLE snapshot — exempt: rows may drift in + // place (a streaming table widening) without re-anchoring, so their + // expected drift never sprays duplicate snapshots. + // [durableRows, committedRows) FORCED-overflow — audited: rows committed + // only because they scrolled above the window under a commit-unstable + // barrier; auditing them re-anchors (duplication, never loss) when the + // barrier later shifts/finalizes/removes, instead of stranding a stale + // prefix that silently drops the rows beneath it. + // Both marks re-base on a wholesale re-slice (full paint / shrink / geometry) + // and otherwise advance per the persistence rules in #updateCommittedAuditRows. + // #auditCommittedPrefix audits [0, committedRows) skipping the exempt window + // [auditRows, durableRows). #committedPrefixAuditRows = 0; + #committedPrefixDurableRows = 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 @@ -2530,6 +2581,29 @@ export class TUI extends Container { const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd; const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd; + // Commit boundaries (also used by the window/commit math in section 3), + // hoisted above the audit gate because the resync needs byteStableBoundary + // to tell a now-permanent forced row (must re-anchor) from a still-live one. + // The commit floor is windowTop in every non-frozen path (see chunkTo), so + // whatever scrolls above the window is committed — never committed nowhere + // AND painted nowhere (the loss bug). The boundaries no longer gate the + // commit; they define the audit-exempt span. byteStableBoundary: rows below + // it are byte-stable (never re-layout), audited. durableBoundary: rows in + // [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off + // but may drift in place (a streaming table re-aligning), committed + // audit-EXEMPT. Rows at/beyond durableBoundary committed only because they + // scrolled above the window (a commit-unstable barrier over a long tail) are + // forced-overflow rows: audited, so a later shift/finalize/removal re-anchors + // (duplication, never loss) instead of stranding a stale prefix. Built on the + // finalized prefix (live-region start); the whole frame when the root reports + // no seam (shell semantics: whatever scrolls is final). + const frameLength = rawFrame.length; + const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength)); + const durableBoundary = Math.max( + byteStableBoundary, + Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary), + ); + // 2. Transition state captured before any emitter runs. const prevWindowTop = this.#windowTopRow; const prevHardwareCursorRow = this.#hardwareCursorRow; @@ -2559,23 +2633,31 @@ export class TUI extends Container { // that provably did not change since the last (aligned) frame cannot // have diverged. let committedRowsResynced = false; + // Audit covers [0, auditRows) and the forced-overflow suffix + // [durableRows, committedRows); the durable middle is exempt. The gate + // fires only when the stable prefix does not already cover every audited + // row: the upper audited bound is committedRows when a forced suffix + // exists, else the byte-stable auditRows — so a wholly durable/exempt + // prefix (a tall snapshot block) never pays the resync walk. + const auditUpper = + this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows; if ( this.#hasEverRendered && !geometryChanged && !this.#clearScrollbackOnNextRender && - this.#renderStablePrefixRows < this.#committedPrefixAuditRows + this.#renderStablePrefixRows < auditUpper ) { const committedRowsBeforeAudit = this.#committedRows; - this.#auditCommittedPrefix(rawFrame); + this.#auditCommittedPrefix(rawFrame, byteStableBoundary); 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. + // Drives the audit-rows / durable-rows caps recomputed after the emit. const preCommitRows = this.#committedRows; const preCommitAuditRows = this.#committedPrefixAuditRows; + const preCommitDurableRows = this.#committedPrefixDurableRows; // 3. Window and commit math (lengths only; content prepared below). - const frameLength = rawFrame.length; let hasVisibleOverlay = false; for (const entry of this.overlayStack) { if (this.#isOverlayVisible(entry)) { @@ -2583,18 +2665,6 @@ export class TUI extends Container { break; } } - // 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: normally the engine // erases and replays so history rewraps at the new geometry (the reader @@ -2612,7 +2682,7 @@ export class TUI extends Container { if (fullPaint) { committedPrefixResliced = true; windowTop = Math.max(0, frameLength - height); - chunkTo = Math.min(durableBoundary, windowTop); + chunkTo = windowTop; } else if ( frameLength <= this.#committedRows || (committedRowsResynced && @@ -2630,7 +2700,7 @@ 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(durableBoundary, windowTop); + chunkTo = windowTop; committedPrefixResliced = true; this.#committedRows = chunkTo; this.#committedPrefix = rawFrame.slice(0, chunkTo); @@ -2648,10 +2718,7 @@ export class TUI extends Container { // pane keeps its own (old-wrap) history — and re-bases the audit // prefix at the new width so the accepted wrap drift does not read // as a violation on the next ordinary frame. - chunkTo = - hasVisibleOverlay || geometryChanged - ? this.#committedRows - : Math.max(this.#committedRows, Math.min(durableBoundary, windowTop)); + chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop; if (geometryChanged) { committedPrefixResliced = true; this.#committedPrefix = rawFrame.slice(0, this.#committedRows); @@ -2711,7 +2778,14 @@ export class TUI extends Container { windowTop, }); this.#committedPrefix = rawFrame.slice(0, chunkTo); - this.#updateCommittedAuditRows(true, preCommitRows, preCommitAuditRows, byteStableBoundary); + this.#updateCommittedAuditRows( + true, + preCommitRows, + preCommitAuditRows, + preCommitDurableRows, + byteStableBoundary, + durableBoundary, + ); this.#clearScrollbackOnNextRender = false; this.#hasEverRendered = true; if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle(); @@ -2730,7 +2804,14 @@ 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); + this.#updateCommittedAuditRows( + committedPrefixResliced, + preCommitRows, + preCommitAuditRows, + preCommitDurableRows, + byteStableBoundary, + durableBoundary, + ); } /** @@ -2740,13 +2821,21 @@ export class TUI extends Container { * restyles keep their alignment and are left alone (stale styling in * history was always the accepted artifact). */ - #auditCommittedPrefix(rawFrame: readonly string[]): void { + #auditCommittedPrefix(rawFrame: readonly string[], byteStableBoundary: number): void { const prefix = this.#committedPrefix; if (prefix.length === 0) return; - const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows); + const resyncTo = findCommittedPrefixResync( + rawFrame, + prefix, + prefix.length, + this.#committedPrefixAuditRows, + this.#committedPrefixDurableRows, + byteStableBoundary, + ); if (resyncTo < 0) return; this.#committedRows = resyncTo; this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo); + this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo); prefix.length = resyncTo; if ($flag("PI_DEBUG_REDRAW")) { const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`; @@ -2755,27 +2844,37 @@ 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. + * Recompute the audit-rows / durable-rows marks after a commit (see the + * #committedPrefixAuditRows field doc for the three audit zones). + * + * auditRows tracks the byte-stable boundary; durableRows the durable snapshot + * boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases + * each mark from the current frame (min(committed, boundary)). An incremental + * extend keeps a mark once a row past it has committed (mark < committed): a + * later RISE in a boundary (a table finalizing) must neither pull + * already-committed stale snapshots back under the byte-stable cap nor + * retroactively exempt forced-overflow rows already audited. durableRows is + * floored at auditRows so the exempt window can never invert. */ #updateCommittedAuditRows( resliced: boolean, preCommittedRows: number, preAuditRows: number, + preDurableRows: number, byteStableBoundary: number, + durableBoundary: number, ): void { const committed = this.#committedRows; - this.#committedPrefixAuditRows = + const auditRows = resliced || preAuditRows >= preCommittedRows ? Math.min(committed, byteStableBoundary) : Math.min(preAuditRows, committed); + const durableRows = + resliced || preDurableRows >= preCommittedRows + ? Math.min(committed, durableBoundary) + : Math.min(preDurableRows, committed); + this.#committedPrefixAuditRows = auditRows; + this.#committedPrefixDurableRows = Math.max(auditRows, durableRows); } /** diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 4c9a02778..629726ab6 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -278,6 +278,13 @@ export interface Scenario { // fixed-line components never produced. Wrapped content must agree with the // real Ghostty-backed terminal's cell widths. reflow: boolean; + // Models a commit-unstable barrier (a provisional tool preview, a displaceable + // `job` poll) pinned at row 0: the main component reports a live-region seam at + // 0 with no commit-safe/snapshot-safe end, so every committed row is a + // forced-overflow row. Exercises the engine's no-loss commit floor on the seam + // path — where durableBoundary collapses below windowTop — which the + // seam-agnostic shadow ledger covers without modification. + barrierSeam: boolean; tags: readonly ScenarioTag[]; replayOperations?: readonly OperationKind[]; } @@ -987,10 +994,12 @@ class StressComponent implements Component, Focusable { focused = false; #model: StressModel; #reflow: boolean; + #barrierSeam: boolean; - constructor(model: StressModel, reflow = false) { + constructor(model: StressModel, reflow = false, barrierSeam = false) { this.#model = model; this.#reflow = reflow; + this.#barrierSeam = barrierSeam; } invalidate(): void {} @@ -999,6 +1008,16 @@ class StressComponent implements Component, Focusable { const lines = this.#model.renderedLines(width, this.focused); return this.#reflow ? reflowToWidth(lines, width) : lines; } + + // Commit-unstable barrier seam: live region from row 0, no commit-safe or + // snapshot-safe end. Collapses durableBoundary to 0, so the whole committed + // prefix is forced-overflow — the engine must still commit every scrolled-off + // row (no-loss floor). A regression to a min(durableBoundary, windowTop) clamp + // would commit nothing here and diverge from the shadow tape (chunkTo = + // windowTop), tripping the native-scrollback fidelity oracle. + getNativeScrollbackLiveRegionStart(): number | undefined { + return this.#barrierSeam ? 0 : undefined; + } } class StressOverlayModel { @@ -1165,7 +1184,7 @@ class StressDriver { this.#scheduler = new StressRenderScheduler(); const maxHeight = maxOf(scenario.heightChoices); this.#model = new StressModel(this.#streams.content, maxHeight + 12, scenario.uniqueContent, "root-"); - this.#component = new StressComponent(this.#model, scenario.reflow); + this.#component = new StressComponent(this.#model, scenario.reflow, scenario.barrierSeam); this.#children = [0, 1].map(id => { const model = new StressModel( this.#streams.children, @@ -3428,6 +3447,7 @@ function materializeScenario( template.envMode !== "tmux" && template.terminalMode === "normal" && template.platform !== "win32"; const foregroundStream = template.foregroundStream ?? false; const reflow = template.reflow ?? false; + const barrierSeam = template.barrierSeam ?? false; return { ...template, seed, @@ -3439,6 +3459,7 @@ function materializeScenario( uniqueContent: template.uniqueContent ?? false, foregroundStream, reflow, + barrierSeam, tags: scenarioTags(template, strictScrollback, foregroundStream), replayOperations, }; @@ -3544,11 +3565,13 @@ type ScenarioTemplate = Omit< | "reflow" | "tags" | "replayOperations" + | "barrierSeam" > & { scrollbackRows?: number; uniqueContent?: boolean; foregroundStream?: boolean; reflow?: boolean; + barrierSeam?: boolean; }; function writeReplayLog(scenario: Scenario, operations: readonly OperationLogEntry[]): string { @@ -3786,6 +3809,26 @@ function coreTemplates(): ScenarioTemplate[] { reflow: true, foregroundStream: true, }, + { + // Commit-unstable barrier (provisional tool preview / displaceable poll) + // pinned at row 0 over content that overflows the viewport. The engine's + // commit floor must push every scrolled-off row into native scrollback + // even though the barrier is never byte-stable, driving the seam path + // where durableBoundary collapses below windowTop — the exact gap that + // silently dropped rows. A regression to a min(durableBoundary, + // windowTop) clamp makes the engine diverge from the seam-agnostic + // shadow ledger and trips the native-scrollback fidelity oracle. + name: "darwin-normal-barrier-seam-small", + platform: "darwin", + terminalMode: "normal", + envMode: "plain", + geometryMode: "small", + columns: 32, + rows: 4, + widthChoices: [8, 16, 24, 32], + heightChoices: [3, 4, 6], + barrierSeam: true, + }, ]; } diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index 2758604e7..6761c2672 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -142,7 +142,7 @@ describe("streaming scrollback defer", () => { savedTerminalEnv = {}; }); - it("keeps mutable live-region head rows out of native scrollback", async () => { + it("commits the live-region head to native scrollback without loss", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); @@ -162,36 +162,39 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); - // The sealed prefix is stable and may enter native scrollback. The - // live block's head (think-0/think-1) has physically left the viewport, - // but it is still mutable; committing it would leave stale rows in - // history when the live block re-renders or collapses. + // The live block's head (think-0/think-1) scrolls above the 4-row + // viewport. The engine floor commits every row that scrolls off, so the + // head reaches native scrollback instead of vanishing — committed + // nowhere, painted nowhere (the loss bug). No ED3 erase. expect(eraseScrollbackCount(writes)).toBe(0); expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([ ...rows("prior-", 12), - ...rows("think-", 6).slice(-4), + ...rows("think-", 6), ]); + // Append-only growth: the committed head is byte-identical, so it never + // re-anchors or duplicates; the new tail just extends. live.setLines(rows("think-", 8)); tui.requestRender(); await settle(term); const buffer = term.getScrollBuffer().map(line => line.trimEnd()); expect(eraseScrollbackCount(writes)).toBe(0); - expect(buffer).toEqual([...rows("prior-", 12), ...rows("think-", 8).slice(-4)]); + expect(buffer).toEqual([...rows("prior-", 12), ...rows("think-", 8)]); } finally { tui.stop(); } }); - it("keeps a tall all-live block transient when no sealed prefix exists", async () => { + it("commits a tall all-live block's scrolled head to native scrollback", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); const tui = new TUI(term); - // The only block is the live one (liveRegionStart === 0). Rows above - // the viewport are mutable, so they must stay out of native scrollback - // instead of being committed as stale history. + // The only block is the live one (liveRegionStart === 0). Rows that scroll + // above the viewport are committed by the engine floor so they reach native + // scrollback rather than vanishing; the block grows append-only here, so no + // committed row is ever rewritten (no duplication). const live = new LiveLineList([]); try { @@ -205,11 +208,10 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); - // tool-0..tool-5 scrolled above the 4-row viewport, but the whole - // block is mutable; only tool-6..tool-9 should remain in the native - // buffer. + // tool-0..tool-5 scrolled above the 4-row viewport and reach native + // scrollback; tool-6..tool-9 stay in the viewport. Nothing is lost. expect(eraseScrollbackCount(writes)).toBe(0); - expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("tool-", 10).slice(-4)); + expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("tool-", 10)); } finally { tui.stop(); } @@ -247,7 +249,7 @@ describe("streaming scrollback defer", () => { } }); - it("does not leave stale mutable live-region rows in native scrollback after a rerender", async () => { + it("recommits fresh rows without loss when the live region is replaced wholesale", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(24, 4); overrideProbe(term, undefined); @@ -271,16 +273,21 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); + // The volatile block's head force-committed while it overflowed, then was + // replaced wholesale. The committed-prefix audit re-anchors at the first + // diverged row and recommits the fresh content: every running-fresh row + // reaches the tape (no loss). A stale pending-stale copy may stay frozen in + // native scrollback — duplication, never loss — which a full repaint (ED3 + // on a real resize / Ctrl+L) clears; no ED3 fires during streaming. const buffer = term.getScrollBuffer().map(line => line.trimEnd()); expect(eraseScrollbackCount(writes)).toBe(0); - expect(buffer.some(line => line.startsWith("pending-stale-"))).toBe(false); - expect(buffer).toContain("running-fresh-9"); + for (const row of rows("running-fresh-", 10)) expect(buffer).toContain(row); } finally { tui.stop(); } }); - it("keeps the topmost live seam when a lower sibling also reports one", async () => { + it("keeps the topmost live seam and recommits fresh rows when a lower sibling also reports one", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(24, 4); overrideProbe(term, undefined); @@ -291,7 +298,7 @@ describe("streaming scrollback defer", () => { // Status loader below the transcript: also reports a seam. Commits are // prefix-only, so the engine must keep the TOPMOST seam — letting the // lower sibling's seam win would move the boundary past the transcript's - // still-mutable rows and commit them as stale history. + // still-mutable rows. const loader = new LiveLineList(["Working..."]); try { @@ -311,10 +318,11 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); + // Fresh content recommits with no loss after the wholesale replace; a + // stale copy may remain frozen above it (duplication, never loss). No ED3. const buffer = term.getScrollBuffer().map(line => line.trimEnd()); expect(eraseScrollbackCount(writes)).toBe(0); - expect(buffer.some(line => line.startsWith("pending-stale-"))).toBe(false); - expect(buffer).toContain("running-fresh-9"); + for (const row of rows("running-fresh-", 10)) expect(buffer).toContain(row); } finally { tui.stop(); } @@ -626,3 +634,407 @@ describe("streaming scrollback defer", () => { } }); }); + +/** + * Root child that reports an arbitrary `NativeScrollbackLiveRegion` seam, so a + * test can reproduce any barrier shape the TranscriptContainer emits without + * standing up the whole transcript. `undefined` on a method means "no seam". + */ +class SeamComponent implements Component, NativeScrollbackLiveRegion { + lines: string[] = []; + liveStart: number | undefined; + commitSafe: number | undefined; + snapSafe: number | undefined; + + invalidate(): void {} + + render(width: number): string[] { + return this.lines.map(line => line.slice(0, width)); + } + + getNativeScrollbackLiveRegionStart(): number | undefined { + return this.liveStart; + } + + getNativeScrollbackCommitSafeEnd(): number | undefined { + return this.commitSafe; + } + + getNativeScrollbackSnapshotSafeEnd(): number | undefined { + return this.snapSafe; + } +} + +/** Indices in `buffer` where `needle` begins as a contiguous run. */ +function contiguousAt(buffer: string[], needle: string[]): number[] { + const hits: number[] = []; + for (let i = 0; i + needle.length <= buffer.length; i++) { + let match = true; + for (let j = 0; j < needle.length; j++) { + if (buffer[i + j] !== needle[j]) { + match = false; + break; + } + } + if (match) hits.push(i); + } + return hits; +} + +/** + * Structural no-loss invariant: the current frame appears as a contiguous run in + * order, and below its last occurrence there is only blank viewport padding + * (fresh content is the most recent thing on the tape; stale duplicates may sit + * above it — duplication, never loss). A row in neither history nor the viewport + * was "committed nowhere, painted nowhere" — the bug. The blank tolerance covers + * sub-viewport frames, whose viewport is padded with blank rows beneath the + * content. Returns the trimmed tape for further assertions. + */ +function expectNoLoss(term: VirtualTerminal, frame: string[]): string[] { + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + const trimmed = frame.map(line => line.trimEnd()); + const hits = contiguousAt(buffer, trimmed); + expect(hits.length).toBeGreaterThan(0); + // Nothing but blank padding may follow the frame's last (most recent) run: + // any non-blank row below it is content painted out of order or a duplicate + // of fresher content sitting above its source — both are loss-class bugs. + const tailStart = hits.at(-1)! + trimmed.length; + for (let i = tailStart; i < buffer.length; i++) { + expect(buffer[i]).toBe(""); + } + return buffer; +} + +describe("scrollback commit gap — commit-unstable barriers", () => { + let savedTerminalEnv: Record = {}; + beforeEach(() => { + for (const key of ["TERM_PROGRAM", "PI_TUI_RESIZE_IN_PLACE"]) { + savedTerminalEnv[key] = Bun.env[key]; + delete Bun.env[key]; + } + }); + afterEach(() => { + for (const key in savedTerminalEnv) { + const value = savedTerminalEnv[key]; + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } + savedTerminalEnv = {}; + }); + + it("does not drop the tail when a pending barrier above it is removed (S5/S6)", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + const root = new SeamComponent(); + + try { + tui.addChild(root); + tui.start(); + await settle(term); + const writes = capture(term); + + // Small commit-unstable barrier above a long finalized tail, overflowing + // the 4-row viewport. liveStart=0 pins the seam; no commit/snapshot end. + const f1 = ["[tool pending]", ...rows("ans-", 8)]; + root.lines = f1; + root.liveStart = 0; + tui.requestRender(); + await settle(term); + expectNoLoss(term, f1); + + // Barrier removed (agent moved past the tool / poll superseded): the tail + // shifts up. The audit must re-anchor instead of trusting the stale + // committed prefix and skipping the shifted rows. + const f2 = rows("ans-", 8); + root.lines = f2; + root.liveStart = undefined; + tui.requestRender(); + await settle(term); + + const buffer = expectNoLoss(term, f2); + for (const row of f2) expect(buffer).toContain(row); + expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("ans-", 8).slice(-4)); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); + + it("does not drop result rows when a provisional preview is replaced by a longer result (S4)", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + const root = new SeamComponent(); + + try { + tui.addChild(root); + tui.start(); + await settle(term); + const writes = capture(term); + + const preview = rows("preview-", 10); + root.lines = preview; + root.liveStart = 0; + tui.requestRender(); + await settle(term); + expectNoLoss(term, preview); + + const result = rows("result-", 9); + root.lines = result; + root.liveStart = undefined; + tui.requestRender(); + await settle(term); + + const buffer = expectNoLoss(term, result); + for (const row of result) expect(buffer).toContain(row); + expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("result-", 9).slice(-4)); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); + + it("does not drop rows when a barrier partially collapses above a long tail (S10)", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + const root = new SeamComponent(); + + try { + tui.addChild(root); + tui.start(); + await settle(term); + const writes = capture(term); + + // 3-row barrier over an 8-row tail (len 11), overflowing. + const f1 = [...rows("bar-", 3), ...rows("tail-", 8)]; + root.lines = f1; + root.liveStart = 0; + tui.requestRender(); + await settle(term); + expectNoLoss(term, f1); + + // Barrier collapses to 1 row but the frame stays longer than the + // committed prefix (NOT the shrink-into-prefix branch), so the audit must + // catch the upward tail shift. + const f2 = ["bar-collapsed", ...rows("tail-", 8)]; + root.lines = f2; + root.liveStart = undefined; + tui.requestRender(); + await settle(term); + + const buffer = expectNoLoss(term, f2); + for (const row of rows("tail-", 8)) expect(buffer).toContain(row); + expect(term.getViewport().map(line => line.trimEnd())).toEqual(f2.slice(-4)); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); + + it("keeps a finalized tail in order when its live barrier sibling is removed (multi-child S6)", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 5); + overrideProbe(term, undefined); + const tui = new TUI(term); + // Realistic transcript shape: a still-live barrier block above a finalized + // tail block. The container concatenates them; the topmost seam (the + // barrier at row 0) pins the boundary, forcing the finalized tail to commit + // under it as it overflows. + const barrier = new LiveLineList(["[tool pending]"]); + const tail = new LineList(rows("out-", 10)); + + try { + tui.addChild(barrier); + tui.addChild(tail); + tui.start(); + await settle(term); + const writes = capture(term); + + // Force overflow: the concatenated frame is 11 rows over a 5-row viewport. + tui.requestRender(); + await settle(term); + expectNoLoss(term, ["[tool pending]", ...rows("out-", 10)]); + + // Remove the barrier. The finalized tail shifts up by one row; every + // out-* row must remain, in order, contiguous at the tape bottom. + tui.removeChild(barrier); + tui.requestRender(); + await settle(term); + + const buffer = expectNoLoss(term, rows("out-", 10)); + // Strong in-order check: the whole tail is one contiguous run at the + // bottom (not merely each row present somewhere). + expect(buffer.slice(-10)).toEqual(rows("out-", 10)); + expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("out-", 10).slice(-5)); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); + + it("survives a streaming-then-removed barrier across many frames without loss", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 5); + overrideProbe(term, undefined); + const tui = new TUI(term); + const root = new SeamComponent(); + + try { + tui.addChild(root); + tui.start(); + await settle(term); + + // Pending barrier above a tail that grows every frame, overflowing + // further each tick; the barrier pins the seam at 0 (commit-unstable). + for (let n = 1; n <= 20; n++) { + const frame = ["[pending]", ...rows("row-", n)]; + root.lines = frame; + root.liveStart = 0; + tui.requestRender(); + await settle(term); + expectNoLoss(term, frame); + } + + const final = rows("row-", 20); + root.lines = final; + root.liveStart = undefined; + tui.requestRender(); + await settle(term); + + const buffer = expectNoLoss(term, final); + for (const row of final) expect(buffer).toContain(row); + expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("row-", 20).slice(-5)); + } finally { + tui.stop(); + } + }); + + it("audits a forced card below a durable prose tail without spraying its snapshot", async () => { + if (process.platform === "win32") return; + // Coexistence case: a commit-stable streaming block whose volatile tail is + // durable-exempt (snapSafe past its body) with a finalized card committed + // BELOW it. The card is a forced-overflow row that MUST stay audited, while + // the prose tail's in-place re-wrap must NOT spray duplicate snapshots. + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + const root = new SeamComponent(); + + try { + tui.addChild(root); + tui.start(); + await settle(term); + const writes = capture(term); + + for (let n = 0; n < 12; n++) { + const prose = rows("prose-", 8); + prose[7] = `prose-7 [w${n}]`; // volatile tail re-wraps in place + root.lines = [...prose, "card-0", "card-1"]; + root.liveStart = 0; + root.commitSafe = 7; // byte-stable through prose-6 + root.snapSafe = 8; // durable through the whole prose body + tui.requestRender(); + await settle(term); + } + + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + // No spray: durable prose head committed once, not once-per-drift. + expect(contiguousAt(buffer, ["prose-0", "prose-1", "prose-2"]).length).toBeLessThanOrEqual(2); + // No loss: the finalized card below the durable tail reached the tape. + expect(buffer).toContain("card-0"); + expect(buffer).toContain("card-1"); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); + + it("does not lose a single-row finalize edit above an unchanged tail (reviewer repro)", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + const root = new SeamComponent(); + + try { + tui.addChild(root); + tui.start(); + await settle(term); + const writes = capture(term); + + // Commit-unstable barrier ("preview") above an 8-row tail, overflowing. + const f1 = ["preview", ...rows("tail-", 8)]; + root.lines = f1; + root.liveStart = 0; + tui.requestRender(); + await settle(term); + expectNoLoss(term, f1); + + // Finalize: the seam clears and ONLY row 0 changes (preview→result); the + // whole tail is byte-identical. The committed head "preview" scrolled + // above the viewport, so the change is invisible unless the audit + // re-anchors. Tail-sample tolerance alone would skip it (one mismatch + // over an aligned tail) and "result" would be committed nowhere, painted + // nowhere — the reviewer's loss. The hard scan of the now-permanent + // forced suffix forces the re-anchor. + const f2 = ["result", ...rows("tail-", 8)]; + root.lines = f2; + root.liveStart = undefined; // seam cleared: forced rows are now permanent + tui.requestRender(); + await settle(term); + + const buffer = expectNoLoss(term, f2); + expect(buffer).toContain("result"); + expect(term.getViewport().map(line => line.trimEnd())).toEqual(f2.slice(-4)); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); + + it("does not lose a single-row finalize edit far above a long unchanged tail (deep tail)", async () => { + if (process.platform === "win32") return; + const term = new VirtualTerminal(20, 4); + overrideProbe(term, undefined); + const tui = new TUI(term); + const root = new SeamComponent(); + + try { + tui.addChild(root); + tui.start(); + await settle(term); + const writes = capture(term); + + // The changed row sits ~30 rows above the commit boundary with an + // unchanged tail — far outside the 24-row tail-sample lookback, so only a + // FULL scan of the now-permanent forced suffix catches it. + const f1 = ["preview", ...rows("tail-", 30)]; + root.lines = f1; + root.liveStart = 0; + tui.requestRender(); + await settle(term); + expectNoLoss(term, f1); + + const f2 = ["result", ...rows("tail-", 30)]; + root.lines = f2; + root.liveStart = undefined; + tui.requestRender(); + await settle(term); + + const buffer = expectNoLoss(term, f2); + // "result" reached the tape exactly once: the stale "preview" head was + // never overwritten in place (no ED3) but the changed row is "result", + // not a second "preview" — duplication of identical rows, never loss. + expect(buffer).toContain("result"); + expect(buffer.filter(line => line === "result")).toHaveLength(1); + expect(eraseScrollbackCount(writes)).toBe(0); + } finally { + tui.stop(); + } + }); +});