Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ceaa34d
test: cover truncatePreview head/tail strategy
code-yeongyu May 10, 2026
d751f8e
feat: add truncatePreview helper with PATCH_PREVIEW_MAX_ constants
code-yeongyu May 10, 2026
0565e81
test: cover displayPath cwd-relative formatting
code-yeongyu May 10, 2026
6a333c4
feat: add displayPath cwd-relative helper
code-yeongyu May 10, 2026
247e98c
refactor: route formatPatchFilePath through displayPath with optional…
code-yeongyu May 10, 2026
b827ff8
test: cover formatPatchPreview collapsed expanded modes and backward …
code-yeongyu May 10, 2026
ffbe0f6
feat: add expanded flag and truncation to formatPatchPreview
code-yeongyu May 10, 2026
9cde6af
test: cover formatInFlightCallText path extraction
code-yeongyu May 10, 2026
2b5e8cc
feat: add formatInFlightCallText for in-flight renderCall labels
code-yeongyu May 10, 2026
5841e46
test: cover clearApplyPatchRenderState helper exists
code-yeongyu May 10, 2026
4752e42
feat: add applyPatchRenderStates map and clearApplyPatchRenderState
code-yeongyu May 10, 2026
87f048f
test: cover renderCall argsComplete and paths integration
code-yeongyu May 10, 2026
9f2d2b4
feat: integrate render state cache and argsComplete in renderCall
code-yeongyu May 10, 2026
a246cd7
test: cover renderResult collapsed multi-file and large diff truncation
code-yeongyu May 10, 2026
f4b3e5d
feat: route renderResult through cwd-aware expanded mode
code-yeongyu May 10, 2026
99e48e2
feat: integrate Pi renderDiff with safe fallback to themed rendering
code-yeongyu May 10, 2026
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
186 changes: 170 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -167,6 +175,39 @@ 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;
const applyPatchRenderStates = new Map<string, ApplyPatchRenderState>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Render-state caching is unbounded and retains full patch strings per tool call, which can cause memory growth during long sessions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/index.ts, line 101:

<comment>Render-state caching is unbounded and retains full patch strings per tool call, which can cause memory growth during long sessions.</comment>

<file context>
@@ -86,6 +94,11 @@ type ApplyPatchTheme = {
+export const PATCH_PREVIEW_MAX_CHARS = 4000;
+const PATCH_PREVIEW_HEAD_LINES = 8;
+const PATCH_PREVIEW_TAIL_LINES = 8;
+const applyPatchRenderStates = new Map<string, ApplyPatchRenderState>();
 
 function normalizeApplyPatchArguments(args: unknown): ApplyPatchParams {
</file context>


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") {
Expand Down Expand Up @@ -347,8 +388,29 @@ function formatLineCountSummary(added: number, removed: number): string {
return `(+${added} -${removed})`;
}

function formatPatchFilePath(file: ApplyPatchPreviewFile): string {
return file.movePath ? `${file.filePath} → ${file.movePath}` : file.filePath;
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;
}

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 {
Expand All @@ -361,16 +423,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");
Expand All @@ -379,16 +449,92 @@ 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");
}

function renderPatchPreview(preview: ApplyPatchPreview, theme: ApplyPatchTheme): string {
return formatPatchPreview(preview)
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();
}

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,
cwd: string,
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) => {
const trimmed = line.trimStart();
Expand Down Expand Up @@ -966,18 +1112,26 @@ 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) {
renderResult(result, options, theme, context) {
const component = new Container();
const preview = result.details?.preview;
if (preview) {
const bgName = options.isPartial ? "toolPendingBg" : "toolSuccessBg";
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;
}
Expand Down
Loading