@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.
- package/CHANGELOG.md +26 -0
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +6 -3
- package/src/edit/block.ts +308 -0
- package/src/edit/indent.ts +150 -0
- package/src/edit/modes/atom.ts +283 -47
- package/src/lsp/utils.ts +6 -36
- package/src/modes/controllers/event-controller.ts +12 -0
- package/src/prompts/tools/atom.md +64 -91
- package/src/prompts/tools/read.md +1 -1
- package/src/session/agent-session.ts +4 -1
- package/src/tools/ast-edit.ts +23 -44
- package/src/tools/ast-grep.ts +18 -42
- package/src/tools/grep.ts +11 -46
- package/src/tools/grouped-file-output.ts +96 -0
- package/src/tools/todo-write.ts +0 -1
|
@@ -1,103 +1,76 @@
|
|
|
1
|
-
Applies precise file edits using
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
- `
|
|
11
|
-
- `
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- `
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
- `splice
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
37
|
+
{{hline 1 "const FALLBACK = \"guest\";"}}
|
|
35
38
|
{{hline 2 ""}}
|
|
36
|
-
{{hline 3 "function
|
|
37
|
-
{{hline 4 "\
|
|
38
|
-
{{hline 5 "\
|
|
39
|
-
{{hline 6 "
|
|
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
|
-
#
|
|
45
|
-
`{path:"a.ts",edits:[{loc:{{href 1 "const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# Delete a line
|
|
51
|
-
`{path:"a.ts",edits:[{loc:{{href
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#
|
|
78
|
-
`{path:"a.ts",edits:[{loc:{{href
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
91
|
-
-
|
|
92
|
-
-
|
|
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
|
|
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 };
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
}
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -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
|
}
|