@oh-my-pi/pi-coding-agent 14.4.1 → 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 (62) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +7 -7
  3. package/src/cli.ts +0 -1
  4. package/src/config/prompt-templates.ts +0 -30
  5. package/src/config/settings-schema.ts +26 -36
  6. package/src/config/settings.ts +1 -1
  7. package/src/edit/index.ts +1 -53
  8. package/src/edit/line-hash.ts +0 -53
  9. package/src/edit/modes/atom.ts +82 -47
  10. package/src/edit/modes/hashline.ts +6 -8
  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/modes/components/settings-defs.ts +0 -5
  17. package/src/modes/components/tool-execution.ts +2 -5
  18. package/src/modes/controllers/btw-controller.ts +17 -105
  19. package/src/modes/controllers/todo-command-controller.ts +537 -0
  20. package/src/modes/interactive-mode.ts +35 -9
  21. package/src/modes/types.ts +2 -0
  22. package/src/modes/utils/ui-helpers.ts +17 -0
  23. package/src/prompts/system/irc-incoming.md +8 -0
  24. package/src/prompts/system/subagent-system-prompt.md +8 -0
  25. package/src/prompts/tools/ast-grep.md +1 -1
  26. package/src/prompts/tools/atom.md +37 -26
  27. package/src/prompts/tools/bash.md +2 -2
  28. package/src/prompts/tools/grep.md +2 -5
  29. package/src/prompts/tools/irc.md +49 -0
  30. package/src/prompts/tools/job.md +11 -0
  31. package/src/prompts/tools/read.md +12 -13
  32. package/src/prompts/tools/task.md +1 -1
  33. package/src/prompts/tools/todo-write.md +14 -5
  34. package/src/registry/agent-registry.ts +139 -0
  35. package/src/sdk.ts +35 -0
  36. package/src/session/agent-session.ts +217 -5
  37. package/src/session/streaming-output.ts +1 -1
  38. package/src/slash-commands/builtin-registry.ts +24 -0
  39. package/src/task/executor.ts +14 -0
  40. package/src/tools/bash.ts +1 -1
  41. package/src/tools/fetch.ts +18 -6
  42. package/src/tools/fs-cache-invalidation.ts +0 -5
  43. package/src/tools/grep.ts +4 -124
  44. package/src/tools/index.ts +12 -6
  45. package/src/tools/irc.ts +258 -0
  46. package/src/tools/job.ts +489 -0
  47. package/src/tools/match-line-format.ts +7 -6
  48. package/src/tools/output-meta.ts +1 -1
  49. package/src/tools/read.ts +36 -126
  50. package/src/tools/renderers.ts +2 -0
  51. package/src/tools/todo-write.ts +243 -12
  52. package/src/utils/edit-mode.ts +1 -2
  53. package/src/utils/file-display-mode.ts +0 -3
  54. package/src/cli/read-cli.ts +0 -67
  55. package/src/commands/read.ts +0 -33
  56. package/src/edit/modes/chunk.ts +0 -832
  57. package/src/prompts/tools/cancel-job.md +0 -5
  58. package/src/prompts/tools/chunk-edit.md +0 -158
  59. package/src/prompts/tools/poll.md +0 -5
  60. package/src/prompts/tools/read-chunk.md +0 -73
  61. package/src/tools/cancel-job.ts +0 -95
  62. package/src/tools/poll-tool.ts +0 -173
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  *
3
3
  * Flat locator + verb edit mode backed by hashline anchors. Each entry carries
4
- * one shared `loc` selector plus one or more verbs (`pre`, `set`, `post`).
4
+ * one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`).
5
5
  * The runtime resolves those verbs into internal anchor-scoped edits and still
6
6
  * reuses hashline's staleness scheme (`computeLineHash`) verbatim.
7
7
  *
8
8
  * External shapes (one entry):
9
- * { path, loc: "5th", set: ["..."] }
9
+ * { path, loc: "5th", splice: ["..."] }
10
10
  * { path, loc: "5th", pre: ["..."] }
11
11
  * { path, loc: "5th", post: ["..."] }
12
- * { path, loc: "5th", pre: [...], set: [...], post: [...] }
13
- * { path, loc: "^", pre: [...] } // prepend to BOF
14
- * { path, loc: "$", post: [...] } // append to EOF
12
+ * { path, loc: "5th", pre: [...], splice: [...], post: [...] }
13
+ * { path, loc: "$", pre: [...] } // prepend to file
14
+ * { path, loc: "$", post: [...] } // append to file
15
+ * { path, loc: "$", sed: "s/foo/bar/" } // sed on every line
15
16
  *
16
- * `set: []` on a single-anchor locator deletes that line. `set:[""]` preserves
17
+ * `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
17
18
  * a blank line. Line ranges are not supported.
18
19
  * in the same entry.
19
20
  *
@@ -57,12 +58,11 @@ const textSchema = Type.Array(Type.String());
57
58
  */
58
59
  export const atomEditSchema = Type.Object(
59
60
  {
60
- path: Type.Optional(Type.String({ description: "file path override", examples: ["src/foo.ts"] })),
61
61
  loc: Type.String({
62
- description: 'edit location: "1ab", "^", "$", or path override like "a.ts:1ab"',
63
- examples: ["1ab", "^", "$", "src/foo.ts:1ab"],
62
+ description: 'edit location: "1ab", "$", or path override like "a.ts:1ab"',
63
+ examples: ["1ab", "$", "src/foo.ts:1ab"],
64
64
  }),
65
- set: Type.Optional(textSchema),
65
+ splice: Type.Optional(textSchema),
66
66
  pre: Type.Optional(textSchema),
67
67
  post: Type.Optional(textSchema),
68
68
  sed: Type.Optional(
@@ -91,13 +91,14 @@ export type AtomParams = Static<typeof atomEditParamsSchema>;
91
91
  // ═══════════════════════════════════════════════════════════════════════════
92
92
 
93
93
  export type AtomEdit =
94
- | { op: "set"; pos: Anchor; lines: string[] }
94
+ | { op: "splice"; pos: Anchor; lines: string[] }
95
95
  | { op: "pre"; pos: Anchor; lines: string[] }
96
96
  | { op: "post"; pos: Anchor; lines: string[] }
97
97
  | { op: "del"; pos: Anchor }
98
98
  | { op: "append_file"; lines: string[] }
99
99
  | { op: "prepend_file"; lines: string[] }
100
- | { op: "sed"; pos: Anchor; spec: SedSpec; expression: string };
100
+ | { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
101
+ | { op: "sed_file"; spec: SedSpec; expression: string };
101
102
 
102
103
  export interface SedSpec {
103
104
  pattern: string;
@@ -111,9 +112,9 @@ export interface SedSpec {
111
112
  // Param guards
112
113
  // ═══════════════════════════════════════════════════════════════════════════
113
114
 
114
- const ATOM_VERB_KEYS = ["set", "pre", "post", "sed"] as const;
115
- type AtomOptionalKey = "path" | "loc" | (typeof ATOM_VERB_KEYS)[number];
116
- const ATOM_OPTIONAL_KEYS = ["path", "loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
115
+ const ATOM_VERB_KEYS = ["splice", "pre", "post", "sed"] as const;
116
+ type AtomOptionalKey = "loc" | (typeof ATOM_VERB_KEYS)[number];
117
+ const ATOM_OPTIONAL_KEYS = ["loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
117
118
 
118
119
  // Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
119
120
  // optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
@@ -141,7 +142,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
141
142
  return (next ?? fields) as AtomToolEdit;
142
143
  }
143
144
 
144
- type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind: "eof" };
145
+ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
145
146
 
146
147
  // ═══════════════════════════════════════════════════════════════════════════
147
148
  // Resolution
@@ -203,10 +204,10 @@ function resolveAtomEntryPath(
203
204
  loc = split[2]!;
204
205
  }
205
206
  }
206
- const path = pathOverride || entry.path || topLevelPath;
207
+ const path = pathOverride || topLevelPath;
207
208
  if (!path) {
208
209
  throw new Error(
209
- `Edit ${editIndex}: missing path. Provide a top-level path, per-entry path, or prefix loc with a file path (for example "a.ts:160sr").`,
210
+ `Edit ${editIndex}: missing path. Provide a top-level path or prefix loc with a file path (for example "a.ts:160sr").`,
210
211
  );
211
212
  }
212
213
  return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
@@ -220,8 +221,7 @@ export function resolveAtomEntryPaths(
220
221
  }
221
222
 
222
223
  function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
223
- if (raw === "^") return { kind: "bof" };
224
- if (raw === "$") return { kind: "eof" };
224
+ if (raw === "$") return { kind: "file" };
225
225
  // Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
226
226
  // loc (e.g. line content like `i--`) should not trigger the range error.
227
227
  const dash = raw.indexOf("-");
@@ -230,7 +230,7 @@ function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
230
230
  const right = raw.slice(dash + 1);
231
231
  if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
232
232
  throw new Error(
233
- `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr", "^", or "$".`,
233
+ `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
234
234
  );
235
235
  }
236
236
  }
@@ -387,57 +387,54 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
387
387
  );
388
388
  }
389
389
  if (typeof entry.loc !== "string") {
390
- throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr", "^", or "$".`);
390
+ throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr" or "$".`);
391
391
  }
392
392
 
393
393
  const loc = parseLoc(entry.loc, editIndex);
394
394
  const resolved: AtomEdit[] = [];
395
395
 
396
- if (loc.kind === "bof") {
397
- if (entry.set !== undefined || entry.post !== undefined || entry.sed !== undefined) {
398
- throw new Error(`Edit ${editIndex}: loc "^" only supports pre.`);
396
+ if (loc.kind === "file") {
397
+ if (entry.splice !== undefined) {
398
+ throw new Error(`Edit ${editIndex}: loc "$" supports pre, post, and sed (not splice).`);
399
399
  }
400
400
  if (entry.pre !== undefined) {
401
401
  resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
402
402
  }
403
- return resolved;
404
- }
405
-
406
- if (loc.kind === "eof") {
407
- if (entry.set !== undefined || entry.pre !== undefined || entry.sed !== undefined) {
408
- throw new Error(`Edit ${editIndex}: loc "$" only supports post.`);
409
- }
410
403
  if (entry.post !== undefined) {
411
404
  resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
412
405
  }
406
+ if (entry.sed !== undefined) {
407
+ const spec = parseSedExpression(entry.sed, editIndex);
408
+ resolved.push({ op: "sed_file", spec, expression: entry.sed });
409
+ }
413
410
  return resolved;
414
411
  }
415
412
 
416
413
  if (entry.pre !== undefined) {
417
414
  resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
418
415
  }
419
- if (entry.set !== undefined) {
420
- if (Array.isArray(entry.set) && entry.set.length === 0) {
421
- // Models often default `set: []` alongside other verbs (notably `sed`).
416
+ if (entry.splice !== undefined) {
417
+ if (Array.isArray(entry.splice) && entry.splice.length === 0) {
418
+ // Models often default `splice: []` alongside other verbs (notably `sed`).
422
419
  // Treating that combination as an explicit `del` produces a confusing
423
420
  // `Conflicting ops` error. When another mutating verb is present, drop
424
- // the empty `set` instead of treating it as a deletion.
421
+ // the empty `splice` instead of treating it as a deletion.
425
422
  if (entry.sed === undefined) {
426
423
  resolved.push({ op: "del", pos: loc.pos });
427
424
  }
428
425
  } else {
429
- resolved.push({ op: "set", pos: loc.pos, lines: hashlineParseText(entry.set) });
426
+ resolved.push({ op: "splice", pos: loc.pos, lines: hashlineParseText(entry.splice) });
430
427
  }
431
428
  }
432
429
  if (entry.post !== undefined) {
433
430
  resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
434
431
  }
435
432
  if (entry.sed !== undefined) {
436
- const setIsExplicitReplacement = Array.isArray(entry.set) && entry.set.length > 0;
437
- // Models often duplicate intent by sending both an explicit `set` and a
433
+ const spliceIsExplicitReplacement = Array.isArray(entry.splice) && entry.splice.length > 0;
434
+ // Models often duplicate intent by sending both an explicit `splice` and a
438
435
  // matching `sed`. The explicit replacement wins; the redundant `sed` would
439
436
  // otherwise trigger a confusing `Conflicting ops` rejection.
440
- if (!setIsExplicitReplacement) {
437
+ if (!spliceIsExplicitReplacement) {
441
438
  const spec = parseSedExpression(entry.sed, editIndex);
442
439
  resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
443
440
  }
@@ -451,7 +448,7 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
451
448
 
452
449
  function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
453
450
  switch (edit.op) {
454
- case "set":
451
+ case "splice":
455
452
  case "pre":
456
453
  case "post":
457
454
  case "del":
@@ -527,16 +524,16 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
527
524
  }
528
525
 
529
526
  function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
530
- // For each anchor line, at most one mutating op (set/del).
527
+ // For each anchor line, at most one mutating op (splice/del).
531
528
  // `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
532
529
  const mutatingPerLine = new Map<number, string>();
533
530
  for (const edit of edits) {
534
- if (edit.op !== "set" && edit.op !== "del" && edit.op !== "sed") continue;
531
+ if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
535
532
  const existing = mutatingPerLine.get(edit.pos.line);
536
533
  if (existing) {
537
534
  throw new Error(
538
535
  `Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
539
- `At most one of set/del/sed is allowed per anchor.`,
536
+ `At most one of splice/del/sed is allowed per anchor.`,
540
537
  );
541
538
  }
542
539
  mutatingPerLine.set(edit.pos.line, edit.op);
@@ -551,7 +548,7 @@ function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: stri
551
548
  const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
552
549
  if (!enabled) return;
553
550
  for (const edit of edits) {
554
- if (edit.op !== "set" && edit.op !== "pre" && edit.op !== "post") continue;
551
+ if (edit.op !== "splice" && edit.op !== "pre" && edit.op !== "post") continue;
555
552
  if (edit.lines.length === 0) continue;
556
553
  const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
557
554
  if (!hasEscapedTabs) continue;
@@ -614,13 +611,15 @@ export function applyAtomEdits(
614
611
  // captured idx so multiple pre/post on the same target are emitted in the order
615
612
  // the model produced them.
616
613
  type Indexed<T> = { edit: T; idx: number };
617
- type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>;
614
+ type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" } | { op: "sed_file" }>;
618
615
  const anchorEdits: Indexed<AnchorEdit>[] = [];
619
616
  const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
617
+ const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
620
618
  const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
621
619
  edits.forEach((edit, idx) => {
622
620
  if (edit.op === "append_file") appendEdits.push({ edit, idx });
623
621
  else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
622
+ else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
624
623
  else anchorEdits.push({ edit, idx });
625
624
  });
626
625
 
@@ -667,7 +666,7 @@ export function applyAtomEdits(
667
666
  replacementSet = true;
668
667
  anchorDeleted = true;
669
668
  break;
670
- case "set":
669
+ case "splice":
671
670
  replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
672
671
  replacementSet = true;
673
672
  anchorMutated = true;
@@ -759,6 +758,42 @@ export function applyAtomEdits(
759
758
  trackFirstChanged(insertIdx + 1);
760
759
  }
761
760
 
761
+ // Apply sed_file ops last so they observe the post-anchor / post-prepend /
762
+ // post-append state of the file. Each op runs across every content line and
763
+ let warnedLiteralFallback = false;
764
+ sedFileEdits.sort((a, b) => a.idx - b.idx);
765
+ for (const { edit } of sedFileEdits) {
766
+ const hasTrailingNewline = fileLines.length > 1 && fileLines[fileLines.length - 1] === "";
767
+ const upper = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
768
+ let anyMatched = false;
769
+ let lastCompileError: string | undefined;
770
+ for (let i = 0; i < upper; i++) {
771
+ const line = fileLines[i] ?? "";
772
+ const r = applySedToLine(line, edit.spec);
773
+ if (r.error) lastCompileError = r.error;
774
+ if (!r.matched) continue;
775
+ anyMatched = true;
776
+ if (r.literalFallback && !warnedLiteralFallback) {
777
+ warnings.push(
778
+ `sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
779
+ );
780
+ warnedLiteralFallback = true;
781
+ }
782
+ if (r.result !== line) {
783
+ fileLines[i] = r.result;
784
+ trackFirstChanged(i + 1);
785
+ }
786
+ }
787
+ if (!anyMatched) {
788
+ if (lastCompileError !== undefined) {
789
+ throw new Error(
790
+ `Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${lastCompileError}`,
791
+ );
792
+ }
793
+ throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
794
+ }
795
+ }
796
+
762
797
  return {
763
798
  lines: fileLines.join("\n"),
764
799
  firstChangedLine,
@@ -59,7 +59,7 @@ const HASHLINE_PREFIX_PLUS_RE = new RegExp(
59
59
  `^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
60
60
  );
61
61
  const DIFF_PLUS_RE = /^[+](?![+])/;
62
- 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+/;
63
63
 
64
64
  type LinePrefixStats = {
65
65
  nonEmpty: number;
@@ -525,7 +525,7 @@ const MISMATCH_CONTEXT = 2;
525
525
  /**
526
526
  * Error thrown when one or more hashline references have stale hashes.
527
527
  *
528
- * 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
529
529
  * surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
530
530
  */
531
531
  export class HashlineMismatchError extends Error {
@@ -604,7 +604,7 @@ export class HashlineMismatchError extends Error {
604
604
 
605
605
  lines.push(
606
606
  `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
607
- "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.",
608
608
  );
609
609
  lines.push("");
610
610
 
@@ -621,9 +621,9 @@ export class HashlineMismatchError extends Error {
621
621
  const prefix = `${lineNum}${hash}`;
622
622
 
623
623
  if (mismatchSet.has(lineNum)) {
624
- lines.push(`${prefix}>${text}`);
624
+ lines.push(`*${prefix}|${text}`);
625
625
  } else {
626
- lines.push(`${prefix}:${text}`);
626
+ lines.push(` ${prefix}|${text}`);
627
627
  }
628
628
  }
629
629
  return lines.join("\n");
@@ -1060,7 +1060,6 @@ export interface CompactHashlineDiffPreview {
1060
1060
 
1061
1061
  export interface CompactHashlineDiffOptions {
1062
1062
  maxUnchangedRun?: number;
1063
- maxAdditionRun?: number;
1064
1063
  maxDeletionRun?: number;
1065
1064
  maxOutputLines?: number;
1066
1065
  }
@@ -1216,7 +1215,6 @@ export function buildCompactHashlineDiffPreview(
1216
1215
  options: CompactHashlineDiffOptions = {},
1217
1216
  ): CompactHashlineDiffPreview {
1218
1217
  const maxUnchangedRun = options.maxUnchangedRun ?? 2;
1219
- const maxAdditionRun = options.maxAdditionRun ?? 2;
1220
1218
  const maxDeletionRun = options.maxDeletionRun ?? 2;
1221
1219
  const maxOutputLines = options.maxOutputLines ?? 16;
1222
1220
 
@@ -1235,7 +1233,7 @@ export function buildCompactHashlineDiffPreview(
1235
1233
  break;
1236
1234
  case "+":
1237
1235
  addedLines += run.lines.length;
1238
- out.push(...collapseFromStart(run.lines, maxAdditionRun, "added"));
1236
+ out.push(...run.lines);
1239
1237
  break;
1240
1238
  case "-":
1241
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>,