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;