Files
oh-my-pi/packages/coding-agent/test/modes/components/transcript-container.test.ts
T
can1357 153c3d6565 refactor(packages/coding-agent): simplified terminal transcript rendering
- Removed terminal risk mode toggles from config, terminal state collection, and render controllers.
- Dropped snapshot freezing, thaw tracking, and finalized-block replay in transcript rendering.
- Removed clear-on-shrink settings and initialization hooks from selector and interactive mode flows.
- Simplified render scheduling by using requestRender() without mutation flags or stream checkpoints.
2026-06-09 20:45:01 +02:00

281 lines
11 KiB
TypeScript

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 {}
render(_width: number): string[] {
return [...this.#lines];
}
}
beforeAll(() => {
initTheme();
});
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
});
afterEach(() => {
resetSettingsForTest();
});
function makeAssistantMessage(overrides: Partial<AssistantMessage> = {}): 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: 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 the live block start that gates native scrollback commits", () => {
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()).toBe(3);
b.set(["b1", "b2"]);
expect(container.render(40)).toEqual(["a1", "a2", "", "b1", "b2"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
});
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"]);
// The seam moves past the now-finalized tool.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// 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).not.toContain(USER_INTERRUPT_LABEL);
expect(rendered).toContain("Copied raw SSE stream");
expect(container.getNativeScrollbackLiveRegionStart()).not.toBe(0);
});
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()).toBe(4);
});
});
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);
});
});