pi-codex-tools 0.1.1 → 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,12 @@ 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
+
9
15
  ## [0.1.1] - 2026-08-04
10
16
 
11
17
  ### Fixed
package/README.md CHANGED
@@ -9,6 +9,7 @@ Give grammar-capable OpenAI/Codex models the Codex `apply_patch` tool in Pi with
9
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
 
@@ -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,6 +49,31 @@ 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) {
39
78
  if (!supportsOpenAIGrammarTools(ctx.model)) {
40
79
  throw new Error("apply_patch is only available for OpenAI models that advertise grammar-tool support.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-tools",
3
- "version": "0.1.1",
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": {
@@ -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
+ }