Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1051,7 +1056,7 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool
{block.toolName}
</span>
<span style={{ color: "var(--text-dim)", fontFamily: "var(--font-mono)", fontSize: 11, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }}>
{isStreamingInput ? t("chat.generatingToolInput") : getToolPreview(block)}
{isStreamingInput ? t("chat.generatingToolInput") : (patchLabel ?? getToolPreview(block))}
</span>
{duration !== undefined && (
<span style={{ fontSize: 11, color: "var(--text-dim)", flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>{duration}s</span>
Expand All @@ -1073,8 +1078,8 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool
)}
</div>

{/* ── Expanded: input args ── */}
{expanded && (isStreamingInput || !isEditTool) && (
{/* ── Expanded: input args (only when no richer view exists) ── */}
{expanded && !isEditTool && !patchFiles && (
<pre
style={{
margin: 0,
Expand All @@ -1093,8 +1098,23 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool
</pre>
)}

{/* ── Expanded: applied-patch split diff ── */}
{expanded && patchFiles && (
<div style={{ borderTop: "1px solid rgba(34,197,94,0.15)", background: "var(--bg)" }}>
<SplitFilesView files={patchFiles} />
</div>
)}

{/* ── Paired result — only shown when expanded ── */}
{expanded && result && (
{expanded && result && patchFiles && isError && (
<PairedResult
text={resultText ?? ""}
images={resultImages}
isEmpty={resultIsEmpty}
isError={isError}
/>
)}
{expanded && result && !patchFiles && (
resultDiff ? (
<PairedDiffResult
diff={resultDiff}
Expand Down Expand Up @@ -1132,9 +1152,13 @@ function PairedDiffResult({ diff }: {
}

function SplitPatchView({ text }: { text: string }) {
const { t } = useI18n();
const files = useMemo(() => parseUnifiedPatch(text), [text]);
if (!files) return <PatchTextView text={text} />;
return <SplitFilesView files={files} />;
}

function SplitFilesView({ files }: { files: SplitDiffFile[] }) {
const { t } = useI18n();
const showFileHeaders = files.length > 1;

return (
Expand Down Expand Up @@ -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;
Expand Down
121 changes: 121 additions & 0 deletions lib/apply-patch.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading