pi-multi-grep 1.0.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -0
  3. package/package.json +51 -0
  4. package/src/index.ts +525 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-multi-grep contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # pi-multi-grep
2
+
3
+ After numerous times seeing agents being confused by bash-style `grep` tool syntax and struggle with the default Pi `grep` tool, I decided to create this extension.
4
+
5
+ `pi-multi-grep` is a simple Pi extension that replaces the built-in `grep` tool with an array-first version for multiple search targets and multiple glob filters. It still uses `ripgrep` underneath.
6
+
7
+ So now agent:
8
+ - stop being confused by the `grep` syntax
9
+ - can search multiple paths at once
10
+
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pi install npm:pi-multi-grep
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```json
21
+ {
22
+ "pattern": "TODO",
23
+ "paths": ["src", "packages"],
24
+ "globs": ["*.ts", "*.tsx", "!*.test.ts"]
25
+ }
26
+ ```
27
+
28
+ `paths` defaults to the current directory when omitted. `globs` is optional.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "pi-multi-grep",
3
+ "version": "1.0.0",
4
+ "description": "Pi extension that replaces the built-in grep tool with multi-path and multi-glob support.",
5
+ "author": {
6
+ "name": "Danny Kok",
7
+ "email": "dannykok1@gmail.com",
8
+ "url": "https://github.com/dannykok"
9
+ },
10
+ "type": "module",
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "pi-package",
20
+ "pi-extension",
21
+ "grep",
22
+ "ripgrep",
23
+ "glob"
24
+ ],
25
+ "license": "MIT",
26
+ "scripts": {
27
+ "test": "tsx --test tests/*.test.ts",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ]
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*",
38
+ "typebox": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-coding-agent": "latest",
42
+ "@earendil-works/pi-tui": "latest",
43
+ "@types/node": "latest",
44
+ "tsx": "latest",
45
+ "typebox": "latest",
46
+ "typescript": "latest"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.19.0"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,525 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { spawn } from "node:child_process";
6
+ import type { ExtensionAPI, GrepToolDetails } from "@earendil-works/pi-coding-agent";
7
+ import { DEFAULT_MAX_BYTES, formatSize, keyHint, truncateHead, truncateLine } from "@earendil-works/pi-coding-agent";
8
+ import { Text } from "@earendil-works/pi-tui";
9
+ import { Type, type Static } from "typebox";
10
+
11
+ const DEFAULT_LIMIT = 100;
12
+ const GREP_MAX_LINE_LENGTH = 500;
13
+
14
+ export const grepSchema = Type.Object({
15
+ pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
16
+ paths: Type.Optional(
17
+ Type.Array(Type.String(), {
18
+ description: "Directories or files to search (default: current directory).",
19
+ }),
20
+ ),
21
+ globs: Type.Optional(
22
+ Type.Array(Type.String(), {
23
+ description:
24
+ "Filter files by glob patterns, e.g. '*.ts' or '**/*.spec.ts'",
25
+ }),
26
+ ),
27
+ ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
28
+ literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" })),
29
+ context: Type.Optional(Type.Number({ description: "Number of lines to show before and after each match (default: 0)" })),
30
+ limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })),
31
+ });
32
+
33
+ export type MultiGlobGrepToolInput = Static<typeof grepSchema>;
34
+
35
+ export interface ExecuteGrepOptions {
36
+ cwd: string;
37
+ signal?: AbortSignal | undefined;
38
+ rgPath?: string | undefined;
39
+ }
40
+
41
+ export type GrepExecutionResult = {
42
+ content: Array<{ type: "text"; text: string }>;
43
+ details: GrepToolDetails | undefined;
44
+ };
45
+
46
+ type Match = {
47
+ filePath: string;
48
+ lineNumber: number;
49
+ lineText?: string;
50
+ };
51
+
52
+ type SearchRoot = {
53
+ rawPath: string;
54
+ searchPath: string;
55
+ isDirectory: boolean;
56
+ };
57
+
58
+ export function normalizeSearchPathInputs(searchPaths?: readonly string[]): string[] {
59
+ const paths = (searchPaths ?? []).filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
60
+ return paths.length > 0 ? paths : ["."];
61
+ }
62
+
63
+ export function formatPathSummary(searchPaths?: readonly string[]): string {
64
+ const paths = normalizeSearchPathInputs(searchPaths);
65
+ const displayPaths = paths.map((entry) => shortenPath(entry));
66
+ if (displayPaths.length <= 3) return displayPaths.join(", ");
67
+ return `${displayPaths.slice(0, 3).join(", ")}, +${displayPaths.length - 3} more`;
68
+ }
69
+
70
+ export function normalizeGlobPatterns(globs?: readonly string[]): string[] {
71
+ return (globs ?? []).filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
72
+ }
73
+
74
+ export function formatGlobSummary(globs?: readonly string[]): string | undefined {
75
+ const patterns = normalizeGlobPatterns(globs);
76
+ if (patterns.length === 0) return undefined;
77
+ if (patterns.length <= 3) return patterns.join(", ");
78
+ return `${patterns.slice(0, 3).join(", ")}, +${patterns.length - 3} more`;
79
+ }
80
+
81
+ export function prepareGrepArguments(args: unknown): unknown {
82
+ if (!args || typeof args !== "object" || Array.isArray(args)) return args;
83
+
84
+ const input = args as Record<string, unknown>;
85
+ let next: Record<string, unknown> | undefined;
86
+
87
+ const legacyPaths = legacyStringOrArray(input.path);
88
+ if (legacyPaths) {
89
+ const { path: _path, ...rest } = input;
90
+ const existingPaths = Array.isArray(input.paths) ? input.paths : [];
91
+ next = {
92
+ ...rest,
93
+ paths: [...legacyPaths, ...existingPaths],
94
+ };
95
+ }
96
+
97
+ const current = next ?? input;
98
+ const legacyGlobs = legacyStringOrArray(current.glob);
99
+ if (legacyGlobs) {
100
+ const { glob: _glob, ...rest } = current;
101
+ const existingGlobs = Array.isArray(current.globs) ? current.globs : [];
102
+ next = {
103
+ ...rest,
104
+ globs: [...legacyGlobs, ...existingGlobs],
105
+ };
106
+ }
107
+
108
+ return next ?? args;
109
+ }
110
+
111
+ function legacyStringOrArray(value: unknown): unknown[] | undefined {
112
+ if (typeof value === "string") return [value];
113
+ if (Array.isArray(value)) return value;
114
+ return undefined;
115
+ }
116
+
117
+ export function buildRipgrepArgs(input: MultiGlobGrepToolInput, searchPaths: string | readonly string[]): string[] {
118
+ const args = ["--json", "--line-number", "--color=never", "--hidden"];
119
+
120
+ if (input.ignoreCase) args.push("--ignore-case");
121
+ if (input.literal) args.push("--fixed-strings");
122
+
123
+ for (const globPattern of normalizeGlobPatterns(input.globs)) {
124
+ args.push("--glob", globPattern);
125
+ }
126
+
127
+ const searchPathArgs = Array.isArray(searchPaths) ? searchPaths : [searchPaths];
128
+ args.push("--", input.pattern, ...searchPathArgs);
129
+ return args;
130
+ }
131
+
132
+ export async function executeGrep(input: MultiGlobGrepToolInput, options: ExecuteGrepOptions): Promise<GrepExecutionResult> {
133
+ const { cwd, signal, rgPath = "rg" } = options;
134
+
135
+ if (signal?.aborted) {
136
+ throw new Error("Operation aborted");
137
+ }
138
+
139
+ const rawSearchPaths = normalizeSearchPathInputs(input.paths);
140
+ const searchRoots = await Promise.all(
141
+ rawSearchPaths.map(async (rawPath): Promise<SearchRoot> => {
142
+ const searchPath = resolveToCwd(rawPath, cwd);
143
+ const searchStat = await stat(searchPath).catch(() => undefined);
144
+ if (!searchStat) {
145
+ throw new Error(`Path not found: ${searchPath}`);
146
+ }
147
+
148
+ return { rawPath, searchPath, isDirectory: searchStat.isDirectory() };
149
+ }),
150
+ );
151
+
152
+ const contextValue = typeof input.context === "number" && input.context > 0 ? Math.floor(input.context) : 0;
153
+ const effectiveLimit = Math.max(1, Math.floor(input.limit ?? DEFAULT_LIMIT));
154
+ const args = buildRipgrepArgs(
155
+ input,
156
+ searchRoots.map((root) => root.searchPath),
157
+ );
158
+
159
+ return new Promise<GrepExecutionResult>((resolve, reject) => {
160
+ let settled = false;
161
+ let aborted = false;
162
+ let killedDueToLimit = false;
163
+ let stderr = "";
164
+ let matchCount = 0;
165
+ let matchLimitReached = false;
166
+ let linesTruncated = false;
167
+ const matches: Match[] = [];
168
+ const outputLines: string[] = [];
169
+ const fileCache = new Map<string, string[]>();
170
+
171
+ const settle = (fn: () => void) => {
172
+ if (!settled) {
173
+ settled = true;
174
+ fn();
175
+ }
176
+ };
177
+
178
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
179
+ const rl = createInterface({ input: child.stdout });
180
+
181
+ function cleanup() {
182
+ rl.close();
183
+ signal?.removeEventListener("abort", onAbort);
184
+ }
185
+
186
+ function stopChild(dueToLimit = false) {
187
+ if (!child.killed) {
188
+ killedDueToLimit = dueToLimit;
189
+ child.kill();
190
+ }
191
+ }
192
+
193
+ function onAbort() {
194
+ aborted = true;
195
+ stopChild();
196
+ }
197
+
198
+ const formatPath = createPathFormatter(searchRoots, cwd);
199
+
200
+ const getFileLines = async (filePath: string) => {
201
+ let lines = fileCache.get(filePath);
202
+ if (!lines) {
203
+ try {
204
+ const content = await readFile(filePath, "utf8");
205
+ lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
206
+ } catch {
207
+ lines = [];
208
+ }
209
+ fileCache.set(filePath, lines);
210
+ }
211
+ return lines;
212
+ };
213
+
214
+ const formatBlock = async (filePath: string, lineNumber: number) => {
215
+ const relativePath = formatPath(filePath);
216
+ const lines = await getFileLines(filePath);
217
+ if (!lines.length) return [`${relativePath}:${lineNumber}: (unable to read file)`];
218
+
219
+ const block: string[] = [];
220
+ const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
221
+ const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
222
+
223
+ for (let current = start; current <= end; current++) {
224
+ const lineText = lines[current - 1] ?? "";
225
+ const sanitized = lineText.replace(/\r/g, "");
226
+ const isMatchLine = current === lineNumber;
227
+ const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
228
+ if (wasTruncated) linesTruncated = true;
229
+
230
+ if (isMatchLine) block.push(`${relativePath}:${current}: ${truncatedText}`);
231
+ else block.push(`${relativePath}-${current}- ${truncatedText}`);
232
+ }
233
+
234
+ return block;
235
+ };
236
+
237
+ signal?.addEventListener("abort", onAbort, { once: true });
238
+
239
+ child.stderr?.on("data", (chunk: Buffer) => {
240
+ stderr += chunk.toString();
241
+ });
242
+
243
+ rl.on("line", (line) => {
244
+ if (!line.trim() || matchCount >= effectiveLimit) return;
245
+
246
+ const match = parseRipgrepMatch(line);
247
+ if (!match) return;
248
+
249
+ matchCount++;
250
+ matches.push(match);
251
+
252
+ if (matchCount >= effectiveLimit) {
253
+ matchLimitReached = true;
254
+ stopChild(true);
255
+ }
256
+ });
257
+
258
+ child.on("error", (error: NodeJS.ErrnoException) => {
259
+ cleanup();
260
+ const message =
261
+ error.code === "ENOENT"
262
+ ? "ripgrep (rg) is not available. Install ripgrep or ensure rg is on PATH."
263
+ : `Failed to run ripgrep: ${error.message}`;
264
+ settle(() => reject(new Error(message)));
265
+ });
266
+
267
+ child.on("close", (code) => {
268
+ void (async () => {
269
+ cleanup();
270
+
271
+ if (aborted) {
272
+ settle(() => reject(new Error("Operation aborted")));
273
+ return;
274
+ }
275
+
276
+ if (!killedDueToLimit && code !== 0 && code !== 1) {
277
+ const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
278
+ settle(() => reject(new Error(errorMsg)));
279
+ return;
280
+ }
281
+
282
+ if (matchCount === 0) {
283
+ settle(() => resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }));
284
+ return;
285
+ }
286
+
287
+ for (const match of matches) {
288
+ if (contextValue === 0 && match.lineText !== undefined) {
289
+ const relativePath = formatPath(match.filePath);
290
+ const sanitized = match.lineText.replace(/\r\n/g, "\n").replace(/\r/g, "").replace(/\n$/, "");
291
+ const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
292
+ if (wasTruncated) linesTruncated = true;
293
+ outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
294
+ } else {
295
+ const block = await formatBlock(match.filePath, match.lineNumber);
296
+ outputLines.push(...block);
297
+ }
298
+ }
299
+
300
+ const rawOutput = outputLines.join("\n");
301
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
302
+ let output = truncation.content;
303
+ const details: GrepToolDetails = {};
304
+ const notices: string[] = [];
305
+
306
+ if (matchLimitReached) {
307
+ notices.push(`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`);
308
+ details.matchLimitReached = effectiveLimit;
309
+ }
310
+
311
+ if (truncation.truncated) {
312
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
313
+ details.truncation = truncation;
314
+ }
315
+
316
+ if (linesTruncated) {
317
+ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
318
+ details.linesTruncated = true;
319
+ }
320
+
321
+ if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
322
+
323
+ settle(() =>
324
+ resolve({
325
+ content: [{ type: "text", text: output }],
326
+ details: Object.keys(details).length > 0 ? details : undefined,
327
+ }),
328
+ );
329
+ })().catch((error: unknown) => {
330
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
331
+ });
332
+ });
333
+ });
334
+ }
335
+
336
+ function createPathFormatter(searchRoots: readonly SearchRoot[], cwd: string): (filePath: string) => string {
337
+ if (searchRoots.length === 1) {
338
+ const root = searchRoots[0]!;
339
+ return (filePath: string) => {
340
+ if (root.isDirectory) {
341
+ const relative = relativeInside(root.searchPath, filePath);
342
+ if (relative) return relative;
343
+ }
344
+
345
+ return path.basename(filePath);
346
+ };
347
+ }
348
+
349
+ return (filePath: string) => {
350
+ const relativeToCwd = relativeInside(cwd, filePath);
351
+ if (relativeToCwd) return relativeToCwd;
352
+
353
+ for (const root of searchRoots) {
354
+ if (root.isDirectory) {
355
+ const relative = relativeInside(root.searchPath, filePath);
356
+ if (relative) {
357
+ const rootLabel = searchRootDisplayLabel(root, cwd);
358
+ return rootLabel === "." ? relative : normalizeDisplayPath(path.join(rootLabel, relative));
359
+ }
360
+ } else if (path.resolve(filePath) === root.searchPath) {
361
+ return searchRootDisplayLabel(root, cwd);
362
+ }
363
+ }
364
+
365
+ return path.basename(filePath);
366
+ };
367
+ }
368
+
369
+ function relativeInside(rootPath: string, targetPath: string): string | undefined {
370
+ const relative = path.relative(rootPath, targetPath);
371
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
372
+ return normalizeDisplayPath(relative);
373
+ }
374
+
375
+ function searchRootDisplayLabel(root: SearchRoot, cwd: string): string {
376
+ const rawPath = root.rawPath.startsWith("@") ? root.rawPath.slice(1) : root.rawPath;
377
+ if (!path.isAbsolute(rawPath)) return normalizeDisplayPath(rawPath || ".");
378
+
379
+ const relativeToCwd = relativeInside(cwd, root.searchPath);
380
+ if (relativeToCwd) return relativeToCwd;
381
+
382
+ return path.basename(root.searchPath);
383
+ }
384
+
385
+ function normalizeDisplayPath(displayPath: string): string {
386
+ return displayPath.replace(/\\/g, "/");
387
+ }
388
+
389
+ function parseRipgrepMatch(line: string): Match | undefined {
390
+ let event: unknown;
391
+ try {
392
+ event = JSON.parse(line);
393
+ } catch {
394
+ return undefined;
395
+ }
396
+
397
+ if (!isRecord(event) || event.type !== "match" || !isRecord(event.data)) return undefined;
398
+
399
+ const pathData = event.data.path;
400
+ const linesData = event.data.lines;
401
+ const filePath = isRecord(pathData) && typeof pathData.text === "string" ? pathData.text : undefined;
402
+ const lineNumber = typeof event.data.line_number === "number" ? event.data.line_number : undefined;
403
+ const lineText = isRecord(linesData) && typeof linesData.text === "string" ? linesData.text : undefined;
404
+
405
+ if (!filePath || lineNumber === undefined) return undefined;
406
+ return lineText === undefined ? { filePath, lineNumber } : { filePath, lineNumber, lineText };
407
+ }
408
+
409
+ function isRecord(value: unknown): value is Record<string, unknown> {
410
+ return typeof value === "object" && value !== null && !Array.isArray(value);
411
+ }
412
+
413
+ function resolveToCwd(rawPath: string, cwd: string): string {
414
+ const withoutAtPrefix = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
415
+ return path.isAbsolute(withoutAtPrefix) ? withoutAtPrefix : path.resolve(cwd, withoutAtPrefix);
416
+ }
417
+
418
+ function str(value: unknown): string | null {
419
+ if (typeof value === "string") return value;
420
+ if (value == null) return "";
421
+ return null;
422
+ }
423
+
424
+ function stringArray(value: unknown): string[] {
425
+ if (typeof value === "string") return [value];
426
+ if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string");
427
+ return [];
428
+ }
429
+
430
+ function shortenPath(displayPath: string): string {
431
+ const home = homedir();
432
+ return displayPath.startsWith(home) ? `~${displayPath.slice(home.length)}` : displayPath;
433
+ }
434
+
435
+ function textOutput(result: { content?: Array<{ type?: string; text?: string }> } | undefined): string {
436
+ return (result?.content ?? [])
437
+ .filter((item): item is { type: "text"; text: string } => item.type === "text" && typeof item.text === "string")
438
+ .map((item) => item.text.replace(/\r/g, ""))
439
+ .join("\n");
440
+ }
441
+
442
+ export function formatGrepCall(args: unknown, theme: any): string {
443
+ const input = isRecord(args) ? args : undefined;
444
+ const pattern = str(input?.pattern);
445
+ const paths = [...stringArray(input?.path), ...stringArray(input?.paths)];
446
+ const displayPath = formatPathSummary(paths);
447
+ const globs = [...stringArray(input?.glob), ...stringArray(input?.globs)];
448
+ const globSummary = formatGlobSummary(globs);
449
+ const limit = input?.limit;
450
+ const invalidArg = theme.fg("error", "[invalid arg]");
451
+
452
+ let text =
453
+ theme.fg("toolTitle", theme.bold("grep")) +
454
+ " " +
455
+ (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
456
+ theme.fg("toolOutput", ` in ${displayPath === null ? invalidArg : displayPath}`);
457
+
458
+ if (globSummary) text += theme.fg("toolOutput", ` (${globSummary})`);
459
+ if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${String(limit)}`);
460
+ return text;
461
+ }
462
+
463
+ function formatGrepResult(
464
+ result: { content?: Array<{ type?: string; text?: string }>; details?: GrepToolDetails } | undefined,
465
+ options: { expanded?: boolean },
466
+ theme: any,
467
+ ): string {
468
+ const output = textOutput(result).trim();
469
+ let text = "";
470
+
471
+ if (output) {
472
+ const lines = output.split("\n");
473
+ const maxLines = options.expanded ? lines.length : 15;
474
+ const displayLines = lines.slice(0, maxLines);
475
+ const remaining = lines.length - maxLines;
476
+ text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
477
+
478
+ if (remaining > 0) {
479
+ text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
480
+ }
481
+ }
482
+
483
+ const matchLimit = result?.details?.matchLimitReached;
484
+ const truncation = result?.details?.truncation;
485
+ const linesTruncated = result?.details?.linesTruncated;
486
+
487
+ if (matchLimit || truncation?.truncated || linesTruncated) {
488
+ const warnings: string[] = [];
489
+ if (matchLimit) warnings.push(`${matchLimit} matches limit`);
490
+ if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
491
+ if (linesTruncated) warnings.push("some lines truncated");
492
+ text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
493
+ }
494
+
495
+ return text;
496
+ }
497
+
498
+ export default function multiGlobGrepExtension(pi: ExtensionAPI) {
499
+ pi.registerTool({
500
+ name: "grep",
501
+ label: "grep",
502
+ description: `Search file contents for a pattern. Supports paths for one or more search targets and globs for one or more include/exclude patterns. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
503
+ promptSnippet: "Search file contents for patterns (respects .gitignore; supports paths/globs filters)",
504
+ parameters: grepSchema,
505
+ prepareArguments(args) {
506
+ return prepareGrepArguments(args) as MultiGlobGrepToolInput;
507
+ },
508
+
509
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
510
+ return executeGrep(params, { cwd: ctx.cwd, signal });
511
+ },
512
+
513
+ renderCall(args, theme, context) {
514
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
515
+ text.setText(formatGrepCall(args, theme));
516
+ return text;
517
+ },
518
+
519
+ renderResult(result, options, theme, context) {
520
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
521
+ text.setText(formatGrepResult(result as { content?: Array<{ type?: string; text?: string }>; details?: GrepToolDetails }, options, theme));
522
+ return text;
523
+ },
524
+ });
525
+ }