- {files.map(({ filePath }) => {
- const name = getFileName(filePath);
- return (
-
- );
- })}
+
+
+ {files.map(({ filePath, additions = 0, deletions = 0 }) => {
+ const name = getFileName(filePath);
+ const isExpanded = expandedPath === filePath;
+ return (
+
+
+
+
+ );
+ })}
+
+ {expanded && (
+ expanded.patch
+ ? (
+
+
+
+ )
+ : (
+
+ {t("chat.noTurnDiff")}
+
+ )
+ )}
);
}
diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts
index 008f0de57..b734acea3 100644
--- a/lib/i18n/messages/en.ts
+++ b/lib/i18n/messages/en.ts
@@ -315,6 +315,9 @@ export const enLocale: LocalePlugin = {
"chat.expandProcess": "Expand process details",
"chat.filesWritten": "Files changed",
"chat.openWrittenFile": "Open {name}",
+ "chat.showTurnDiff": "Show Diff for {name}",
+ "chat.hideTurnDiff": "Hide Diff for {name}",
+ "chat.noTurnDiff": "No Diff to show",
"chat.loadEarlier": "Scroll up to load earlier messages",
"chat.extensionRequest": "extension request",
"chat.extensionExpiresIn": "expires in {seconds}s",
diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts
index ef1e4ae84..a9321813e 100644
--- a/lib/i18n/messages/zh-CN.ts
+++ b/lib/i18n/messages/zh-CN.ts
@@ -315,6 +315,9 @@ export const zhCNLocale: LocalePlugin = {
"chat.expandProcess": "展开处理详情",
"chat.filesWritten": "改动的文件",
"chat.openWrittenFile": "打开 {name}",
+ "chat.showTurnDiff": "显示 {name} 的 Diff",
+ "chat.hideTurnDiff": "隐藏 {name} 的 Diff",
+ "chat.noTurnDiff": "没有可显示的 Diff",
"chat.loadEarlier": "向上滚动以加载更早的消息",
"chat.extensionRequest": "扩展请求",
"chat.extensionExpiresIn": "{seconds} 秒后过期",
diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts
index c45ef21b9..4b8a54916 100644
--- a/lib/i18n/messages/zh-TW.ts
+++ b/lib/i18n/messages/zh-TW.ts
@@ -315,6 +315,9 @@ export const zhTWLocale: LocalePlugin = {
"chat.expandProcess": "展開處理詳細資料",
"chat.filesWritten": "已變更的檔案",
"chat.openWrittenFile": "開啟 {name}",
+ "chat.showTurnDiff": "顯示 {name} 的 Diff",
+ "chat.hideTurnDiff": "隱藏 {name} 的 Diff",
+ "chat.noTurnDiff": "沒有可顯示的 Diff",
"chat.loadEarlier": "向上捲動以載入較早的訊息",
"chat.extensionRequest": "擴充功能請求",
"chat.extensionExpiresIn": "{seconds} 秒後過期",
diff --git a/lib/patch.ts b/lib/patch.ts
index b1488c4fc..226af476e 100644
--- a/lib/patch.ts
+++ b/lib/patch.ts
@@ -129,6 +129,31 @@ export function parseUnifiedPatch(text: string): SplitDiffFile[] | null {
return parsed.length > 0 ? parsed : null;
}
+/** Count +/− lines the same way SplitPatchView renders them. */
+export function countPatchLineStats(patch: string): { additions: number; deletions: number } {
+ const files = parseUnifiedPatch(patch);
+ if (files) {
+ let additions = 0;
+ let deletions = 0;
+ for (const file of files) {
+ for (const row of file.rows) {
+ if (row.type !== "line") continue;
+ if (row.right.type === "added") additions += 1;
+ if (row.left.type === "removed") deletions += 1;
+ }
+ }
+ return { additions, deletions };
+ }
+
+ let additions = 0;
+ let deletions = 0;
+ for (const line of patch.split(/\r?\n/)) {
+ if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
+ }
+ return { additions, deletions };
+}
+
function cleanPatchPath(path: string): string {
return path.split("\t")[0].trim();
}
diff --git a/lib/turn-written-files.test.mjs b/lib/turn-written-files.test.mjs
index ed8bf3e59..6139318ad 100644
--- a/lib/turn-written-files.test.mjs
+++ b/lib/turn-written-files.test.mjs
@@ -4,13 +4,14 @@ import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url, { tsconfigPaths: true });
const { extractTurnWrittenFiles } = await jiti.import("./turn-written-files.ts");
+const { TEXT_PREVIEW_MAX_BYTES } = await jiti.import("./file-types.ts");
function toolCall(toolCallId, toolName, input) {
return { type: "toolCall", toolCallId, toolName, input };
}
-function okResult(toolCallId) {
- return { role: "toolResult", toolCallId, content: [{ type: "text", text: "ok" }] };
+function okResult(toolCallId, details) {
+ return { role: "toolResult", toolCallId, content: [{ type: "text", text: "ok" }], details };
}
function errorResult(toolCallId) {
@@ -135,3 +136,163 @@ test("returns an empty array for an empty or text-only turn", () => {
assert.deepEqual(paths([], results()), []);
assert.deepEqual(paths([{ type: "text", text: "hi" }], results()), []);
});
+
+const EDIT_PATCH = [
+ "--- a/a.ts",
+ "+++ b/a.ts",
+ "@@ -1 +1 @@",
+ "-old",
+ "+new",
+ "",
+].join("\n");
+
+test("attaches an edit tool result patch to the written file", () => {
+ const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1", { patch: EDIT_PATCH })));
+ assert.equal(files[0]?.filePath, "/abs/src/a.ts");
+ assert.equal(files[0]?.patch, EDIT_PATCH);
+});
+
+test("prefers details.patch over details.diff for an edit", () => {
+ const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1", { patch: EDIT_PATCH, diff: "-ignored\n+nope\n" })));
+ assert.equal(files[0]?.patch, EDIT_PATCH);
+});
+
+test("falls back to details.diff when patch is absent", () => {
+ const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1", { diff: EDIT_PATCH })));
+ assert.equal(files[0]?.patch, EDIT_PATCH);
+});
+
+test("synthesizes a new-file patch from a successful write's content", () => {
+ const content = [toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\n" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1")));
+ assert.equal(files[0]?.filePath, "/abs/out/report.html");
+ assert.match(files[0]?.patch ?? "", /new file mode 100644/);
+ assert.match(files[0]?.patch ?? "", /\+\+\+ b\/report\.html/);
+ assert.match(files[0]?.patch ?? "", /\+hello/);
+});
+
+test("omits patch for a write without content", () => {
+ const content = [toolCall("1", "write", { file_path: "/abs/out/report.html" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1")));
+ assert.equal(files[0]?.filePath, "/abs/out/report.html");
+ assert.equal(files[0]?.patch, undefined);
+});
+
+test("concatenates patches when the same file is written then edited", () => {
+ const content = [
+ toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\n" }),
+ toolCall("2", "edit", { path: "/abs/out/report.html" }),
+ ];
+ const files = extractTurnWrittenFiles(content, results(
+ okResult("1"),
+ okResult("2", { patch: EDIT_PATCH }),
+ ));
+ assert.equal(files.length, 1);
+ assert.match(files[0]?.patch ?? "", /\+hello/);
+ assert.match(files[0]?.patch ?? "", /\+new/);
+});
+
+test("omits a synthesized write patch when content exceeds the preview limit", () => {
+ const content = [toolCall("1", "write", {
+ file_path: "/abs/out/huge.txt",
+ content: "x".repeat(TEXT_PREVIEW_MAX_BYTES + 1),
+ })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1")));
+ assert.equal(files[0]?.filePath, "/abs/out/huge.txt");
+ assert.equal(files[0]?.patch, undefined);
+});
+
+test("counts added and deleted lines from an edit patch", () => {
+ const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1", { patch: EDIT_PATCH })));
+ assert.equal(files[0]?.additions, 1);
+ assert.equal(files[0]?.deletions, 1);
+});
+
+test("counts added lines from a synthesized write patch", () => {
+ const content = [toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\nworld\n" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1")));
+ assert.equal(files[0]?.additions, 2);
+ assert.equal(files[0]?.deletions, 0);
+});
+
+test("sums line counts when the same file is written then edited", () => {
+ const content = [
+ toolCall("1", "write", { file_path: "/abs/out/report.html", content: "hello\n" }),
+ toolCall("2", "edit", { path: "/abs/out/report.html" }),
+ ];
+ const files = extractTurnWrittenFiles(content, results(
+ okResult("1"),
+ okResult("2", { patch: EDIT_PATCH }),
+ ));
+ assert.equal(files[0]?.additions, 2);
+ assert.equal(files[0]?.deletions, 1);
+});
+
+test("uses zero line counts when no patch is available", () => {
+ const content = [toolCall("1", "write", { file_path: "/abs/out/report.html" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1")));
+ assert.equal(files[0]?.additions, 0);
+ assert.equal(files[0]?.deletions, 0);
+});
+
+test("counts in-hunk lines that look like ---/+++ file headers", () => {
+ const patch = [
+ "--- a/notes.md",
+ "+++ b/notes.md",
+ "@@ -1,2 +1,2 @@",
+ "--- old bullet",
+ "+++ new bullet",
+ "",
+ ].join("\n");
+ const content = [toolCall("1", "edit", { path: "/abs/notes.md" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1", { patch })));
+ assert.equal(files[0]?.additions, 1);
+ assert.equal(files[0]?.deletions, 1);
+});
+
+test("does not count absolute-path ---/+++ headers as line stats", () => {
+ // pi's edit tool emits `--- /abs/path` headers, not `--- a/file`.
+ const patch = [
+ "--- /Users/kitten9455/iMile/smip/README.md",
+ "+++ /Users/kitten9455/iMile/smip/README.md",
+ "@@ -28,4 +28,8 @@",
+ " bun run dev:all",
+ " ```",
+ " ",
+ " context line",
+ "+",
+ "+```ts",
+ '+console.log("test");',
+ "+```",
+ "",
+ ].join("\n");
+ const content = [toolCall("1", "edit", { path: "/Users/kitten9455/iMile/smip/README.md" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1", { patch })));
+ assert.equal(files[0]?.additions, 4);
+ assert.equal(files[0]?.deletions, 0);
+});
+
+test("counts replace hunks from absolute-path patches as visible +/− lines", () => {
+ const patch = [
+ "--- /Users/kitten9455/iMile/smip/README.md",
+ "+++ /Users/kitten9455/iMile/smip/README.md",
+ "@@ -30,6 +30,7 @@",
+ " ",
+ " context line",
+ " ",
+ " ```ts",
+ '-console.log("test");',
+ '+console.log("line a");',
+ '+console.log("line b");',
+ " ```",
+ "",
+ ].join("\n");
+ const content = [toolCall("1", "edit", { path: "/Users/kitten9455/iMile/smip/README.md" })];
+ const files = extractTurnWrittenFiles(content, results(okResult("1", { patch })));
+ assert.equal(files[0]?.additions, 2);
+ assert.equal(files[0]?.deletions, 1);
+});
diff --git a/lib/turn-written-files.ts b/lib/turn-written-files.ts
index 2fded76db..f3527b7c7 100644
--- a/lib/turn-written-files.ts
+++ b/lib/turn-written-files.ts
@@ -1,10 +1,17 @@
import type { AssistantContentBlock, ToolResultMessage } from "./types";
import { resolveLocalFilePath } from "./file-links";
+import { getFileName } from "./file-paths";
+import { TEXT_PREVIEW_MAX_BYTES } from "./file-types";
+import { countPatchLineStats } from "./patch";
import { isEditToolName, isWriteToolName } from "./tool-names";
export interface WrittenFile {
/** Resolved absolute path of a file this turn wrote. */
filePath: string;
+ /** Unified patch for this turn's writes/edits, when one can be derived. */
+ patch?: string;
+ additions: number;
+ deletions: number;
}
function isFileWritingToolName(toolName: string): boolean {
@@ -17,6 +24,60 @@ function readToolPath(input: Record
| undefined): string | null
return typeof value === "string" && value.length > 0 ? value : null;
}
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function readToolResultPatch(result: ToolResultMessage): string | null {
+ if (!isRecord(result.details)) return null;
+ if (typeof result.details.patch === "string" && result.details.patch.length > 0) {
+ return result.details.patch;
+ }
+ if (typeof result.details.diff === "string" && result.details.diff.length > 0) {
+ return result.details.diff;
+ }
+ return null;
+}
+
+function createAddedFilePatch(gitPath: string, content: string): string {
+ const hasTrailingNewline = content.endsWith("\n");
+ const lines = content.split("\n");
+ if (hasTrailingNewline) lines.pop();
+ const body = lines.map((line) => `+${line}`).join("\n");
+ const noNewlineMarker = !hasTrailingNewline && lines.length > 0
+ ? "\n\\ No newline at end of file"
+ : "";
+ return [
+ `diff --git a/${gitPath} b/${gitPath}`,
+ "new file mode 100644",
+ "--- /dev/null",
+ `+++ b/${gitPath}`,
+ `@@ -0,0 +1,${lines.length} @@`,
+ `${body}${noNewlineMarker}`,
+ ].join("\n");
+}
+
+function readCallPatch(
+ toolName: string,
+ input: Record | undefined,
+ result: ToolResultMessage,
+ displayPath: string,
+): string | null {
+ const fromResult = readToolResultPatch(result);
+ if (fromResult) return fromResult;
+ if (!isWriteToolName(toolName)) return null;
+ const content = input?.content;
+ if (typeof content !== "string") return null;
+ if (content.length > TEXT_PREVIEW_MAX_BYTES) return null;
+ return createAddedFilePatch(displayPath, content);
+}
+
+function appendPatch(existing: string | undefined, next: string | null): string | undefined {
+ if (!next) return existing;
+ if (!existing) return next;
+ return `${existing}\n${next}`;
+}
+
/**
* Collect the distinct files a single assistant turn actually wrote.
*
@@ -26,13 +87,15 @@ function readToolPath(input: Record | undefined): string | null
* source here; the tool call is the record of what happened.
*
* Paths are resolved against `cwd`, deduped, and kept in first-seen order.
+ * When a patch can be derived from the tool result or write content, it is
+ * attached and later calls against the same path are concatenated.
*/
export function extractTurnWrittenFiles(
content: AssistantContentBlock[],
toolResults: Map | undefined,
cwd?: string,
): WrittenFile[] {
- const seen = new Set();
+ const byPath = new Map();
const writtenFiles: WrittenFile[] = [];
for (const block of content) {
@@ -51,9 +114,21 @@ export function extractTurnWrittenFiles(
const filePath = resolveLocalFilePath(rawPath, cwd);
if (!filePath) continue;
- if (seen.has(filePath)) continue;
- seen.add(filePath);
- writtenFiles.push({ filePath });
+ let entry = byPath.get(filePath);
+ if (!entry) {
+ entry = { filePath, additions: 0, deletions: 0 };
+ byPath.set(filePath, entry);
+ writtenFiles.push(entry);
+ }
+
+ const patch = readCallPatch(block.toolName, block.input, result, getFileName(filePath));
+ const nextPatch = appendPatch(entry.patch, patch);
+ if (nextPatch) {
+ entry.patch = nextPatch;
+ const stats = countPatchLineStats(nextPatch);
+ entry.additions = stats.additions;
+ entry.deletions = stats.deletions;
+ }
}
return writtenFiles;