diff --git a/src/agents/sessions/tools/edit-diff.ts b/src/agents/sessions/tools/edit-diff.ts index c18efcbd9ce..2e1b3e94acb 100644 --- a/src/agents/sessions/tools/edit-diff.ts +++ b/src/agents/sessions/tools/edit-diff.ts @@ -423,6 +423,11 @@ export interface EditDiffError { error: string; } +export interface EditDiffOperations { + readFile: (absolutePath: string) => Promise; + access: (absolutePath: string) => Promise; +} + /** * Compute the diff for one or more edit operations without applying them. * Used for preview rendering in the TUI before the tool executes. @@ -431,13 +436,18 @@ export async function computeEditsDiff( path: string, edits: Edit[], cwd: string, + operations?: EditDiffOperations, ): Promise { const absolutePath = resolveToCwd(path, cwd); try { // Check if file exists and is readable try { - await access(absolutePath, constants.R_OK); + if (operations) { + await operations.access(absolutePath); + } else { + await access(absolutePath, constants.R_OK); + } } catch (error: unknown) { const errorMessage = error instanceof Error && "code" in error @@ -447,7 +457,11 @@ export async function computeEditsDiff( } // Read the file - const rawContent = await readFile(absolutePath, "utf-8"); + const rawContentResult = operations + ? await operations.readFile(absolutePath) + : await readFile(absolutePath, "utf-8"); + const rawContent = + typeof rawContentResult === "string" ? rawContentResult : rawContentResult.toString("utf-8"); // Strip BOM before matching (LLM won't include invisible BOM in oldText) const { text: content } = stripBom(rawContent); diff --git a/src/agents/sessions/tools/edit.test.ts b/src/agents/sessions/tools/edit.test.ts index 642dcf045c2..ce069ca6881 100644 --- a/src/agents/sessions/tools/edit.test.ts +++ b/src/agents/sessions/tools/edit.test.ts @@ -1,8 +1,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createEditTool, type EditOperations } from "./edit.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; +import { createEditTool, createEditToolDefinition, type EditOperations } from "./edit.js"; + +const testTheme = { + bg: (_name: string, text: string) => text, + bold: (text: string) => text, + fg: (_name: string, text: string) => text, +} as Theme; describe("edit tool", () => { let tmpDir = ""; @@ -125,4 +132,40 @@ describe("edit tool", () => { text: `Successfully replaced 2 block(s) in ${filePath}.`, }); }); + + it("renders previews through custom edit operations", async () => { + const readFile = vi.fn(async () => Buffer.from("remote original\n")); + const operations: EditOperations = { + access: async () => {}, + readFile, + writeFile: async () => {}, + }; + const tool = createEditToolDefinition("/workspace", { operations }); + const args = { + path: "remote.txt", + edits: [{ oldText: "remote original", newText: "remote changed" }], + }; + const context = { + args, + argsComplete: true, + cwd: "/workspace", + executionStarted: false, + expanded: false, + invalidate: vi.fn(), + isError: false, + isPartial: false, + lastComponent: undefined, + showImages: false, + state: {}, + toolCallId: "call-preview", + }; + + const component = tool.renderCall?.(args, testTheme, context); + await vi.waitFor(() => expect(context.invalidate).toHaveBeenCalled()); + + expect(readFile).toHaveBeenCalledWith(path.join("/workspace", "remote.txt")); + expect((component as { preview?: { diff?: string } } | undefined)?.preview?.diff).toContain( + "remote changed", + ); + }); }); diff --git a/src/agents/sessions/tools/edit.ts b/src/agents/sessions/tools/edit.ts index e9ec69e475b..8869bbfd009 100644 --- a/src/agents/sessions/tools/edit.ts +++ b/src/agents/sessions/tools/edit.ts @@ -471,7 +471,7 @@ export function createEditToolDefinition( if (context.argsComplete && previewInput && !component.preview && !component.previewPending) { component.previewPending = true; const requestKey = argsKey; - void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then( + void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd, ops).then( (preview) => { if (component.previewArgsKey === requestKey) { setEditPreview(component, preview, requestKey);