@trim21/personal-pi-extensions 0.0.138 → 0.0.140

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/README.md CHANGED
@@ -85,7 +85,7 @@ pi -e ./src/bwrap/index.ts
85
85
  阻止 `write` 和 `edit` 工具写入 workspace 外部的路径。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
86
86
 
87
87
  - workspace 内或 `/tmp` 下的路径自动放行
88
- - 外部路径需通过确认对话框由用户审批
88
+ - 外部路径需通过确认对话框由用户审批,对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff)
89
89
  - 无需配置
90
90
 
91
91
  ### 使用
@@ -98,7 +98,7 @@ pi -e ./src/workspace-guard.ts
98
98
 
99
99
  ## opencode-edit
100
100
 
101
- 替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode,行为与原版完全一致。
101
+ 替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode,行为与原版完全一致。匹配引擎位于 `src/opencode-edit-engine.ts`,与 workspace-guard 的审批弹窗 diff 预览共享。
102
102
 
103
103
  支持的匹配策略:
104
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.138",
3
+ "version": "0.0.140",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -171,60 +171,6 @@ export class GhError extends Error {
171
171
  }
172
172
  }
173
173
 
174
- /** How long `read-github-pr-status` waits for pending checks to resolve. */
175
- const POLL_INTERVAL_MS = 30_000;
176
- const POLL_TIMEOUT_MS = 30 * 60_000;
177
-
178
- /** Sleep for `ms`, resolving early if `signal` is aborted. */
179
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
180
- return new Promise((resolve) => {
181
- if (signal?.aborted) {
182
- resolve();
183
- return;
184
- }
185
- const onAbort = () => {
186
- clearTimeout(timer);
187
- resolve();
188
- };
189
- const timer = setTimeout(() => {
190
- signal?.removeEventListener("abort", onAbort);
191
- resolve();
192
- }, ms);
193
- signal?.addEventListener("abort", onAbort, { once: true });
194
- });
195
- }
196
-
197
- /**
198
- * Poll a `gh pr checks` query until it reaches a final state.
199
- *
200
- * `gh pr checks` exit codes: 0 = all passed, 1 = some failed, 8 = some pending.
201
- * Pending (8) is polled every `intervalMs` until `timeoutMs` elapses, at which
202
- * point the current result is returned as-is. Any other code is returned
203
- * immediately; the caller decides whether it is an error.
204
- */
205
- export async function pollChecksResult<R extends { code: number; stdout: string }>(
206
- query: () => Promise<R>,
207
- opts: { signal?: AbortSignal; intervalMs?: number; timeoutMs?: number } = {},
208
- ): Promise<R> {
209
- const { signal, intervalMs = POLL_INTERVAL_MS, timeoutMs = POLL_TIMEOUT_MS } = opts;
210
- const deadline = Date.now() + timeoutMs;
211
- for (;;) {
212
- if (signal?.aborted) {
213
- throw new Error("read-github-pr-status aborted");
214
- }
215
- const result = await query();
216
- if (result.code !== 8) {
217
- // 0 = all passed, 1 = some failed, anything else is a real error
218
- return result;
219
- }
220
- // Still pending — keep waiting unless the overall timeout expired
221
- if (Date.now() >= deadline) {
222
- return result;
223
- }
224
- await sleep(intervalMs, signal);
225
- }
226
- }
227
-
228
174
  /** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
229
175
  export async function ghExec(
230
176
  args: string[],
@@ -1035,7 +981,8 @@ export default function (pi: ExtensionAPI) {
1035
981
  pi.registerTool({
1036
982
  name: "read-github-pr-status",
1037
983
  label: "GitHub PR Status",
1038
- description: "Get status checks and CI results for a GitHub pull request.",
984
+ description:
985
+ "Get the current status checks and CI results for a GitHub pull request. Returns the current snapshot immediately; pending checks are reported as-is, not waited on. Use wait-github-pr-checks to block until checks finish.",
1039
986
  promptSnippet: "Read GitHub PR status checks",
1040
987
  parameters: Type.Object({
1041
988
  number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
@@ -1045,16 +992,16 @@ export default function (pi: ExtensionAPI) {
1045
992
  const { number, repo } = params;
1046
993
  const args = ["pr", "checks", String(number), ...repoArgs(repo)];
1047
994
 
1048
- // Pending is not an error poll until checks fail or all pass.
1049
- const final = await pollChecksResult(() => runGh(args, { cwd: ctx.cwd, signal }), {
1050
- signal,
1051
- });
995
+ // `gh pr checks` exit codes: 0 = all passed, 1 = some failed, 8 = some
996
+ // pending. All three are valid states return the current snapshot
997
+ // as-is without waiting. `wait-github-pr-checks` is the blocking variant.
998
+ const result = await runGh(args, { cwd: ctx.cwd, signal });
1052
999
 
1053
- if (final.code !== 0 && final.code !== 1 && final.code !== 8) {
1000
+ if (result.code !== 0 && result.code !== 1 && result.code !== 8) {
1054
1001
  // Anything else is a real error (cancelled, auth, network, ...)
1055
- throw new GhError(args, final, params);
1002
+ throw new GhError(args, result, params);
1056
1003
  }
1057
- return toToolResult(final.stdout, params);
1004
+ return toToolResult(result.stdout, params);
1058
1005
  },
1059
1006
  });
1060
1007
 
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Opencode edit matching engine.
3
+ *
4
+ * The core replacers and replace() function are copied directly from
5
+ * https://github.com/anomalyco/opencode (packages/opencode/src/tool/edit.ts)
6
+ * and wrapped in a pi extension so the behaviour is identical to opencode.
7
+ *
8
+ * Shared by:
9
+ * - opencode-edit.ts — the edit tool implementation
10
+ * - workspace-guard.ts — the diff preview shown in the approval dialog
11
+ */
12
+
13
+ // ── BOM & line ending helpers ─────────────────────────────────────────────────
14
+
15
+ export function stripBom(content: string): { bom: string; text: string } {
16
+ return content.startsWith("\uFEFF")
17
+ ? { bom: "\uFEFF", text: content.slice(1) }
18
+ : { bom: "", text: content };
19
+ }
20
+
21
+ export function detectLineEnding(content: string): "\r\n" | "\n" {
22
+ return content.includes("\r\n") ? "\r\n" : "\n";
23
+ }
24
+
25
+ export function normalizeToLF(text: string): string {
26
+ return text.replaceAll("\r\n", "\n");
27
+ }
28
+
29
+ export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
30
+ return ending === "\r\n" ? text.replaceAll("\n", "\r\n") : text;
31
+ }
32
+
33
+ /** Strip BOM and normalize line endings to LF (what the replacers expect). */
34
+ export function normalizeForEdit(content: string): string {
35
+ const { text } = stripBom(content);
36
+ return normalizeToLF(text);
37
+ }
38
+
39
+ // ── copied from opencode ──────────────────────────────────────────────────────
40
+
41
+ function levenshtein(a: string, b: string): number {
42
+ if (a === "" || b === "") {
43
+ return Math.max(a.length, b.length);
44
+ }
45
+ const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
46
+ Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
47
+ );
48
+ for (let i = 1; i <= a.length; i++) {
49
+ for (let j = 1; j <= b.length; j++) {
50
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
51
+ matrix[i][j] = Math.min(
52
+ matrix[i - 1][j] + 1,
53
+ matrix[i][j - 1] + 1,
54
+ matrix[i - 1][j - 1] + cost,
55
+ );
56
+ }
57
+ }
58
+ return matrix[a.length][b.length];
59
+ }
60
+
61
+ type Replacer = (content: string, find: string) => Generator<string, void, unknown>;
62
+
63
+ const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65;
64
+ const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65;
65
+
66
+ const SimpleReplacer: Replacer = function* (_content, find) {
67
+ yield find;
68
+ };
69
+
70
+ const LineTrimmedReplacer: Replacer = function* (content, find) {
71
+ const originalLines = content.split("\n");
72
+ const searchLines = find.split("\n");
73
+ if (searchLines[searchLines.length - 1] === "") {
74
+ searchLines.pop();
75
+ }
76
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
77
+ let matches = true;
78
+ for (let j = 0; j < searchLines.length; j++) {
79
+ const originalTrimmed = originalLines[i + j].trim();
80
+ const searchTrimmed = searchLines[j].trim();
81
+ if (originalTrimmed !== searchTrimmed) {
82
+ matches = false;
83
+ break;
84
+ }
85
+ }
86
+ if (matches) {
87
+ let matchStartIndex = 0;
88
+ for (let k = 0; k < i; k++) {
89
+ matchStartIndex += originalLines[k].length + 1;
90
+ }
91
+ let matchEndIndex = matchStartIndex;
92
+ for (let k = 0; k < searchLines.length; k++) {
93
+ matchEndIndex += originalLines[i + k].length;
94
+ if (k < searchLines.length - 1) {
95
+ matchEndIndex += 1;
96
+ }
97
+ }
98
+ yield content.substring(matchStartIndex, matchEndIndex);
99
+ }
100
+ }
101
+ };
102
+
103
+ const BlockAnchorReplacer: Replacer = function* (content, find) {
104
+ const originalLines = content.split("\n");
105
+ const searchLines = find.split("\n");
106
+ if (searchLines.length < 3) {
107
+ return;
108
+ }
109
+ if (searchLines[searchLines.length - 1] === "") {
110
+ searchLines.pop();
111
+ }
112
+ const firstLineSearch = searchLines[0].trim();
113
+ const lastLineSearch = searchLines[searchLines.length - 1].trim();
114
+ const searchBlockSize = searchLines.length;
115
+ const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25));
116
+
117
+ const candidates: Array<{ startLine: number; endLine: number }> = [];
118
+ for (let i = 0; i < originalLines.length; i++) {
119
+ if (originalLines[i].trim() !== firstLineSearch) {
120
+ continue;
121
+ }
122
+ for (let j = i + 2; j < originalLines.length; j++) {
123
+ if (originalLines[j].trim() === lastLineSearch) {
124
+ const actualBlockSize = j - i + 1;
125
+ if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
126
+ candidates.push({ startLine: i, endLine: j });
127
+ }
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (candidates.length === 0) {
133
+ return;
134
+ }
135
+
136
+ if (candidates.length === 1) {
137
+ const { startLine, endLine } = candidates[0];
138
+ const actualBlockSize = endLine - startLine + 1;
139
+ let similarity = 0;
140
+ const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
141
+ if (linesToCheck > 0) {
142
+ for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
143
+ const originalLine = originalLines[startLine + j].trim();
144
+ const searchLine = searchLines[j].trim();
145
+ const maxLen = Math.max(originalLine.length, searchLine.length);
146
+ if (maxLen === 0) {
147
+ continue;
148
+ }
149
+ const distance = levenshtein(originalLine, searchLine);
150
+ similarity += (1 - distance / maxLen) / linesToCheck;
151
+ if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
152
+ break;
153
+ }
154
+ }
155
+ } else {
156
+ similarity = 1.0;
157
+ }
158
+ if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
159
+ let matchStartIndex = 0;
160
+ for (let k = 0; k < startLine; k++) {
161
+ matchStartIndex += originalLines[k].length + 1;
162
+ }
163
+ let matchEndIndex = matchStartIndex;
164
+ for (let k = startLine; k <= endLine; k++) {
165
+ matchEndIndex += originalLines[k].length;
166
+ if (k < endLine) {
167
+ matchEndIndex += 1;
168
+ }
169
+ }
170
+ yield content.substring(matchStartIndex, matchEndIndex);
171
+ }
172
+ return;
173
+ }
174
+
175
+ let bestMatch: { startLine: number; endLine: number } | null = null;
176
+ let maxSimilarity = -1;
177
+ for (const candidate of candidates) {
178
+ const { startLine, endLine } = candidate;
179
+ const actualBlockSize = endLine - startLine + 1;
180
+ let similarity = 0;
181
+ const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
182
+ if (linesToCheck > 0) {
183
+ for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
184
+ const originalLine = originalLines[startLine + j].trim();
185
+ const searchLine = searchLines[j].trim();
186
+ const maxLen = Math.max(originalLine.length, searchLine.length);
187
+ if (maxLen === 0) {
188
+ continue;
189
+ }
190
+ const distance = levenshtein(originalLine, searchLine);
191
+ similarity += 1 - distance / maxLen;
192
+ }
193
+ similarity /= linesToCheck;
194
+ } else {
195
+ similarity = 1.0;
196
+ }
197
+ if (similarity > maxSimilarity) {
198
+ maxSimilarity = similarity;
199
+ bestMatch = candidate;
200
+ }
201
+ }
202
+ if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
203
+ const { startLine, endLine } = bestMatch;
204
+ let matchStartIndex = 0;
205
+ for (let k = 0; k < startLine; k++) {
206
+ matchStartIndex += originalLines[k].length + 1;
207
+ }
208
+ let matchEndIndex = matchStartIndex;
209
+ for (let k = startLine; k <= endLine; k++) {
210
+ matchEndIndex += originalLines[k].length;
211
+ if (k < endLine) {
212
+ matchEndIndex += 1;
213
+ }
214
+ }
215
+ yield content.substring(matchStartIndex, matchEndIndex);
216
+ }
217
+ };
218
+
219
+ const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
220
+ const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
221
+ const normalizedFind = normalizeWhitespace(find);
222
+ const lines = content.split("\n");
223
+ for (let i = 0; i < lines.length; i++) {
224
+ const line = lines[i];
225
+ if (normalizeWhitespace(line) === normalizedFind) {
226
+ yield line;
227
+ } else {
228
+ const normalizedLine = normalizeWhitespace(line);
229
+ if (normalizedLine.includes(normalizedFind)) {
230
+ const words = find.trim().split(/\s+/);
231
+ if (words.length > 0) {
232
+ const pattern = words
233
+ .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
234
+ .join("\\s+");
235
+ try {
236
+ const regex = new RegExp(pattern);
237
+ const match = line.match(regex);
238
+ if (match) {
239
+ yield match[0];
240
+ }
241
+ } catch {
242
+ // Invalid regex pattern, skip
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+ const findLines = find.split("\n");
249
+ if (findLines.length > 1) {
250
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
251
+ const block = lines.slice(i, i + findLines.length);
252
+ if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
253
+ yield block.join("\n");
254
+ }
255
+ }
256
+ }
257
+ };
258
+
259
+ const IndentationFlexibleReplacer: Replacer = function* (content, find) {
260
+ const removeIndentation = (text: string) => {
261
+ const lines = text.split("\n");
262
+ const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
263
+ if (nonEmptyLines.length === 0) return text;
264
+ const minIndent = Math.min(
265
+ ...nonEmptyLines.map((line) => {
266
+ const match = line.match(/^(\s*)/);
267
+ return match ? match[1].length : 0;
268
+ }),
269
+ );
270
+ return lines
271
+ .map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
272
+ .join("\n");
273
+ };
274
+ const normalizedFind = removeIndentation(find);
275
+ const contentLines = content.split("\n");
276
+ const findLines = find.split("\n");
277
+ for (let i = 0; i <= contentLines.length - findLines.length; i++) {
278
+ const block = contentLines.slice(i, i + findLines.length).join("\n");
279
+ if (removeIndentation(block) === normalizedFind) {
280
+ yield block;
281
+ }
282
+ }
283
+ };
284
+
285
+ const EscapeNormalizedReplacer: Replacer = function* (content, find) {
286
+ const unescapeString = (str: string): string => {
287
+ return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_match, capturedChar) => {
288
+ switch (capturedChar) {
289
+ case "n":
290
+ return "\n";
291
+ case "t":
292
+ return "\t";
293
+ case "r":
294
+ return "\r";
295
+ case "'":
296
+ return "'";
297
+ case '"':
298
+ return '"';
299
+ case "`":
300
+ return "`";
301
+ case "\\":
302
+ return "\\";
303
+ case "\n":
304
+ return "\n";
305
+ case "$":
306
+ return "$";
307
+ default:
308
+ return _match;
309
+ }
310
+ });
311
+ };
312
+ const unescapedFind = unescapeString(find);
313
+ if (content.includes(unescapedFind)) {
314
+ yield unescapedFind;
315
+ }
316
+ const lines = content.split("\n");
317
+ const findLines = unescapedFind.split("\n");
318
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
319
+ const block = lines.slice(i, i + findLines.length).join("\n");
320
+ const unescapedBlock = unescapeString(block);
321
+ if (unescapedBlock === unescapedFind) {
322
+ yield block;
323
+ }
324
+ }
325
+ };
326
+
327
+ const MultiOccurrenceReplacer: Replacer = function* (content, find) {
328
+ let startIndex = 0;
329
+ while (true) {
330
+ const index = content.indexOf(find, startIndex);
331
+ if (index === -1) break;
332
+ yield find;
333
+ startIndex = index + find.length;
334
+ }
335
+ };
336
+
337
+ const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
338
+ const trimmedFind = find.trim();
339
+ if (trimmedFind === find) {
340
+ return;
341
+ }
342
+ if (content.includes(trimmedFind)) {
343
+ yield trimmedFind;
344
+ }
345
+ const lines = content.split("\n");
346
+ const findLines = find.split("\n");
347
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
348
+ const block = lines.slice(i, i + findLines.length).join("\n");
349
+ if (block.trim() === trimmedFind) {
350
+ yield block;
351
+ }
352
+ }
353
+ };
354
+
355
+ const ContextAwareReplacer: Replacer = function* (content, find) {
356
+ const findLines = find.split("\n");
357
+ if (findLines.length < 3) {
358
+ return;
359
+ }
360
+ if (findLines[findLines.length - 1] === "") {
361
+ findLines.pop();
362
+ }
363
+ const contentLines = content.split("\n");
364
+ const firstLine = findLines[0].trim();
365
+ const lastLine = findLines[findLines.length - 1].trim();
366
+ for (let i = 0; i < contentLines.length; i++) {
367
+ if (contentLines[i].trim() !== firstLine) continue;
368
+ for (let j = i + 2; j < contentLines.length; j++) {
369
+ if (contentLines[j].trim() === lastLine) {
370
+ const blockLines = contentLines.slice(i, j + 1);
371
+ const block = blockLines.join("\n");
372
+ if (blockLines.length === findLines.length) {
373
+ let matchingLines = 0;
374
+ let totalNonEmptyLines = 0;
375
+ for (let k = 1; k < blockLines.length - 1; k++) {
376
+ const blockLine = blockLines[k].trim();
377
+ const findLine = findLines[k].trim();
378
+ if (blockLine.length > 0 || findLine.length > 0) {
379
+ totalNonEmptyLines++;
380
+ if (blockLine === findLine) {
381
+ matchingLines++;
382
+ }
383
+ }
384
+ }
385
+ if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
386
+ yield block;
387
+ break;
388
+ }
389
+ }
390
+ break;
391
+ }
392
+ }
393
+ }
394
+ };
395
+
396
+ function isDisproportionateMatch(search: string, oldString: string) {
397
+ const oldLines = oldString.split("\n").length;
398
+ const searchLines = search.split("\n").length;
399
+ if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true;
400
+ if (oldLines === 1) return false;
401
+ return (
402
+ search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
403
+ );
404
+ }
405
+
406
+ /**
407
+ * Replace `oldString` with `newString` in `content`, using opencode's matching
408
+ * engine. Expects LF-normalized content (see `normalizeForEdit`).
409
+ * Throws when oldString cannot be matched or the match is ambiguous.
410
+ */
411
+ export function replace(
412
+ content: string,
413
+ oldString: string,
414
+ newString: string,
415
+ replaceAll = false,
416
+ ): string {
417
+ if (oldString === newString) {
418
+ throw new Error("No changes to apply: oldString and newString are identical.");
419
+ }
420
+ if (oldString === "") {
421
+ throw new Error(
422
+ "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
423
+ );
424
+ }
425
+
426
+ let notFound = true;
427
+
428
+ for (const replacer of [
429
+ SimpleReplacer,
430
+ LineTrimmedReplacer,
431
+ BlockAnchorReplacer,
432
+ WhitespaceNormalizedReplacer,
433
+ IndentationFlexibleReplacer,
434
+ EscapeNormalizedReplacer,
435
+ TrimmedBoundaryReplacer,
436
+ ContextAwareReplacer,
437
+ MultiOccurrenceReplacer,
438
+ ]) {
439
+ for (const search of replacer(content, oldString)) {
440
+ const index = content.indexOf(search);
441
+ if (index === -1) continue;
442
+ notFound = false;
443
+ if (isDisproportionateMatch(search, oldString)) {
444
+ throw new Error(
445
+ "Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.",
446
+ );
447
+ }
448
+ if (replaceAll) {
449
+ return content.replaceAll(search, newString);
450
+ }
451
+ const lastIndex = content.lastIndexOf(search);
452
+ if (index !== lastIndex) continue;
453
+ return content.substring(0, index) + newString + content.substring(index + search.length);
454
+ }
455
+ }
456
+
457
+ if (notFound) {
458
+ throw new Error(
459
+ "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
460
+ );
461
+ }
462
+ throw new Error(
463
+ "Found multiple matches for oldString. Provide more surrounding context to make the match unique.",
464
+ );
465
+ }
@@ -2,9 +2,8 @@
2
2
  * Opencode Edit Extension — Replaces the built-in edit tool with opencode's
3
3
  * schema and matching engine.
4
4
  *
5
- * The core replacers and replace() function are copied directly from
6
- * https://github.com/anomalyco/opencode (packages/opencode/src/tool/edit.ts)
7
- * and wrapped in a pi extension so the behaviour is identical to opencode.
5
+ * The matching engine (replacers + replace()) lives in opencode-edit-engine.ts
6
+ * and is also used by workspace-guard for the diff preview.
8
7
  *
9
8
  * Usage:
10
9
  * pi -e ./opencode-edit.ts
@@ -16,6 +15,13 @@ import {
16
15
  generateUnifiedPatch,
17
16
  withFileMutationQueue,
18
17
  } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ detectLineEnding,
20
+ normalizeToLF,
21
+ replace,
22
+ restoreLineEndings,
23
+ stripBom,
24
+ } from "./opencode-edit-engine.js";
19
25
  import { constants } from "fs";
20
26
  import { access, readFile, writeFile } from "fs/promises";
21
27
  import { isAbsolute, resolve } from "path";
@@ -34,449 +40,6 @@ const editSchema = Type.Object({
34
40
  ),
35
41
  });
36
42
 
37
- // ── BOM & line ending helpers ─────────────────────────────────────────────────
38
-
39
- function stripBom(content: string): { bom: string; text: string } {
40
- return content.startsWith("\uFEFF")
41
- ? { bom: "\uFEFF", text: content.slice(1) }
42
- : { bom: "", text: content };
43
- }
44
-
45
- function detectLineEnding(content: string): "\r\n" | "\n" {
46
- return content.includes("\r\n") ? "\r\n" : "\n";
47
- }
48
-
49
- function normalizeToLF(text: string): string {
50
- return text.replaceAll("\r\n", "\n");
51
- }
52
-
53
- function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
54
- return ending === "\r\n" ? text.replaceAll("\n", "\r\n") : text;
55
- }
56
-
57
- // ── copied from opencode ──────────────────────────────────────────────────────
58
-
59
- function levenshtein(a: string, b: string): number {
60
- if (a === "" || b === "") {
61
- return Math.max(a.length, b.length);
62
- }
63
- const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
64
- Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
65
- );
66
- for (let i = 1; i <= a.length; i++) {
67
- for (let j = 1; j <= b.length; j++) {
68
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
69
- matrix[i][j] = Math.min(
70
- matrix[i - 1][j] + 1,
71
- matrix[i][j - 1] + 1,
72
- matrix[i - 1][j - 1] + cost,
73
- );
74
- }
75
- }
76
- return matrix[a.length][b.length];
77
- }
78
-
79
- type Replacer = (content: string, find: string) => Generator<string, void, unknown>;
80
-
81
- const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65;
82
- const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65;
83
-
84
- const SimpleReplacer: Replacer = function* (_content, find) {
85
- yield find;
86
- };
87
-
88
- const LineTrimmedReplacer: Replacer = function* (content, find) {
89
- const originalLines = content.split("\n");
90
- const searchLines = find.split("\n");
91
- if (searchLines[searchLines.length - 1] === "") {
92
- searchLines.pop();
93
- }
94
- for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
95
- let matches = true;
96
- for (let j = 0; j < searchLines.length; j++) {
97
- const originalTrimmed = originalLines[i + j].trim();
98
- const searchTrimmed = searchLines[j].trim();
99
- if (originalTrimmed !== searchTrimmed) {
100
- matches = false;
101
- break;
102
- }
103
- }
104
- if (matches) {
105
- let matchStartIndex = 0;
106
- for (let k = 0; k < i; k++) {
107
- matchStartIndex += originalLines[k].length + 1;
108
- }
109
- let matchEndIndex = matchStartIndex;
110
- for (let k = 0; k < searchLines.length; k++) {
111
- matchEndIndex += originalLines[i + k].length;
112
- if (k < searchLines.length - 1) {
113
- matchEndIndex += 1;
114
- }
115
- }
116
- yield content.substring(matchStartIndex, matchEndIndex);
117
- }
118
- }
119
- };
120
-
121
- const BlockAnchorReplacer: Replacer = function* (content, find) {
122
- const originalLines = content.split("\n");
123
- const searchLines = find.split("\n");
124
- if (searchLines.length < 3) {
125
- return;
126
- }
127
- if (searchLines[searchLines.length - 1] === "") {
128
- searchLines.pop();
129
- }
130
- const firstLineSearch = searchLines[0].trim();
131
- const lastLineSearch = searchLines[searchLines.length - 1].trim();
132
- const searchBlockSize = searchLines.length;
133
- const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25));
134
-
135
- const candidates: Array<{ startLine: number; endLine: number }> = [];
136
- for (let i = 0; i < originalLines.length; i++) {
137
- if (originalLines[i].trim() !== firstLineSearch) {
138
- continue;
139
- }
140
- for (let j = i + 2; j < originalLines.length; j++) {
141
- if (originalLines[j].trim() === lastLineSearch) {
142
- const actualBlockSize = j - i + 1;
143
- if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
144
- candidates.push({ startLine: i, endLine: j });
145
- }
146
- break;
147
- }
148
- }
149
- }
150
- if (candidates.length === 0) {
151
- return;
152
- }
153
-
154
- if (candidates.length === 1) {
155
- const { startLine, endLine } = candidates[0];
156
- const actualBlockSize = endLine - startLine + 1;
157
- let similarity = 0;
158
- const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
159
- if (linesToCheck > 0) {
160
- for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
161
- const originalLine = originalLines[startLine + j].trim();
162
- const searchLine = searchLines[j].trim();
163
- const maxLen = Math.max(originalLine.length, searchLine.length);
164
- if (maxLen === 0) {
165
- continue;
166
- }
167
- const distance = levenshtein(originalLine, searchLine);
168
- similarity += (1 - distance / maxLen) / linesToCheck;
169
- if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
170
- break;
171
- }
172
- }
173
- } else {
174
- similarity = 1.0;
175
- }
176
- if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
177
- let matchStartIndex = 0;
178
- for (let k = 0; k < startLine; k++) {
179
- matchStartIndex += originalLines[k].length + 1;
180
- }
181
- let matchEndIndex = matchStartIndex;
182
- for (let k = startLine; k <= endLine; k++) {
183
- matchEndIndex += originalLines[k].length;
184
- if (k < endLine) {
185
- matchEndIndex += 1;
186
- }
187
- }
188
- yield content.substring(matchStartIndex, matchEndIndex);
189
- }
190
- return;
191
- }
192
-
193
- let bestMatch: { startLine: number; endLine: number } | null = null;
194
- let maxSimilarity = -1;
195
- for (const candidate of candidates) {
196
- const { startLine, endLine } = candidate;
197
- const actualBlockSize = endLine - startLine + 1;
198
- let similarity = 0;
199
- const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
200
- if (linesToCheck > 0) {
201
- for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
202
- const originalLine = originalLines[startLine + j].trim();
203
- const searchLine = searchLines[j].trim();
204
- const maxLen = Math.max(originalLine.length, searchLine.length);
205
- if (maxLen === 0) {
206
- continue;
207
- }
208
- const distance = levenshtein(originalLine, searchLine);
209
- similarity += 1 - distance / maxLen;
210
- }
211
- similarity /= linesToCheck;
212
- } else {
213
- similarity = 1.0;
214
- }
215
- if (similarity > maxSimilarity) {
216
- maxSimilarity = similarity;
217
- bestMatch = candidate;
218
- }
219
- }
220
- if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
221
- const { startLine, endLine } = bestMatch;
222
- let matchStartIndex = 0;
223
- for (let k = 0; k < startLine; k++) {
224
- matchStartIndex += originalLines[k].length + 1;
225
- }
226
- let matchEndIndex = matchStartIndex;
227
- for (let k = startLine; k <= endLine; k++) {
228
- matchEndIndex += originalLines[k].length;
229
- if (k < endLine) {
230
- matchEndIndex += 1;
231
- }
232
- }
233
- yield content.substring(matchStartIndex, matchEndIndex);
234
- }
235
- };
236
-
237
- const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
238
- const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
239
- const normalizedFind = normalizeWhitespace(find);
240
- const lines = content.split("\n");
241
- for (let i = 0; i < lines.length; i++) {
242
- const line = lines[i];
243
- if (normalizeWhitespace(line) === normalizedFind) {
244
- yield line;
245
- } else {
246
- const normalizedLine = normalizeWhitespace(line);
247
- if (normalizedLine.includes(normalizedFind)) {
248
- const words = find.trim().split(/\s+/);
249
- if (words.length > 0) {
250
- const pattern = words
251
- .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
252
- .join("\\s+");
253
- try {
254
- const regex = new RegExp(pattern);
255
- const match = line.match(regex);
256
- if (match) {
257
- yield match[0];
258
- }
259
- } catch {
260
- // Invalid regex pattern, skip
261
- }
262
- }
263
- }
264
- }
265
- }
266
- const findLines = find.split("\n");
267
- if (findLines.length > 1) {
268
- for (let i = 0; i <= lines.length - findLines.length; i++) {
269
- const block = lines.slice(i, i + findLines.length);
270
- if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
271
- yield block.join("\n");
272
- }
273
- }
274
- }
275
- };
276
-
277
- const IndentationFlexibleReplacer: Replacer = function* (content, find) {
278
- const removeIndentation = (text: string) => {
279
- const lines = text.split("\n");
280
- const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
281
- if (nonEmptyLines.length === 0) return text;
282
- const minIndent = Math.min(
283
- ...nonEmptyLines.map((line) => {
284
- const match = line.match(/^(\s*)/);
285
- return match ? match[1].length : 0;
286
- }),
287
- );
288
- return lines
289
- .map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
290
- .join("\n");
291
- };
292
- const normalizedFind = removeIndentation(find);
293
- const contentLines = content.split("\n");
294
- const findLines = find.split("\n");
295
- for (let i = 0; i <= contentLines.length - findLines.length; i++) {
296
- const block = contentLines.slice(i, i + findLines.length).join("\n");
297
- if (removeIndentation(block) === normalizedFind) {
298
- yield block;
299
- }
300
- }
301
- };
302
-
303
- const EscapeNormalizedReplacer: Replacer = function* (content, find) {
304
- const unescapeString = (str: string): string => {
305
- return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_match, capturedChar) => {
306
- switch (capturedChar) {
307
- case "n":
308
- return "\n";
309
- case "t":
310
- return "\t";
311
- case "r":
312
- return "\r";
313
- case "'":
314
- return "'";
315
- case '"':
316
- return '"';
317
- case "`":
318
- return "`";
319
- case "\\":
320
- return "\\";
321
- case "\n":
322
- return "\n";
323
- case "$":
324
- return "$";
325
- default:
326
- return _match;
327
- }
328
- });
329
- };
330
- const unescapedFind = unescapeString(find);
331
- if (content.includes(unescapedFind)) {
332
- yield unescapedFind;
333
- }
334
- const lines = content.split("\n");
335
- const findLines = unescapedFind.split("\n");
336
- for (let i = 0; i <= lines.length - findLines.length; i++) {
337
- const block = lines.slice(i, i + findLines.length).join("\n");
338
- const unescapedBlock = unescapeString(block);
339
- if (unescapedBlock === unescapedFind) {
340
- yield block;
341
- }
342
- }
343
- };
344
-
345
- const MultiOccurrenceReplacer: Replacer = function* (content, find) {
346
- let startIndex = 0;
347
- while (true) {
348
- const index = content.indexOf(find, startIndex);
349
- if (index === -1) break;
350
- yield find;
351
- startIndex = index + find.length;
352
- }
353
- };
354
-
355
- const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
356
- const trimmedFind = find.trim();
357
- if (trimmedFind === find) {
358
- return;
359
- }
360
- if (content.includes(trimmedFind)) {
361
- yield trimmedFind;
362
- }
363
- const lines = content.split("\n");
364
- const findLines = find.split("\n");
365
- for (let i = 0; i <= lines.length - findLines.length; i++) {
366
- const block = lines.slice(i, i + findLines.length).join("\n");
367
- if (block.trim() === trimmedFind) {
368
- yield block;
369
- }
370
- }
371
- };
372
-
373
- const ContextAwareReplacer: Replacer = function* (content, find) {
374
- const findLines = find.split("\n");
375
- if (findLines.length < 3) {
376
- return;
377
- }
378
- if (findLines[findLines.length - 1] === "") {
379
- findLines.pop();
380
- }
381
- const contentLines = content.split("\n");
382
- const firstLine = findLines[0].trim();
383
- const lastLine = findLines[findLines.length - 1].trim();
384
- for (let i = 0; i < contentLines.length; i++) {
385
- if (contentLines[i].trim() !== firstLine) continue;
386
- for (let j = i + 2; j < contentLines.length; j++) {
387
- if (contentLines[j].trim() === lastLine) {
388
- const blockLines = contentLines.slice(i, j + 1);
389
- const block = blockLines.join("\n");
390
- if (blockLines.length === findLines.length) {
391
- let matchingLines = 0;
392
- let totalNonEmptyLines = 0;
393
- for (let k = 1; k < blockLines.length - 1; k++) {
394
- const blockLine = blockLines[k].trim();
395
- const findLine = findLines[k].trim();
396
- if (blockLine.length > 0 || findLine.length > 0) {
397
- totalNonEmptyLines++;
398
- if (blockLine === findLine) {
399
- matchingLines++;
400
- }
401
- }
402
- }
403
- if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
404
- yield block;
405
- break;
406
- }
407
- }
408
- break;
409
- }
410
- }
411
- }
412
- };
413
-
414
- function isDisproportionateMatch(search: string, oldString: string) {
415
- const oldLines = oldString.split("\n").length;
416
- const searchLines = search.split("\n").length;
417
- if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true;
418
- if (oldLines === 1) return false;
419
- return (
420
- search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
421
- );
422
- }
423
-
424
- function replace(
425
- content: string,
426
- oldString: string,
427
- newString: string,
428
- replaceAll = false,
429
- ): string {
430
- if (oldString === newString) {
431
- throw new Error("No changes to apply: oldString and newString are identical.");
432
- }
433
- if (oldString === "") {
434
- throw new Error(
435
- "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
436
- );
437
- }
438
-
439
- let notFound = true;
440
-
441
- for (const replacer of [
442
- SimpleReplacer,
443
- LineTrimmedReplacer,
444
- BlockAnchorReplacer,
445
- WhitespaceNormalizedReplacer,
446
- IndentationFlexibleReplacer,
447
- EscapeNormalizedReplacer,
448
- TrimmedBoundaryReplacer,
449
- ContextAwareReplacer,
450
- MultiOccurrenceReplacer,
451
- ]) {
452
- for (const search of replacer(content, oldString)) {
453
- const index = content.indexOf(search);
454
- if (index === -1) continue;
455
- notFound = false;
456
- if (isDisproportionateMatch(search, oldString)) {
457
- throw new Error(
458
- "Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.",
459
- );
460
- }
461
- if (replaceAll) {
462
- return content.replaceAll(search, newString);
463
- }
464
- const lastIndex = content.lastIndexOf(search);
465
- if (index !== lastIndex) continue;
466
- return content.substring(0, index) + newString + content.substring(index + search.length);
467
- }
468
- }
469
-
470
- if (notFound) {
471
- throw new Error(
472
- "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
473
- );
474
- }
475
- throw new Error(
476
- "Found multiple matches for oldString. Provide more surrounding context to make the match unique.",
477
- );
478
- }
479
-
480
43
  // ── extension ─────────────────────────────────────────────────────────────────
481
44
 
482
45
  export default function (pi: ExtensionAPI) {
@@ -4,6 +4,7 @@
4
4
  * File-modifying tools (write, edit) are gated:
5
5
  * - Paths inside the workspace or /tmp are auto-allowed.
6
6
  * - Paths outside require user approval via confirmation dialog.
7
+ * The dialog shows a ```diff code block preview of the pending change.
7
8
  *
8
9
  * Read tools (read, ls, find, grep) are unrestricted.
9
10
  *
@@ -11,14 +12,23 @@
11
12
  * pi -e workspace-guard
12
13
  */
13
14
 
14
- import { isAbsolute, join, resolve, relative, sep } from "node:path";
15
+ import { basename, isAbsolute, join, resolve, relative, sep } from "node:path";
15
16
  import { homedir } from "node:os";
16
- import type { ExtensionAPI, ToolCallEvent } from "@earendil-works/pi-coding-agent";
17
+ import { readFile } from "node:fs/promises";
18
+ import {
19
+ generateUnifiedPatch,
20
+ type ExtensionAPI,
21
+ type ToolCallEvent,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import { normalizeForEdit, replace } from "./opencode-edit-engine.js";
17
24
 
18
25
  const WRITE_TOOLS = new Set(["write", "edit"]);
19
26
  const ALWAYS_ALLOW = ["/tmp"];
20
27
 
21
- function getWriteTarget(input: ToolCallEvent["input"]): string | undefined {
28
+ /** Maximum lines of the diff preview shown in the approval dialog. */
29
+ const MAX_PREVIEW_LINES = 100;
30
+
31
+ export function getWriteTarget(input: ToolCallEvent["input"]): string | undefined {
22
32
  if (typeof input !== "object" || input === null) return undefined;
23
33
  if ("path" in input && typeof input.path === "string") return input.path;
24
34
  if ("filePath" in input && typeof input.filePath === "string") return input.filePath;
@@ -52,6 +62,115 @@ function isPathAllowed(resolvedPath: string, cwd: string): boolean {
52
62
  return false;
53
63
  }
54
64
 
65
+ /**
66
+ * Type guard for built-in edit entries `{ oldText, newText }`.
67
+ */
68
+ function isEditPair(value: unknown): value is { oldText: string; newText: string } {
69
+ return (
70
+ typeof value === "object" &&
71
+ value !== null &&
72
+ "oldText" in value &&
73
+ typeof value.oldText === "string" &&
74
+ "newText" in value &&
75
+ typeof value.newText === "string"
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Extract the opencode-style edit pair `{ oldString, newString, replaceAll }`.
81
+ */
82
+ function getOpencodeEditPair(
83
+ input: ToolCallEvent["input"],
84
+ ): { oldString: string; newString: string; replaceAll: boolean } | undefined {
85
+ if (typeof input !== "object" || input === null) return undefined;
86
+ if (!("oldString" in input) || !("newString" in input)) return undefined;
87
+ if (typeof input.oldString !== "string" || typeof input.newString !== "string") return undefined;
88
+ return {
89
+ oldString: input.oldString,
90
+ newString: input.newString,
91
+ replaceAll: "replaceAll" in input && input.replaceAll === true,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Apply the pending write/edit to `content`.
97
+ * Returns undefined when the change cannot be applied (missing fields or oldText not found).
98
+ */
99
+ function applyChange(
100
+ toolName: string,
101
+ input: ToolCallEvent["input"],
102
+ content: string,
103
+ ): string | undefined {
104
+ if (typeof input !== "object" || input === null) return undefined;
105
+
106
+ if (toolName === "write") {
107
+ return "content" in input && typeof input.content === "string" ? input.content : undefined;
108
+ }
109
+
110
+ // Built-in edit: { path, edits: [{ oldText, newText }] }
111
+ if ("edits" in input && Array.isArray(input.edits)) {
112
+ let next = content;
113
+ for (const edit of input.edits) {
114
+ if (!isEditPair(edit)) return undefined;
115
+ if (!next.includes(edit.oldText)) return undefined;
116
+ next = next.replace(edit.oldText, edit.newText);
117
+ }
118
+ return next;
119
+ }
120
+
121
+ return undefined;
122
+ }
123
+
124
+ /** Wrap patch text in a ```diff code block, truncating very large diffs. */
125
+ function wrapDiff(patch: string): string {
126
+ const lines = patch.split("\n");
127
+ if (lines.length > MAX_PREVIEW_LINES) {
128
+ const truncated = lines.slice(0, MAX_PREVIEW_LINES).join("\n");
129
+ return `\`\`\`diff\n${truncated}\n… (preview truncated to ${MAX_PREVIEW_LINES} lines)\n\`\`\``;
130
+ }
131
+ return `\`\`\`diff\n${patch}\n\`\`\``;
132
+ }
133
+
134
+ /**
135
+ * Build a ```diff code block preview of the pending change.
136
+ * Returns undefined when the diff cannot be computed.
137
+ */
138
+ export async function buildDiffPreview(
139
+ toolName: string,
140
+ input: ToolCallEvent["input"],
141
+ resolvedPath: string,
142
+ ): Promise<string | undefined> {
143
+ let oldContent = "";
144
+ try {
145
+ oldContent = await readFile(resolvedPath, "utf-8");
146
+ } catch {
147
+ // Unreadable or missing file: treat as empty so writes show as full additions.
148
+ }
149
+
150
+ // opencode-edit: reuse the real matching engine to locate oldString, giving a
151
+ // line-numbered patch when it matches. Fall back to a parameter diff when the
152
+ // edit cannot be applied (oldString not found, ambiguous, or no file).
153
+ const pair = getOpencodeEditPair(input);
154
+ if (pair) {
155
+ try {
156
+ const normalized = normalizeForEdit(oldContent);
157
+ const newContent = replace(normalized, pair.oldString, pair.newString, pair.replaceAll);
158
+ // The full path is shown in the dialog title, so the patch header only
159
+ // carries the file name.
160
+ return wrapDiff(generateUnifiedPatch(basename(resolvedPath), normalized, newContent));
161
+ } catch {
162
+ const removed = pair.oldString.split("\n").map((line) => `-${line}`);
163
+ const added = pair.newString.split("\n").map((line) => `+${line}`);
164
+ return wrapDiff([...removed, ...added].join("\n"));
165
+ }
166
+ }
167
+
168
+ const newContent = applyChange(toolName, input, oldContent);
169
+ if (newContent === undefined) return undefined;
170
+
171
+ return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, newContent));
172
+ }
173
+
55
174
  export default function (pi: ExtensionAPI) {
56
175
  pi.on("before_agent_start", (event, ctx) => {
57
176
  const currentCwd = ctx.cwd;
@@ -83,13 +202,17 @@ export default function (pi: ExtensionAPI) {
83
202
 
84
203
  let choice: string | undefined;
85
204
  while (!choice) {
86
- choice = await ctx.ui.select(
205
+ const diffPreview = await buildDiffPreview(event.toolName, event.input, resolved);
206
+
207
+ const title =
87
208
  `Model requests write access outside workspace:\n\n` +
88
- ` Tool: ${event.toolName}\n` +
89
- ` Path: ${rawPath}\n` +
90
- ` Resolved: ${resolved}\n\nAllow?`,
91
- ["Approve once", "Block", "Block with reason"],
92
- );
209
+ ` Tool: ${event.toolName}\n` +
210
+ ` Path: ${rawPath}\n` +
211
+ ` Resolved: ${resolved}\n` +
212
+ (diffPreview ? `\n${diffPreview}\n` : "") +
213
+ `\nAllow?`;
214
+
215
+ choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
93
216
 
94
217
  if (typeof choice === "undefined") {
95
218
  ctx.abort();