The root compose recorded seams last-writer-wins, so the status container's new seam (mounted loader) overwrote the transcript's seam whenever both reported in the same frame. With a volatile live block (no commit-safe end) the boundary then moved below the transcript's still-mutable rows and committed them as stale history — the same duplication class this PR fixes for the loader, relocated upward. Commits are prefix-only: the first child reporting a live region (plus its own commit-safe extension) already bounds everything below it, so the topmost seam now wins. Adds a two-reporter regression test and documents the multi-reporter contract on NativeScrollbackLiveRegion.
510 lines
16 KiB
TypeScript
510 lines
16 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import {
|
|
type Component,
|
|
type NativeScrollbackCommittedRows,
|
|
type NativeScrollbackLiveRegion,
|
|
TUI,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { VirtualTerminal } from "./virtual-terminal";
|
|
|
|
class LineList implements Component {
|
|
#lines: string[];
|
|
|
|
constructor(lines: string[]) {
|
|
this.#lines = [...lines];
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
render(width: number): string[] {
|
|
return this.#lines.map(line => line.slice(0, width));
|
|
}
|
|
|
|
setLines(lines: string[]): void {
|
|
this.#lines = [...lines];
|
|
}
|
|
}
|
|
|
|
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.
|
|
*/
|
|
class AppendOnlyLiveLineList extends LiveLineList {
|
|
getNativeScrollbackCommitSafeEnd(): number | undefined {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Records the engine's committed-row claim visible at each render() call.
|
|
* Pins the propagation contract: the claim must be fed *before* render so the
|
|
* child (e.g. the transcript container) can skip re-deriving blocks that
|
|
* already live in immutable native scrollback.
|
|
*/
|
|
class CommittedRowsProbe extends AppendOnlyLiveLineList implements NativeScrollbackCommittedRows {
|
|
#committedRows = 0;
|
|
committedRowsAtRender: number[] = [];
|
|
|
|
setNativeScrollbackCommittedRows(rows: number): void {
|
|
this.#committedRows = rows;
|
|
}
|
|
|
|
override render(width: number): string[] {
|
|
this.committedRowsAtRender.push(this.#committedRows);
|
|
return super.render(width);
|
|
}
|
|
}
|
|
|
|
async function settle(term: VirtualTerminal): Promise<void> {
|
|
const nextTick = Promise.withResolvers<void>();
|
|
process.nextTick(nextTick.resolve);
|
|
await nextTick.promise;
|
|
await Bun.sleep(40);
|
|
await term.flush();
|
|
}
|
|
|
|
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) => {
|
|
writes.push(data);
|
|
realWrite(data);
|
|
};
|
|
return writes;
|
|
}
|
|
|
|
function overrideProbe(term: VirtualTerminal, answer: boolean | undefined): void {
|
|
(term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => answer;
|
|
}
|
|
|
|
const ERASE_SCROLLBACK = /\x1b\[3J/g;
|
|
|
|
function eraseScrollbackCount(writes: string[]): number {
|
|
return writes.join("").match(ERASE_SCROLLBACK)?.length ?? 0;
|
|
}
|
|
|
|
function rows(prefix: string, count: number): string[] {
|
|
return Array.from({ length: count }, (_, i) => `${prefix}${i}`);
|
|
}
|
|
|
|
describe("streaming scrollback defer", () => {
|
|
it("keeps mutable live-region head rows out of native scrollback", 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([]);
|
|
|
|
try {
|
|
tui.addChild(sealed);
|
|
tui.addChild(live);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
live.setLines(rows("think-", 6));
|
|
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.
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([
|
|
...rows("prior-", 12),
|
|
...rows("think-", 6).slice(-4),
|
|
]);
|
|
|
|
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)]);
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("keeps a tall all-live block transient when no sealed prefix exists", 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.
|
|
const live = new LiveLineList([]);
|
|
|
|
try {
|
|
tui.addChild(live);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
live.setLines(rows("tool-", 10));
|
|
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.
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("tool-", 10).slice(-4));
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("commits the scrolled-off head of an append-only live block 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), 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([]);
|
|
|
|
try {
|
|
tui.addChild(live);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
live.setLines(rows("text-", 10));
|
|
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));
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("does not leave stale mutable live-region rows in native scrollback after a rerender", 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([]);
|
|
|
|
try {
|
|
tui.addChild(sealed);
|
|
tui.addChild(live);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
live.setLines(rows("pending-stale-", 10));
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
live.setLines(rows("running-fresh-", 10));
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
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");
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
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([]);
|
|
// 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.
|
|
const loader = new LiveLineList(["Working..."]);
|
|
|
|
try {
|
|
tui.addChild(sealed);
|
|
tui.addChild(live);
|
|
tui.addChild(loader);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
live.setLines(rows("pending-stale-", 10));
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
live.setLines(rows("running-fresh-", 10));
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
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");
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("commits scrolled streaming rows to history exactly once without ED3", async () => {
|
|
if (process.platform === "win32") return;
|
|
const term = new VirtualTerminal(40, 10);
|
|
overrideProbe(term, undefined);
|
|
const tui = new TUI(term);
|
|
const component = new LineList([...rows("init-", 10), "prompt"]);
|
|
|
|
try {
|
|
tui.addChild(component);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
// Grow content past the viewport — without a live-region seam the
|
|
// scrolled-off rows commit to native history as they pass the seam
|
|
// (shell semantics): exactly once, in frame order, with no ED3.
|
|
const frame1 = [...rows("init-", 10), ...rows("stream-", 30), "prompt"];
|
|
component.setLines(frame1);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
let buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
|
expect(buffer).toEqual(frame1.slice(0, buffer.length));
|
|
expect(
|
|
term
|
|
.getViewport()
|
|
.map(line => line.trim())
|
|
.at(-1),
|
|
).toBe("prompt");
|
|
|
|
// Grow further — history extends append-only: still no ED3, no
|
|
// duplicates, and previously committed rows are untouched.
|
|
const frame2 = [...rows("init-", 10), ...rows("stream-", 50), "prompt"];
|
|
component.setLines(frame2);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
|
expect(buffer).toEqual(frame2.slice(0, buffer.length));
|
|
expect(buffer.length).toBeGreaterThan(frame1.length - 10);
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("does not emit ED3 during streaming", async () => {
|
|
if (process.platform === "win32") return;
|
|
const term = new VirtualTerminal(40, 10);
|
|
overrideProbe(term, undefined);
|
|
const tui = new TUI(term);
|
|
const component = new LineList([...rows("init-", 10), "prompt"]);
|
|
|
|
try {
|
|
tui.addChild(component);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
component.setLines([...rows("grow-", 30), "prompt"]);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
expect(
|
|
term
|
|
.getViewport()
|
|
.map(line => line.trim())
|
|
.at(-1),
|
|
).toBe("prompt");
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("does not duplicate committed sealed rows when the live region collapses mid-stream", async () => {
|
|
if (process.platform === "win32") return;
|
|
const term = new VirtualTerminal(20, 4);
|
|
overrideProbe(term, undefined);
|
|
const tui = new TUI(term);
|
|
// Sealed prefix above a live block: growth commits the sealed rows to
|
|
// 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([]);
|
|
|
|
try {
|
|
tui.addChild(sealed);
|
|
tui.addChild(live);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
// Live block overflows the viewport — sealed prefix commits once.
|
|
live.setLines(rows("think-", 30));
|
|
tui.requestRender();
|
|
await settle(term);
|
|
expect(term.getScrollBuffer().filter(line => line.startsWith("prior-"))).toEqual(rows("prior-", 12));
|
|
|
|
// Live block collapses to its compact result. The bottom-anchored
|
|
// viewport would re-expose committed sealed rows; the pin must clamp the
|
|
// repaint to the committed boundary instead of duplicating them.
|
|
live.setLines(["done"]);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
expect(term.getScrollBuffer().filter(line => line.startsWith("prior-"))).toEqual(rows("prior-", 12));
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("keeps committed prefix accounting after a capped streaming frame", 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("base-", 12));
|
|
|
|
try {
|
|
tui.addChild(sealed);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
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.
|
|
sealed.setLines([...rows("base-", 12), ...rows("transient-", 30)]);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
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));
|
|
sealed.setLines(rows("base-", 12));
|
|
tui.addChild(live);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
expect(term.getScrollBuffer().filter(line => line.startsWith("base-"))).toEqual(rows("base-", 12));
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("erases mis-wrapped native scrollback on resize even mid-stream", async () => {
|
|
if (process.platform === "win32") return;
|
|
const term = new VirtualTerminal(40, 10);
|
|
overrideProbe(term, undefined);
|
|
const tui = new TUI(term);
|
|
const component = new LineList([...rows("init-", 5), "prompt"]);
|
|
|
|
try {
|
|
tui.addChild(component);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
const writes = capture(term);
|
|
|
|
// Stream past the viewport: scrolled rows commit to history in
|
|
// order (shell semantics) and no ED3 fires.
|
|
component.setLines([...rows("stream-", 30), "prompt"]);
|
|
tui.requestRender();
|
|
await settle(term);
|
|
expect(eraseScrollbackCount(writes)).toBe(0);
|
|
const streamed = term.getScrollBuffer().map(line => line.trimEnd());
|
|
expect(streamed).toEqual([...rows("stream-", 30), "prompt"].slice(0, streamed.length));
|
|
|
|
// Resize mid-stream. The terminal re-wrapped its saved lines at the old
|
|
// width, so the rebuild must erase them (ED 3) rather than capping to a
|
|
// viewport repaint that would leave the corrupt history on screen.
|
|
term.resize(30, 10);
|
|
await settle(term);
|
|
|
|
expect(eraseScrollbackCount(writes)).toBeGreaterThan(0);
|
|
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([...rows("stream-", 30), "prompt"]);
|
|
expect(
|
|
term
|
|
.getViewport()
|
|
.map(line => line.trim())
|
|
.at(-1),
|
|
).toBe("prompt");
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
|
|
it("feeds committed native scrollback rows to interested children before render", async () => {
|
|
if (process.platform === "win32") return;
|
|
const term = new VirtualTerminal(20, 4);
|
|
overrideProbe(term, undefined);
|
|
const tui = new TUI(term);
|
|
const probe = new CommittedRowsProbe([]);
|
|
|
|
try {
|
|
tui.addChild(probe);
|
|
tui.start();
|
|
await settle(term);
|
|
|
|
// Grow well past the 4-row viewport: the append-only body lets the
|
|
// engine commit the scrolled-off head to native scrollback.
|
|
probe.setLines(rows("out-", 12));
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
// The next compose must surface the engine's committed claim to the
|
|
// child before render(). A severed wire here silently disables the
|
|
// transcript's committed-block bypass (rows stay 0 forever).
|
|
tui.requestRender();
|
|
await settle(term);
|
|
|
|
expect(probe.committedRowsAtRender.at(-1)!).toBeGreaterThan(0);
|
|
} finally {
|
|
tui.stop();
|
|
}
|
|
});
|
|
});
|