@oh-my-pi/pi-coding-agent 14.4.0 → 14.4.3

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 (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +7 -7
  3. package/src/cli.ts +0 -1
  4. package/src/config/prompt-templates.ts +1 -31
  5. package/src/config/settings-schema.ts +27 -37
  6. package/src/config/settings.ts +1 -1
  7. package/src/edit/index.ts +1 -53
  8. package/src/edit/line-hash.ts +13 -63
  9. package/src/edit/modes/atom.ts +334 -64
  10. package/src/edit/modes/hashline.ts +19 -26
  11. package/src/edit/renderer.ts +6 -8
  12. package/src/edit/streaming.ts +90 -114
  13. package/src/export/html/template.generated.ts +1 -1
  14. package/src/export/html/template.js +10 -15
  15. package/src/internal-urls/docs-index.generated.ts +1 -2
  16. package/src/lsp/defaults.json +142 -652
  17. package/src/modes/components/session-selector.ts +3 -3
  18. package/src/modes/components/settings-defs.ts +0 -5
  19. package/src/modes/components/tool-execution.ts +2 -5
  20. package/src/modes/controllers/btw-controller.ts +17 -105
  21. package/src/modes/controllers/todo-command-controller.ts +537 -0
  22. package/src/modes/interactive-mode.ts +35 -9
  23. package/src/modes/types.ts +2 -0
  24. package/src/modes/utils/ui-helpers.ts +17 -0
  25. package/src/prompts/system/irc-incoming.md +8 -0
  26. package/src/prompts/system/subagent-system-prompt.md +8 -0
  27. package/src/prompts/tools/ast-edit.md +1 -1
  28. package/src/prompts/tools/ast-grep.md +1 -0
  29. package/src/prompts/tools/atom.md +55 -53
  30. package/src/prompts/tools/bash.md +2 -2
  31. package/src/prompts/tools/grep.md +2 -5
  32. package/src/prompts/tools/irc.md +49 -0
  33. package/src/prompts/tools/job.md +11 -0
  34. package/src/prompts/tools/read.md +12 -13
  35. package/src/prompts/tools/task.md +1 -1
  36. package/src/prompts/tools/todo-write.md +14 -5
  37. package/src/registry/agent-registry.ts +139 -0
  38. package/src/sdk.ts +35 -0
  39. package/src/session/agent-session.ts +217 -5
  40. package/src/session/session-manager.ts +4 -1
  41. package/src/session/streaming-output.ts +1 -1
  42. package/src/slash-commands/builtin-registry.ts +24 -0
  43. package/src/task/executor.ts +14 -0
  44. package/src/tools/bash.ts +1 -1
  45. package/src/tools/fetch.ts +18 -6
  46. package/src/tools/fs-cache-invalidation.ts +0 -5
  47. package/src/tools/grep.ts +5 -125
  48. package/src/tools/index.ts +12 -6
  49. package/src/tools/irc.ts +258 -0
  50. package/src/tools/job.ts +489 -0
  51. package/src/tools/match-line-format.ts +8 -7
  52. package/src/tools/output-meta.ts +1 -1
  53. package/src/tools/read.ts +37 -131
  54. package/src/tools/renderers.ts +2 -0
  55. package/src/tools/todo-write.ts +243 -12
  56. package/src/tools/write.ts +2 -2
  57. package/src/utils/edit-mode.ts +1 -2
  58. package/src/utils/file-display-mode.ts +0 -3
  59. package/src/cli/read-cli.ts +0 -67
  60. package/src/commands/read.ts +0 -33
  61. package/src/edit/modes/chunk.ts +0 -832
  62. package/src/prompts/tools/cancel-job.md +0 -5
  63. package/src/prompts/tools/chunk-edit.md +0 -158
  64. package/src/prompts/tools/poll.md +0 -5
  65. package/src/prompts/tools/read-chunk.md +0 -73
  66. package/src/tools/cancel-job.ts +0 -95
  67. package/src/tools/poll-tool.ts +0 -173
@@ -8,7 +8,7 @@
8
8
  * if the file has changed since the caller last read it, hash mismatches are caught
9
9
  * before any mutation occurs.
10
10
  *
11
- * Displayed format: `LINE+ID:TEXT`
11
+ * Displayed format: `LINE+ID|TEXT`
12
12
  * Reference format: `"LINE+ID"` (e.g. `"1ab"`)
13
13
  *
14
14
  * In tool JSON, each edit's `content` is `string[]` (one string per logical line) or
@@ -28,7 +28,7 @@ import { resolveToCwd } from "../../tools/path-utils";
28
28
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
29
29
  import { formatCodeFrameLine } from "../../tools/render-utils";
30
30
  import { generateDiffString } from "../diff";
31
- import { computeLineHash, formatLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
31
+ import { computeLineHash, formatHashLine, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
32
32
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
33
33
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
34
34
 
@@ -38,7 +38,7 @@ export interface HashMismatch {
38
38
  actual: string;
39
39
  }
40
40
 
41
- export type Anchor = { line: number; hash: string };
41
+ export type Anchor = { line: number; hash: string; contentHint?: string };
42
42
  export type HashlineEdit =
43
43
  | { op: "replace_line"; pos: Anchor; lines: string[] }
44
44
  | { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
@@ -47,10 +47,11 @@ export type HashlineEdit =
47
47
  | { op: "append_file"; lines: string[] }
48
48
  | { op: "prepend_file"; lines: string[] };
49
49
 
50
- // Tight prefix matchers for the new format `LINE+ID:content`. Hard
51
- // cutover do not accept legacy `LINENUM#BIGRAM:content` or tab separators.
52
- // The terminator must be a literal colon; line-number digits are mandatory.
53
- const HASHLINE_CONTENT_SEPARATOR_RE = HASHLINE_CONTENT_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ // Tight prefix matchers for the new format `LINE+ID|content`. The pipe is the
51
+ // canonical separator; legacy reads using `:` are tolerated for back-compat.
52
+ // Line-number digits are mandatory.
53
+ // Accept both `|` (canonical) and `:` (legacy) so re-reads of older outputs still parse.
54
+ const HASHLINE_CONTENT_SEPARATOR_RE = "[:|]";
54
55
  const HASHLINE_PREFIX_RE = new RegExp(
55
56
  `^\\s*(?:>>>|>>)?\\s*(?:\\+\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
56
57
  );
@@ -58,7 +59,7 @@ const HASHLINE_PREFIX_PLUS_RE = new RegExp(
58
59
  `^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
59
60
  );
60
61
  const DIFF_PLUS_RE = /^[+](?![+])/;
61
- const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
62
+ const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
62
63
 
63
64
  type LinePrefixStats = {
64
65
  nonEmpty: number;
@@ -312,8 +313,6 @@ interface ResolvedHashlineStreamOptions {
312
313
  maxChunkBytes: number;
313
314
  }
314
315
 
315
- type HashlineLineFormatter = (lineNumber: number, line: string) => string;
316
-
317
316
  interface HashlineChunkEmitter {
318
317
  pushLine: (line: string) => string[];
319
318
  flush: () => string | undefined;
@@ -329,7 +328,7 @@ function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedH
329
328
 
330
329
  function createHashlineChunkEmitter(
331
330
  options: ResolvedHashlineStreamOptions,
332
- formatLine: HashlineLineFormatter,
331
+ formatLine = formatHashLine,
333
332
  ): HashlineChunkEmitter {
334
333
  let lineNumber = options.startLine;
335
334
  let outLines: string[] = [];
@@ -373,10 +372,6 @@ function createHashlineChunkEmitter(
373
372
  return { pushLine, flush };
374
373
  }
375
374
 
376
- function formatHashlineStreamLine(lineNumber: number, line: string): string {
377
- return `${formatLineHash(lineNumber, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
378
- }
379
-
380
375
  function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
381
376
  return (
382
377
  typeof value === "object" &&
@@ -416,7 +411,7 @@ export async function* streamHashLinesFromUtf8(
416
411
  let pending = "";
417
412
  let sawAnyText = false;
418
413
  let endedWithNewline = false;
419
- const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
414
+ const emitter = createHashlineChunkEmitter(resolvedOptions);
420
415
 
421
416
  const consumeText = (text: string): string[] => {
422
417
  if (text.length === 0) return [];
@@ -469,7 +464,7 @@ export async function* streamHashLinesFromLines(
469
464
  options: HashlineStreamOptions = {},
470
465
  ): AsyncGenerator<string> {
471
466
  const resolvedOptions = resolveHashlineStreamOptions(options);
472
- const emitter = createHashlineChunkEmitter(resolvedOptions, formatHashlineStreamLine);
467
+ const emitter = createHashlineChunkEmitter(resolvedOptions);
473
468
  let sawAnyLine = false;
474
469
 
475
470
  const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator];
@@ -530,7 +525,7 @@ const MISMATCH_CONTEXT = 2;
530
525
  /**
531
526
  * Error thrown when one or more hashline references have stale hashes.
532
527
  *
533
- * Displays grep-style output with `:` separator on mismatched lines and `-` on
528
+ * Displays grep-style output with `*` marker on mismatched lines and a leading space on
534
529
  * surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
535
530
  */
536
531
  export class HashlineMismatchError extends Error {
@@ -552,7 +547,7 @@ export class HashlineMismatchError extends Error {
552
547
  /**
553
548
  * User-visible variant of {@link formatMessage} — omits the bigram fingerprint
554
549
  * and uses a `│` gutter so TUI rendering is clean. The model still receives
555
- * the full `LINE+ID:content` form via {@link Error.message}.
550
+ * the full `LINE+ID|content` form via {@link Error.message}.
556
551
  */
557
552
  get displayMessage(): string {
558
553
  return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
@@ -609,7 +604,7 @@ export class HashlineMismatchError extends Error {
609
604
 
610
605
  lines.push(
611
606
  `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
612
- "Use the updated anchors shown below (`:` marks changed lines, `-` marks context) and retry the edit.",
607
+ "Use the updated anchors shown below (`*` marks changed lines, leading space marks context) and retry the edit.",
613
608
  );
614
609
  lines.push("");
615
610
 
@@ -626,9 +621,9 @@ export class HashlineMismatchError extends Error {
626
621
  const prefix = `${lineNum}${hash}`;
627
622
 
628
623
  if (mismatchSet.has(lineNum)) {
629
- lines.push(`${prefix}:${text}`);
624
+ lines.push(`*${prefix}|${text}`);
630
625
  } else {
631
- lines.push(`${prefix}-${text}`);
626
+ lines.push(` ${prefix}|${text}`);
632
627
  }
633
628
  }
634
629
  return lines.join("\n");
@@ -762,7 +757,7 @@ function collectBoundaryDuplicationWarning(edit: HashlineEdit, originalFileLines
762
757
  const trimmedNext = nextSurvivingLine.trim();
763
758
  const trimmedLast = lastInsertedLine.trim();
764
759
  if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
765
- const tag = formatLineHash(endLine + 1, nextSurvivingLine);
760
+ const tag = formatHashLine(endLine + 1, nextSurvivingLine);
766
761
  warnings.push(
767
762
  `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
768
763
  `If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
@@ -1065,7 +1060,6 @@ export interface CompactHashlineDiffPreview {
1065
1060
 
1066
1061
  export interface CompactHashlineDiffOptions {
1067
1062
  maxUnchangedRun?: number;
1068
- maxAdditionRun?: number;
1069
1063
  maxDeletionRun?: number;
1070
1064
  maxOutputLines?: number;
1071
1065
  }
@@ -1221,7 +1215,6 @@ export function buildCompactHashlineDiffPreview(
1221
1215
  options: CompactHashlineDiffOptions = {},
1222
1216
  ): CompactHashlineDiffPreview {
1223
1217
  const maxUnchangedRun = options.maxUnchangedRun ?? 2;
1224
- const maxAdditionRun = options.maxAdditionRun ?? 2;
1225
1218
  const maxDeletionRun = options.maxDeletionRun ?? 2;
1226
1219
  const maxOutputLines = options.maxOutputLines ?? 16;
1227
1220
 
@@ -1240,7 +1233,7 @@ export function buildCompactHashlineDiffPreview(
1240
1233
  break;
1241
1234
  case "+":
1242
1235
  addedLines += run.lines.length;
1243
- out.push(...collapseFromStart(run.lines, maxAdditionRun, "added"));
1236
+ out.push(...run.lines);
1244
1237
  break;
1245
1238
  case "-":
1246
1239
  removedLines += run.lines.length;
@@ -94,7 +94,7 @@ interface EditRenderArgs {
94
94
  */
95
95
  previewDiff?: string;
96
96
  __partialJson?: string;
97
- // Hashline / chunk mode fields
97
+ // Hashline mode fields
98
98
  edits?: EditRenderEntry[];
99
99
  }
100
100
 
@@ -141,7 +141,7 @@ export interface EditRenderContext {
141
141
  editMode?: EditMode;
142
142
  /** Pre-computed diff preview (computed before tool executes) */
143
143
  editDiffPreview?: DiffResult | DiffError;
144
- /** Multi-file streaming diff preview (chunk edits spanning several files) */
144
+ /** Multi-file streaming diff preview (edits spanning several files) */
145
145
  perFileDiffPreview?: PerFileDiffPreview[];
146
146
  /** Function to render diff text with syntax highlighting */
147
147
  renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
@@ -151,11 +151,9 @@ const EDIT_STREAMING_PREVIEW_LINES = 12;
151
151
  const CALL_TEXT_PREVIEW_LINES = 6;
152
152
  const CALL_TEXT_PREVIEW_WIDTH = 80;
153
153
 
154
- /** Extract file path from an edit entry's path (handles chunk's file:selector format). */
154
+ /** Extract file path from an edit entry. */
155
155
  function filePathFromEditEntry(p: string | undefined): string | undefined {
156
- if (!p) return undefined;
157
- const ci = /^[a-zA-Z]:[/\\]/.test(p) ? p.indexOf(":", 2) : p.indexOf(":");
158
- return ci === -1 ? p : p.slice(0, ci);
156
+ return p ?? undefined;
159
157
  }
160
158
 
161
159
  function decodePartialJsonStringFragment(fragment: string): string {
@@ -284,7 +282,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
284
282
  const parts: string[] = [];
285
283
  for (const preview of previews) {
286
284
  if (!preview.diff && !preview.error) continue;
287
- const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
285
+ const header = uiTheme.fg("dim", `\n\n\u2500\u2500 ${shortenPath(preview.path)} \u2500\u2500`);
288
286
  if (preview.error) {
289
287
  parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
290
288
  continue;
@@ -303,7 +301,7 @@ function getCallPreview(
303
301
  renderContext: EditRenderContext | undefined,
304
302
  ): string {
305
303
  const multi = renderContext?.perFileDiffPreview;
306
- if (multi && multi.length > 0 && multi.some(p => p.diff || p.error)) {
304
+ if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
307
305
  return formatMultiFileStreamingDiff(multi, uiTheme);
308
306
  }
309
307
  if (args.previewDiff) {
@@ -16,7 +16,6 @@ import type { Theme } from "../modes/theme/theme";
16
16
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
17
17
  import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
18
18
  import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
19
- import { type ChunkToolEdit, computeChunkDiff, parseChunkEditPath } from "./modes/chunk";
20
19
  import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
21
20
  import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
22
21
  import type { ReplaceEditEntry } from "./modes/replace";
@@ -126,6 +125,38 @@ export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: stri
126
125
  return [...edits];
127
126
  }
128
127
 
128
+ // -----------------------------------------------------------------------------
129
+ // Multi-file grouping
130
+ // -----------------------------------------------------------------------------
131
+
132
+ /** Cap on how many distinct files a streaming preview will render diffs for. */
133
+ const MAX_PREVIEW_FILES = 5;
134
+
135
+ /**
136
+ * Group a list of edits by their effective `path` (per-edit `path` falls back
137
+ * to the top-level `args.path`). Insertion order is preserved and the number of
138
+ * distinct buckets is capped at {@link MAX_PREVIEW_FILES}.
139
+ */
140
+ function groupEditsByPath<T extends { path?: string }>(
141
+ edits: readonly T[],
142
+ fallbackPath: string | undefined,
143
+ ): Map<string, Array<T & { path: string }>> {
144
+ const groups = new Map<string, Array<T & { path: string }>>();
145
+ for (const edit of edits) {
146
+ if (!edit) continue;
147
+ const editPath = edit.path ?? fallbackPath;
148
+ if (!editPath) continue;
149
+ let bucket = groups.get(editPath);
150
+ if (!bucket) {
151
+ if (groups.size >= MAX_PREVIEW_FILES) continue;
152
+ bucket = [];
153
+ groups.set(editPath, bucket);
154
+ }
155
+ bucket.push({ ...edit, path: editPath });
156
+ }
157
+ return groups;
158
+ }
159
+
129
160
  // -----------------------------------------------------------------------------
130
161
  // Strategies
131
162
  // -----------------------------------------------------------------------------
@@ -142,22 +173,26 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
142
173
  return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
143
174
  },
144
175
  async computeDiffPreview(args, ctx) {
145
- const first = args.edits?.[0];
146
- if (!first) return null;
147
- const path = first.path ?? args.path;
148
- if (!path || first.old_text === undefined || first.new_text === undefined) return null;
149
- ctx.signal.throwIfAborted();
150
- const result = await computeEditDiff(
151
- path,
152
- first.old_text,
153
- first.new_text,
154
- ctx.cwd,
155
- ctx.allowFuzzy ?? true,
156
- first.all,
157
- ctx.fuzzyThreshold,
158
- );
159
- ctx.signal.throwIfAborted();
160
- return [toPerFilePreview(path, result)];
176
+ const groups = groupEditsByPath(args.edits ?? [], args.path);
177
+ if (groups.size === 0) return null;
178
+ const previews: PerFileDiffPreview[] = [];
179
+ for (const [path, fileEdits] of groups) {
180
+ const first = fileEdits[0];
181
+ if (!first || first.old_text === undefined || first.new_text === undefined) continue;
182
+ ctx.signal.throwIfAborted();
183
+ const result = await computeEditDiff(
184
+ path,
185
+ first.old_text,
186
+ first.new_text,
187
+ ctx.cwd,
188
+ ctx.allowFuzzy ?? true,
189
+ first.all,
190
+ ctx.fuzzyThreshold,
191
+ );
192
+ ctx.signal.throwIfAborted();
193
+ previews.push(toPerFilePreview(path, result));
194
+ }
195
+ return previews.length > 0 ? previews : null;
161
196
  },
162
197
  renderStreamingFallback() {
163
198
  return "";
@@ -176,17 +211,22 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
176
211
  return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
177
212
  },
178
213
  async computeDiffPreview(args, ctx) {
179
- const first = args.edits?.[0];
180
- const path = first?.path ?? args.path;
181
- if (!path) return null;
182
- ctx.signal.throwIfAborted();
183
- const result = await computePatchDiff(
184
- { path, op: first?.op ?? "update", rename: first?.rename, diff: first?.diff },
185
- ctx.cwd,
186
- { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
187
- );
188
- ctx.signal.throwIfAborted();
189
- return [toPerFilePreview(path, result)];
214
+ const groups = groupEditsByPath(args.edits ?? [], args.path);
215
+ if (groups.size === 0) return null;
216
+ const previews: PerFileDiffPreview[] = [];
217
+ for (const [path, fileEdits] of groups) {
218
+ const first = fileEdits[0];
219
+ if (!first) continue;
220
+ ctx.signal.throwIfAborted();
221
+ const result = await computePatchDiff(
222
+ { path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
223
+ ctx.cwd,
224
+ { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
225
+ );
226
+ ctx.signal.throwIfAborted();
227
+ previews.push(toPerFilePreview(path, result));
228
+ }
229
+ return previews.length > 0 ? previews : null;
190
230
  },
191
231
  renderStreamingFallback() {
192
232
  return "";
@@ -205,83 +245,14 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
205
245
  return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
206
246
  },
207
247
  async computeDiffPreview(args, ctx) {
208
- const first = args.edits?.[0] as (HashlineToolEdit & { path?: string }) | undefined;
209
- const path = first?.path ?? args.path;
210
- if (!path) return null;
211
- const fileEdits = (args.edits ?? [])
212
- .map(e => {
213
- if (!e || typeof e !== "object") return undefined;
214
- const entryPath = (e as { path?: string }).path ?? args.path;
215
- if (!entryPath || entryPath !== path) return undefined;
216
- return { ...(e as HashlineToolEdit), path } as HashlineToolEdit & { path: string };
217
- })
218
- .filter((e): e is HashlineToolEdit & { path: string } => e !== undefined);
219
- ctx.signal.throwIfAborted();
220
- const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
221
- ctx.signal.throwIfAborted();
222
- return [toPerFilePreview(path, result)];
223
- },
224
- renderStreamingFallback() {
225
- return "";
226
- },
227
- };
228
-
229
- interface ChunkArgs {
230
- path?: string;
231
- edits?: ChunkToolEdit[];
232
- __partialJson?: string;
233
- }
234
-
235
- const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
236
- extractCompleteEdits(args, partialJson) {
237
- if (!args?.edits) return args;
238
- let edits = dropIncompleteLastEdit(args.edits, partialJson, "edits");
239
- // Extra guard: if partial JSON still contains `":nu` / `":nul` (partial
240
- // `null` literals), `partial-json` may have already surfaced the last
241
- // entry with `write === null`. When that entry's `}` hasn't closed
242
- // yet, it has already been dropped above. But if dropping was not
243
- // triggered (e.g. list still open and no new `{` after), also drop the
244
- // trailing null-write entry so the preview does not flicker with an
245
- // error for an incomplete string/null literal.
246
- if (partialJson && edits.length > 0) {
247
- const last = edits[edits.length - 1] as Partial<ChunkToolEdit> | undefined;
248
- const endsInPartialNull = /:\s*nu?l?\s*$/.test(partialJson.trimEnd());
249
- if (last && endsInPartialNull && last.write === null) {
250
- edits = edits.slice(0, -1);
251
- }
252
- }
253
- return { ...args, edits };
254
- },
255
- async computeDiffPreview(args, ctx) {
256
- const edits = args.edits ?? [];
257
- if (edits.length === 0) return null;
258
- // Group edits by file path
259
- const groups = new Map<string, ChunkToolEdit[]>();
260
- const fileOrder: string[] = [];
261
- for (const edit of edits) {
262
- if (!edit) continue;
263
- const editPath = edit.path ?? args.path;
264
- if (!editPath) continue;
265
- const { filePath } = parseChunkEditPath(editPath);
266
- if (!filePath) continue;
267
- let bucket = groups.get(filePath);
268
- if (!bucket) {
269
- bucket = [];
270
- groups.set(filePath, bucket);
271
- fileOrder.push(filePath);
272
- }
273
- bucket.push({ ...edit, path: editPath });
274
- }
275
- if (fileOrder.length === 0) return null;
276
-
277
- const MAX_FILES = 5;
278
- const selected = fileOrder.slice(0, MAX_FILES);
248
+ const groups = groupEditsByPath(args.edits ?? [], args.path);
249
+ if (groups.size === 0) return null;
279
250
  const previews: PerFileDiffPreview[] = [];
280
- for (const filePath of selected) {
251
+ for (const [path, fileEdits] of groups) {
281
252
  ctx.signal.throwIfAborted();
282
- const fileEdits = groups.get(filePath) ?? [];
283
- const result = await computeChunkDiff({ path: filePath, edits: fileEdits }, ctx.cwd, { signal: ctx.signal });
284
- previews.push(toPerFilePreview(filePath, result));
253
+ const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
254
+ ctx.signal.throwIfAborted();
255
+ previews.push(toPerFilePreview(path, result));
285
256
  }
286
257
  return previews;
287
258
  },
@@ -311,16 +282,22 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
311
282
  return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
312
283
  }
313
284
  }
314
- const first = entries[0];
315
- if (!first?.path) return null;
316
- ctx.signal.throwIfAborted();
317
- const result = await computePatchDiff(
318
- { path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
319
- ctx.cwd,
320
- { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
321
- );
322
- ctx.signal.throwIfAborted();
323
- return [toPerFilePreview(first.path, result)];
285
+ const groups = groupEditsByPath(entries, undefined);
286
+ if (groups.size === 0) return null;
287
+ const previews: PerFileDiffPreview[] = [];
288
+ for (const [path, fileEntries] of groups) {
289
+ const first = fileEntries[0];
290
+ if (!first) continue;
291
+ ctx.signal.throwIfAborted();
292
+ const result = await computePatchDiff(
293
+ { path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
294
+ ctx.cwd,
295
+ { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
296
+ );
297
+ ctx.signal.throwIfAborted();
298
+ previews.push(toPerFilePreview(path, result));
299
+ }
300
+ return previews.length > 0 ? previews : null;
324
301
  },
325
302
  renderStreamingFallback() {
326
303
  return "";
@@ -365,7 +342,6 @@ export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknow
365
342
  replace: replaceStrategy as EditStreamingStrategy<unknown>,
366
343
  patch: patchStrategy as EditStreamingStrategy<unknown>,
367
344
  hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
368
- chunk: chunkStrategy as EditStreamingStrategy<unknown>,
369
345
  apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
370
346
  vim: vimStrategy,
371
347
  atom: atomStrategy as EditStreamingStrategy<unknown>,