@d3ara1n/pi-hashline-edit 0.5.0 → 0.5.2

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.
@@ -30,12 +30,12 @@
30
30
  */
31
31
 
32
32
  import {
33
- getAgentDir,
34
- createGrepTool,
35
- truncateHead,
36
- truncateLine,
37
- formatSize,
38
- DEFAULT_MAX_BYTES,
33
+ getAgentDir,
34
+ createGrepTool,
35
+ truncateHead,
36
+ truncateLine,
37
+ formatSize,
38
+ DEFAULT_MAX_BYTES,
39
39
  } from "@earendil-works/pi-coding-agent";
40
40
  import { Type } from "typebox";
41
41
  import { Text } from "@earendil-works/pi-tui";
@@ -55,26 +55,27 @@ const GREP_MAX_LINE_LENGTH = 500;
55
55
 
56
56
  /** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
57
57
  async function findRg(): Promise<string | null> {
58
- const agentDir = getAgentDir();
59
- const piRg = join(agentDir, "bin", "rg");
60
- try {
61
- await access(piRg, constants.X_OK);
62
- return piRg;
63
- } catch {}
64
- for (const dir of process.env.PATH?.split(delimiter) ?? []) {
65
- if (!dir) continue;
66
- const p = join(dir, "rg");
67
- try {
68
- await access(p, constants.X_OK);
69
- return p;
70
- } catch {}
71
- }
72
- return null;
58
+ const executable = process.platform === "win32" ? "rg.exe" : "rg";
59
+ const agentDir = getAgentDir();
60
+ const piRg = join(agentDir, "bin", executable);
61
+ try {
62
+ await access(piRg, constants.X_OK);
63
+ return piRg;
64
+ } catch {}
65
+ for (const dir of process.env.PATH?.split(delimiter) ?? []) {
66
+ if (!dir) continue;
67
+ const p = join(dir, executable);
68
+ try {
69
+ await access(p, constants.X_OK);
70
+ return p;
71
+ } catch {}
72
+ }
73
+ return null;
73
74
  }
74
75
 
75
76
  /** Escape a literal string for use as a regex source. */
76
77
  function escapeRegex(s: string): string {
77
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
79
  }
79
80
 
80
81
  /**
@@ -85,66 +86,153 @@ function escapeRegex(s: string): string {
85
86
  * regex (e.g. `(?P<name>…)`) throw rather than silently degrade.
86
87
  */
87
88
  function compileLineMatcher(
88
- pattern: string,
89
- opts: { literal: boolean; ignoreCase: boolean; word: boolean },
89
+ pattern: string,
90
+ opts: { literal: boolean; ignoreCase: boolean; word: boolean },
90
91
  ): RegExp {
91
- let source = opts.literal ? escapeRegex(pattern) : pattern;
92
- if (opts.word) source = `\\b(?:${source})\\b`;
93
- const flags = opts.ignoreCase ? "i" : "";
94
- try {
95
- return new RegExp(source, flags);
96
- } catch (err) {
97
- throw new Error(`Pattern not supported for line filtering: ${pattern} (${(err as Error).message})`);
98
- }
92
+ let source = opts.literal ? escapeRegex(pattern) : pattern;
93
+ if (opts.word) source = `\\b(?:${source})\\b`;
94
+ const flags = opts.ignoreCase ? "i" : "";
95
+ try {
96
+ return new RegExp(source, flags);
97
+ } catch (err) {
98
+ throw new Error(
99
+ `Pattern not supported for line filtering: ${pattern} (${(err as Error).message})`,
100
+ );
101
+ }
99
102
  }
100
103
 
101
104
  /** Normalize a `string | string[]` param to an array (`undefined` → `[]`). */
102
105
  function toArray(v: string | string[] | undefined): string[] {
103
- if (v === undefined) return [];
104
- return Array.isArray(v) ? v : [v];
106
+ if (v === undefined) return [];
107
+ return Array.isArray(v) ? v : [v];
105
108
  }
106
109
 
107
110
  const grepOverrideSchema = Type.Object({
108
- pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
109
- description:
110
- "Search pattern (regex, or literal with literal:true). String or array; an array combines patterns per matchMode (any = OR, all = AND on the same line)",
111
- }),
112
- matchMode: Type.Optional(
113
- Type.Union([Type.Literal("any"), Type.Literal("all")], {
114
- description:
115
- 'How multiple patterns combine (default "any"). "any": line matches at least one pattern. "all": line must match every pattern — equivalent to `grep A | grep B`',
116
- }),
117
- ),
118
- excludePattern: Type.Optional(
119
- Type.Union([Type.String(), Type.Array(Type.String())], {
120
- description:
121
- "Drop lines matching this pattern, like grep -v (string or array; same regex/literal/ignoreCase settings as pattern). Applied after pattern matching",
122
- }),
123
- ),
124
- outputMode: Type.Optional(
125
- Type.Union([Type.Literal("content"), Type.Literal("files"), Type.Literal("count")], {
126
- description:
127
- 'Output shape (default "content"). "content": anchored matching lines. "files": only file paths with matches (rg -l). "count": per-file match counts + total (grep -c)',
128
- }),
129
- ),
130
- wordMatch: Type.Optional(Type.Boolean({ description: "Match whole words only (rg -w)" })),
131
- path: Type.Union([Type.String(), Type.Array(Type.String())], {
132
- description: "Directory or file to search (string or array of paths; default: current directory)",
133
- }),
134
- glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
135
- ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
136
- literal: Type.Optional(
137
- Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
138
- ),
139
- context: Type.Optional(
140
- Type.Number({ description: "Number of lines to show before and after each match (default: 0); context lines are anchored too" }),
141
- ),
142
- limit: Type.Optional(Type.Number({ description: "Maximum number of matching lines to return (default: 100)" })),
111
+ pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
112
+ description:
113
+ "Search pattern (regex, or literal with literal:true). String or array; an array combines patterns per matchMode (any = OR, all = AND on the same line)",
114
+ }),
115
+ matchMode: Type.Optional(
116
+ Type.Union([Type.Literal("any"), Type.Literal("all")], {
117
+ description:
118
+ 'How multiple patterns combine (default "any"). "any": line matches at least one pattern. "all": line must match every pattern — equivalent to `grep A | grep B`',
119
+ }),
120
+ ),
121
+ excludePattern: Type.Optional(
122
+ Type.Union([Type.String(), Type.Array(Type.String())], {
123
+ description:
124
+ "Drop lines matching this pattern, like grep -v (string or array; same regex/literal/ignoreCase settings as pattern). Applied after pattern matching",
125
+ }),
126
+ ),
127
+ outputMode: Type.Optional(
128
+ Type.Union([Type.Literal("content"), Type.Literal("files"), Type.Literal("count")], {
129
+ description:
130
+ 'Output shape (default "content"). "content": anchored matching lines. "files": only file paths with matches (rg -l). "count": per-file match counts + total (grep -c)',
131
+ }),
132
+ ),
133
+ wordMatch: Type.Optional(Type.Boolean({ description: "Match whole words only (rg -w)" })),
134
+ path: Type.Union([Type.String(), Type.Array(Type.String())], {
135
+ description:
136
+ "Directory or file to search (string or array of paths; default: current directory)",
137
+ }),
138
+ glob: Type.Optional(
139
+ Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" }),
140
+ ),
141
+ ignoreCase: Type.Optional(
142
+ Type.Boolean({ description: "Case-insensitive search (default: false)" }),
143
+ ),
144
+ literal: Type.Optional(
145
+ Type.Boolean({
146
+ description: "Treat pattern as literal string instead of regex (default: false)",
147
+ }),
148
+ ),
149
+ context: Type.Optional(
150
+ Type.Number({
151
+ description:
152
+ "Number of lines to show before and after each match (default: 0); context lines are anchored too",
153
+ }),
154
+ ),
155
+ limit: Type.Optional(
156
+ Type.Number({ description: "Maximum number of matching lines to return (default: 100)" }),
157
+ ),
143
158
  });
144
159
 
145
160
  interface RgMatch {
146
- filePath: string;
147
- lineNumber: number;
161
+ filePath: string;
162
+ lineNumber: number;
163
+ }
164
+
165
+ interface RgRunResult {
166
+ code: number | null;
167
+ stderr: string;
168
+ stopped: boolean;
169
+ }
170
+
171
+ /** @internal — injectable process and fallback boundary for deterministic tests. */
172
+ export interface GrepBackend {
173
+ findRg(): Promise<string | null>;
174
+ runRg(
175
+ rgPath: string,
176
+ args: string[],
177
+ signal: AbortSignal | undefined,
178
+ onLine: (line: string) => boolean,
179
+ ): Promise<RgRunResult>;
180
+ delegate(
181
+ toolCallId: string,
182
+ params: any,
183
+ signal: AbortSignal | undefined,
184
+ onUpdate: any,
185
+ ): Promise<any>;
186
+ }
187
+
188
+ /** Run ripgrep and stream its JSON lines to the caller until it asks to stop. */
189
+ function runRg(
190
+ rgPath: string,
191
+ args: string[],
192
+ signal: AbortSignal | undefined,
193
+ onLine: (line: string) => boolean,
194
+ ): Promise<RgRunResult> {
195
+ return new Promise((resolve, reject) => {
196
+ if (signal?.aborted) {
197
+ reject(new Error("Operation aborted"));
198
+ return;
199
+ }
200
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
201
+ const rl = createInterface({ input: child.stdout });
202
+ let stderr = "";
203
+ let stopped = false;
204
+ let settled = false;
205
+
206
+ const cleanup = () => {
207
+ rl.close();
208
+ signal?.removeEventListener("abort", onAbort);
209
+ };
210
+ const settle = (fn: () => void) => {
211
+ if (settled) return;
212
+ settled = true;
213
+ cleanup();
214
+ fn();
215
+ };
216
+ const stopChild = () => {
217
+ stopped = true;
218
+ if (!child.killed) child.kill();
219
+ };
220
+ const onAbort = () => stopChild();
221
+ signal?.addEventListener("abort", onAbort, { once: true });
222
+ child.stderr?.on("data", (chunk: Buffer) => {
223
+ stderr += chunk.toString();
224
+ });
225
+ rl.on("line", (line: string) => {
226
+ if (!line.trim() || stopped) return;
227
+ if (!onLine(line)) stopChild();
228
+ });
229
+ child.on("error", (error) => {
230
+ settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
231
+ });
232
+ child.on("close", (code) => {
233
+ settle(() => resolve({ code, stderr, stopped }));
234
+ });
235
+ });
148
236
  }
149
237
 
150
238
  /**
@@ -156,355 +244,363 @@ interface RgMatch {
156
244
  * receives the anchored `content` text verbatim — this only affects what the user sees.
157
245
  */
158
246
  function countLeading(s: string): number {
159
- const m = s.match(/^[ \t]*/);
160
- return m ? m[0].length : 0;
247
+ const m = s.match(/^[ \t]*/);
248
+ return m ? m[0].length : 0;
161
249
  }
162
250
 
163
251
  function toDisplayLines(raw: string, theme: any): string[] {
164
- const out: string[] = [];
165
- const lines = raw.split("\n");
166
- let i = 0;
167
- while (i < lines.length) {
168
- const line = lines[i];
169
- const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
170
- if (h) {
171
- out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
172
- // collect the anchor lines in this file group
173
- const group: { lineNo: string; content: string }[] = [];
174
- let j = i + 1;
175
- while (j < lines.length) {
176
- const a = parseHashline(lines[j]);
177
- if (!a) break;
178
- group.push({ lineNo: a.lineNo, content: a.content });
179
- j++;
180
- }
181
- // common base = min leading whitespace across the group; fold it into a marker
182
- const base = group.length ? Math.min(...group.map((g) => countLeading(g.content))) : 0;
183
- const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
184
- for (const g of group) {
185
- const body = g.content.slice(base);
186
- out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
187
- }
188
- i = j;
189
- continue;
190
- }
191
- if (line.startsWith("[")) out.push(theme.fg("warning", line));
192
- else out.push(theme.fg("toolOutput", line));
193
- i++;
194
- }
195
- return out;
252
+ const out: string[] = [];
253
+ const lines = raw.split("\n");
254
+ let i = 0;
255
+ while (i < lines.length) {
256
+ const line = lines[i];
257
+ const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
258
+ if (h) {
259
+ out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
260
+ // collect the anchor lines in this file group
261
+ const group: { lineNo: string; content: string }[] = [];
262
+ let j = i + 1;
263
+ while (j < lines.length) {
264
+ const a = parseHashline(lines[j]);
265
+ if (!a) break;
266
+ group.push({ lineNo: a.lineNo, content: a.content });
267
+ j++;
268
+ }
269
+ // common base = min leading whitespace across the group; fold it into a marker
270
+ const base = group.length ? Math.min(...group.map((g) => countLeading(g.content))) : 0;
271
+ const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
272
+ for (const g of group) {
273
+ const body = g.content.slice(base);
274
+ out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
275
+ }
276
+ i = j;
277
+ continue;
278
+ }
279
+ if (line.startsWith("[")) out.push(theme.fg("warning", line));
280
+ else out.push(theme.fg("toolOutput", line));
281
+ i++;
282
+ }
283
+ return out;
196
284
  }
197
285
 
198
- /** Build the grep override (a ToolDefinition fragment for registerTool). */
286
+ /** Build the production grep override (a ToolDefinition fragment for registerTool). */
199
287
  export function makeGrepOverride(cwd: string) {
200
- const builtin = createGrepTool(cwd);
201
- const delegate = (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) =>
202
- builtin.execute(toolCallId, params, signal, onUpdate);
203
-
204
- return {
205
- name: "grep" as const,
206
- label: "grep",
207
- description:
208
- "Search file contents for a pattern. Results are grouped by file with LINE#HASH anchors usable directly in edit. Supports multi-pattern AND (matchMode:all), line exclusion (excludePattern, grep -v), whole-word matching (wordMatch), multiple search paths, and files-only / count output modes — the common `grep A | grep -v B` / `rg -l` / `grep -c` pipelines without bash. Respects .gitignore.",
209
- promptSnippet:
210
- "Search file contents; results show LINE#HASH anchors usable directly in edit; multi-pattern AND, exclude, files-only and count modes replace bash grep pipelines",
211
- promptGuidelines: [
212
- "Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
213
- "Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
214
- "Prefer this over bash pipes: `matchMode:\"all\"` + `excludePattern` express `grep A | grep -v B`; `outputMode:\"files\"`/`\"count\"` replace `rg -l`/`grep -c` when you only need locations or counts. `files` output pastes back as a `path` array.",
215
- "Pass `pattern` (string or array); optionally `path` (string or array), `glob`, `ignoreCase`, `literal`, `wordMatch`, `context` (lines before+after each match), `limit` (max matches, default 100).",
216
- ],
217
- parameters: grepOverrideSchema,
218
-
219
- renderShell: "default" as const,
220
-
221
- renderCall(args: any, theme: any) {
222
- const rawPattern = args?.pattern;
223
- const patternText = Array.isArray(rawPattern) ? rawPattern.join(" | ") : String(rawPattern ?? "");
224
- const rawPath = args?.path;
225
- const pathText = Array.isArray(rawPath) ? rawPath.join(" ") : String(rawPath ?? ".");
226
- let text =
227
- theme.fg("toolTitle", theme.bold("grep ")) +
228
- theme.fg("accent", `/${patternText}/`) +
229
- theme.fg("toolOutput", ` in ${pathText}`);
230
- if (args?.matchMode === "all") text += theme.fg("accent", " all");
231
- if (args?.excludePattern) {
232
- const ex = Array.isArray(args.excludePattern) ? args.excludePattern.join(",") : args.excludePattern;
233
- text += theme.fg("toolOutput", ` -v:${ex}`);
234
- }
235
- if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
236
- if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
237
- if (args?.outputMode && args.outputMode !== "content")
238
- text += theme.fg("success", ` → ${args.outputMode}`);
239
- if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
240
- return new Text(text, 0, 0);
241
- },
242
-
243
- renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
244
- if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
245
- if (context?.isError) {
246
- const t = result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
247
- return new Text(theme.fg("error", t), 0, 0);
248
- }
249
- const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
250
- const styled = toDisplayLines(out, theme);
251
- const maxLines = expanded ? styled.length : 15;
252
- const shown = styled.slice(0, maxLines);
253
- const more =
254
- !expanded && styled.length > maxLines
255
- ? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
256
- : "";
257
- return new Text(shown.join("\n") + more, 0, 0);
258
- },
259
-
260
- async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
261
- const state = getState();
262
- // aborted → built-in grep (it handles abort itself)
263
- if (signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
264
-
265
- // Plain built-in-shaped params (single string pattern/path, no new fields)
266
- // can delegate safely; anything else must run the local pipeline below.
267
- const legacyShaped =
268
- typeof params.pattern === "string" &&
269
- params.matchMode === undefined &&
270
- params.excludePattern === undefined &&
271
- params.outputMode === undefined &&
272
- params.wordMatch === undefined &&
273
- !Array.isArray(params.path);
274
-
275
- // disabled + plain params → built-in grep, exactly as before
276
- if (!state.config.enabled && legacyShaped) return delegate(toolCallId, params, signal, onUpdate);
277
-
278
- const rgPath = await findRg();
279
- // ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
280
- if (!rgPath) {
281
- if (legacyShaped) return delegate(toolCallId, params, signal, onUpdate);
282
- throw new Error(
283
- "ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
284
- );
285
- }
286
-
287
- // disabled + extended params still run locally, formatted without anchors
288
- const anchored = state.config.enabled;
289
-
290
- const patterns = toArray(params.pattern);
291
- const excludes = toArray(params.excludePattern);
292
- if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
293
- const matchMode: "any" | "all" = params.matchMode ?? "any";
294
- const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
295
- const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
296
- const ctx = context && context > 0 ? context : 0;
297
- const searchPaths = (() => {
298
- const raw = toArray(params.path);
299
- return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
300
- })();
301
- const hashLen = state.config.hashLen;
302
-
303
- // Verify search paths upfront; remember dir-ness for relative display.
304
- const roots: { path: string; isDir: boolean }[] = [];
305
- for (const sp of searchPaths) {
306
- try {
307
- roots.push({ path: sp, isDir: (await stat(sp)).isDirectory() });
308
- } catch {
309
- throw new Error(`Path not found: ${sp}`);
310
- }
311
- }
312
-
313
- // Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
314
- const excludeMatchers = excludes.map((p) =>
315
- compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: false }),
316
- );
317
- const andMatchers =
318
- matchMode === "all" && patterns.length > 1
319
- ? patterns.map((p) =>
320
- compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: !!wordMatch }),
321
- )
322
- : [];
323
- const linePasses = (line: string): boolean =>
324
- andMatchers.every((re) => re.test(line)) && !excludeMatchers.some((re) => re.test(line));
325
-
326
- return new Promise((resolvePromise, reject) => {
327
- if (signal?.aborted) {
328
- reject(new Error("Operation aborted"));
329
- return;
330
- }
331
-
332
- const args = ["--json", "--line-number", "--color=never", "--hidden"];
333
- if (ignoreCase) args.push("--ignore-case");
334
- if (literal) args.push("--fixed-strings");
335
- if (wordMatch) args.push("--word-regexp");
336
- if (glob) args.push("--glob", glob);
337
- for (const p of patterns) args.push("-e", p);
338
- args.push("--", ...searchPaths);
339
-
340
- const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
341
- const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
342
- const rl = createInterface({ input: child.stdout });
343
- let stderr = "";
344
- let matchCount = 0;
345
- let matchLimitReached = false;
346
- let linesTruncated = false;
347
- let aborted = false;
348
- let killedDueToLimit = false;
349
- const raw: RgMatch[] = [];
350
-
351
- const cleanup = () => {
352
- rl.close();
353
- signal?.removeEventListener("abort", onAbort);
354
- };
355
- const stopChild = (dueToLimit = false) => {
356
- if (!child.killed) {
357
- killedDueToLimit = dueToLimit;
358
- child.kill();
359
- }
360
- };
361
- const onAbort = () => {
362
- aborted = true;
363
- stopChild();
364
- };
365
- signal?.addEventListener("abort", onAbort, { once: true });
366
- child.stderr?.on("data", (chunk: Buffer) => {
367
- stderr += chunk.toString();
368
- });
369
-
370
- rl.on("line", (line: string) => {
371
- if (!line.trim() || matchCount >= effectiveLimit) return;
372
- let event: any;
373
- try {
374
- event = JSON.parse(line);
375
- } catch {
376
- return;
377
- }
378
- if (event.type !== "match") return;
379
- const filePath = event.data?.path?.text;
380
- const lineNumber = event.data?.line_number;
381
- if (!filePath || typeof lineNumber !== "number") return;
382
- // AND / exclude filters run on the matched line's text as streamed
383
- // by rg, so the limit counts final results, not pre-filter candidates.
384
- const text = typeof event.data?.lines?.text === "string" ? event.data.lines.text : "";
385
- if (!linePasses(text.replace(/\r?\n$/, ""))) return;
386
- matchCount++;
387
- raw.push({ filePath, lineNumber });
388
- if (matchCount >= effectiveLimit) {
389
- matchLimitReached = true;
390
- stopChild(true);
391
- }
392
- });
393
-
394
- child.on("error", (error) => {
395
- cleanup();
396
- reject(new Error(`Failed to run ripgrep: ${error.message}`));
397
- });
398
-
399
- child.on("close", async (code) => {
400
- cleanup();
401
- if (aborted) {
402
- reject(new Error("Operation aborted"));
403
- return;
404
- }
405
- if (!killedDueToLimit && code !== 0 && code !== 1) {
406
- reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
407
- return;
408
- }
409
- if (raw.length === 0) {
410
- resolvePromise({ content: [{ type: "text", text: "No matches found" }], details: undefined });
411
- return;
412
- }
413
-
414
- // Group by file, matches sorted by line number (Map keeps rg's discovery order).
415
- const byFile = new Map<string, number[]>();
416
- for (const m of raw) {
417
- const arr = byFile.get(m.filePath) ?? [];
418
- arr.push(m.lineNumber);
419
- byFile.set(m.filePath, arr);
420
- }
421
- for (const arr of byFile.values()) arr.sort((a, b) => a - b);
422
-
423
- // Read each file once and hash all its lines; hash is computed from the FULL line.
424
- const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
425
- const getFile = async (fp: string) => {
426
- let entry = fileCache.get(fp);
427
- if (!entry) {
428
- let content = "";
429
- try {
430
- content = (await readFile(fp)).toString("utf-8");
431
- } catch {
432
- content = "";
433
- }
434
- const lines = splitLines(content);
435
- entry = { lines, hashes: hashFileLines(lines, hashLen) };
436
- fileCache.set(fp, entry);
437
- }
438
- return entry;
439
- };
440
-
441
- const formatPath = (fp: string): string => {
442
- for (const root of roots) {
443
- if (!root.isDir) continue;
444
- const rel = relative(root.path, fp).replace(/\\/g, "/");
445
- if (rel && !rel.startsWith("..")) return rel;
446
- }
447
- return basename(fp);
448
- };
449
-
450
- const blocks: string[] = [];
451
- if (outputMode === "content") {
452
- for (const [fp, matchLines] of byFile) {
453
- const { lines, hashes } = await getFile(fp);
454
- const matchSet = new Set(matchLines);
455
- // Context windows are rebuilt from surviving matches so context
456
- // lines of a filtered-out match never leak.
457
- const windowSet = new Set<number>();
458
- for (const ln of matchLines) {
459
- for (let n = Math.max(1, ln - ctx); n <= Math.min(lines.length, ln + ctx); n++) windowSet.add(n);
460
- }
461
- const header = anchored
462
- ? `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`
463
- : "";
464
- const rows: string[] = [];
465
- for (const n of [...windowSet].sort((a, b) => a - b)) {
466
- const content = lines[n - 1] ?? "";
467
- const hash = hashes[n - 1] ?? "";
468
- const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
469
- if (wasTruncated) linesTruncated = true;
470
- if (anchored) rows.push(`${n}#${hash}│${disp}`);
471
- else if (matchSet.has(n)) rows.push(`${formatPath(fp)}:${n}: ${disp}`);
472
- else rows.push(`${formatPath(fp)}-${n}- ${disp}`);
473
- }
474
- blocks.push(`${header}${rows.join("\n")}`);
475
- }
476
- } else if (outputMode === "files") {
477
- for (const fp of byFile.keys()) blocks.push(formatPath(fp));
478
- } else {
479
- // count
480
- let total = 0;
481
- for (const [fp, matchLines] of byFile) {
482
- blocks.push(`${formatPath(fp)}: ${matchLines.length}`);
483
- total += matchLines.length;
484
- }
485
- blocks.push(`Total: ${total} match${total !== 1 ? "es" : ""} in ${byFile.size} file${byFile.size !== 1 ? "s" : ""}`);
486
- }
487
-
488
- let output = blocks.join(outputMode === "content" ? "\n\n" : "\n");
489
- const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
490
- output = truncation.content;
491
-
492
- const notices: string[] = [];
493
- if (matchLimitReached)
494
- notices.push(
495
- `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
496
- );
497
- if (truncation.truncated) notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
498
- if (linesTruncated)
499
- notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`);
500
- if (notices.length) output += `\n\n[${notices.join(". ")}]`;
501
-
502
- resolvePromise({
503
- content: [{ type: "text" as const, text: output }],
504
- details: undefined,
505
- });
506
- });
507
- });
508
- },
509
- };
288
+ return makeGrepOverrideWithBackend(cwd, {});
289
+ }
290
+
291
+ /** @internal — build a grep override with deterministic process and fallback backends for tests. */
292
+ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<GrepBackend>) {
293
+ let builtin: ReturnType<typeof createGrepTool> | undefined;
294
+ const backend: GrepBackend = {
295
+ findRg,
296
+ runRg,
297
+ delegate(toolCallId, params, signal, onUpdate) {
298
+ builtin ??= createGrepTool(cwd);
299
+ return builtin.execute(toolCallId, params, signal, onUpdate);
300
+ },
301
+ ...overrides,
302
+ };
303
+
304
+ return {
305
+ name: "grep" as const,
306
+ label: "grep",
307
+ description:
308
+ "Search file contents for a pattern. Results are grouped by file with LINE#HASH anchors usable directly in edit. Supports multi-pattern AND (matchMode:all), line exclusion (excludePattern, grep -v), whole-word matching (wordMatch), multiple search paths, and files-only / count output modes — the common `grep A | grep -v B` / `rg -l` / `grep -c` pipelines without bash. Respects .gitignore.",
309
+ promptSnippet:
310
+ "Search file contents; results show LINE#HASH anchors usable directly in edit; multi-pattern AND, exclude, files-only and count modes replace bash grep pipelines",
311
+ promptGuidelines: [
312
+ "Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
313
+ "Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
314
+ 'Prefer this over bash pipes: `matchMode:"all"` + `excludePattern` express `grep A | grep -v B`; `outputMode:"files"`/`"count"` replace `rg -l`/`grep -c` when you only need locations or counts. `files` output pastes back as a `path` array.',
315
+ "Pass `pattern` (string or array); optionally `path` (string or array), `glob`, `ignoreCase`, `literal`, `wordMatch`, `context` (lines before+after each match), `limit` (max matches, default 100).",
316
+ ],
317
+ parameters: grepOverrideSchema,
318
+
319
+ renderShell: "default" as const,
320
+
321
+ renderCall(args: any, theme: any) {
322
+ const rawPattern = args?.pattern;
323
+ const patternText = Array.isArray(rawPattern)
324
+ ? rawPattern.join(" | ")
325
+ : String(rawPattern ?? "");
326
+ const rawPath = args?.path;
327
+ const pathText = Array.isArray(rawPath) ? rawPath.join(" ") : String(rawPath ?? ".");
328
+ let text =
329
+ theme.fg("toolTitle", theme.bold("grep ")) +
330
+ theme.fg("accent", `/${patternText}/`) +
331
+ theme.fg("toolOutput", ` in ${pathText}`);
332
+ if (args?.matchMode === "all") text += theme.fg("accent", " all");
333
+ if (args?.excludePattern) {
334
+ const ex = Array.isArray(args.excludePattern)
335
+ ? args.excludePattern.join(",")
336
+ : args.excludePattern;
337
+ text += theme.fg("toolOutput", ` -v:${ex}`);
338
+ }
339
+ if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
340
+ if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
341
+ if (args?.outputMode && args.outputMode !== "content")
342
+ text += theme.fg("success", ` → ${args.outputMode}`);
343
+ if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
344
+ return new Text(text, 0, 0);
345
+ },
346
+
347
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
348
+ if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
349
+ if (context?.isError) {
350
+ const t =
351
+ result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
352
+ return new Text(theme.fg("error", t), 0, 0);
353
+ }
354
+ const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
355
+ const styled = toDisplayLines(out, theme);
356
+ const maxLines = expanded ? styled.length : 15;
357
+ const shown = styled.slice(0, maxLines);
358
+ const more =
359
+ !expanded && styled.length > maxLines
360
+ ? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
361
+ : "";
362
+ return new Text(shown.join("\n") + more, 0, 0);
363
+ },
364
+
365
+ async execute(
366
+ toolCallId: string,
367
+ params: any,
368
+ signal: AbortSignal | undefined,
369
+ onUpdate: any,
370
+ ): Promise<any> {
371
+ const state = getState();
372
+ // aborted → built-in grep (it handles abort itself)
373
+ if (signal?.aborted) return backend.delegate(toolCallId, params, signal, onUpdate);
374
+
375
+ // Plain built-in-shaped params (single string pattern/path, no new fields)
376
+ // can delegate safely; anything else must run the local pipeline below.
377
+ const legacyShaped =
378
+ typeof params.pattern === "string" &&
379
+ params.matchMode === undefined &&
380
+ params.excludePattern === undefined &&
381
+ params.outputMode === undefined &&
382
+ params.wordMatch === undefined &&
383
+ !Array.isArray(params.path);
384
+
385
+ // disabled + plain params → built-in grep, exactly as before
386
+ if (!state.config.enabled && legacyShaped)
387
+ return backend.delegate(toolCallId, params, signal, onUpdate);
388
+
389
+ const rgPath = await backend.findRg();
390
+ // ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
391
+ if (!rgPath) {
392
+ if (legacyShaped) return backend.delegate(toolCallId, params, signal, onUpdate);
393
+ throw new Error(
394
+ "ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
395
+ );
396
+ }
397
+
398
+ // disabled + extended params still run locally, formatted without anchors
399
+ const anchored = state.config.enabled;
400
+
401
+ const patterns = toArray(params.pattern);
402
+ const excludes = toArray(params.excludePattern);
403
+ if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
404
+ const matchMode: "any" | "all" = params.matchMode ?? "any";
405
+ const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
406
+ const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
407
+ const ctx = context && context > 0 ? context : 0;
408
+ const searchPaths = (() => {
409
+ const raw = toArray(params.path);
410
+ return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
411
+ })();
412
+ const hashLen = state.config.hashLen;
413
+
414
+ // Verify search paths upfront; remember dir-ness for relative display.
415
+ const roots: { path: string; isDir: boolean }[] = [];
416
+ for (const sp of searchPaths) {
417
+ try {
418
+ roots.push({ path: sp, isDir: (await stat(sp)).isDirectory() });
419
+ } catch {
420
+ throw new Error(`Path not found: ${sp}`);
421
+ }
422
+ }
423
+
424
+ // Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
425
+ const excludeMatchers = excludes.map((p) =>
426
+ compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: false }),
427
+ );
428
+ const andMatchers =
429
+ matchMode === "all" && patterns.length > 1
430
+ ? patterns.map((p) =>
431
+ compileLineMatcher(p, {
432
+ literal: !!literal,
433
+ ignoreCase: !!ignoreCase,
434
+ word: !!wordMatch,
435
+ }),
436
+ )
437
+ : [];
438
+ const linePasses = (line: string): boolean =>
439
+ andMatchers.every((re) => re.test(line)) && !excludeMatchers.some((re) => re.test(line));
440
+
441
+ return new Promise((resolvePromise, reject) => {
442
+ if (signal?.aborted) {
443
+ reject(new Error("Operation aborted"));
444
+ return;
445
+ }
446
+
447
+ const args = ["--json", "--line-number", "--color=never", "--hidden"];
448
+ if (ignoreCase) args.push("--ignore-case");
449
+ if (literal) args.push("--fixed-strings");
450
+ if (wordMatch) args.push("--word-regexp");
451
+ if (glob) args.push("--glob", glob);
452
+ for (const p of patterns) args.push("-e", p);
453
+ args.push("--", ...searchPaths);
454
+
455
+ const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
456
+ let matchCount = 0;
457
+ let matchLimitReached = false;
458
+ let linesTruncated = false;
459
+ const raw: RgMatch[] = [];
460
+
461
+ backend
462
+ .runRg(rgPath, args, signal, (line) => {
463
+ if (matchCount >= effectiveLimit) return false;
464
+ let event: any;
465
+ try {
466
+ event = JSON.parse(line);
467
+ } catch {
468
+ return true;
469
+ }
470
+ if (event.type !== "match") return true;
471
+ const filePath = event.data?.path?.text;
472
+ const lineNumber = event.data?.line_number;
473
+ if (!filePath || typeof lineNumber !== "number") return true;
474
+ // AND / exclude filters run on the matched line's text as streamed
475
+ // by rg, so the limit counts final results, not pre-filter candidates.
476
+ const text = typeof event.data?.lines?.text === "string" ? event.data.lines.text : "";
477
+ if (!linePasses(text.replace(/\r?\n$/, ""))) return true;
478
+ matchCount++;
479
+ raw.push({ filePath, lineNumber });
480
+ if (matchCount >= effectiveLimit) {
481
+ matchLimitReached = true;
482
+ return false;
483
+ }
484
+ return true;
485
+ })
486
+ .then(async ({ code, stderr, stopped }) => {
487
+ if (signal?.aborted) {
488
+ reject(new Error("Operation aborted"));
489
+ return;
490
+ }
491
+ if (!stopped && code !== 0 && code !== 1) {
492
+ reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
493
+ return;
494
+ }
495
+ if (raw.length === 0) {
496
+ resolvePromise({
497
+ content: [{ type: "text", text: "No matches found" }],
498
+ details: undefined,
499
+ });
500
+ return;
501
+ }
502
+
503
+ // Group by file, matches sorted by line number (Map keeps rg's discovery order).
504
+ const byFile = new Map<string, number[]>();
505
+ for (const m of raw) {
506
+ const arr = byFile.get(m.filePath) ?? [];
507
+ arr.push(m.lineNumber);
508
+ byFile.set(m.filePath, arr);
509
+ }
510
+ for (const arr of byFile.values()) arr.sort((a, b) => a - b);
511
+
512
+ // Read each file once and hash all its lines; hash is computed from the FULL line.
513
+ const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
514
+ const getFile = async (fp: string) => {
515
+ let entry = fileCache.get(fp);
516
+ if (!entry) {
517
+ let content = "";
518
+ try {
519
+ content = (await readFile(fp)).toString("utf-8");
520
+ } catch {
521
+ content = "";
522
+ }
523
+ const lines = splitLines(content);
524
+ entry = { lines, hashes: hashFileLines(lines, hashLen) };
525
+ fileCache.set(fp, entry);
526
+ }
527
+ return entry;
528
+ };
529
+
530
+ const formatPath = (fp: string): string => {
531
+ for (const root of roots) {
532
+ if (!root.isDir) continue;
533
+ const rel = relative(root.path, fp).replace(/\\/g, "/");
534
+ if (rel && !rel.startsWith("..")) return rel;
535
+ }
536
+ return basename(fp);
537
+ };
538
+
539
+ const blocks: string[] = [];
540
+ if (outputMode === "content") {
541
+ for (const [fp, matchLines] of byFile) {
542
+ const { lines, hashes } = await getFile(fp);
543
+ const matchSet = new Set(matchLines);
544
+ // Context windows are rebuilt from surviving matches so context
545
+ // lines of a filtered-out match never leak.
546
+ const windowSet = new Set<number>();
547
+ for (const ln of matchLines) {
548
+ for (let n = Math.max(1, ln - ctx); n <= Math.min(lines.length, ln + ctx); n++)
549
+ windowSet.add(n);
550
+ }
551
+ const header = anchored
552
+ ? `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`
553
+ : "";
554
+ const rows: string[] = [];
555
+ for (const n of [...windowSet].sort((a, b) => a - b)) {
556
+ const content = lines[n - 1] ?? "";
557
+ const hash = hashes[n - 1] ?? "";
558
+ const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
559
+ if (wasTruncated) linesTruncated = true;
560
+ if (anchored) rows.push(`${n}#${hash}│${disp}`);
561
+ else if (matchSet.has(n)) rows.push(`${formatPath(fp)}:${n}: ${disp}`);
562
+ else rows.push(`${formatPath(fp)}-${n}- ${disp}`);
563
+ }
564
+ blocks.push(`${header}${rows.join("\n")}`);
565
+ }
566
+ } else if (outputMode === "files") {
567
+ for (const fp of byFile.keys()) blocks.push(formatPath(fp));
568
+ } else {
569
+ // count
570
+ let total = 0;
571
+ for (const [fp, matchLines] of byFile) {
572
+ blocks.push(`${formatPath(fp)}: ${matchLines.length}`);
573
+ total += matchLines.length;
574
+ }
575
+ blocks.push(
576
+ `Total: ${total} match${total !== 1 ? "es" : ""} in ${byFile.size} file${byFile.size !== 1 ? "s" : ""}`,
577
+ );
578
+ }
579
+
580
+ let output = blocks.join(outputMode === "content" ? "\n\n" : "\n");
581
+ const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
582
+ output = truncation.content;
583
+
584
+ const notices: string[] = [];
585
+ if (matchLimitReached)
586
+ notices.push(
587
+ `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
588
+ );
589
+ if (truncation.truncated)
590
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
591
+ if (linesTruncated)
592
+ notices.push(
593
+ `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`,
594
+ );
595
+ if (notices.length) output += `\n\n[${notices.join(". ")}]`;
596
+
597
+ resolvePromise({
598
+ content: [{ type: "text" as const, text: output }],
599
+ details: undefined,
600
+ });
601
+ })
602
+ .catch(reject);
603
+ });
604
+ },
605
+ };
510
606
  }