diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index c59bf4456..f06390552 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -2659,7 +2659,7 @@ mod tests { for index in 0..300 { let path = root - .join(format!("dir_{:02}/nested_{:02}/file_{index:03}.txt", index % 12, index % 5,)); + .join(format!("dir_{:02}/nested_{:02}/file_{index:03}.txt", index % 12, index % 5)); let content = if index % 3 == 0 { format!("before {index}\nneedle {index}\nafter {index}\n") } else { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 694779b88..229561c9a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,10 +2,18 @@ ## [Unreleased] +### Added + +- Added `Markdown.getLastRenderSettledRows()`: the rendered frozen-token-prefix row count of the most recent streaming render, exposed (hard-monotone per text lineage) for native-scrollback commit gating. Frozen-prefix code blocks now syntax-highlight during streaming renders so settled rows stay byte-stable across finalize. + ### Changed -- Simplified native scrollback logic to use a single finality boundary -- Optimized committed prefix auditing by removing exempt-zone tracking +- Simplified native scrollback logic to use a single finality boundary: `NativeScrollbackLiveRegion` is now one method (`getNativeScrollbackLiveRegionStart`), and the engine commits a row only when the seam declares it final. Live rows that scroll above the viewport are deferred (never force-committed) and backfill history in order once they settle — a still-mutating block can no longer strand a stale copy in immutable scrollback. `getNativeScrollbackCommitSafeEnd`/`getNativeScrollbackSnapshotSafeEnd` are removed. +- Optimized committed prefix auditing by removing exempt-zone tracking: the audit is single-zone (tail sample + first-divergence re-anchor) and runs only when the composed frame's stable prefix does not cover the committed rows. + +### Fixed + +- Fixed live tool/eval preview boxes duplicating into native scrollback mid-run (a collapsed streaming preview's head was force-committed when it scrolled above the viewport, and the next re-layout re-anchored and recommitted the whole box below the stale copy). ## [16.3.5] - 2026-07-04 diff --git a/packages/tui/test/abort-collapse-gap.test.ts b/packages/tui/test/abort-collapse-gap.test.ts index 211c927cb..d4ce9122e 100644 --- a/packages/tui/test/abort-collapse-gap.test.ts +++ b/packages/tui/test/abort-collapse-gap.test.ts @@ -9,8 +9,8 @@ import { VirtualTerminal } from "./virtual-terminal"; class Transcript implements Component, NativeScrollbackLiveRegion { lines: string[] = []; + /** Declared-final boundary: rows above it may enter native scrollback. */ seam = 0; - safeEnd: number | undefined; invalidate(): void {} @@ -21,10 +21,6 @@ class Transcript implements Component, NativeScrollbackLiveRegion { getNativeScrollbackLiveRegionStart(): number | undefined { return this.seam; } - - getNativeScrollbackCommitSafeEnd(): number | undefined { - return this.safeEnd; - } } class Editor implements Component, Focusable { @@ -58,29 +54,27 @@ describe("abort-collapse gap regression", () => { // 20 finalized rows + live block streaming. transcript.lines = [...rows("hist-", 20), ...rows("live-", 4)]; - transcript.seam = 20; - transcript.safeEnd = 20; // nothing sealed yet + transcript.seam = 20; // nothing of the live block declared final yet try { tui.start(); await scheduler.drain(term); - // Stream: live block grows, sealed prefix advances behind the tail. + // Stream: live block grows, its declared-final prefix advances behind + // the tail (a streaming reply's settled markdown prefix). for (let grow = 8; grow <= 24; grow += 4) { transcript.lines = [...rows("hist-", 20), ...rows("live-", grow)]; - transcript.seam = 20; - transcript.safeEnd = 20 + grow - 2; // sealed prefix trails by 2 + transcript.seam = 20 + grow - 2; // declared-final prefix trails by 2 tui.requestRender(); await scheduler.drain(term); } - // ABORT: the live block collapses after some of its append-only prefix - // reached native scrollback. The viewport should pull the short tail - // back down instead of pinning the editor near the top with blank rows - // underneath. + // ABORT: the live block collapses after some of its declared-final + // prefix reached native scrollback. The viewport should pull the short + // tail back down instead of pinning the editor near the top with blank + // rows underneath. transcript.lines = [...rows("hist-", 20), "aborted!", "interrupted"]; transcript.seam = 22; - transcript.safeEnd = 22; tui.requestRender(); await scheduler.drain(term); diff --git a/packages/tui/test/committed-prefix-resync.test.ts b/packages/tui/test/committed-prefix-resync.test.ts index 707953d11..5617e749d 100644 --- a/packages/tui/test/committed-prefix-resync.test.ts +++ b/packages/tui/test/committed-prefix-resync.test.ts @@ -2,24 +2,18 @@ import { describe, expect, it } from "bun:test"; import { findCommittedPrefixResync } from "@oh-my-pi/pi-tui"; // Regression coverage for the committed-prefix resync seam that decides where the -// engine re-anchors #committedRows after a live block re-lays-out at settle. +// engine re-anchors #committedRows after a component violates the declared-final +// contract (a budget-demoted image, a TTSR rewind, a post-finalize mutation). // // Contract, condensed from the tui.ts doc: -// findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd) +// findCommittedPrefixResync(frame, prefix, auditTo) // ► returns -1 when frame is aligned with prefix -// ► returns i the earliest AUDITED row index where they diverge -// ► rows in [exemptFrom, exemptTo) are exempt — durable in-place drift -// ► rows in [exemptTo, permanentEnd) are hard-scanned in FULL — a settle-time -// edit there re-anchors even when the tail sample would tolerate it -// ► one non-hard mismatch in the tail sample (last 24 audited rows / 8 -// non-blank samples) is tolerated (offscreen spinner, single in-place edit) +// ► returns i the earliest row index where they diverge +// ► one mismatch in the tail sample (last 24 rows / 8 non-blank samples) +// is tolerated (a no-seam root's offscreen animated row, a single +// in-place edit) — the stale copy in history is the accepted artifact // ► frame.length < prefix.length always re-anchors at frame.length so the // shrunk tail is dropped from history (duplication, never loss) -// -// Issue #4124: a formerly forced-overflow row that later becomes permanent AND -// changes MUST re-anchor at the EARLIEST audited mismatch, not somewhere later -// and not never — otherwise a settle transition strands stale pending chrome -// (e.g. `⏳ SSH: [host]`) above the final settled block. function rows(prefix: string, count: number): string[] { return Array.from({ length: count }, (_, i) => `${prefix}${i}`); @@ -67,88 +61,6 @@ describe("findCommittedPrefixResync", () => { expect(findCommittedPrefixResync(frame, prefix)).toBe(14); }); - it("re-anchors at the earliest audited mismatch even when a later hard mismatch triggers the audit", () => { - // Issue #4124 core: a formerly forced-overflow row later becomes - // permanent and its content changes. The hard scan detects the - // permanent-zone change and forces the audit; the re-anchor loop must - // walk from row 0 and return the FIRST audited mismatch, not the hard - // scan's stop position — otherwise an earlier stranded pending row - // (e.g. `⏳ SSH: [host]`) is left uncommitted-behind above the settled - // block. - // - // Geometry: auditRows=5 (byte-stable zone [0,5)), durableRows=10 (exempt - // window [5,10)), durableBoundary=15 rose past three previously - // forced-overflow rows (hard-scanned suffix [10,15)). Row 3 changed - // (byte-stable) AND row 12 changed (in the hard-scanned newly-permanent - // zone). The hard scan trips on row 12, but the re-anchor MUST return - // row 3 — the earliest audited divergence. - const prefix = rows("r", 20); - const frame = [...prefix]; - frame[3] = "r3-changed"; - frame[12] = "r12-permanent-now-changed"; - const auditTo = 20; - const exemptFrom = 5; - const exemptTo = 10; - const permanentEnd = 15; - expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(3); - }); - - it("hard-scans the newly-permanent forced suffix in full, escaping tail-sample tolerance", () => { - // A single mismatch inside the forced suffix that just became permanent - // (durableBoundary rose past it this frame) MUST re-anchor, even though - // one non-hard mismatch would otherwise be tolerated. This is what - // prevents a pending SSH header from silently swallowing its settled - // replacement — the header row was forced-overflow while streaming, now - // the block finalized, and the tolerance would otherwise eat it. - // - // Geometry: byte-stable zone [0,3), exempt window [3,7), hard-scan zone - // [7,12) — the barrier just finalized past row 11 so those rows are - // declared permanent this frame. Row 10 flips content: the hard scan - // catches it and re-anchors at row 10. - const prefix = rows("r", 20); - const frame = [...prefix]; - frame[10] = "r10-settled"; - const auditTo = 20; - const exemptFrom = 3; - const exemptTo = 7; - const permanentEnd = 12; - expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(10); - }); - - it("keeps drift inside the exempt window silent (durable in-place snapshot)", () => { - // Rows in [exemptFrom, exemptTo) are durable snapshots that legitimately - // drift in place (a streaming table re-aligning columns). Their - // mismatch must NOT re-anchor — otherwise every column-realign frame - // sprays duplicate snapshots. - const prefix = rows("r", 20); - const frame = [...prefix]; - frame[6] = "r6-realigned"; - frame[7] = "r7-realigned"; - const auditTo = 20; - const exemptFrom = 5; - const exemptTo = 15; - const permanentEnd = 5; - expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(-1); - }); - - it("re-anchors at the earliest audited mismatch outside the exempt window", () => { - // The exempt window is scoped drift: a mismatch OUTSIDE it (byte-stable - // audited zone [0, exemptFrom) or the forced suffix [exemptTo, - // committed)) still re-anchors, and it does so at the earliest audited - // row — even when the earliest is in the byte-stable zone above the - // exempt window. - const prefix = rows("r", 20); - const frame = [...prefix]; - frame[2] = "r2-shift"; - frame[16] = "r16-shift"; - frame[17] = "r17-shift"; - const auditTo = 20; - const exemptFrom = 5; - const exemptTo = 15; - const permanentEnd = 5; - expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(2); - }); - it("re-anchors at frame.length when the frame shrinks into the committed prefix", () => { // A shrink drops rows the prefix still holds. The engine has no way to // keep those rows painted — history keeps whatever scrolled off, and @@ -178,20 +90,4 @@ describe("findCommittedPrefixResync", () => { // auditTo=10 means rows 10..19 are outside the audit expect(findCommittedPrefixResync(frame, prefix, 10)).toBe(-1); }); - - it("still re-anchors when a mismatch straddles the byte-stable/forced-suffix boundary", () => { - // exempt=[5,15); mismatches at row 4 (byte-stable) and row 16 (forced - // suffix). The re-anchor MUST return 4 (earliest audited), never 16 — - // this guards against a bug where the re-anchor loop started at exTo - // instead of 0. - const prefix = rows("r", 20); - const frame = [...prefix]; - frame[4] = "r4-changed"; - frame[16] = "r16-changed"; - const auditTo = 20; - const exemptFrom = 5; - const exemptTo = 15; - const permanentEnd = 5; - expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(4); - }); }); diff --git a/packages/tui/test/issue-1974-repro.test.ts b/packages/tui/test/issue-1974-repro.test.ts index 4f4b837fe..5cfd2c17f 100644 --- a/packages/tui/test/issue-1974-repro.test.ts +++ b/packages/tui/test/issue-1974-repro.test.ts @@ -41,8 +41,9 @@ class LineList implements Component { /** * Minimal append-only live region. Models the real omp setup where - * `TranscriptContainer` wraps an `AssistantMessageComponent` that reports - * itself as `isTranscriptBlockAppendOnly() === true`. + * `TranscriptContainer` wraps a streaming `AssistantMessageComponent` whose + * rendered rows are all declared final (settled) as they stream, so its + * scrolled-off head commits to pane history mid-stream. */ class StreamingLiveRegion implements Component, NativeScrollbackLiveRegion { #lines: string[]; @@ -62,10 +63,6 @@ class StreamingLiveRegion implements Component, NativeScrollbackLiveRegion { } getNativeScrollbackLiveRegionStart(): number | undefined { - return 0; - } - - getNativeScrollbackCommitSafeEnd(): number | undefined { return this.#lines.length; } } diff --git a/packages/tui/test/issue-2130-repro.test.ts b/packages/tui/test/issue-2130-repro.test.ts index 7f59a023f..9e0e9c10f 100644 --- a/packages/tui/test/issue-2130-repro.test.ts +++ b/packages/tui/test/issue-2130-repro.test.ts @@ -41,10 +41,7 @@ class StreamingLiveRegion implements Component, NativeScrollbackLiveRegion { } getNativeScrollbackLiveRegionStart(): number | undefined { - return 0; - } - - getNativeScrollbackCommitSafeEnd(): number | undefined { + // Append-only stream: every rendered row is declared final. return this.#lines.length; } } diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index 1fdd86018..5c0609d24 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -7,6 +7,23 @@ import { } from "@oh-my-pi/pi-tui"; import { VirtualTerminal } from "./virtual-terminal"; +// Law-encoding suite for gated native-scrollback commits. +// +// The engine commits a row to native scrollback only when the component seam +// declares it FINAL (`getNativeScrollbackLiveRegionStart`). Live rows that +// scroll above the window are neither painted nor committed — a deferred gap — +// and enter history later, in order, exactly once, when the boundary passes +// them. A root with no seam commits everything that scrolls (shell semantics). +// Consequences pinned here: +// ► a volatile live block leaves NO trace on the tape until it finalizes — +// no stale preview copies, ever; +// ► an append-only stream that declares its rendered rows final commits its +// scrolled-off head mid-stream; +// ► removing/collapsing/replacing a live block backfills the tape with the +// final content only; +// ► a declared-final row that later mutates is a contract violation the +// audit repairs by re-anchoring once (duplication, never loss). + class LineList implements Component { #lines: string[]; @@ -25,21 +42,18 @@ class LineList implements Component { } } -class LiveLineList extends LineList implements NativeScrollbackLiveRegion { - getNativeScrollbackLiveRegionStart(): number | undefined { - return 0; - } -} - /** - * A live block whose rendered rows only grow at the bottom and never re-layout - * (a streaming assistant reply). Its entire body is append-only, so scrolled-off - * head rows are safe to commit to native scrollback. `Infinity` is clamped to - * the rendered length by TUI's aggregation. + * Live block with a settable declared-final boundary: + * 0 — nothing final (a volatile tool preview); + * Infinity — everything rendered so far is final (an append-only streaming + * reply; the engine clamps to the rendered length); + * undefined — no seam (finalized block / plain shell content). */ -class AppendOnlyLiveLineList extends LiveLineList { - getNativeScrollbackCommitSafeEnd(): number | undefined { - return Number.POSITIVE_INFINITY; +class SeamLineList extends LineList implements NativeScrollbackLiveRegion { + seam: number | undefined = 0; + + getNativeScrollbackLiveRegionStart(): number | undefined { + return this.seam; } } @@ -49,10 +63,15 @@ class AppendOnlyLiveLineList extends LiveLineList { * child (e.g. the transcript container) can skip re-deriving blocks that * already live in immutable native scrollback. */ -class CommittedRowsProbe extends AppendOnlyLiveLineList implements NativeScrollbackCommittedRows { +class CommittedRowsProbe extends SeamLineList implements NativeScrollbackCommittedRows { #committedRows = 0; committedRowsAtRender: number[] = []; + constructor(lines: string[]) { + super(lines); + this.seam = Number.POSITIVE_INFINITY; + } + setNativeScrollbackCommittedRows(rows: number): void { this.#committedRows = rows; } @@ -63,24 +82,6 @@ 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); @@ -101,7 +102,7 @@ async function settleResize(term: VirtualTerminal): Promise { function capture(term: VirtualTerminal): string[] { const writes: string[] = []; const realWrite = term.write.bind(term); - (term as unknown as { write: (s: string) => void }).write = (data: string) => { + term.write = (data: string) => { writes.push(data); realWrite(data); }; @@ -109,7 +110,10 @@ function capture(term: VirtualTerminal): string[] { } function overrideProbe(term: VirtualTerminal, answer: boolean | undefined): void { - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => answer; + // The probe is not on VirtualTerminal's public type; shadow it so the + // scrollback math stays deterministic in headless runs. + const probeHost = term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }; + probeHost.isNativeViewportAtBottom = () => answer; } const ERASE_SCROLLBACK = /\x1b\[3J/g; @@ -122,33 +126,66 @@ function rows(prefix: string, count: number): string[] { return Array.from({ length: count }, (_, i) => `${prefix}${i}`); } +/** Scrollback history + active grid, right-trimmed, trailing blank rows dropped. */ +function tape(term: VirtualTerminal): string[] { + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + while (buffer.length > 0 && buffer.at(-1) === "") buffer.pop(); + return buffer; +} + +/** 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; +} + +function saveTerminalEnv(): Record { + // A resize on Warp takes the in-place path (no ED3), so neutralize the + // ambient terminal identity to keep the direct-terminal scrollback + // assertions deterministic on any dev machine. + const saved: Record = {}; + for (const key of ["TERM_PROGRAM", "PI_TUI_RESIZE_IN_PLACE"]) { + saved[key] = Bun.env[key]; + delete Bun.env[key]; + } + return saved; +} + +function restoreTerminalEnv(saved: Record): void { + for (const key in saved) { + const value = saved[key]; + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } +} + describe("streaming scrollback defer", () => { let savedTerminalEnv: Record = {}; beforeEach(() => { - // A resize on Warp takes the in-place path (no ED3), so neutralize the - // ambient terminal identity to keep the direct-terminal scrollback - // assertions below deterministic on any dev machine. - for (const key of ["TERM_PROGRAM", "PI_TUI_RESIZE_IN_PLACE"]) { - savedTerminalEnv[key] = Bun.env[key]; - delete Bun.env[key]; - } + savedTerminalEnv = saveTerminalEnv(); }); afterEach(() => { - for (const key in savedTerminalEnv) { - const value = savedTerminalEnv[key]; - if (value === undefined) delete Bun.env[key]; - else Bun.env[key] = value; - } + restoreTerminalEnv(savedTerminalEnv); savedTerminalEnv = {}; }); - it("commits the live-region head to native scrollback without loss", async () => { + it("defers a volatile live block's rows and commits them once on finalize", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); const tui = new TUI(term); const sealed = new LineList(rows("prior-", 12)); - const live = new LiveLineList([]); + const live = new SeamLineList([]); try { tui.addChild(sealed); @@ -162,40 +199,44 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); - // 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. + // The live block overflows the 4-row viewport, but none of its rows + // are declared final: the finalized prefix commits, the window shows + // the live tail, and the overflowed head (think-0/think-1) sits in + // the deferred gap — on the tape NOWHERE yet. No ED3. expect(eraseScrollbackCount(writes)).toBe(0); - expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([ - ...rows("prior-", 12), - ...rows("think-", 6), - ]); + expect(tape(term)).toEqual([...rows("prior-", 12), "think-2", "think-3", "think-4", "think-5"]); - // 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); + expect(tape(term)).toEqual([...rows("prior-", 12), "think-4", "think-5", "think-6", "think-7"]); - const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + // Finalize: the seam clears and the deferred head backfills in order. + live.seam = undefined; + tui.requestRender(); + await settle(term); + + const buffer = tape(term); expect(eraseScrollbackCount(writes)).toBe(0); expect(buffer).toEqual([...rows("prior-", 12), ...rows("think-", 8)]); + // Exactly once: no duplicate copies of any live row. + for (const row of rows("think-", 8)) { + expect(buffer.filter(line => line === row)).toHaveLength(1); + } } finally { tui.stop(); } }); - it("commits a tall all-live block's scrolled head to native scrollback", async () => { + it("defers a tall all-live block's scrolled head and commits it in order on finalize", 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 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([]); + // The only block is live with nothing declared final: rows that scroll + // above the viewport wait in the deferred gap instead of entering + // immutable history as a mutable preview. + const live = new SeamLineList([]); try { tui.addChild(live); @@ -208,25 +249,32 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); - // 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. + // Nothing committed: the tape is exactly the visible window. expect(eraseScrollbackCount(writes)).toBe(0); - expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("tool-", 10)); + expect(tape(term)).toEqual(["tool-6", "tool-7", "tool-8", "tool-9"]); + + live.seam = undefined; + tui.requestRender(); + await settle(term); + + // The deferred head backfills in frame order, exactly once. + expect(eraseScrollbackCount(writes)).toBe(0); + expect(tape(term)).toEqual(rows("tool-", 10)); } finally { tui.stop(); } }); - it("commits the scrolled-off head of an append-only live block to native scrollback", async () => { + it("commits the scrolled-off head of an append-only live block mid-stream", 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), but unlike a - // volatile tool preview it is append-only (a streaming assistant reply). - // Rows that scroll above the viewport must reach native scrollback rather - // than vanishing — committed nowhere, repainted nowhere. - const live = new AppendOnlyLiveLineList([]); + // An append-only streaming reply declares every rendered row final + // (Infinity clamps to the rendered length), so its scrolled-off head + // reaches native scrollback mid-stream instead of waiting for finalize. + const live = new SeamLineList([]); + live.seam = Number.POSITIVE_INFINITY; try { tui.addChild(live); @@ -239,23 +287,20 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); - // text-0..text-5 scrolled above the 4-row viewport; because the block - // is append-only they enter native scrollback (via `\r\n`, no ED3 - // erase) instead of being dropped like the volatile case above. expect(eraseScrollbackCount(writes)).toBe(0); - expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("text-", 10)); + expect(tape(term)).toEqual(rows("text-", 10)); } finally { tui.stop(); } }); - it("recommits fresh rows without loss when the live region is replaced wholesale", async () => { + it("leaves no stale copy when a volatile live block is replaced wholesale", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(24, 4); overrideProbe(term, undefined); const tui = new TUI(term); const sealed = new LineList(rows("prior-", 12)); - const live = new LiveLineList([]); + const live = new SeamLineList([]); try { tui.addChild(sealed); @@ -273,33 +318,35 @@ 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()); + live.seam = undefined; + tui.requestRender(); + await settle(term); + + // The provisional preview never touched the tape: after the wholesale + // replace + finalize, history holds only the final content — no + // stranded pending-stale fragment, no ED3, nothing lost. + const buffer = tape(term); expect(eraseScrollbackCount(writes)).toBe(0); - for (const row of rows("running-fresh-", 10)) expect(buffer).toContain(row); + expect(buffer).toEqual([...rows("prior-", 12), ...rows("running-fresh-", 10)]); + expect(buffer.some(line => line.startsWith("pending-stale-"))).toBe(false); } finally { tui.stop(); } }); - it("keeps the topmost live seam and recommits fresh rows when a lower sibling also reports one", async () => { + it("keeps the topmost live seam when a lower sibling also reports one", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(24, 4); overrideProbe(term, undefined); const tui = new TUI(term); const sealed = new LineList(rows("prior-", 12)); - // Volatile live transcript block: seam at 0, no commit-safe end. - const live = new LiveLineList([]); + // Volatile live transcript block: nothing declared final. + const live = new SeamLineList([]); // 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. - const loader = new LiveLineList(["Working..."]); + const loader = new SeamLineList(["Working..."]); try { tui.addChild(sealed); @@ -314,21 +361,34 @@ describe("streaming scrollback defer", () => { tui.requestRender(); await settle(term); + // Boundary stops at the transcript block: only the sealed prefix is on + // the tape (plus the visible window). + expect(tape(term).filter(line => line.startsWith("pending-stale-"))).toEqual( + tape(term) + .slice(-4) + .filter(line => line.startsWith("pending-stale-")), + ); + live.setLines(rows("running-fresh-", 10)); 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()); + live.seam = undefined; + tui.requestRender(); + await settle(term); + + // The transcript's final content commits; the loader (still live) + // stays out of history at the tape bottom. + const buffer = tape(term); expect(eraseScrollbackCount(writes)).toBe(0); - for (const row of rows("running-fresh-", 10)) expect(buffer).toContain(row); + expect(buffer).toEqual([...rows("prior-", 12), ...rows("running-fresh-", 10), "Working..."]); + expect(buffer.some(line => line.startsWith("pending-stale-"))).toBe(false); } finally { tui.stop(); } }); - it("commits scrolled streaming rows to history exactly once without ED3", async () => { + it("commits scrolled streaming rows to history exactly once without ED3 (shell semantics)", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(40, 10); overrideProbe(term, undefined); @@ -420,7 +480,7 @@ describe("streaming scrollback defer", () => { // native scrollback; a later collapse must not repaint them back into the // viewport (which would duplicate them in history with no ED3 to erase). const sealed = new LineList(rows("prior-", 12)); - const live = new LiveLineList([]); + const live = new SeamLineList([]); try { tui.addChild(sealed); @@ -464,9 +524,7 @@ describe("streaming scrollback defer", () => { const writes = capture(term); - // No live-region marker yet: streaming caps this transient - // frame to the viewport. The already-committed base-0..base-7 rows - // remain physically in native scrollback and must stay accounted. + // No live-region seam yet: shell semantics commit the scrolled rows. sealed.setLines([...rows("base-", 12), ...rows("transient-", 30)]); tui.requestRender(); await settle(term); @@ -474,9 +532,9 @@ describe("streaming scrollback defer", () => { expect(eraseScrollbackCount(writes)).toBe(0); // A later frame introduces a live region after the same sealed prefix. - // If the cap zeroed the high-water mark, liveRegionPinned would append - // base-0..base-11 again, duplicating base-0..base-7 in native history. - const live = new LiveLineList(rows("live-", 20)); + // The already-committed base rows must stay accounted — never appended + // to native history a second time. + const live = new SeamLineList(rows("live-", 20)); sealed.setLines(rows("base-", 12)); tui.addChild(live); tui.requestRender(); @@ -544,7 +602,7 @@ describe("streaming scrollback defer", () => { tui.start(); await settle(term); - // Grow well past the 4-row viewport: the append-only body lets the + // Grow well past the 4-row viewport: the declared-final body lets the // engine commit the scrolled-off head to native scrollback. probe.setLines(rows("out-", 12)); tui.requestRender(); @@ -562,16 +620,16 @@ describe("streaming scrollback defer", () => { } }); - it("commits the scrolled-off head of a durable snapshot block even while it re-lays-out", async () => { + it("never commits intermediate layouts of a re-laying-out live block", 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([]); + // A block that rewrites an interior row every frame (a streaming table + // re-aligning its columns) declares nothing final. Under the old + // heuristic law its head force-committed and every drift risked a + // duplicate-snapshot spray; now nothing enters history until finalize. + const live = new SeamLineList([]); try { tui.addChild(live); @@ -588,146 +646,84 @@ describe("streaming scrollback defer", () => { 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). + // Mid-stream: the tape is exactly the visible window — zero snapshots + // of any intermediate layout. + expect(tape(term)).toEqual(["tbl-8", "tbl-9", "tbl-10", "tbl-11"]); + + live.seam = undefined; + tui.requestRender(); + await settle(term); + + // Finalize commits the FINAL layout exactly once. + const final = rows("tbl-", 12); + final[1] = "tbl-1 [w12]"; 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"); + expect(tape(term)).toEqual(final); } finally { tui.stop(); } }); - it("does not spray duplicate snapshots when an already-committed durable row drifts", async () => { + it("repairs a declared-final violation by re-anchoring once, never spraying", 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)); + // The block declares its whole body final, commits, then violates the + // contract by rewriting TWO committed rows (alignment breaks, so the + // tail-sample tolerance cannot absorb it). The audit re-anchors and + // recommits — duplication, never loss — and stays quiet on the frames + // after the violation (no per-frame spray). + const live = new SeamLineList(rows("row-", 12)); + live.seam = Number.POSITIVE_INFINITY; 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); + const writes = capture(term); + const violated = rows("row-", 12); + violated[5] = "row-5 [edited]"; + violated[6] = "row-6 [edited]"; + live.setLines(violated); + tui.requestRender(); + await settle(term); + + const afterViolation = tape(term); + // No loss: the edited rows reached the tape in final form. + expect(afterViolation).toContain("row-5 [edited]"); + expect(afterViolation).toContain("row-6 [edited]"); + + // Stability: identical follow-up frames must not grow the tape. + for (let i = 0; i < 5; i++) { 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"); + expect(tape(term)).toEqual(afterViolation); + expect(eraseScrollbackCount(writes)).toBe(0); } finally { tui.stop(); } }); }); -/** - * 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", () => { +describe("scrollback commit gap — deferred live 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]; - } + savedTerminalEnv = saveTerminalEnv(); }); afterEach(() => { - for (const key in savedTerminalEnv) { - const value = savedTerminalEnv[key]; - if (value === undefined) delete Bun.env[key]; - else Bun.env[key] = value; - } + restoreTerminalEnv(savedTerminalEnv); savedTerminalEnv = {}; }); - it("does not drop the tail when a pending barrier above it is removed (S5/S6)", async () => { + it("defers everything under a pending barrier and backfills when it clears (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(); + const root = new SeamLineList([]); try { tui.addChild(root); @@ -735,26 +731,24 @@ describe("scrollback commit gap — commit-unstable barriers", () => { 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; + // Small pending barrier above a long finalized tail, overflowing the + // 4-row viewport. The seam at 0 defers the whole frame. + root.setLines(["[tool pending]", ...rows("ans-", 8)]); + root.seam = 0; tui.requestRender(); await settle(term); - expectNoLoss(term, f1); + expect(tape(term)).toEqual(rows("ans-", 8).slice(-4)); - // 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; + // Barrier removed (agent moved past the tool): the tail commits in + // order; the pending row never existed on the tape. + root.setLines(rows("ans-", 8)); + root.seam = undefined; tui.requestRender(); await settle(term); - const buffer = expectNoLoss(term, f2); - for (const row of f2) expect(buffer).toContain(row); + const buffer = tape(term); + expect(buffer).toEqual(rows("ans-", 8)); + expect(buffer).not.toContain("[tool pending]"); expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("ans-", 8).slice(-4)); expect(eraseScrollbackCount(writes)).toBe(0); } finally { @@ -762,12 +756,12 @@ describe("scrollback commit gap — commit-unstable barriers", () => { } }); - it("does not drop result rows when a provisional preview is replaced by a longer result (S4)", async () => { + it("commits only the result when a provisional preview is replaced (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(); + const root = new SeamLineList([]); try { tui.addChild(root); @@ -775,21 +769,20 @@ describe("scrollback commit gap — commit-unstable barriers", () => { await settle(term); const writes = capture(term); - const preview = rows("preview-", 10); - root.lines = preview; - root.liveStart = 0; + root.setLines(rows("preview-", 10)); + root.seam = 0; tui.requestRender(); await settle(term); - expectNoLoss(term, preview); + expect(tape(term)).toEqual(rows("preview-", 10).slice(-4)); - const result = rows("result-", 9); - root.lines = result; - root.liveStart = undefined; + root.setLines(rows("result-", 9)); + root.seam = undefined; tui.requestRender(); await settle(term); - const buffer = expectNoLoss(term, result); - for (const row of result) expect(buffer).toContain(row); + const buffer = tape(term); + expect(buffer).toEqual(rows("result-", 9)); + expect(buffer.some(line => line.startsWith("preview-"))).toBe(false); expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("result-", 9).slice(-4)); expect(eraseScrollbackCount(writes)).toBe(0); } finally { @@ -802,7 +795,7 @@ describe("scrollback commit gap — commit-unstable barriers", () => { const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); const tui = new TUI(term); - const root = new SeamComponent(); + const root = new SeamLineList([]); try { tui.addChild(root); @@ -810,25 +803,23 @@ describe("scrollback commit gap — commit-unstable barriers", () => { 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; + // 3-row barrier over an 8-row tail (len 11), overflowing. All deferred. + root.setLines([...rows("bar-", 3), ...rows("tail-", 8)]); + root.seam = 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. + // Barrier collapses to 1 row and finalizes: the whole final frame — and + // only the final frame — reaches the tape. const f2 = ["bar-collapsed", ...rows("tail-", 8)]; - root.lines = f2; - root.liveStart = undefined; + root.setLines(f2); + root.seam = undefined; tui.requestRender(); await settle(term); - const buffer = expectNoLoss(term, f2); - for (const row of rows("tail-", 8)) expect(buffer).toContain(row); + const buffer = tape(term); + expect(buffer).toEqual(f2); + expect(buffer.some(line => line.startsWith("bar-") && line !== "bar-collapsed")).toBe(false); expect(term.getViewport().map(line => line.trimEnd())).toEqual(f2.slice(-4)); expect(eraseScrollbackCount(writes)).toBe(0); } finally { @@ -842,10 +833,9 @@ describe("scrollback commit gap — commit-unstable barriers", () => { 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]"]); + // tail block. The topmost seam (the barrier at row 0) gates the boundary, + // so the finalized tail below it is deferred too (commits are prefix-only). + const barrier = new SeamLineList(["[tool pending]"]); const tail = new LineList(rows("out-", 10)); try { @@ -855,21 +845,20 @@ describe("scrollback commit gap — commit-unstable barriers", () => { await settle(term); const writes = capture(term); - // Force overflow: the concatenated frame is 11 rows over a 5-row viewport. + // Force overflow: 11 rows over a 5-row viewport. Nothing commits. tui.requestRender(); await settle(term); - expectNoLoss(term, ["[tool pending]", ...rows("out-", 10)]); + expect(tape(term)).toEqual(rows("out-", 10).slice(-5)); - // Remove the barrier. The finalized tail shifts up by one row; every - // out-* row must remain, in order, contiguous at the tape bottom. + // Remove the barrier. The tail commits as one contiguous in-order run; + // the pending row never reached the tape. 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)); + const buffer = tape(term); + expect(buffer).toEqual(rows("out-", 10)); + expect(buffer).not.toContain("[tool pending]"); expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("out-", 10).slice(-5)); expect(eraseScrollbackCount(writes)).toBe(0); } finally { @@ -882,7 +871,7 @@ describe("scrollback commit gap — commit-unstable barriers", () => { const term = new VirtualTerminal(20, 5); overrideProbe(term, undefined); const tui = new TUI(term); - const root = new SeamComponent(); + const root = new SeamLineList([]); try { tui.addChild(root); @@ -890,40 +879,42 @@ describe("scrollback commit gap — commit-unstable barriers", () => { 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). + // further each tick; the barrier defers every row. for (let n = 1; n <= 20; n++) { - const frame = ["[pending]", ...rows("row-", n)]; - root.lines = frame; - root.liveStart = 0; + root.setLines(["[pending]", ...rows("row-", n)]); + root.seam = 0; tui.requestRender(); await settle(term); - expectNoLoss(term, frame); } + // Nothing has committed across 20 overflowing frames. + expect(tape(term)).toEqual(rows("row-", 20).slice(-5)); const final = rows("row-", 20); - root.lines = final; - root.liveStart = undefined; + root.setLines(final); + root.seam = undefined; tui.requestRender(); await settle(term); - const buffer = expectNoLoss(term, final); - for (const row of final) expect(buffer).toContain(row); + const buffer = tape(term); + expect(buffer).toEqual(final); + expect(buffer).not.toContain("[pending]"); 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 () => { + it("commits a declared-final prose head once under a volatile tail and live card", 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. + // Coexistence case: a streaming block whose settled head is declared + // final (the transcript's settled-prefix path) while its last row + // re-wraps in place every frame and a still-live card renders below. + // The head must commit exactly once — no per-drift spray — and the + // volatile tail + card stay off the tape until finalize. const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); const tui = new TUI(term); - const root = new SeamComponent(); + const root = new SeamLineList([]); try { tui.addChild(root); @@ -934,32 +925,39 @@ describe("scrollback commit gap — commit-unstable barriers", () => { 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 + root.setLines([...prose, "card-0", "card-1"]); + root.seam = 7; // declared-final through prose-6 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"); + const streaming = tape(term); + // The declared head committed exactly once. + expect(contiguousAt(streaming, ["prose-0", "prose-1", "prose-2"])).toHaveLength(1); + // The volatile row and the live card are only in the visible window. + expect(streaming.filter(line => line === "card-0")).toHaveLength(1); + expect(streaming.at(-2)).toBe("card-0"); + + root.seam = undefined; + tui.requestRender(); + await settle(term); + + const buffer = tape(term); + const final = rows("prose-", 8); + final[7] = "prose-7 [w11]"; + expect(buffer).toEqual([...final, "card-0", "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 () => { + it("shows the finalize edit of a deferred row instead of a stale committed copy", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); const tui = new TUI(term); - const root = new SeamComponent(); + const root = new SeamLineList([]); try { tui.addChild(root); @@ -967,70 +965,25 @@ describe("scrollback commit gap — commit-unstable barriers", () => { 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; + // Pending barrier row above an unchanged tail, overflowing. Deferred. + root.setLines(["preview", ...rows("tail-", 8)]); + root.seam = 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. + // Finalize: ONLY row 0 changes (preview → result). Under the old + // force-commit law the stale "preview" sat in history and only a + // full-suffix hard scan recovered "result"; now the row was deferred, + // so the tape simply receives the final content. const f2 = ["result", ...rows("tail-", 8)]; - root.lines = f2; - root.liveStart = undefined; // seam cleared: forced rows are now permanent + root.setLines(f2); + root.seam = undefined; 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"); + const buffer = tape(term); + expect(buffer).toEqual(f2); + expect(buffer).not.toContain("preview"); expect(buffer.filter(line => line === "result")).toHaveLength(1); expect(eraseScrollbackCount(writes)).toBe(0); } finally { @@ -1038,12 +991,12 @@ describe("scrollback commit gap — commit-unstable barriers", () => { } }); - it("does not spray when a commit-unstable barrier becomes durable and a scrolled row drifts", async () => { + it("shows the finalize edit far above a long unchanged tail (deep backfill)", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4); overrideProbe(term, undefined); const tui = new TUI(term); - const root = new SeamComponent(); + const root = new SeamLineList([]); try { tui.addChild(root); @@ -1051,37 +1004,24 @@ describe("scrollback commit gap — commit-unstable barriers", () => { await settle(term); const writes = capture(term); - // Phase 1: a commit-unstable barrier (no commit/snapshot end) overflows - // the viewport, force-committing its head as forced-overflow rows. - root.lines = rows("tbl-", 12); - root.liveStart = 0; + // The changed row ends up ~30 rows above the window at finalize — + // far outside the audit's 24-row tail-sample lookback. Deferral makes + // that irrelevant: the backfill emits the final bytes directly. + root.setLines(["preview", ...rows("tail-", 30)]); + root.seam = 0; tui.requestRender(); await settle(term); - // Phase 2: the block becomes DURABLE (reports a snapshot-safe end over its - // whole body) while a scrolled-off INTERIOR committed row re-lays-out in - // place every frame — a streaming table that re-aligns its columns after it - // stops being provisional. An interior row (not row 0) is the load-bearing - // case: re-anchoring there leaves preDurable < preCommit, so without the - // hard-audit durableRows advance the durable-rise gate re-fires the full - // scan AND re-anchors on the drift every frame, ballooning native - // scrollback with duplicate snapshots (spray). With the advance, the - // durable row is exempt after the one transition re-anchor. - for (let n = 0; n < 30; n++) { - const lines = rows("tbl-", 12); - lines[5] = `tbl-5 [w${n}]`; // scrolled-off interior durable row drifts - root.lines = lines; - root.liveStart = 0; - root.snapSafe = 12; // durable through the whole body - tui.requestRender(); - await settle(term); - } + const f2 = ["result", ...rows("tail-", 30)]; + root.setLines(f2); + root.seam = undefined; + tui.requestRender(); + await settle(term); - const buffer = term.getScrollBuffer().map(line => line.trimEnd()); - // Bounded: ~12 logical rows + at most one transition re-anchor's stale - // copy. Spray would push this far past 40. - expect(buffer.length).toBeLessThan(40); - expect(buffer.join("\n")).toContain("tbl-11"); + const buffer = tape(term); + expect(buffer).toEqual(f2); + expect(buffer).not.toContain("preview"); + expect(buffer.filter(line => line === "result")).toHaveLength(1); expect(eraseScrollbackCount(writes)).toBe(0); } finally { tui.stop();