@ff-labs/pi-fff 0.6.5-nightly.e00b41d → 0.7.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.
- package/package.json +2 -1
- package/src/index.ts +519 -286
- package/src/query.ts +87 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ff-labs/pi-fff",
|
|
3
3
|
"public": true,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"description": "pi extension: FFF-powered fuzzy file and content search",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
+
"test": "bun test test/",
|
|
39
40
|
"typecheck": "tsc --noEmit"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
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,
|
|
@@ -26,13 +21,14 @@ import type {
|
|
|
26
21
|
SearchResult,
|
|
27
22
|
MixedItem,
|
|
28
23
|
} from "@ff-labs/fff-node";
|
|
24
|
+
import { buildQuery } from "./query";
|
|
29
25
|
|
|
30
26
|
// ---------------------------------------------------------------------------
|
|
31
27
|
// Constants
|
|
32
28
|
// ---------------------------------------------------------------------------
|
|
33
29
|
|
|
34
|
-
const DEFAULT_GREP_LIMIT =
|
|
35
|
-
const DEFAULT_FIND_LIMIT =
|
|
30
|
+
const DEFAULT_GREP_LIMIT = 20;
|
|
31
|
+
const DEFAULT_FIND_LIMIT = 30;
|
|
36
32
|
const GREP_MAX_LINE_LENGTH = 500;
|
|
37
33
|
const MENTION_MAX_RESULTS = 20;
|
|
38
34
|
|
|
@@ -82,6 +78,33 @@ function getCursor(id: string): GrepCursor | undefined {
|
|
|
82
78
|
return cursorCache.get(id);
|
|
83
79
|
}
|
|
84
80
|
|
|
81
|
+
// Find pagination uses a page-index cursor: native `fileSearch` takes
|
|
82
|
+
// pageIndex/pageSize, so the cursor is just the next page index paired with
|
|
83
|
+
// the query+limit that produced it. Stored tokens are opaque IDs to the agent.
|
|
84
|
+
interface FindCursor {
|
|
85
|
+
query: string;
|
|
86
|
+
pattern: string;
|
|
87
|
+
pageSize: number;
|
|
88
|
+
nextPageIndex: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const findCursorCache = new Map<string, FindCursor>();
|
|
92
|
+
let findCursorCounter = 0;
|
|
93
|
+
|
|
94
|
+
function storeFindCursor(cursor: FindCursor): string {
|
|
95
|
+
const id = `${++findCursorCounter}`;
|
|
96
|
+
findCursorCache.set(id, cursor);
|
|
97
|
+
if (findCursorCache.size > 200) {
|
|
98
|
+
const first = findCursorCache.keys().next().value;
|
|
99
|
+
if (first) findCursorCache.delete(first);
|
|
100
|
+
}
|
|
101
|
+
return id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getFindCursor(id: string): FindCursor | undefined {
|
|
105
|
+
return findCursorCache.get(id);
|
|
106
|
+
}
|
|
107
|
+
|
|
85
108
|
// ---------------------------------------------------------------------------
|
|
86
109
|
// Output formatting helpers
|
|
87
110
|
// ---------------------------------------------------------------------------
|
|
@@ -91,44 +114,122 @@ function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
|
|
|
91
114
|
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
|
|
92
115
|
}
|
|
93
116
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
117
|
+
const HOT_FRECENCY = 25;
|
|
118
|
+
const WARM_FRECENCY = 20;
|
|
119
|
+
|
|
120
|
+
// Shared annotation helper for both find-output paths and grep-output file
|
|
121
|
+
// headers. Returns at most ONE tag so output stays scannable. Priority:
|
|
122
|
+
// git-dirty (most actionable — file is changing right now) beats frecency
|
|
123
|
+
// (historically often-touched). Keeping one function ensures the two tools
|
|
124
|
+
// never drift in how they surface git/frecency signal.
|
|
125
|
+
export function fffFileAnnotation(item: {
|
|
126
|
+
gitStatus?: string;
|
|
127
|
+
totalFrecencyScore?: number;
|
|
128
|
+
accessFrecencyScore?: number;
|
|
129
|
+
}): string {
|
|
130
|
+
const git = item.gitStatus;
|
|
131
|
+
if (git && git !== "clean" && git !== "unknown" && git !== "") {
|
|
132
|
+
return ` [${git} in git]`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const frecency = item.totalFrecencyScore ?? item.accessFrecencyScore ?? 0;
|
|
136
|
+
if (frecency >= HOT_FRECENCY) return " [VERY often touched file]";
|
|
137
|
+
if (frecency >= WARM_FRECENCY) return " [often touched file]";
|
|
97
138
|
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// fff-core native definition classifier (byte-level scanner in Rust) is enabled
|
|
143
|
+
// via GrepOptions.classifyDefinitions. Each GrepMatch carries isDefinition for
|
|
144
|
+
// downstream consumers; pi-fff does NOT use it to re-sort.
|
|
145
|
+
//
|
|
146
|
+
// Ordering policy: NO CUSTOM SORTING. The engine already returns items in
|
|
147
|
+
// frecency order (most-accessed files first). pi-fff only groups consecutive
|
|
148
|
+
// matches into per-file blocks and preserves whatever order the engine
|
|
149
|
+
// provided — inside a file we keep matches in source-line order because the
|
|
150
|
+
// engine emits them that way.
|
|
151
|
+
|
|
152
|
+
function formatGrepOutput(result: GrepResult): string {
|
|
153
|
+
if (result.items.length === 0) return "No matches found";
|
|
154
|
+
|
|
155
|
+
// Build file-grouped output in the order files first appear in the result.
|
|
156
|
+
// This preserves native frecency ordering across files without re-sorting.
|
|
98
157
|
const lines: string[] = [];
|
|
99
158
|
let currentFile = "";
|
|
159
|
+
let shown = 0;
|
|
100
160
|
|
|
101
|
-
for (const match of items) {
|
|
161
|
+
for (const match of result.items) {
|
|
102
162
|
if (match.relativePath !== currentFile) {
|
|
103
|
-
currentFile = match.relativePath;
|
|
104
163
|
if (lines.length > 0) lines.push("");
|
|
164
|
+
currentFile = match.relativePath;
|
|
165
|
+
lines.push(`${currentFile}${fffFileAnnotation(match)}`);
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
match.contextBefore?.forEach((line: string, i: number) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
169
|
+
const lineNum = match.lineNumber - match.contextBefore!.length + i;
|
|
170
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
111
171
|
});
|
|
112
172
|
|
|
113
|
-
lines.push(
|
|
114
|
-
|
|
115
|
-
);
|
|
173
|
+
lines.push(` ${match.lineNumber}: ${truncateLine(match.lineContent)}`);
|
|
174
|
+
shown++;
|
|
116
175
|
|
|
117
176
|
match.contextAfter?.forEach((line: string, i: number) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
177
|
+
const lineNum = match.lineNumber + 1 + i;
|
|
178
|
+
lines.push(` ${lineNum}- ${truncateLine(line)}`);
|
|
121
179
|
});
|
|
122
180
|
}
|
|
123
181
|
|
|
124
182
|
return lines.join("\n");
|
|
125
183
|
}
|
|
126
184
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
185
|
+
// Weak-match threshold is derived from the query length, matching the
|
|
186
|
+
// scoring formula in crates/fff-core/src/score.rs: a perfect match scores
|
|
187
|
+
// `len * 16`, so we treat anything below 50% of that as scattered fuzzy noise.
|
|
188
|
+
// When the top score is weak, trim output to a small sample instead of dumping
|
|
189
|
+
// the full limit worth of noise into the agent's context.
|
|
190
|
+
const FIND_WEAK_SAMPLE_SIZE = 5;
|
|
191
|
+
|
|
192
|
+
function weakScoreThreshold(pattern: string): number {
|
|
193
|
+
const perfect = pattern.length * 12;
|
|
194
|
+
return Math.floor((perfect * 50) / 100);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface FormattedFind {
|
|
198
|
+
output: string;
|
|
199
|
+
weak: boolean;
|
|
200
|
+
shownCount: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatFindOutput(
|
|
204
|
+
result: SearchResult,
|
|
205
|
+
limit: number,
|
|
206
|
+
pattern: string,
|
|
207
|
+
): FormattedFind {
|
|
208
|
+
if (result.items.length === 0) {
|
|
209
|
+
return {
|
|
210
|
+
output: "No files found matching pattern",
|
|
211
|
+
weak: false,
|
|
212
|
+
shownCount: 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// NO CUSTOM SORTING — trust native frecency order from the engine.
|
|
217
|
+
const reordered = result.items.map((item) => ({ item }));
|
|
218
|
+
|
|
219
|
+
// Peek at the top native score to decide whether results are scattered
|
|
220
|
+
// fuzzy noise (query length-scaled threshold from score.rs).
|
|
221
|
+
const topScore = result.scores[0]?.total ?? 0;
|
|
222
|
+
const weak = topScore < weakScoreThreshold(pattern);
|
|
223
|
+
const effective = weak ? Math.min(FIND_WEAK_SAMPLE_SIZE, limit) : limit;
|
|
224
|
+
const shown = reordered.slice(0, effective);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
output: shown
|
|
228
|
+
.map((p) => `${p.item.relativePath}${fffFileAnnotation(p.item)}`)
|
|
229
|
+
.join("\n"),
|
|
230
|
+
weak,
|
|
231
|
+
shownCount: shown.length,
|
|
232
|
+
};
|
|
132
233
|
}
|
|
133
234
|
|
|
134
235
|
// ---------------------------------------------------------------------------
|
|
@@ -155,7 +256,9 @@ function createFffMentionProvider(
|
|
|
155
256
|
|
|
156
257
|
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
157
258
|
const items = await getItems(query, options.signal);
|
|
158
|
-
return options.signal.aborted || items.length === 0
|
|
259
|
+
return options.signal.aborted || items.length === 0
|
|
260
|
+
? null
|
|
261
|
+
: { items, prefix };
|
|
159
262
|
},
|
|
160
263
|
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
161
264
|
const currentLine = _lines[cursorLine] || "";
|
|
@@ -164,7 +267,11 @@ function createFffMentionProvider(
|
|
|
164
267
|
const newLine = before + item.value + after;
|
|
165
268
|
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
166
269
|
return {
|
|
167
|
-
lines: [
|
|
270
|
+
lines: [
|
|
271
|
+
..._lines.slice(0, cursorLine),
|
|
272
|
+
newLine,
|
|
273
|
+
..._lines.slice(cursorLine + 1),
|
|
274
|
+
],
|
|
168
275
|
cursorLine,
|
|
169
276
|
cursorCol: newCursorCol,
|
|
170
277
|
};
|
|
@@ -172,68 +279,11 @@ function createFffMentionProvider(
|
|
|
172
279
|
};
|
|
173
280
|
}
|
|
174
281
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
signal: AbortSignal,
|
|
181
|
-
) => Promise<AutocompleteItem[]>;
|
|
182
|
-
|
|
183
|
-
constructor(
|
|
184
|
-
tui: any,
|
|
185
|
-
theme: any,
|
|
186
|
-
keybindings: any,
|
|
187
|
-
getMentionItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
188
|
-
) {
|
|
189
|
-
super(tui, theme, keybindings);
|
|
190
|
-
this.getMentionItems = getMentionItems;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
override setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
194
|
-
this.baseProvider = provider;
|
|
195
|
-
// Create composite provider that handles @-mentions and falls back to base
|
|
196
|
-
const mentionProvider = createFffMentionProvider(this.getMentionItems);
|
|
197
|
-
const compositeProvider: AutocompleteProvider = {
|
|
198
|
-
getSuggestions: async (lines, cursorLine, cursorCol, options) => {
|
|
199
|
-
// Try @-mention first
|
|
200
|
-
const mentionResult = await mentionProvider.getSuggestions(
|
|
201
|
-
lines,
|
|
202
|
-
cursorLine,
|
|
203
|
-
cursorCol,
|
|
204
|
-
options,
|
|
205
|
-
);
|
|
206
|
-
if (mentionResult) return mentionResult;
|
|
207
|
-
// Fall back to base provider
|
|
208
|
-
return (
|
|
209
|
-
this.baseProvider?.getSuggestions(lines, cursorLine, cursorCol, options) ?? null
|
|
210
|
-
);
|
|
211
|
-
},
|
|
212
|
-
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
|
213
|
-
// Let mention provider handle @ completions, base provider for others
|
|
214
|
-
if (prefix?.startsWith("@")) {
|
|
215
|
-
return mentionProvider.applyCompletion!(
|
|
216
|
-
lines,
|
|
217
|
-
cursorLine,
|
|
218
|
-
cursorCol,
|
|
219
|
-
item,
|
|
220
|
-
prefix,
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
return (
|
|
224
|
-
this.baseProvider?.applyCompletion?.(
|
|
225
|
-
lines,
|
|
226
|
-
cursorLine,
|
|
227
|
-
cursorCol,
|
|
228
|
-
item,
|
|
229
|
-
prefix,
|
|
230
|
-
) ?? { lines, cursorLine, cursorCol }
|
|
231
|
-
);
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
super.setAutocompleteProvider(compositeProvider);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
282
|
+
// FffEditor is defined inside fffExtension() so it can capture `getMentionItems`
|
|
283
|
+
// via closure rather than via a 4th constructor parameter. This makes the class
|
|
284
|
+
// safe to subclass via `new SubClass(tui, theme, keybindings)` -- the pattern
|
|
285
|
+
// pi-vim and pi-image-attachments use to compose editors. See:
|
|
286
|
+
// https://github.com/badlogic/pi-mono/issues/3935
|
|
237
287
|
|
|
238
288
|
// ---------------------------------------------------------------------------
|
|
239
289
|
// Extension
|
|
@@ -242,6 +292,11 @@ class FffEditor extends CustomEditor {
|
|
|
242
292
|
export default function fffExtension(pi: ExtensionAPI) {
|
|
243
293
|
let finder: FileFinder | null = null;
|
|
244
294
|
let finderCwd: string | null = null;
|
|
295
|
+
// Concurrent ensureFinder() callers share the same in-flight promise so
|
|
296
|
+
// FileFinder.create() (which takes native DB locks) runs at most once per
|
|
297
|
+
// base path at a time — otherwise parallel tool calls would race and
|
|
298
|
+
// deadlock at the native layer (issue #403).
|
|
299
|
+
let finderPromise: Promise<FileFinder> | null = null;
|
|
245
300
|
let activeCwd = process.cwd();
|
|
246
301
|
|
|
247
302
|
// Mode resolution: flag > env > default
|
|
@@ -274,27 +329,37 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
274
329
|
return currentMode !== "tools-only";
|
|
275
330
|
}
|
|
276
331
|
|
|
277
|
-
|
|
278
|
-
if (finder && !finder.isDestroyed && finderCwd === cwd)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
finder = null;
|
|
282
|
-
finderCwd = null;
|
|
283
|
-
}
|
|
332
|
+
function ensureFinder(cwd: string): Promise<FileFinder> {
|
|
333
|
+
if (finder && !finder.isDestroyed && finderCwd === cwd)
|
|
334
|
+
return Promise.resolve(finder);
|
|
335
|
+
if (finderPromise) return finderPromise;
|
|
284
336
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
337
|
+
finderPromise = (async () => {
|
|
338
|
+
if (finder && !finder.isDestroyed) {
|
|
339
|
+
finder.destroy();
|
|
340
|
+
finder = null;
|
|
341
|
+
finderCwd = null;
|
|
342
|
+
}
|
|
291
343
|
|
|
292
|
-
|
|
344
|
+
const result = FileFinder.create({
|
|
345
|
+
basePath: cwd,
|
|
346
|
+
frecencyDbPath,
|
|
347
|
+
historyDbPath,
|
|
348
|
+
aiMode: true,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (!result.ok)
|
|
352
|
+
throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
|
353
|
+
|
|
354
|
+
finder = result.value;
|
|
355
|
+
finderCwd = cwd;
|
|
356
|
+
await finder.waitForScan(15000);
|
|
357
|
+
return finder;
|
|
358
|
+
})().finally(() => {
|
|
359
|
+
finderPromise = null;
|
|
360
|
+
});
|
|
293
361
|
|
|
294
|
-
|
|
295
|
-
finderCwd = cwd;
|
|
296
|
-
await finder.waitForScan(15000);
|
|
297
|
-
return finder;
|
|
362
|
+
return finderPromise;
|
|
298
363
|
}
|
|
299
364
|
|
|
300
365
|
function destroyFinder() {
|
|
@@ -316,20 +381,80 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
316
381
|
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
317
382
|
if (!result.ok) return [];
|
|
318
383
|
|
|
319
|
-
return result.value.items
|
|
320
|
-
|
|
384
|
+
return result.value.items
|
|
385
|
+
.slice(0, MENTION_MAX_RESULTS)
|
|
386
|
+
.map((mixed: MixedItem) => {
|
|
387
|
+
if (mixed.type === "directory") {
|
|
388
|
+
return {
|
|
389
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
390
|
+
label: mixed.item.dirName,
|
|
391
|
+
description: mixed.item.relativePath,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
321
394
|
return {
|
|
322
395
|
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
323
|
-
label: mixed.item.
|
|
396
|
+
label: mixed.item.fileName,
|
|
324
397
|
description: mixed.item.relativePath,
|
|
325
398
|
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Editor wrapper that injects FFF @-mention autocomplete alongside base provider.
|
|
403
|
+
// Defined inside fffExtension() so the class methods capture `getMentionItems`
|
|
404
|
+
// via closure. Subclasses constructed as `new Sub(tui, theme, keybindings)` by
|
|
405
|
+
// composability wrappers (pi-vim, pi-image-attachments) still get a working
|
|
406
|
+
// mention provider because the closure binding is preserved across subclassing.
|
|
407
|
+
class FffEditor extends CustomEditor {
|
|
408
|
+
private baseProvider: AutocompleteProvider | undefined;
|
|
409
|
+
|
|
410
|
+
override setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
411
|
+
this.baseProvider = provider;
|
|
412
|
+
// Create composite provider that handles @-mentions and falls back to base
|
|
413
|
+
const mentionProvider = createFffMentionProvider(getMentionItems);
|
|
414
|
+
const compositeProvider: AutocompleteProvider = {
|
|
415
|
+
getSuggestions: async (lines, cursorLine, cursorCol, options) => {
|
|
416
|
+
// Try @-mention first
|
|
417
|
+
const mentionResult = await mentionProvider.getSuggestions(
|
|
418
|
+
lines,
|
|
419
|
+
cursorLine,
|
|
420
|
+
cursorCol,
|
|
421
|
+
options,
|
|
422
|
+
);
|
|
423
|
+
if (mentionResult) return mentionResult;
|
|
424
|
+
// Fall back to base provider
|
|
425
|
+
return (
|
|
426
|
+
this.baseProvider?.getSuggestions(
|
|
427
|
+
lines,
|
|
428
|
+
cursorLine,
|
|
429
|
+
cursorCol,
|
|
430
|
+
options,
|
|
431
|
+
) ?? null
|
|
432
|
+
);
|
|
433
|
+
},
|
|
434
|
+
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
|
435
|
+
// Let mention provider handle @ completions, base provider for others
|
|
436
|
+
if (prefix?.startsWith("@")) {
|
|
437
|
+
return mentionProvider.applyCompletion!(
|
|
438
|
+
lines,
|
|
439
|
+
cursorLine,
|
|
440
|
+
cursorCol,
|
|
441
|
+
item,
|
|
442
|
+
prefix,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return (
|
|
446
|
+
this.baseProvider?.applyCompletion?.(
|
|
447
|
+
lines,
|
|
448
|
+
cursorLine,
|
|
449
|
+
cursorCol,
|
|
450
|
+
item,
|
|
451
|
+
prefix,
|
|
452
|
+
) ?? { lines, cursorLine, cursorCol }
|
|
453
|
+
);
|
|
454
|
+
},
|
|
331
455
|
};
|
|
332
|
-
|
|
456
|
+
super.setAutocompleteProvider(compositeProvider);
|
|
457
|
+
}
|
|
333
458
|
}
|
|
334
459
|
|
|
335
460
|
function applyEditorMode(ctx: {
|
|
@@ -344,7 +469,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
344
469
|
} else {
|
|
345
470
|
ctx.ui.setEditorComponent(
|
|
346
471
|
(tui: any, theme: any, keybindings: any) =>
|
|
347
|
-
new FffEditor(tui, theme, keybindings
|
|
472
|
+
new FffEditor(tui, theme, keybindings),
|
|
348
473
|
);
|
|
349
474
|
}
|
|
350
475
|
}
|
|
@@ -357,12 +482,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
357
482
|
});
|
|
358
483
|
|
|
359
484
|
pi.registerFlag("fff-frecency-db", {
|
|
360
|
-
description:
|
|
485
|
+
description:
|
|
486
|
+
"Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
361
487
|
type: "string",
|
|
362
488
|
});
|
|
363
489
|
|
|
364
490
|
pi.registerFlag("fff-history-db", {
|
|
365
|
-
description:
|
|
491
|
+
description:
|
|
492
|
+
"Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
366
493
|
type: "string",
|
|
367
494
|
});
|
|
368
495
|
|
|
@@ -392,15 +519,20 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
392
519
|
context: any,
|
|
393
520
|
maxLines = 15,
|
|
394
521
|
) => {
|
|
395
|
-
const text =
|
|
396
|
-
|
|
522
|
+
const text =
|
|
523
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
524
|
+
const output =
|
|
525
|
+
result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
397
526
|
if (!output) {
|
|
398
527
|
text.setText(theme.fg("muted", "No output"));
|
|
399
528
|
return text;
|
|
400
529
|
}
|
|
401
530
|
|
|
402
531
|
const lines = output.split("\n");
|
|
403
|
-
const displayLines = lines.slice(
|
|
532
|
+
const displayLines = lines.slice(
|
|
533
|
+
0,
|
|
534
|
+
options.expanded ? lines.length : maxLines,
|
|
535
|
+
);
|
|
404
536
|
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
405
537
|
if (lines.length > displayLines.length) {
|
|
406
538
|
content += theme.fg(
|
|
@@ -415,44 +547,50 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
415
547
|
// --- grep tool ---
|
|
416
548
|
|
|
417
549
|
const grepSchema = Type.Object({
|
|
418
|
-
pattern: Type.String({
|
|
550
|
+
pattern: Type.String({
|
|
551
|
+
description: "Search pattern (literal text or regex)",
|
|
552
|
+
}),
|
|
419
553
|
path: Type.Optional(
|
|
420
554
|
Type.String({
|
|
421
555
|
description:
|
|
422
|
-
"
|
|
556
|
+
"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.",
|
|
557
|
+
}),
|
|
558
|
+
),
|
|
559
|
+
exclude: Type.Optional(
|
|
560
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
561
|
+
description:
|
|
562
|
+
"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/'.",
|
|
423
563
|
}),
|
|
424
564
|
),
|
|
425
|
-
|
|
565
|
+
caseSensitive: Type.Optional(
|
|
426
566
|
Type.Boolean({
|
|
427
|
-
description:
|
|
567
|
+
description:
|
|
568
|
+
"Force case-sensitive matching. Default uses smart-case (case-insensitive when pattern is all lowercase).",
|
|
428
569
|
}),
|
|
429
570
|
),
|
|
430
571
|
context: Type.Optional(
|
|
431
|
-
Type.Number({
|
|
432
|
-
description: "Number of lines to show before and after each match (default: 0)",
|
|
433
|
-
}),
|
|
572
|
+
Type.Number({ description: "Context lines before+after each match" }),
|
|
434
573
|
),
|
|
435
574
|
limit: Type.Optional(
|
|
436
575
|
Type.Number({
|
|
437
|
-
description: `
|
|
576
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
438
577
|
}),
|
|
439
578
|
),
|
|
440
579
|
cursor: Type.Optional(
|
|
441
|
-
Type.String({ description: "
|
|
580
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
442
581
|
),
|
|
443
582
|
});
|
|
444
583
|
|
|
445
584
|
pi.registerTool({
|
|
446
585
|
name: toolNames.grep,
|
|
447
586
|
label: toolNames.grep,
|
|
448
|
-
description: `
|
|
449
|
-
promptSnippet:
|
|
450
|
-
"Search file contents for patterns (FFF: frecency-ranked, git-aware, respects .gitignore)",
|
|
587
|
+
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}.`,
|
|
588
|
+
promptSnippet: "Grep contents",
|
|
451
589
|
promptGuidelines: [
|
|
452
|
-
"
|
|
453
|
-
"
|
|
454
|
-
"
|
|
455
|
-
"
|
|
590
|
+
"Prefer bare identifiers as patterns. Literal queries are most efficient.",
|
|
591
|
+
"Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
|
|
592
|
+
"caseSensitive: true when you need exact case (smart-case otherwise).",
|
|
593
|
+
"After 1-2 greps, read the top match instead of more greps.",
|
|
456
594
|
],
|
|
457
595
|
parameters: grepSchema,
|
|
458
596
|
|
|
@@ -461,53 +599,109 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
461
599
|
|
|
462
600
|
const f = await ensureFinder(activeCwd);
|
|
463
601
|
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
464
|
-
const query = params.path
|
|
465
|
-
|
|
602
|
+
const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
|
603
|
+
// Auto-detect: regex if the pattern has regex metacharacters AND parses
|
|
604
|
+
// as a valid regex, otherwise plain literal. The fuzzy fallback below
|
|
605
|
+
// only kicks in for plain mode — regex queries are intentional.
|
|
606
|
+
const hasRegexSyntax =
|
|
607
|
+
params.pattern !==
|
|
608
|
+
params.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
609
|
+
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
610
|
+
if (mode === "regex") {
|
|
611
|
+
try {
|
|
612
|
+
new RegExp(params.pattern);
|
|
613
|
+
} catch {
|
|
614
|
+
mode = "plain";
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
|
|
619
|
+
// to try to read a whole file. That's not what grep is for — return a terse error
|
|
620
|
+
// steering them to a real pattern, preventing dozens of wasted retries.
|
|
621
|
+
const p = params.pattern.trim();
|
|
622
|
+
const isWildcardOnly =
|
|
623
|
+
hasRegexSyntax &&
|
|
624
|
+
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
|
|
625
|
+
p,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
if (isWildcardOnly) {
|
|
629
|
+
return {
|
|
630
|
+
content: [
|
|
631
|
+
{
|
|
632
|
+
type: "text",
|
|
633
|
+
text: `Pattern '${params.pattern}' matches everything — grep needs a concrete substring or identifier. Example: \`pattern: 'MyClass'\` or \`pattern: 'export function'\`.`,
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
details: { totalMatched: 0, totalFiles: 0 },
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// caseSensitive override flips smartCase off; omitting it keeps smart-case
|
|
641
|
+
// (case-insensitive when pattern is all lowercase).
|
|
642
|
+
const smartCase = params.caseSensitive !== true;
|
|
466
643
|
|
|
467
644
|
const grepResult = f.grep(query, {
|
|
468
645
|
mode,
|
|
469
|
-
smartCase
|
|
646
|
+
smartCase,
|
|
470
647
|
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
471
648
|
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
472
649
|
beforeContext: params.context ?? 0,
|
|
473
650
|
afterContext: params.context ?? 0,
|
|
651
|
+
classifyDefinitions: true,
|
|
474
652
|
});
|
|
475
653
|
|
|
476
654
|
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
477
655
|
|
|
478
|
-
|
|
479
|
-
let
|
|
480
|
-
|
|
481
|
-
|
|
656
|
+
let result = grepResult.value;
|
|
657
|
+
let fuzzyNotice: string | null = null;
|
|
658
|
+
|
|
659
|
+
// automatic fuzzy fallback allows to broad the queries and find different cases
|
|
660
|
+
if (result.items.length === 0 && !params.cursor && mode !== "regex") {
|
|
661
|
+
const fuzzy = f.grep(params.pattern, {
|
|
662
|
+
mode: "fuzzy",
|
|
663
|
+
smartCase,
|
|
664
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
665
|
+
cursor: null,
|
|
666
|
+
beforeContext: 0,
|
|
667
|
+
afterContext: 0,
|
|
668
|
+
classifyDefinitions: true,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
if (fuzzy.ok && fuzzy.value.items.length > 0) {
|
|
672
|
+
fuzzyNotice = `0 exact matches. Maybe you meant this?`;
|
|
673
|
+
result = fuzzy.value;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
482
676
|
|
|
677
|
+
let output = formatGrepOutput(result);
|
|
483
678
|
const notices: string[] = [];
|
|
484
|
-
if (result.
|
|
679
|
+
if (result.regexFallbackError) {
|
|
485
680
|
notices.push(
|
|
486
|
-
|
|
681
|
+
`Invalid regex: ${result.regexFallbackError}, used literal match`,
|
|
487
682
|
);
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (result.regexFallbackError)
|
|
491
|
-
notices.push(`Regex failed: ${result.regexFallbackError}, used literal match`);
|
|
492
|
-
if (result.nextCursor)
|
|
683
|
+
}
|
|
684
|
+
if (result.nextCursor) {
|
|
493
685
|
notices.push(
|
|
494
|
-
`
|
|
686
|
+
`Continue with cursor="${storeCursor(result.nextCursor)}"`,
|
|
495
687
|
);
|
|
688
|
+
}
|
|
496
689
|
|
|
497
690
|
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
691
|
+
if (fuzzyNotice) output = `[${fuzzyNotice}]\n${output}`;
|
|
498
692
|
|
|
499
693
|
return {
|
|
500
694
|
content: [{ type: "text", text: output }],
|
|
501
695
|
details: {
|
|
502
696
|
totalMatched: result.totalMatched,
|
|
503
697
|
totalFiles: result.totalFiles,
|
|
504
|
-
truncated: truncation.truncated,
|
|
505
698
|
},
|
|
506
699
|
};
|
|
507
700
|
},
|
|
508
701
|
|
|
509
702
|
renderCall(args, theme, context) {
|
|
510
|
-
const text =
|
|
703
|
+
const text =
|
|
704
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
511
705
|
const pattern = args?.pattern ?? "";
|
|
512
706
|
const path = args?.path ?? ".";
|
|
513
707
|
let content =
|
|
@@ -532,28 +726,42 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
532
726
|
const findSchema = Type.Object({
|
|
533
727
|
pattern: Type.String({
|
|
534
728
|
description:
|
|
535
|
-
"Fuzzy search
|
|
729
|
+
"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
730
|
}),
|
|
537
731
|
path: Type.Optional(
|
|
538
|
-
Type.String({
|
|
732
|
+
Type.String({
|
|
733
|
+
description:
|
|
734
|
+
"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.",
|
|
735
|
+
}),
|
|
736
|
+
),
|
|
737
|
+
exclude: Type.Optional(
|
|
738
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
739
|
+
description:
|
|
740
|
+
"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/'.",
|
|
741
|
+
}),
|
|
539
742
|
),
|
|
540
743
|
limit: Type.Optional(
|
|
541
744
|
Type.Number({
|
|
542
|
-
description: `
|
|
745
|
+
description: `Max results per page (default ${DEFAULT_FIND_LIMIT})`,
|
|
543
746
|
}),
|
|
544
747
|
),
|
|
748
|
+
cursor: Type.Optional(
|
|
749
|
+
Type.String({ description: "Pagination cursor from previous result" }),
|
|
750
|
+
),
|
|
545
751
|
});
|
|
546
752
|
|
|
547
753
|
pi.registerTool({
|
|
548
754
|
name: toolNames.find,
|
|
549
755
|
label: toolNames.find,
|
|
550
|
-
description: `Fuzzy
|
|
551
|
-
promptSnippet:
|
|
552
|
-
"Find files by name (FFF: fuzzy, frecency-ranked, git-aware, respects .gitignore)",
|
|
756
|
+
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}.`,
|
|
757
|
+
promptSnippet: "Find files by path or glob",
|
|
553
758
|
promptGuidelines: [
|
|
554
|
-
"
|
|
555
|
-
"
|
|
556
|
-
"Use
|
|
759
|
+
"Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
|
|
760
|
+
"Keep queries to 1-2 terms; extra words narrow.",
|
|
761
|
+
"Use for paths, not content. Use grep for content.",
|
|
762
|
+
"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.",
|
|
763
|
+
"To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
|
|
764
|
+
"Use exclude: 'test/,*.min.js' to cut noise in large repos.",
|
|
557
765
|
],
|
|
558
766
|
parameters: findSchema,
|
|
559
767
|
|
|
@@ -561,43 +769,71 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
561
769
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
562
770
|
|
|
563
771
|
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
772
|
|
|
567
|
-
|
|
773
|
+
// Resume from a prior cursor if supplied — cursor owns query+pageSize so
|
|
774
|
+
// the agent can't accidentally mix patterns across pages.
|
|
775
|
+
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
776
|
+
const effectiveLimit = resumed
|
|
777
|
+
? resumed.pageSize
|
|
778
|
+
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
779
|
+
const query = resumed
|
|
780
|
+
? resumed.query
|
|
781
|
+
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
|
782
|
+
const pattern = resumed ? resumed.pattern : params.pattern;
|
|
783
|
+
const pageIndex = resumed?.nextPageIndex ?? 0;
|
|
784
|
+
|
|
785
|
+
const searchResult = f.fileSearch(query, {
|
|
786
|
+
pageIndex,
|
|
787
|
+
pageSize: effectiveLimit,
|
|
788
|
+
});
|
|
568
789
|
if (!searchResult.ok) throw new Error(searchResult.error);
|
|
569
790
|
|
|
570
791
|
const result = searchResult.value;
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
792
|
+
const formatted = formatFindOutput(result, effectiveLimit, pattern);
|
|
793
|
+
let output = formatted.output;
|
|
794
|
+
|
|
795
|
+
// Infer hasMore: native fileSearch fills pageSize when more results
|
|
796
|
+
// exist, so if we got a full page AND totalMatched exceeds what we've
|
|
797
|
+
// shown so far there's another page to fetch.
|
|
798
|
+
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
|
799
|
+
const hasMore =
|
|
800
|
+
result.items.length >= effectiveLimit &&
|
|
801
|
+
result.totalMatched > shownSoFar;
|
|
574
802
|
|
|
575
803
|
const notices: string[] = [];
|
|
576
|
-
if (
|
|
804
|
+
if (formatted.weak && formatted.shownCount > 0)
|
|
577
805
|
notices.push(
|
|
578
|
-
|
|
806
|
+
`Query "${pattern}" produced only weak scattered fuzzy matches. Output capped at ${formatted.shownCount}/${result.totalMatched}.`,
|
|
579
807
|
);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
808
|
+
|
|
809
|
+
if (!formatted.weak && hasMore) {
|
|
810
|
+
const remaining = result.totalMatched - shownSoFar;
|
|
811
|
+
const cursorId = storeFindCursor({
|
|
812
|
+
query,
|
|
813
|
+
pattern,
|
|
814
|
+
pageSize: effectiveLimit,
|
|
815
|
+
nextPageIndex: pageIndex + 1,
|
|
816
|
+
});
|
|
583
817
|
notices.push(
|
|
584
|
-
`${
|
|
818
|
+
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
|
|
585
819
|
);
|
|
820
|
+
}
|
|
586
821
|
|
|
587
822
|
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
588
|
-
|
|
589
823
|
return {
|
|
590
824
|
content: [{ type: "text", text: output }],
|
|
591
825
|
details: {
|
|
592
826
|
totalMatched: result.totalMatched,
|
|
593
827
|
totalFiles: result.totalFiles,
|
|
594
|
-
|
|
828
|
+
pageIndex,
|
|
829
|
+
hasMore,
|
|
595
830
|
},
|
|
596
831
|
};
|
|
597
832
|
},
|
|
598
833
|
|
|
599
834
|
renderCall(args, theme, context) {
|
|
600
|
-
const text =
|
|
835
|
+
const text =
|
|
836
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
601
837
|
const pattern = args?.pattern ?? "";
|
|
602
838
|
const path = args?.path ?? ".";
|
|
603
839
|
let content =
|
|
@@ -607,6 +843,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
607
843
|
theme.fg("toolOutput", ` in ${path}`);
|
|
608
844
|
if (args?.limit !== undefined)
|
|
609
845
|
content += theme.fg("toolOutput", ` (limit ${args.limit})`);
|
|
846
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
610
847
|
text.setText(content);
|
|
611
848
|
return text;
|
|
612
849
|
},
|
|
@@ -617,121 +854,111 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
617
854
|
});
|
|
618
855
|
|
|
619
856
|
// --- multi_grep tool ---
|
|
857
|
+
// My latest tests are showing that the multi grep tool is only harmful, trying to get rid of it
|
|
858
|
+
const enableMultiGrep = process.env.PI_FFF_MULTIGREP === "1";
|
|
620
859
|
|
|
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({
|
|
860
|
+
if (enableMultiGrep) {
|
|
861
|
+
const multiGrepSchema = Type.Object({
|
|
862
|
+
patterns: Type.Array(Type.String(), {
|
|
628
863
|
description:
|
|
629
|
-
"
|
|
630
|
-
}),
|
|
631
|
-
),
|
|
632
|
-
context: Type.Optional(
|
|
633
|
-
Type.Number({
|
|
634
|
-
description: "Number of context lines before and after each match (default: 0)",
|
|
635
|
-
}),
|
|
636
|
-
),
|
|
637
|
-
limit: Type.Optional(
|
|
638
|
-
Type.Number({
|
|
639
|
-
description: `Maximum number of matches to return (default: ${DEFAULT_GREP_LIMIT})`,
|
|
864
|
+
"Literal patterns (OR). Include snake_case/camelCase/PascalCase variants.",
|
|
640
865
|
}),
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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);
|
|
681
|
-
|
|
682
|
-
const result = grepResult.value;
|
|
683
|
-
let output = formatGrepOutput(result, effectiveLimit);
|
|
684
|
-
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
685
|
-
output = truncation.content;
|
|
866
|
+
constraints: Type.Optional(
|
|
867
|
+
Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
|
|
868
|
+
),
|
|
869
|
+
context: Type.Optional(
|
|
870
|
+
Type.Number({ description: "Context lines before+after" }),
|
|
871
|
+
),
|
|
872
|
+
limit: Type.Optional(
|
|
873
|
+
Type.Number({
|
|
874
|
+
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
875
|
+
}),
|
|
876
|
+
),
|
|
877
|
+
cursor: Type.Optional(Type.String({ description: "Pagination cursor" })),
|
|
878
|
+
});
|
|
686
879
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
880
|
+
pi.registerTool({
|
|
881
|
+
name: toolNames.multiGrep,
|
|
882
|
+
label: toolNames.multiGrep,
|
|
883
|
+
description:
|
|
884
|
+
"Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
|
|
885
|
+
promptSnippet: "Multi-pattern OR content search",
|
|
886
|
+
promptGuidelines: [
|
|
887
|
+
"Use when searching for several identifiers at once.",
|
|
888
|
+
"Include all naming-convention variants (snake/camel/Pascal).",
|
|
889
|
+
"Patterns are literal. Use constraints for file filters.",
|
|
890
|
+
],
|
|
891
|
+
parameters: multiGrepSchema,
|
|
892
|
+
|
|
893
|
+
async execute(_toolCallId, params, signal) {
|
|
894
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
895
|
+
if (!params.patterns?.length)
|
|
896
|
+
throw new Error("patterns array must have at least 1 element");
|
|
897
|
+
|
|
898
|
+
const f = await ensureFinder(activeCwd);
|
|
899
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
900
|
+
|
|
901
|
+
const grepResult = f.multiGrep({
|
|
902
|
+
patterns: params.patterns,
|
|
903
|
+
constraints: params.constraints,
|
|
904
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
905
|
+
smartCase: true,
|
|
906
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
907
|
+
beforeContext: params.context ?? 0,
|
|
908
|
+
afterContext: params.context ?? 0,
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
912
|
+
|
|
913
|
+
const result = grepResult.value;
|
|
914
|
+
let output = formatGrepOutput(result);
|
|
915
|
+
|
|
916
|
+
const notices: string[] = [];
|
|
917
|
+
if (result.items.length >= effectiveLimit)
|
|
918
|
+
notices.push(`${effectiveLimit}+ matches (refine patterns)`);
|
|
919
|
+
if (result.nextCursor)
|
|
920
|
+
notices.push(
|
|
921
|
+
`More available. cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
922
|
+
);
|
|
698
923
|
|
|
699
|
-
|
|
924
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
700
925
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
},
|
|
926
|
+
return {
|
|
927
|
+
content: [{ type: "text", text: output }],
|
|
928
|
+
details: {
|
|
929
|
+
totalMatched: result.totalMatched,
|
|
930
|
+
totalFiles: result.totalFiles,
|
|
931
|
+
patterns: params.patterns,
|
|
932
|
+
},
|
|
933
|
+
};
|
|
934
|
+
},
|
|
711
935
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
936
|
+
renderCall(args, theme, context) {
|
|
937
|
+
const text =
|
|
938
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
939
|
+
const patterns = args?.patterns ?? [];
|
|
940
|
+
const constraints = args?.constraints;
|
|
941
|
+
let content =
|
|
942
|
+
theme.fg("toolTitle", theme.bold(toolNames.multiGrep)) +
|
|
943
|
+
" " +
|
|
944
|
+
theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
|
|
945
|
+
if (constraints) content += theme.fg("toolOutput", ` (${constraints})`);
|
|
946
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
947
|
+
text.setText(content);
|
|
948
|
+
return text;
|
|
949
|
+
},
|
|
725
950
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
951
|
+
renderResult(result, options, theme, context) {
|
|
952
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
} // end if (enableMultiGrep)
|
|
730
956
|
|
|
731
957
|
// --- commands ---
|
|
732
958
|
|
|
733
959
|
pi.registerCommand("fff-mode", {
|
|
734
|
-
description:
|
|
960
|
+
description:
|
|
961
|
+
"Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
735
962
|
handler: async (args, ctx) => {
|
|
736
963
|
const arg = (args || "").trim();
|
|
737
964
|
|
|
@@ -740,13 +967,19 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
740
967
|
const mode = getMode();
|
|
741
968
|
const flag = pi.getFlag("fff-mode") ?? "unset";
|
|
742
969
|
const env = process.env.PI_FFF_MODE ?? "unset";
|
|
743
|
-
ctx.ui.notify(
|
|
970
|
+
ctx.ui.notify(
|
|
971
|
+
`Current mode: '${mode}'\nFlag: ${flag}, Env: ${env}`,
|
|
972
|
+
"info",
|
|
973
|
+
);
|
|
744
974
|
return;
|
|
745
975
|
}
|
|
746
976
|
|
|
747
977
|
// Validate and set mode
|
|
748
978
|
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
749
|
-
ctx.ui.notify(
|
|
979
|
+
ctx.ui.notify(
|
|
980
|
+
`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
|
|
981
|
+
"warning",
|
|
982
|
+
);
|
|
750
983
|
return;
|
|
751
984
|
}
|
|
752
985
|
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export function normalizePathConstraint(
|
|
4
|
+
pathConstraint: string,
|
|
5
|
+
cwd = process.cwd(),
|
|
6
|
+
): string | null {
|
|
7
|
+
let trimmed = pathConstraint.trim();
|
|
8
|
+
if (!trimmed) return trimmed;
|
|
9
|
+
|
|
10
|
+
if (path.isAbsolute(trimmed)) {
|
|
11
|
+
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
|
|
12
|
+
if (relative === "") return null;
|
|
13
|
+
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
trimmed = relative;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (trimmed === "." || trimmed === "./") return null;
|
|
22
|
+
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
|
|
23
|
+
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
|
24
|
+
|
|
25
|
+
// FFF's glob matcher can treat a hidden directory root glob such as
|
|
26
|
+
// `.agents/**` as empty, while the tool contract says this means "inside
|
|
27
|
+
// this directory". Collapse simple trailing recursive directory globs to the
|
|
28
|
+
// directory-prefix constraint understood by the parser. Keep real file globs
|
|
29
|
+
// such as `src/**/*.ts` unchanged.
|
|
30
|
+
const recursiveDir = trimmed.match(/^(.*)\/\*\*(?:\/\*)?$/);
|
|
31
|
+
if (recursiveDir) {
|
|
32
|
+
const dir = recursiveDir[1];
|
|
33
|
+
if (dir && !/[*?[{]/.test(dir)) return `${dir}/`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Already signals path-constraint syntax to the parser.
|
|
37
|
+
if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
|
|
38
|
+
// Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
|
|
39
|
+
if (/[*?[{]/.test(trimmed)) return trimmed;
|
|
40
|
+
// Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
|
|
41
|
+
const lastSegment = trimmed.split("/").pop() ?? "";
|
|
42
|
+
if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
|
|
43
|
+
// Bare directory prefix → append `/` so the parser sees a PathSegment.
|
|
44
|
+
return `${trimmed}/`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
|
|
48
|
+
// understands (crates/fff-query-parser/src/parser.rs). We normalize each one
|
|
49
|
+
// the same way as the include path so bare dirs become PathSegment excludes.
|
|
50
|
+
// Tolerate callers passing already-negated forms like `!src/` by stripping
|
|
51
|
+
// the leading `!` before normalizing so we never double-negate (`!!src/`).
|
|
52
|
+
export function normalizeExcludes(
|
|
53
|
+
exclude: string | string[] | undefined,
|
|
54
|
+
cwd = process.cwd(),
|
|
55
|
+
): string[] {
|
|
56
|
+
if (!exclude) return [];
|
|
57
|
+
const list = Array.isArray(exclude) ? exclude : [exclude];
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (const raw of list) {
|
|
60
|
+
const parts = raw
|
|
61
|
+
.split(/[,\s]+/)
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
for (const p of parts) {
|
|
65
|
+
const stripped = p.startsWith("!") ? p.slice(1) : p;
|
|
66
|
+
const normalized = normalizePathConstraint(stripped, cwd);
|
|
67
|
+
if (normalized) out.push(`!${normalized}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildQuery(
|
|
74
|
+
path: string | undefined,
|
|
75
|
+
pattern: string,
|
|
76
|
+
exclude?: string | string[],
|
|
77
|
+
cwd = process.cwd(),
|
|
78
|
+
): string {
|
|
79
|
+
const parts: string[] = [];
|
|
80
|
+
if (path) {
|
|
81
|
+
const pathConstraint = normalizePathConstraint(path, cwd);
|
|
82
|
+
if (pathConstraint) parts.push(pathConstraint);
|
|
83
|
+
}
|
|
84
|
+
parts.push(...normalizeExcludes(exclude, cwd));
|
|
85
|
+
parts.push(pattern);
|
|
86
|
+
return parts.join(" ");
|
|
87
|
+
}
|