@oh-my-pi/pi-coding-agent 8.12.2 → 8.12.5

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/src/tools/grep.ts CHANGED
@@ -1,26 +1,23 @@
1
1
  import * as nodePath from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import { StringEnum } from "@oh-my-pi/pi-ai";
4
+ import { type GrepMatch as WasmGrepMatch, grep as wasmGrep } from "@oh-my-pi/pi-natives";
4
5
  import type { Component } from "@oh-my-pi/pi-tui";
5
6
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { ptree } from "@oh-my-pi/pi-utils";
7
- import { Type } from "@sinclair/typebox";
8
- import { $ } from "bun";
7
+ import { untilAborted } from "@oh-my-pi/pi-utils";
8
+ import { type Static, Type } from "@sinclair/typebox";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
13
13
  import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
14
- import { ensureTool } from "../utils/tools-manager";
15
- import { untilAborted } from "../utils/utils";
16
14
  import type { ToolSession } from ".";
17
- import { applyListLimit } from "./list-limit";
18
15
  import type { OutputMeta } from "./output-meta";
19
16
  import { resolveToCwd } from "./path-utils";
20
17
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
21
- import { ToolAbortError, ToolError } from "./tool-errors";
18
+ import { ToolError } from "./tool-errors";
22
19
  import { toolResult } from "./tool-result";
23
- import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead, truncateLine } from "./truncate";
20
+ import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "./truncate";
24
21
 
25
22
  const grepSchema = Type.Object({
26
23
  pattern: Type.String({ description: "Regex pattern to search for" }),
@@ -28,16 +25,13 @@ const grepSchema = Type.Object({
28
25
  glob: Type.Optional(Type.String({ description: "Filter files by glob pattern (e.g., '*.js')" })),
29
26
  type: Type.Optional(Type.String({ description: "Filter by file type (e.g., js, py, rust)" })),
30
27
  output_mode: Type.Optional(
31
- StringEnum(["files_with_matches", "content", "count"], {
28
+ StringEnum(["filesWithMatches", "content", "count"], {
32
29
  description: "Output format (default: files_with_matches)",
33
30
  }),
34
31
  ),
35
32
  i: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
36
33
  n: Type.Optional(Type.Boolean({ description: "Show line numbers (default: true)" })),
37
- a: Type.Optional(Type.Number({ description: "Lines to show after each match (default: 0)" })),
38
- b: Type.Optional(Type.Number({ description: "Lines to show before each match (default: 0)" })),
39
- c: Type.Optional(Type.Number({ description: "Lines of context (before and after) (default: 0)" })),
40
- context: Type.Optional(Type.Number({ description: "Lines of context (alias for c)" })),
34
+ context: Type.Optional(Type.Number({ description: "Lines of context (default: 5)" })),
41
35
  multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching (default: false)" })),
42
36
  limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 100 in content mode)" })),
43
37
  offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
@@ -51,109 +45,26 @@ export interface GrepToolDetails {
51
45
  resultLimitReached?: number;
52
46
  linesTruncated?: boolean;
53
47
  meta?: OutputMeta;
54
- // Fields for TUI rendering
55
48
  scopePath?: string;
56
49
  matchCount?: number;
57
50
  fileCount?: number;
58
51
  files?: string[];
59
52
  fileMatches?: Array<{ path: string; count: number }>;
60
- mode?: "content" | "files_with_matches" | "count";
53
+ mode?: "content" | "filesWithMatches" | "count";
61
54
  truncated?: boolean;
62
55
  error?: string;
63
56
  }
64
57
 
65
- export interface RgResult {
66
- stdout: string;
67
- stderr: string;
68
- exitCode: number | null;
69
- }
70
-
71
- /**
72
- * Run rg command and capture output.
73
- *
74
- * @throws ToolAbortError if signal is aborted
75
- */
76
- export async function runRg(
77
- rgPath: string,
78
- args: string[],
79
- options?: { signal?: AbortSignal; timeoutMs?: number },
80
- ): Promise<RgResult> {
81
- const child = ptree.cspawn([rgPath, ...args], { signal: options?.signal, timeout: options?.timeoutMs });
82
- const timeoutSeconds = options?.timeoutMs ? Math.max(1, Math.round(options.timeoutMs / 1000)) : undefined;
83
- const timeoutMessage = timeoutSeconds ? `rg timed out after ${timeoutSeconds}s` : "rg timed out";
84
-
85
- let stdout: string;
86
- try {
87
- stdout = await child.nothrow().text();
88
- } catch (err) {
89
- if (err instanceof ptree.TimeoutError) {
90
- throw new ToolError(timeoutMessage);
91
- }
92
- if (err instanceof ptree.Exception && err.aborted) {
93
- throw new ToolAbortError();
94
- }
95
- throw err;
96
- }
97
-
98
- let exitError: unknown;
99
- try {
100
- await child.exited;
101
- } catch (err) {
102
- exitError = err;
103
- if (err instanceof ptree.TimeoutError) {
104
- throw new ToolError(timeoutMessage);
105
- }
106
- if (err instanceof ptree.Exception && err.aborted) {
107
- throw new ToolAbortError();
108
- }
109
- }
110
-
111
- const exitCode = child.exitCode ?? (exitError instanceof ptree.Exception ? exitError.exitCode : null);
112
-
113
- return {
114
- stdout,
115
- stderr: child.peekStderr(),
116
- exitCode,
117
- };
118
- }
119
-
120
- /**
121
- * Pluggable operations for the grep tool.
122
- * Override these to delegate search to remote systems (e.g., SSH).
123
- */
124
58
  export interface GrepOperations {
125
- /** Check if path is a directory. Throws if path doesn't exist. */
126
59
  isDirectory: (absolutePath: string) => Promise<boolean> | boolean;
127
- /** Read file contents for context lines */
128
60
  readFile: (absolutePath: string) => Promise<string> | string;
129
61
  }
130
62
 
131
- const defaultGrepOperations: GrepOperations = {
132
- isDirectory: async p => (await Bun.file(p).stat()).isDirectory(),
133
- readFile: p => Bun.file(p).text(),
134
- };
135
-
136
63
  export interface GrepToolOptions {
137
- /** Custom operations for grep. Default: local filesystem + ripgrep */
138
64
  operations?: GrepOperations;
139
65
  }
140
66
 
141
- interface GrepParams {
142
- pattern: string;
143
- path?: string;
144
- glob?: string;
145
- type?: string;
146
- output_mode?: "content" | "files_with_matches" | "count";
147
- i?: boolean;
148
- n?: boolean;
149
- a?: number;
150
- b?: number;
151
- c?: number;
152
- context?: number;
153
- multiline?: boolean;
154
- limit?: number;
155
- offset?: number;
156
- }
67
+ type GrepParams = Static<typeof grepSchema>;
157
68
 
158
69
  export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
159
70
  public readonly name = "grep";
@@ -162,61 +73,20 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
162
73
  public readonly parameters = grepSchema;
163
74
 
164
75
  private readonly session: ToolSession;
165
- private readonly ops: GrepOperations;
166
76
 
167
- constructor(session: ToolSession, options?: GrepToolOptions) {
77
+ constructor(session: ToolSession, _options?: GrepToolOptions) {
168
78
  this.session = session;
169
- this.ops = options?.operations ?? defaultGrepOperations;
170
79
  this.description = renderPromptTemplate(grepDescription);
171
80
  }
172
81
 
173
- /**
174
- * Validates a pattern against ripgrep's regex engine.
175
- * Uses a quick dry-run against /dev/null to check for parse errors.
176
- */
177
- private async validateRegexPattern(pattern: string, rgPath?: string): Promise<{ valid: boolean; error?: string }> {
178
- if (!rgPath) {
179
- return { valid: true }; // Can't validate, assume valid
180
- }
181
-
182
- // Run ripgrep against /dev/null with the pattern - this validates regex syntax
183
- // without searching any files
184
- const result = await $`${rgPath} --no-config --quiet -- ${pattern} /dev/null`.quiet().nothrow();
185
- const stderr = result.stderr?.toString() ?? "";
186
- const exitCode = result.exitCode ?? 0;
187
-
188
- // Exit code 1 = no matches (pattern is valid), 0 = matches found
189
- // Exit code 2 = error (often regex parse error)
190
- if (exitCode === 2 && stderr.includes("regex parse error")) {
191
- return { valid: false, error: stderr.trim() };
192
- }
193
-
194
- return { valid: true };
195
- }
196
-
197
82
  public async execute(
198
83
  _toolCallId: string,
199
84
  params: GrepParams,
200
85
  signal?: AbortSignal,
201
86
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
202
- toolContext?: AgentToolContext,
87
+ _toolContext?: AgentToolContext,
203
88
  ): Promise<AgentToolResult<GrepToolDetails>> {
204
- const {
205
- pattern,
206
- path: searchDir,
207
- glob,
208
- type,
209
- output_mode,
210
- i,
211
- n,
212
- a,
213
- b,
214
- c,
215
- context,
216
- multiline,
217
- limit,
218
- offset,
219
- } = params;
89
+ const { pattern, path: searchDir, glob, type, output_mode, i, n, context, multiline, limit, offset } = params;
220
90
 
221
91
  return untilAborted(signal, async () => {
222
92
  const normalizedPattern = pattern.trim();
@@ -235,51 +105,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
235
105
  }
236
106
  const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined;
237
107
 
238
- const normalizeContext = (value: number | undefined, label: string): number => {
239
- if (value === undefined) return 0;
240
- const normalized = Number.isFinite(value) ? Math.floor(value) : Number.NaN;
241
- if (!Number.isFinite(normalized) || normalized < 0) {
242
- throw new ToolError(`${label} must be a non-negative number`);
243
- }
244
- return normalized;
245
- };
246
-
247
- const normalizedAfter = normalizeContext(a, "After context");
248
- const normalizedBefore = normalizeContext(b, "Before context");
249
- const hasContextParam = context !== undefined;
250
- const hasCParam = c !== undefined;
251
- if (hasContextParam && hasCParam) {
252
- throw new ToolError("Cannot combine context with c");
253
- }
254
- const normalizedContext = normalizeContext(hasContextParam ? context : c, "Context");
255
- if (normalizedContext > 0 && (normalizedAfter > 0 || normalizedBefore > 0)) {
256
- throw new ToolError("Cannot combine context with a or b");
257
- }
258
- const contextAfterValue = normalizedContext > 0 ? normalizedContext : normalizedAfter;
259
- const contextBeforeValue = normalizedContext > 0 ? normalizedContext : normalizedBefore;
108
+ const normalizedContext = context ?? 5;
260
109
  const showLineNumbers = n ?? true;
261
110
  const ignoreCase = i ?? false;
262
- const normalizedGlob = glob?.trim() ?? "";
263
- const normalizedType = type?.trim() ?? "";
264
- const hasContentHints =
265
- limit !== undefined || context !== undefined || c !== undefined || a !== undefined || b !== undefined;
266
-
267
- // Validate regex patterns early to surface parse errors before running rg
268
- const rgPath = await ensureTool("rg", {
269
- silent: true,
270
- notify: message => toolContext?.ui?.notify(message, "info"),
271
- });
272
-
273
- if (!rgPath) {
274
- throw new ToolError("rg is not available and could not be downloaded");
275
- }
276
-
277
- const validation = await this.validateRegexPattern(normalizedPattern, rgPath);
278
- if (!validation.valid) {
279
- throw new ToolError(validation.error ?? "Invalid regex pattern");
280
- }
111
+ const hasContentHints = limit !== undefined || context !== undefined;
281
112
 
282
- // rgPath resolved earlier
283
113
  const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
284
114
  const scopePath = (() => {
285
115
  const relative = nodePath.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
@@ -288,95 +118,50 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
288
118
 
289
119
  let isDirectory: boolean;
290
120
  try {
291
- isDirectory = await this.ops.isDirectory(searchPath);
121
+ const stat = await Bun.file(searchPath).stat();
122
+ isDirectory = stat.isDirectory();
292
123
  } catch {
293
124
  throw new ToolError(`Path not found: ${searchPath}`);
294
125
  }
295
- const effectiveOutputMode =
296
- output_mode ?? (!isDirectory || hasContentHints ? "content" : "files_with_matches");
297
- const effectiveOffset = normalizedOffset > 0 ? normalizedOffset : 0;
126
+
127
+ const effectiveOutputMode = output_mode ?? (!isDirectory || hasContentHints ? "content" : "filesWithMatches");
298
128
  const effectiveLimit =
299
129
  effectiveOutputMode === "content" ? (normalizedLimit ?? DEFAULT_MATCH_LIMIT) : normalizedLimit;
300
130
 
301
- const formatPath = (filePath: string): string => {
302
- if (isDirectory) {
303
- const relative = nodePath.relative(searchPath, filePath);
304
- if (relative && !relative.startsWith("..")) {
305
- return relative.replace(/\\/g, "/");
306
- }
307
- }
308
- return nodePath.basename(filePath);
309
- };
310
-
311
- const fileCache = new Map<string, Promise<string[]>>();
312
- const getFileLines = async (filePath: string): Promise<string[]> => {
313
- let linesPromise = fileCache.get(filePath);
314
- if (!linesPromise) {
315
- linesPromise = (async () => {
316
- try {
317
- const content = await this.ops.readFile(filePath);
318
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
319
- } catch {
320
- return [];
321
- }
322
- })();
323
- fileCache.set(filePath, linesPromise);
324
- }
325
- return linesPromise;
326
- };
327
-
328
- const args: string[] = [];
329
-
330
- // Base arguments depend on output mode
331
- if (effectiveOutputMode === "files_with_matches") {
332
- args.push("--files-with-matches", "--color=never");
333
- } else if (effectiveOutputMode === "count") {
334
- args.push("--count", "--color=never");
335
- } else {
336
- args.push("--json", "--color=never");
337
- if (showLineNumbers) {
338
- args.push("--line-number");
131
+ // Run WASM grep
132
+ let result: Awaited<ReturnType<typeof wasmGrep>>;
133
+ try {
134
+ result = await wasmGrep({
135
+ pattern: normalizedPattern,
136
+ path: searchPath,
137
+ glob: glob?.trim() || undefined,
138
+ type: type?.trim() || undefined,
139
+ ignoreCase,
140
+ multiline: multiline ?? false,
141
+ hidden: true,
142
+ maxCount: effectiveLimit,
143
+ offset: normalizedOffset > 0 ? normalizedOffset : undefined,
144
+ context: effectiveOutputMode === "content" ? normalizedContext : undefined,
145
+ maxColumns: DEFAULT_MAX_COLUMN,
146
+ mode: effectiveOutputMode,
147
+ });
148
+ } catch (err) {
149
+ if (err instanceof Error && err.message.startsWith("regex parse error")) {
150
+ throw new ToolError(err.message);
339
151
  }
152
+ throw err;
340
153
  }
341
154
 
342
- args.push("--hidden");
343
-
344
- if (ignoreCase) {
345
- args.push("--ignore-case");
346
- } else {
347
- args.push("--case-sensitive");
348
- }
349
-
350
- if (multiline) {
351
- args.push("--multiline");
352
- }
353
-
354
- if (normalizedGlob) {
355
- args.push("--glob", normalizedGlob);
356
- }
357
-
358
- args.push("--glob", "!**/.git/**");
359
- args.push("--glob", "!**/node_modules/**");
360
-
361
- if (normalizedType) {
362
- args.push("--type", normalizedType);
363
- }
364
-
365
- if (effectiveOutputMode === "content") {
366
- if (normalizedContext > 0) {
367
- args.push("-C", String(normalizedContext));
155
+ const formatPath = (filePath: string): string => {
156
+ // WASM returns paths starting with / (the virtual root)
157
+ const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
158
+ if (isDirectory) {
159
+ return cleanPath.replace(/\\/g, "/");
368
160
  }
369
- }
370
-
371
- args.push("--", normalizedPattern, searchPath);
372
-
373
- const child = ptree.cspawn([rgPath, ...args], { signal });
161
+ return nodePath.basename(cleanPath);
162
+ };
374
163
 
375
- let matchCount = 0;
376
- let matchLimitReached = false;
377
- let linesTruncated = false;
378
- let killedDueToLimit = false;
379
- const outputLines: string[] = [];
164
+ // Build output
380
165
  const files = new Set<string>();
381
166
  const fileList: string[] = [];
382
167
  const fileMatchCounts = new Map<string, number>();
@@ -389,316 +174,88 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
389
174
  }
390
175
  };
391
176
 
392
- const recordFileMatch = (filePath: string) => {
393
- const relative = formatPath(filePath);
394
- fileMatchCounts.set(relative, (fileMatchCounts.get(relative) ?? 0) + 1);
395
- };
396
-
397
- // For simple output modes (files_with_matches, count), process text directly
398
- if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") {
399
- const stdout = await child.text().catch(x => {
400
- if (x instanceof ptree.Exception && x.exitCode === 1) {
401
- return "";
402
- }
403
- return Promise.reject(x);
404
- });
405
-
406
- const exitCode = child.exitCode ?? 0;
407
- if (exitCode !== 0 && exitCode !== 1) {
408
- const errorMsg = child.peekStderr().trim() || `ripgrep exited with code ${exitCode}`;
409
- throw new ToolError(errorMsg);
410
- }
411
-
412
- const lines = stdout
413
- .trim()
414
- .split("\n")
415
- .filter(line => line.length > 0);
416
-
417
- if (lines.length === 0) {
418
- const details: GrepToolDetails = {
419
- scopePath,
420
- matchCount: 0,
421
- fileCount: 0,
422
- files: [],
423
- mode: effectiveOutputMode,
424
- truncated: false,
425
- };
426
- return toolResult(details).text("No matches found").done();
427
- }
428
-
429
- const offsetLines = effectiveOffset > 0 ? lines.slice(effectiveOffset) : lines;
430
- const listLimit = applyListLimit(offsetLines, {
431
- limit: normalizedLimit,
432
- limitType: "result",
433
- });
434
- const processedLines = listLimit.items;
435
- const limitMeta = listLimit.meta;
436
-
437
- let simpleMatchCount = 0;
438
- let fileCount = 0;
439
- const simpleFiles = new Set<string>();
440
- const simpleFileList: string[] = [];
441
- const simpleFileMatchCounts = new Map<string, number>();
442
-
443
- const recordSimpleFile = (filePath: string) => {
444
- const relative = formatPath(filePath);
445
- if (!simpleFiles.has(relative)) {
446
- simpleFiles.add(relative);
447
- simpleFileList.push(relative);
448
- }
449
- };
450
-
451
- // Count mode: ripgrep provides total count per file, so we set directly (not increment)
452
- const setFileMatchCount = (filePath: string, count: number) => {
453
- const relative = formatPath(filePath);
454
- simpleFileMatchCounts.set(relative, count);
455
- };
456
-
457
- if (effectiveOutputMode === "files_with_matches") {
458
- for (const line of processedLines) {
459
- recordSimpleFile(line);
460
- }
461
- fileCount = simpleFiles.size;
462
- simpleMatchCount = fileCount;
463
- } else {
464
- for (const line of processedLines) {
465
- const separatorIndex = line.lastIndexOf(":");
466
- const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
467
- const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1);
468
- const count = Number.parseInt(countPart, 10);
469
- recordSimpleFile(filePart);
470
- if (!Number.isNaN(count)) {
471
- simpleMatchCount += count;
472
- setFileMatchCount(filePart, count);
473
- }
474
- }
475
- fileCount = simpleFiles.size;
476
- }
477
-
478
- const truncatedByLimit = Boolean(limitMeta.resultLimit);
479
-
480
- // For count mode, format as "path:count"
481
- if (effectiveOutputMode === "count") {
482
- const formatted = processedLines.map(line => {
483
- const separatorIndex = line.lastIndexOf(":");
484
- const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex));
485
- const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1);
486
- return `${relative}:${count}`;
487
- });
488
- const output = formatted.join("\n");
489
- const details: GrepToolDetails = {
490
- scopePath,
491
- matchCount: simpleMatchCount,
492
- fileCount,
493
- files: simpleFileList,
494
- fileMatches: simpleFileList.map(path => ({
495
- path,
496
- count: simpleFileMatchCounts.get(path) ?? 0,
497
- })),
498
- mode: effectiveOutputMode,
499
- truncated: truncatedByLimit,
500
- resultLimitReached: limitMeta.resultLimit?.reached,
501
- };
502
- return toolResult(details)
503
- .text(output)
504
- .limits({
505
- resultLimit: limitMeta.resultLimit?.reached,
506
- })
507
- .done();
508
- }
509
-
510
- // For files_with_matches, format paths
511
- const formatted = processedLines.map(line => formatPath(line));
512
- const output = formatted.join("\n");
177
+ if (result.totalMatches === 0) {
513
178
  const details: GrepToolDetails = {
514
179
  scopePath,
515
- matchCount: simpleMatchCount,
516
- fileCount,
517
- files: simpleFileList,
180
+ matchCount: 0,
181
+ fileCount: 0,
182
+ files: [],
518
183
  mode: effectiveOutputMode,
519
- truncated: truncatedByLimit,
520
- resultLimitReached: limitMeta.resultLimit?.reached,
184
+ truncated: false,
521
185
  };
522
- return toolResult(details)
523
- .text(output)
524
- .limits({
525
- resultLimit: limitMeta.resultLimit?.reached,
526
- })
527
- .done();
186
+ return toolResult(details).text("No matches found").done();
528
187
  }
529
188
 
530
- // Content mode - existing JSON processing
531
- const formatBlock = async (filePath: string, lineNumber: number): Promise<string[]> => {
532
- const relativePath = formatPath(filePath);
533
- const lines = await getFileLines(filePath);
534
- if (!lines.length) {
535
- return showLineNumbers
536
- ? [`${relativePath}:${lineNumber}: (unable to read file)`]
537
- : [`${relativePath}: (unable to read file)`];
538
- }
539
-
540
- const block: string[] = [];
541
- const start = contextBeforeValue > 0 ? Math.max(1, lineNumber - contextBeforeValue) : lineNumber;
542
- const end = contextAfterValue > 0 ? Math.min(lines.length, lineNumber + contextAfterValue) : lineNumber;
543
-
544
- for (let current = start; current <= end; current++) {
545
- const lineText = lines[current - 1] ?? "";
546
- const sanitized = lineText.replace(/\r/g, "");
547
- const isMatchLine = current === lineNumber;
548
-
549
- const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
550
- if (wasTruncated) {
551
- linesTruncated = true;
552
- }
553
-
554
- if (isMatchLine) {
555
- block.push(
556
- showLineNumbers
557
- ? `${relativePath}:${current}: ${truncatedText}`
558
- : `${relativePath}: ${truncatedText}`,
559
- );
560
- } else {
561
- block.push(
562
- showLineNumbers
563
- ? `${relativePath}-${current}- ${truncatedText}`
564
- : `${relativePath}- ${truncatedText}`,
565
- );
566
- }
567
- }
568
-
569
- return block;
570
- };
571
-
572
- const maxMatches = effectiveLimit !== undefined ? effectiveLimit + effectiveOffset : undefined;
573
- const processEvent = async (event: unknown): Promise<void> => {
574
- if (!event || typeof event !== "object") {
575
- return;
576
- }
577
- const parsed = event as { type?: string; data?: { path?: { text?: string }; line_number?: number } };
578
- if (parsed.type !== "match") {
579
- return;
580
- }
581
-
582
- const nextIndex = matchCount + 1;
583
- if (maxMatches !== undefined && nextIndex > maxMatches) {
584
- matchLimitReached = true;
585
- killedDueToLimit = true;
586
- child.kill("SIGKILL");
587
- return;
588
- }
589
-
590
- matchCount = nextIndex;
591
- const filePath = parsed.data?.path?.text;
592
- const lineNumber = parsed.data?.line_number;
189
+ let outputLines: string[] = [];
190
+ let linesTruncated = false;
593
191
 
594
- if (filePath && typeof lineNumber === "number") {
595
- if (matchCount <= effectiveOffset) {
596
- return;
192
+ for (const match of result.matches) {
193
+ recordFile(match.path);
194
+ const relativePath = formatPath(match.path);
195
+
196
+ if (effectiveOutputMode === "content") {
197
+ // Add context before
198
+ if (match.contextBefore) {
199
+ for (const ctx of match.contextBefore) {
200
+ outputLines.push(
201
+ showLineNumbers
202
+ ? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
203
+ : `${relativePath}- ${ctx.line}`,
204
+ );
205
+ }
597
206
  }
598
- recordFile(filePath);
599
- recordFileMatch(filePath);
600
- const block = await formatBlock(filePath, lineNumber);
601
- outputLines.push(...block);
602
- }
603
- };
604
207
 
605
- const decoder = new TextDecoder();
606
- let buffer = "";
607
- const parseBuffer = async () => {
608
- while (buffer.length > 0) {
609
- const result = Bun.JSONL.parseChunk(buffer);
610
- for (const value of result.values) {
611
- await processEvent(value);
612
- }
208
+ // Add match line
209
+ outputLines.push(
210
+ showLineNumbers
211
+ ? `${relativePath}:${match.lineNumber}: ${match.line}`
212
+ : `${relativePath}: ${match.line}`,
213
+ );
613
214
 
614
- if (result.read > 0) {
615
- buffer = buffer.slice(result.read);
215
+ if (match.truncated) {
216
+ linesTruncated = true;
616
217
  }
617
218
 
618
- if (result.error) {
619
- const nextNewline = buffer.indexOf("\n");
620
- if (nextNewline === -1) {
621
- buffer = "";
622
- break;
219
+ // Add context after
220
+ if (match.contextAfter) {
221
+ for (const ctx of match.contextAfter) {
222
+ outputLines.push(
223
+ showLineNumbers
224
+ ? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
225
+ : `${relativePath}- ${ctx.line}`,
226
+ );
623
227
  }
624
- buffer = buffer.slice(nextNewline + 1);
625
- continue;
626
- }
627
-
628
- if (result.read === 0) {
629
- break;
630
- }
631
- }
632
- };
633
-
634
- // Process stdout stream with JSONL chunk parsing
635
- try {
636
- for await (const chunk of child.stdout) {
637
- if (killedDueToLimit) {
638
- break;
639
228
  }
640
- buffer += decoder.decode(chunk, { stream: true });
641
- await parseBuffer();
642
- }
643
- if (!killedDueToLimit) {
644
- buffer += decoder.decode();
645
- await parseBuffer();
646
- }
647
- } catch (err) {
648
- if (err instanceof ptree.Exception && err.aborted) {
649
- throw new ToolAbortError();
650
- }
651
- // Stream may close early if we killed due to limit - that's ok
652
- if (!killedDueToLimit) {
653
- throw err;
654
- }
655
- }
656
229
 
657
- // Wait for process to exit
658
- try {
659
- await child.exited;
660
- } catch (err) {
661
- if (err instanceof ptree.Exception) {
662
- if (err.aborted) {
663
- throw new ToolAbortError();
664
- }
665
- // Non-zero exit is ok if we killed due to limit or exit code 1 (no matches)
666
- if (!killedDueToLimit && err.exitCode !== 1) {
667
- const errorMsg = child.peekStderr().trim() || `ripgrep exited with code ${err.exitCode}`;
668
- throw new ToolError(errorMsg);
669
- }
230
+ // Track per-file counts
231
+ fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
232
+ } else if (effectiveOutputMode === "filesWithMatches") {
233
+ // One line per file
234
+ const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
235
+ fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 1);
670
236
  } else {
671
- throw err;
237
+ // count mode
238
+ const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
239
+ fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 0);
672
240
  }
673
241
  }
674
242
 
675
- if (matchCount === 0) {
676
- const details: GrepToolDetails = {
677
- scopePath,
678
- matchCount: 0,
679
- fileCount: 0,
680
- files: [],
681
- mode: effectiveOutputMode,
682
- truncated: false,
683
- };
684
- return toolResult(details).text("No matches found").done();
243
+ // Format output based on mode
244
+ if (effectiveOutputMode === "filesWithMatches") {
245
+ outputLines = fileList;
246
+ } else if (effectiveOutputMode === "count") {
247
+ outputLines = fileList.map(f => `${f}:${fileMatchCounts.get(f) ?? 0}`);
685
248
  }
686
249
 
687
- const limitMeta =
688
- matchLimitReached && effectiveLimit !== undefined
689
- ? { matchLimit: { reached: effectiveLimit, suggestion: effectiveLimit * 2 } }
690
- : {};
691
-
692
- // Apply byte truncation (no line limit since we already have match limit)
693
250
  const rawOutput = outputLines.join("\n");
694
251
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
695
-
696
252
  const output = truncation.content;
697
- const truncated = Boolean(matchLimitReached || truncation.truncated || limitMeta.matchLimit || linesTruncated);
253
+
254
+ const truncated = Boolean(result.limitReached || truncation.truncated || linesTruncated);
698
255
  const details: GrepToolDetails = {
699
256
  scopePath,
700
- matchCount: effectiveOffset > 0 ? Math.max(0, matchCount - effectiveOffset) : matchCount,
701
- fileCount: files.size,
257
+ matchCount: result.totalMatches,
258
+ fileCount: result.filesWithMatches,
702
259
  files: fileList,
703
260
  fileMatches: fileList.map(path => ({
704
261
  path,
@@ -706,22 +263,19 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
706
263
  })),
707
264
  mode: effectiveOutputMode,
708
265
  truncated,
709
- matchLimitReached: limitMeta.matchLimit?.reached,
266
+ matchLimitReached: result.limitReached ? effectiveLimit : undefined,
710
267
  };
711
268
 
712
- // Keep TUI compatibility fields
713
- if (matchLimitReached && effectiveLimit !== undefined) {
714
- details.matchLimitReached = effectiveLimit;
715
- }
716
269
  if (truncation.truncated) details.truncation = truncation;
717
270
  if (linesTruncated) details.linesTruncated = true;
718
271
 
719
272
  const resultBuilder = toolResult(details)
720
273
  .text(output)
721
274
  .limits({
722
- matchLimit: limitMeta.matchLimit?.reached,
275
+ matchLimit: result.limitReached ? effectiveLimit : undefined,
723
276
  columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined,
724
277
  });
278
+
725
279
  if (truncation.truncated) {
726
280
  resultBuilder.truncation(truncation, { direction: "head" });
727
281
  }
@@ -742,9 +296,6 @@ interface GrepRenderArgs {
742
296
  type?: string;
743
297
  i?: boolean;
744
298
  n?: boolean;
745
- a?: number;
746
- b?: number;
747
- c?: number;
748
299
  context?: number;
749
300
  multiline?: boolean;
750
301
  output_mode?: string;
@@ -762,13 +313,10 @@ export const grepToolRenderer = {
762
313
  if (args.path) meta.push(`in ${args.path}`);
763
314
  if (args.glob) meta.push(`glob:${args.glob}`);
764
315
  if (args.type) meta.push(`type:${args.type}`);
765
- if (args.output_mode && args.output_mode !== "files_with_matches") meta.push(`mode:${args.output_mode}`);
316
+ if (args.output_mode && args.output_mode !== "filesWithMatches") meta.push(`mode:${args.output_mode}`);
766
317
  if (args.i) meta.push("case:insensitive");
767
318
  if (args.n === false) meta.push("no-line-numbers");
768
- const contextValue = args.context ?? args.c;
769
- if (contextValue !== undefined && contextValue > 0) meta.push(`context:${contextValue}`);
770
- if (args.a !== undefined && args.a > 0) meta.push(`after:${args.a}`);
771
- if (args.b !== undefined && args.b > 0) meta.push(`before:${args.b}`);
319
+ if (args.context !== undefined && args.context > 0) meta.push(`context:${args.context}`);
772
320
  if (args.multiline) meta.push("multiline");
773
321
  if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
774
322
  if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
@@ -821,7 +369,7 @@ export const grepToolRenderer = {
821
369
 
822
370
  const matchCount = details?.matchCount ?? 0;
823
371
  const fileCount = details?.fileCount ?? 0;
824
- const mode = details?.mode ?? "files_with_matches";
372
+ const mode = details?.mode ?? "filesWithMatches";
825
373
  const truncation = details?.meta?.truncation;
826
374
  const limits = details?.meta?.limits;
827
375
  const truncated = Boolean(
@@ -838,7 +386,7 @@ export const grepToolRenderer = {
838
386
  }
839
387
 
840
388
  const summaryParts =
841
- mode === "files_with_matches"
389
+ mode === "filesWithMatches"
842
390
  ? [formatCount("file", fileCount)]
843
391
  : [formatCount("match", matchCount), formatCount("file", fileCount)];
844
392
  const meta = [...summaryParts];