@oh-my-pi/pi-coding-agent 14.5.2 → 14.5.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.
@@ -1,22 +1,24 @@
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`, `splice`, `post`).
4
+ * one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`, `sed`).
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", splice: ["..."] }
10
- * { path, loc: "5th", pre: ["..."] }
11
- * { path, loc: "5th", post: ["..."] }
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: { pat, rep, g?, F? } } // sed on every line
9
+ * { path, loc: "5th", splice: ["..."] } // line replace
10
+ * { path, loc: "(5th)", splice: ["..."] } // block body replace
11
+ * { path, loc: "[5th]", splice: ["..."] } // whole node replace
12
+ * { path, loc: "[5th", splice: ["..."] } // anchor (incl) closer-1
13
+ * { path, loc: "5th]", splice: ["..."] } // opener+1 anchor (incl)
14
+ * { path, loc: "5th", pre: [...], splice: [...], post: [...] } // line verbs combinable
15
+ * { path, loc: "$", pre: [...] | post: [...] | sed: {...} } // file-scoped
16
16
  *
17
- * `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
18
- * a blank line. Line ranges are not supported.
19
- * in the same entry.
17
+ * `splice: []` deletes; `splice: [""]` replaces with a single blank line. These
18
+ * apply uniformly to single-line and bracketed (region) locators.
19
+ *
20
+ * Bracket forms in `loc` are reserved for `splice` (region replacement). `pre`,
21
+ * `post`, and `sed` reject bracketed locators — they are line-only.
20
22
  *
21
23
  * For deleting or moving files, the agent should use bash.
22
24
  */
@@ -29,7 +31,9 @@ import { assertEditableFileContent } from "../../tools/auto-generated-guard";
29
31
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
30
32
  import { outputMeta } from "../../tools/output-meta";
31
33
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
34
+ import { checkBodyBraceBalance, type DelimiterKind, findEnclosingBlock } from "../block";
32
35
  import { generateDiffString } from "../diff";
36
+ import { applyIndent, detectIndentStyle, stripCommonIndent } from "../indent";
33
37
  import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
34
38
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
35
39
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
@@ -59,7 +63,7 @@ const textSchema = Type.Array(Type.String());
59
63
  export const atomEditSchema = Type.Object(
60
64
  {
61
65
  loc: Type.String({
62
- description: 'edit location: "1ab", "$", or path override like "a.ts:1ab"',
66
+ description: "edit location",
63
67
  examples: ["1ab", "$", "src/foo.ts:1ab"],
64
68
  }),
65
69
  splice: Type.Optional(textSchema),
@@ -68,10 +72,9 @@ export const atomEditSchema = Type.Object(
68
72
  sed: Type.Optional(
69
73
  Type.Object(
70
74
  {
71
- pat: Type.String({ description: "pattern to find" }),
72
- rep: Type.String({ description: "replacement text" }),
73
- g: Type.Optional(Type.Boolean({ description: "global replace", default: false })),
74
- F: Type.Optional(Type.Boolean({ description: "literal replace", default: false })),
75
+ pat: Type.String({ description: "regex" }),
76
+ rep: Type.String({ description: "expression to replace with" }),
77
+ g: Type.Optional(Type.Boolean({ description: "global flag", default: false })),
75
78
  },
76
79
  {
77
80
  additionalProperties: false,
@@ -105,13 +108,41 @@ export type AtomEdit =
105
108
  | { op: "append_file"; lines: string[] }
106
109
  | { op: "prepend_file"; lines: string[] }
107
110
  | { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
108
- | { op: "sed_file"; spec: SedSpec; expression: string };
111
+ | { op: "sed_file"; spec: SedSpec; expression: string }
112
+ | { op: "splice_block"; pos: Anchor; spec: SpliceBlockSpec; bracket: BracketShape };
109
113
 
110
114
  export interface SedSpec {
111
115
  pattern: string;
112
116
  replacement: string;
113
117
  global: boolean;
114
- literal: boolean;
118
+ }
119
+
120
+ export interface SpliceBlockSpec {
121
+ body: string[];
122
+ kind: DelimiterKind;
123
+ }
124
+
125
+ type BracketShape = "none" | "body" | "node" | "left_incl" | "left_excl" | "right_incl" | "right_excl";
126
+
127
+ // File-extension lookup for the block delimiter family used when `loc`
128
+ // has bracket forms. Most languages are brace-family; lisp-family uses `(`.
129
+ // Anything not listed defaults to `{` (covers the long tail of brace-style
130
+ // languages without enumerating every extension).
131
+ const LISP_EXTENSIONS = new Set(["clj", "cljs", "cljc", "edn", "lisp", "lsp", "el", "scm", "ss", "rkt", "fnl"]);
132
+
133
+ function fileExtension(path: string | undefined): string | undefined {
134
+ if (!path) return undefined;
135
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
136
+ const base = slash >= 0 ? path.slice(slash + 1) : path;
137
+ const dot = base.lastIndexOf(".");
138
+ if (dot <= 0) return undefined;
139
+ return base.slice(dot + 1).toLowerCase();
140
+ }
141
+
142
+ function resolveBlockDelimiterForPath(path: string | undefined): DelimiterKind {
143
+ const ext = fileExtension(path);
144
+ if (ext && LISP_EXTENSIONS.has(ext)) return "(";
145
+ return "{";
115
146
  }
116
147
 
117
148
  // ═══════════════════════════════════════════════════════════════════════════
@@ -135,7 +166,9 @@ const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC
135
166
  // `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
136
167
  // fails the anchor pattern.
137
168
  const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
138
- const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
169
+ const PATH_LOC_SPLIT_RE = new RegExp(
170
+ `^(.+?):([\\[(]?${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?[\\])]?)$`,
171
+ );
139
172
 
140
173
  function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
141
174
  let next: Record<string, unknown> | undefined;
@@ -148,7 +181,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
148
181
  return (next ?? fields) as AtomToolEdit;
149
182
  }
150
183
 
151
- type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
184
+ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor; bracket: BracketShape } | { kind: "file" };
152
185
 
153
186
  // ═══════════════════════════════════════════════════════════════════════════
154
187
  // Resolution
@@ -227,28 +260,52 @@ export function resolveAtomEntryPaths(
227
260
  }
228
261
 
229
262
  function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
230
- if (raw === "$") return { kind: "file" };
263
+ const trimmed = raw.trim();
264
+ if (trimmed === "$") return { kind: "file" };
265
+
266
+ const leading = trimmed[0];
267
+ const trailing = trimmed[trimmed.length - 1];
268
+ const hasLeading = leading === "[" || leading === "(";
269
+ const hasTrailing = trailing === "]" || trailing === ")";
270
+ if ((leading === "(" && trailing === "]") || (leading === "[" && trailing === ")")) {
271
+ throw new Error(
272
+ `Edit ${editIndex}: mixed bracket inclusivity in loc is ambiguous; use [anchor, (anchor, anchor], anchor), [anchor], or a bare anchor.`,
273
+ );
274
+ }
275
+
276
+ let inner = trimmed;
277
+ if (hasLeading) inner = inner.slice(1);
278
+ if (hasTrailing) inner = inner.slice(0, -1);
279
+
231
280
  // Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
232
281
  // loc (e.g. line content like `i--`) should not trigger the range error.
233
- const dash = raw.indexOf("-");
282
+ const dash = inner.indexOf("-");
234
283
  if (dash > 0) {
235
- const left = raw.slice(0, dash);
236
- const right = raw.slice(dash + 1);
284
+ const left = inner.slice(0, dash);
285
+ const right = inner.slice(dash + 1);
237
286
  if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
238
287
  throw new Error(
239
288
  `Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
240
289
  );
241
290
  }
242
291
  }
243
- const pos = parseAnchor(raw, "loc");
292
+ const pos = parseAnchor(inner, "loc");
244
293
  // Capture an optional content suffix after the anchor: `82zu| for (...)`.
245
294
  // The suffix acts as a hint for anchor disambiguation when the model's hash
246
295
  // is wrong but the content reveals the intended line.
247
- const hint = extractAnchorContentHint(raw);
296
+ const hint = extractAnchorContentHint(inner);
248
297
  if (hint !== undefined) {
249
298
  pos.contentHint = hint;
250
299
  }
251
- return { kind: "anchor", pos };
300
+
301
+ let bracket: BracketShape = "none";
302
+ if (leading === "[" && trailing === "]") bracket = "node";
303
+ else if (leading === "[") bracket = "left_incl";
304
+ else if (leading === "(" && trailing === ")") bracket = "body";
305
+ else if (leading === "(") bracket = "left_excl";
306
+ else if (trailing === "]") bracket = "right_incl";
307
+ else if (trailing === ")") bracket = "right_excl";
308
+ return { kind: "anchor", pos, bracket };
252
309
  }
253
310
 
254
311
  function extractAnchorContentHint(raw: string): string | undefined {
@@ -266,7 +323,7 @@ function extractAnchorContentHint(raw: string): string | undefined {
266
323
 
267
324
  function parseSedSpec(input: unknown, editIndex: number): SedSpec {
268
325
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
269
- throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g?, F?}.`);
326
+ throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g?}.`);
270
327
  }
271
328
  const obj = input as Record<string, unknown>;
272
329
  const pat = obj.pat;
@@ -282,27 +339,24 @@ function parseSedSpec(input: unknown, editIndex: number): SedSpec {
282
339
  if (typeof rep !== "string") {
283
340
  throw new Error(`Edit ${editIndex}: sed.rep must be a string.`);
284
341
  }
285
- const readBool = (key: "g" | "F", defaultValue: boolean): boolean => {
286
- const v = obj[key];
287
- if (v === undefined) return defaultValue;
288
- if (typeof v !== "boolean") {
289
- throw new Error(`Edit ${editIndex}: sed.${key} must be a boolean when provided.`);
342
+ const rawGlobal = obj.g;
343
+ let global = false;
344
+ if (rawGlobal !== undefined) {
345
+ if (typeof rawGlobal !== "boolean") {
346
+ throw new Error(`Edit ${editIndex}: sed.g must be a boolean when provided.`);
290
347
  }
291
- return v;
292
- };
293
- const global = readBool("g", false);
294
- const literal = readBool("F", false);
295
- return { pattern: pat, replacement: rep, global, literal };
348
+ global = rawGlobal;
349
+ }
350
+ return { pattern: pat, replacement: rep, global };
296
351
  }
297
352
 
298
353
  function formatSedExpression(spec: SedSpec): string {
299
- const obj: { pat: string; rep: string; g?: boolean; F?: boolean } = {
354
+ const obj: { pat: string; rep: string; g?: boolean } = {
300
355
  pat: spec.pattern,
301
356
  rep: spec.replacement,
302
357
  };
303
358
  // Only emit non-default flags so error messages stay compact (g defaults false).
304
359
  if (spec.global) obj.g = true;
305
- if (spec.literal) obj.F = true;
306
360
  return JSON.stringify(obj);
307
361
  }
308
362
 
@@ -322,9 +376,6 @@ function applySedToLine(
322
376
  currentLine: string,
323
377
  spec: SedSpec,
324
378
  ): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
325
- if (spec.literal) {
326
- return applyLiteralSed(currentLine, spec);
327
- }
328
379
  let flags = "";
329
380
  if (spec.global) flags += "g";
330
381
  let re: RegExp | undefined;
@@ -367,7 +418,7 @@ function classifyAtomEdit(edit: AtomToolEdit): string {
367
418
  return verbs.length > 0 ? verbs.join("+") : "unknown";
368
419
  }
369
420
 
370
- function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
421
+ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0, path?: string): AtomEdit[] {
371
422
  const entry = stripNullAtomFields(edit);
372
423
  const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
373
424
  if (verbKeysPresent.length === 0) {
@@ -399,6 +450,25 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
399
450
  return resolved;
400
451
  }
401
452
 
453
+ if (loc.bracket !== "none") {
454
+ // Bracketed locator: only `splice` is meaningful (region replacement).
455
+ const hasInvalidVerb = entry.pre !== undefined || entry.post !== undefined || entry.sed !== undefined;
456
+ if (hasInvalidVerb) {
457
+ throw new Error(
458
+ `Edit ${editIndex}: bracket forms in loc are splice-only; remove pre/post/sed or use a bare anchor.`,
459
+ );
460
+ }
461
+ if (entry.splice === undefined) {
462
+ throw new Error(
463
+ `Edit ${editIndex}: bracket loc requires \`splice\`. Bare anchors are line-only; brackets address a region.`,
464
+ );
465
+ }
466
+ const kind = resolveBlockDelimiterForPath(path);
467
+ const body = hashlineParseText(entry.splice);
468
+ resolved.push({ op: "splice_block", pos: loc.pos, spec: { body, kind }, bracket: loc.bracket });
469
+ return resolved;
470
+ }
471
+
402
472
  if (entry.pre !== undefined) {
403
473
  resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
404
474
  }
@@ -442,6 +512,7 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
442
512
  case "post":
443
513
  case "del":
444
514
  case "sed":
515
+ case "splice_block":
445
516
  yield edit.pos;
446
517
  return;
447
518
  default:
@@ -542,6 +613,151 @@ export interface AtomNoopEdit {
542
613
  current: string;
543
614
  }
544
615
 
616
+ interface SpliceBlockApplyResult {
617
+ text: string;
618
+ firstChangedLine: number | undefined;
619
+ }
620
+
621
+ type SpliceBlockEdit = Extract<AtomEdit, { op: "splice_block" }>;
622
+
623
+ function lineStartOffset(text: string, line: number): number {
624
+ let currentLine = 1;
625
+ let offset = 0;
626
+ while (offset < text.length && currentLine < line) {
627
+ if (text[offset] === "\n") currentLine++;
628
+ offset++;
629
+ }
630
+ return offset;
631
+ }
632
+
633
+ function lineEndOffset(text: string, line: number): number {
634
+ let offset = lineStartOffset(text, line);
635
+ while (offset < text.length && text[offset] !== "\n") offset++;
636
+ return offset;
637
+ }
638
+
639
+ function lineEndIncludingNewlineOffset(text: string, line: number): number {
640
+ const offset = lineEndOffset(text, line);
641
+ return text[offset] === "\n" ? offset + 1 : offset;
642
+ }
643
+
644
+ function spliceBlockLocatorLabel(bracket: BracketShape): string {
645
+ switch (bracket) {
646
+ case "none":
647
+ case "body":
648
+ return "(anchor)";
649
+ case "node":
650
+ return "[anchor]";
651
+ case "left_incl":
652
+ return "[anchor";
653
+ case "left_excl":
654
+ return "(anchor";
655
+ case "right_incl":
656
+ return "anchor]";
657
+ case "right_excl":
658
+ return "anchor)";
659
+ }
660
+ }
661
+
662
+ function applySpliceBlockEdits(
663
+ originalText: string,
664
+ edits: SpliceBlockEdit[],
665
+ warnings: string[],
666
+ ): SpliceBlockApplyResult {
667
+ // Sort by anchor line descending so applying earlier ops doesn't shift
668
+ // later anchors. (Multiple splice_block ops within one call are assumed
669
+ // non-overlapping; overlapping ranges are not supported.)
670
+ const sorted = [...edits].sort((a, b) => b.pos.line - a.pos.line);
671
+ const destStyle = detectIndentStyle(originalText);
672
+ let text = originalText;
673
+ let firstChangedLine: number | undefined;
674
+
675
+ for (const edit of sorted) {
676
+ const kind: DelimiterKind = edit.spec.kind;
677
+ const found = findEnclosingBlock(text, edit.pos.line, { kind, depth: 0 });
678
+ if ("message" in found) {
679
+ throw new Error(`splice at anchor ${edit.pos.line}: ${found.message}`);
680
+ }
681
+ const replacedLineCount = found.closeLine - found.openLine + 1;
682
+ warnings.push(
683
+ `splice locator ${spliceBlockLocatorLabel(edit.bracket)} replaced \`${kind}\` block at lines ${found.openLine}-${found.closeLine} ` +
684
+ `(${replacedLineCount} lines, 1 of ${found.enclosingCount} enclosing \`${kind}\` blocks).`,
685
+ );
686
+ const balanceErr = checkBodyBraceBalance(edit.spec.body.join("\n"), kind);
687
+ if (balanceErr) {
688
+ throw new Error(`splice at anchor ${edit.pos.line}: ${balanceErr}`);
689
+ }
690
+
691
+ const stripped = stripCommonIndent(edit.spec.body);
692
+ const bodyPrefix = found.bodyLineIndent ?? `${found.openerLineIndent}${defaultIndentUnit(destStyle)}`;
693
+
694
+ let replacementText: string;
695
+ let replaceStart: number;
696
+ let replaceEnd: number;
697
+
698
+ switch (edit.bracket) {
699
+ case "node": {
700
+ const indented = applyIndent(stripped, found.openerLineIndent, destStyle);
701
+ replacementText = indented.join("\n");
702
+ replaceStart = found.openLineStart;
703
+ replaceEnd = found.closeOffsetExclusive;
704
+ break;
705
+ }
706
+ case "left_incl":
707
+ case "left_excl": {
708
+ const indented = applyIndent(stripped, bodyPrefix, destStyle);
709
+ replacementText = `${indented.join("\n")}\n${found.openerLineIndent}`;
710
+ replaceStart =
711
+ edit.bracket === "left_incl"
712
+ ? lineStartOffset(text, edit.pos.line)
713
+ : lineEndIncludingNewlineOffset(text, edit.pos.line);
714
+ replaceEnd = found.bodyEnd;
715
+ break;
716
+ }
717
+ case "right_incl":
718
+ case "right_excl": {
719
+ const indented = applyIndent(stripped, bodyPrefix, destStyle);
720
+ replacementText = `\n${indented.join("\n")}\n`;
721
+ replaceStart = found.bodyStart;
722
+ replaceEnd =
723
+ edit.bracket === "right_incl"
724
+ ? lineEndIncludingNewlineOffset(text, edit.pos.line)
725
+ : lineStartOffset(text, edit.pos.line);
726
+ break;
727
+ }
728
+ case "none":
729
+ case "body": {
730
+ const goInline = found.sameLine && stripped.length === 1;
731
+ if (goInline) {
732
+ const single = stripped.length === 0 ? "" : stripped[0]!.trim();
733
+ const pad = kind === "{" ? " " : "";
734
+ replacementText = single.length > 0 ? `${pad}${single}${pad}` : pad;
735
+ } else {
736
+ const indented = applyIndent(stripped, bodyPrefix, destStyle);
737
+ replacementText = `\n${indented.join("\n")}\n${found.openerLineIndent}`;
738
+ }
739
+ replaceStart = found.bodyStart;
740
+ replaceEnd = found.bodyEnd;
741
+ break;
742
+ }
743
+ }
744
+
745
+ const before = text.slice(0, replaceStart);
746
+ const after = text.slice(replaceEnd);
747
+ const newText = before + replacementText + after;
748
+
749
+ text = newText;
750
+ if (firstChangedLine === undefined || found.openLine < firstChangedLine) {
751
+ firstChangedLine = found.openLine;
752
+ }
753
+ }
754
+ return { text, firstChangedLine };
755
+ }
756
+
757
+ function defaultIndentUnit(style: { kind: "tab" | "space"; width: number }): string {
758
+ return style.kind === "tab" ? "\t" : " ".repeat(Math.max(1, style.width));
759
+ }
760
+
545
761
  export function applyAtomEdits(
546
762
  text: string,
547
763
  edits: AtomEdit[],
@@ -578,6 +794,26 @@ export function applyAtomEdits(
578
794
  }
579
795
  validateNoConflictingAnchorOps(effective);
580
796
 
797
+ // splice_block ops own their entire block range. To keep line numbers sane,
798
+ // they cannot mix with other anchor-scoped ops in the same call. They may
799
+ // coexist with each other (sorted by openLine descending so earlier ops
800
+ // don't shift later anchors).
801
+ const spliceBlockEdits = effective.filter(
802
+ (e): e is Extract<AtomEdit, { op: "splice_block" }> => e.op === "splice_block",
803
+ );
804
+ if (spliceBlockEdits.length > 0) {
805
+ const result = applySpliceBlockEdits(text, spliceBlockEdits, warnings);
806
+ if (result.firstChangedLine !== undefined) {
807
+ if (firstChangedLine === undefined || result.firstChangedLine < firstChangedLine) {
808
+ firstChangedLine = result.firstChangedLine;
809
+ }
810
+ }
811
+ // Continue pipeline against the post-splice_block text.
812
+ fileLines.length = 0;
813
+ for (const line of result.text.split("\n")) fileLines.push(line);
814
+ effective = effective.filter(e => e.op !== "splice_block");
815
+ }
816
+
581
817
  const trackFirstChanged = (line: number) => {
582
818
  if (firstChangedLine === undefined || line < firstChangedLine) {
583
819
  firstChangedLine = line;
@@ -661,7 +897,7 @@ export function applyAtomEdits(
661
897
  }
662
898
  if (literalFallback) {
663
899
  warnings.push(
664
- `sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
900
+ `sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Escape regex metacharacters in the pattern to match as a regex.`,
665
901
  );
666
902
  }
667
903
  replacement = [result];
@@ -753,7 +989,7 @@ export function applyAtomEdits(
753
989
  anyMatched = true;
754
990
  if (r.literalFallback && !warnedLiteralFallback) {
755
991
  warnings.push(
756
- `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.`,
992
+ `sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Escape regex metacharacters in the pattern to match as a regex.`,
757
993
  );
758
994
  warnedLiteralFallback = true;
759
995
  }
@@ -797,7 +1033,7 @@ export async function executeAtomSingle(
797
1033
  ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
798
1034
  const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
799
1035
 
800
- const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i));
1036
+ const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i, path));
801
1037
 
802
1038
  enforcePlanModeWrite(session, path, { op: "update" });
803
1039
 
package/src/lsp/utils.ts CHANGED
@@ -4,6 +4,7 @@ import * as fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
6
  import { type Theme, theme } from "../modes/theme/theme";
7
+ import { formatGroupedFiles } from "../tools/grouped-file-output";
7
8
  import { resolveToCwd } from "../tools/path-utils";
8
9
  import type {
9
10
  CodeAction,
@@ -154,7 +155,7 @@ const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/;
154
155
  /**
155
156
  * Reformat pre-formatted diagnostic messages into grep-style directory/file groups.
156
157
  * Input: ["path:line:col [sev] msg", ...]
157
- * Output: "# dir\n## └─ file.ts\n line:col [sev] msg"
158
+ * Output: "# dir/\n## file.ts\n line:col [sev] msg"
158
159
  *
159
160
  * Messages that don't match the expected format are appended ungrouped at the end.
160
161
  */
@@ -183,41 +184,10 @@ export function formatGroupedDiagnosticMessages(messages: string[]): string {
183
184
  return ungrouped.join("\n");
184
185
  }
185
186
 
186
- const filesByDirectory = new Map<string, string[]>();
187
- for (const filePath of fileOrder) {
188
- const directory = path.dirname(filePath).replace(/\\/g, "/");
189
- if (!filesByDirectory.has(directory)) {
190
- filesByDirectory.set(directory, []);
191
- }
192
- filesByDirectory.get(directory)?.push(filePath);
193
- }
194
-
195
- const lines: string[] = [];
196
- for (const [directory, directoryFiles] of filesByDirectory) {
197
- if (directory === ".") {
198
- for (const filePath of directoryFiles) {
199
- if (lines.length > 0) {
200
- lines.push("");
201
- }
202
- lines.push(`# ${path.basename(filePath)}`);
203
- for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
204
- lines.push(` ${diagnostic}`);
205
- }
206
- }
207
- continue;
208
- }
209
-
210
- if (lines.length > 0) {
211
- lines.push("");
212
- }
213
- lines.push(`# ${directory}`);
214
- for (const filePath of directoryFiles) {
215
- lines.push(`## └─ ${path.basename(filePath)}`);
216
- for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
217
- lines.push(` ${diagnostic}`);
218
- }
219
- }
220
- }
187
+ const grouped = formatGroupedFiles(fileOrder, filePath => ({
188
+ modelLines: (diagnosticsByFile.get(filePath) ?? []).map(diagnostic => ` ${diagnostic}`),
189
+ }));
190
+ const lines: string[] = grouped.model;
221
191
 
222
192
  if (ungrouped.length > 0) {
223
193
  lines.push("");
@@ -52,6 +52,7 @@ export class EventController {
52
52
  ttsr_triggered: e => this.#handleTtsrTriggered(e),
53
53
  todo_reminder: e => this.#handleTodoReminder(e),
54
54
  todo_auto_clear: e => this.#handleTodoAutoClear(e),
55
+ irc_message: e => this.#handleIrcMessage(e),
55
56
  } satisfies AgentSessionEventHandlers;
56
57
  }
57
58
 
@@ -203,6 +204,17 @@ export class EventController {
203
204
  }
204
205
  }
205
206
 
207
+ async #handleIrcMessage(event: Extract<AgentSessionEvent, { type: "irc_message" }>): Promise<void> {
208
+ const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
209
+ if (this.#renderedCustomMessages.has(signature)) {
210
+ return;
211
+ }
212
+ this.#renderedCustomMessages.add(signature);
213
+ this.#resetReadGroup();
214
+ this.ctx.addMessageToChat(event.message);
215
+ this.ctx.ui.requestRender();
216
+ }
217
+
206
218
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
207
219
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
208
220
  this.ctx.streamingMessage = event.message;