import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages"; import { type Component, Text } from "@oh-my-pi/pi-tui"; // Models a transcript block that re-lays-out (tool preview collapsing, assistant // message finalizing, late async result) after newer blocks were appended below // it — the window must always reflect its current content. class MutableBlock implements Component { #lines: string[]; constructor(lines: string[]) { this.#lines = lines; } set(lines: string[]): void { this.#lines = lines; } invalidate(): void {} render(_width: number): string[] { return [...this.#lines]; } } // A block that can declare itself still-mutating (a foreground tool awaiting its // result). The container must keep such a block in the repaintable live region — // even with finalized blocks below it — until it finalizes. class StreamingBlock implements Component { #lines: string[]; #finalized: boolean; constructor(lines: string[], finalized = false) { this.#lines = lines; this.#finalized = finalized; } set(lines: string[]): void { this.#lines = lines; } finalize(lines?: string[]): void { if (lines) this.#lines = lines; this.#finalized = true; } isTranscriptBlockFinalized(): boolean { return this.#finalized; } invalidate(): void {} renderCount = 0; render(_width: number): string[] { this.renderCount++; return [...this.#lines]; } } // A still-live block that can declare a byte-stable rendered prefix. The // transcript container may commit only those declared rows before finalization. class DeclaredSettledStreamingBlock extends StreamingBlock { #settledRows: number; constructor(lines: string[], settledRows: number) { super(lines); this.#settledRows = settledRows; } setSettledRows(rows: number): void { this.#settledRows = rows; } getTranscriptBlockSettledRows(): number { return this.#settledRows; } } class CountingFinalizedBlock implements Component { renderCount = 0; #lines: string[]; constructor(lines: string[]) { this.#lines = lines; } set(lines: string[]): void { this.#lines = lines; } invalidate(): void {} render(_width: number): string[] { this.renderCount++; return [...this.#lines]; } } // A finalized block that can still mutate afterwards (an assistant message whose // suppressed inline error is restored at the next turn, late tool-result images) // and reports each mutation through the transcript block version protocol. class VersionedFinalizedBlock implements Component { renderCount = 0; #lines: string[]; #version = 0; constructor(lines: string[]) { this.#lines = lines; } mutate(lines: string[]): void { this.#lines = lines; this.#version++; } isTranscriptBlockFinalized(): boolean { return true; } getTranscriptBlockVersion(): number { return this.#version; } invalidate(): void {} render(_width: number): string[] { this.renderCount++; return [...this.#lines]; } } beforeAll(() => { initTheme(); }); beforeEach(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: process.cwd() }); }); afterEach(() => { resetSettingsForTest(); }); function makeAssistantMessage(overrides: Partial = {}): AssistantMessage { return { role: "assistant", content: [{ type: "text", text: "Continuing." }], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", stopReason: "stop", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), ...overrides, }; } function plain(lines: readonly string[]): string { return stripVTControlCharacters(lines.join("\n")); } describe("TranscriptContainer", () => { it("always renders a block's current content, even after newer blocks append below it", () => { const container = new TranscriptContainer(); const a = new MutableBlock(["a1"]); container.addChild(a); expect(container.render(40)).toEqual(["a1"]); a.set(["a2"]); expect(container.render(40)).toEqual(["a2"]); const b = new MutableBlock(["b1"]); container.addChild(b); expect(container.render(40)).toEqual(["a2", "", "b1"]); // A late re-layout of `a` (collapse, late async result, expand toggle) is // reflected immediately: committed history keeps its old bytes, but the // visible window always shows the present state. a.set(["a3-collapsed"]); expect(container.render(40)).toEqual(["a3-collapsed", "", "b1"]); b.set(["b2"]); expect(container.render(40)).toEqual(["a3-collapsed", "", "b2"]); // Width changes recompute like any other frame. a.set(["a-reflowed"]); expect(container.render(80)).toEqual(["a-reflowed", "", "b2"]); }); it("reports undefined as the native scrollback boundary when every block is finalized", () => { const container = new TranscriptContainer(); const a = new MutableBlock(["a1", "a2"]); const b = new MutableBlock(["b1"]); container.addChild(a); container.addChild(b); expect(container.render(40)).toEqual(["a1", "a2", "", "b1"]); expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); b.set(["b1", "b2"]); expect(container.render(40)).toEqual(["a1", "a2", "", "b1", "b2"]); expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); }); it("keeps an unfinalized block below the seam when a finalized block is appended below it", () => { const container = new TranscriptContainer(); // A foreground tool whose args are still streaming (no result yet). const tool = new StreamingBlock(["write (streaming)"]); container.addChild(tool); expect(container.render(40)).toEqual(["write (streaming)"]); // An out-of-band card (TTSR/todo reminder) is appended below the in-flight // tool while it is still streaming. The tool's rows must not commit here. const card = new MutableBlock(["rule card"]); container.addChild(card); expect(container.render(40)).toEqual(["write (streaming)", "", "rule card"]); // The live region begins at the unfinalized tool, not the bottom card. expect(container.getNativeScrollbackLiveRegionStart()).toBe(0); // The tool's result lands after the card is already below it. tool.finalize(["✔ write: 4 lines"]); expect(container.render(40)).toEqual(["✔ write: 4 lines", "", "rule card"]); // All blocks are finalized; the whole rendered frame is committable. expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); // Even after finalizing, a late re-layout still repaints in the window. tool.set(["collapsed"]); expect(container.render(40)).toEqual(["collapsed", "", "rule card"]); }); it("keeps a streaming assistant live so final interrupted content can land after status rows below it", () => { const container = new TranscriptContainer(); const assistant = new AssistantMessageComponent(); assistant.updateContent( makeAssistantMessage({ content: [{ type: "text", text: "The config file write went through." }], }), ); container.addChild(assistant); expect(assistant.isTranscriptBlockFinalized()).toBe(false); expect(plain(container.render(80))).toContain("The config file write went through."); // Status/notice rows can arrive below the still-streaming assistant before // message_end finalizes the interrupted message. The assistant must stay repaintable. container.addChild(new Text("Copied raw SSE stream", 0, 0)); expect(plain(container.render(80))).toContain("Copied raw SSE stream"); expect(container.getNativeScrollbackLiveRegionStart()).toBe(0); assistant.updateContent( makeAssistantMessage({ content: [{ type: "text", text: "The config file write went through despite the interruption." }], stopReason: "aborted", errorMessage: USER_INTERRUPT_LABEL, }), ); assistant.markTranscriptBlockFinalized(); const rendered = plain(container.render(80)); expect(rendered).toContain("The config file write went through despite the interruption."); expect(rendered).toContain("Copied raw SSE stream"); expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); }); it("starts the live region at the earliest of several unfinalized blocks", () => { const container = new TranscriptContainer(); const sealed = new StreamingBlock(["done"], true); const pending = new StreamingBlock(["pending"]); const card = new MutableBlock(["card"]); container.addChild(sealed); container.addChild(pending); container.addChild(card); expect(container.render(40)).toEqual(["done", "", "pending", "", "card"]); // Live region starts at the pending block (offset 1), so the already-sealed // leading block can commit while pending + card stay repaintable. expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); // The sealed block's late re-layout still renders current content; the // seam is unaffected (it keys off finalization, not row diffs). sealed.set(["done-collapsed"]); expect(container.render(40)).toEqual(["done-collapsed", "", "pending", "", "card"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); // The pending block updates freely while live. pending.finalize(["pending-final"]); expect(container.render(40)).toEqual(["done-collapsed", "", "pending-final", "", "card"]); expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); }); it("stops the boundary at the first unfinalized block's first content row when no rows are settled", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const live = new StreamingBlock(["live-0", "live-1"]); container.addChild(live); container.addChild(new MutableBlock(["below"])); expect(container.render(40)).toEqual(["history", "", "live-0", "live-1", "", "below"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); live.set(["live-0 updated", "live-1"]); expect(container.render(40)).toEqual(["history", "", "live-0 updated", "live-1", "", "below"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); }); it("extends the boundary through declared settled rows after stripping leading blank padding", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const live = new DeclaredSettledStreamingBlock(["", "settled-a", "settled-b", "live-tail", ""], 3); container.addChild(live); expect(container.render(40)).toEqual(["history", "", "settled-a", "settled-b", "live-tail"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(4); }); it("returns undefined after the first unfinalized block finalizes", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const live = new StreamingBlock(["live"]); container.addChild(live); expect(container.render(40)).toEqual(["history", "", "live"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); live.finalize(["done"]); expect(container.render(40)).toEqual(["history", "", "done"]); expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); }); it("pins the boundary at an empty unfinalized block's row position", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); container.addChild(new StreamingBlock([])); container.addChild(new MutableBlock(["below"])); expect(container.render(40)).toEqual(["history", "", "below"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(1); }); it("does not let finalized blocks below the first unfinalized block extend the boundary", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); container.addChild(new StreamingBlock(["live"])); container.addChild(new StreamingBlock(["finalized-below-0", "finalized-below-1"], true)); expect(container.render(40)).toEqual(["history", "", "live", "", "finalized-below-0", "finalized-below-1"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); }); it("does not re-render finalized rows already committed to native scrollback", () => { const container = new TranscriptContainer(); const committed = new CountingFinalizedBlock(["committed"]); const liveTail = new CountingFinalizedBlock(["tail"]); container.addChild(committed); container.addChild(liveTail); expect(container.render(40)).toEqual(["committed", "", "tail"]); expect(committed.renderCount).toBe(1); expect(liveTail.renderCount).toBe(1); container.setNativeScrollbackCommittedRows(1); expect(container.render(40)).toEqual(["committed", "", "tail"]); expect(committed.renderCount).toBe(1); expect(liveTail.renderCount).toBe(2); container.invalidate(); expect(container.render(40)).toEqual(["committed", "", "tail"]); expect(committed.renderCount).toBe(2); }); it("re-renders a committed finalized block when its version changes", () => { const container = new TranscriptContainer(); const block = new VersionedFinalizedBlock(["original"]); container.addChild(block); expect(container.render(40)).toEqual(["original"]); container.setNativeScrollbackCommittedRows(1); expect(container.render(40)).toEqual(["original"]); expect(block.renderCount).toBe(1); // Post-finalize mutation (e.g. setErrorPinned(false) restoring the inline // error) must surface even though the rows sit in committed scrollback — // the render is what lets the TUI's committed-prefix audit re-anchor. block.mutate(["original", "Error: boom"]); expect(container.render(40)).toEqual(["original", "Error: boom"]); expect(block.renderCount).toBe(2); // Once observed, the bypass re-engages at the new version. container.setNativeScrollbackCommittedRows(2); expect(container.render(40)).toEqual(["original", "Error: boom"]); expect(block.renderCount).toBe(2); }); it("renders once after a block finalizes with rows already inside committed scrollback", () => { const container = new TranscriptContainer(); const block = new StreamingBlock(["streaming"]); const counting = new CountingFinalizedBlock(["tail"]); container.addChild(block); container.addChild(counting); expect(container.render(40)).toEqual(["streaming", "", "tail"]); // The block's rows settle into committed scrollback while it is still // live (append-only commit path), then it finalizes with different bytes. // The first post-transition frame must render — the previous segment was // produced by a non-finalized render and is not trustworthy history. container.setNativeScrollbackCommittedRows(3); block.finalize(["streaming", "done"]); const rendersBeforeTransition = block.renderCount; expect(container.render(40)).toEqual(["streaming", "done", "", "tail"]); expect(block.renderCount).toBe(rendersBeforeTransition + 1); // The post-finalize render is now trustworthy history: once its rows are // committed, the bypass replays it without calling render(). container.setNativeScrollbackCommittedRows(2); const rendersAfterTransition = block.renderCount; expect(container.render(40)).toEqual(["streaming", "done", "", "tail"]); expect(block.renderCount).toBe(rendersAfterTransition); }); it("reports a new assistant block version after post-finalize error unpinning", () => { const message: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "hello" }], timestamp: Date.now(), stopReason: "error", errorMessage: "boom", } as AssistantMessage; const component = new AssistantMessageComponent(message); expect(component.isTranscriptBlockFinalized()).toBe(true); component.setErrorPinned(true); const pinnedVersion = component.getTranscriptBlockVersion(); // The restore path at the next turn's agent_start must be observable by // the transcript container's committed-scrollback bypass. component.setErrorPinned(false); expect(component.getTranscriptBlockVersion()).toBeGreaterThan(pinnedVersion); }); }); describe("TranscriptContainer spacing", () => { it("inserts exactly one blank line between consecutive blocks", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["a"])); container.addChild(new MutableBlock(["b"])); container.addChild(new MutableBlock(["c"])); // One separator between each block; none above the first. expect(container.render(40)).toEqual(["a", "", "b", "", "c"]); }); it("strips a block's plain-blank top/bottom padding", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["a"])); // Leading Spacer rows + a trailing paddingY row collapse to just the body. container.addChild(new MutableBlock(["", " ", "body", ""])); expect(container.render(40)).toEqual(["a", "", "body"]); }); it("preserves background-colored padding rows (block-internal design)", () => { const bgPad = "\x1b[48;2;0;0;0m \x1b[0m"; const container = new TranscriptContainer(); container.addChild(new MutableBlock(["a"])); // The ANSI-bearing padding row is not "plain blank", so it survives stripping. container.addChild(new MutableBlock([bgPad, "x", bgPad])); expect(container.render(40)).toEqual(["a", "", bgPad, "x", bgPad]); }); it("does not double the gap when a block carries its own trailing blank", () => { const container = new TranscriptContainer(); // The trailing blank is stripped, so only the container's separator remains. container.addChild(new MutableBlock(["note", ""])); container.addChild(new MutableBlock(["b"])); expect(container.render(40)).toEqual(["note", "", "b"]); }); it("does not inject separators within a single block's rows", () => { const container = new TranscriptContainer(); // An IRC card / file-mention list wrapped as one block stays tight inside. container.addChild(new MutableBlock(["header", " body1", " body2"])); expect(container.render(40)).toEqual(["header", " body1", " body2"]); }); it("drops a blank-only block without leaving a stray gap", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["a"])); container.addChild(new MutableBlock(["", " "])); container.addChild(new MutableBlock(["b"])); expect(container.render(40)).toEqual(["a", "", "b"]); }); it("counts the separator into the committed prefix below the live region (ED3-risk)", () => { const container = new TranscriptContainer(); // A finalized block, then a still-live block below it. container.addChild(new MutableBlock(["a1", "a2"])); container.addChild(new StreamingBlock(["b"])); // Separator sits at index 2; the live block's content begins at index 3. expect(container.render(40)).toEqual(["a1", "a2", "", "b"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(3); }); }); // The consumable stable-prefix floor (RenderStablePrefix): render() returns // the SAME persistent array every call, mutated in place, so the engine relies // on this report — not reference equality — to know which leading rows // survived. Reading consumes the report (re-bases the baseline to the current // array state); between reads the floor accumulates the MIN across renders. // `Text` children are ref-stable per (text, width), so an unchanged block's // segment is reused and counts toward the floor. describe("TranscriptContainer getRenderStablePrefixRows", () => { it("reports 0 until a second render proves the rows, then the full length", () => { const container = new TranscriptContainer(); container.addChild(new Text("alpha", 0, 0)); container.addChild(new Text("beta", 0, 0)); // First render only pushed rows; nothing is proven stable yet. expect(container.render(40)).toHaveLength(3); // alpha, separator, beta expect(container.getRenderStablePrefixRows()).toBe(0); // Unchanged finalized blocks: the second render reuses every row. const second = container.render(40); expect(container.getRenderStablePrefixRows()).toBe(second.length); }); it("keeps the previous rows stable when a finalized block is appended", () => { const container = new TranscriptContainer(); container.addChild(new Text("alpha", 0, 0)); container.addChild(new Text("beta", 0, 0)); const before = container.render(40).length; container.getRenderStablePrefixRows(); // consume: re-base to the current rows container.addChild(new Text("gamma", 0, 0)); const grown = container.render(40); expect(grown.length).toBeGreaterThan(before); // Only the appended block's separator + body are new rows. expect(container.getRenderStablePrefixRows()).toBe(before); }); it("lowers the report to a mutated early block's start row", () => { const container = new TranscriptContainer(); const beta = new Text("beta", 0, 0); container.addChild(new Text("alpha", 0, 0)); container.addChild(beta); container.addChild(new Text("gamma", 0, 0)); expect(container.render(40)).toHaveLength(5); container.getRenderStablePrefixRows(); // consume: re-base to the current rows beta.setText("beta-edited"); container.render(40); // alpha's single row survives; beta's segment (separator + body, start // row 1) and everything below it was re-pushed. expect(container.getRenderStablePrefixRows()).toBe(1); }); it("accumulates the minimum across renders between reads", () => { const container = new TranscriptContainer(); const gamma = new Text("gamma", 0, 0); container.addChild(new Text("alpha", 0, 0)); container.addChild(new Text("beta", 0, 0)); container.addChild(gamma); expect(container.render(40)).toHaveLength(5); container.getRenderStablePrefixRows(); // consume: re-base to the current rows // First render after the edit drops the floor to gamma's segment start // (row 3); a second, fully stable render must NOT lift it back — an // out-of-band render between engine frames can only lower the report. gamma.setText("gamma-edited"); container.render(40); container.render(40); expect(container.getRenderStablePrefixRows()).toBe(3); }); it("reports 0 after a width change", () => { const container = new TranscriptContainer(); container.addChild(new Text("alpha", 0, 0)); container.addChild(new Text("beta", 0, 0)); container.render(40); container.getRenderStablePrefixRows(); // consume: re-base to the current rows // A width change re-renders every block; no row carries over. container.render(80); expect(container.getRenderStablePrefixRows()).toBe(0); }); it("consumes on read: an immediate second read re-bases to the current rows", () => { const container = new TranscriptContainer(); container.addChild(new Text("alpha", 0, 0)); container.addChild(new Text("beta", 0, 0)); container.render(40); container.getRenderStablePrefixRows(); // consume: re-base to the current rows const reflowed = container.render(80); expect(container.getRenderStablePrefixRows()).toBe(0); // The read above re-based the baseline to the just-returned state, so // without any render in between the full array now counts as stable. expect(container.getRenderStablePrefixRows()).toBe(reflowed.length); }); }); describe("TranscriptContainer isBlockInLiveRegion", () => { it("opens at the first still-mutating block and includes everything below it", () => { const container = new TranscriptContainer(); const above = new StreamingBlock(["above"], true); const live = new StreamingBlock(["live"], false); const below = new StreamingBlock(["below"], true); container.addChild(above); container.addChild(live); container.addChild(below); expect(container.isBlockInLiveRegion(above)).toBe(false); expect(container.isBlockInLiveRegion(live)).toBe(true); expect(container.isBlockInLiveRegion(below)).toBe(true); }); it("anchors on the tail block when every block has finalized", () => { const container = new TranscriptContainer(); const first = new StreamingBlock(["first"], true); const tail = new StreamingBlock(["tail"], false); container.addChild(first); container.addChild(tail); expect(container.isBlockInLiveRegion(tail)).toBe(true); tail.finalize(); // All finalized: only the tail anchors the live region — a finalized // block above it is commit-eligible history (a detached task block // stops animating exactly on this transition). expect(container.isBlockInLiveRegion(first)).toBe(false); expect(container.isBlockInLiveRegion(tail)).toBe(true); }); it("returns false for a component that is not a child", () => { const container = new TranscriptContainer(); container.addChild(new StreamingBlock(["a"], true)); expect(container.isBlockInLiveRegion(new StreamingBlock(["x"], false))).toBe(false); }); }); describe("TranscriptContainer isBlockUncommitted", () => { it("returns true for a block that has never rendered", () => { const container = new TranscriptContainer(); const block = new MutableBlock(["not painted yet"]); container.addChild(block); expect(container.isBlockUncommitted(block)).toBe(true); }); it("tracks whether committed rows have reached a rendered block", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const block = new MutableBlock(["target-0", "target-1"]); container.addChild(block); expect(container.render(40)).toEqual(["history", "", "target-0", "target-1"]); container.setNativeScrollbackCommittedRows(1); expect(container.isBlockUncommitted(block)).toBe(true); container.setNativeScrollbackCommittedRows(3); expect(container.isBlockUncommitted(block)).toBe(false); }); it("keeps empty-render blocks uncommitted after committed rows advance", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const empty = new MutableBlock([]); container.addChild(empty); container.addChild(new MutableBlock(["tail"])); expect(container.render(40)).toEqual(["history", "", "tail"]); expect(container.isBlockUncommitted(empty)).toBe(true); container.setNativeScrollbackCommittedRows(100); expect(container.isBlockUncommitted(empty)).toBe(true); }); }); describe("TranscriptContainer renderViewportTail", () => { const W = 40; // Four two-row blocks. A full render joins them with one blank separator: // b0a b0b "" b1a b1b "" b2a b2b "" b3a b3b → 11 rows. function fourBlocks(): { container: TranscriptContainer; blocks: CountingFinalizedBlock[] } { const blocks = [0, 1, 2, 3].map(i => new CountingFinalizedBlock([`b${i}a`, `b${i}b`])); const container = new TranscriptContainer(); for (const b of blocks) container.addChild(b); return { container, blocks }; } it("returns exactly the bottom rows of a full render", () => { const { container } = fourBlocks(); const full = [...container.render(W)]; expect(full).toEqual(["b0a", "b0b", "", "b1a", "b1b", "", "b2a", "b2b", "", "b3a", "b3b"]); // A clean block boundary (the separator before b2) lands at the fold. expect([...container.renderViewportTail(W, 5)]).toEqual(full.slice(full.length - 5)); // A mid-block fold still yields the exact bottom rows. expect([...container.renderViewportTail(W, 4)]).toEqual(full.slice(full.length - 4)); expect([...container.renderViewportTail(W, 1)]).toEqual(["b3b"]); }); it("renders only the blocks needed to fill the request", () => { const { container, blocks } = fourBlocks(); container.render(W); for (const b of blocks) b.renderCount = 0; // Bottom 4 rows span b3 (whole) and b2 (its last row visible): blocks // above the fold must never be rendered. container.renderViewportTail(W, 4); expect(blocks.map(b => b.renderCount)).toEqual([0, 0, 1, 1]); }); it("returns the whole transcript top-aligned when it is shorter than the request", () => { const { container } = fourBlocks(); const full = [...container.render(W)]; expect([...container.renderViewportTail(W, 100)]).toEqual(full); }); it("never mutates persistent full-compose state", () => { const { container } = fourBlocks(); const before = [...container.render(W)]; const liveBefore = container.getNativeScrollbackLiveRegionStart(); // Interleave tail renders at other widths and sizes. container.renderViewportTail(80, 3); container.renderViewportTail(20, 7); container.renderViewportTail(W, 2); const after = [...container.render(W)]; expect(after).toEqual(before); expect(container.getNativeScrollbackLiveRegionStart()).toBe(liveBefore); }); it("handles empty and zero-row requests", () => { const empty = new TranscriptContainer(); expect([...empty.renderViewportTail(W, 10)]).toEqual([]); const { container } = fourBlocks(); expect([...container.renderViewportTail(W, 0)]).toEqual([]); }); }); // A displaceable snapshot (todo/poll card): kept unfinalized only so a matching // follow-up call can retract it. Mirrors ToolExecutionComponent.seal — sealing // finalizes the block in place and it stops reporting displaceable. A pending // tool starts non-displaceable and becomes a displaceable snapshot only when // its successful result arrives (`makeDisplaceable`). class DisplaceableBlock implements Component { sealCount = 0; #sealed = false; #displaceable: boolean; #lines: string[]; constructor(lines: string[], displaceable = true) { this.#lines = lines; this.#displaceable = displaceable; } makeDisplaceable(): void { this.#displaceable = true; } isTranscriptBlockFinalized(): boolean { return this.#sealed; } isDisplaceableBlock(): boolean { return this.#displaceable && !this.#sealed; } seal(): void { this.sealCount++; this.#sealed = true; } invalidate(): void {} render(_width: number): string[] { return [...this.#lines]; } } // Seal-on-commit: rows on the native-scrollback tape are immutable, so once the // commit boundary covers any of a displaceable snapshot's rows the container // must seal it in place — retracting it would strand an orphaned copy in // terminal history, and left unfinalized it would pin the live-region seam // open. setNativeScrollbackCommittedRows is a pure store; the seal walk runs at // the start of the NEXT render, over the previous frame's segments (the // geometry the committed count was computed against), before the seam scan so // the seam unpins in that same frame. describe("TranscriptContainer seal-on-commit", () => { const W = 40; // history(0) | sep(1) | todo-header(2) | todo-body(3); the leading // separator row belongs to the card's segment (segment.startRow = 1). function cardAfterHistory(displaceable = true): { container: TranscriptContainer; card: DisplaceableBlock } { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const card = new DisplaceableBlock(["todo-header", "todo-body"], displaceable); container.addChild(card); expect(container.render(W)).toEqual(["history", "", "todo-header", "todo-body"]); return { container, card }; } it("seals on the next render once the boundary covers the block's rows", () => { const { container, card } = cardAfterHistory(); // The unsealed card pins the live-region seam at its own rows. expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); // Rows 0..2 (through the card's header) are immutable history now. container.setNativeScrollbackCommittedRows(3); // The setter is a pure store: sealing waits for the next compose. expect(card.sealCount).toBe(0); container.render(W); expect(card.sealCount).toBe(1); expect(container.isBlockUncommitted(card)).toBe(false); // The seal pre-pass ran before the seam scan: the SAME render already // reports the seam unpinned (no still-mutating block left). expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined(); }); it("does not seal while the boundary stays above the block", () => { const { container, card } = cardAfterHistory(); // Only "history" committed; the card's rows are all still retractable. container.setNativeScrollbackCommittedRows(1); container.render(W); expect(card.sealCount).toBe(0); expect(container.isBlockUncommitted(card)).toBe(true); }); it("never seals across same-value or decreasing republishes above the block", () => { const { container, card } = cardAfterHistory(); // The engine republishes the committed count every frame (compose and // post-emit): repeated same-value and decreasing stores above the // card's rows never accumulate into a seal. container.setNativeScrollbackCommittedRows(1); container.render(W); container.setNativeScrollbackCommittedRows(1); container.render(W); container.setNativeScrollbackCommittedRows(0); container.render(W); expect(card.sealCount).toBe(0); expect(container.isBlockUncommitted(card)).toBe(true); }); it("seals exactly once as the boundary sweeps past the block in stages", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const card = new DisplaceableBlock(["todo-header", "todo-body"]); container.addChild(card); container.addChild(new MutableBlock(["tail"])); expect(container.render(W)).toEqual(["history", "", "todo-header", "todo-body", "", "tail"]); // First crossing (through the header) seals; the sealed block stops // reporting displaceable. container.setNativeScrollbackCommittedRows(3); container.render(W); expect(card.sealCount).toBe(1); // A later sweep past the whole block, and every subsequent render at // that boundary, must not seal again. container.setNativeScrollbackCommittedRows(6); container.render(W); container.render(W); expect(card.sealCount).toBe(1); }); it("never seals a displaceable block with an empty contribution", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); const empty = new DisplaceableBlock([]); container.addChild(empty); container.addChild(new MutableBlock(["tail"])); expect(container.render(W)).toEqual(["history", "", "tail"]); // None of the block's rows are on the tape: nothing to seal, ever. container.setNativeScrollbackCommittedRows(3); container.render(W); expect(empty.sealCount).toBe(0); expect(container.isBlockUncommitted(empty)).toBe(true); }); it("walks past blocks without the displaceable protocol", () => { const container = new TranscriptContainer(); const plain = new MutableBlock(["plain-block"]); container.addChild(plain); const card = new DisplaceableBlock(["todo-header"]); container.addChild(card); expect(container.render(W)).toEqual(["plain-block", "", "todo-header"]); container.setNativeScrollbackCommittedRows(3); // The pre-pass visits the plain block first (its rows also committed); // absent duck-typed methods are a no-op and the card below still seals. container.render(W); expect(card.sealCount).toBe(1); expect(container.isBlockUncommitted(plain)).toBe(false); }); it("seals a block that became displaceable after its rows committed", () => { // A pending tool's preview rows scroll into native scrollback before // its successful result arrives; only then does the block become a // displaceable snapshot. The walk runs every render, so the flip is // caught on the next compose — not only when the boundary moves. const { container, card } = cardAfterHistory(false); container.setNativeScrollbackCommittedRows(3); container.render(W); // Rows committed while not displaceable: nothing to seal yet. expect(card.sealCount).toBe(0); card.makeDisplaceable(); container.render(W); expect(card.sealCount).toBe(1); }); });