import { beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { InMemorySnapshotStore } from "@oh-my-pi/hashline"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer"; import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { Text, type TUI } from "@oh-my-pi/pi-tui"; beforeAll(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: process.cwd() }); }); async function getUiTheme() { await themeModule.initTheme(false, undefined, undefined, "dark", "light"); const theme = await themeModule.getThemeByName("dark"); expect(theme).toBeDefined(); return theme!; } async function waitForRenderedText( component: ToolExecutionComponent, width: number, expectedText: string, ): Promise { const deadline = Date.now() + 1_000; let rendered = ""; while (Date.now() < deadline) { rendered = Bun.stripANSI(component.render(width).join("\n")); if (rendered.includes(expectedText)) return rendered; await Bun.sleep(10); } return rendered; } describe("editToolRenderer", () => { it("shows the target path from partial JSON while edit args stream", async () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( { edits: [{}], __partialJson: '{"edits":[{"path":"packages/coding-agent/src/edit/renderer.ts","old_text":"before', }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "replace" } }, uiTheme, ); const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts"); }); it("uses hashline input headers for streaming call path without apply_patch errors", async () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( { input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts"); expect(rendered).not.toContain("The first line of the patch must be"); }); it("shows hashline envelope target path while preview diff is not computable yet", async () => { await getUiTheme(); const uiStub = { requestRender() {} } as unknown as TUI; const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool; const component = new ToolExecutionComponent( "edit", { input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "|pub fn streaming_preview() {"].join( "\n", ), }, {}, hashlineTool, uiStub, ); const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("crates/pi-natives/src/shell.rs"); expect(rendered).not.toContain("EOF:"); expect(rendered).not.toContain("|pub fn streaming_preview() {"); expect(rendered).not.toContain("*** Begin Patch"); }); it("recognizes compact and quoted hashline input headers", async () => { const uiTheme = await getUiTheme(); const compactComponent = editToolRenderer.renderCall( { input: "¶foo bar.ts\nBOF:\n|// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); const quotedComponent = editToolRenderer.renderCall( { input: "¶'baz qux.ts'\nBOF:\n|// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); const compactRendered = Bun.stripANSI(compactComponent.render(160).join("\n")); const quotedRendered = Bun.stripANSI(quotedComponent.render(160).join("\n")); expect(compactRendered).toContain("foo bar.ts"); expect(quotedRendered).toContain("baz qux.ts"); }); it("strips canonical `¶` and longer `¶` runs from hashline input headers", async () => { const uiTheme = await getUiTheme(); // Canonical `¶PATH` form — the parser strips the marker and the // renderer keeps the title clean. const canonical = editToolRenderer.renderCall( { input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n|// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); // Even longer runs should still produce the clean path. const triple = editToolRenderer.renderCall( { input: "¶¶¶a/b/c.ts\nBOF:\n|// preview" }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); const canonicalRendered = Bun.stripANSI(canonical.render(160).join("\n")); const tripleRendered = Bun.stripANSI(triple.render(160).join("\n")); expect(canonicalRendered).toContain("packages/coding-agent/src/slash-commands/builtin-registry.ts"); expect(canonicalRendered).not.toMatch(/¶packages\/coding-agent/); expect(tripleRendered).toContain("a/b/c.ts"); expect(tripleRendered).not.toMatch(/¶+a\/b\/c\.ts/); }); it("uses hashline input headers for completed single-file result path", async () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderResult( { content: [{ type: "text", text: "Updated packages/coding-agent/src/edit/renderer.ts" }], details: { diff: "+1|// preview", op: "update", }, }, { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, uiTheme, { input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview", }, ); const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("packages/coding-agent/src/edit/renderer.ts"); expect(rendered).not.toContain(" …"); }); it("computes the hashline preview diff once a single-line edit finishes streaming", async () => { await getUiTheme(); const uiStub = { requestRender() {} } as unknown as TUI; const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-stream-preview-")); try { const content = "export const a = 1;\nexport const b = 2;\nexport const c = 3;\n"; const filePath = path.join(tmpDir, "memory.ts"); await Bun.write(filePath, content); const snapshots = new InMemorySnapshotStore(); const tag = snapshots.record(filePath, content); // The trailing payload line carries no newline — the common shape for a // single-line edit. The streaming pass trims that in-flight line, so the // preview only becomes computable once args are marked complete. const input = `¶memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;`; const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir); component.setArgsComplete(); await Bun.sleep(50); const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("export const b = 22;"); expect(rendered).not.toContain("No changes would be made"); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("renders raw custom hashline input carried only in partialJson", async () => { await getUiTheme(); const uiStub = { requestRender() {} } as unknown as TUI; const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-custom-stream-preview-")); try { const content = "export const a = 1;\nexport const b = 2;\n"; const filePath = path.join(tmpDir, "memory.ts"); await Bun.write(filePath, content); const snapshots = new InMemorySnapshotStore(); const tag = snapshots.record(filePath, content); const input = `¶memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;\n`; const component = new ToolExecutionComponent( "edit", { __partialJson: input }, { snapshots }, hashlineTool, uiStub, tmpDir, ); const rendered = await waitForRenderedText(component, 160, "export const b = 22;"); expect(rendered).toContain("memory.ts"); expect(rendered).toContain("export const b = 22;"); expect(rendered).not.toContain(" …"); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it("renders raw custom apply_patch input carried only in partialJson", async () => { await getUiTheme(); const uiStub = { requestRender() {} } as unknown as TUI; const input = [ "*** Begin Patch", "*** Update File: src/demo.ts", "@@", "-const value = 1;", "+const value = 2;", "*** End Patch", ].join("\n"); const component = new ToolExecutionComponent("apply_patch", { __partialJson: input }, {}, undefined, uiStub); const rendered = await waitForRenderedText(component, 160, "const value = 2;"); expect(rendered).toContain("src/demo.ts"); expect(rendered).toContain("const value = 2;"); expect(rendered).not.toContain(" …"); }); it("normalizes raw streamed text input for any renderer", async () => { await getUiTheme(); const uiStub = { requestRender() {} } as unknown as TUI; const customTextTool = { name: "custom_text", label: "Custom Text", renderCall(args: unknown) { const input = typeof (args as { input?: unknown }).input === "string" ? (args as { input: string }).input : ""; return new Text(input, 0, 0); }, } as unknown as AgentTool; const component = new ToolExecutionComponent( "custom_text", { __partialJson: "plain streamed text" }, {}, customTextTool, uiStub, ); const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("plain streamed text"); }); });