diff --git a/src/headless.ts b/src/headless.ts index 4145a50..bb048a5 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -73,6 +73,7 @@ import { validateRegistryHandleOrThrow, } from "./cli/startupFlagValidation"; import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "./constants"; +import { computeDiffPreviews } from "./helpers/diffPreview"; import { mergeQueuedTurnInput, type QueuedTurnInput, @@ -2548,6 +2549,9 @@ async function runBidirectionalMode( }> { const requestId = `perm-${toolCallId}`; + // Compute diff previews for file-modifying tools + const diffs = await computeDiffPreviews(toolName, toolInput); + // Build can_use_tool control request (Claude SDK format) const canUseToolRequest: CanUseToolControlRequest = { subtype: "can_use_tool", @@ -2556,6 +2560,7 @@ async function runBidirectionalMode( tool_call_id: toolCallId, // Letta-specific permission_suggestions: [], // TODO: not implemented blocked_path: null, // TODO: not implemented + ...(diffs.length > 0 ? { diffs } : {}), }; const controlRequest: ControlRequest = { diff --git a/src/helpers/diffPreview.ts b/src/helpers/diffPreview.ts new file mode 100644 index 0000000..394de3f --- /dev/null +++ b/src/helpers/diffPreview.ts @@ -0,0 +1,191 @@ +/** + * Converts internal diff results (AdvancedDiffResult) to wire-safe DiffPreview + * for the bidirectional protocol. Strips full file contents (oldStr/newStr) + * and only sends hunks, which is sufficient for rendering. + */ + +import { basename } from "node:path"; +import type { AdvancedDiffResult, AdvancedHunk } from "../cli/helpers/diff"; +import type { DiffHunk, DiffHunkLine, DiffPreview } from "../types/protocol"; + +function parseHunkLinePrefix(raw: string): DiffHunkLine | null { + if (raw.length === 0) { + return { type: "context", content: "" }; + } + if (raw[0] === "\\") { + // Metadata line (e.g. "\ No newline at end of file"), not a diff row. + return null; + } + const prefix = raw[0]; + const content = raw.slice(1); + if (prefix === "+") return { type: "add", content }; + if (prefix === "-") return { type: "remove", content }; + if (prefix === " ") return { type: "context", content }; + // Unknown prefix: preserve full line as context rather than dropping first char. + return { type: "context", content: raw }; +} + +function convertHunk(hunk: AdvancedHunk): DiffHunk { + const lines: DiffHunkLine[] = []; + for (const line of hunk.lines) { + const parsed = parseHunkLinePrefix(line.raw); + if (parsed) { + lines.push(parsed); + } + } + + let oldLines = 0; + let newLines = 0; + for (const line of lines) { + if (line.type === "context") { + oldLines++; + newLines++; + } else if (line.type === "remove") { + oldLines++; + } else if (line.type === "add") { + newLines++; + } + } + + return { + oldStart: hunk.oldStart, + oldLines, + newStart: hunk.newStart, + newLines, + lines, + }; +} + +/** + * Convert a single AdvancedDiffResult to a wire-safe DiffPreview. + * For multi-file patch tools, call this once per file operation. + */ +export function toDiffPreview( + result: AdvancedDiffResult, + fileNameOverride?: string, +): DiffPreview { + switch (result.mode) { + case "advanced": + return { + mode: "advanced", + fileName: fileNameOverride ?? result.fileName, + hunks: result.hunks.map(convertHunk), + }; + case "fallback": + return { + mode: "fallback", + fileName: fileNameOverride ?? "unknown", + reason: result.reason, + }; + case "unpreviewable": + return { + mode: "unpreviewable", + fileName: fileNameOverride ?? "unknown", + reason: result.reason, + }; + } +} + +type DiffDeps = { + computeAdvancedDiff: typeof import("../cli/helpers/diff").computeAdvancedDiff; + parsePatchToAdvancedDiff: typeof import("../cli/helpers/diff").parsePatchToAdvancedDiff; + isFileWriteTool: typeof import("../cli/helpers/toolNameMapping").isFileWriteTool; + isFileEditTool: typeof import("../cli/helpers/toolNameMapping").isFileEditTool; + isPatchTool: typeof import("../cli/helpers/toolNameMapping").isPatchTool; + parsePatchOperations: typeof import("../cli/helpers/formatArgsDisplay").parsePatchOperations; +}; + +let cachedDiffDeps: DiffDeps | null = null; + +async function getDiffDeps(): Promise { + if (cachedDiffDeps) return cachedDiffDeps; + const [diffMod, toolNameMod, formatMod] = await Promise.all([ + import("../cli/helpers/diff"), + import("../cli/helpers/toolNameMapping"), + import("../cli/helpers/formatArgsDisplay"), + ]); + cachedDiffDeps = { + computeAdvancedDiff: diffMod.computeAdvancedDiff, + parsePatchToAdvancedDiff: diffMod.parsePatchToAdvancedDiff, + isFileWriteTool: toolNameMod.isFileWriteTool, + isFileEditTool: toolNameMod.isFileEditTool, + isPatchTool: toolNameMod.isPatchTool, + parsePatchOperations: formatMod.parsePatchOperations, + }; + return cachedDiffDeps; +} + +/** + * Compute diff previews for a tool call. Returns an array of DiffPreview + * (one per file for patch tools, one for Write/Edit tools). + * + * Mirrors the diff computation logic in App.tsx:4372-4438. + */ +export async function computeDiffPreviews( + toolName: string, + toolArgs: Record, +): Promise { + const { + computeAdvancedDiff, + parsePatchToAdvancedDiff, + isFileWriteTool, + isFileEditTool, + isPatchTool, + parsePatchOperations, + } = await getDiffDeps(); + const previews: DiffPreview[] = []; + + try { + if (isFileWriteTool(toolName)) { + const filePath = toolArgs.file_path as string | undefined; + if (filePath) { + const result = computeAdvancedDiff({ + kind: "write", + filePath, + content: (toolArgs.content as string) || "", + }); + previews.push(toDiffPreview(result, basename(filePath))); + } + } else if (isFileEditTool(toolName)) { + const filePath = toolArgs.file_path as string | undefined; + if (filePath) { + if (toolArgs.edits && Array.isArray(toolArgs.edits)) { + const result = computeAdvancedDiff({ + kind: "multi_edit", + filePath, + edits: toolArgs.edits as Array<{ + old_string: string; + new_string: string; + replace_all?: boolean; + }>, + }); + previews.push(toDiffPreview(result, basename(filePath))); + } else { + const result = computeAdvancedDiff({ + kind: "edit", + filePath, + oldString: (toolArgs.old_string as string) || "", + newString: (toolArgs.new_string as string) || "", + replaceAll: toolArgs.replace_all as boolean | undefined, + }); + previews.push(toDiffPreview(result, basename(filePath))); + } + } + } else if (isPatchTool(toolName) && toolArgs.input) { + const operations = parsePatchOperations(toolArgs.input as string); + for (const op of operations) { + if (op.kind === "add" || op.kind === "update") { + const result = parsePatchToAdvancedDiff(op.patchLines, op.path); + if (result) { + previews.push(toDiffPreview(result, basename(op.path))); + } + } + // Delete operations don't produce diffs + } + } + } catch { + // Ignore diff computation errors — return whatever we have so far + } + + return previews; +} diff --git a/src/tests/helpers/diffPreview.test.ts b/src/tests/helpers/diffPreview.test.ts new file mode 100644 index 0000000..64ae5c2 --- /dev/null +++ b/src/tests/helpers/diffPreview.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "bun:test"; +import type { + AdvancedDiffFallback, + AdvancedDiffSuccess, + AdvancedDiffUnpreviewable, +} from "../../cli/helpers/diff"; +import { computeDiffPreviews, toDiffPreview } from "../../helpers/diffPreview"; + +describe("toDiffPreview", () => { + it("converts an AdvancedDiffSuccess to an advanced DiffPreview", () => { + const input: AdvancedDiffSuccess = { + mode: "advanced", + fileName: "foo.ts", + oldStr: "const a = 1;\n", + newStr: "const a = 2;\n", + hunks: [ + { + oldStart: 1, + newStart: 1, + lines: [{ raw: "-const a = 1;" }, { raw: "+const a = 2;" }], + }, + ], + }; + + const result = toDiffPreview(input); + + expect(result.mode).toBe("advanced"); + if (result.mode !== "advanced") throw new Error("unreachable"); + + expect(result.fileName).toBe("foo.ts"); + // oldStr/newStr must NOT appear on the wire type + expect("oldStr" in result).toBe(false); + expect("newStr" in result).toBe(false); + + expect(result.hunks).toHaveLength(1); + const hunk = result.hunks[0]; + expect(hunk?.oldStart).toBe(1); + expect(hunk?.newStart).toBe(1); + expect(hunk?.oldLines).toBe(1); // one remove + expect(hunk?.newLines).toBe(1); // one add + expect(hunk?.lines).toEqual([ + { type: "remove", content: "const a = 1;" }, + { type: "add", content: "const a = 2;" }, + ]); + }); + + it("computes oldLines/newLines correctly for mixed hunks", () => { + const input: AdvancedDiffSuccess = { + mode: "advanced", + fileName: "test.ts", + oldStr: "", + newStr: "", + hunks: [ + { + oldStart: 5, + newStart: 5, + lines: [ + { raw: " context line" }, + { raw: "-removed line 1" }, + { raw: "-removed line 2" }, + { raw: "+added line" }, + { raw: " more context" }, + ], + }, + ], + }; + + const result = toDiffPreview(input); + if (result.mode !== "advanced") throw new Error("unreachable"); + + const hunk = result.hunks[0]; + // context: 2 (contributes to both), remove: 2, add: 1 + expect(hunk?.oldLines).toBe(4); // 2 context + 2 remove + expect(hunk?.newLines).toBe(3); // 2 context + 1 add + }); + + it("parses empty raw lines as context", () => { + const input: AdvancedDiffSuccess = { + mode: "advanced", + fileName: "empty.ts", + oldStr: "", + newStr: "", + hunks: [ + { + oldStart: 1, + newStart: 1, + lines: [{ raw: "" }], + }, + ], + }; + + const result = toDiffPreview(input); + if (result.mode !== "advanced") throw new Error("unreachable"); + + expect(result.hunks[0]?.lines[0]).toEqual({ + type: "context", + content: "", + }); + }); + + it("converts fallback results with fileName", () => { + const input: AdvancedDiffFallback = { + mode: "fallback", + reason: "File not readable", + }; + + const result = toDiffPreview(input, "myfile.ts"); + expect(result).toEqual({ + mode: "fallback", + fileName: "myfile.ts", + reason: "File not readable", + }); + }); + + it("converts unpreviewable results with fileName", () => { + const input: AdvancedDiffUnpreviewable = { + mode: "unpreviewable", + reason: "Edit not found in file", + }; + + const result = toDiffPreview(input, "target.ts"); + expect(result).toEqual({ + mode: "unpreviewable", + fileName: "target.ts", + reason: "Edit not found in file", + }); + }); + + it("uses 'unknown' fileName when no override provided for fallback/unpreviewable", () => { + const fallback: AdvancedDiffFallback = { + mode: "fallback", + reason: "File not readable", + }; + expect(toDiffPreview(fallback).fileName).toBe("unknown"); + + const unpreviewable: AdvancedDiffUnpreviewable = { + mode: "unpreviewable", + reason: "reason", + }; + expect(toDiffPreview(unpreviewable).fileName).toBe("unknown"); + }); + + it("allows fileName override on advanced results", () => { + const input: AdvancedDiffSuccess = { + mode: "advanced", + fileName: "original.ts", + oldStr: "", + newStr: "", + hunks: [], + }; + + const result = toDiffPreview(input, "overridden.ts"); + expect(result.fileName).toBe("overridden.ts"); + }); + + it("ignores no-newline metadata lines in advanced hunks", () => { + const input: AdvancedDiffSuccess = { + mode: "advanced", + fileName: "x.txt", + oldStr: "old-no-newline", + newStr: "new-no-newline", + hunks: [ + { + oldStart: 1, + newStart: 1, + lines: [ + { raw: "-old-no-newline" }, + { raw: "\\ No newline at end of file" }, + { raw: "+new-no-newline" }, + { raw: "\\ No newline at end of file" }, + ], + }, + ], + }; + + const result = toDiffPreview(input); + if (result.mode !== "advanced") throw new Error("unreachable"); + + const hunk = result.hunks[0]; + expect(hunk?.oldLines).toBe(1); + expect(hunk?.newLines).toBe(1); + expect(hunk?.lines).toEqual([ + { type: "remove", content: "old-no-newline" }, + { type: "add", content: "new-no-newline" }, + ]); + }); +}); + +describe("computeDiffPreviews", () => { + it("returns one preview for write tool", async () => { + const previews = await computeDiffPreviews("write", { + file_path: "sample.txt", + content: "hello", + }); + expect(previews).toHaveLength(1); + expect(previews[0]?.mode).toBe("advanced"); + expect(previews[0]?.fileName).toBe("sample.txt"); + }); + + it("returns one preview per file for apply_patch", async () => { + const patch = [ + "*** Begin Patch", + "*** Update File: a.txt", + "@@ -1 +1 @@", + "-old", + "+new", + "*** Add File: b.txt", + "+hello", + "*** End Patch", + ].join("\n"); + + const previews = await computeDiffPreviews("apply_patch", { input: patch }); + expect(previews).toHaveLength(2); + expect(previews.map((p) => p.fileName).sort()).toEqual(["a.txt", "b.txt"]); + }); +}); diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 4163553..29930a0 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -369,6 +369,26 @@ export interface ExternalToolDefinition { parameters: Record; // JSON Schema } +// --- Diff preview types (wire-safe, no CLI imports) --- + +export interface DiffHunkLine { + type: "context" | "add" | "remove"; + content: string; +} + +export interface DiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: DiffHunkLine[]; +} + +export type DiffPreview = + | { mode: "advanced"; fileName: string; hunks: DiffHunk[] } + | { mode: "fallback"; fileName: string; reason: string } + | { mode: "unpreviewable"; fileName: string; reason: string }; + // CLI → SDK request subtypes export interface CanUseToolControlRequest { subtype: "can_use_tool"; @@ -379,6 +399,8 @@ export interface CanUseToolControlRequest { permission_suggestions: unknown[]; /** TODO: Not implemented - path that triggered the permission check */ blocked_path: string | null; + /** Pre-computed diff previews for file-modifying tools (Write/Edit/Patch) */ + diffs?: DiffPreview[]; } /**