mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
236 lines
6.8 KiB
TypeScript
236 lines
6.8 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { Container, type Terminal, Text, TUI } from "@earendil-works/pi-tui";
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { createEditToolDefinition } from "../src/core/tools/edit.js";
|
|
import { computeEditsDiff, type Edit } from "../src/core/tools/edit-diff.js";
|
|
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";
|
|
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
|
|
|
class FakeTerminal implements Terminal {
|
|
columns = 80;
|
|
rows = 24;
|
|
kittyProtocolActive = true;
|
|
writes: string[] = [];
|
|
|
|
start(): void {}
|
|
stop(): void {}
|
|
async drainInput(): Promise<void> {}
|
|
write(data: string): void {
|
|
this.writes.push(data);
|
|
}
|
|
moveBy(_lines: number): void {}
|
|
hideCursor(): void {}
|
|
showCursor(): void {}
|
|
clearLine(): void {}
|
|
clearFromCursor(): void {}
|
|
clearScreen(): void {}
|
|
setTitle(_title: string): void {}
|
|
setProgress(_active: boolean): void {}
|
|
|
|
get fullClearCount(): number {
|
|
return this.writes.filter((write) => write.includes("\x1b[2J\x1b[H\x1b[3J")).length;
|
|
}
|
|
}
|
|
|
|
async function waitForRender(): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
async function waitForRenderedText(
|
|
getRender: () => string,
|
|
expectedText: string,
|
|
onRetry?: () => void,
|
|
timeoutMs = 2000,
|
|
): Promise<string> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastRender = "";
|
|
while (Date.now() < deadline) {
|
|
onRetry?.();
|
|
await waitForRender();
|
|
lastRender = getRender();
|
|
if (lastRender.includes(expectedText)) {
|
|
return lastRender;
|
|
}
|
|
}
|
|
throw new Error(`Timed out waiting for render to include "${expectedText}". Last render:\n${lastRender}`);
|
|
}
|
|
|
|
function createLargeEdits(lines: string[]): Edit[] {
|
|
const targets = [50, 150, 250, 350, 450, 550, 650, 750, 850, 950];
|
|
return targets.map((lineNumber) => ({
|
|
oldText: `${lines[lineNumber - 1]}\n${lines[lineNumber]}\n${lines[lineNumber + 1]}`,
|
|
newText: `${lines[lineNumber - 1]}\n${lines[lineNumber]} changed\n${lines[lineNumber + 1]}`,
|
|
}));
|
|
}
|
|
|
|
describe("edit tool TUI rendering", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
beforeAll(() => {
|
|
initTheme("dark");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
it("renders the large diff in the call preview and does not full-redraw when the result settles", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "pi-edit-redraw-"));
|
|
tempDirs.push(dir);
|
|
const filePath = join(dir, "large-edit.txt");
|
|
await writeFile(
|
|
filePath,
|
|
`${Array.from({ length: 1000 }, (_, i) => `line ${i}`).join("\n")}
|
|
`,
|
|
"utf8",
|
|
);
|
|
const lines = (await readFile(filePath, "utf8")).trimEnd().split("\n");
|
|
const edits = createLargeEdits(lines);
|
|
const diff = await computeEditsDiff(filePath, edits, process.cwd());
|
|
if ("error" in diff) {
|
|
throw new Error(diff.error);
|
|
}
|
|
|
|
const terminal = new FakeTerminal();
|
|
const tui = new TUI(terminal);
|
|
const root = new Container();
|
|
for (let i = 0; i < 200; i++) {
|
|
root.addChild(new Text(`history ${i}`, 0, 0));
|
|
}
|
|
|
|
const component = new ToolExecutionComponent(
|
|
"edit",
|
|
"tool-call-1",
|
|
{ path: filePath, edits },
|
|
{},
|
|
createEditToolDefinition(process.cwd()),
|
|
tui,
|
|
process.cwd(),
|
|
);
|
|
root.addChild(component);
|
|
tui.addChild(root);
|
|
tui.start();
|
|
await waitForRender();
|
|
|
|
component.setArgsComplete();
|
|
tui.requestRender();
|
|
await waitForRender();
|
|
await waitForRender();
|
|
|
|
const callOnlyRender = await waitForRenderedText(
|
|
() => component.render(80).join("\n"),
|
|
"line 50 changed",
|
|
() => tui.requestRender(true),
|
|
);
|
|
expect(callOnlyRender).toContain("edit");
|
|
expect(callOnlyRender).toContain("line 950 changed");
|
|
|
|
const redrawsBeforeResult = tui.fullRedraws;
|
|
const clearsBeforeResult = terminal.fullClearCount;
|
|
component.updateResult(
|
|
{
|
|
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${filePath}.` }],
|
|
details: diff,
|
|
isError: false,
|
|
},
|
|
false,
|
|
);
|
|
tui.requestRender();
|
|
await waitForRender();
|
|
|
|
expect(tui.fullRedraws).toBe(redrawsBeforeResult);
|
|
expect(terminal.fullClearCount).toBe(clearsBeforeResult);
|
|
|
|
const settledRender = component.render(80).join("\n");
|
|
expect(settledRender).toContain("line 50 changed");
|
|
expect(settledRender).toContain("line 950 changed");
|
|
expect(settledRender).not.toContain("Successfully replaced");
|
|
});
|
|
|
|
it("reconstructs the boxed preview from a settled result without argsComplete", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "pi-edit-replay-"));
|
|
tempDirs.push(dir);
|
|
const filePath = join(dir, "replay-edit.txt");
|
|
await writeFile(
|
|
filePath,
|
|
`${Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n")}
|
|
`,
|
|
"utf8",
|
|
);
|
|
const lines = (await readFile(filePath, "utf8")).trimEnd().split("\n");
|
|
const edits = createLargeEdits(lines).slice(0, 2);
|
|
const diff = await computeEditsDiff(filePath, edits, process.cwd());
|
|
if ("error" in diff) {
|
|
throw new Error(diff.error);
|
|
}
|
|
await rm(filePath, { force: true });
|
|
|
|
const terminal = new FakeTerminal();
|
|
const tui = new TUI(terminal);
|
|
const component = new ToolExecutionComponent(
|
|
"edit",
|
|
"tool-call-replay",
|
|
{ path: filePath, edits },
|
|
{},
|
|
createEditToolDefinition(process.cwd()),
|
|
tui,
|
|
process.cwd(),
|
|
);
|
|
tui.addChild(component);
|
|
tui.start();
|
|
await waitForRender();
|
|
|
|
component.updateResult(
|
|
{
|
|
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${filePath}.` }],
|
|
details: diff,
|
|
isError: false,
|
|
},
|
|
false,
|
|
);
|
|
await waitForRender();
|
|
await waitForRender();
|
|
|
|
const rendered = component.render(80).join("\n");
|
|
expect(rendered).toContain("line 50 changed");
|
|
expect(rendered).toContain("line 150 changed");
|
|
});
|
|
|
|
it("shows a preflight error without rendering a diff when the edits do not apply", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "pi-edit-preflight-"));
|
|
tempDirs.push(dir);
|
|
const filePath = join(dir, "missing-edit.txt");
|
|
await writeFile(filePath, "line 0\nline 1\n", "utf8");
|
|
|
|
const terminal = new FakeTerminal();
|
|
const tui = new TUI(terminal);
|
|
const component = new ToolExecutionComponent(
|
|
"edit",
|
|
"tool-call-2",
|
|
{ path: filePath, edits: [{ oldText: "does not exist", newText: "replacement" }] },
|
|
{},
|
|
createEditToolDefinition(process.cwd()),
|
|
tui,
|
|
process.cwd(),
|
|
);
|
|
tui.addChild(component);
|
|
tui.start();
|
|
await waitForRender();
|
|
|
|
component.setArgsComplete();
|
|
tui.requestRender();
|
|
await waitForRender();
|
|
await waitForRender();
|
|
|
|
const rendered = await waitForRenderedText(
|
|
() => component.render(80).join("\n"),
|
|
"Could not find",
|
|
() => tui.requestRender(true),
|
|
);
|
|
expect(rendered).not.toContain("+1 ");
|
|
expect(rendered).not.toContain("-1 ");
|
|
});
|
|
});
|