Files
oh-my-pi/packages/tui/test/component-render.test.ts
T
roboomp 7cff20cfd0 fix(tui): pin anchored regions under an unpinned streaming seam
TUI.render merged live-region seams with a topmost-seam-wins rule that
adopted only that seam's pin policy for the whole frame. While the primary
turn streams, the transcript reports the topmost, unpinned seam, so the
frame-wide pin flag becomes false and a pinned AnchoredLiveContainer below
it (the /btw panel, the working HUD) loses its pinning: once its content
grows past the viewport, the emit path commits the scrolled-off rows as
frozen snapshots on every growth frame, piling duplicates into native
scrollback.

Track a pinnedBoundary (start row of the topmost pinned region)
independently of which seam wins the topmost merge, and cap every commit
ceiling at it. Equivalent to the prior behavior for a fully-pinned frame
and for a frame with no pinned region; only the mixed case changes.

Fixes #8793
2026-08-19 08:51:13 +00:00

886 lines
29 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
type Component,
Container,
CURSOR_MARKER,
Editor,
type Focusable,
type NativeScrollbackCommittedRows,
type NativeScrollbackLiveRegion,
type NativeScrollbackReplay,
TUI,
} from "@oh-my-pi/pi-tui";
import { withoutTerminalMultiplexer } from "./helpers/terminal-multiplexer";
import { StressRenderScheduler } from "./render-stress-scheduler";
import { defaultEditorTheme } from "./test-themes";
import { VirtualTerminal } from "./virtual-terminal";
withoutTerminalMultiplexer();
// Behavioral tests for TUI.requestComponentRender: a component whose own
// content changed (spinner frame, blink) asks for a component-scoped frame.
// When every request since the last frame is component-scoped and the frame is
// otherwise quiet, the compose re-renders only the root subtrees containing
// the requesting components and reuses the previous segment — rows and seam
// report — of every other root child. Any concurrent full request or unsafe
// condition must downgrade to a normal full compose.
/** Ref-stable leaf: fresh array per change, counts render() calls. */
class CountingLines implements Component {
renders = 0;
#lines: string[];
constructor(lines: string[]) {
this.#lines = lines;
}
set(lines: string[]): void {
this.#lines = lines;
}
invalidate(): void {}
render(_width: number): readonly string[] {
this.renders++;
return this.#lines;
}
}
/** Transcript-shaped head: final rows committed, the last row stays live. */
class LiveHead extends CountingLines implements NativeScrollbackLiveRegion {
#seam = 0;
setSeam(seam: number): void {
this.#seam = seam;
}
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.#seam;
}
}
class AnchoredStatusContainer extends Container implements NativeScrollbackLiveRegion {
getNativeScrollbackLiveRegionStart(): number | undefined {
const hasAnchoredRows = this.children.length > 0;
return hasAnchoredRows ? 0 : undefined;
}
isNativeScrollbackLiveRegionPinned(): boolean {
return true;
}
}
function strip(rows: string[]): string[] {
return rows.map(row => Bun.stripANSI(row).trimEnd());
}
function visible(term: VirtualTerminal): string[] {
return strip(term.getViewport()).filter(row => row.length > 0);
}
function captureWrites(term: VirtualTerminal): string[] {
const writes: string[] = [];
const write = term.write.bind(term);
term.write = (data: string): void => {
writes.push(data);
write(data);
};
return writes;
}
class RenderCountingTUI extends TUI {
renders = 0;
override render(width: number): readonly string[] {
this.renders++;
return super.render(width);
}
}
class ReplayVirtualizedLines implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
readonly lines: readonly string[];
replayPreparations = 0;
#compacted = false;
#replayPending = false;
constructor(lines: readonly string[]) {
this.lines = lines;
}
invalidate(): void {}
setNativeScrollbackCommittedRows(rows: number): void {
if (rows >= 4) this.#compacted = true;
}
prepareNativeScrollbackReplay(): void {
this.replayPreparations++;
this.#replayPending = true;
}
render(_width: number): readonly string[] {
if (this.#replayPending) {
this.#replayPending = false;
return this.lines;
}
return this.#compacted ? this.lines.slice(4) : this.lines;
}
}
describe("TUI native scrollback replay", () => {
it("rehydrates virtualized roots before a destructive full paint", async () => {
const term = new VirtualTerminal(40, 4, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new ReplayVirtualizedLines([
"history-0",
"history-1",
"history-2",
"history-3",
"tail-0",
"tail-1",
"tail-2",
"tail-3",
]);
tui.addChild(transcript);
try {
tui.start();
await scheduler.drain(term);
tui.requestRender();
await scheduler.drain(term);
tui.requestRender(true, { clearScrollback: true });
await scheduler.drain(term);
expect(transcript.replayPreparations).toBe(1);
const buffer = strip(term.getScrollBuffer());
expect(buffer).toContain("history-0");
expect(buffer).toContain("tail-3");
} finally {
tui.stop();
await term.flush();
}
});
});
describe("TUI.requestComponentRender", () => {
it("re-renders only the requesting subtree on a quiet frame", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]);
const status = new Container();
const spinner = new CountingLines(["spin-0"]);
status.addChild(spinner);
tui.addChild(transcript);
tui.addChild(status);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-0"]);
const transcriptRenders = transcript.renders;
// Spinner tick: component-scoped request, nested one level deep.
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-1"]);
// The transcript subtree was reused, not re-rendered.
expect(transcript.renders).toBe(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("downgrades to a full compose when a full request shares the frame", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(transcript);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
// Both a component-scoped and a full request coalesce into one
// frame; the full request wins regardless of arrival order.
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
transcript.set(["msg-0", "msg-edited"]);
tui.requestRender();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-edited", "spin-1"]);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose while an overlay is up", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(transcript);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
tui.showOverlay(new CountingLines(["modal"]), { width: 10 });
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
// Unsafe condition: the frame rendered fully (and correctly).
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose when the root child list changed", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(transcript);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
// Structural change with only a component-scoped request pending:
// the segment ledger no longer matches the root list, so the frame
// must compose fully and paint the new child.
tui.addChild(new CountingLines(["banner"]));
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "spin-1", "banner"]);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose when the component is not in the tree", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const status = new Container();
const spinner = new CountingLines(["spin-0"]);
status.addChild(spinner);
tui.addChild(transcript);
tui.addChild(status);
try {
tui.start();
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
// A detached component (cleared status container) can still fire a
// trailing tick; the frame must not skip anything based on it.
status.removeChild(spinner);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0"]);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("replays the seam report of a skipped root child across partial frames", async () => {
const term = new VirtualTerminal(40, 4, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const markers = Array.from({ length: 8 }, (_unused, i) => `ROW-${String(i).padStart(3, "0")}`);
// All but the last head row are final; the tail row stays live.
const head = new LiveHead([...markers, "streaming"]);
head.setSeam(markers.length);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(head);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
const headRenders = head.renders;
// Several spinner-only frames while the head (and its commit seam)
// ride the reused segment.
for (let tick = 1; tick <= 3; tick++) {
spinner.set([`spin-${tick}`]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
}
expect(head.renders).toBe(headRenders);
expect(visible(term).at(-1)).toBe("spin-3");
// A later full frame must still commit exactly once: every final
// row appears exactly once across history + grid, in order.
head.set([...markers, "streamed-final", "tail"]);
head.setSeam(markers.length + 2);
tui.requestRender();
await scheduler.drain(term);
const buffer = strip(term.getScrollBuffer()).join("\n");
const missing = markers.filter(mark => buffer.split(mark).length - 1 === 0);
const duplicated = markers.filter(mark => buffer.split(mark).length - 1 > 1);
expect(missing).toEqual([]);
expect(duplicated).toEqual([]);
const observed = Array.from(buffer.matchAll(/ROW-\d{3}/g), match => match[0]);
expect(observed).toEqual(markers);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps removed pinned panels and repeated transcript copies out of scrollback", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const markers = Array.from({ length: 5 }, (_unused, index) => `HIST-${index}`);
const transcript = new CountingLines(markers);
const status = new AnchoredStatusContainer();
const editor = new CountingLines([`editor${CURSOR_MARKER}`]);
tui.addChild(transcript);
tui.addChild(status);
tui.addChild(editor);
try {
tui.start();
await scheduler.drain(term);
for (let cycle = 1; cycle <= 3; cycle++) {
const panel = new CountingLines([`panel-${cycle}-0`]);
status.addChild(panel);
tui.requestRender();
await scheduler.drain(term);
for (let tick = 1; tick <= 8; tick++) {
panel.set(Array.from({ length: tick + 1 }, (_row, index) => `panel-${cycle}-${tick}-${index}`));
tui.requestComponentRender(panel);
await scheduler.drain(term);
}
status.clear();
tui.requestRender();
await scheduler.drain(term);
}
const buffer = strip(term.getScrollBuffer()).join("\n");
for (const marker of markers) {
expect(buffer.split(marker)).toHaveLength(2);
}
expect(buffer).not.toContain("panel-");
} finally {
tui.stop();
await term.flush();
}
});
it("keeps a pinned panel out of scrollback under an unpinned streaming transcript seam", async () => {
// Regression for the /btw panel re-committing its frame while the primary
// turn streams (#8793): the transcript reports the topmost, UNPINNED seam,
// so the frame-wide pin policy is false, yet an anchored pinned panel below
// it must still never commit its scrolled-off rows to native scrollback.
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const markers = Array.from({ length: 5 }, (_unused, index) => `HIST-${index}`);
const transcript = new LiveHead([...markers, "streaming-tail"]);
transcript.setSeam(markers.length); // committed prefix + one live (streaming) tail row
const status = new AnchoredStatusContainer();
const editor = new CountingLines([`editor${CURSOR_MARKER}`]);
tui.addChild(transcript);
tui.addChild(status);
tui.addChild(editor);
try {
tui.start();
await scheduler.drain(term);
const panel = new CountingLines(["btw-0"]);
status.addChild(panel);
tui.requestRender();
await scheduler.drain(term);
for (let tick = 1; tick <= 12; tick++) {
panel.set(Array.from({ length: tick + 1 }, (_row, index) => `BTWROW-${tick}-${index}`));
tui.requestComponentRender(panel);
await scheduler.drain(term);
}
// Native scrollback is everything above the visible viewport; mid-stream
// it must hold zero rows of the growing pinned panel.
const history = strip(term.getScrollBuffer()).slice(0, -term.rows);
expect(history.filter(row => row.startsWith("BTWROW-"))).toEqual([]);
} finally {
tui.stop();
await term.flush();
}
});
});
describe("TUI keystroke-scoped render", () => {
it("fully composes callback-driven sibling updates without explicit scoped opt-in", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const status = new CountingLines(["status-idle"]);
const input: Component & Focusable = {
focused: false,
invalidate() {},
render() {
const state = this.focused ? "focused" : "idle";
return [`input-${state}`];
},
handleInput() {
status.set(["status-submitted"]);
},
};
tui.addChild(status);
tui.addChild(input);
tui.setFocus(input);
try {
tui.start();
await scheduler.drain(term);
const statusRenders = status.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(status.renders).toBeGreaterThan(statusRenders);
expect(visible(term)).toEqual(["status-submitted", "input-focused"]);
expect(tui.getFocused()).toBe(input);
} finally {
tui.stop();
await term.flush();
}
});
it("does not re-render a quiet sibling transcript while typing in the focused editor", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]);
const editor = new Editor(defaultEditorTheme);
tui.enableScopedInputRender(editor);
tui.addChild(transcript);
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(editor.getText()).toBe("x");
expect(transcript.renders).toBe(transcriptRenders);
expect(visible(term).some(row => row.includes("msg-0"))).toBe(true);
expect(visible(term).some(row => row.includes("x"))).toBe(true);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps a correct viewport when a keystroke grows the editor by one wrapped row", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1"]);
const editor = new Editor(defaultEditorTheme);
tui.enableScopedInputRender(editor);
// 34 chars fills the first content row at width 40; the next char wraps.
editor.setText("x".repeat(34));
tui.addChild(transcript);
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual([
"msg-0",
"msg-1",
"+--------------------------------------+",
"+- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|-+",
]);
const transcriptRenders = transcript.renders;
term.sendInput("y");
await scheduler.drain(term);
expect(editor.getText()).toBe(`${"x".repeat(34)}y`);
expect(transcript.renders).toBe(transcriptRenders);
expect(visible(term)).toEqual([
"msg-0",
"msg-1",
"+--------------------------------------+",
"| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |",
"+- y| -+",
]);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose when handleInput moves focus", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const nextFocus = new CountingLines(["selector"]);
const focusMover: Component & Focusable = {
focused: false,
invalidate() {},
render() {
return this.focused ? ["editor-focused"] : ["editor-idle"];
},
handleInput() {
tui.setFocus(nextFocus);
},
};
tui.enableScopedInputRender(focusMover);
tui.addChild(transcript);
tui.addChild(focusMover);
tui.addChild(nextFocus);
tui.setFocus(focusMover);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "editor-focused", "selector"]);
const transcriptRenders = transcript.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
expect(visible(term)).toEqual(["msg-0", "editor-idle", "selector"]);
expect(tui.getFocused()).toBe(nextFocus);
} finally {
tui.stop();
await term.flush();
}
});
});
describe("TUI.requestDirectWrite", () => {
it("directly rewrites a visible unchanged-size root segment without a full render", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1"]);
const spinner = new CountingLines(["spin-0"]);
const footer = new CountingLines(["footer"]);
tui.addChild(transcript);
tui.addChild(spinner);
tui.addChild(footer);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-0", "footer"]);
const tuiRenders = tui.renders;
const transcriptRenders = transcript.renders;
const footerRenders = footer.renders;
spinner.set(["spin-1"]);
tui.requestDirectWrite(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-1", "footer"]);
expect(tui.renders).toBe(tuiRenders);
expect(transcript.renders).toBe(transcriptRenders);
expect(footer.renders).toBe(footerRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("directly strips markers while updating ANSI and wide-grapheme cursor geometry", async () => {
const term = new VirtualTerminal(40, 5, 1_000);
const writes = captureWrites(term);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
const head = new CountingLines(["head"]);
const editor = new CountingLines(["plain"]);
const footer = new CountingLines(["footer"]);
tui.addChild(head);
tui.addChild(editor);
tui.addChild(footer);
try {
tui.start();
await scheduler.drain(term);
const tuiRenders = tui.renders;
writes.length = 0;
editor.set([`\x1b[31m好a${CURSOR_MARKER}b${CURSOR_MARKER}\x1b[0m`]);
tui.requestDirectWrite(editor);
await scheduler.drain(term);
expect(strip(term.getViewport())).toEqual(["head", "好ab", "footer", "", ""]);
expect(term.getCursor()).toEqual({ row: 1, col: 3 });
expect(tui.renders).toBe(tuiRenders);
expect(writes.join("")).not.toContain(CURSOR_MARKER);
writes.length = 0;
editor.set(["done"]);
tui.requestDirectWrite(editor);
await scheduler.drain(term);
expect(strip(term.getViewport())).toEqual(["head", "done", "footer", "", ""]);
expect(tui.renders).toBe(tuiRenders);
expect(writes.join("")).toContain("\x1b[?25l");
expect(writes.join("")).not.toContain(CURSOR_MARKER);
} finally {
tui.stop();
await term.flush();
}
});
it("moves a marker without repainting unchanged row bytes", async () => {
const term = new VirtualTerminal(40, 4, 1_000);
const writes = captureWrites(term);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
const editor = new CountingLines([`ab${CURSOR_MARKER}cd`, "efgh"]);
tui.addChild(editor);
try {
tui.start();
await scheduler.drain(term);
expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]);
expect(term.getCursor()).toEqual({ row: 0, col: 2 });
const tuiRenders = tui.renders;
writes.length = 0;
editor.set(["abcd", `e${CURSOR_MARKER}fgh`]);
tui.requestDirectWrite(editor);
await scheduler.drain(term);
expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]);
expect(term.getCursor()).toEqual({ row: 1, col: 1 });
expect(tui.renders).toBe(tuiRenders);
expect(writes.join("")).not.toContain("abcd");
expect(writes.join("")).not.toContain(CURSOR_MARKER);
} finally {
tui.stop();
await term.flush();
}
});
it("preserves later sibling precedence across marker interval growth, shrink, and recompose", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const writes = captureWrites(term);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
const earlier = new CountingLines(["early"]);
const target = new CountingLines(["t0", "t1", "t2"]);
const later = new CountingLines([`later${CURSOR_MARKER}`]);
tui.addChild(earlier);
tui.addChild(target);
tui.addChild(later);
try {
tui.start();
await scheduler.drain(term);
expect(term.getCursor()).toEqual({ row: 4, col: 5 });
const tuiRenders = tui.renders;
writes.length = 0;
target.set([`${CURSOR_MARKER}t0`, `t${CURSOR_MARKER}1`, `t2${CURSOR_MARKER}`]);
tui.requestDirectWrite(target);
await scheduler.drain(term);
expect(term.getCursor()).toEqual({ row: 4, col: 5 });
expect(tui.renders).toBe(tuiRenders);
target.set(["t0", `t${CURSOR_MARKER}1`, "t2"]);
tui.requestDirectWrite(target);
await scheduler.drain(term);
expect(term.getCursor()).toEqual({ row: 4, col: 5 });
expect(tui.renders).toBe(tuiRenders);
later.set(["later"]);
tui.requestDirectWrite(later);
await scheduler.drain(term);
expect(term.getCursor()).toEqual({ row: 2, col: 1 });
expect(tui.renders).toBe(tuiRenders);
expect(writes.join("")).not.toContain(CURSOR_MARKER);
const targetRenders = target.renders;
writes.length = 0;
earlier.set(["early-new"]);
tui.requestComponentRender(earlier);
await scheduler.drain(term);
expect(strip(term.getViewport())).toEqual(["early-new", "t0", "t1", "t2", "later", "", "", ""]);
expect(term.getCursor()).toEqual({ row: 2, col: 1 });
expect(tui.renders).toBeGreaterThan(tuiRenders);
expect(target.renders).toBe(targetRenders);
expect(writes.join("")).toContain("\x1b[?25h");
expect(writes.join("")).not.toContain(CURSOR_MARKER);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps non-empty native scrollback byte-for-byte stable during marker direct writes", async () => {
const term = new VirtualTerminal(30, 4, 1_000);
const writes = captureWrites(term);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
const transcript = new CountingLines(Array.from({ length: 8 }, (_unused, row) => `history-${row}`));
const editor = new CountingLines(["anim-0"]);
tui.addChild(transcript);
tui.addChild(editor);
try {
tui.start();
await scheduler.drain(term);
const beforeBuffer = term.getScrollBuffer();
const beforeHistory = beforeBuffer.slice(0, Math.max(0, beforeBuffer.length - term.rows));
expect(beforeHistory.length).toBeGreaterThan(0);
expect(beforeHistory.some(row => row.trimEnd().length > 0)).toBe(true);
const tuiRenders = tui.renders;
writes.length = 0;
editor.set([`anim-1${CURSOR_MARKER}`]);
tui.requestDirectWrite(editor);
await scheduler.drain(term);
const afterBuffer = term.getScrollBuffer();
const afterHistory = afterBuffer.slice(0, Math.max(0, afterBuffer.length - term.rows));
expect(afterHistory).toEqual(beforeHistory);
expect(strip(term.getViewport())).toEqual(["history-5", "history-6", "history-7", "anim-1"]);
expect(term.getCursor()).toEqual({ row: 3, col: 6 });
expect(tui.renders).toBe(tuiRenders);
expect(writes.join("")).not.toContain(CURSOR_MARKER);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back safely when marker-bearing output changes row count", async () => {
const term = new VirtualTerminal(40, 5, 1_000);
const writes = captureWrites(term);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
const head = new CountingLines(["head"]);
const editor = new CountingLines([`one${CURSOR_MARKER}`]);
tui.addChild(head);
tui.addChild(editor);
try {
tui.start();
await scheduler.drain(term);
const tuiRenders = tui.renders;
writes.length = 0;
editor.set([`one${CURSOR_MARKER}`, `two${CURSOR_MARKER}`]);
tui.requestDirectWrite(editor);
await scheduler.drain(term);
expect(strip(term.getViewport())).toEqual(["head", "one", "two", "", ""]);
expect(term.getCursor()).toEqual({ row: 2, col: 3 });
expect(tui.renders).toBeGreaterThan(tuiRenders);
expect(writes.join("")).not.toContain(CURSOR_MARKER);
} finally {
tui.stop();
await term.flush();
}
});
it("directly rewrites fully live anchored status segments", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1"]);
const status = new AnchoredStatusContainer();
const spinner = new CountingLines(["spin-0"]);
status.addChild(spinner);
tui.addChild(transcript);
tui.addChild(status);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-0"]);
const tuiRenders = tui.renders;
const transcriptRenders = transcript.renders;
spinner.set(["spin-1"]);
tui.requestDirectWrite(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-1"]);
expect(tui.renders).toBe(tuiRenders);
expect(transcript.renders).toBe(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full render while a visible overlay is up", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
const footer = new CountingLines(["footer"]);
tui.addChild(transcript);
tui.addChild(spinner);
tui.addChild(footer);
try {
tui.start();
await scheduler.drain(term);
tui.showOverlay(new CountingLines(["modal"]), { width: 5, anchor: "top-left" });
await scheduler.drain(term);
expect(visible(term)).toEqual(["modal", "spin-0", "footer"]);
const tuiRenders = tui.renders;
const transcriptRenders = transcript.renders;
spinner.set(["spin-1"]);
tui.requestDirectWrite(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["modal", "spin-1", "footer"]);
expect(tui.renders).toBeGreaterThan(tuiRenders);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
});