From ceaa34dfb642d6f21e9abb99bbac8ad97a53e2bd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:32:21 +0900 Subject: [PATCH 01/16] test: cover truncatePreview head/tail strategy --- test/render.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 test/render.test.ts diff --git a/test/render.test.ts b/test/render.test.ts new file mode 100644 index 0000000..a7e482c --- /dev/null +++ b/test/render.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { PATCH_PREVIEW_MAX_CHARS, PATCH_PREVIEW_MAX_LINES, truncatePreview } from "../src/index.js"; + +describe("render helpers", () => { + it("#given long diff #when truncating #then keeps head and tail", () => { + // given + const lines = Array.from({ length: PATCH_PREVIEW_MAX_LINES + 12 }, (_, index) => `line-${index + 1}`); + const diff = lines.join("\n"); + + // when + const preview = truncatePreview(diff); + + // then + expect(preview).toContain("line-1"); + expect(preview).toContain(`line-${lines.length}`); + expect(preview).toContain("…"); + }); + + it("#given huge payload #when truncating #then enforces max chars", () => { + // given + const diff = `${"x".repeat(PATCH_PREVIEW_MAX_CHARS + 500)}\nend`; + + // when + const preview = truncatePreview(diff); + + // then + expect(preview.length).toBeLessThanOrEqual(PATCH_PREVIEW_MAX_CHARS + 2); + expect(preview).toContain("…"); + }); +}); From d751f8e6aba0ede01b544798cc91c64696a78c44 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:32:44 +0900 Subject: [PATCH 02/16] feat: add truncatePreview helper with PATCH_PREVIEW_MAX_ constants --- src/index.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9f2f012..9947c73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -167,6 +167,38 @@ export async function __testWriteFileAtomic( } const GPT_APPLY_PATCH_PROVIDERS = new Set(["openai", "azure-openai-responses", "github-copilot"]); +export const PATCH_PREVIEW_MAX_LINES = 16; +export const PATCH_PREVIEW_MAX_CHARS = 4000; +const PATCH_PREVIEW_HEAD_LINES = 8; +const PATCH_PREVIEW_TAIL_LINES = 8; + +function countLines(text: string): number { + if (text.length === 0) { + return 0; + } + let lines = 1; + for (let index = 0; index < text.length; index++) { + if (text.charCodeAt(index) === 10) { + lines += 1; + } + } + return lines; +} + +export function truncatePreview(text: string): string { + if (text.length <= PATCH_PREVIEW_MAX_CHARS && countLines(text) <= PATCH_PREVIEW_MAX_LINES) { + return text; + } + + const lines = text.split("\n"); + const head = lines.slice(0, PATCH_PREVIEW_HEAD_LINES); + const tail = lines.slice(-PATCH_PREVIEW_TAIL_LINES); + let preview = [...head, "…", ...tail].join("\n"); + if (preview.length > PATCH_PREVIEW_MAX_CHARS) { + preview = `${preview.slice(0, PATCH_PREVIEW_MAX_CHARS).trimEnd()}\n…`; + } + return preview; +} function normalizeApplyPatchArguments(args: unknown): ApplyPatchParams { if (typeof args === "string") { From 0565e81911505f4df8edd26e375ae8affd0952c1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:33:06 +0900 Subject: [PATCH 03/16] test: cover displayPath cwd-relative formatting --- test/render.test.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/test/render.test.ts b/test/render.test.ts index a7e482c..a34eba8 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { PATCH_PREVIEW_MAX_CHARS, PATCH_PREVIEW_MAX_LINES, truncatePreview } from "../src/index.js"; +import { displayPath, PATCH_PREVIEW_MAX_CHARS, PATCH_PREVIEW_MAX_LINES, truncatePreview } from "../src/index.js"; describe("render helpers", () => { it("#given long diff #when truncating #then keeps head and tail", () => { @@ -27,4 +27,28 @@ describe("render helpers", () => { expect(preview.length).toBeLessThanOrEqual(PATCH_PREVIEW_MAX_CHARS + 2); expect(preview).toContain("…"); }); + + it("#given absolute path under cwd #when displaying #then returns relative path", () => { + // given + const cwd = "/workspace/project"; + const absolute = "/workspace/project/src/index.ts"; + + // when + const rendered = displayPath(absolute, cwd); + + // then + expect(rendered).toBe("src/index.ts"); + }); + + it("#given absolute path outside cwd #when displaying #then keeps absolute path", () => { + // given + const cwd = "/workspace/project"; + const absolute = "/tmp/file.ts"; + + // when + const rendered = displayPath(absolute, cwd); + + // then + expect(rendered).toBe(absolute); + }); }); From 6a333c41c36203578c2a2ed87131636b999dc1aa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:33:22 +0900 Subject: [PATCH 04/16] feat: add displayPath cwd-relative helper --- src/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9947c73..4228aea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -379,6 +379,23 @@ function formatLineCountSummary(added: number, removed: number): string { return `(+${added} -${removed})`; } +export function displayPath(filePath: string, cwd: string): string { + if (!path.isAbsolute(filePath)) { + return filePath; + } + + const absoluteCwd = path.resolve(cwd); + const relativePath = path.relative(absoluteCwd, filePath); + if ( + relativePath === "" || + (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath)) + ) { + return relativePath || "."; + } + + return filePath; +} + function formatPatchFilePath(file: ApplyPatchPreviewFile): string { return file.movePath ? `${file.filePath} → ${file.movePath}` : file.filePath; } From 247e98c78fe1dc4ac92cfebea20bcf068146e326 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:33:39 +0900 Subject: [PATCH 05/16] refactor: route formatPatchFilePath through displayPath with optional cwd --- src/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4228aea..94f8a06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -396,8 +396,12 @@ export function displayPath(filePath: string, cwd: string): string { return filePath; } -function formatPatchFilePath(file: ApplyPatchPreviewFile): string { - return file.movePath ? `${file.filePath} → ${file.movePath}` : file.filePath; +export function formatPatchFilePath(file: ApplyPatchPreviewFile, cwd: string = process.cwd()): string { + const filePath = displayPath(file.filePath, cwd); + if (!file.movePath) { + return filePath; + } + return `${filePath} → ${displayPath(file.movePath, cwd)}`; } function formatPatchOperation(operation: ApplyPatchOperation): string { From b827ff87572c0806cbf0a0ac6128e2239d2c17ca Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:34:49 +0900 Subject: [PATCH 06/16] test: cover formatPatchPreview collapsed expanded modes and backward compat --- test/render.test.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/test/render.test.ts b/test/render.test.ts index a34eba8..79941b1 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { displayPath, PATCH_PREVIEW_MAX_CHARS, PATCH_PREVIEW_MAX_LINES, truncatePreview } from "../src/index.js"; +import { + displayPath, + formatPatchPreview, + PATCH_PREVIEW_MAX_CHARS, + PATCH_PREVIEW_MAX_LINES, + truncatePreview, +} from "../src/index.js"; describe("render helpers", () => { it("#given long diff #when truncating #then keeps head and tail", () => { @@ -51,4 +57,54 @@ describe("render helpers", () => { // then expect(rendered).toBe(absolute); }); + + it("#given expanded false #when formatting preview #then renders headers only", () => { + // given + const preview = { + files: [ + { + filePath: "/workspace/project/src/foo.ts", + operation: "update" as const, + diff: "-1 old\n+1 new", + added: 1, + removed: 1, + }, + ], + added: 1, + removed: 1, + }; + + // when + const collapsed = formatPatchPreview(preview, "/workspace/project", false); + const expanded = formatPatchPreview(preview, "/workspace/project", true); + + // then + expect(collapsed).toContain("• Edited src/foo.ts (+1 -1)"); + expect(collapsed).not.toContain("+1 new"); + expect(expanded).toContain("+1 new"); + }); + + it("#given omitted optional args #when formatting preview #then keeps backward compatible defaults", () => { + // given + const preview = { + files: [ + { + filePath: "src/foo.ts", + operation: "update" as const, + diff: "-1 old\n+1 new", + added: 1, + removed: 1, + }, + ], + added: 1, + removed: 1, + }; + + // when + const rendered = formatPatchPreview(preview); + + // then + expect(rendered).toContain("• Edited src/foo.ts (+1 -1)"); + expect(rendered).toContain("+1 new"); + }); }); From ffbe0f6bfe0d11c0b4903153752074c13a874f87 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:35:42 +0900 Subject: [PATCH 07/16] feat: add expanded flag and truncation to formatPatchPreview --- src/index.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 94f8a06..4baea3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -414,16 +414,24 @@ function formatPatchOperation(operation: ApplyPatchOperation): string { return "Edited"; } -function formatPatchPreview(preview: ApplyPatchPreview): string { +export function formatPatchPreview( + preview: ApplyPatchPreview, + cwd: string = process.cwd(), + expanded: boolean = true, +): string { const lines: string[] = []; if (preview.files.length === 1) { const file = preview.files[0]; if (file) { lines.push( - `• ${formatPatchOperation(file.operation)} ${formatPatchFilePath(file)} ${formatLineCountSummary(file.added, file.removed)}`, + `• ${formatPatchOperation(file.operation)} ${formatPatchFilePath(file, cwd)} ${formatLineCountSummary(file.added, file.removed)}`, ); - if (file.diff) { - lines.push(...file.diff.split("\n").map((line) => ` ${line}`)); + if (expanded && file.diff) { + lines.push( + ...truncatePreview(file.diff) + .split("\n") + .map((line) => ` ${line}`), + ); } } return lines.join("\n"); @@ -432,9 +440,13 @@ function formatPatchPreview(preview: ApplyPatchPreview): string { const noun = preview.files.length === 1 ? "file" : "files"; lines.push(`• Edited ${preview.files.length} ${noun} ${formatLineCountSummary(preview.added, preview.removed)}`); for (const file of preview.files) { - lines.push(` └ ${formatPatchFilePath(file)} ${formatLineCountSummary(file.added, file.removed)}`); - if (file.diff) { - lines.push(...file.diff.split("\n").map((line) => ` ${line}`)); + lines.push(` └ ${formatPatchFilePath(file, cwd)} ${formatLineCountSummary(file.added, file.removed)}`); + if (expanded && file.diff) { + lines.push( + ...truncatePreview(file.diff) + .split("\n") + .map((line) => ` ${line}`), + ); } } return lines.join("\n"); From 9cde6af4f4fda5e99bf9c3247fe43e64dbba530d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:36:08 +0900 Subject: [PATCH 08/16] test: cover formatInFlightCallText path extraction --- test/render.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/render.test.ts b/test/render.test.ts index 79941b1..692132c 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { displayPath, + formatInFlightCallText, formatPatchPreview, PATCH_PREVIEW_MAX_CHARS, PATCH_PREVIEW_MAX_LINES, @@ -107,4 +108,20 @@ describe("render helpers", () => { expect(rendered).toContain("• Edited src/foo.ts (+1 -1)"); expect(rendered).toContain("+1 new"); }); + + it("#given parseable call text #when formatting in-flight label #then includes count and paths", () => { + // given + const patch = `*** Begin Patch +*** Update File: src/a.ts +*** Add File: src/b.ts +*** End Patch`; + + // when + const callText = formatInFlightCallText(patch); + + // then + expect(callText).toContain("(2 files)"); + expect(callText).toContain("src/a.ts"); + expect(callText).toContain("src/b.ts"); + }); }); From 2b5e8ccdae89b490a5f4951339c98e0a3654d7d1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:36:26 +0900 Subject: [PATCH 09/16] feat: add formatInFlightCallText for in-flight renderCall labels --- src/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/index.ts b/src/index.ts index 4baea3f..7b9061f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -452,6 +452,16 @@ export function formatPatchPreview( return lines.join("\n"); } +export function formatInFlightCallText(patchText: string): string { + const paths = extractPatchedPaths(patchText); + if (paths.length === 0) { + return "Patching"; + } + const noun = paths.length === 1 ? "file" : "files"; + const count = paths.length > 1 ? ` (${paths.length} ${noun})` : ""; + return `Patching${count}: ${paths.join(", ")}`; +} + function renderPatchPreview(preview: ApplyPatchPreview, theme: ApplyPatchTheme): string { return formatPatchPreview(preview) .split("\n") From 5841e467aa603a71b2d7f78fa9a38bc9e3b51c9c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:36:51 +0900 Subject: [PATCH 10/16] test: cover clearApplyPatchRenderState helper exists --- test/render.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/render.test.ts b/test/render.test.ts index 692132c..6647dd9 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + clearApplyPatchRenderState, displayPath, formatInFlightCallText, formatPatchPreview, @@ -109,6 +110,11 @@ describe("render helpers", () => { expect(rendered).toContain("+1 new"); }); + it("#given cached state #when clearing #then reset helper is callable", () => { + // given/when/then + expect(() => clearApplyPatchRenderState()).not.toThrow(); + }); + it("#given parseable call text #when formatting in-flight label #then includes count and paths", () => { // given const patch = `*** Begin Patch From 4752e4261609279172eaf4a35f87ed9a84f94a3e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:39:01 +0900 Subject: [PATCH 11/16] feat: add applyPatchRenderStates map and clearApplyPatchRenderState --- src/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/index.ts b/src/index.ts index 7b9061f..e8a4fe3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,14 @@ export class ApplyPatchError extends Error { } } +type ApplyPatchRenderState = { + cwd: string; + patchText: string; + callText: string; + collapsed: string; + expanded: string; +}; + type ApplyPatchThemeColor = | "accent" | "error" @@ -171,6 +179,7 @@ export const PATCH_PREVIEW_MAX_LINES = 16; export const PATCH_PREVIEW_MAX_CHARS = 4000; const PATCH_PREVIEW_HEAD_LINES = 8; const PATCH_PREVIEW_TAIL_LINES = 8; +const applyPatchRenderStates = new Map(); function countLines(text: string): number { if (text.length === 0) { @@ -452,6 +461,10 @@ export function formatPatchPreview( return lines.join("\n"); } +export function clearApplyPatchRenderState(): void { + applyPatchRenderStates.clear(); +} + export function formatInFlightCallText(patchText: string): string { const paths = extractPatchedPaths(patchText); if (paths.length === 0) { From 87f048f98c202c0a674e682d451dafa5973e3d12 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:39:28 +0900 Subject: [PATCH 12/16] test: cover renderCall argsComplete and paths integration --- test/render.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/render.test.ts b/test/render.test.ts index 6647dd9..5c78290 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { clearApplyPatchRenderState, + createApplyPatchTool, displayPath, formatInFlightCallText, formatPatchPreview, @@ -9,6 +10,13 @@ import { truncatePreview, } from "../src/index.js"; +const identityTheme = { + fg: (_name: string, text: string) => text, + bg: (_name: string, text: string) => text, + bold: (text: string) => text, + inverse: (text: string) => text, +}; + describe("render helpers", () => { it("#given long diff #when truncating #then keeps head and tail", () => { // given @@ -130,4 +138,50 @@ describe("render helpers", () => { expect(callText).toContain("src/a.ts"); expect(callText).toContain("src/b.ts"); }); + + it("#given partial args #when rendering call #then shows patching placeholder", () => { + // given + const tool = createApplyPatchTool(); + + // when + const component = tool.renderCall?.( + { input: "{" }, + identityTheme as never, + { + argsComplete: false, + cwd: "/workspace/project", + toolCallId: "call-1", + } as never, + ); + const rendered = component?.render(120).join("\n") ?? ""; + + // then + expect(rendered).toContain("apply_patch: Patching"); + }); + + it("#given patch args #when rendering call #then shows paths and count", () => { + // given + const tool = createApplyPatchTool(); + const args = { + input: `*** Begin Patch +*** Update File: src/a.ts +*** Add File: src/b.ts +*** End Patch`, + }; + + // when + const component = tool.renderCall?.( + args, + identityTheme as never, + { + argsComplete: true, + cwd: "/workspace/project", + toolCallId: "call-2", + } as never, + ); + const rendered = component?.render(200).join("\n") ?? ""; + + // then + expect(rendered).toContain("apply_patch: Patching (2 files): src/a.ts, src/b.ts"); + }); }); From 9f2d2b4ae4db5ec6fbfe221ac1872e46bebe8091 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:40:04 +0900 Subject: [PATCH 13/16] feat: integrate render state cache and argsComplete in renderCall --- src/index.ts | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index e8a4fe3..60bf760 100644 --- a/src/index.ts +++ b/src/index.ts @@ -461,6 +461,39 @@ export function formatPatchPreview( return lines.join("\n"); } +function getApplyPatchRenderState(toolCallId: string, cwd: string, patchText: string): ApplyPatchRenderState { + const existing = applyPatchRenderStates.get(toolCallId); + if (existing && existing.cwd === cwd && existing.patchText === patchText) { + return existing; + } + + const callText = formatInFlightCallText(patchText); + let collapsed = ""; + let expanded = ""; + try { + const hunks = parsePatch(patchText); + if (hunks.length > 0) { + const files = hunks.map((hunk) => ({ + filePath: hunk.filePath, + movePath: hunk.type === "update" ? hunk.movePath : undefined, + operation: hunk.type, + diff: "", + added: 0, + removed: 0, + })) satisfies ApplyPatchPreviewFile[]; + const preview: ApplyPatchPreview = { files, added: 0, removed: 0 }; + collapsed = formatPatchPreview(preview, cwd, false); + expanded = formatPatchPreview(preview, cwd, true); + } + } catch { + // leave summaries empty for partial/incomplete patch text + } + + const nextState: ApplyPatchRenderState = { cwd, patchText, callText, collapsed, expanded }; + applyPatchRenderStates.set(toolCallId, nextState); + return nextState; +} + export function clearApplyPatchRenderState(): void { applyPatchRenderStates.clear(); } @@ -1054,8 +1087,15 @@ export function createApplyPatchTool(): ApplyPatchToolDefinition { details: { result }, }; }, - renderCall(_args, theme) { - return new Text(theme.fg("toolTitle", theme.bold("apply_patch")), 0, 0); + renderCall(args, theme, context) { + if (!context.argsComplete) { + return new Text(theme.fg("toolTitle", theme.bold("apply_patch: Patching")), 0, 0); + } + + const normalizedArgs = normalizeApplyPatchArguments(args); + const renderState = getApplyPatchRenderState(context.toolCallId, context.cwd, normalizedArgs.input); + const text = renderState.callText.length > 0 ? `apply_patch: ${renderState.callText}` : "apply_patch"; + return new Text(theme.fg("toolTitle", theme.bold(text)), 0, 0); }, renderResult(result, options, theme) { const component = new Container(); From a246cd7046bab76163c129a040b1e9cef32a6dc9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:40:34 +0900 Subject: [PATCH 14/16] test: cover renderResult collapsed multi-file and large diff truncation --- test/render.test.ts | 98 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/test/render.test.ts b/test/render.test.ts index 5c78290..6ac79a4 100644 --- a/test/render.test.ts +++ b/test/render.test.ts @@ -184,4 +184,102 @@ describe("render helpers", () => { // then expect(rendered).toContain("apply_patch: Patching (2 files): src/a.ts, src/b.ts"); }); + + it("#given preview #when rendering result collapsed #then shows headers without diff lines", () => { + // given + const tool = createApplyPatchTool(); + const result = { + content: [{ type: "text" as const, text: "Applying patch" }], + details: { + preview: { + files: [ + { + filePath: "src/foo.ts", + operation: "update" as const, + diff: "-1 old\n+1 new", + added: 1, + removed: 1, + }, + ], + added: 1, + removed: 1, + }, + }, + }; + + // when + const component = tool.renderResult?.( + result, + { expanded: false, isPartial: false }, + identityTheme as never, + { cwd: "/workspace/project", toolCallId: "result-1", args: { input: "" } } as never, + ); + const rendered = component?.render(200).join("\n") ?? ""; + + // then + expect(rendered).toContain("• Edited src/foo.ts (+1 -1)"); + expect(rendered).not.toContain("+1 new"); + }); + + it("#given multi-file preview #when rendering result collapsed #then shows grouped summary", () => { + // given + const tool = createApplyPatchTool(); + const result = { + content: [{ type: "text" as const, text: "Applying patch" }], + details: { + preview: { + files: [ + { filePath: "src/a.ts", operation: "update" as const, diff: "+1 one", added: 1, removed: 0 }, + { filePath: "src/b.ts", operation: "update" as const, diff: "+1 two", added: 1, removed: 0 }, + ], + added: 2, + removed: 0, + }, + }, + }; + + // when + const component = tool.renderResult?.( + result, + { expanded: false, isPartial: false }, + identityTheme as never, + { cwd: "/workspace/project", toolCallId: "result-3", args: { input: "" } } as never, + ); + const rendered = component?.render(400).join("\n") ?? ""; + + // then + expect(rendered).toContain("• Edited 2 files (+2 -0)"); + expect(rendered).toContain("└ src/a.ts (+1 -0)"); + expect(rendered).toContain("└ src/b.ts (+1 -0)"); + expect(rendered).not.toContain("+1 one"); + }); + + it("#given large preview #when rendering result expanded #then shows truncation marker", () => { + // given + const tool = createApplyPatchTool(); + const diff = Array.from({ length: 50 }, (_, index) => `+${index + 1} line`).join("\n"); + const result = { + content: [{ type: "text" as const, text: "Applying patch" }], + details: { + preview: { + files: [{ filePath: "src/large.ts", operation: "update" as const, diff, added: 50, removed: 0 }], + added: 50, + removed: 0, + }, + }, + }; + + // when + const component = tool.renderResult?.( + result, + { expanded: true, isPartial: false }, + identityTheme as never, + { cwd: "/workspace/project", toolCallId: "result-large", args: { input: "" } } as never, + ); + const rendered = component?.render(400).join("\n") ?? ""; + + // then + expect(rendered).toContain("• Edited src/large.ts (+50 -0)"); + expect(rendered).toContain("…"); + }); }); From f4b3e5d37a33e735b17002454bd48b7de4123f07 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:45:31 +0900 Subject: [PATCH 15/16] feat: route renderResult through cwd-aware expanded mode --- src/index.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 60bf760..3c38372 100644 --- a/src/index.ts +++ b/src/index.ts @@ -508,8 +508,13 @@ export function formatInFlightCallText(patchText: string): string { return `Patching${count}: ${paths.join(", ")}`; } -function renderPatchPreview(preview: ApplyPatchPreview, theme: ApplyPatchTheme): string { - return formatPatchPreview(preview) +function renderPatchPreview( + preview: ApplyPatchPreview, + cwd: string, + theme: ApplyPatchTheme, + expanded: boolean, +): string { + return formatPatchPreview(preview, cwd, expanded) .split("\n") .map((line) => { const trimmed = line.trimStart(); @@ -1097,7 +1102,7 @@ export function createApplyPatchTool(): ApplyPatchToolDefinition { const text = renderState.callText.length > 0 ? `apply_patch: ${renderState.callText}` : "apply_patch"; return new Text(theme.fg("toolTitle", theme.bold(text)), 0, 0); }, - renderResult(result, options, theme) { + renderResult(result, options, theme, context) { const component = new Container(); const preview = result.details?.preview; if (preview) { @@ -1105,7 +1110,8 @@ export function createApplyPatchTool(): ApplyPatchToolDefinition { const box = new Box(1, 1, (text: string) => theme.bg(bgName, text)); box.addChild(new Text(theme.fg("toolTitle", theme.bold("Applying patch")), 0, 0)); box.addChild(new Spacer(1)); - box.addChild(new Text(renderPatchPreview(preview, theme), 0, 0)); + const expanded = options.isPartial ? true : (options.expanded ?? true); + box.addChild(new Text(renderPatchPreview(preview, context.cwd, theme, expanded), 0, 0)); component.addChild(box); return component; } From 99e48e27f8cd06c5fbbfa7d0c7edbd00ce2956f8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:46:08 +0900 Subject: [PATCH 16/16] feat: integrate Pi renderDiff with safe fallback to themed rendering --- src/index.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 3c38372..83d5924 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from " import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; -import { defineTool, type ExtensionAPI, type ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { defineTool, type ExtensionAPI, renderDiff, type ToolDefinition } from "@mariozechner/pi-coding-agent"; import { Box, Container, Spacer, Text } from "@mariozechner/pi-tui"; import * as Diff from "diff"; import { Type } from "typebox"; @@ -514,6 +514,26 @@ function renderPatchPreview( theme: ApplyPatchTheme, expanded: boolean, ): string { + if (expanded) { + try { + const renderedFiles = preview.files + .map((file) => { + const header = `• ${formatPatchOperation(file.operation)} ${formatPatchFilePath(file, cwd)} ${formatLineCountSummary(file.added, file.removed)}`; + if (!file.diff) { + return header; + } + const previewDiff = truncatePreview(file.diff); + return `${header}\n${renderDiff(previewDiff)}`; + }) + .join("\n"); + if (renderedFiles.length > 0) { + return renderedFiles; + } + } catch { + // fall back to manual themed line rendering + } + } + return formatPatchPreview(preview, cwd, expanded) .split("\n") .map((line) => {