a8a0cc8a40
- Clamped tool output preview height to the available viewport rows to stop redundant banner commits. - Added `outputBlockContentWidth` helper to accurately measure visual lines for scrollback budget calculations. - Updated `bash` and `eval-render` output wrapping to account for block padding and inner content width. - Added regression test confirming streaming tool output maintains a stable line count without duplicating headers.
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
|
|
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
|
|
import { theme as activeTheme, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import { evalToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/eval-render";
|
|
import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils";
|
|
import { type Component, TUI } from "@oh-my-pi/pi-tui";
|
|
import { VirtualTerminal } from "../../tui/test/virtual-terminal";
|
|
|
|
// Long, path-like output that wraps at the box's inner width — the case that
|
|
// made a fixed 10-line preview overflow the viewport once committed.
|
|
function longLines(count: number): string {
|
|
return Array.from(
|
|
{ length: count },
|
|
(_, i) => `out-line-${i} ${"=".repeat(60)} https://example.com/very/long/path/segment/${i}`,
|
|
).join("\n");
|
|
}
|
|
|
|
type DrainableScheduler = {
|
|
now(): number;
|
|
scheduleImmediate(cb: () => void): void;
|
|
scheduleRender(cb: () => void, delayMs: number): { cancel(): void };
|
|
flush(): void;
|
|
};
|
|
function makeDrainableScheduler(): DrainableScheduler {
|
|
let clock = 0;
|
|
const queue: Array<{ run: () => void; cancelled: boolean }> = [];
|
|
const enqueue = (cb: () => void) => {
|
|
const item = { run: cb, cancelled: false };
|
|
queue.push(item);
|
|
return item;
|
|
};
|
|
return {
|
|
now: () => clock,
|
|
scheduleImmediate(cb) {
|
|
enqueue(cb);
|
|
},
|
|
scheduleRender(cb) {
|
|
const item = enqueue(cb);
|
|
return {
|
|
cancel() {
|
|
item.cancelled = true;
|
|
},
|
|
};
|
|
},
|
|
flush() {
|
|
let guard = 0;
|
|
while (queue.length > 0) {
|
|
if (++guard > 100_000) throw new Error("scheduler did not settle");
|
|
const item = queue.shift()!;
|
|
clock += 1;
|
|
if (!item.cancelled) item.run();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// Plain Component → finalized by default: a settled block above the live region.
|
|
class StaticBlock implements Component {
|
|
#lines: string[];
|
|
constructor(lines: string[]) {
|
|
this.#lines = lines;
|
|
}
|
|
invalidate(): void {}
|
|
render(_width: number): string[] {
|
|
return this.#lines;
|
|
}
|
|
}
|
|
|
|
// A still-live predecessor (e.g. a parallel tool that is still running): being
|
|
// non-finalized closes the transcript's commit-safe run, so the streaming tool
|
|
// below it commits as forced-overflow — the path that sprayed.
|
|
class LiveBarrier extends StaticBlock {
|
|
isTranscriptBlockFinalized(): boolean {
|
|
return false;
|
|
}
|
|
isTranscriptBlockCommitStable(): boolean {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Stand-in for the input editor + status drawn below the transcript.
|
|
class Footer implements Component {
|
|
#rows: number;
|
|
constructor(rows: number) {
|
|
this.#rows = rows;
|
|
}
|
|
invalidate(): void {}
|
|
render(_width: number): string[] {
|
|
return Array.from({ length: this.#rows }, (_, i) => `editor-${i}`);
|
|
}
|
|
}
|
|
|
|
const ORIGINAL_ROWS = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
|
function stubStdoutRows(rows: number): void {
|
|
Object.defineProperty(process.stdout, "rows", { configurable: true, value: rows });
|
|
}
|
|
|
|
describe("streaming tool output never sprays duplicate scrollback banners", () => {
|
|
beforeAll(async () => {
|
|
await initTheme();
|
|
});
|
|
afterEach(() => {
|
|
if (ORIGINAL_ROWS) Object.defineProperty(process.stdout, "rows", ORIGINAL_ROWS);
|
|
else Reflect.deleteProperty(process.stdout, "rows");
|
|
});
|
|
|
|
test("bash: growing partial output under a live predecessor does not duplicate banners", async () => {
|
|
if (process.platform === "win32") return;
|
|
const rows = 14;
|
|
stubStdoutRows(rows);
|
|
const term = new VirtualTerminal(80, rows);
|
|
const scheduler = makeDrainableScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const transcript = new TranscriptContainer();
|
|
transcript.addChild(new StaticBlock(["user: run the build"]));
|
|
transcript.addChild(new LiveBarrier(["assistant: still working in a parallel tool…"]));
|
|
const bash = new ToolExecutionComponent("bash", { command: "build.sh" }, {}, undefined, tui, process.cwd());
|
|
transcript.addChild(bash);
|
|
tui.addChild(transcript);
|
|
tui.addChild(new Footer(6));
|
|
|
|
try {
|
|
tui.start();
|
|
scheduler.flush();
|
|
await term.flush();
|
|
for (let n = 1; n <= 40; n++) {
|
|
bash.updateResult({ content: [{ type: "text", text: longLines(n) }], isError: false }, true);
|
|
term.scrollLines(1000);
|
|
tui.requestRender();
|
|
scheduler.flush();
|
|
await term.flush();
|
|
}
|
|
const buffer = term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd());
|
|
const banners = buffer.filter(row => row.includes("ctrl+o")).length;
|
|
// Pre-fix this re-committed a fresh snapshot per streamed frame (~30+).
|
|
expect(banners).toBeLessThanOrEqual(1);
|
|
} finally {
|
|
bash.stopAnimation();
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
}, 30_000);
|
|
|
|
test("eval: collapsed cell output stays within the viewport budget", () => {
|
|
const rows = 18;
|
|
stubStdoutRows(rows);
|
|
const result = {
|
|
content: [{ type: "text", text: "" }],
|
|
details: {
|
|
cells: [
|
|
{ index: 0, code: "run()", language: "js" as const, output: longLines(60), status: "running" as const },
|
|
],
|
|
},
|
|
isError: false,
|
|
};
|
|
const component = evalToolRenderer.renderResult(result, { expanded: false, isPartial: true }, activeTheme);
|
|
const lines = component.render(80);
|
|
// The collapsed cell box fits the viewport budget: code + output tails are
|
|
// each capped at previewWindowRows() VISUAL rows. Pre-fix the long output
|
|
// wrapped into ~2x its line count and blew past this.
|
|
expect(lines.length).toBeLessThanOrEqual(previewWindowRows() + 10);
|
|
expect(lines.map(line => Bun.stripANSI(line)).join("\n")).toContain("ctrl+o");
|
|
});
|
|
});
|