pi-readseek 0.7.7 → 0.7.8

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 (60) hide show
  1. package/README.md +28 -46
  2. package/dist/index.js +7333 -0
  3. package/package.json +8 -7
  4. package/index.ts +0 -147
  5. package/src/binary-detect.ts +0 -22
  6. package/src/check.ts +0 -92
  7. package/src/coerce-obvious-int.ts +0 -41
  8. package/src/confusable-hyphens.ts +0 -6
  9. package/src/def.ts +0 -143
  10. package/src/diff-data.ts +0 -302
  11. package/src/edit-classify.ts +0 -58
  12. package/src/edit-diff.ts +0 -359
  13. package/src/edit-output.ts +0 -110
  14. package/src/edit-render-helpers.ts +0 -141
  15. package/src/edit-syntax-validate.ts +0 -65
  16. package/src/edit.ts +0 -686
  17. package/src/file-map.ts +0 -17
  18. package/src/fs-error.ts +0 -33
  19. package/src/grep-budget.ts +0 -35
  20. package/src/grep-output.ts +0 -181
  21. package/src/grep-render-helpers.ts +0 -77
  22. package/src/grep-symbol-scope.ts +0 -184
  23. package/src/grep.ts +0 -608
  24. package/src/hashline.ts +0 -736
  25. package/src/hover.ts +0 -140
  26. package/src/path-utils.ts +0 -24
  27. package/src/pending-diff-preview.ts +0 -269
  28. package/src/read-local-bundle.ts +0 -75
  29. package/src/read-output.ts +0 -186
  30. package/src/read-render-helpers.ts +0 -104
  31. package/src/read.ts +0 -661
  32. package/src/readseek/constants.ts +0 -17
  33. package/src/readseek/enums.ts +0 -38
  34. package/src/readseek/formatter.ts +0 -350
  35. package/src/readseek/symbol-error-format.ts +0 -18
  36. package/src/readseek/symbol-lookup.ts +0 -240
  37. package/src/readseek/symbol-tree.ts +0 -21
  38. package/src/readseek/types.ts +0 -49
  39. package/src/readseek-client.ts +0 -1250
  40. package/src/readseek-params.ts +0 -39
  41. package/src/readseek-settings.ts +0 -263
  42. package/src/readseek-value.ts +0 -184
  43. package/src/refs-output.ts +0 -47
  44. package/src/refs.ts +0 -161
  45. package/src/register-tool.ts +0 -29
  46. package/src/rename.ts +0 -182
  47. package/src/replace-symbol.ts +0 -77
  48. package/src/runtime.ts +0 -3
  49. package/src/search-output.ts +0 -50
  50. package/src/search.ts +0 -214
  51. package/src/session-anchors.ts +0 -34
  52. package/src/stat-search-path.ts +0 -29
  53. package/src/syntax-validate-mode.ts +0 -15
  54. package/src/tool-prompt-metadata.ts +0 -107
  55. package/src/tool-types.ts +0 -12
  56. package/src/tui-diff-component.ts +0 -108
  57. package/src/tui-diff-renderer.ts +0 -92
  58. package/src/tui-render-utils.ts +0 -286
  59. package/src/view.ts +0 -158
  60. package/src/write.ts +0 -357
package/src/grep.ts DELETED
@@ -1,608 +0,0 @@
1
- import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
2
- import path from "node:path";
3
-
4
- import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
5
- import { createGrepTool } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "@sinclair/typebox";
7
- import { Text } from "@earendil-works/pi-tui";
8
-
9
- import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
10
- import { normalizeToLF, stripBom, hasBareCarriageReturn } from "./edit-diff.js";
11
- import { looksLikeBinary } from "./binary-detect.js";
12
- import { ensureHashInit, escapeControlCharsForDisplay } from "./hashline.js";
13
- import { buildReadSeekLine, buildToolErrorResult, type ReadSeekLine } from "./readseek-value.js";
14
- import { buildGrepOutput, type GrepOutputEntry, type GrepOutputGroup, type GrepOutputRecord, type GrepScopeWarning } from "./grep-output.js";
15
-
16
- import { getOrGenerateMap } from "./file-map.js";
17
- import { scopeGrepGroupsToSymbols } from "./grep-symbol-scope.js";
18
- import { resolveToCwd } from "./path-utils.js";
19
- import { throwIfAborted } from "./runtime.js";
20
- import { formatGrepCallText, formatGrepResultText, GREP_TRUNCATION_THRESHOLD } from "./grep-render-helpers.js";
21
- import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
22
- import { clampLineToWidth, clampLinesToWidth, linkedPathLine, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
23
- import type { FileAnchoredCallback } from "./tool-types.js";
24
- import { optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
25
- import { searchPathParam } from "./readseek-params.js";
26
-
27
-
28
- const grepSchema = Type.Object({
29
- pattern: Type.String({ description: "Regex pattern; use literal for exact text" }),
30
- path: searchPathParam(),
31
- glob: Type.Optional(Type.String({ description: "File-name glob, such as *.ts" })),
32
- ignoreCase: Type.Optional(Type.Boolean({ description: "Ignore case" })),
33
- literal: Type.Optional(Type.Boolean({ description: "Treat pattern literally" })),
34
- context: optionalIntOrString("Surrounding lines for each match"),
35
- limit: optionalIntOrString("Maximum matches to return"),
36
- summary: Type.Optional(Type.Boolean({ description: "Return per-file counts" })),
37
- scope: Type.Optional(
38
- Type.Literal("symbol", {
39
- description: "Group matches by enclosing symbol",
40
- }),
41
- ),
42
- scopeContext: optionalIntOrString("Context lines within each symbol"),
43
- });
44
-
45
- interface GrepParams {
46
- pattern: string;
47
- path?: string;
48
- glob?: string;
49
- ignoreCase?: boolean;
50
- literal?: boolean;
51
- context?: number | string;
52
- limit?: number | string;
53
- summary?: boolean;
54
- scope?: "symbol";
55
- scopeContext?: number | string;
56
- }
57
-
58
- const MATCH_LINE_RE = /^(.*?):(\d+): (.*)$/;
59
- const CONTEXT_LINE_RE = /^(.*?)-(\d+)- (.*)$/;
60
-
61
- function parseGrepOutputLine(line: string):
62
- | { kind: "match"; displayPath: string; lineNumber: number; text: string }
63
- | { kind: "context"; displayPath: string; lineNumber: number; text: string }
64
- | null {
65
- const match = line.match(MATCH_LINE_RE);
66
- if (match) {
67
- return {
68
- kind: "match",
69
- displayPath: match[1],
70
- lineNumber: Number.parseInt(match[2], 10),
71
- text: match[3],
72
- };
73
- }
74
-
75
- const context = line.match(CONTEXT_LINE_RE);
76
- if (context) {
77
- return {
78
- kind: "context",
79
- displayPath: context[1],
80
- lineNumber: Number.parseInt(context[2], 10),
81
- text: context[3],
82
- };
83
- }
84
-
85
- return null;
86
- }
87
-
88
- const GREP_MAX_MATCHES_PER_FILE = 10;
89
-
90
- type GrepAnchoredEntry = Extract<GrepOutputEntry, { kind: "match" | "context" }>;
91
-
92
- /**
93
- * Collapse context lines that duplicate a match line by line number (a match
94
- * supersedes a context entry at the same line), then sort ascending and insert
95
- * a `--` separator across each line-number gap.
96
- */
97
- function dedupeContextEntries(entries: GrepOutputEntry[]): GrepOutputEntry[] {
98
- if (entries.length === 0) return entries;
99
-
100
- const byLine = new Map<number, GrepAnchoredEntry>();
101
- for (const entry of entries) {
102
- if (entry.kind === "separator") continue;
103
- const existing = byLine.get(entry.line.line);
104
- if (!existing || (entry.kind === "match" && existing.kind === "context")) {
105
- byLine.set(entry.line.line, entry);
106
- }
107
- }
108
-
109
- const sorted = [...byLine.entries()].sort(([a], [b]) => a - b);
110
- const result: GrepOutputEntry[] = [];
111
- for (let i = 0; i < sorted.length; i++) {
112
- if (i > 0 && sorted[i][0] > sorted[i - 1][0] + 1) {
113
- result.push({ kind: "separator", text: "--" });
114
- }
115
- result.push(sorted[i][1]);
116
- }
117
-
118
- return result;
119
- }
120
-
121
- /**
122
- * Keep at most {@link GREP_MAX_MATCHES_PER_FILE} matches (with their context)
123
- * per file, appending a `... +N more matches` separator when matches are
124
- * dropped. The group's `matchCount` retains the pre-truncation total.
125
- */
126
- function truncateGroupEntries(group: GrepOutputGroup): GrepOutputGroup {
127
- let matchesSeen = 0;
128
- let truncatedCount = 0;
129
- const kept: GrepOutputEntry[] = [];
130
-
131
- for (const entry of group.entries) {
132
- if (entry.kind === "match") {
133
- matchesSeen++;
134
- if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
135
- kept.push(entry);
136
- } else {
137
- truncatedCount++;
138
- }
139
- } else if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
140
- kept.push(entry);
141
- }
142
- }
143
-
144
- if (truncatedCount > 0) {
145
- kept.push({ kind: "separator", text: `... +${truncatedCount} more matches` });
146
- }
147
-
148
- return { ...group, entries: kept };
149
- }
150
-
151
- function recordsFromGroups(groups: GrepOutputGroup[]): GrepOutputRecord[] {
152
- return groups.flatMap((group) =>
153
- group.entries.flatMap((entry) =>
154
- entry.kind === "separator" ? [] : [{ path: group.absolutePath, ...entry.line, kind: entry.kind }],
155
- ),
156
- );
157
- }
158
-
159
- /**
160
- * Escape special regex characters in a literal string for use in `new RegExp()`.
161
- */
162
- function escapeForRegex(s: string): string {
163
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
164
- }
165
-
166
- interface GrepToolOptions {
167
- onFileAnchored?: FileAnchoredCallback;
168
- name?: string;
169
- }
170
-
171
- export interface ExecuteGrepOptions {
172
- toolCallId: string;
173
- params: unknown;
174
- signal: AbortSignal | undefined;
175
- onUpdate: any;
176
- cwd: string;
177
- onFileAnchored?: FileAnchoredCallback;
178
- }
179
-
180
- export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
181
- const { toolCallId, params, signal, onUpdate, cwd, onFileAnchored } = opts;
182
- await ensureHashInit();
183
- const rawParams = params as GrepParams;
184
- const context = coerceObviousBase10Int(rawParams.context, "context");
185
- if (!context.ok) {
186
- return buildToolErrorResult("grep", "invalid-params-combo", context.message);
187
- }
188
- const limit = coerceObviousBase10Int(rawParams.limit, "limit");
189
- if (!limit.ok) {
190
- return buildToolErrorResult("grep", "invalid-limit", limit.message);
191
- }
192
- const scopeContext = coerceObviousBase10Int(rawParams.scopeContext, "scopeContext");
193
- if (!scopeContext.ok) {
194
- return buildToolErrorResult("grep", "invalid-params-combo", scopeContext.message);
195
- }
196
- if (scopeContext.value !== undefined && rawParams.scope !== "symbol") {
197
- const message = 'Invalid scopeContext: requires scope: "symbol". For normal surrounding-line context outside symbol scope, use the `context` parameter.';
198
- return buildToolErrorResult("grep", "invalid-params-combo", message);
199
- }
200
- if (scopeContext.value !== undefined && scopeContext.value < 0) {
201
- const message = `Invalid scopeContext: expected a non-negative integer, received ${scopeContext.value}.`;
202
- return buildToolErrorResult("grep", "invalid-params-combo", message);
203
- }
204
- const p: GrepParams = {
205
- ...rawParams,
206
- context: context.value,
207
- limit: limit.value,
208
- scopeContext: scopeContext.value,
209
- };
210
- const builtin = createGrepTool(cwd);
211
- const result = await builtin.execute(
212
- toolCallId,
213
- {
214
- ...p,
215
- context: context.value,
216
- limit: limit.value,
217
- },
218
- signal,
219
- onUpdate,
220
- );
221
-
222
- const textBlock = result.content?.find(
223
- (item): item is { type: "text"; text: string } =>
224
- item.type === "text" && "text" in item && typeof (item as { text?: unknown }).text === "string",
225
- );
226
- if (!textBlock?.text) return result;
227
-
228
- const { path: rawSearchPath } = p;
229
- const searchPath = resolveToCwd(rawSearchPath || ".", cwd);
230
-
231
- let searchPathIsDirectory = false;
232
- try {
233
- searchPathIsDirectory = (await fsStat(searchPath)).isDirectory();
234
- } catch {
235
- searchPathIsDirectory = false;
236
- }
237
- // Warn when the user targets a single binary file directly — grep
238
- // silently skips binary files and would return 0 matches with no
239
- // indication of why.
240
- if (!searchPathIsDirectory) {
241
- try {
242
- const buf = await fsReadFile(searchPath);
243
- if (looksLikeBinary(buf)) {
244
- const warning = `[Warning: '${p.path ?? searchPath}' appears to be a binary file — grep skips binary files by default. Use a hex tool or the read tool to inspect it.]`;
245
- return {
246
- ...result,
247
- content: result.content.map((item) =>
248
- item === textBlock ? ({ ...item, text: warning } as typeof item) : item,
249
- ),
250
- details: {
251
- ...(typeof result.details === "object" && result.details !== null ? result.details : {}),
252
- readSeekValue: {
253
- tool: "grep",
254
- summary: !!p.summary,
255
- totalMatches: 0,
256
- records: [],
257
- },
258
- },
259
- };
260
- }
261
- } catch {
262
- // can't read file — let normal flow continue
263
- }
264
- }
265
-
266
- const fileCache = new Map<string, string[] | undefined>();
267
- const bareCRFiles = new Set<string>();
268
- const getFileLines = async (absolutePath: string): Promise<string[] | undefined> => {
269
- throwIfAborted(signal);
270
- if (fileCache.has(absolutePath)) return fileCache.get(absolutePath);
271
- try {
272
- const rawBuffer = await fsReadFile(absolutePath);
273
- if (looksLikeBinary(rawBuffer)) {
274
- fileCache.set(absolutePath, undefined);
275
- return undefined;
276
- }
277
- const raw = rawBuffer.toString("utf-8");
278
- if (hasBareCarriageReturn(raw)) bareCRFiles.add(absolutePath);
279
- const lines = normalizeToLF(stripBom(raw).text).split("\n");
280
- fileCache.set(absolutePath, lines);
281
- return lines;
282
- } catch {
283
- fileCache.set(absolutePath, undefined);
284
- return undefined;
285
- }
286
- };
287
-
288
- const toAbsolutePath = (displayPath: string): string => {
289
- if (searchPathIsDirectory) return path.resolve(searchPath, displayPath);
290
- return searchPath;
291
- };
292
-
293
- const groupsByPath = new Map<string, GrepOutputGroup>();
294
- const passthroughLines: string[] = [];
295
- let totalMatches = 0;
296
- let parsedCount = 0;
297
- let candidateUnparsedCount = 0;
298
- const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
299
- // Pre-read all matched files with bounded concurrency so the processing
300
- // loop below hits the cache on every getFileLines call instead of
301
- // serialising disk I/O.
302
- if (!p.summary) {
303
- const pathsToRead = new Set<string>();
304
- for (const line of textBlock.text.split("\n")) {
305
- throwIfAborted(signal);
306
- const parsed = parseGrepOutputLine(line);
307
- if (parsed && Number.isFinite(parsed.lineNumber) && parsed.lineNumber >= 1) {
308
- pathsToRead.add(toAbsolutePath(parsed.displayPath));
309
- }
310
- }
311
- const CONCURRENCY = 8;
312
- const pathList = [...pathsToRead];
313
- for (let i = 0; i < pathList.length; i += CONCURRENCY) {
314
- await Promise.all(pathList.slice(i, i + CONCURRENCY).map((p) => getFileLines(p)));
315
- }
316
- }
317
-
318
- const addSummaryMatch = (displayPath: string, absolutePath: string) => {
319
- let group = groupsByPath.get(displayPath);
320
- if (!group) {
321
- group = { displayPath, absolutePath, matchCount: 0, entries: [] };
322
- groupsByPath.set(displayPath, group);
323
- }
324
- group.matchCount++;
325
- totalMatches++;
326
- };
327
-
328
- const addEntry = (displayPath: string, absolutePath: string, kind: "match" | "context", line: ReadSeekLine) => {
329
- let group = groupsByPath.get(displayPath);
330
- if (!group) {
331
- group = { displayPath, absolutePath, matchCount: 0, entries: [] };
332
- groupsByPath.set(displayPath, group);
333
- }
334
- group.entries.push({ kind, line });
335
- if (kind === "match") {
336
- group.matchCount++;
337
- totalMatches++;
338
- }
339
- };
340
-
341
- for (const line of textBlock.text.split("\n")) {
342
- throwIfAborted(signal);
343
- const parsed = parseGrepOutputLine(line);
344
- if (!parsed || !Number.isFinite(parsed.lineNumber) || parsed.lineNumber < 1) {
345
- if (candidateLinePattern.test(line)) {
346
- candidateUnparsedCount++;
347
- }
348
- const trimmed = line.trim();
349
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
350
- passthroughLines.push(trimmed);
351
- }
352
- continue;
353
- }
354
- parsedCount++;
355
- const absolute = toAbsolutePath(parsed.displayPath);
356
- if (p.summary) {
357
- if (parsed.kind === "match") {
358
- addSummaryMatch(parsed.displayPath, absolute);
359
- }
360
- continue;
361
- }
362
- const fileLines = await getFileLines(absolute);
363
- if (fileLines === undefined) continue;
364
- // Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
365
- // builtin grep tool may strip \r before this code sees the output. So
366
- // parsed.text is just the first CR-separated fragment and parsed.lineNumber
367
- // is always 1 — both are wrong for match lines. Only remap when
368
- // parsed.kind === "match"; context lines are irrelevant here (rg won’t
369
- // produce them for bare-CR files in any meaningful way).
370
- if (parsed.kind === "match" && bareCRFiles.has(absolute)) {
371
- const flags = p.ignoreCase ? "i" : "";
372
- let patternRe: RegExp | null = null;
373
- try {
374
- patternRe = p.literal
375
- ? new RegExp(escapeForRegex(p.pattern), flags)
376
- : new RegExp(p.pattern, flags);
377
- } catch {
378
- // Malformed regex — fall through to normal anchor path
379
- }
380
- if (patternRe !== null) {
381
- let emitted = false;
382
- for (let i = 0; i < fileLines.length; i++) {
383
- if (!patternRe.test(fileLines[i])) continue;
384
- addEntry(parsed.displayPath, absolute, "match", buildReadSeekLine(i + 1, fileLines[i]));
385
- emitted = true;
386
- }
387
- if (emitted) continue;
388
- // No lines matched — fall through to normal path
389
- }
390
- }
391
- // Normal (non-bare-CR) path
392
- const sourceLine = fileLines[parsed.lineNumber - 1] ?? parsed.text;
393
- const built = buildReadSeekLine(parsed.lineNumber, sourceLine);
394
- addEntry(parsed.displayPath, absolute, parsed.kind, {
395
- ...built,
396
- display: escapeControlCharsForDisplay(parsed.text),
397
- });
398
- }
399
-
400
- if (p.summary && parsedCount === 0 && candidateUnparsedCount > 0) {
401
- const passthroughDetails =
402
- typeof result.details === "object" && result.details !== null
403
- ? (result.details as Record<string, unknown>)
404
- : {};
405
- return {
406
- ...result,
407
- details: {
408
- ...passthroughDetails,
409
- readSeekValue: {
410
- tool: "grep",
411
- summary: true,
412
- totalMatches: 0,
413
- records: [],
414
- },
415
- },
416
- };
417
- }
418
-
419
- if (parsedCount === 0 && candidateUnparsedCount > 0) {
420
- const warning =
421
- "[hashline grep passthrough] Unparsed grep format; returned original output.";
422
- const passthroughDetails =
423
- typeof result.details === "object" && result.details !== null
424
- ? (result.details as Record<string, unknown>)
425
- : {};
426
- return {
427
- ...result,
428
- content: result.content.map((item) =>
429
- item === textBlock ? ({ ...item, text: `${textBlock.text}\n\n${warning}` } as typeof item) : item,
430
- ),
431
- details: {
432
- ...passthroughDetails,
433
- hashlinePassthrough: true,
434
- hashlineWarning: warning,
435
- readSeekValue: {
436
- tool: "grep",
437
- summary: !!p.summary,
438
- totalMatches: 0,
439
- records: [],
440
- },
441
- },
442
- };
443
- }
444
-
445
- const summary = !!p.summary;
446
- const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
447
- const groups = [...groupsByPath.values()];
448
- for (const group of groups) {
449
- group.entries = dedupeContextEntries(group.entries);
450
- }
451
- let renderedGroups: GrepOutputGroup[] =
452
- totalMatches > GREP_TRUNCATION_THRESHOLD ? groups.map(truncateGroupEntries) : groups;
453
- let scopeWarnings: GrepScopeWarning[] = [];
454
-
455
- if (p.scope === "symbol" && !summary) {
456
- const fileLinesByPath = new Map<string, string[]>();
457
- const fileMapsByPath = new Map<string, Awaited<ReturnType<typeof getOrGenerateMap>>>();
458
-
459
- for (const group of renderedGroups) {
460
- const lines = await getFileLines(group.absolutePath);
461
- if (lines) fileLinesByPath.set(group.absolutePath, lines);
462
- fileMapsByPath.set(group.absolutePath, await getOrGenerateMap(group.absolutePath));
463
- }
464
-
465
- const scoped = scopeGrepGroupsToSymbols({
466
- groups: renderedGroups,
467
- fileLinesByPath,
468
- fileMapsByPath,
469
- contextLines: typeof p.context === "number" ? p.context : 0,
470
- scopeContext: typeof p.scopeContext === "number" ? p.scopeContext : undefined,
471
- });
472
-
473
- renderedGroups = scoped.groups;
474
- scopeWarnings = scoped.warnings;
475
- }
476
- const readSeekRecords = recordsFromGroups(renderedGroups);
477
- const builtOutput = buildGrepOutput({
478
- summary: !!summary,
479
- totalMatches,
480
- groups: renderedGroups,
481
- limit: effectiveLimit,
482
- records: readSeekRecords,
483
- scopeMode: p.scope === "symbol" && !summary ? "symbol" : undefined,
484
- scopeWarnings,
485
- passthroughLines,
486
- });
487
-
488
- if (!summary && readSeekRecords.length > 0) {
489
- const anchoredPaths = new Set(readSeekRecords.map((record) => record.path));
490
- for (const absolutePath of anchoredPaths) {
491
- onFileAnchored?.(absolutePath);
492
- }
493
- }
494
-
495
- const existingDetails =
496
- typeof result.details === "object" && result.details !== null
497
- ? (result.details as Record<string, unknown>)
498
- : {};
499
- const { linesTruncated: _ignoredLinesTruncated, truncation: _ignoredTruncation, ...compactDetails } = existingDetails;
500
- return {
501
- ...result,
502
- content: result.content.map((item) =>
503
- item === textBlock ? ({ ...item, text: builtOutput.text } as typeof item) : item,
504
- ),
505
- details: {
506
- ...compactDetails,
507
- readSeekValue: builtOutput.readSeekValue,
508
- },
509
- };
510
- }
511
-
512
- export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
513
- const name = options.name ?? "readSeek_grep";
514
- const promptMetadata = defineToolPromptMetadata({
515
- promptUrl: new URL("../prompts/grep.md", import.meta.url),
516
- promptSnippet: "Search plain text or regex with edit-ready anchors",
517
- registeredName: name,
518
- });
519
- const tool = registerReadSeekTool(pi, {
520
- name,
521
- label: "grep",
522
- description: promptMetadata.description,
523
- parameters: grepSchema,
524
- promptSnippet: promptMetadata.promptSnippet,
525
- promptGuidelines: promptMetadata.promptGuidelines,
526
- async execute(toolCallId, params, signal, onUpdate, ctx) {
527
- return executeGrep({
528
- toolCallId,
529
- params,
530
- signal,
531
- onUpdate,
532
- cwd: ctx.cwd,
533
- onFileAnchored: options.onFileAnchored,
534
- });
535
- },
536
- renderCall(args: any, theme: any, ...rest: any[]) {
537
- const context = rest[0] ?? {};
538
- const cwd = context.cwd ?? process.cwd();
539
- const { pattern, suffix } = formatGrepCallText(args);
540
- const rawPath = typeof args?.path === "string" && args.path !== "." ? args.path : undefined;
541
- const glob = typeof args?.glob === "string" ? args.glob : undefined;
542
- let text = `${renderToolLabel(theme, "grep")} ${theme.fg("accent", `/${pattern}/`)}`;
543
- if (suffix) {
544
- if (rawPath) {
545
- text += theme.fg("dim", " in ");
546
- text += linkToolPath(theme.fg("dim", rawPath), rawPath, cwd);
547
- if (glob) text += theme.fg("dim", ` ${glob}`);
548
- } else {
549
- text += theme.fg("dim", ` in ${suffix}`);
550
- }
551
- }
552
- return new Text(clampLineToWidth(text, context.width), 0, 0);
553
- },
554
- renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
555
- const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
556
-
557
- if (isPartial) return renderPendingResult("pending grep", width, theme);
558
-
559
- const content = result.content?.[0];
560
- const textContent = content?.type === "text" ? content.text : "";
561
-
562
- if (isError || result.isError) return renderErrorResult(textContent, { expanded, width, theme });
563
-
564
- const readSeekValue = (result.details as any)?.readSeekValue as {
565
- tool: "grep";
566
- summary: boolean;
567
- totalMatches: number;
568
- records: Array<{ path: string; kind: string }>;
569
- } | undefined;
570
-
571
- const hasBinaryWarning = textContent.includes("appears to be a binary file");
572
-
573
- const fileSet = new Set<string>();
574
- for (const r of readSeekValue?.records ?? []) {
575
- if (r.path) fileSet.add(r.path);
576
- }
577
-
578
- const info = formatGrepResultText({
579
- totalMatches: readSeekValue?.totalMatches ?? 0,
580
- summary: readSeekValue?.summary ?? false,
581
- records: readSeekValue?.records ?? [],
582
- fileCount: fileSet.size,
583
- hasBinaryWarning,
584
- });
585
-
586
- if (info.noMatches && !hasBinaryWarning) return new Text(summaryLine("no matches", { theme, style: "dim" }), 0, 0);
587
- const matchCount = readSeekValue?.totalMatches ?? 0;
588
- const matchWord = matchCount === 1 ? "match" : "matches";
589
- let text = summaryLine(`${matchCount} ${matchWord} returned`, {
590
- hidden: !!textContent && !expanded,
591
- theme,
592
- style: "success",
593
- });
594
- for (const badge of info.badges) text += theme.fg(badge.startsWith("⚠") ? "warning" : "dim", ` ${badge}`);
595
- if (expanded && readSeekValue?.records) {
596
- const fileCounts = new Map<string, number>();
597
- for (const r of readSeekValue.records) if (r.path && r.kind === "match") fileCounts.set(r.path, (fileCounts.get(r.path) ?? 0) + 1);
598
- for (const [filePath, count] of [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)) {
599
- const display = path.relative(cwd, filePath) || filePath;
600
- text += `\n${linkedPathLine(theme, filePath, display, cwd, ` (${count})`, width)}`;
601
- }
602
- if (fileCounts.size > 20) text += "\n" + theme.fg("muted", ` … and ${fileCounts.size - 20} more files`);
603
- }
604
- return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
605
- },
606
- });
607
- return tool;
608
- }