@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,103 +1,76 @@
1
- Applies precise file edits using full anchors from `read` output (for example `160sr`).
2
-
3
- Read the file first. Copy the full anchors exactly as shown by `read`.
4
-
5
- <operations>
6
- **Top level**: `{ path, edits: […] }` — `path` is shared by all entries. You may still override the file inside `loc` with forms like `other.ts:160sr`.
7
-
8
- Each entry has one shared locator plus one or more verbs:
9
- - `loc: "160sr"` single anchored line
10
- - `loc: "$"` whole file: `pre` prepends, `post` appends, `sed` substitutes across every line
11
- - `loc: "a.ts:160sr"` cross-file override inside the locator
12
-
13
- Verbs:
14
- - `splice: […]`: lines are spliced in at the anchor.
15
- - `pre: […]`: prepend before the anchor (or at BOF if `loc=$`)
16
- - `post: […]`: append after the anchor (or at EOF if `loc=$`)
17
- - `sed: { pat, rep, g?, F? }` — structured find/replace on the anchor line. **Prefer this over `splice` for token-level changes**
18
- - `pat`: pattern to find (regex by default)
19
- - `rep`: replacement (regex back-refs like `$1`, `$&` available)
20
- - `g`: global — replace every occurrence (default `false`; pass `true` to replace all)
21
- - `F`: literal — treat `pat` as a literal substring (no regex). Use this whenever `pat` contains `||`, `.`, `(`, `?`, `\`, etc. you mean literally.
22
- You **MUST** keep `pat` as short as possible.
23
-
24
- Combination rules:
25
- - On a single-anchor `loc`, you may combine `pre`, `splice`, and `post` in the same entry.
26
- - `splice: []` on a single-anchor `loc` deletes that line.
27
- - `splice:[""]` is **not** delete — it replaces the line with a blank line.
28
- </operations>
1
+ Applies precise file edits using anchors (line+hash).
2
+
3
+ <ops>
4
+ Each call **MUST** have shape `{path:"a.ts",edits:[…]}`. `path` is the default file; you **MAY** override it per edit with `loc:"b.ts:160sr"`.
5
+ Each edit **MUST** have exactly one `loc` and **MUST** include one or more verbs.
6
+
7
+ # Locators
8
+ - `"A"` targets one anchored line. `"$"` targets the whole file: `pre` = BOF, `post` = EOF, `sed` = every line.
9
+ - Bracketed locators are **`splice` only** and select a balanced region around anchor `A`.
10
+ - `"(A)"` = block body. `"[A]"` = whole block/node.
11
+ - `"[A"` / `"(A"` = tail after/including anchor, closer excluded.
12
+ - `"A]"` / `"A)"` = head through/before anchor, opener excluded.
13
+ - Anchor bracketed forms on a body line of the intended block, not the opener line.
14
+ - Do not use bracketed locators on files that do not currently parse.
15
+
16
+ # Verbs
17
+ - `splice:[…]` replaces the anchored line, or the bracketed region. `[]` deletes; `[""]` makes a blank line.
18
+ - `pre:[…]` inserts before the anchor, or BOF with `loc:"$"`.
19
+ - `post:[…]` inserts after the anchor, or EOF with `loc:"$"`.
20
+ </ops>
21
+
22
+ <splice>
23
+ Replaces the anchored line, or the bracketed region.
24
+ - `[]` deletes. `[""]` leaves a blank line.
25
+ - For bracketed `splice`, write body at column 0, it will be re-indented.
26
+ - Do not use bracketed `splice` on broken files, or for single line edits.
27
+ </splice>
28
+
29
+ <sed>
30
+ Use for tiny inline edits: names, operators, literals.
31
+ - Keep `pat` as short as possible, it does not have to be unique.
32
+ - `g:false` by default; set to replace all instead of first.
33
+ </sed>
29
34
 
30
35
  <examples>
31
- All examples below reference the same file:
32
-
33
36
  ```ts title="a.ts"
34
- {{hline 1 "const tag = \"BAD\";"}}
37
+ {{hline 1 "const FALLBACK = \"guest\";"}}
35
38
  {{hline 2 ""}}
36
- {{hline 3 "function beta(x) {"}}
37
- {{hline 4 "\tif (x) {"}}
38
- {{hline 5 "\t\treturn parse(data) || fallback;"}}
39
- {{hline 6 "\t}"}}
40
- {{hline 7 "\treturn null;"}}
41
- {{hline 8 "}"}}
39
+ {{hline 3 "export function label(name) {"}}
40
+ {{hline 4 "\tconst clean = name || FALLBACK;"}}
41
+ {{hline 5 "\treturn clean.trim().toLowerCase();"}}
42
+ {{hline 6 "}"}}
42
43
  ```
43
44
 
44
- # Replace a line with `splice`
45
- `{path:"a.ts",edits:[{loc:{{href 1 "const tag = \"BAD\";"}},splice:["const tag = \"OK\";"]}]}`
46
-
47
- # Combine `pre` + `splice` + `post` in one entry
48
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},pre:["\tvalidate();"],splice:["\tif (!x) {"],post:["\t\tlog();"]}]}`
49
-
50
- # Delete a line with `splice: []`
51
- `{path:"a.ts",edits:[{loc:{{href 7 "\treturn null;"}},splice:[]}]}`
52
-
53
- # Preserve a blank line with `splice:[""]`
45
+ # Single-line replacement:
46
+ `{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";"}},splice:["const FALLBACK = \"anonymous\";"]}]}`
47
+ # Small token edit: prefer `sed`:
48
+ `{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},sed:{pat:"toLowerCase",rep:"toUpperCase"}}]}`
49
+ # Insert before / after an anchor:
50
+ `{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},pre:["\tif (!clean) return FALLBACK;"],post:["\t// normalized label"]}]}`
51
+ # Delete a line vs make it blank:
52
+ `{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[]}]}`
54
53
  `{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[""]}]}`
55
-
56
- # Insert before / after a line
57
- `{path:"a.ts",edits:[{loc:{{href 3 "function beta(x) {"}},pre:["function gamma() {","\tvalidate();","}",""]}]}`
58
-
59
- # Substitute one token with `sed` (regex) — preferred for token-level edits
60
- Use the smallest `pat` that uniquely identifies the change.
61
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:{pat:"\\|\\|",rep:"??"}}]}`
62
-
63
- # Substitute literal text — set `F:true` so `pat` is not parsed as regex
64
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:{pat:"data",rep:"input",F:true}}]}`
65
-
66
- # Comment out a line by capturing the whole content with a regex
67
- Use `$&` (the entire match) inside `rep` to keep the original text and prepend `// `.
68
- `{path:"a.ts",edits:[{loc:{{href 7 "\treturn null;"}},sed:{pat:".+",rep:"// $&"}}]}`
69
-
70
- # Prepend / append at file edges
54
+ # File edges:
71
55
  `{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}`
72
- `{path:"a.ts",edits:[{loc:"$",post:["","export const VERSION = \"1.0.0\";"]}]}`
73
-
74
- # Cross-file override inside `loc`
75
- `{path:"a.ts",edits:[{loc:"b.ts:{{href 1 "const tag = \"BAD\";"}}",splice:["const tag = \"OK\";"]}]}`
76
-
77
- # WRONG: retyping unchanged neighbors inside `splice` duplicates them
78
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},splice:["\tif (x && ready) {","\t\treturn parse(data) ?? fallback;","\t\t//unreachable"]}]}`
79
- The 2nd array element matches existing line 5, which is **not** overwritten, it shifts, so return statement ends up duplicated.
80
-
81
- # RIGHT: split into separate edits
82
- - `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},sed:{pat:"x",rep:"x && ready",g:false}},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},post:["\t\t//unreachable"]}]}`
83
- OR
84
- - `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},splice:["\tif (x && ready) {"]},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},splice:["\t\treturn parse(data) ?? fallback;","\t\t//unreachable"]}]}`
56
+ `{path:"a.ts",edits:[{loc:"$",post:["","export { FALLBACK };"]}]}`
57
+ # Cross-file override:
58
+ `{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";" "config.ts:" ""}},splice:["const FALLBACK = \"anonymous\";"]}]}`
59
+ # Body replacement: use bracketed `splice`, write body at column 0:
60
+ `{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;" "(" ")"}},splice:["if (name == null) return FALLBACK;","const clean = String(name).trim();","return clean || FALLBACK;"]}]}`
61
+ # Whole function replacement: anchor on a body line:
62
+ `{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();" "[" "]"}},splice:["export function label(name) {","\treturn String(name ?? FALLBACK).trim().toLowerCase();","}"]}]}`
63
+ # WRONG: bare-anchor `splice` does not own neighboring lines:
64
+ `{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;"}},splice:["\tconst clean = String(name ?? FALLBACK).trim();","\treturn clean.toLowerCase();"]}]}`
65
+ This replaces only line 4. Original line 5 still shifts down, so the function now has two returns.
66
+ # RIGHT: use a body edit for that rewrite:
67
+ `{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;" "(" ")"}},splice:["const clean = String(name ?? FALLBACK).trim();","return clean.toLowerCase();"]}]}`
85
68
  </examples>
86
69
 
87
70
  <critical>
88
- - Make the minimum exact edit.
89
- - Copy the full anchors exactly as shown by `read/grep` (for example `160sr`, not just `sr`).
90
- - `loc` chooses the target. Verbs describe what to do there.
91
- - On a single-anchor `loc`, you may combine `pre`, `splice`, and `post`.
92
- - `loc:"$"` operates on the whole file: `pre` prepends, `post` appends, `sed` runs across every line.
93
- - `splice: []` deletes the anchored line. `splice:[""]` preserves a blank line.
94
- - Within a single request you may submit edits in any order — the runtime applies them bottom-up so they don't shift each other. After any request that mutates a file, anchors below the mutation are stale on disk; re-read before issuing more edits to that file.
95
- - `splice` operations target the current file content only. Do not try to reference old line text after the file has changed.
96
- - For **small** in-line edits (renaming a token, flipping an operator, tweaking a literal), prefer `sed` over `splice`. The `loc` anchor already pins the line — repeating the entire line in a `splice` array invites hallucinated content. Use the smallest `pat` that uniquely identifies the change on that line; do not pad it with surrounding text just to feel safe. When `pat` contains regex metacharacters you mean literally (e.g. `||`, `.`, `(`, `?`, `\`), set `F:true` to disable regex. `g` is `false` by default — pass `g:true` to replace every occurrence. For multi-line restructuring (wrapping logic, adding new branches, inserting blocks), use `splice`/`pre`/`post` — do **not** stretch `sed` into a rewrite tool.
97
- - When you do use `splice`, re-read the anchored line first and copy it verbatim, changing only the required token(s). Anchor identity does not verify line content, so a hallucinated replacement will silently corrupt the file.
98
- - Anchors are pin points, not region markers. One anchor pins exactly one line. If your change touches N distinct source lines, that is N edits with N anchors — not one big `splice` array intended to cover the whole region. `splice` cannot "replace lines 4 through 7"; it can only splice content in at one anchor.
99
- - You **MUST NOT** include lines in `splice`/`pre`/`post` that already exist immediately adjacent to the anchor in the current file. `splice` does not overwrite the lines below — they shift down — so any neighbor you re-type in your array becomes a duplicate. If your intended replacement contains content that is already on neighboring source lines, split into multiple edits at each real change site instead of one fat `splice`.
100
- - Before issuing a multi-line `splice`, mentally diff each array element against the current file lines at and just below the anchor. Any element that matches a line within ~5 lines of the anchor will become a duplicate after the splice. If you find a match, drop that element and use a separate edit (or `pre`/`post`) at the real change point.
101
- - Text content must be literal file content with matching indentation. If the file uses tabs, use real tabs.
102
- - You **MUST NOT** use this tool to reformat or clean up unrelated code.
71
+ - You **MUST** copy full anchors exactly from a read op (e.g. `160sr`); you **MUST NOT** send only the 2-letter suffix.
72
+ - You **MUST** make the minimum exact edit; you **MUST NOT** reformat unrelated code.
73
+ - A bare anchor **MUST** target one line only; you **MUST** use bracketed `splice` for balanced block rewrites.
74
+ - You **MUST NOT** include unchanged adjacent lines in `splice`/`pre`/`post`; they shift and duplicate.
75
+ - For bracketed `splice`, replacement braces **MUST** be balanced for the selected region.
103
76
  </critical>
@@ -23,7 +23,7 @@ The `read` tool is multi-purpose and more capable than it looks — inspects fil
23
23
  # Filesystem
24
24
  - Reading a directory path returns a list of dirents.
25
25
  {{#if IS_HASHLINE_MODE}}
26
- - Reading a file returns lines prefixed with anchors (line # .. hash .. | .. line content): `41th|def alpha():`
26
+ - Reading a file returns lines prefixed with anchors (line+hash): `41th|def alpha():`
27
27
  {{else}}
28
28
  {{#if IS_LINE_NUMBER_MODE}}
29
29
  - Reading a file returns lines prefixed with line numbers: `41|def alpha():`
@@ -196,7 +196,8 @@ export type AgentSessionEvent =
196
196
  | { type: "retry_fallback_succeeded"; model: string; role: string }
197
197
  | { type: "ttsr_triggered"; rules: Rule[] }
198
198
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
199
- | { type: "todo_auto_clear" };
199
+ | { type: "todo_auto_clear" }
200
+ | { type: "irc_message"; message: CustomMessage };
200
201
 
201
202
  /** Listener function for agent session events */
202
203
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -5997,6 +5998,7 @@ export class AgentSession {
5997
5998
  attribution: "agent",
5998
5999
  timestamp: incomingTimestamp,
5999
6000
  };
6001
+ void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
6000
6002
 
6001
6003
  if (!awaitReply) {
6002
6004
  this.#queueBackgroundExchangeInjection([incomingRecord]);
@@ -6021,6 +6023,7 @@ export class AgentSession {
6021
6023
  attribution: "agent",
6022
6024
  timestamp: Date.now(),
6023
6025
  };
6026
+ void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
6024
6027
  this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
6025
6028
 
6026
6029
  return { replyText };
@@ -13,6 +13,7 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
13
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
14
  import type { ToolSession } from ".";
15
15
  import { createFileRecorder, formatResultPath } from "./file-recorder";
16
+ import { formatGroupedFiles } from "./grouped-file-output";
16
17
  import type { OutputMeta } from "./output-meta";
17
18
  import {
18
19
  hasGlobPathChars,
@@ -204,7 +205,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
204
205
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
205
206
  const outputLines: string[] = [];
206
207
  const displayLines: string[] = [];
207
- const renderChangesForFile = (relativePath: string) => {
208
+ const renderChangesForFile = (relativePath: string): { model: string[]; display: string[] } => {
209
+ const modelOut: string[] = [];
210
+ const displayOut: string[] = [];
208
211
  const fileChanges = changesByFile.get(relativePath) ?? [];
209
212
  const lineNumberWidth = fileChanges.reduce(
210
213
  (width, change) => Math.max(width, String(change.startLine).length),
@@ -222,55 +225,31 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
222
225
  ? `${change.startLine}${computeLineHash(change.startLine, afterFirstLine)}`
223
226
  : `${change.startLine}:${change.startColumn}`;
224
227
  const lineSeparator = useHashLines ? HASHLINE_CONTENT_SEPARATOR : " ";
225
- outputLines.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
226
- outputLines.push(`+${afterRef}${lineSeparator}${afterLine}`);
227
- displayLines.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
228
- displayLines.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
228
+ modelOut.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
229
+ modelOut.push(`+${afterRef}${lineSeparator}${afterLine}`);
230
+ displayOut.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
231
+ displayOut.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
229
232
  }
233
+ return { model: modelOut, display: displayOut };
230
234
  };
231
235
 
232
236
  if (isDirectory) {
233
- const filesByDirectory = new Map<string, string[]>();
234
- for (const relativePath of fileList) {
235
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
236
- if (!filesByDirectory.has(directory)) {
237
- filesByDirectory.set(directory, []);
238
- }
239
- filesByDirectory.get(directory)!.push(relativePath);
240
- }
241
- for (const [directory, directoryFiles] of filesByDirectory) {
242
- if (directory === ".") {
243
- for (const relativePath of directoryFiles) {
244
- if (outputLines.length > 0) {
245
- outputLines.push("");
246
- displayLines.push("");
247
- }
248
- const count = fileReplacementCounts.get(relativePath) ?? 0;
249
- const header = `# ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
250
- outputLines.push(header);
251
- displayLines.push(header);
252
- renderChangesForFile(relativePath);
253
- }
254
- continue;
255
- }
256
- if (outputLines.length > 0) {
257
- outputLines.push("");
258
- displayLines.push("");
259
- }
260
- const dirHeader = `# ${directory}`;
261
- outputLines.push(dirHeader);
262
- displayLines.push(dirHeader);
263
- for (const relativePath of directoryFiles) {
264
- const count = fileReplacementCounts.get(relativePath) ?? 0;
265
- const fileHeader = `## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
266
- outputLines.push(fileHeader);
267
- displayLines.push(fileHeader);
268
- renderChangesForFile(relativePath);
269
- }
270
- }
237
+ const grouped = formatGroupedFiles(fileList, relativePath => {
238
+ const rendered = renderChangesForFile(relativePath);
239
+ const count = fileReplacementCounts.get(relativePath) ?? 0;
240
+ return {
241
+ headerSuffix: ` (${formatCount("replacement", count)})`,
242
+ modelLines: rendered.model,
243
+ displayLines: rendered.display,
244
+ };
245
+ });
246
+ outputLines.push(...grouped.model);
247
+ displayLines.push(...grouped.display);
271
248
  } else {
272
249
  for (const relativePath of fileList) {
273
- renderChangesForFile(relativePath);
250
+ const rendered = renderChangesForFile(relativePath);
251
+ outputLines.push(...rendered.model);
252
+ displayLines.push(...rendered.display);
274
253
  }
275
254
  }
276
255
 
@@ -12,6 +12,7 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
12
12
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
13
13
  import type { ToolSession } from ".";
14
14
  import { createFileRecorder, formatResultPath } from "./file-recorder";
15
+ import { formatGroupedFiles } from "./grouped-file-output";
15
16
  import { formatMatchLine } from "./match-line-format";
16
17
  import type { OutputMeta } from "./output-meta";
17
18
  import {
@@ -184,7 +185,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
184
185
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
185
186
  const outputLines: string[] = [];
186
187
  const displayLines: string[] = [];
187
- const renderMatchesForFile = (relativePath: string) => {
188
+ const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
189
+ const modelOut: string[] = [];
190
+ const displayOut: string[] = [];
188
191
  const fileMatches = matchesByFile.get(relativePath) ?? [];
189
192
  const lineNumberWidth = fileMatches.reduce((width, match) => {
190
193
  const lineCount = match.text.split("\n").length;
@@ -197,61 +200,34 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
197
200
  const lineNumber = match.startLine + index;
198
201
  const isMatch = index === 0;
199
202
  const line = matchLines[index] ?? "";
200
- outputLines.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
201
- displayLines.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
203
+ modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
204
+ displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
202
205
  }
203
206
  if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
204
207
  const serializedMeta = Object.entries(match.metaVariables)
205
208
  .sort(([left], [right]) => left.localeCompare(right))
206
209
  .map(([key, value]) => `${key}=${value}`)
207
210
  .join(", ");
208
- outputLines.push(` meta: ${serializedMeta}`);
209
- displayLines.push(` meta: ${serializedMeta}`);
211
+ modelOut.push(` meta: ${serializedMeta}`);
212
+ displayOut.push(` meta: ${serializedMeta}`);
210
213
  }
211
214
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
212
215
  }
216
+ return { model: modelOut, display: displayOut };
213
217
  };
214
218
 
215
219
  if (isDirectory) {
216
- const filesByDirectory = new Map<string, string[]>();
217
- for (const relativePath of fileList) {
218
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
219
- if (!filesByDirectory.has(directory)) {
220
- filesByDirectory.set(directory, []);
221
- }
222
- filesByDirectory.get(directory)!.push(relativePath);
223
- }
224
- for (const [directory, directoryFiles] of filesByDirectory) {
225
- if (directory === ".") {
226
- for (const relativePath of directoryFiles) {
227
- if (outputLines.length > 0) {
228
- outputLines.push("");
229
- displayLines.push("");
230
- }
231
- const header = `# ${path.basename(relativePath)}`;
232
- outputLines.push(header);
233
- displayLines.push(header);
234
- renderMatchesForFile(relativePath);
235
- }
236
- continue;
237
- }
238
- if (outputLines.length > 0) {
239
- outputLines.push("");
240
- displayLines.push("");
241
- }
242
- const dirHeader = `# ${directory}`;
243
- outputLines.push(dirHeader);
244
- displayLines.push(dirHeader);
245
- for (const relativePath of directoryFiles) {
246
- const fileHeader = `## └─ ${path.basename(relativePath)}`;
247
- outputLines.push(fileHeader);
248
- displayLines.push(fileHeader);
249
- renderMatchesForFile(relativePath);
250
- }
251
- }
220
+ const grouped = formatGroupedFiles(fileList, relativePath => {
221
+ const rendered = renderMatchesForFile(relativePath);
222
+ return { modelLines: rendered.model, displayLines: rendered.display };
223
+ });
224
+ outputLines.push(...grouped.model);
225
+ displayLines.push(...grouped.display);
252
226
  } else {
253
227
  for (const relativePath of fileList) {
254
- renderMatchesForFile(relativePath);
228
+ const rendered = renderMatchesForFile(relativePath);
229
+ outputLines.push(...rendered.model);
230
+ displayLines.push(...rendered.display);
255
231
  }
256
232
  }
257
233
 
package/src/tools/grep.ts CHANGED
@@ -14,6 +14,7 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
16
  import { createFileRecorder } from "./file-recorder";
17
+ import { formatGroupedFiles } from "./grouped-file-output";
17
18
  import { formatMatchLine } from "./match-line-format";
18
19
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
19
20
  import {
@@ -283,7 +284,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
283
284
  }
284
285
  const outputLines: string[] = [];
285
286
  let linesTruncated = false;
286
- const hasContextLines = normalizedContextBefore > 0 || normalizedContextAfter > 0;
287
287
  const matchesByFile = new Map<string, GrepMatch[]>();
288
288
  for (const match of selectedMatches) {
289
289
  const relativePath = formatPath(match.path);
@@ -332,46 +332,16 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
332
332
  return { model: modelOut, display: displayOut };
333
333
  };
334
334
  if (isDirectory) {
335
- const filesByDirectory = new Map<string, string[]>();
336
- for (const relativePath of fileList) {
337
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
338
- if (!filesByDirectory.has(directory)) {
339
- filesByDirectory.set(directory, []);
340
- }
341
- filesByDirectory.get(directory)!.push(relativePath);
342
- }
343
- for (const [directory, directoryFiles] of filesByDirectory) {
344
- if (directory === ".") {
345
- for (const relativePath of directoryFiles) {
346
- const rendered = renderMatchesForFile(relativePath);
347
- if (rendered.model.length === 0) continue;
348
- if (outputLines.length > 0) {
349
- outputLines.push("");
350
- displayLines.push("");
351
- }
352
- const header = `# ${path.basename(relativePath)}`;
353
- outputLines.push(header, ...rendered.model);
354
- displayLines.push(header, ...rendered.display);
355
- }
356
- continue;
357
- }
358
- const renderedFiles = directoryFiles
359
- .map(relativePath => ({ relativePath, rendered: renderMatchesForFile(relativePath) }))
360
- .filter(file => file.rendered.model.length > 0);
361
- if (renderedFiles.length === 0) continue;
362
- if (outputLines.length > 0) {
363
- outputLines.push("");
364
- displayLines.push("");
365
- }
366
- const dirHeader = `# ${directory}`;
367
- outputLines.push(dirHeader);
368
- displayLines.push(dirHeader);
369
- for (const { relativePath, rendered } of renderedFiles) {
370
- const fileHeader = `## └─ ${path.basename(relativePath)}`;
371
- outputLines.push(fileHeader, ...rendered.model);
372
- displayLines.push(fileHeader, ...rendered.display);
373
- }
374
- }
335
+ const grouped = formatGroupedFiles(fileList, relativePath => {
336
+ const rendered = renderMatchesForFile(relativePath);
337
+ return {
338
+ modelLines: rendered.model,
339
+ displayLines: rendered.display,
340
+ skip: rendered.model.length === 0,
341
+ };
342
+ });
343
+ outputLines.push(...grouped.model);
344
+ displayLines.push(...grouped.display);
375
345
  } else {
376
346
  for (const relativePath of fileList) {
377
347
  const rendered = renderMatchesForFile(relativePath);
@@ -379,11 +349,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
379
349
  displayLines.push(...rendered.display);
380
350
  }
381
351
  }
382
- if (hasContextLines && outputLines.length > 0) {
383
- outputLines.unshift(
384
- "[grep] '*' marks match lines; leading space marks context. Anchor and content are separated by '|'.",
385
- );
386
- }
387
352
  if (matchLimitReached || result.limitReached) {
388
353
  outputLines.push("", limitMessage);
389
354
  }
@@ -0,0 +1,96 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * One file's contribution to a grouped file output. The header itself is generated
5
+ * by `formatGroupedFiles` (single `#` for root files, `##` for files inside a dir);
6
+ * use `headerSuffix` to tack on extras like ` (1 replacement)`.
7
+ */
8
+ export interface GroupedFileSection {
9
+ /** Optional suffix appended to the file header. */
10
+ headerSuffix?: string;
11
+ /** Body lines emitted into the textual model output. */
12
+ modelLines: string[];
13
+ /** Body lines emitted into the display output. Defaults to `modelLines`. */
14
+ displayLines?: string[];
15
+ /** When true, the file (and its header) is omitted entirely. */
16
+ skip?: boolean;
17
+ }
18
+
19
+ export interface GroupedFilesOutput {
20
+ model: string[];
21
+ display: string[];
22
+ }
23
+
24
+ /**
25
+ * Render a list of files as directory-grouped sections shared by grep, ast-grep,
26
+ * ast-edit, and the LSP diagnostic formatter.
27
+ *
28
+ * Layout:
29
+ * # dir/
30
+ * ## file.ts
31
+ * …body…
32
+ *
33
+ * # otherdir/
34
+ * ## other.ts
35
+ * …body…
36
+ *
37
+ * Files in the project root (directory `.`) become single-`#` headers without a
38
+ * `## file` line, matching the existing convention.
39
+ */
40
+ export function formatGroupedFiles(
41
+ files: string[],
42
+ renderFile: (filePath: string) => GroupedFileSection,
43
+ ): GroupedFilesOutput {
44
+ const filesByDirectory = new Map<string, string[]>();
45
+ for (const filePath of files) {
46
+ const directory = path.dirname(filePath).replace(/\\/g, "/");
47
+ if (!filesByDirectory.has(directory)) {
48
+ filesByDirectory.set(directory, []);
49
+ }
50
+ filesByDirectory.get(directory)!.push(filePath);
51
+ }
52
+
53
+ const model: string[] = [];
54
+ const display: string[] = [];
55
+
56
+ const pushSeparatorIfNeeded = () => {
57
+ if (model.length > 0) {
58
+ model.push("");
59
+ display.push("");
60
+ }
61
+ };
62
+
63
+ for (const [directory, dirFiles] of filesByDirectory) {
64
+ if (directory === ".") {
65
+ for (const filePath of dirFiles) {
66
+ const section = renderFile(filePath);
67
+ if (section.skip) continue;
68
+ pushSeparatorIfNeeded();
69
+ const header = `# ${path.basename(filePath)}${section.headerSuffix ?? ""}`;
70
+ model.push(header, ...section.modelLines);
71
+ display.push(header, ...(section.displayLines ?? section.modelLines));
72
+ }
73
+ continue;
74
+ }
75
+
76
+ const sections: Array<{ filePath: string; section: GroupedFileSection }> = [];
77
+ for (const filePath of dirFiles) {
78
+ const section = renderFile(filePath);
79
+ if (section.skip) continue;
80
+ sections.push({ filePath, section });
81
+ }
82
+ if (sections.length === 0) continue;
83
+
84
+ pushSeparatorIfNeeded();
85
+ const dirHeader = `# ${directory}/`;
86
+ model.push(dirHeader);
87
+ display.push(dirHeader);
88
+ for (const { filePath, section } of sections) {
89
+ const fileHeader = `## ${path.basename(filePath)}${section.headerSuffix ?? ""}`;
90
+ model.push(fileHeader, ...section.modelLines);
91
+ display.push(fileHeader, ...(section.displayLines ?? section.modelLines));
92
+ }
93
+ }
94
+
95
+ return { model, display };
96
+ }
@@ -717,7 +717,6 @@ export const todoWriteToolRenderer = {
717
717
  const lines: string[] = [header];
718
718
  for (let p = 0; p < phases.length; p++) {
719
719
  const phase = phases[p];
720
- if (p > 0) lines.push("");
721
720
  if (phases.length > 1) {
722
721
  lines.push(uiTheme.fg("accent", chalk.bold(` ${phase.name}`)));
723
722
  }