@ff-labs/pi-fff 0.6.4 → 0.6.5-nightly.0f5ead1
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/package.json +1 -1
- package/src/index.ts +493 -209
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -6,12 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import {
|
|
10
|
-
CustomEditor,
|
|
11
|
-
truncateHead,
|
|
12
|
-
DEFAULT_MAX_BYTES,
|
|
13
|
-
formatSize,
|
|
14
|
-
} from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { CustomEditor } from "@mariozechner/pi-coding-agent";
|
|
15
10
|
import {
|
|
16
11
|
Text,
|
|
17
12
|
type AutocompleteItem,
|
|
@@ -31,8 +26,8 @@ import type {
|
|
|
31
26
|
// Constants
|
|
32
27
|
// ---------------------------------------------------------------------------
|
|
33
28
|
|
|
34
|
-
const DEFAULT_GREP_LIMIT =
|
|
35
|
-
const DEFAULT_FIND_LIMIT =
|
|
29
|
+
const DEFAULT_GREP_LIMIT = 20;
|
|
30
|
+
const DEFAULT_FIND_LIMIT = 30;
|
|
36
31
|
const GREP_MAX_LINE_LENGTH = 500;
|
|
37
32
|
const MENTION_MAX_RESULTS = 20;
|
|
38
33
|
|
|
@@ -82,6 +77,92 @@ function getCursor(id: string): GrepCursor | undefined {
|
|
|
82
77
|
return cursorCache.get(id);
|
|
83
78
|
}
|
|
84
79
|
|
|
80
|
+
// Find pagination uses a page-index cursor: native `fileSearch` takes
|
|
81
|
+
// pageIndex/pageSize, so the cursor is just the next page index paired with
|
|
82
|
+
// the query+limit that produced it. Stored tokens are opaque IDs to the agent.
|
|
83
|
+
interface FindCursor {
|
|
84
|
+
query: string;
|
|
85
|
+
pattern: string;
|
|
86
|
+
pageSize: number;
|
|
87
|
+
nextPageIndex: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const findCursorCache = new Map<string, FindCursor>();
|
|
91
|
+
let findCursorCounter = 0;
|
|
92
|
+
|
|
93
|
+
function storeFindCursor(cursor: FindCursor): string {
|
|
94
|
+
const id = `${++findCursorCounter}`;
|
|
95
|
+
findCursorCache.set(id, cursor);
|
|
96
|
+
if (findCursorCache.size > 200) {
|
|
97
|
+
const first = findCursorCache.keys().next().value;
|
|
98
|
+
if (first) findCursorCache.delete(first);
|
|
99
|
+
}
|
|
100
|
+
return id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getFindCursor(id: string): FindCursor | undefined {
|
|
104
|
+
return findCursorCache.get(id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Query building helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function normalizePathConstraint(path: string): string | null {
|
|
112
|
+
let trimmed = path.trim();
|
|
113
|
+
if (!trimmed) return trimmed;
|
|
114
|
+
if (trimmed === "." || trimmed === "./") return null;
|
|
115
|
+
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
|
|
116
|
+
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
|
117
|
+
// Already signals path-constraint syntax to the parser.
|
|
118
|
+
if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
|
|
119
|
+
// Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
|
|
120
|
+
if (/[*?\[{]/.test(trimmed)) return trimmed;
|
|
121
|
+
// Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
|
|
122
|
+
const lastSegment = trimmed.split("/").pop() ?? "";
|
|
123
|
+
if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
|
|
124
|
+
// Bare directory prefix → append `/` so the parser sees a PathSegment.
|
|
125
|
+
return `${trimmed}/`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
|
|
129
|
+
// understands (crates/fff-query-parser/src/parser.rs). We normalize each one
|
|
130
|
+
// the same way as the include path so bare dirs become PathSegment excludes.
|
|
131
|
+
// Tolerate callers passing already-negated forms like `!src/` by stripping
|
|
132
|
+
// the leading `!` before normalizing so we never double-negate (`!!src/`).
|
|
133
|
+
function normalizeExcludes(exclude: string | string[] | undefined): string[] {
|
|
134
|
+
if (!exclude) return [];
|
|
135
|
+
const list = Array.isArray(exclude) ? exclude : [exclude];
|
|
136
|
+
const out: string[] = [];
|
|
137
|
+
for (const raw of list) {
|
|
138
|
+
const parts = raw
|
|
139
|
+
.split(/[,\s]+/)
|
|
140
|
+
.map((s) => s.trim())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
for (const p of parts) {
|
|
143
|
+
const stripped = p.startsWith("!") ? p.slice(1) : p;
|
|
144
|
+
const normalized = normalizePathConstraint(stripped);
|
|
145
|
+
if (normalized) out.push(`!${normalized}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildQuery(
|
|
152
|
+
path: string | undefined,
|
|
153
|
+
pattern: string,
|
|
154
|
+
exclude?: string | string[],
|
|
155
|
+
): string {
|
|
156
|
+
const parts: string[] = [];
|
|
157
|
+
if (path) {
|
|
158
|
+
const pathConstraint = normalizePathConstraint(path);
|
|
159
|
+
if (pathConstraint) parts.push(pathConstraint);
|
|
160
|
+
}
|
|
161
|
+
parts.push(...normalizeExcludes(exclude));
|
|
162
|
+
parts.push(pattern);
|
|
163
|
+
return parts.join(" ");
|
|
164
|
+
}
|
|
165
|
+
|
|
85
166
|
// ---------------------------------------------------------------------------
|
|
86
167
|
// Output formatting helpers
|
|
87
168
|
// ---------------------------------------------------------------------------
|
|
@@ -91,44 +172,122 @@ function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
|
|
|
91
172
|
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
|
|
92
173
|
}
|
|
93
174
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
175
|
+
const HOT_FRECENCY = 25;
|
|
176
|
+
const WARM_FRECENCY = 20;
|
|
177
|
+
|
|
178
|
+
// Shared annotation helper for both find-output paths and grep-output file
|
|
179
|
+
// headers. Returns at most ONE tag so output stays scannable. Priority:
|
|
180
|
+
// git-dirty (most actionable — file is changing right now) beats frecency
|
|
181
|
+
// (historically often-touched). Keeping one function ensures the two tools
|
|
182
|
+
// never drift in how they surface git/frecency signal.
|
|
183
|
+
export function fffFileAnnotation(item: {
|
|
184
|
+
gitStatus?: string;
|
|
185
|
+
totalFrecencyScore?: number;
|
|
186
|
+
accessFrecencyScore?: number;
|
|
187
|
+
}): string {
|
|
188
|
+
const git = item.gitStatus;
|
|
189
|
+
if (git && git !== "clean" && git !== "unknown" && git !== "") {
|
|
190
|
+
return ` [${git} in git]`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const frecency = item.totalFrecencyScore ?? item.accessFrecencyScore ?? 0;
|
|
194
|
+
if (frecency >= HOT_FRECENCY) return " [VERY often touched file]";
|
|
195
|
+
if (frecency >= WARM_FRECENCY) return " [often touched file]";
|
|
196
|
+
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
97
199
|
|
|
200
|
+
// fff-core native definition classifier (byte-level scanner in Rust) is enabled
|
|
201
|
+
// via GrepOptions.classifyDefinitions. Each GrepMatch carries isDefinition for
|
|
202
|
+
// downstream consumers; pi-fff does NOT use it to re-sort.
|
|
203
|
+
//
|
|
204
|
+
// Ordering policy: NO CUSTOM SORTING. The engine already returns items in
|
|
205
|
+
// frecency order (most-accessed files first). pi-fff only groups consecutive
|
|
206
|
+
// matches into per-file blocks and preserves whatever order the engine
|
|
207
|
+
// provided — inside a file we keep matches in source-line order because the
|
|
208
|
+
// engine emits them that way.
|
|
209
|
+
|
|
210
|
+
function formatGrepOutput(result: GrepResult): string {
|
|
211
|
+
if (result.items.length === 0) return "No matches found";
|
|
212
|
+
|
|
213
|
+
// Build file-grouped output in the order files first appear in the result.
|
|
214
|
+
// This preserves native frecency ordering across files without re-sorting.
|
|
98
215
|
const lines: string[] = [];
|
|
99
216
|
let currentFile = "";
|
|
217
|
+
let shown = 0;
|
|
100
218
|
|
|
101
|
-
for (const match of items) {
|
|
219
|
+
for (const match of result.items) {
|
|
102
220
|
if (match.relativePath !== currentFile) {
|
|
103
|
-
currentFile = match.relativePath;
|
|
104
221
|
if (lines.length > 0) lines.push("");
|
|
222
|
+
currentFile = match.relativePath;
|
|
223
|
+
lines.push(`${currentFile}${fffFileAnnotation(match)}`);
|
|
105
224
|
}
|
|
106
225
|
|
|
107
226
|
match.contextBefore?.forEach((line: string, i: number) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
227
|
+
const lineNum = match.lineNumber - match.contextBefore!.length + i;
|
|
228
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
111
229
|
});
|
|
112
230
|
|
|
113
|
-
lines.push(
|
|
114
|
-
|
|
115
|
-
);
|
|
231
|
+
lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
|
|
232
|
+
shown++;
|
|
116
233
|
|
|
117
234
|
match.contextAfter?.forEach((line: string, i: number) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
235
|
+
const lineNum = match.lineNumber + 1 + i;
|
|
236
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
121
237
|
});
|
|
122
238
|
}
|
|
123
239
|
|
|
124
240
|
return lines.join("\n");
|
|
125
241
|
}
|
|
126
242
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
243
|
+
// Weak-match threshold is derived from the query length, matching the
|
|
244
|
+
// scoring formula in crates/fff-core/src/score.rs: a perfect match scores
|
|
245
|
+
// `len * 16`, so we treat anything below 50% of that as scattered fuzzy noise.
|
|
246
|
+
// When the top score is weak, trim output to a small sample instead of dumping
|
|
247
|
+
// the full limit worth of noise into the agent's context.
|
|
248
|
+
const FIND_WEAK_SAMPLE_SIZE = 5;
|
|
249
|
+
|
|
250
|
+
function weakScoreThreshold(pattern: string): number {
|
|
251
|
+
const perfect = pattern.length * 12;
|
|
252
|
+
return Math.floor((perfect * 50) / 100);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface FormattedFind {
|
|
256
|
+
output: string;
|
|
257
|
+
weak: boolean;
|
|
258
|
+
shownCount: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function formatFindOutput(
|
|
262
|
+
result: SearchResult,
|
|
263
|
+
limit: number,
|
|
264
|
+
pattern: string,
|
|
265
|
+
): FormattedFind {
|
|
266
|
+
if (result.items.length === 0) {
|
|
267
|
+
return {
|
|
268
|
+
output: "No files found matching pattern",
|
|
269
|
+
weak: false,
|
|
270
|
+
shownCount: 0,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// NO CUSTOM SORTING — trust native frecency order from the engine.
|
|
275
|
+
const reordered = result.items.map((item) => ({ item }));
|
|
276
|
+
|
|
277
|
+
// Peek at the top native score to decide whether results are scattered
|
|
278
|
+
// fuzzy noise (query length-scaled threshold from score.rs).
|
|
279
|
+
const topScore = result.scores[0]?.total ?? 0;
|
|
280
|
+
const weak = topScore < weakScoreThreshold(pattern);
|
|
281
|
+
const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
|
|
282
|
+
const shown = reordered.slice(0, effective);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
output: shown
|
|
286
|
+
.map((p) => `${p.item.relativePath}${fffFileAnnotation(p.item)}`)
|
|
287
|
+
.join("\n"),
|
|
288
|
+
weak,
|
|
289
|
+
shownCount: shown.length,
|
|
290
|
+
};
|
|
132
291
|
}
|
|
133
292
|
|
|
134
293
|
// ---------------------------------------------------------------------------
|
|
@@ -155,7 +314,9 @@ function createFffMentionProvider(
|
|
|
155
314
|
|
|
156
315
|
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
157
316
|
const items = await getItems(query, options.signal);
|
|
158
|
-
return options.signal.aborted || items.length === 0
|
|
317
|
+
return options.signal.aborted || items.length === 0
|
|
318
|
+
? null
|
|
319
|
+
: { items, prefix };
|
|
159
320
|
},
|
|
160
321
|
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
161
322
|
const currentLine = _lines[cursorLine] || "";
|
|
@@ -164,7 +325,11 @@ function createFffMentionProvider(
|
|
|
164
325
|
const newLine = before + item.value + after;
|
|
165
326
|
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
166
327
|
return {
|
|
167
|
-
lines: [
|
|
328
|
+
lines: [
|
|
329
|
+
..._lines.slice(0, cursorLine),
|
|
330
|
+
newLine,
|
|
331
|
+
..._lines.slice(cursorLine + 1),
|
|
332
|
+
],
|
|
168
333
|
cursorLine,
|
|
169
334
|
cursorCol: newCursorCol,
|
|
170
335
|
};
|
|
@@ -184,7 +349,10 @@ class FffEditor extends CustomEditor {
|
|
|
184
349
|
tui: any,
|
|
185
350
|
theme: any,
|
|
186
351
|
keybindings: any,
|
|
187
|
-
getMentionItems: (
|
|
352
|
+
getMentionItems: (
|
|
353
|
+
query: string,
|
|
354
|
+
signal: AbortSignal,
|
|
355
|
+
) => Promise<AutocompleteItem[]>,
|
|
188
356
|
) {
|
|
189
357
|
super(tui, theme, keybindings);
|
|
190
358
|
this.getMentionItems = getMentionItems;
|
|
@@ -206,7 +374,12 @@ class FffEditor extends CustomEditor {
|
|
|
206
374
|
if (mentionResult) return mentionResult;
|
|
207
375
|
// Fall back to base provider
|
|
208
376
|
return (
|
|
209
|
-
this.baseProvider?.getSuggestions(
|
|
377
|
+
this.baseProvider?.getSuggestions(
|
|
378
|
+
lines,
|
|
379
|
+
cursorLine,
|
|
380
|
+
cursorCol,
|
|
381
|
+
options,
|
|
382
|
+
) ?? null
|
|
210
383
|
);
|
|
211
384
|
},
|
|
212
385
|
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
|
@@ -289,7 +462,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
289
462
|
aiMode: true,
|
|
290
463
|
});
|
|
291
464
|
|
|
292
|
-
if (!result.ok)
|
|
465
|
+
if (!result.ok)
|
|
466
|
+
throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
|
293
467
|
|
|
294
468
|
finder = result.value;
|
|
295
469
|
finderCwd = cwd;
|
|
@@ -316,20 +490,22 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
316
490
|
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
317
491
|
if (!result.ok) return [];
|
|
318
492
|
|
|
319
|
-
return result.value.items
|
|
320
|
-
|
|
493
|
+
return result.value.items
|
|
494
|
+
.slice(0, MENTION_MAX_RESULTS)
|
|
495
|
+
.map((mixed: MixedItem) => {
|
|
496
|
+
if (mixed.type === "directory") {
|
|
497
|
+
return {
|
|
498
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
499
|
+
label: mixed.item.dirName,
|
|
500
|
+
description: mixed.item.relativePath,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
321
503
|
return {
|
|
322
504
|
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
323
|
-
label: mixed.item.
|
|
505
|
+
label: mixed.item.fileName,
|
|
324
506
|
description: mixed.item.relativePath,
|
|
325
507
|
};
|
|
326
|
-
}
|
|
327
|
-
return {
|
|
328
|
-
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
329
|
-
label: mixed.item.fileName,
|
|
330
|
-
description: mixed.item.relativePath,
|
|
331
|
-
};
|
|
332
|
-
});
|
|
508
|
+
});
|
|
333
509
|
}
|
|
334
510
|
|
|
335
511
|
function applyEditorMode(ctx: {
|
|
@@ -357,12 +533,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
357
533
|
});
|
|
358
534
|
|
|
359
535
|
pi.registerFlag("fff-frecency-db", {
|
|
360
|
-
description:
|
|
536
|
+
description:
|
|
537
|
+
"Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
361
538
|
type: "string",
|
|
362
539
|
});
|
|
363
540
|
|
|
364
541
|
pi.registerFlag("fff-history-db", {
|
|
365
|
-
description:
|
|
542
|
+
description:
|
|
543
|
+
"Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
366
544
|
type: "string",
|
|
367
545
|
});
|
|
368
546
|
|
|
@@ -392,15 +570,20 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
392
570
|
context: any,
|
|
393
571
|
maxLines = 15,
|
|
394
572
|
) => {
|
|
395
|
-
const text =
|
|
396
|
-
|
|
573
|
+
const text =
|
|
574
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
575
|
+
const output =
|
|
576
|
+
result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
397
577
|
if (!output) {
|
|
398
578
|
text.setText(theme.fg("muted", "No output"));
|
|
399
579
|
return text;
|
|
400
580
|
}
|
|
401
581
|
|
|
402
582
|
const lines = output.split("\n");
|
|
403
|
-
const displayLines = lines.slice(
|
|
583
|
+
const displayLines = lines.slice(
|
|
584
|
+
0,
|
|
585
|
+
options.expanded ? lines.length : maxLines,
|
|
586
|
+
);
|
|
404
587
|
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
405
588
|
if (lines.length > displayLines.length) {
|
|
406
589
|
content += theme.fg(
|
|
@@ -415,44 +598,50 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
415
598
|
// --- grep tool ---
|
|
416
599
|
|
|
417
600
|
const grepSchema = Type.Object({
|
|
418
|
-
pattern: Type.String({
|
|
601
|
+
pattern: Type.String({
|
|
602
|
+
description: "Search pattern (literal text or regex)",
|
|
603
|
+
}),
|
|
419
604
|
path: Type.Optional(
|
|
420
605
|
Type.String({
|
|
421
606
|
description:
|
|
422
|
-
"
|
|
607
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
|
423
608
|
}),
|
|
424
609
|
),
|
|
425
|
-
|
|
610
|
+
exclude: Type.Optional(
|
|
611
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
612
|
+
description:
|
|
613
|
+
"Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js', '**/*.{rs,go}'). A leading '!' is optional and ignored — both 'test/' and '!test/' work. Example: 'test/,*.min.js,!vendor/'.",
|
|
614
|
+
}),
|
|
615
|
+
),
|
|
616
|
+
caseSensitive: Type.Optional(
|
|
426
617
|
Type.Boolean({
|
|
427
|
-
description:
|
|
618
|
+
description:
|
|
619
|
+
"Force case-sensitive matching. Default uses smart-case (case-insensitive when pattern is all lowercase).",
|
|
428
620
|
}),
|
|
429
621
|
),
|
|
430
622
|
context: Type.Optional(
|
|
431
|
-
Type.Number({
|
|
432
|
-
description: "Number of lines to show before and after each match (default: 0)",
|
|
433
|
-
}),
|
|
623
|
+
Type.Number({ description: "Context lines before+after each match" }),
|
|
434
624
|
),
|
|
435
625
|
limit: Type.Optional(
|
|
436
626
|
Type.Number({
|
|
437
|
-
description: `
|
|
627
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
438
628
|
}),
|
|
439
629
|
),
|
|
440
630
|
cursor: Type.Optional(
|
|
441
|
-
Type.String({ description: "
|
|
631
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
442
632
|
),
|
|
443
633
|
});
|
|
444
634
|
|
|
445
635
|
pi.registerTool({
|
|
446
636
|
name: toolNames.grep,
|
|
447
637
|
label: toolNames.grep,
|
|
448
|
-
description: `
|
|
449
|
-
promptSnippet:
|
|
450
|
-
"Search file contents for patterns (FFF: frecency-ranked, git-aware, respects .gitignore)",
|
|
638
|
+
description: `Grep file contents. Smart-case, auto-detects regex vs literal, git-aware. Results are ranked by frecency (most-accessed files first); matches within a file stay in source order. Default limit ${DEFAULT_GREP_LIMIT}.`,
|
|
639
|
+
promptSnippet: "Grep contents",
|
|
451
640
|
promptGuidelines: [
|
|
452
|
-
"
|
|
453
|
-
"
|
|
454
|
-
"
|
|
455
|
-
"
|
|
641
|
+
"Prefer bare identifiers as patterns. Literal queries are most efficient.",
|
|
642
|
+
"Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
|
|
643
|
+
"caseSensitive: true when you need exact case (smart-case otherwise).",
|
|
644
|
+
"After 1-2 greps, read the top match instead of more greps.",
|
|
456
645
|
],
|
|
457
646
|
parameters: grepSchema,
|
|
458
647
|
|
|
@@ -461,53 +650,109 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
461
650
|
|
|
462
651
|
const f = await ensureFinder(activeCwd);
|
|
463
652
|
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
464
|
-
const query = params.path
|
|
465
|
-
|
|
653
|
+
const query = buildQuery(params.path, params.pattern, params.exclude);
|
|
654
|
+
// Auto-detect: regex if the pattern has regex metacharacters AND parses
|
|
655
|
+
// as a valid regex, otherwise plain literal. The fuzzy fallback below
|
|
656
|
+
// only kicks in for plain mode — regex queries are intentional.
|
|
657
|
+
const hasRegexSyntax =
|
|
658
|
+
params.pattern !==
|
|
659
|
+
params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
660
|
+
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
661
|
+
if (mode === "regex") {
|
|
662
|
+
try {
|
|
663
|
+
new RegExp(params.pattern);
|
|
664
|
+
} catch {
|
|
665
|
+
mode = "plain";
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
|
|
670
|
+
// to try to read a whole file. That's not what grep is for — return a terse error
|
|
671
|
+
// steering them to a real pattern, preventing dozens of wasted retries.
|
|
672
|
+
const p = params.pattern.trim();
|
|
673
|
+
const isWildcardOnly =
|
|
674
|
+
hasRegexSyntax &&
|
|
675
|
+
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
|
|
676
|
+
p,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
if (isWildcardOnly) {
|
|
680
|
+
return {
|
|
681
|
+
content: [
|
|
682
|
+
{
|
|
683
|
+
type: "text",
|
|
684
|
+
text: `Pattern '${params.pattern}' matches everything — grep needs a concrete substring or identifier. Example: \`pattern: 'MyClass'\` or \`pattern: 'export function'\`.`,
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
details: { totalMatched: 0, totalFiles: 0 },
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// caseSensitive override flips smartCase off; omitting it keeps smart-case
|
|
692
|
+
// (case-insensitive when pattern is all lowercase).
|
|
693
|
+
const smartCase = params.caseSensitive !== true;
|
|
466
694
|
|
|
467
695
|
const grepResult = f.grep(query, {
|
|
468
696
|
mode,
|
|
469
|
-
smartCase
|
|
697
|
+
smartCase,
|
|
470
698
|
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
471
699
|
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
472
700
|
beforeContext: params.context ?? 0,
|
|
473
701
|
afterContext: params.context ?? 0,
|
|
702
|
+
classifyDefinitions: true,
|
|
474
703
|
});
|
|
475
704
|
|
|
476
705
|
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
477
706
|
|
|
478
|
-
|
|
479
|
-
let
|
|
480
|
-
|
|
481
|
-
|
|
707
|
+
let result = grepResult.value;
|
|
708
|
+
let fuzzyNotice: string | null = null;
|
|
709
|
+
|
|
710
|
+
// automatic fuzzy fallback allows to broad the queries and find different cases
|
|
711
|
+
if (result.items.length === 0 && !params.cursor && mode !== "regex") {
|
|
712
|
+
const fuzzy = f.grep(params.pattern, {
|
|
713
|
+
mode: "fuzzy",
|
|
714
|
+
smartCase,
|
|
715
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
716
|
+
cursor: null,
|
|
717
|
+
beforeContext: 0,
|
|
718
|
+
afterContext: 0,
|
|
719
|
+
classifyDefinitions: true,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
if (fuzzy.ok && fuzzy.value.items.length > 0) {
|
|
723
|
+
fuzzyNotice = `0 exact matches. Maybe you meant this?`;
|
|
724
|
+
result = fuzzy.value;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
482
727
|
|
|
728
|
+
let output = formatGrepOutput(result);
|
|
483
729
|
const notices: string[] = [];
|
|
484
|
-
if (result.
|
|
730
|
+
if (result.regexFallbackError) {
|
|
485
731
|
notices.push(
|
|
486
|
-
|
|
732
|
+
`Invalid regex: ${result.regexFallbackError}, used literal match`,
|
|
487
733
|
);
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (result.regexFallbackError)
|
|
491
|
-
notices.push(`Regex failed: ${result.regexFallbackError}, used literal match`);
|
|
492
|
-
if (result.nextCursor)
|
|
734
|
+
}
|
|
735
|
+
if (result.nextCursor) {
|
|
493
736
|
notices.push(
|
|
494
|
-
`
|
|
737
|
+
`Continue with cursor="${storeCursor(result.nextCursor)}"`,
|
|
495
738
|
);
|
|
739
|
+
}
|
|
496
740
|
|
|
497
741
|
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
742
|
+
if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
|
|
498
743
|
|
|
499
744
|
return {
|
|
500
745
|
content: [{ type: "text", text: output }],
|
|
501
746
|
details: {
|
|
502
747
|
totalMatched: result.totalMatched,
|
|
503
748
|
totalFiles: result.totalFiles,
|
|
504
|
-
truncated: truncation.truncated,
|
|
505
749
|
},
|
|
506
750
|
};
|
|
507
751
|
},
|
|
508
752
|
|
|
509
753
|
renderCall(args, theme, context) {
|
|
510
|
-
const text =
|
|
754
|
+
const text =
|
|
755
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
511
756
|
const pattern = args?.pattern ?? "";
|
|
512
757
|
const path = args?.path ?? ".";
|
|
513
758
|
let content =
|
|
@@ -532,28 +777,42 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
532
777
|
const findSchema = Type.Object({
|
|
533
778
|
pattern: Type.String({
|
|
534
779
|
description:
|
|
535
|
-
"Fuzzy search
|
|
780
|
+
"Fuzzy filename search and glob search. Frecency-ranked, git-aware. Multi-word = narrower (AND) not bound to order, use for multi word related concept search. Prefer this over ls/find/bash as the first exploration step whenever the user names a concept, feature, or symbol — it surfaces the relevant files in one call. Only use ls/read on a directory when you specifically need the alphabetical layout of an unknown repo, or when a concept search returned nothing.",
|
|
536
781
|
}),
|
|
537
782
|
path: Type.Optional(
|
|
538
|
-
Type.String({
|
|
783
|
+
Type.String({
|
|
784
|
+
description:
|
|
785
|
+
"Repo-relative path constraint. Directory prefix (src/ or src/foo/), bare filename with extension (main.rs), or glob (*.ts, src/**/*.cc, {src,lib}/**). Applied to the full repo-relative path.",
|
|
786
|
+
}),
|
|
787
|
+
),
|
|
788
|
+
exclude: Type.Optional(
|
|
789
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
790
|
+
description:
|
|
791
|
+
"Exclude paths (comma/space-separated or array). Same syntax as path: directory prefix ('test/'), filename with extension ('config.json'), or glob ('*.min.js', '**/*.{rs,go}'). A leading '!' is optional and ignored — both 'test/' and '!test/' work. Example: 'test/,*.min.js,!vendor/'.",
|
|
792
|
+
}),
|
|
539
793
|
),
|
|
540
794
|
limit: Type.Optional(
|
|
541
795
|
Type.Number({
|
|
542
|
-
description: `
|
|
796
|
+
description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
|
|
543
797
|
}),
|
|
544
798
|
),
|
|
799
|
+
cursor: Type.Optional(
|
|
800
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
801
|
+
),
|
|
545
802
|
});
|
|
546
803
|
|
|
547
804
|
pi.registerTool({
|
|
548
805
|
name: toolNames.find,
|
|
549
806
|
label: toolNames.find,
|
|
550
|
-
description: `Fuzzy
|
|
551
|
-
promptSnippet:
|
|
552
|
-
"Find files by name (FFF: fuzzy, frecency-ranked, git-aware, respects .gitignore)",
|
|
807
|
+
description: `Fuzzy path search and glob search. Matches against the whole repo-relative path, not just the filename. Frecency-ranked, git-aware. Multi-word = narrower (AND). Default limit ${DEFAULT_FIND_LIMIT}.`,
|
|
808
|
+
promptSnippet: "Find files by path or glob",
|
|
553
809
|
promptGuidelines: [
|
|
554
|
-
"
|
|
555
|
-
"
|
|
556
|
-
"Use
|
|
810
|
+
"Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
|
|
811
|
+
"Keep queries to 1-2 terms; extra words narrow.",
|
|
812
|
+
"Use for paths, not content. Use grep for content.",
|
|
813
|
+
"For exact path matches use a glob in `path` — e.g. path: '**/profile.h' for exact filename, or path: 'src/**/profile.h' scoped to a subtree. Bare patterns are fuzzy.",
|
|
814
|
+
"To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
|
|
815
|
+
"Use exclude: 'test/,*.min.js' to cut noise in large repos.",
|
|
557
816
|
],
|
|
558
817
|
parameters: findSchema,
|
|
559
818
|
|
|
@@ -561,43 +820,71 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
561
820
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
562
821
|
|
|
563
822
|
const f = await ensureFinder(activeCwd);
|
|
564
|
-
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
565
|
-
const query = params.path ? `${params.path} ${params.pattern}` : params.pattern;
|
|
566
823
|
|
|
567
|
-
|
|
824
|
+
// Resume from a prior cursor if supplied — cursor owns query+pageSize so
|
|
825
|
+
// the agent can't accidentally mix patterns across pages.
|
|
826
|
+
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
827
|
+
const effectiveLimit = resumed
|
|
828
|
+
? resumed.pageSize
|
|
829
|
+
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
830
|
+
const query = resumed
|
|
831
|
+
? resumed.query
|
|
832
|
+
: buildQuery(params.path, params.pattern, params.exclude);
|
|
833
|
+
const pattern = resumed ? resumed.pattern : params.pattern;
|
|
834
|
+
const pageIndex = resumed?.nextPageIndex ?? 0;
|
|
835
|
+
|
|
836
|
+
const searchResult = f.fileSearch(query, {
|
|
837
|
+
pageIndex,
|
|
838
|
+
pageSize: effectiveLimit,
|
|
839
|
+
});
|
|
568
840
|
if (!searchResult.ok) throw new Error(searchResult.error);
|
|
569
841
|
|
|
570
842
|
const result = searchResult.value;
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
843
|
+
const formatted = formatFindOutput(result, effectiveLimit, pattern);
|
|
844
|
+
let output = formatted.output;
|
|
845
|
+
|
|
846
|
+
// Infer hasMore: native fileSearch fills pageSize when more results
|
|
847
|
+
// exist, so if we got a full page AND totalMatched exceeds what we've
|
|
848
|
+
// shown so far there's another page to fetch.
|
|
849
|
+
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
|
850
|
+
const hasMore =
|
|
851
|
+
result.items.length >= effectiveLimit &&
|
|
852
|
+
result.totalMatched > shownSoFar;
|
|
574
853
|
|
|
575
854
|
const notices: string[] = [];
|
|
576
|
-
if (
|
|
855
|
+
if (formatted.weak && formatted.shownCount > 0)
|
|
577
856
|
notices.push(
|
|
578
|
-
|
|
857
|
+
`Query "${pattern}" produced only weak scattered fuzzy matches. Output capped at ${formatted.shownCount}/${result.totalMatched}.`,
|
|
579
858
|
);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
859
|
+
|
|
860
|
+
if (!formatted.weak && hasMore) {
|
|
861
|
+
const remaining = result.totalMatched - shownSoFar;
|
|
862
|
+
const cursorId = storeFindCursor({
|
|
863
|
+
query,
|
|
864
|
+
pattern,
|
|
865
|
+
pageSize: effectiveLimit,
|
|
866
|
+
nextPageIndex: pageIndex + 1,
|
|
867
|
+
});
|
|
583
868
|
notices.push(
|
|
584
|
-
`${
|
|
869
|
+
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
|
|
585
870
|
);
|
|
871
|
+
}
|
|
586
872
|
|
|
587
873
|
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
588
|
-
|
|
589
874
|
return {
|
|
590
875
|
content: [{ type: "text", text: output }],
|
|
591
876
|
details: {
|
|
592
877
|
totalMatched: result.totalMatched,
|
|
593
878
|
totalFiles: result.totalFiles,
|
|
594
|
-
|
|
879
|
+
pageIndex,
|
|
880
|
+
hasMore,
|
|
595
881
|
},
|
|
596
882
|
};
|
|
597
883
|
},
|
|
598
884
|
|
|
599
885
|
renderCall(args, theme, context) {
|
|
600
|
-
const text =
|
|
886
|
+
const text =
|
|
887
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
601
888
|
const pattern = args?.pattern ?? "";
|
|
602
889
|
const path = args?.path ?? ".";
|
|
603
890
|
let content =
|
|
@@ -607,6 +894,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
607
894
|
theme.fg("toolOutput", ` in ${path}`);
|
|
608
895
|
if (args?.limit !== undefined)
|
|
609
896
|
content += theme.fg("toolOutput", ` (limit ${args.limit})`);
|
|
897
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
610
898
|
text.setText(content);
|
|
611
899
|
return text;
|
|
612
900
|
},
|
|
@@ -617,121 +905,111 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
617
905
|
});
|
|
618
906
|
|
|
619
907
|
// --- multi_grep tool ---
|
|
908
|
+
// My latest tests are showing that the multi grep tool is only harmful, trying to get rid of it
|
|
909
|
+
const enableMultiGrep = process.env.PI_FFF_MULTIGREP === "1";
|
|
620
910
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
"Patterns to search for (OR logic -- matches lines containing ANY pattern). Include all naming conventions: snake_case, PascalCase, camelCase.",
|
|
625
|
-
}),
|
|
626
|
-
constraints: Type.Optional(
|
|
627
|
-
Type.String({
|
|
911
|
+
if (enableMultiGrep) {
|
|
912
|
+
const multiGrepSchema = Type.Object({
|
|
913
|
+
patterns: Type.Array(Type.String(), {
|
|
628
914
|
description:
|
|
629
|
-
"
|
|
915
|
+
"Literal patterns (OR). Include snake_case/camelCase/PascalCase variants.",
|
|
630
916
|
}),
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
),
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
pi.registerTool({
|
|
648
|
-
name: toolNames.multiGrep,
|
|
649
|
-
label: toolNames.multiGrep,
|
|
650
|
-
description:
|
|
651
|
-
"Search file contents for lines matching ANY of multiple patterns (OR logic). Uses SIMD-accelerated Aho-Corasick multi-pattern matching. Faster than regex alternation. Patterns are literal text -- never escape special characters. Use the constraints parameter for file filtering ('*.rs', 'src/', '!test/').",
|
|
652
|
-
promptSnippet:
|
|
653
|
-
"Multi-pattern OR search across file contents (FFF: SIMD-accelerated, frecency-ranked)",
|
|
654
|
-
promptGuidelines: [
|
|
655
|
-
`Use ${toolNames.multiGrep} when you need to find multiple identifiers at once (OR logic).`,
|
|
656
|
-
"Include all naming conventions: snake_case, PascalCase, camelCase variants.",
|
|
657
|
-
"Patterns are literal text. Never escape special characters.",
|
|
658
|
-
"Use the constraints parameter for file type/path filtering, not inside patterns.",
|
|
659
|
-
],
|
|
660
|
-
parameters: multiGrepSchema,
|
|
661
|
-
|
|
662
|
-
async execute(_toolCallId, params, signal) {
|
|
663
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
664
|
-
if (!params.patterns?.length)
|
|
665
|
-
throw new Error("patterns array must have at least 1 element");
|
|
666
|
-
|
|
667
|
-
const f = await ensureFinder(activeCwd);
|
|
668
|
-
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
669
|
-
|
|
670
|
-
const grepResult = f.multiGrep({
|
|
671
|
-
patterns: params.patterns,
|
|
672
|
-
constraints: params.constraints,
|
|
673
|
-
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
674
|
-
smartCase: true,
|
|
675
|
-
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
676
|
-
beforeContext: params.context ?? 0,
|
|
677
|
-
afterContext: params.context ?? 0,
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
917
|
+
constraints: Type.Optional(
|
|
918
|
+
Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
|
|
919
|
+
),
|
|
920
|
+
context: Type.Optional(
|
|
921
|
+
Type.Number({ description: "Context lines before+after" }),
|
|
922
|
+
),
|
|
923
|
+
limit: Type.Optional(
|
|
924
|
+
Type.Number({
|
|
925
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
926
|
+
}),
|
|
927
|
+
),
|
|
928
|
+
cursor: Type.Optional(Type.String({ description: "Pagination cursor" })),
|
|
929
|
+
});
|
|
681
930
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
931
|
+
pi.registerTool({
|
|
932
|
+
name: toolNames.multiGrep,
|
|
933
|
+
label: toolNames.multiGrep,
|
|
934
|
+
description:
|
|
935
|
+
"Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
|
|
936
|
+
promptSnippet: "Multi-pattern OR content search",
|
|
937
|
+
promptGuidelines: [
|
|
938
|
+
"Use when searching for several identifiers at once.",
|
|
939
|
+
"Include all naming-convention variants (snake/camel/Pascal).",
|
|
940
|
+
"Patterns are literal. Use constraints for file filters.",
|
|
941
|
+
],
|
|
942
|
+
parameters: multiGrepSchema,
|
|
943
|
+
|
|
944
|
+
async execute(_toolCallId, params, signal) {
|
|
945
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
946
|
+
if (!params.patterns?.length)
|
|
947
|
+
throw new Error("patterns array must have at least 1 element");
|
|
948
|
+
|
|
949
|
+
const f = await ensureFinder(activeCwd);
|
|
950
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
951
|
+
|
|
952
|
+
const grepResult = f.multiGrep({
|
|
953
|
+
patterns: params.patterns,
|
|
954
|
+
constraints: params.constraints,
|
|
955
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
956
|
+
smartCase: true,
|
|
957
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
958
|
+
beforeContext: params.context ?? 0,
|
|
959
|
+
afterContext: params.context ?? 0,
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
963
|
+
|
|
964
|
+
const result = grepResult.value;
|
|
965
|
+
let output = formatGrepOutput(result);
|
|
966
|
+
|
|
967
|
+
const notices: string[] = [];
|
|
968
|
+
if (result.items.length >= effectiveLimit)
|
|
969
|
+
notices.push(`${effectiveLimit}+ matches (refine patterns)`);
|
|
970
|
+
if (result.nextCursor)
|
|
971
|
+
notices.push(
|
|
972
|
+
`More available. cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
973
|
+
);
|
|
686
974
|
|
|
687
|
-
|
|
688
|
-
if (result.items.length >= effectiveLimit)
|
|
689
|
-
notices.push(
|
|
690
|
-
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more`,
|
|
691
|
-
);
|
|
692
|
-
if (truncation.truncated)
|
|
693
|
-
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
694
|
-
if (result.nextCursor)
|
|
695
|
-
notices.push(
|
|
696
|
-
`More results available. Use cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
697
|
-
);
|
|
975
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
698
976
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
},
|
|
709
|
-
};
|
|
710
|
-
},
|
|
977
|
+
return {
|
|
978
|
+
content: [{ type: "text", text: output }],
|
|
979
|
+
details: {
|
|
980
|
+
totalMatched: result.totalMatched,
|
|
981
|
+
totalFiles: result.totalFiles,
|
|
982
|
+
patterns: params.patterns,
|
|
983
|
+
},
|
|
984
|
+
};
|
|
985
|
+
},
|
|
711
986
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
987
|
+
renderCall(args, theme, context) {
|
|
988
|
+
const text =
|
|
989
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
990
|
+
const patterns = args?.patterns ?? [];
|
|
991
|
+
const constraints = args?.constraints;
|
|
992
|
+
let content =
|
|
993
|
+
theme.fg("toolTitle", theme.bold(toolNames.multiGrep)) +
|
|
994
|
+
" " +
|
|
995
|
+
theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
|
|
996
|
+
if (constraints) content += theme.fg("toolOutput", ` (${constraints})`);
|
|
997
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
998
|
+
text.setText(content);
|
|
999
|
+
return text;
|
|
1000
|
+
},
|
|
725
1001
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1002
|
+
renderResult(result, options, theme, context) {
|
|
1003
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
} // end if (enableMultiGrep)
|
|
730
1007
|
|
|
731
1008
|
// --- commands ---
|
|
732
1009
|
|
|
733
1010
|
pi.registerCommand("fff-mode", {
|
|
734
|
-
description:
|
|
1011
|
+
description:
|
|
1012
|
+
"Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
735
1013
|
handler: async (args, ctx) => {
|
|
736
1014
|
const arg = (args || "").trim();
|
|
737
1015
|
|
|
@@ -740,13 +1018,19 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
740
1018
|
const mode = getMode();
|
|
741
1019
|
const flag = pi.getFlag("fff-mode") ?? "unset";
|
|
742
1020
|
const env = process.env.PI_FFF_MODE ?? "unset";
|
|
743
|
-
ctx.ui.notify(
|
|
1021
|
+
ctx.ui.notify(
|
|
1022
|
+
`Current mode: '${mode}'\nFlag: ${flag}, Env: ${env}`,
|
|
1023
|
+
"info",
|
|
1024
|
+
);
|
|
744
1025
|
return;
|
|
745
1026
|
}
|
|
746
1027
|
|
|
747
1028
|
// Validate and set mode
|
|
748
1029
|
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
749
|
-
ctx.ui.notify(
|
|
1030
|
+
ctx.ui.notify(
|
|
1031
|
+
`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
|
|
1032
|
+
"warning",
|
|
1033
|
+
);
|
|
750
1034
|
return;
|
|
751
1035
|
}
|
|
752
1036
|
|