"use client"; import { memo, useMemo, useState } from "react"; import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; import { CheckIcon, ChevronRightIcon } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/radix/collapsible"; import { cn } from "@/lib/utils"; import { PatchDiff } from "@pierre/diffs/react"; import { ToolStatusIcon, getPatchInfo, isCancelledToolStatus, str, } from "@/components/tools/tool-ui-shared"; // ── Patch format conversion ────────────────────────────────────────────── // Claude Code's apply_patch uses a custom "v2" format: // *** Begin Patch // *** Update File: path/to/file.ts // @@ context search text @@ // context line // -removed // +added // *** End Patch // // The @@ markers are context-search strings, NOT standard unified diff // hunk headers. We convert to proper unified diff for @pierre/diffs. interface PatchBlock { type: string; path: string; body: string; } function parseClaudePatchBlocks(patchText: string): PatchBlock[] { // Strip wrapper lines const cleaned = patchText .replace(/^\*\*\*\s*Begin Patch\s*$/gm, "") .replace(/^\*\*\*\s*End Patch\s*$/gm, "") .trim(); const headerRegex = /^\*\*\*\s+(Update|Add|Delete)\s+File:\s+(.+)$/gm; const blocks: PatchBlock[] = []; let lastIndex = 0; for (const match of cleaned.matchAll(headerRegex)) { if (blocks.length > 0) { blocks[blocks.length - 1]!.body = cleaned.slice(lastIndex, match.index); } blocks.push({ type: match[1]!, path: match[2]!.trim(), body: "" }); lastIndex = match.index + match[0].length; } if (blocks.length > 0) { blocks[blocks.length - 1]!.body = cleaned.slice(lastIndex); } return blocks; } function buildHunkHeader( lines: string[], oldStart: number, newStart: number, ): { header: string; oldCount: number; newCount: number } { let oldCount = 0; let newCount = 0; for (const line of lines) { if (line.startsWith("+")) newCount++; else if (line.startsWith("-")) oldCount++; else { // context line (space prefix or empty) oldCount++; newCount++; } } return { header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, oldCount, newCount, }; } function claudeBlockToUnifiedDiff(block: PatchBlock): string { const { type, path, body } = block; const bodyLines = body .split("\n") .filter((l) => l.trim() !== "" || l.startsWith(" ")); // Filter out empty trailing lines but keep intentional blank context lines const diffLines: string[] = []; for (const line of bodyLines) { // Skip Claude's @@ context search @@ markers (e.g. "@@ function foo() { @@") // These are NOT standard unified diff hunk headers (@@ -N,M +N,M @@) if (/^@@.*@@\s*$/.test(line)) continue; // Keep actual diff lines: +, -, or space-prefixed context if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) { diffLines.push(line); } } let gitHeader: string; let fileHeader: string; switch (type) { case "Add": gitHeader = `diff --git a/${path} b/${path}\nnew file mode 100644`; fileHeader = `--- /dev/null\n+++ b/${path}`; break; case "Delete": gitHeader = `diff --git a/${path} b/${path}\ndeleted file mode 100644`; fileHeader = `--- a/${path}\n+++ /dev/null`; break; default: gitHeader = `diff --git a/${path} b/${path}`; fileHeader = `--- a/${path}\n+++ b/${path}`; } if (diffLines.length === 0) return `${gitHeader}\n${fileHeader}`; // For Add files, all lines should be additions if (type === "Add") { const addLines = diffLines.map((l) => (l.startsWith("+") ? l : `+${l}`)); const count = addLines.length; return `${gitHeader}\n${fileHeader}\n@@ -0,0 +1,${count} @@\n${addLines.join("\n")}`; } // For Delete files, all lines should be deletions if (type === "Delete") { const delLines = diffLines.map((l) => (l.startsWith("-") ? l : `-${l}`)); const count = delLines.length; return `${gitHeader}\n${fileHeader}\n@@ -1,${count} +0,0 @@\n${delLines.join("\n")}`; } // For Update files, synthesize proper hunk headers const { header: hunkHeader } = buildHunkHeader(diffLines, 1, 1); return `${gitHeader}\n${fileHeader}\n${hunkHeader}\n${diffLines.join("\n")}`; } // ── Component ──────────────────────────────────────────────────────────── export const ApplyPatchDiff: ToolCallMessagePartComponent = memo( ({ toolName, args, status }) => { const [open, setOpen] = useState(false); const patchText = str(args?.patchText); const patchInfo = useMemo(() => getPatchInfo(patchText), [patchText]); const unifiedPatch = useMemo(() => { if (!patchText) return ""; const blocks = parseClaudePatchBlocks(patchText); return blocks.map(claudeBlockToUnifiedDiff).join("\n"); }, [patchText]); const isRunning = status?.type === "running"; const isCancelled = isCancelledToolStatus(status); const hasDiff = unifiedPatch.length > 0 && !isRunning; return ( {hasDiff && (
)}
); }, ); ApplyPatchDiff.displayName = "ApplyPatchDiff";