pi-codex-tools 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.2] - 2026-08-06
10
+
11
+ ### Added
12
+
13
+ - Stream `apply_patch` progress in the TUI: while a patch is generated the tool now shows a live diff glimpse of the content being written plus a running `+added -removed` tally (and a capped per-file roster for multi-file patches), reusing Pi's shared diff rendering. Moves render the source → destination transition, and the preview is byte/file bounded for responsiveness. Execution behavior is unchanged.
14
+
15
+ ## [0.1.1] - 2026-08-04
16
+
17
+ ### Fixed
18
+
19
+ - Guard `apply_patch` execution when the active model does not advertise grammar-tool support.
20
+ - Require the Pi runtime grammar-tool contract and fail closed on unsupported filesystem platforms.
21
+ - Preflight repeated file hunks sequentially without rejecting valid Codex patches.
22
+ - Respect `enableInstallTelemetry: false` even when `PI_TELEMETRY` is enabled.
23
+ - Document credential-containing target reads, telemetry controls, runtime requirements, and line-oriented compatibility.
24
+
9
25
  ## [0.1.0] - 2026-08-03
10
26
 
11
27
  ### Added
package/README.md CHANGED
@@ -6,9 +6,10 @@ Give grammar-capable OpenAI/Codex models the Codex `apply_patch` tool in Pi with
6
6
 
7
7
  - **Raw `apply_patch`** — sends Codex's Lark grammar as an OpenAI custom tool, so patches are not JSON-wrapped.
8
8
  - **Capability-based activation** — requires `openai-codex-responses` or `openai-responses` plus `model.compat.supportsOpenAIGrammarTools === true`; model names alone are never enough.
9
- - **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux/macOS, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
9
+ - **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
10
10
  - **Model switching** — supported models replace Pi's `edit` and `write` tools with `apply_patch`; other active tools are preserved. Switching back restores only the file tools that were active before the switch.
11
11
  - **Sequential patch calls** — the extension marks patch execution sequential and disables provider-side parallel tool calls when the patch tool is active.
12
+ - **Streaming progress** — while a patch is generated, the TUI shows a live, color-coded glimpse of the content being written (new-file content, or `+`/`-` lines for updates) plus a running `+added -removed` tally and a per-file roster for multi-file patches. It reuses Pi's shared diff rendering and mirrors the built-in `write`/`edit` previews; patch execution is unchanged.
12
13
 
13
14
  ## Installation
14
15
 
@@ -24,7 +25,7 @@ pi -e /path/to/pi-mono/packages/pi-codex-tools
24
25
 
25
26
  ## Scope decisions
26
27
 
27
- The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` runs only on Linux/macOS and fails closed on unsupported platforms.
28
+ The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` runs only on Linux and fails closed on unsupported platforms. It also requires a Pi model runtime that advertises `compat.supportsOpenAIGrammarTools`; older runtimes leave the tool inactive.
28
29
 
29
30
  | Codex surface | Decision |
30
31
  | --- | --- |
@@ -48,6 +49,8 @@ These choices are based on the Codex tool specifications in `codex-rs/core/src/t
48
49
 
49
50
  These behaviors intentionally match Codex `apply_patch`.
50
51
 
52
+ The provider contract is runtime-specific: use Pi 0.83.0 or newer for OpenAI grammar-tool support. For a manual smoke test, start Pi with this extension and a model that advertises `supportsOpenAIGrammarTools`, then verify that a file change appears as an `apply_patch` call and not as `edit`, `write`, or `bash`.
53
+
51
54
  ## Development
52
55
 
53
56
  ```bash
@@ -56,7 +59,7 @@ npm test -w packages/pi-codex-tools
56
59
  npm run -w packages/pi-codex-tools pack:dry-run
57
60
  ```
58
61
 
59
- Install/update telemetry can be disabled with `PI_OFFLINE=1` or `PI_TELEMETRY=0`.
62
+ Install/update telemetry is disabled in CI and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0` or `PI_TELEMETRY=false`, or Pi's `enableInstallTelemetry: false` setting. See [SECURITY.md](./SECURITY.md).
60
63
 
61
64
  ## License
62
65
 
package/SECURITY.md CHANGED
@@ -19,8 +19,8 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
19
19
 
20
20
  Pi extensions execute with the same permissions as the local user running Pi. Review installed extensions and only install packages from sources you trust.
21
21
 
22
- `apply_patch` does not access the network or credentials. It validates paths beneath the current working directory, rejects symlink paths and symlinked parents, limits patch input to 1 MiB and target-file reads to 64 MiB, preflights file changes before writing, and uses root-anchored descriptor-based no-follow operations on Linux/macOS. It fails closed on unsupported platforms because Pi does not provide Codex's OS-level filesystem sandbox. A failure during a multi-file write can still leave earlier files changed; callers should use version control and review the resulting diff.
22
+ `apply_patch` does not access the network or credential APIs. It can read credential-containing files when a patch targets them. It validates paths beneath the current working directory, rejects symlink paths and symlinked parents, limits patch input to 1 MiB and target-file reads to 64 MiB, preflights file changes before writing, and uses root-anchored descriptor-based no-follow operations on Linux. It fails closed on unsupported platforms because Pi does not provide Codex's OS-level filesystem sandbox. A failure during a multi-file write can still leave earlier files changed; callers should use version control and review the resulting diff.
23
23
 
24
24
  The package reads the current provider/model capability flags only to select tools. It does not log prompts, patches, file contents, credentials, auth headers, or provider responses.
25
25
 
26
- Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, CI detection, or Pi's `enableInstallTelemetry: false` setting. It sends only package/version and runtime metadata through `@mocito/install-telemetry`.
26
+ Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, `PI_TELEMETRY=false`, CI detection, or Pi's `enableInstallTelemetry: false` setting. Through `@mocito/install-telemetry`, it sends the package name and version as HTTPS URL query parameters and adds `process.platform`, the runtime name/version, and `process.arch` to the `User-Agent`. These fields are not intended to identify a host, user, repository, or path; no prompts, patches, file contents, credentials, auth headers, or provider responses are sent.
@@ -1,8 +1,22 @@
1
+ import { Container, Text } from "@earendil-works/pi-tui";
2
+ import type { Component } from "@earendil-works/pi-tui";
1
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
4
  import { reportInstallTelemetry } from "../src/install-telemetry.js";
3
5
  import { applyPatch, APPLY_PATCH_GRAMMAR, MAX_PATCH_BYTES } from "../src/apply-patch.js";
4
6
  import { createFreeformInputSchema, createOpenAILarkSampling, type OpenAIGrammarSampling } from "../src/grammar.js";
5
7
  import { supportsOpenAIGrammarTools } from "../src/model-support.js";
8
+ import { formatApplyPatchCallText, formatApplyPatchResultText } from "../src/patch-preview.js";
9
+
10
+ class ApplyPatchCallComponent extends Text {
11
+ cache?: { key: string; text: string };
12
+ constructor() {
13
+ super("", 0, 0);
14
+ }
15
+ }
16
+
17
+ function readPatchArg(args: unknown): string {
18
+ return typeof (args as { patch?: unknown })?.patch === "string" ? (args as { patch: string }).patch : "";
19
+ }
6
20
 
7
21
  const APPLY_PATCH = "apply_patch";
8
22
  const EDIT = "edit";
@@ -35,7 +49,35 @@ export default function piCodexTools(pi: ExtensionAPI): void {
35
49
  parameters: APPLY_PATCH_PARAMETERS,
36
50
  constrainedSampling: createOpenAILarkSampling(APPLY_PATCH_GRAMMAR),
37
51
  executionMode: "sequential",
52
+ renderCall(args, theme, context) {
53
+ const component =
54
+ context.lastComponent instanceof ApplyPatchCallComponent ? context.lastComponent : new ApplyPatchCallComponent();
55
+ const rawPatch = readPatchArg(args);
56
+ const key = `${context.expanded ? "1" : "0"}:${rawPatch}`;
57
+ if (!component.cache || component.cache.key !== key) {
58
+ component.cache = {
59
+ key,
60
+ text: formatApplyPatchCallText(rawPatch, theme, { expanded: context.expanded }),
61
+ };
62
+ }
63
+ component.setText(component.cache.text);
64
+ return component as Component;
65
+ },
66
+ renderResult(result, _options, theme, context) {
67
+ const text = formatApplyPatchResultText(result, theme, context.isError);
68
+ if (!text) {
69
+ const component = (context.lastComponent ?? new Container()) as Container;
70
+ component.clear();
71
+ return component as Component;
72
+ }
73
+ const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
74
+ component.setText(text);
75
+ return component as Component;
76
+ },
38
77
  async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
78
+ if (!supportsOpenAIGrammarTools(ctx.model)) {
79
+ throw new Error("apply_patch is only available for OpenAI models that advertise grammar-tool support.");
80
+ }
39
81
  const patch = (rawParams as { patch?: unknown }).patch;
40
82
  if (typeof patch !== "string") throw new Error("apply_patch requires raw patch text.");
41
83
  const result = await applyPatch(patch, { cwd: ctx.cwd, signal });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-tools",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Codex-compatible apply_patch tooling for Pi's grammar-capable OpenAI models.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -52,14 +52,16 @@
52
52
  "peerDependencies": {
53
53
  "@earendil-works/pi-ai": "*",
54
54
  "@earendil-works/pi-coding-agent": "*",
55
+ "@earendil-works/pi-tui": "*",
55
56
  "typebox": "*"
56
57
  },
57
58
  "devDependencies": {
58
- "@earendil-works/pi-ai": "^0.80.10",
59
- "@earendil-works/pi-coding-agent": "^0.80.10",
60
- "@types/node": "^26.1.1",
59
+ "@earendil-works/pi-ai": "^0.82.1",
60
+ "@earendil-works/pi-coding-agent": "^0.82.1",
61
+ "@earendil-works/pi-tui": "^0.82.1",
62
+ "@types/node": "^26.1.2",
61
63
  "tsx": "^4.23.1",
62
- "typebox": "^1.3.6",
64
+ "typebox": "^1.3.8",
63
65
  "typescript": "^7.0.2"
64
66
  },
65
67
  "publishConfig": {
@@ -10,7 +10,7 @@ export const MAX_PATCH_HUNKS = 1_000;
10
10
  export const MAX_TARGET_FILE_BYTES = 64 * 1024 * 1024;
11
11
 
12
12
  const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
13
- const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" : process.platform === "darwin" ? "/dev/fd" : undefined;
13
+ const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" : undefined;
14
14
  const SECURE_FILESYSTEM_SUPPORTED = SECURE_FD_DIRECTORY !== undefined && O_NOFOLLOW !== 0;
15
15
  const SECURE_DIRECTORY_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_DIRECTORY ?? 0) | (constants.O_NONBLOCK ?? 0);
16
16
  const SECURE_READ_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_NONBLOCK ?? 0);
@@ -225,6 +225,7 @@ type PlannedOperation =
225
225
  | { kind: "update"; path: string; displayPath: string; moveTo?: string; moveDisplayPath?: string; chunkGroups: UpdateChunk[][]; content: string };
226
226
 
227
227
  type SafePath = { absolute: string; exists: boolean; isDirectory: boolean; isFile: boolean };
228
+ type VirtualFile = Omit<SafePath, "absolute"> & { content?: string };
228
229
 
229
230
  export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
230
231
  requireSecureFilesystem();
@@ -271,77 +272,108 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
271
272
 
272
273
  async function planOperations(hunks: ApplyPatchHunk[], root: string, rootHandle: FileHandle, signal?: AbortSignal): Promise<PlannedOperation[]> {
273
274
  const operations: PlannedOperation[] = [];
274
- const byPath = new Map<string, PlannedOperation>();
275
- const occupied = new Set<string>();
275
+ const virtualFiles = new Map<string, VirtualFile>();
276
+
277
+ const getVirtualFile = async (rawPath: string): Promise<{ absolute: string; file: VirtualFile }> => {
278
+ const absolute = resolvePatchPath(rawPath, root);
279
+ const existing = virtualFiles.get(absolute);
280
+ if (existing) return { absolute, file: existing };
281
+ const safe = await safePath(rawPath, root, signal);
282
+ const file: VirtualFile = {
283
+ exists: safe.exists,
284
+ isDirectory: safe.isDirectory,
285
+ isFile: safe.isFile,
286
+ };
287
+ virtualFiles.set(absolute, file);
288
+ return { absolute, file };
289
+ };
290
+
291
+ const getVirtualContent = async (absolute: string, file: VirtualFile): Promise<string> => {
292
+ if (!file.exists || !file.isFile) throw new Error(`Cannot read non-file '${absolute}'.`);
293
+ if (file.content === undefined) file.content = await readSecureFile(rootHandle, root, absolute, signal);
294
+ return file.content;
295
+ };
276
296
 
277
297
  for (const hunk of hunks) {
278
298
  throwIfAborted(signal);
279
- const source = await safePath(hunk.path, root, signal);
280
- if (occupied.has(source.absolute) && !(hunk.kind === "update" && byPath.get(source.absolute)?.kind === "update")) {
281
- throw new Error(`Patch addresses '${hunk.path}' more than once.`);
282
- }
299
+ const source = await getVirtualFile(hunk.path);
283
300
 
284
301
  if (hunk.kind === "add") {
285
- if (source.isDirectory || (source.exists && !source.isFile)) throw new Error(`Cannot add file over non-file '${hunk.path}'.`);
286
- const operation: PlannedOperation = { kind: "add", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path), content: hunk.content };
287
- operations.push(operation);
288
- byPath.set(source.absolute, operation);
289
- occupied.add(source.absolute);
302
+ if (source.file.isDirectory || (source.file.exists && !source.file.isFile)) {
303
+ throw new Error(`Cannot add file over non-file '${hunk.path}'.`);
304
+ }
305
+ source.file.exists = true;
306
+ source.file.isDirectory = false;
307
+ source.file.isFile = true;
308
+ source.file.content = hunk.content;
309
+ operations.push({
310
+ kind: "add",
311
+ path: source.absolute,
312
+ displayPath: displayPath(root, source.absolute, hunk.path),
313
+ content: hunk.content,
314
+ });
290
315
  continue;
291
316
  }
292
317
 
293
318
  if (hunk.kind === "delete") {
294
- if (!source.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
295
- if (source.isDirectory || !source.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
296
- const operation: PlannedOperation = { kind: "delete", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path) };
297
- operations.push(operation);
298
- byPath.set(source.absolute, operation);
299
- occupied.add(source.absolute);
319
+ if (!source.file.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
320
+ if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
321
+ operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path) });
322
+ source.file.exists = false;
323
+ source.file.content = undefined;
300
324
  continue;
301
325
  }
302
326
 
303
- if (!source.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
304
- if (source.isDirectory || !source.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
305
- const original = await readSecureFile(rootHandle, root, source.absolute, signal);
306
- const moveTo = hunk.moveTo ? (await safePath(hunk.moveTo, root, signal)).absolute : undefined;
327
+ if (!source.file.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
328
+ if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
329
+ const original = await getVirtualContent(source.absolute, source.file);
330
+ const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, root) : undefined;
307
331
  if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
332
+
333
+ let destination: { absolute: string; file: VirtualFile } | undefined;
308
334
  if (moveTo) {
309
- const destination = await safePath(hunk.moveTo!, root, signal);
310
- if (destination.isDirectory || (destination.exists && !destination.isFile)) throw new Error(`Cannot move file over non-file '${hunk.moveTo}'.`);
311
- if (occupied.has(moveTo)) throw new Error(`Patch addresses move destination '${hunk.moveTo}' more than once.`);
312
- occupied.add(moveTo);
335
+ destination = await getVirtualFile(hunk.moveTo!);
336
+ if (destination.file.isDirectory || (destination.file.exists && !destination.file.isFile)) {
337
+ throw new Error(`Cannot move file over non-file '${hunk.moveTo}'.`);
338
+ }
313
339
  }
314
340
 
315
- const existing = byPath.get(source.absolute);
316
- if (existing?.kind === "update") {
317
- if (existing.moveTo || moveTo) throw new Error(`A file can only be moved once in a patch: '${hunk.path}'.`);
318
- existing.chunkGroups.push(hunk.chunks);
319
- existing.content = existing.chunkGroups.reduce(
320
- (content, chunks) => applyUpdateContent(content, chunks, hunk.path),
321
- original,
322
- );
341
+ const content = applyUpdateContent(original, hunk.chunks, hunk.path);
342
+ if (destination) {
343
+ operations.push({
344
+ kind: "update",
345
+ path: source.absolute,
346
+ displayPath: displayPath(root, source.absolute, hunk.path),
347
+ moveTo: moveTo!,
348
+ moveDisplayPath: displayPath(root, moveTo!, hunk.moveTo!),
349
+ chunkGroups: [[...hunk.chunks]],
350
+ content,
351
+ });
352
+ source.file.exists = false;
353
+ source.file.content = undefined;
354
+ destination.file.exists = true;
355
+ destination.file.isDirectory = false;
356
+ destination.file.isFile = true;
357
+ destination.file.content = content;
323
358
  continue;
324
359
  }
325
360
 
326
- const operation: PlannedOperation = {
327
- kind: "update",
328
- path: source.absolute,
329
- displayPath: displayPath(root, source.absolute, hunk.path),
330
- moveTo,
331
- moveDisplayPath: moveTo ? displayPath(root, moveTo, hunk.moveTo!) : undefined,
332
- chunkGroups: [[...hunk.chunks]],
333
- content: applyUpdateContent(original, hunk.chunks, hunk.path),
334
- };
335
- operations.push(operation);
336
- byPath.set(source.absolute, operation);
337
- occupied.add(source.absolute);
338
- }
339
-
340
- for (const operation of operations) {
341
- if (operation.kind === "update" && operation.moveTo && byPath.has(operation.moveTo)) {
342
- throw new Error(`Move destination '${operation.moveDisplayPath}' is also changed by this patch.`);
361
+ const previous = operations.at(-1);
362
+ if (previous?.kind === "update" && previous.path === source.absolute && !previous.moveTo) {
363
+ previous.content = content;
364
+ previous.chunkGroups.push([...hunk.chunks]);
365
+ } else {
366
+ operations.push({
367
+ kind: "update",
368
+ path: source.absolute,
369
+ displayPath: displayPath(root, source.absolute, hunk.path),
370
+ chunkGroups: [[...hunk.chunks]],
371
+ content,
372
+ });
343
373
  }
374
+ source.file.content = content;
344
375
  }
376
+
345
377
  return operations;
346
378
  }
347
379
 
@@ -44,14 +44,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
44
44
  return normalized !== "0" && normalized !== "false" && normalized !== "no";
45
45
  }
46
46
 
47
- function isInstallTelemetryEnabled(): boolean {
48
- if (isTruthyEnvFlag(process.env.CI)) return false;
49
- if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]))) return false;
50
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
51
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
47
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
48
+ if (isTruthyEnvFlag(env.CI)) return false;
49
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
50
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
52
51
 
53
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as SettingsDocument;
54
- return settings.enableInstallTelemetry !== false;
52
+ const settings = readJsonFile(settingsPath) as SettingsDocument;
53
+ if (settings.enableInstallTelemetry === false) return false;
54
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
55
+ return true;
55
56
  }
56
57
 
57
58
  function getPackageVersion(): string {
@@ -0,0 +1,265 @@
1
+ // Streaming preview rendering for the apply_patch tool.
2
+ //
3
+ // The strict parser in apply-patch.ts rejects incomplete input, but the patch arrives as a
4
+ // growing prefix while the model generates it. This module scans that partial text tolerantly
5
+ // (it never throws) to drive a live, write-style glimpse of the content plus a running
6
+ // added/removed tally. It reuses Pi's shared `renderDiff` primitive for +/- coloring, so the
7
+ // preview stays consistent with the built-in `edit` tool's diff preview.
8
+ import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
9
+
10
+ const FILE_ADD = "*** Add File: ";
11
+ const FILE_DELETE = "*** Delete File: ";
12
+ const FILE_UPDATE = "*** Update File: ";
13
+ const MOVE_TO = "*** Move to: ";
14
+ const END_OF_FILE = "*** End of File";
15
+ const BEGIN_PATCH = "*** Begin Patch";
16
+ const END_PATCH = "*** End Patch";
17
+
18
+ /**
19
+ * Preview bounds. The patch itself is capped at apply time (1 MiB / 1000 hunks), but the TUI
20
+ * re-renders on every streaming token, so the preview scanner bails earlier to keep the UI
21
+ * responsive and its retained output bounded (see AGENTS.md: "bound tool output").
22
+ */
23
+ const PREVIEW_LINES_COLLAPSED = 10;
24
+ const PREVIEW_LINES_EXPANDED = 500;
25
+ const MAX_PREVIEW_BYTES = 256 * 1024;
26
+ const MAX_PREVIEW_FILES = 500;
27
+ /** Cap a single rendered glimpse line so minified/generated content cannot flood the TUI. */
28
+ const PREVIEW_LINE_CHARS = 200;
29
+
30
+ export type PatchLineType = "add" | "del" | "ctx";
31
+
32
+ export interface PatchPreviewLine {
33
+ type: PatchLineType;
34
+ text: string;
35
+ }
36
+
37
+ export interface PatchPreviewFile {
38
+ kind: "add" | "update" | "delete";
39
+ path: string;
40
+ /** Destination for an update that carries `*** Move to:`; execution writes this path and deletes `path`. */
41
+ moveTo?: string;
42
+ /** Diff lines for the file. Empty for deletes (the patch carries no content for them). */
43
+ lines: PatchPreviewLine[];
44
+ }
45
+
46
+ export interface PatchPreview {
47
+ files: PatchPreviewFile[];
48
+ totalAdded: number;
49
+ totalRemoved: number;
50
+ /** True when the preview stopped early because the patch exceeded a preview bound. */
51
+ truncated: boolean;
52
+ }
53
+
54
+ /**
55
+ * Tolerantly scan a (possibly incomplete) apply_patch body into a preview.
56
+ *
57
+ * The grammar is line-oriented, so a partial buffer is always a prefix of valid lines. We walk
58
+ * every line and accumulate per-file diff lines and running counts; malformed/unknown lines are
59
+ * ignored rather than thrown on. Each call re-scans the current buffer from scratch, so there is
60
+ * no accumulation drift across streaming deltas.
61
+ */
62
+ export function scanPatchPreview(input: string): PatchPreview {
63
+ // Bound per-render work: cap the bytes we split and walk so a large patch (up to 1 MiB at apply
64
+ // time) cannot make streaming re-renders quadratic. Anything past the cap is reported truncated.
65
+ const capped = input.length > MAX_PREVIEW_BYTES + 1 ? input.slice(0, MAX_PREVIEW_BYTES + 1) : input;
66
+ const lines = capped.split("\n");
67
+
68
+ const filesByPath = new Map<string, PatchPreviewFile>();
69
+ const files: PatchPreviewFile[] = []; // first-seen order
70
+ let totalAdded = 0;
71
+ let totalRemoved = 0;
72
+ let current: PatchPreviewFile | undefined;
73
+ let truncated = input.length > capped.length;
74
+
75
+ for (const raw of lines) {
76
+ const line = raw.replace(/\r$/, "");
77
+ // The terminator ends the patch; trailing text must not change totals or the active file.
78
+ if (line === END_PATCH) break;
79
+ if (line === "" || line === BEGIN_PATCH || line === END_OF_FILE) continue;
80
+
81
+ if (line.startsWith(MOVE_TO)) {
82
+ // A move renames the current update's file; preserve the destination so the preview can
83
+ // show the source -> destination transition (execution deletes source, writes destination).
84
+ if (current) current.moveTo = line.slice(MOVE_TO.length).trim();
85
+ continue;
86
+ }
87
+
88
+ if (line.startsWith(FILE_ADD) || line.startsWith(FILE_DELETE) || line.startsWith(FILE_UPDATE)) {
89
+ const kind: PatchPreviewFile["kind"] = line.startsWith(FILE_ADD)
90
+ ? "add"
91
+ : line.startsWith(FILE_DELETE)
92
+ ? "delete"
93
+ : "update";
94
+ const prefix = kind === "add" ? FILE_ADD : kind === "delete" ? FILE_DELETE : FILE_UPDATE;
95
+ const path = line.slice(prefix.length);
96
+ const existing = filesByPath.get(path);
97
+ if (existing) {
98
+ // Execution coalesces multiple hunks for the same path; the preview must too, otherwise
99
+ // the roster shows duplicate rows and an inflated file count.
100
+ current = existing;
101
+ continue;
102
+ }
103
+ if (files.length >= MAX_PREVIEW_FILES) {
104
+ truncated = true;
105
+ break;
106
+ }
107
+ current = { kind, path, lines: [] };
108
+ filesByPath.set(path, current);
109
+ files.push(current);
110
+ continue;
111
+ }
112
+
113
+ // Lines before the first file header (or inside a delete) carry no preview content.
114
+ if (!current || current.kind === "delete") continue;
115
+
116
+ if (line === "@@" || line.startsWith("@@ ")) continue; // chunk context marker
117
+
118
+ if (line.startsWith("+")) {
119
+ current.lines.push({ type: "add", text: line.slice(1) });
120
+ totalAdded++;
121
+ } else if (line.startsWith("-")) {
122
+ current.lines.push({ type: "del", text: line.slice(1) });
123
+ totalRemoved++;
124
+ } else {
125
+ // Context line (" text") or a bare "" inside a chunk.
126
+ current.lines.push({ type: "ctx", text: line.startsWith(" ") ? line.slice(1) : line });
127
+ }
128
+ }
129
+
130
+ return { files, totalAdded, totalRemoved, truncated };
131
+ }
132
+
133
+ function fileStatusMark(file: PatchPreviewFile): string {
134
+ switch (file.kind) {
135
+ case "add":
136
+ return "A";
137
+ case "delete":
138
+ return "D";
139
+ default:
140
+ return "M";
141
+ }
142
+ }
143
+
144
+ function fileCountsLabel(file: PatchPreviewFile, theme: Theme): string {
145
+ if (file.kind === "delete") return "";
146
+ const added = theme.fg("toolDiffAdded", `+${countLines(file, "add")}`);
147
+ const removed = file.kind === "update" ? ` ${theme.fg("toolDiffRemoved", `-${countLines(file, "del")}`)}` : "";
148
+ return `${added}${removed}`;
149
+ }
150
+
151
+ function fileMarkLabel(file: PatchPreviewFile, theme: Theme): string {
152
+ const mark = file.kind === "add" ? "toolDiffAdded" : file.kind === "delete" ? "toolDiffRemoved" : "warning";
153
+ return theme.fg(mark, fileStatusMark(file));
154
+ }
155
+
156
+ function filePathLabel(file: PatchPreviewFile, theme: Theme): string {
157
+ const path = theme.fg("accent", truncatePath(file.path));
158
+ return file.moveTo ? `${path} ${theme.fg("muted", "->")} ${theme.fg("accent", truncatePath(file.moveTo))}` : path;
159
+ }
160
+
161
+ function formatFileRosterLine(file: PatchPreviewFile, theme: Theme): string {
162
+ const counts = fileCountsLabel(file, theme);
163
+ return `${fileMarkLabel(file, theme)} ${filePathLabel(file, theme)}${counts ? ` ${counts}` : ""}`;
164
+ }
165
+
166
+ function countLines(file: PatchPreviewFile, type: PatchLineType): number {
167
+ let n = 0;
168
+ for (const line of file.lines) if (line.type === type) n++;
169
+ return n;
170
+ }
171
+
172
+ /**
173
+ * Render one glimpse line directly. apply_patch has no real line numbers (only `@@` anchors), so
174
+ * we color +/- lines without fabricating numbers, and cap each line's width.
175
+ */
176
+ function renderGlimpseLine(line: PatchPreviewLine, theme: Theme): string {
177
+ const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
178
+ const body = line.text.length > PREVIEW_LINE_CHARS ? `${line.text.slice(0, PREVIEW_LINE_CHARS)}…` : line.text;
179
+ const styled = `${sign}${body}`;
180
+ if (line.type === "add") return theme.fg("toolDiffAdded", styled);
181
+ if (line.type === "del") return theme.fg("toolDiffRemoved", styled);
182
+ return theme.fg("toolDiffContext", styled);
183
+ }
184
+
185
+ function focusFile(files: PatchPreviewFile[]): PatchPreviewFile | undefined {
186
+ // Prefer the most recent file that has visible content to glimpse; fall back to the last file.
187
+ for (let index = files.length - 1; index >= 0; index--) {
188
+ const file = files[index];
189
+ if (file.kind !== "delete" && file.lines.length > 0) return file;
190
+ }
191
+ return files.at(-1);
192
+ }
193
+
194
+ /** Cap a rendered path: keep the tail (filename/extension) since that is the meaningful part. */
195
+ function truncatePath(path: string): string {
196
+ return path.length > PREVIEW_LINE_CHARS ? `…${path.slice(path.length - PREVIEW_LINE_CHARS + 1)}` : path;
197
+ }
198
+
199
+ /** Format the live apply_patch tool-call text (streaming glimpse + tally). */
200
+ export function formatApplyPatchCallText(rawPatch: string, theme: Theme, options: { expanded: boolean }): string {
201
+ const preview = scanPatchPreview(rawPatch);
202
+ const title = theme.fg("toolTitle", theme.bold("apply_patch"));
203
+ if (preview.files.length === 0) return title;
204
+
205
+ let text: string;
206
+ if (preview.files.length === 1) {
207
+ // Single file: lead with the status mark + path (like the built-in `write` tool, with the
208
+ // file's A/M/D status so a delete is distinguishable from an empty in-progress update).
209
+ const file = preview.files[0];
210
+ const counts = fileCountsLabel(file, theme);
211
+ text = `${title} ${fileMarkLabel(file, theme)} ${filePathLabel(file, theme)}${counts ? ` ${counts}` : ""}`;
212
+ } else {
213
+ const tally = `${theme.fg("toolDiffAdded", `+${preview.totalAdded}`)} ${theme.fg("toolDiffRemoved", `-${preview.totalRemoved}`)} ${theme.fg("muted", `· ${preview.files.length} files`)}`;
214
+ // Cap the collapsed roster so a multi-hundred-file patch cannot re-render hundreds of lines
215
+ // every token; expansion reveals the rest (up to the preview file cap).
216
+ const maxRoster = options.expanded ? preview.files.length : PREVIEW_LINES_COLLAPSED;
217
+ const visible = preview.files.slice(0, maxRoster);
218
+ let roster = visible.map((file) => formatFileRosterLine(file, theme)).join("\n");
219
+ const hidden = preview.files.length - visible.length;
220
+ if (hidden > 0) roster += `\n${theme.fg("muted", `… ${hidden} more files`)}`;
221
+ text = `${title} ${tally}\n${roster}`;
222
+ }
223
+
224
+ const focus = focusFile(preview.files);
225
+ if (focus && focus.kind !== "delete" && focus.lines.length > 0) {
226
+ // Even expanded, cap rendered output so a huge file cannot stall the terminal.
227
+ const maxLines = options.expanded ? PREVIEW_LINES_EXPANDED : PREVIEW_LINES_COLLAPSED;
228
+ const visible = focus.lines.slice(0, maxLines);
229
+ const remaining = focus.lines.length - maxLines;
230
+ let body = visible.map((line) => renderGlimpseLine(line, theme)).join("\n");
231
+ if (remaining > 0) {
232
+ body += theme.fg(
233
+ "muted",
234
+ `\n... (${remaining} more lines, ${focus.lines.length} total, ${keyHint("app.tools.expand", "to expand")})`,
235
+ );
236
+ }
237
+ // In multi-file previews the focused file may be hidden behind the capped roster, so label the
238
+ // glimpse with its own row (the single-file header already carries the path).
239
+ const label = preview.files.length > 1 ? `${formatFileRosterLine(focus, theme)}\n` : "";
240
+ text += `\n\n${label}${body}`;
241
+ }
242
+
243
+ if (preview.truncated) {
244
+ text += `\n${theme.fg("muted", "(large patch; preview truncated)")}`;
245
+ }
246
+ return text;
247
+ }
248
+
249
+ /**
250
+ * Format the apply_patch tool-result text. On success the streaming glimpse already conveys the
251
+ * change, so nothing is added (mirrors the built-in `write` tool). On error the message is shown.
252
+ */
253
+ export function formatApplyPatchResultText(
254
+ result: { content: Array<{ type: string; text?: string }> },
255
+ theme: Theme,
256
+ isError: boolean,
257
+ ): string | undefined {
258
+ if (!isError) return undefined;
259
+ const output = result.content
260
+ .filter((part) => part.type === "text")
261
+ .map((part) => part.text ?? "")
262
+ .join("\n")
263
+ .trim();
264
+ return output ? theme.fg("error", output) : undefined;
265
+ }