e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
231 lines
7.9 KiB
TypeScript
231 lines
7.9 KiB
TypeScript
"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 (
|
|
<Collapsible open={open} onOpenChange={setOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="group text-muted-foreground hover:text-foreground flex w-full items-center gap-2 py-0.5 text-sm transition-colors"
|
|
>
|
|
<ToolStatusIcon
|
|
status={status}
|
|
completeIcon={<CheckIcon className="size-3 shrink-0" />}
|
|
/>
|
|
|
|
<span
|
|
className={cn(
|
|
"flex items-center gap-1.5 truncate",
|
|
isCancelled && "line-through opacity-50",
|
|
)}
|
|
>
|
|
<span className="font-medium">{toolName}</span>
|
|
{patchInfo.files.length > 0 && (
|
|
<span className="opacity-60">
|
|
{patchInfo.files.length === 1
|
|
? patchInfo.files[0]
|
|
: `${patchInfo.files.length} files`}
|
|
</span>
|
|
)}
|
|
{(patchInfo.added > 0 || patchInfo.removed > 0) && !isRunning && (
|
|
<span className="ml-0.5 flex items-center gap-1 font-mono text-xs">
|
|
{patchInfo.added > 0 && (
|
|
<span className="text-green-500">+{patchInfo.added}</span>
|
|
)}
|
|
{patchInfo.removed > 0 && (
|
|
<span className="text-red-500">-{patchInfo.removed}</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
|
|
{hasDiff && (
|
|
<>
|
|
<span className="border-muted-foreground/20 group-hover:border-muted-foreground/50 mt-0.5 min-w-4 flex-1 self-center border-b-[0.5px] transition-colors" />
|
|
<ChevronRightIcon
|
|
className={cn(
|
|
"stroke-muted-foreground/60 group-hover:stroke-foreground/60 mt-0.5 size-3.75 shrink-0 transition-[transform,stroke]",
|
|
open && "rotate-90",
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
|
|
{hasDiff && (
|
|
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden data-[state=closed]:ease-out data-[state=open]:ease-in">
|
|
<div className="mt-1 ml-5 overflow-hidden rounded-md border">
|
|
<PatchDiff
|
|
patch={unifiedPatch}
|
|
options={{
|
|
theme: { dark: "pierre-dark", light: "pierre-light" },
|
|
diffStyle: "unified",
|
|
overflow: "wrap",
|
|
}}
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
)}
|
|
</Collapsible>
|
|
);
|
|
},
|
|
);
|
|
ApplyPatchDiff.displayName = "ApplyPatchDiff";
|