From 2ce9c6df69fb3082b3f3b0436f75e8de00b5a958 Mon Sep 17 00:00:00 2001 From: draxxris <24984408+draxxris@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:40:25 -0400 Subject: [PATCH] feat: render apply_patch tool calls as split diffs apply_patch (pi-apply-patch extension) calls showed a raw JSON dump. Parse its V4A patch document and applied-result preview into the shared split diff model so it renders like the edit tool: file paths in the header and a side-by-side diff when expanded. - add lib/apply-patch.ts with parsers for both data formats - prefer details.preview (real line numbers, rename targets), fall back to parsing the call input for errors and streaming - count apply_patch in turn-written-files via the same pipeline --- components/MessageView.tsx | 69 ++++++++++-- lib/apply-patch.test.mjs | 121 ++++++++++++++++++++ lib/apply-patch.ts | 225 +++++++++++++++++++++++++++++++++++++ lib/tool-names.ts | 9 ++ lib/turn-written-files.ts | 59 +++++++--- 5 files changed, 460 insertions(+), 23 deletions(-) create mode 100644 lib/apply-patch.test.mjs create mode 100644 lib/apply-patch.ts diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 354c67097..7fc167bfc 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -9,8 +9,9 @@ import { copyText } from "@/lib/clipboard"; import { useI18n } from "@/hooks/useI18n"; import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, getThinkingPreview, isEmptyThinkingBlock } from "@/lib/message-display"; -import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; -import { isEditToolName } from "@/lib/tool-names"; +import { parseUnifiedPatch, type SplitDiffCell, type SplitDiffFile } from "@/lib/patch"; +import { applyPatchPreviewToFiles, extractApplyPatchPaths, getApplyPatchInputText, parseApplyPatchInput } from "@/lib/apply-patch"; +import { isApplyPatchToolName, isEditToolName } from "@/lib/tool-names"; import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference"; import { TurnWrittenFiles } from "./TurnWrittenFiles"; import type { WrittenFile } from "@/lib/turn-written-files"; @@ -1008,6 +1009,10 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool const isStreamingInput = block.rawInput !== undefined; const isEditTool = isEditToolName(block.toolName); const resultDiff = result && !result.isError ? getResultDiff(result) : null; + const patchFiles = getApplyPatchFiles(block, result); + const patchLabel = isApplyPatchToolName(block.toolName) + ? summarizeApplyPatchInput(block) + : null; // Result display const resultText = result @@ -1051,7 +1056,7 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool {block.toolName} - {isStreamingInput ? t("chat.generatingToolInput") : getToolPreview(block)} + {isStreamingInput ? t("chat.generatingToolInput") : (patchLabel ?? getToolPreview(block))} {duration !== undefined && ( {duration}s @@ -1073,8 +1078,8 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool )} - {/* ── Expanded: input args ── */} - {expanded && (isStreamingInput || !isEditTool) && ( + {/* ── Expanded: input args (only when no richer view exists) ── */} + {expanded && !isEditTool && !patchFiles && (
       )}
 
+      {/* ── Expanded: applied-patch split diff ── */}
+      {expanded && patchFiles && (
+        
+ +
+ )} + {/* ── Paired result — only shown when expanded ── */} - {expanded && result && ( + {expanded && result && patchFiles && isError && ( + + )} + {expanded && result && !patchFiles && ( resultDiff ? ( parseUnifiedPatch(text), [text]); if (!files) return ; + return ; +} + +function SplitFilesView({ files }: { files: SplitDiffFile[] }) { + const { t } = useI18n(); const showFileHeaders = files.length > 1; return ( @@ -1331,6 +1355,37 @@ function PatchTextView({ text }: { text: string }) { ); } +/** + * Split diff rows for an apply_patch-style tool call. + * + * Prefers parsing the V4A patch document from the call input. The extension's + * applied result preview contains the complete old/new file with unchanged + * lines, so it is only used as a fallback when the call input is unavailable. + * A single call may contain several file operations — each becomes its own + * file section. + */ +function getApplyPatchFiles(block: ToolCallContent, result?: ToolResultMessage): SplitDiffFile[] | null { + if (!isApplyPatchToolName(block.toolName)) return null; + + const fromInput = parseApplyPatchInput(getApplyPatchInputText(block.input, block.rawInput)); + if (fromInput) return fromInput; + + const details = result && !result.isError ? (result as ToolResultMessage & { details?: unknown }).details : undefined; + if (isRecord(details)) { + const fromPreview = applyPatchPreviewToFiles(details.preview); + if (fromPreview) return fromPreview; + } + + return null; +} + +/** Header label listing the files targeted by an apply_patch call. */ +function summarizeApplyPatchInput(block: ToolCallContent): string | null { + const paths = extractApplyPatchPaths(getApplyPatchInputText(block.input, block.rawInput)); + if (paths.length === 0) return null; + return paths.join(", ").slice(0, 120); +} + function getResultDiff(result: ToolResultMessage): ResultDiff | null { const details = (result as ToolResultMessage & { details?: unknown }).details; if (!isRecord(details)) return null; diff --git a/lib/apply-patch.test.mjs b/lib/apply-patch.test.mjs new file mode 100644 index 000000000..fd6330e35 --- /dev/null +++ b/lib/apply-patch.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { + applyPatchPreviewToFiles, + extractApplyPatchPaths, + getApplyPatchInputText, + parseApplyPatchInput, +} = await jiti.import("./apply-patch.ts"); + +test("extractApplyPatchPaths lists every file operation in order, deduped", () => { + const patch = [ + "*** Begin Patch", + "*** Add File: a.ts", + "+x", + "*** Update File: b.ts", + "-y", + "+z", + "*** Delete File: a.ts", + "*** End Patch", + ].join("\n"); + assert.deepEqual(extractApplyPatchPaths(patch), ["a.ts", "b.ts"]); +}); + +test("getApplyPatchInputText prefers the structured input field", () => { + assert.equal(getApplyPatchInputText({ input: "patch" }, '{"input": "raw'), "patch"); + assert.equal(getApplyPatchInputText(undefined, "raw"), "raw"); + assert.equal(getApplyPatchInputText(null), ""); +}); + +test("parseApplyPatchInput handles add, delete, and update with move in one call", () => { + const patch = [ + "*** Begin Patch", + "*** Add File: a.ts", + "+hello", + "*** Delete File: b.ts", + "-old one", + "*** Update File: c.ts", + "*** Move to: d.ts", + "@@ marker text is ignored", + " ctx", + "-x", + "+y", + "*** End Patch", + ].join("\n"); + + const files = parseApplyPatchInput(patch); + assert.equal(files.length, 3); + + const [add, del, update] = files; + assert.deepEqual(add, { + oldPath: undefined, + newPath: "a.ts", + rows: [{ type: "line", left: { lineNo: null, text: "", type: "empty" }, right: { lineNo: null, text: "hello", type: "added" } }], + }); + assert.equal(del.oldPath, "b.ts"); + assert.equal(del.newPath, undefined); + assert.equal(update.oldPath, "c.ts"); + assert.equal(update.newPath, "d.ts"); + assert.deepEqual( + update.rows.map((row) => [row.left.type, row.right.type]), + [["context", "context"], ["removed", "added"]], + ); +}); + +test("parseApplyPatchInput tolerates truncated streaming input and rejects non-patches", () => { + const partial = "*** Begin Patch\n*** Update File: e.ts\n@@\n-a\n+b\n*** Update File: f.ts\n@@\n-"; + const files = parseApplyPatchInput(partial); + assert.equal(files.length, 2); + assert.equal(files[0].oldPath, "e.ts"); + + assert.equal(parseApplyPatchInput(""), null); + assert.equal(parseApplyPatchInput('{"input": "not a patch'), null); +}); + +test("applyPatchPreviewToFiles converts the extension result preview with real line numbers", () => { + const preview = { + added: 1, + removed: 1, + files: [ + { + filePath: "src/app.ts", + operation: "update", + diff: ' 1 keep\n- 10 old line\n+ 10 new line\n 11 tail', + }, + { filePath: "new.ts", operation: "add", diff: "+ 1 first" }, + { filePath: "gone.ts", operation: "delete", diff: "- 1 bye" }, + ], + }; + + const files = applyPatchPreviewToFiles(preview); + assert.equal(files.length, 3); + + const [update, add, del] = files; + assert.equal(update.oldPath, "src/app.ts"); + assert.deepEqual( + update.rows.map((row) => [row.left.lineNo, row.left.type, row.right.type, row.right.lineNo]), + [[1, "context", "context", 1], [10, "removed", "added", 10], [11, "context", "context", 11]], + ); + assert.equal(update.rows[1].left.text, "old line"); + assert.equal(update.rows[1].right.text, "new line"); + + assert.equal(add.oldPath, undefined); + assert.equal(add.newPath, "new.ts"); + + assert.equal(del.oldPath, "gone.ts"); + assert.equal(del.newPath, undefined); +}); + +test("applyPatchPreviewToFiles keeps move targets and rejects malformed previews", () => { + const files = applyPatchPreviewToFiles({ + files: [{ filePath: "old.ts", movePath: "renamed.ts", operation: "update", diff: " 1 same" }], + }); + assert.equal(files[0].newPath, "renamed.ts"); + + assert.equal(applyPatchPreviewToFiles(null), null); + assert.equal(applyPatchPreviewToFiles({}), null); + assert.equal(applyPatchPreviewToFiles({ files: ["nope"] }), null); +}); diff --git a/lib/apply-patch.ts b/lib/apply-patch.ts new file mode 100644 index 000000000..b4bec5c09 --- /dev/null +++ b/lib/apply-patch.ts @@ -0,0 +1,225 @@ +/** + * Rendering support for Codex-style `apply_patch` tools (e.g. the + * pi-apply-patch extension). + * + * Two data sources describe what such a call changed, and neither is a + * standard unified diff: + * + * 1. The tool call input — a freeform V4A patch document that may contain + * several file operations in one call: + * + * *** Begin Patch + * *** Add File: new.ts + * +line + * *** Update File: old.ts + * *** Move to: renamed.ts + * @@ optional context marker + * context + * -removed + * +added + * *** Delete File: gone.ts + * *** End Patch + * + * 2. The tool result `details.preview` — per-file applied diffs whose lines + * embed their line number after the +/-/space marker + * (`+12 text`, `-3 text`, ` 7 text`), produced by the extension. + * + * Both convert into the shared `SplitDiffFile[]` model from ./patch so they + * render through the same split diff view as the built-in edit tool. + */ + +import type { SplitDiffCell, SplitDiffFile, SplitDiffRow } from "./patch"; + +export interface ApplyPatchPreviewFile { + filePath?: string; + movePath?: string; + operation?: string; + diff?: string; +} + +/** Extract the file paths targeted by a V4A patch document, in order. */ +export function extractApplyPatchPaths(patchText: string): string[] { + const paths: string[] = []; + for (const match of patchText.matchAll(/^\*\*\* (?:Add|Delete|Update) File: (.+)$/gm)) { + const filePath = (match[1] ?? "").trim(); + if (filePath && !paths.includes(filePath)) paths.push(filePath); + } + return paths; +} + +/** Pull the patch document out of an apply_patch tool call's input. */ +export function getApplyPatchInputText(input: unknown, rawInput?: string): string { + if (input && typeof input === "object" && !Array.isArray(input)) { + const value = (input as Record).input; + if (typeof value === "string" && value.length > 0) return value; + } + return typeof rawInput === "string" ? rawInput : ""; +} + +// ── Shared row building ────────────────────────────────────────────────────── + +interface RowSink { + rows: SplitDiffRow[]; + context(text: string, lineNo: number | null): void; + removed(text: string, lineNo: number | null): void; + added(text: string, lineNo: number | null): void; + finish(): void; +} + +function createRowSink(): RowSink { + const rows: SplitDiffRow[] = []; + let pendingRemoved: SplitDiffCell[] = []; + let pendingAdded: SplitDiffCell[] = []; + + const emptyCell = (): SplitDiffCell => ({ lineNo: null, text: "", type: "empty" }); + + const flushChanges = () => { + const count = Math.max(pendingRemoved.length, pendingAdded.length); + for (let i = 0; i < count; i++) { + rows.push({ + type: "line", + left: pendingRemoved[i] ?? emptyCell(), + right: pendingAdded[i] ?? emptyCell(), + }); + } + pendingRemoved = []; + pendingAdded = []; + }; + + return { + rows, + context(text, lineNo) { + flushChanges(); + rows.push({ + type: "line", + left: { lineNo, text, type: "context" }, + right: { lineNo, text, type: "context" }, + }); + }, + removed(text, lineNo) { + pendingRemoved.push({ lineNo, text, type: "removed" }); + }, + added(text, lineNo) { + pendingAdded.push({ lineNo, text, type: "added" }); + }, + finish() { + flushChanges(); + // Drop files whose body produced no renderable line (e.g. an empty + // Add section while streaming) — mutate in place, callers already hold + // a reference to this array. + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i].type !== "line") rows.splice(i, 1); + } + }, + }; +} + +// ── Source 1: V4A patch document (tool call input) ─────────────────────────── + +/** + * Parse a V4A patch document into split diff files. Tolerant of truncated + * input (streaming) — complete operations parsed so far are returned. + */ +export function parseApplyPatchInput(patchText: string): SplitDiffFile[] | null { + if (!patchText.includes("*** Begin Patch") && !/\*\*\* (?:Add|Delete|Update) File: /.test(patchText)) { + return null; + } + + const files: SplitDiffFile[] = []; + let sink: RowSink | null = null; + let current: SplitDiffFile | null = null; + // "add" / "delete" bodies carry bare content lines; "update" bodies carry + // prefixed ones. Tracked so unprefixed lines land on the correct side. + let operation: "add" | "delete" | "update" | null = null; + + for (const rawLine of patchText.split(/\r?\n/)) { + const header = rawLine.match(/^\*\*\* (Add|Delete|Update) File: (.+)$/); + if (header) { + const op = (header[1]?.toLowerCase() ?? "update") as "add" | "delete" | "update"; + const filePath = (header[2] ?? "").trim(); + sink?.finish(); + operation = op; + sink = createRowSink(); + current = { + oldPath: op === "add" ? undefined : filePath, + newPath: op === "delete" ? undefined : filePath, + rows: sink.rows, + }; + files.push(current); + continue; + } + + if (/^\*\*\* Move to: /.test(rawLine)) { + const movePath = rawLine.replace(/^\*\*\* Move to: /, "").trim(); + if (current && operation === "update") current.newPath = movePath; + continue; + } + + if (!sink || !current) continue; + const body: RowSink = sink; + if (rawLine.startsWith("*** ")) continue; // Begin/End Patch markers + if (operation === "update" && rawLine.startsWith("@@")) continue; // hunk context markers carry no line numbers here + + if (operation === "update") { + const prefix = rawLine[0]; + const content = rawLine.slice(1); + if (prefix === "+") body.added(content, null); + else if (prefix === "-") body.removed(content, null); + else if (prefix === " ") body.context(content, null); + else if (rawLine !== "") body.context(rawLine, null); // defensive: unprefixed context + } else if (operation === "add") { + if (rawLine === "") continue; + sink.added(rawLine.startsWith("+") ? rawLine.slice(1) : rawLine, null); + } else if (operation === "delete") { + if (rawLine === "") continue; + sink.removed(rawLine.startsWith("-") ? rawLine.slice(1) : rawLine, null); + } + } + sink?.finish(); + + const parsed = files.filter((file) => file.rows.length > 0); + return parsed.length > 0 ? parsed : null; +} + +// ── Source 2: applied result preview (details.preview) ─────────────────────── + +/** + * Convert the extension's applied-result preview into split diff files. + * Its per-file `diff` lines look like `+12 text` / `-3 text` / `␣7 text` + * with real line numbers, so those are preserved. + */ +export function applyPatchPreviewToFiles(preview: unknown): SplitDiffFile[] | null { + if (!preview || typeof preview !== "object" || Array.isArray(preview)) return null; + const rawFiles = (preview as Record).files; + if (!Array.isArray(rawFiles)) return null; + + const files: SplitDiffFile[] = []; + for (const rawFile of rawFiles) { + if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile)) continue; + const entry = rawFile as ApplyPatchPreviewFile; + if (typeof entry.filePath !== "string" || typeof entry.diff !== "string") continue; + + const sink = createRowSink(); + for (const line of entry.diff.split(/\r?\n/)) { + const match = line.match(/^([+\- ])\s*(\d+) (.*)$/); + if (!match) continue; + const [, marker, num, text] = match; + const lineNo = Number(num); + if (marker === "+") sink.added(text, lineNo); + else if (marker === "-") sink.removed(text, lineNo); + else sink.context(text, lineNo); + } + sink.finish(); + + const isAdd = entry.operation === "add"; + const isDelete = entry.operation === "delete"; + files.push({ + oldPath: isAdd ? undefined : entry.filePath, + newPath: isDelete ? undefined : (entry.movePath ?? entry.filePath), + rows: sink.rows, + }); + } + + const parsed = files.filter((file) => file.rows.length > 0); + return parsed.length > 0 ? parsed : null; +} diff --git a/lib/tool-names.ts b/lib/tool-names.ts index bd7c9be2e..92d8480a1 100644 --- a/lib/tool-names.ts +++ b/lib/tool-names.ts @@ -23,3 +23,12 @@ export function isEditToolName(toolName: string): boolean { name.includes("str_replace") || name.includes("replace_editor"); } + +/** Codex-style patch tools (e.g. the pi-apply-patch extension). */ +export function isApplyPatchToolName(toolName: string): boolean { + const name = toolName.toLowerCase(); + return name === "apply_patch" || + name.startsWith("apply_patch_") || + name.endsWith(".apply_patch") || + name.endsWith("_apply_patch"); +} diff --git a/lib/turn-written-files.ts b/lib/turn-written-files.ts index 2fded76db..cf82c8b3e 100644 --- a/lib/turn-written-files.ts +++ b/lib/turn-written-files.ts @@ -1,6 +1,7 @@ import type { AssistantContentBlock, ToolResultMessage } from "./types"; import { resolveLocalFilePath } from "./file-links"; -import { isEditToolName, isWriteToolName } from "./tool-names"; +import { isApplyPatchToolName, isEditToolName, isWriteToolName } from "./tool-names"; +import { applyPatchPreviewToFiles, extractApplyPatchPaths, getApplyPatchInputText } from "./apply-patch"; export interface WrittenFile { /** Resolved absolute path of a file this turn wrote. */ @@ -17,13 +18,35 @@ function readToolPath(input: Record | undefined): string | null return typeof value === "string" && value.length > 0 ? value : null; } +/** + * Collect the paths targeted by one apply_patch call. + * + * Prefers the applied-result preview — it reflects what actually landed on + * disk, including rename targets. Falls back to parsing the patch document + * from the call input. A single call may contain several file operations. + */ +function readApplyPatchPaths(input: Record | undefined, result: ToolResultMessage | undefined): string[] { + const details = (result as (ToolResultMessage & { details?: unknown }) | undefined)?.details; + if (details && typeof details === "object" && !Array.isArray(details)) { + const files = applyPatchPreviewToFiles((details as Record).preview); + if (files) { + const paths = files + .map((file) => file.newPath ?? file.oldPath) + .filter((path): path is string => typeof path === "string"); + if (paths.length > 0) return paths; + } + } + return extractApplyPatchPaths(getApplyPatchInputText(input)); +} + /** * Collect the distinct files a single assistant turn actually wrote. * - * Every entry is derived from a `write`/`edit` tool call whose result arrived - * and did not error — never from the reply text. A path the assistant merely - * mentions in prose is not evidence that any file was touched, so it is not a - * source here; the tool call is the record of what happened. + * Every entry is derived from a `write`/`edit`/`apply_patch` tool call whose + * result arrived and did not error — never from the reply text. A path the + * assistant merely mentions in prose is not evidence that any file was + * touched, so it is not a source here; the tool call is the record of what + * happened. * * Paths are resolved against `cwd`, deduped, and kept in first-seen order. */ @@ -37,23 +60,27 @@ export function extractTurnWrittenFiles( for (const block of content) { if (block.type !== "toolCall") continue; - if (!isFileWritingToolName(block.toolName)) continue; + if (!isFileWritingToolName(block.toolName) && !isApplyPatchToolName(block.toolName)) continue; - // No result yet (still streaming) or the call failed — nothing was written. const result = toolResults?.get(block.toolCallId); if (!result || result.isError) continue; - const rawPath = readToolPath(block.input); - if (!rawPath) continue; + const rawPaths = isApplyPatchToolName(block.toolName) + ? readApplyPatchPaths(block.input, result) + : [readToolPath(block.input)]; + + for (const rawPath of rawPaths) { + if (!rawPath) continue; - // Tool arguments are filesystem paths, not hrefs: preserve characters such - // as #, ?, and :digits that have special meaning in links and source refs. - const filePath = resolveLocalFilePath(rawPath, cwd); - if (!filePath) continue; + // Tool arguments are filesystem paths, not hrefs: preserve characters such + // as #, ?, and :digits that have special meaning in links and source refs. + const filePath = resolveLocalFilePath(rawPath, cwd); + if (!filePath) continue; - if (seen.has(filePath)) continue; - seen.add(filePath); - writtenFiles.push({ filePath }); + if (seen.has(filePath)) continue; + seen.add(filePath); + writtenFiles.push({ filePath }); + } } return writtenFiles;