test(tui): consolidated streaming scrollback logic and testing infrastructure
- Exposed `Markdown.getLastRenderSettledRows` to track streaming progress. - Simplified scrollback architecture by migrating logic to a unified `SeamLineList` boundary. - Removed legacy safe-end methods and redundant `findCommittedPrefixResync` test suites. - Fixed live tool and evaluation preview duplication issues during re-layouts.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user