@oh-my-pi/pi-coding-agent 14.2.1 → 14.3.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/package.json +19 -19
  3. package/src/cli/args.ts +10 -1
  4. package/src/cli/shell-cli.ts +15 -3
  5. package/src/config/settings-schema.ts +60 -1
  6. package/src/debug/system-info.ts +6 -2
  7. package/src/discovery/claude.ts +58 -36
  8. package/src/discovery/opencode.ts +20 -2
  9. package/src/edit/index.ts +2 -1
  10. package/src/edit/modes/chunk.ts +132 -56
  11. package/src/edit/modes/hashline.ts +36 -11
  12. package/src/edit/renderer.ts +98 -133
  13. package/src/edit/streaming.ts +351 -0
  14. package/src/exec/bash-executor.ts +60 -5
  15. package/src/internal-urls/docs-index.generated.ts +5 -5
  16. package/src/internal-urls/pi-protocol.ts +0 -2
  17. package/src/lsp/client.ts +8 -1
  18. package/src/lsp/defaults.json +2 -1
  19. package/src/modes/acp/acp-agent.ts +76 -2
  20. package/src/modes/components/assistant-message.ts +1 -34
  21. package/src/modes/components/hook-editor.ts +1 -1
  22. package/src/modes/components/tool-execution.ts +111 -101
  23. package/src/modes/controllers/input-controller.ts +1 -1
  24. package/src/modes/interactive-mode.ts +0 -2
  25. package/src/modes/theme/mermaid-cache.ts +13 -52
  26. package/src/modes/theme/theme.ts +2 -2
  27. package/src/prompts/system/system-prompt.md +1 -1
  28. package/src/prompts/tools/browser.md +1 -0
  29. package/src/prompts/tools/chunk-edit.md +25 -22
  30. package/src/prompts/tools/gh-pr-push.md +2 -1
  31. package/src/prompts/tools/grep.md +4 -3
  32. package/src/prompts/tools/lsp.md +6 -0
  33. package/src/prompts/tools/read-chunk.md +46 -7
  34. package/src/prompts/tools/read.md +7 -4
  35. package/src/sdk.ts +8 -5
  36. package/src/session/agent-session.ts +36 -20
  37. package/src/session/session-manager.ts +228 -57
  38. package/src/session/streaming-output.ts +11 -0
  39. package/src/system-prompt.ts +7 -2
  40. package/src/task/executor.ts +1 -0
  41. package/src/tools/bash.ts +13 -0
  42. package/src/tools/gh.ts +6 -16
  43. package/src/tools/sqlite-reader.ts +116 -3
  44. package/src/web/search/providers/codex.ts +129 -6
@@ -24,12 +24,12 @@ import {
24
24
  } from "../tools/render-utils";
25
25
  import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
26
26
  import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
27
+ import type { EditMode } from "../utils/edit-mode";
27
28
  import type { VimToolDetails } from "../vim/types";
28
29
  import type { DiffError, DiffResult } from "./diff";
29
30
  import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
30
- import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
31
- import type { HashlineToolEdit } from "./modes/hashline";
32
31
  import type { Operation, PatchEditEntry } from "./modes/patch";
32
+ import type { PerFileDiffPreview } from "./streaming";
33
33
 
34
34
  // ═══════════════════════════════════════════════════════════════════════════
35
35
  // LSP Batching
@@ -90,6 +90,7 @@ interface EditRenderArgs {
90
90
  * Computed preview diff (used when tool args don't include a diff, e.g. hashline mode).
91
91
  */
92
92
  previewDiff?: string;
93
+ __partialJson?: string;
93
94
  // Hashline / chunk mode fields
94
95
  edits?: EditRenderEntry[];
95
96
  }
@@ -133,8 +134,12 @@ function isVimToolDetails(details: unknown): details is VimToolDetails {
133
134
 
134
135
  /** Extended context for edit tool rendering */
135
136
  export interface EditRenderContext {
137
+ /** Edit mode resolved by the caller; lets the renderer dispatch without shape-sniffing */
138
+ editMode?: EditMode;
136
139
  /** Pre-computed diff preview (computed before tool executes) */
137
140
  editDiffPreview?: DiffResult | DiffError;
141
+ /** Multi-file streaming diff preview (chunk edits spanning several files) */
142
+ perFileDiffPreview?: PerFileDiffPreview[];
138
143
  /** Function to render diff text with syntax highlighting */
139
144
  renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
140
145
  }
@@ -142,14 +147,6 @@ export interface EditRenderContext {
142
147
  const EDIT_STREAMING_PREVIEW_LINES = 12;
143
148
  const CALL_TEXT_PREVIEW_LINES = 6;
144
149
  const CALL_TEXT_PREVIEW_WIDTH = 80;
145
- const STREAMING_EDIT_PREVIEW_WIDTH = 120;
146
- const STREAMING_EDIT_PREVIEW_LIMIT = 4;
147
- const STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT = 8;
148
-
149
- interface FormattedStreamingEdit {
150
- srcLabel: string;
151
- dst: string;
152
- }
153
150
 
154
151
  /** Extract file path from an edit entry's path (handles chunk's file:selector format). */
155
152
  function filePathFromEditEntry(p: string | undefined): string | undefined {
@@ -158,6 +155,48 @@ function filePathFromEditEntry(p: string | undefined): string | undefined {
158
155
  return ci === -1 ? p : p.slice(0, ci);
159
156
  }
160
157
 
158
+ function decodePartialJsonStringFragment(fragment: string): string {
159
+ let text = fragment;
160
+ const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0;
161
+ if (trailingBackslashes % 2 === 1) {
162
+ text = text.slice(0, -1);
163
+ }
164
+ try {
165
+ return JSON.parse(`"${text}"`) as string;
166
+ } catch {
167
+ return text
168
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
169
+ .replace(/\\(["\\/bfnrt])/g, (_, ch: string) => {
170
+ switch (ch) {
171
+ case "b":
172
+ return "\b";
173
+ case "f":
174
+ return "\f";
175
+ case "n":
176
+ return "\n";
177
+ case "r":
178
+ return "\r";
179
+ case "t":
180
+ return "\t";
181
+ default:
182
+ return ch;
183
+ }
184
+ });
185
+ }
186
+ }
187
+
188
+ function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined {
189
+ if (!partialJson) return undefined;
190
+ const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u");
191
+ const match = pattern.exec(partialJson);
192
+ if (!match) return undefined;
193
+ return decodePartialJsonStringFragment(match[1]);
194
+ }
195
+
196
+ function getPartialJsonEditPath(args: EditRenderArgs): string | undefined {
197
+ return filePathFromEditEntry(extractPartialJsonString(args.__partialJson, "path"));
198
+ }
199
+
161
200
  /** Count distinct file paths in an edits array. */
162
201
  function countEditFiles(edits: EditRenderEntry[]): number {
163
202
  return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
@@ -230,133 +269,46 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
230
269
  return text;
231
270
  }
232
271
 
233
- function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
234
- return (
235
- typeof edit === "object" &&
236
- edit !== null &&
237
- "path" in edit &&
238
- ("write" in edit || "replace" in edit || "insert" in edit)
239
- );
240
- }
241
-
242
- function getStreamingEditContent(content: unknown): string {
243
- if (Array.isArray(content)) {
244
- return content.join("\n");
245
- }
246
- return typeof content === "string" ? content : "";
247
- }
248
-
249
- function formatHashlineStreamingEdit(edit: Partial<HashlineToolEdit>): FormattedStreamingEdit {
250
- if (typeof edit !== "object" || !edit) {
251
- return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
252
- }
253
-
254
- const contentLines = getStreamingEditContent(edit.content);
255
- const loc = edit.loc;
256
-
257
- if (loc === "append" || loc === "prepend") {
258
- return { srcLabel: `\u2022 ${loc} (file-level)`, dst: contentLines };
259
- }
260
- if (typeof loc === "object" && loc) {
261
- if ("range" in loc && typeof loc.range === "object" && loc.range) {
262
- return { srcLabel: `\u2022 range ${loc.range.pos ?? "?"}\u2026${loc.range.end ?? "?"}`, dst: contentLines };
263
- }
264
- if ("line" in loc) {
265
- return { srcLabel: `\u2022 line ${(loc as { line: string }).line}`, dst: contentLines };
266
- }
267
- if ("append" in loc) {
268
- return { srcLabel: `\u2022 append ${(loc as { append: string }).append}`, dst: contentLines };
269
- }
270
- if ("prepend" in loc) {
271
- return { srcLabel: `\u2022 prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
272
- }
273
- }
274
- return { srcLabel: "\u2022 (unknown edit)", dst: contentLines };
275
- }
276
-
277
- function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStreamingEdit {
278
- if (typeof edit !== "object" || !edit) {
279
- return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
280
- }
281
-
282
- const target = edit.path ? (parseChunkEditPath(edit.path).selector ?? edit.path) : "?";
283
- if (edit.write === null) {
284
- return { srcLabel: `\u2022 remove ${target}`, dst: "" };
285
- }
286
- if (typeof edit.write === "string") {
287
- return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.write) };
288
- }
289
- if (typeof edit.replace === "object" && edit.replace) {
290
- return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.replace.new) };
291
- }
292
- if (typeof edit.insert === "object" && edit.insert) {
293
- return { srcLabel: `\u2022 ${edit.insert.loc} ${target}`, dst: getStreamingEditContent(edit.insert.body) };
272
+ function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
273
+ const icon = uiTheme.getLangIcon(language);
274
+ if (lineCount !== null) {
275
+ return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
294
276
  }
295
- return { srcLabel: `\u2022 edit ${target}`, dst: "" };
277
+ return uiTheme.fg("dim", `${icon}`);
296
278
  }
297
279
 
298
- function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit | ChunkToolEdit>[], uiTheme: Theme): string {
299
- let text = "\n\n";
300
-
301
- // Detect whether these are chunk edits (target field) or hashline edits (loc field)
302
- const isChunk = edits.length > 0 && isChunkStreamingEdit(edits[0]);
303
- const label = isChunk ? "chunk edit" : "hashline edit";
304
- const formatEdit = isChunk ? formatChunkStreamingEdit : formatHashlineStreamingEdit;
305
- text += uiTheme.fg("dim", `[${edits.length} ${label}${edits.length === 1 ? "" : "s"}]`);
306
- text += "\n";
307
- let shownEdits = 0;
308
- let shownDstLines = 0;
309
- for (const edit of edits) {
310
- shownEdits++;
311
- if (shownEdits > STREAMING_EDIT_PREVIEW_LIMIT) break;
312
- const formatted = formatEdit(edit as never);
313
- text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), STREAMING_EDIT_PREVIEW_WIDTH));
314
- text += "\n";
315
- if (formatted.dst === "") {
316
- text += uiTheme.fg("dim", truncateToWidth(" (delete)", STREAMING_EDIT_PREVIEW_WIDTH));
317
- text += "\n";
280
+ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
281
+ const parts: string[] = [];
282
+ for (const preview of previews) {
283
+ if (!preview.diff && !preview.error) continue;
284
+ const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
285
+ if (preview.error) {
286
+ parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
318
287
  continue;
319
288
  }
320
- for (const dstLine of formatted.dst.split("\n")) {
321
- shownDstLines++;
322
- if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
323
- text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), STREAMING_EDIT_PREVIEW_WIDTH));
324
- text += "\n";
289
+ if (preview.diff) {
290
+ parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
325
291
  }
326
- if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
327
292
  }
328
- if (edits.length > STREAMING_EDIT_PREVIEW_LIMIT) {
329
- text += uiTheme.fg("dim", `\u2026 (${edits.length - STREAMING_EDIT_PREVIEW_LIMIT} more edits)`);
330
- }
331
- if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) {
332
- text += uiTheme.fg("dim", `\n\u2026 (${shownDstLines - STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT} more dst lines)`);
333
- }
334
-
335
- return text.trimEnd();
293
+ return parts.join("");
336
294
  }
337
295
 
338
- function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
339
- const icon = uiTheme.getLangIcon(language);
340
- if (lineCount !== null) {
341
- return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
296
+ function getCallPreview(
297
+ args: EditRenderArgs,
298
+ rawPath: string,
299
+ uiTheme: Theme,
300
+ renderContext: EditRenderContext | undefined,
301
+ ): string {
302
+ const multi = renderContext?.perFileDiffPreview;
303
+ if (multi && multi.length > 0 && multi.some(p => p.diff || p.error)) {
304
+ return formatMultiFileStreamingDiff(multi, uiTheme);
342
305
  }
343
- return uiTheme.fg("dim", `${icon}`);
344
- }
345
-
346
- function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme): string {
347
306
  if (args.previewDiff) {
348
307
  return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
349
308
  }
350
309
  if (args.diff && args.op) {
351
310
  return formatStreamingDiff(args.diff, rawPath, uiTheme);
352
311
  }
353
- if (args.edits && args.edits.length > 0) {
354
- // Only show hashline/chunk streaming edits — replace/patch use previewDiff above
355
- const first = args.edits[0];
356
- if (first && typeof first === "object" && ("loc" in first || isChunkStreamingEdit(first))) {
357
- return formatStreamingHashlineEdits(args.edits, uiTheme);
358
- }
359
- }
360
312
  if (args.diff) {
361
313
  return renderPlainTextPreview(args.diff, uiTheme);
362
314
  }
@@ -446,31 +398,44 @@ function wrapEditRendererLine(line: string, width: number): string[] {
446
398
  export const editToolRenderer = {
447
399
  mergeCallAndResult: true,
448
400
 
449
- renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
450
- if (isVimRenderArgs(args)) {
451
- return vimToolRenderer.renderCall(args, options, uiTheme);
401
+ renderCall(
402
+ args: EditRenderArgs | VimRenderArgs,
403
+ options: RenderResultOptions & { renderContext?: EditRenderContext },
404
+ uiTheme: Theme,
405
+ ): Component {
406
+ const renderContext = options.renderContext;
407
+ // Dispatch on the explicit editMode when available; fall back to the
408
+ // shape probe for legacy call sites that don't thread renderContext.
409
+ if (renderContext?.editMode === "vim" || isVimRenderArgs(args)) {
410
+ return vimToolRenderer.renderCall(args as VimRenderArgs, options, uiTheme);
452
411
  }
453
412
 
454
- const applyPatchSummary = getApplyPatchRenderSummary(args, options.isPartial);
413
+ const editArgs = args as EditRenderArgs;
414
+ const applyPatchSummary = getApplyPatchRenderSummary(editArgs, options.isPartial);
455
415
  const firstApplyPatchEntry = applyPatchSummary?.entries[0];
456
416
  // Extract path from first edit entry when top-level path is absent (new schema)
457
- const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
417
+ const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
458
418
  const rawPath =
459
- args.file_path || args.path || filePathFromEditEntry(firstEdit?.path) || firstApplyPatchEntry?.path || "";
460
- const rename = args.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
461
- const op = args.op || firstEdit?.op || firstApplyPatchEntry?.op;
419
+ editArgs.file_path ||
420
+ editArgs.path ||
421
+ filePathFromEditEntry(firstEdit?.path) ||
422
+ getPartialJsonEditPath(editArgs) ||
423
+ firstApplyPatchEntry?.path ||
424
+ "";
425
+ const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
426
+ const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
462
427
  const { description } = formatEditDescription(rawPath, uiTheme, { rename });
463
428
  const spinner =
464
429
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
465
430
  let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
466
431
  // Show file count hint for multi-file edits
467
- const fileCount = Array.isArray(args.edits)
468
- ? countEditFiles(args.edits)
432
+ const fileCount = Array.isArray(editArgs.edits)
433
+ ? countEditFiles(editArgs.edits)
469
434
  : (applyPatchSummary?.entries.length ?? 0);
470
435
  if (fileCount > 1) {
471
436
  text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
472
437
  }
473
- text += getCallPreview(args, rawPath, uiTheme);
438
+ text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
474
439
  if (applyPatchSummary?.error) {
475
440
  text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
476
441
  }
@@ -484,7 +449,7 @@ export const editToolRenderer = {
484
449
  uiTheme: Theme,
485
450
  args?: EditRenderArgs,
486
451
  ): Component {
487
- if (isVimToolDetails(result.details)) {
452
+ if (options.renderContext?.editMode === "vim" || isVimToolDetails(result.details)) {
488
453
  return vimToolRenderer.renderResult(
489
454
  result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
490
455
  options,
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Streaming edit preview strategies.
3
+ *
4
+ * Each edit mode owns a strategy that knows how to:
5
+ * - collapse partial-JSON args to the subset safe to preview
6
+ * (`extractCompleteEdits`),
7
+ * - compute unified diff previews for the in-flight args
8
+ * (`computeDiffPreview`), and
9
+ * - render a text placeholder while no diff exists yet
10
+ * (`renderStreamingFallback`).
11
+ *
12
+ * The shared renderer / `ToolExecutionComponent` consult the strategy via
13
+ * the injected `editMode` rather than probing argument shape.
14
+ */
15
+ import type { Theme } from "../modes/theme/theme";
16
+ import { type EditMode, resolveEditMode } from "../utils/edit-mode";
17
+ import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
18
+ import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
19
+ import { type ChunkToolEdit, computeChunkDiff, parseChunkEditPath } from "./modes/chunk";
20
+ import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
21
+ import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
22
+ import type { ReplaceEditEntry } from "./modes/replace";
23
+
24
+ export interface PerFileDiffPreview {
25
+ path: string;
26
+ diff?: string;
27
+ firstChangedLine?: number;
28
+ error?: string;
29
+ }
30
+
31
+ export interface StreamingDiffContext {
32
+ cwd: string;
33
+ signal: AbortSignal;
34
+ fuzzyThreshold?: number;
35
+ allowFuzzy?: boolean;
36
+ }
37
+
38
+ export interface EditStreamingStrategy<Args = unknown> {
39
+ /**
40
+ * Return the args restricted to edits that are "complete enough" to
41
+ * compute a diff against. Strategies drop the trailing incomplete entry
42
+ * when `partialJson` indicates its closing `}` hasn't arrived yet.
43
+ */
44
+ extractCompleteEdits(args: Args, partialJson: string | undefined): Args;
45
+ /**
46
+ * Compute diff(s) for the given partial args. Returns `null` when args
47
+ * do not yet carry enough structure to compute anything.
48
+ */
49
+ computeDiffPreview(args: Args, ctx: StreamingDiffContext): Promise<PerFileDiffPreview[] | null>;
50
+ /**
51
+ * Rendered inline while the diff hasn't been computed yet (or when the
52
+ * compute returned `null` because args are still too partial).
53
+ */
54
+ renderStreamingFallback(args: Args, uiTheme: Theme): string;
55
+ }
56
+
57
+ // -----------------------------------------------------------------------------
58
+ // Partial-JSON handling
59
+ // -----------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Given an edits array parsed from partial JSON, drop the last entry when the
63
+ * corresponding object in `partialJson` does not yet end with a closed `}`.
64
+ *
65
+ * This guards against `partial-json` silently coercing truncated tails like
66
+ * `"write":nu` / `"write":nul` into `{ write: null }`, which would make the
67
+ * last entry render a spurious null-write error until the value finishes
68
+ * streaming.
69
+ */
70
+ export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: string | undefined, listKey: string): T[] {
71
+ if (!Array.isArray(edits) || edits.length === 0) return [...(edits ?? [])];
72
+ if (!partialJson) return [...edits];
73
+
74
+ const keyMarker = `"${listKey}"`;
75
+ const keyIdx = partialJson.indexOf(keyMarker);
76
+ if (keyIdx === -1) return [...edits];
77
+
78
+ // Find the `[` that opens the list value.
79
+ let i = partialJson.indexOf("[", keyIdx + keyMarker.length);
80
+ if (i === -1) return [...edits];
81
+ i++;
82
+
83
+ let depth = 0;
84
+ let inString = false;
85
+ let escaped = false;
86
+ let lastClose = -1;
87
+ for (; i < partialJson.length; i++) {
88
+ const ch = partialJson[i];
89
+ if (escaped) {
90
+ escaped = false;
91
+ continue;
92
+ }
93
+ if (ch === "\\") {
94
+ if (inString) escaped = true;
95
+ continue;
96
+ }
97
+ if (ch === '"') {
98
+ inString = !inString;
99
+ continue;
100
+ }
101
+ if (inString) continue;
102
+ if (ch === "{" || ch === "[") {
103
+ depth++;
104
+ } else if (ch === "}" || ch === "]") {
105
+ depth--;
106
+ if (ch === "}" && depth === 0) {
107
+ lastClose = i;
108
+ }
109
+ if (ch === "]" && depth === -1) {
110
+ // End of list reached.
111
+ break;
112
+ }
113
+ }
114
+ }
115
+
116
+ // If we're still inside the list and saw no closing `}` for the last entry,
117
+ // or there is trailing non-whitespace after the last `}` before the list
118
+ // ended (i.e. a new object has opened), drop the trailing entry.
119
+ const tail = lastClose === -1 ? partialJson.slice(i) : partialJson.slice(lastClose + 1);
120
+ const sawNewObjectAfterLastClose = /\{/.test(tail);
121
+ const listIsStillOpen = depth >= 0;
122
+
123
+ if (lastClose === -1 || (listIsStillOpen && sawNewObjectAfterLastClose)) {
124
+ return edits.slice(0, -1);
125
+ }
126
+ return [...edits];
127
+ }
128
+
129
+ // -----------------------------------------------------------------------------
130
+ // Strategies
131
+ // -----------------------------------------------------------------------------
132
+
133
+ interface ReplaceArgs {
134
+ edits?: ReplaceEditEntry[];
135
+ __partialJson?: string;
136
+ }
137
+
138
+ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
139
+ extractCompleteEdits(args, partialJson) {
140
+ if (!args?.edits) return args;
141
+ return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
142
+ },
143
+ async computeDiffPreview(args, ctx) {
144
+ const first = args.edits?.[0];
145
+ if (!first?.path || first.old_text === undefined || first.new_text === undefined) return null;
146
+ ctx.signal.throwIfAborted();
147
+ const result = await computeEditDiff(
148
+ first.path,
149
+ first.old_text,
150
+ first.new_text,
151
+ ctx.cwd,
152
+ ctx.allowFuzzy ?? true,
153
+ first.all,
154
+ ctx.fuzzyThreshold,
155
+ );
156
+ ctx.signal.throwIfAborted();
157
+ return [toPerFilePreview(first.path, result)];
158
+ },
159
+ renderStreamingFallback() {
160
+ return "";
161
+ },
162
+ };
163
+
164
+ interface PatchArgs {
165
+ edits?: PatchEditEntry[];
166
+ __partialJson?: string;
167
+ }
168
+
169
+ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
170
+ extractCompleteEdits(args, partialJson) {
171
+ if (!args?.edits) return args;
172
+ return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
173
+ },
174
+ async computeDiffPreview(args, ctx) {
175
+ const first = args.edits?.[0];
176
+ if (!first?.path) return null;
177
+ ctx.signal.throwIfAborted();
178
+ const result = await computePatchDiff(
179
+ { path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
180
+ ctx.cwd,
181
+ { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
182
+ );
183
+ ctx.signal.throwIfAborted();
184
+ return [toPerFilePreview(first.path, result)];
185
+ },
186
+ renderStreamingFallback() {
187
+ return "";
188
+ },
189
+ };
190
+
191
+ interface HashlineArgs {
192
+ edits?: HashlineToolEdit[];
193
+ move?: string;
194
+ __partialJson?: string;
195
+ }
196
+
197
+ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
198
+ extractCompleteEdits(args, partialJson) {
199
+ if (!args?.edits) return args;
200
+ return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
201
+ },
202
+ async computeDiffPreview(args, ctx) {
203
+ const first = args.edits?.[0] as (HashlineToolEdit & { path?: string }) | undefined;
204
+ if (!first?.path) return null;
205
+ const path = first.path;
206
+ const fileEdits = (args.edits ?? []).filter((e): e is HashlineToolEdit & { path: string } => {
207
+ return !!e && typeof e === "object" && (e as { path?: string }).path === path;
208
+ });
209
+ ctx.signal.throwIfAborted();
210
+ const result = await computeHashlineDiff({ path, edits: fileEdits, move: args.move }, ctx.cwd);
211
+ ctx.signal.throwIfAborted();
212
+ return [toPerFilePreview(path, result)];
213
+ },
214
+ renderStreamingFallback() {
215
+ return "";
216
+ },
217
+ };
218
+
219
+ interface ChunkArgs {
220
+ edits?: ChunkToolEdit[];
221
+ __partialJson?: string;
222
+ }
223
+
224
+ const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
225
+ extractCompleteEdits(args, partialJson) {
226
+ if (!args?.edits) return args;
227
+ let edits = dropIncompleteLastEdit(args.edits, partialJson, "edits");
228
+ // Extra guard: if partial JSON still contains `":nu` / `":nul` (partial
229
+ // `null` literals), `partial-json` may have already surfaced the last
230
+ // entry with `write === null`. When that entry's `}` hasn't closed
231
+ // yet, it has already been dropped above. But if dropping was not
232
+ // triggered (e.g. list still open and no new `{` after), also drop the
233
+ // trailing null-write entry so the preview does not flicker with an
234
+ // error for an incomplete string/null literal.
235
+ if (partialJson && edits.length > 0) {
236
+ const last = edits[edits.length - 1] as Partial<ChunkToolEdit> | undefined;
237
+ const endsInPartialNull = /:\s*nu?l?\s*$/.test(partialJson.trimEnd());
238
+ if (last && endsInPartialNull && last.write === null) {
239
+ edits = edits.slice(0, -1);
240
+ }
241
+ }
242
+ return { ...args, edits };
243
+ },
244
+ async computeDiffPreview(args, ctx) {
245
+ const edits = args.edits ?? [];
246
+ if (edits.length === 0) return null;
247
+ // Group edits by file path
248
+ const groups = new Map<string, ChunkToolEdit[]>();
249
+ const fileOrder: string[] = [];
250
+ for (const edit of edits) {
251
+ if (!edit?.path) continue;
252
+ const { filePath } = parseChunkEditPath(edit.path);
253
+ if (!filePath) continue;
254
+ let bucket = groups.get(filePath);
255
+ if (!bucket) {
256
+ bucket = [];
257
+ groups.set(filePath, bucket);
258
+ fileOrder.push(filePath);
259
+ }
260
+ bucket.push(edit);
261
+ }
262
+ if (fileOrder.length === 0) return null;
263
+
264
+ const MAX_FILES = 5;
265
+ const selected = fileOrder.slice(0, MAX_FILES);
266
+ const previews: PerFileDiffPreview[] = [];
267
+ for (const filePath of selected) {
268
+ ctx.signal.throwIfAborted();
269
+ const fileEdits = groups.get(filePath) ?? [];
270
+ const result = await computeChunkDiff({ path: filePath, edits: fileEdits }, ctx.cwd, { signal: ctx.signal });
271
+ previews.push(toPerFilePreview(filePath, result));
272
+ }
273
+ return previews;
274
+ },
275
+ renderStreamingFallback() {
276
+ return "";
277
+ },
278
+ };
279
+
280
+ interface ApplyPatchArgs {
281
+ input?: string;
282
+ }
283
+
284
+ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
285
+ extractCompleteEdits(args) {
286
+ // Apply_patch payload is plain text, not an edits array. Nothing to trim.
287
+ return args;
288
+ },
289
+ async computeDiffPreview(args, ctx) {
290
+ if (typeof args.input !== "string" || args.input.length === 0) return null;
291
+ let entries: PatchEditEntry[];
292
+ try {
293
+ entries = expandApplyPatchToEntries({ input: args.input });
294
+ } catch {
295
+ try {
296
+ entries = expandApplyPatchToPreviewEntries({ input: args.input });
297
+ } catch (err) {
298
+ return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
299
+ }
300
+ }
301
+ const first = entries[0];
302
+ if (!first?.path) return null;
303
+ ctx.signal.throwIfAborted();
304
+ const result = await computePatchDiff(
305
+ { path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
306
+ ctx.cwd,
307
+ { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
308
+ );
309
+ ctx.signal.throwIfAborted();
310
+ return [toPerFilePreview(first.path, result)];
311
+ },
312
+ renderStreamingFallback() {
313
+ return "";
314
+ },
315
+ };
316
+
317
+ // Vim streaming preview is handled by the existing vimToolRenderer inside
318
+ // edit/renderer.ts. The strategy here is a no-op so the registry is total.
319
+ const vimStrategy: EditStreamingStrategy<unknown> = {
320
+ extractCompleteEdits(args) {
321
+ return args;
322
+ },
323
+ async computeDiffPreview() {
324
+ return null;
325
+ },
326
+ renderStreamingFallback() {
327
+ return "";
328
+ },
329
+ };
330
+
331
+ export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
332
+ replace: replaceStrategy as EditStreamingStrategy<unknown>,
333
+ patch: patchStrategy as EditStreamingStrategy<unknown>,
334
+ hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
335
+ chunk: chunkStrategy as EditStreamingStrategy<unknown>,
336
+ apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
337
+ vim: vimStrategy,
338
+ };
339
+
340
+ export { resolveEditMode };
341
+
342
+ // -----------------------------------------------------------------------------
343
+ // Helpers
344
+ // -----------------------------------------------------------------------------
345
+
346
+ function toPerFilePreview(path: string, result: DiffResult | DiffError): PerFileDiffPreview {
347
+ if ("error" in result) {
348
+ return { path, error: result.error };
349
+ }
350
+ return { path, diff: result.diff, firstChangedLine: result.firstChangedLine };
351
+ }