@ff-labs/pi-fff 0.9.7-nightly.fce72fa → 0.10.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 +7 -1
- package/src/aux-finders.ts +159 -0
- package/src/index.ts +178 -70
- package/src/query.ts +8 -1
- package/src/sdk.ts +29 -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.10.0",
|
|
5
5
|
"description": "pi extension: FFF-powered fuzzy file and content search",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -45,8 +45,14 @@
|
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@earendil-works/pi-coding-agent": "*",
|
|
47
47
|
"@earendil-works/pi-tui": "*",
|
|
48
|
+
"@ff-labs/fff-bun": "*",
|
|
48
49
|
"@sinclair/typebox": "*"
|
|
49
50
|
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@ff-labs/fff-bun": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
50
56
|
"devDependencies": {
|
|
51
57
|
"@types/node": "^22.0.0",
|
|
52
58
|
"typescript": "^5.0.0"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { FileFinderApi } from "@ff-labs/fff-node";
|
|
5
|
+
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
|
|
6
|
+
|
|
7
|
+
export const MAX_AUX = 3;
|
|
8
|
+
export const IDLE_TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
interface AuxPicker {
|
|
11
|
+
root: string;
|
|
12
|
+
finder: FileFinderApi;
|
|
13
|
+
lastUsed: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AuxOpts {
|
|
17
|
+
frecencyDbPath?: string;
|
|
18
|
+
historyDbPath?: string;
|
|
19
|
+
enableFsRootScanning: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class AuxFinderPool {
|
|
23
|
+
private entries: AuxPicker[] = [];
|
|
24
|
+
constructor(private opts: AuxOpts) {}
|
|
25
|
+
|
|
26
|
+
destroy(): void {
|
|
27
|
+
for (const e of this.entries) {
|
|
28
|
+
e.finder.destroy();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.entries = [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private sweepIdle(now = Date.now()): void {
|
|
35
|
+
const kept: AuxPicker[] = [];
|
|
36
|
+
for (const e of this.entries) {
|
|
37
|
+
if (now - e.lastUsed > IDLE_TTL_MS) {
|
|
38
|
+
if (!e.finder.isDestroyed) e.finder.destroy();
|
|
39
|
+
} else {
|
|
40
|
+
kept.push(e);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
this.entries = kept;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async acquire(
|
|
47
|
+
maybeRoot: string,
|
|
48
|
+
opts?: { exact?: boolean },
|
|
49
|
+
): Promise<{ finder: FileFinderApi; root: string }> {
|
|
50
|
+
this.sweepIdle();
|
|
51
|
+
let covering: AuxPicker | null = null;
|
|
52
|
+
for (const e of this.entries) {
|
|
53
|
+
if (e.finder.isDestroyed) continue;
|
|
54
|
+
if (opts?.exact ? e.root !== maybeRoot : !rootCovers(e.root, maybeRoot)) continue;
|
|
55
|
+
if (!covering || e.root.length > covering.root.length) covering = e;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (covering) {
|
|
59
|
+
covering.lastUsed = Date.now();
|
|
60
|
+
return { finder: covering.finder, root: covering.root };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.entries.length >= MAX_AUX) {
|
|
64
|
+
let oldest = this.entries[0];
|
|
65
|
+
for (const e of this.entries)
|
|
66
|
+
if (e.lastUsed < oldest.lastUsed) oldest = e;
|
|
67
|
+
if (!oldest.finder.isDestroyed) oldest.finder.destroy();
|
|
68
|
+
this.entries = this.entries.filter((e) => e !== oldest);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { FileFinder } = await loadSdk();
|
|
72
|
+
const result = FileFinder.create({
|
|
73
|
+
basePath: maybeRoot,
|
|
74
|
+
frecencyDbPath: this.opts.frecencyDbPath,
|
|
75
|
+
historyDbPath: this.opts.historyDbPath,
|
|
76
|
+
aiMode: true,
|
|
77
|
+
enableHomeDirScanning: true,
|
|
78
|
+
enableFsRootScanning: this.opts.enableFsRootScanning,
|
|
79
|
+
});
|
|
80
|
+
if (!result.ok)
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Failed to create aux file finder for ${maybeRoot}: ${result.error}`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await result.value.waitForScan(SCAN_TIMEOUT_MS);
|
|
86
|
+
this.entries.push({ root: maybeRoot, finder: result.value, lastUsed: Date.now() });
|
|
87
|
+
return { finder: result.value, root: maybeRoot };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
size(): number {
|
|
91
|
+
this.sweepIdle();
|
|
92
|
+
return this.entries.length;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Split an absolute path into an existing directory (the aux root) and a
|
|
97
|
+
// remainder usable as a fuzzy path constraint relative to that root. Glob and
|
|
98
|
+
// nonexistent segments both go into the suffix: we walk up to the nearest
|
|
99
|
+
// existing ancestor so partially-wrong paths still resolve to a search root.
|
|
100
|
+
export function resolveAuxRoot(
|
|
101
|
+
absPath: string,
|
|
102
|
+
): { root: string; suffix: string } | null {
|
|
103
|
+
const trimmed = path.normalize(absPath.trim()).replace(/\/+$/, "") || "/";
|
|
104
|
+
if (!path.isAbsolute(trimmed)) return null;
|
|
105
|
+
if (trimmed === path.sep) return { root: path.sep, suffix: "" };
|
|
106
|
+
|
|
107
|
+
const parts = trimmed.split(path.sep);
|
|
108
|
+
const firstGlob = parts.findIndex((p) => /[*?[{]/.test(p));
|
|
109
|
+
const boundary = firstGlob === -1 ? parts.length : firstGlob;
|
|
110
|
+
|
|
111
|
+
// Deepest existing non-glob prefix wins; everything below it is suffix.
|
|
112
|
+
for (let i = boundary; i > 0; i--) {
|
|
113
|
+
const candidate = parts.slice(0, i).join(path.sep) || path.sep;
|
|
114
|
+
let stat: fs.Stats;
|
|
115
|
+
try {
|
|
116
|
+
stat = fs.statSync(candidate);
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (stat.isFile()) {
|
|
121
|
+
return {
|
|
122
|
+
root: parts.slice(0, i - 1).join(path.sep) || path.sep,
|
|
123
|
+
suffix: parts.slice(i - 1).join("/"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { root: candidate, suffix: parts.slice(i).join("/") };
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Decide whether a `path` parameter should route to the workspace finder or
|
|
132
|
+
// to an aux finder. Accepts absolute paths, `~`-prefixed paths, and relative
|
|
133
|
+
// paths escaping the workspace (`../other-project`); everything is resolved
|
|
134
|
+
// against cwd first. Returns null to signal "no rerouting" (workspace finder).
|
|
135
|
+
export function routePathConstraint(
|
|
136
|
+
pathConstraint: string | undefined,
|
|
137
|
+
cwd: string,
|
|
138
|
+
): { root: string; suffix: string } | null {
|
|
139
|
+
if (!pathConstraint) return null;
|
|
140
|
+
let candidate = pathConstraint.trim();
|
|
141
|
+
if (!candidate) return null;
|
|
142
|
+
if (candidate === "~" || candidate.startsWith("~/"))
|
|
143
|
+
candidate = path.join(os.homedir(), candidate.slice(1));
|
|
144
|
+
if (!path.isAbsolute(candidate)) {
|
|
145
|
+
// Plain workspace-relative constraints stay on the workspace finder.
|
|
146
|
+
if (candidate !== ".." && !candidate.startsWith("../")) return null;
|
|
147
|
+
candidate = path.resolve(cwd, candidate);
|
|
148
|
+
}
|
|
149
|
+
const rel = path.relative(cwd, candidate);
|
|
150
|
+
if (rel !== ".." && !rel.startsWith(`..${path.sep}`)) return null;
|
|
151
|
+
return resolveAuxRoot(candidate);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
export function rootCovers(root: string, target: string): boolean {
|
|
156
|
+
if (root === target) return true;
|
|
157
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
158
|
+
return target.startsWith(prefix);
|
|
159
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @-mention autocomplete suggestions to the interactive editor.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import nodePath from "node:path";
|
|
8
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import {
|
|
10
11
|
type AutocompleteItem,
|
|
@@ -12,15 +13,19 @@ import {
|
|
|
12
13
|
Text,
|
|
13
14
|
} from "@earendil-works/pi-tui";
|
|
14
15
|
import type {
|
|
16
|
+
FileFinderApi,
|
|
15
17
|
GrepCursor,
|
|
16
18
|
GrepMode,
|
|
17
19
|
GrepResult,
|
|
18
20
|
MixedItem,
|
|
19
21
|
SearchResult,
|
|
20
22
|
} from "@ff-labs/fff-node";
|
|
21
|
-
import { FileFinder } from "@ff-labs/fff-node";
|
|
22
23
|
import { Type } from "@sinclair/typebox";
|
|
24
|
+
import { AuxFinderPool, routePathConstraint } from "./aux-finders";
|
|
23
25
|
import { buildQuery } from "./query";
|
|
26
|
+
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
|
|
27
|
+
|
|
28
|
+
export { SCAN_TIMEOUT_MS } from "./sdk";
|
|
24
29
|
|
|
25
30
|
// ---------------------------------------------------------------------------
|
|
26
31
|
// Constants
|
|
@@ -85,6 +90,7 @@ interface FindCursor {
|
|
|
85
90
|
pattern: string;
|
|
86
91
|
pageSize: number;
|
|
87
92
|
nextPageIndex: number;
|
|
93
|
+
auxRoot?: string;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
const findCursorCache = new Map<string, FindCursor>();
|
|
@@ -255,7 +261,9 @@ function createFffMentionProvider(
|
|
|
255
261
|
|
|
256
262
|
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
257
263
|
const items = await getItems(query, options.signal);
|
|
258
|
-
return options.signal.aborted || items.length === 0
|
|
264
|
+
return options.signal.aborted || items.length === 0
|
|
265
|
+
? null
|
|
266
|
+
: { items, prefix };
|
|
259
267
|
},
|
|
260
268
|
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
261
269
|
const currentLine = _lines[cursorLine] || "";
|
|
@@ -264,7 +272,11 @@ function createFffMentionProvider(
|
|
|
264
272
|
const newLine = before + item.value + after;
|
|
265
273
|
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
266
274
|
return {
|
|
267
|
-
lines: [
|
|
275
|
+
lines: [
|
|
276
|
+
..._lines.slice(0, cursorLine),
|
|
277
|
+
newLine,
|
|
278
|
+
..._lines.slice(cursorLine + 1),
|
|
279
|
+
],
|
|
268
280
|
cursorLine,
|
|
269
281
|
cursorCol: newCursorCol,
|
|
270
282
|
};
|
|
@@ -277,13 +289,13 @@ function createFffMentionProvider(
|
|
|
277
289
|
// ---------------------------------------------------------------------------
|
|
278
290
|
|
|
279
291
|
export default function fffExtension(pi: ExtensionAPI) {
|
|
280
|
-
let
|
|
292
|
+
let mainFinder: FileFinderApi | null = null;
|
|
281
293
|
let finderCwd: string | null = null;
|
|
282
294
|
// Concurrent ensureFinder() callers share the same in-flight promise so
|
|
283
295
|
// FileFinder.create() (which takes native DB locks) runs at most once per
|
|
284
296
|
// base path at a time — otherwise parallel tool calls would race and
|
|
285
297
|
// deadlock at the native layer (issue #403).
|
|
286
|
-
let finderPromise: Promise<
|
|
298
|
+
let finderPromise: Promise<FileFinderApi> | null = null;
|
|
287
299
|
let activeCwd = process.cwd();
|
|
288
300
|
|
|
289
301
|
// Mode resolution: flag > env > default
|
|
@@ -331,18 +343,27 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
331
343
|
return currentMode !== "tools-only";
|
|
332
344
|
}
|
|
333
345
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
346
|
+
let auxPool = new AuxFinderPool({
|
|
347
|
+
frecencyDbPath,
|
|
348
|
+
historyDbPath,
|
|
349
|
+
enableFsRootScanning,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// in case cwd changes we need to figure this out
|
|
353
|
+
function ensureFinder(cwd: string): Promise<FileFinderApi> {
|
|
354
|
+
if (mainFinder && !mainFinder.isDestroyed && finderCwd === cwd)
|
|
355
|
+
return Promise.resolve(mainFinder);
|
|
356
|
+
|
|
337
357
|
if (finderPromise) return finderPromise;
|
|
338
358
|
|
|
339
359
|
finderPromise = (async () => {
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
|
|
360
|
+
if (mainFinder && !mainFinder.isDestroyed) {
|
|
361
|
+
mainFinder.destroy();
|
|
362
|
+
mainFinder = null;
|
|
343
363
|
finderCwd = null;
|
|
344
364
|
}
|
|
345
365
|
|
|
366
|
+
const { FileFinder } = await loadSdk();
|
|
346
367
|
const result = FileFinder.create({
|
|
347
368
|
basePath: cwd,
|
|
348
369
|
frecencyDbPath,
|
|
@@ -355,10 +376,10 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
355
376
|
if (!result.ok)
|
|
356
377
|
throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
|
357
378
|
|
|
358
|
-
|
|
379
|
+
mainFinder = result.value;
|
|
359
380
|
finderCwd = cwd;
|
|
360
|
-
await
|
|
361
|
-
return
|
|
381
|
+
await mainFinder.waitForScan(SCAN_TIMEOUT_MS);
|
|
382
|
+
return mainFinder;
|
|
362
383
|
})().finally(() => {
|
|
363
384
|
finderPromise = null;
|
|
364
385
|
});
|
|
@@ -367,11 +388,33 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
367
388
|
}
|
|
368
389
|
|
|
369
390
|
function destroyFinder() {
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
391
|
+
if (mainFinder && !mainFinder.isDestroyed) {
|
|
392
|
+
mainFinder.destroy();
|
|
393
|
+
mainFinder = null;
|
|
373
394
|
finderCwd = null;
|
|
374
395
|
}
|
|
396
|
+
|
|
397
|
+
if (auxPool) {
|
|
398
|
+
auxPool.destroy();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function resolveFinderForPath(
|
|
403
|
+
pathParam: string | undefined,
|
|
404
|
+
pattern: string,
|
|
405
|
+
exclude: string | string[] | undefined,
|
|
406
|
+
): Promise<{ finder: FileFinderApi; query: string; root: string } | null> {
|
|
407
|
+
const route = routePathConstraint(pathParam, activeCwd);
|
|
408
|
+
if (!route) return null;
|
|
409
|
+
const aux = await auxPool.acquire(route.root);
|
|
410
|
+
// A broader covering picker may have been reused; rebase the suffix so the
|
|
411
|
+
// constraint stays relative to the picker's actual root.
|
|
412
|
+
const rebase = nodePath
|
|
413
|
+
.relative(aux.root, route.root)
|
|
414
|
+
.replaceAll(nodePath.sep, "/");
|
|
415
|
+
const suffix = [rebase, route.suffix].filter(Boolean).join("/");
|
|
416
|
+
const query = buildQuery(suffix || undefined, pattern, exclude, aux.root);
|
|
417
|
+
return { finder: aux.finder, query, root: aux.root };
|
|
375
418
|
}
|
|
376
419
|
|
|
377
420
|
async function getMentionItems(
|
|
@@ -385,29 +428,35 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
385
428
|
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
386
429
|
if (!result.ok) return [];
|
|
387
430
|
|
|
388
|
-
return result.value.items
|
|
389
|
-
|
|
431
|
+
return result.value.items
|
|
432
|
+
.slice(0, MENTION_MAX_RESULTS)
|
|
433
|
+
.map((mixed: MixedItem) => {
|
|
434
|
+
if (mixed.type === "directory") {
|
|
435
|
+
return {
|
|
436
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
437
|
+
label: mixed.item.dirName,
|
|
438
|
+
description: mixed.item.relativePath,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
390
441
|
return {
|
|
391
442
|
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
392
|
-
label: mixed.item.
|
|
443
|
+
label: mixed.item.fileName,
|
|
393
444
|
description: mixed.item.relativePath,
|
|
394
445
|
};
|
|
395
|
-
}
|
|
396
|
-
return {
|
|
397
|
-
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
398
|
-
label: mixed.item.fileName,
|
|
399
|
-
description: mixed.item.relativePath,
|
|
400
|
-
};
|
|
401
|
-
});
|
|
446
|
+
});
|
|
402
447
|
}
|
|
403
448
|
|
|
404
449
|
function registerAutocompleteProvider(ctx: {
|
|
405
450
|
ui: {
|
|
406
|
-
addAutocompleteProvider
|
|
451
|
+
addAutocompleteProvider?: (
|
|
407
452
|
factory: (current: AutocompleteProvider) => AutocompleteProvider,
|
|
408
453
|
) => void;
|
|
409
454
|
};
|
|
410
455
|
}) {
|
|
456
|
+
// pi forks (e.g. omp) may not expose addAutocompleteProvider; skip UI wiring
|
|
457
|
+
// and let tools continue to work instead of failing session_start.
|
|
458
|
+
if (typeof ctx.ui.addAutocompleteProvider !== "function") return;
|
|
459
|
+
|
|
411
460
|
ctx.ui.addAutocompleteProvider((current) => {
|
|
412
461
|
const mentionProvider = createFffMentionProvider(getMentionItems);
|
|
413
462
|
|
|
@@ -430,11 +479,21 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
430
479
|
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
431
480
|
},
|
|
432
481
|
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
433
|
-
return current.applyCompletion(
|
|
482
|
+
return current.applyCompletion(
|
|
483
|
+
lines,
|
|
484
|
+
cursorLine,
|
|
485
|
+
cursorCol,
|
|
486
|
+
item,
|
|
487
|
+
prefix,
|
|
488
|
+
);
|
|
434
489
|
},
|
|
435
490
|
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
436
491
|
return (
|
|
437
|
-
current.shouldTriggerFileCompletion?.(
|
|
492
|
+
current.shouldTriggerFileCompletion?.(
|
|
493
|
+
lines,
|
|
494
|
+
cursorLine,
|
|
495
|
+
cursorCol,
|
|
496
|
+
) ?? true
|
|
438
497
|
);
|
|
439
498
|
},
|
|
440
499
|
};
|
|
@@ -449,12 +508,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
449
508
|
});
|
|
450
509
|
|
|
451
510
|
pi.registerFlag("fff-frecency-db", {
|
|
452
|
-
description:
|
|
511
|
+
description:
|
|
512
|
+
"Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
453
513
|
type: "string",
|
|
454
514
|
});
|
|
455
515
|
|
|
456
516
|
pi.registerFlag("fff-history-db", {
|
|
457
|
-
description:
|
|
517
|
+
description:
|
|
518
|
+
"Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
458
519
|
type: "string",
|
|
459
520
|
});
|
|
460
521
|
|
|
@@ -514,15 +575,20 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
514
575
|
context: any,
|
|
515
576
|
maxLines = 15,
|
|
516
577
|
) => {
|
|
517
|
-
const text =
|
|
518
|
-
|
|
578
|
+
const text =
|
|
579
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
580
|
+
const output =
|
|
581
|
+
result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
519
582
|
if (!output) {
|
|
520
583
|
text.setText(theme.fg("muted", "No output"));
|
|
521
584
|
return text;
|
|
522
585
|
}
|
|
523
586
|
|
|
524
587
|
const lines = output.split("\n");
|
|
525
|
-
const displayLines = lines.slice(
|
|
588
|
+
const displayLines = lines.slice(
|
|
589
|
+
0,
|
|
590
|
+
options.expanded ? lines.length : maxLines,
|
|
591
|
+
);
|
|
526
592
|
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
527
593
|
if (lines.length > displayLines.length) {
|
|
528
594
|
content += theme.fg(
|
|
@@ -543,7 +609,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
543
609
|
path: Type.Optional(
|
|
544
610
|
Type.String({
|
|
545
611
|
description:
|
|
546
|
-
"
|
|
612
|
+
"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. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
|
|
547
613
|
}),
|
|
548
614
|
),
|
|
549
615
|
exclude: Type.Optional(
|
|
@@ -587,18 +653,29 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
587
653
|
async execute(_toolCallId, params, signal) {
|
|
588
654
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
589
655
|
|
|
590
|
-
const
|
|
656
|
+
const pattern = params.pattern;
|
|
657
|
+
const aux = await resolveFinderForPath(
|
|
658
|
+
params.path,
|
|
659
|
+
pattern,
|
|
660
|
+
params.exclude,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
|
|
591
664
|
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
592
|
-
const query =
|
|
665
|
+
const query = aux
|
|
666
|
+
? aux.query
|
|
667
|
+
: buildQuery(params.path, pattern, params.exclude, activeCwd);
|
|
668
|
+
|
|
593
669
|
// Auto-detect: regex if the pattern has regex metacharacters AND parses
|
|
594
670
|
// as a valid regex, otherwise plain literal. The fuzzy fallback below
|
|
595
671
|
// only kicks in for plain mode — regex queries are intentional.
|
|
596
672
|
const hasRegexSyntax =
|
|
597
|
-
|
|
673
|
+
pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
674
|
+
|
|
598
675
|
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
599
676
|
if (mode === "regex") {
|
|
600
677
|
try {
|
|
601
|
-
new RegExp(
|
|
678
|
+
new RegExp(pattern);
|
|
602
679
|
} catch {
|
|
603
680
|
mode = "plain";
|
|
604
681
|
}
|
|
@@ -607,7 +684,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
607
684
|
// Guard: the agent keeps calling grep with '.*' or similar wildcard-only regex
|
|
608
685
|
// to try to read a whole file. That's not what grep is for — return a terse error
|
|
609
686
|
// steering them to a real pattern, preventing dozens of wasted retries.
|
|
610
|
-
const p =
|
|
687
|
+
const p = pattern.trim();
|
|
611
688
|
const isWildcardOnly =
|
|
612
689
|
hasRegexSyntax &&
|
|
613
690
|
/^(?:[.^$]*(?:[.][*+?]|\*|\+)[.^$]*|[.^$\s]*|\.\*\??|\.\*[+?]?|\.\+\??|\.|\*|\?)$/.test(
|
|
@@ -630,7 +707,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
630
707
|
// (case-insensitive when pattern is all lowercase).
|
|
631
708
|
const smartCase = params.caseSensitive !== true;
|
|
632
709
|
|
|
633
|
-
const grepResult =
|
|
710
|
+
const grepResult = picker.grep(query, {
|
|
634
711
|
mode,
|
|
635
712
|
smartCase,
|
|
636
713
|
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
@@ -647,7 +724,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
647
724
|
|
|
648
725
|
// automatic fuzzy fallback allows to broad the queries and find different cases
|
|
649
726
|
if (result.items.length === 0 && !params.cursor && mode !== "regex") {
|
|
650
|
-
const fuzzy =
|
|
727
|
+
const fuzzy = picker.grep(pattern, {
|
|
651
728
|
mode: "fuzzy",
|
|
652
729
|
smartCase,
|
|
653
730
|
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
@@ -666,10 +743,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
666
743
|
let output = formatGrepOutput(result);
|
|
667
744
|
const notices: string[] = [];
|
|
668
745
|
if (result.regexFallbackError) {
|
|
669
|
-
notices.push(
|
|
746
|
+
notices.push(
|
|
747
|
+
`Invalid regex: ${result.regexFallbackError}, used literal match`,
|
|
748
|
+
);
|
|
670
749
|
}
|
|
671
750
|
if (result.nextCursor) {
|
|
672
|
-
notices.push(
|
|
751
|
+
notices.push(
|
|
752
|
+
`Continue with cursor="${storeCursor(result.nextCursor)}"`,
|
|
753
|
+
);
|
|
673
754
|
}
|
|
674
755
|
|
|
675
756
|
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
@@ -685,7 +766,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
685
766
|
},
|
|
686
767
|
|
|
687
768
|
renderCall(args, theme, context) {
|
|
688
|
-
const text =
|
|
769
|
+
const text =
|
|
770
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
689
771
|
const pattern = args?.pattern ?? "";
|
|
690
772
|
const path = args?.path ?? ".";
|
|
691
773
|
let content =
|
|
@@ -715,7 +797,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
715
797
|
path: Type.Optional(
|
|
716
798
|
Type.String({
|
|
717
799
|
description:
|
|
718
|
-
"
|
|
800
|
+
"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. Absolute, ~/, and ../ paths outside the workspace are also supported and searched with a separate index.",
|
|
719
801
|
}),
|
|
720
802
|
),
|
|
721
803
|
exclude: Type.Optional(
|
|
@@ -752,21 +834,38 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
752
834
|
async execute(_toolCallId, params, signal) {
|
|
753
835
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
754
836
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
// Resume from a prior cursor if supplied — cursor owns query+pageSize so
|
|
758
|
-
// the agent can't accidentally mix patterns across pages.
|
|
837
|
+
// if resumed we use the same picker as before
|
|
759
838
|
const resumed = params.cursor ? getFindCursor(params.cursor) : undefined;
|
|
839
|
+
const aux = resumed
|
|
840
|
+
? resumed.auxRoot
|
|
841
|
+
? {
|
|
842
|
+
finder: (await auxPool.acquire(resumed.auxRoot, { exact: true }))
|
|
843
|
+
.finder,
|
|
844
|
+
root: resumed.auxRoot,
|
|
845
|
+
}
|
|
846
|
+
: null
|
|
847
|
+
: await resolveFinderForPath(
|
|
848
|
+
params.path,
|
|
849
|
+
params.pattern,
|
|
850
|
+
params.exclude,
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
|
|
760
854
|
const effectiveLimit = resumed
|
|
761
855
|
? resumed.pageSize
|
|
762
856
|
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
857
|
+
|
|
763
858
|
const query = resumed
|
|
764
859
|
? resumed.query
|
|
765
|
-
:
|
|
860
|
+
: aux && "query" in aux
|
|
861
|
+
? (aux as { query: string }).query
|
|
862
|
+
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
|
|
863
|
+
|
|
766
864
|
const pattern = resumed ? resumed.pattern : params.pattern;
|
|
767
865
|
const pageIndex = resumed?.nextPageIndex ?? 0;
|
|
866
|
+
const auxRoot = resumed?.auxRoot ?? aux?.root;
|
|
768
867
|
|
|
769
|
-
const searchResult =
|
|
868
|
+
const searchResult = picker.fileSearch(query, {
|
|
770
869
|
pageIndex,
|
|
771
870
|
pageSize: effectiveLimit,
|
|
772
871
|
});
|
|
@@ -781,7 +880,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
781
880
|
// shown so far there's another page to fetch.
|
|
782
881
|
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
|
783
882
|
const hasMore =
|
|
784
|
-
result.items.length >= effectiveLimit &&
|
|
883
|
+
result.items.length >= effectiveLimit &&
|
|
884
|
+
result.totalMatched > shownSoFar;
|
|
785
885
|
|
|
786
886
|
const notices: string[] = [];
|
|
787
887
|
if (formatted.weak && formatted.shownCount > 0)
|
|
@@ -796,6 +896,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
796
896
|
pattern,
|
|
797
897
|
pageSize: effectiveLimit,
|
|
798
898
|
nextPageIndex: pageIndex + 1,
|
|
899
|
+
auxRoot,
|
|
799
900
|
});
|
|
800
901
|
notices.push(
|
|
801
902
|
`${remaining} more match${remaining === 1 ? "" : "es"} available. cursor="${cursorId}" to continue`,
|
|
@@ -815,7 +916,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
815
916
|
},
|
|
816
917
|
|
|
817
918
|
renderCall(args, theme, context) {
|
|
818
|
-
const text =
|
|
919
|
+
const text =
|
|
920
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
819
921
|
const pattern = args?.pattern ?? "";
|
|
820
922
|
const path = args?.path ?? ".";
|
|
821
923
|
let content =
|
|
@@ -848,7 +950,9 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
848
950
|
constraints: Type.Optional(
|
|
849
951
|
Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
|
|
850
952
|
),
|
|
851
|
-
context: Type.Optional(
|
|
953
|
+
context: Type.Optional(
|
|
954
|
+
Type.Number({ description: "Context lines before+after" }),
|
|
955
|
+
),
|
|
852
956
|
limit: Type.Optional(
|
|
853
957
|
Type.Number({
|
|
854
958
|
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
@@ -914,7 +1018,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
914
1018
|
},
|
|
915
1019
|
|
|
916
1020
|
renderCall(args, theme, context) {
|
|
917
|
-
const text =
|
|
1021
|
+
const text =
|
|
1022
|
+
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
918
1023
|
const patterns = args?.patterns ?? [];
|
|
919
1024
|
const constraints = args?.constraints;
|
|
920
1025
|
let content =
|
|
@@ -936,7 +1041,8 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
936
1041
|
// --- commands ---
|
|
937
1042
|
|
|
938
1043
|
pi.registerCommand("fff-mode", {
|
|
939
|
-
description:
|
|
1044
|
+
description:
|
|
1045
|
+
"Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
940
1046
|
handler: async (args, ctx) => {
|
|
941
1047
|
const arg = (args || "").trim();
|
|
942
1048
|
|
|
@@ -950,7 +1056,10 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
950
1056
|
|
|
951
1057
|
// Validate and set mode
|
|
952
1058
|
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
953
|
-
ctx.ui.notify(
|
|
1059
|
+
ctx.ui.notify(
|
|
1060
|
+
`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
|
|
1061
|
+
"warning",
|
|
1062
|
+
);
|
|
954
1063
|
return;
|
|
955
1064
|
}
|
|
956
1065
|
|
|
@@ -971,28 +1080,27 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
971
1080
|
pi.registerCommand("fff-health", {
|
|
972
1081
|
description: "Show FFF file finder health and status",
|
|
973
1082
|
handler: async (_args, ctx) => {
|
|
974
|
-
if (!
|
|
1083
|
+
if (!mainFinder || mainFinder.isDestroyed) {
|
|
975
1084
|
ctx.ui.notify("FFF not initialized", "warning");
|
|
976
1085
|
return;
|
|
977
1086
|
}
|
|
978
1087
|
|
|
979
|
-
const health =
|
|
1088
|
+
const health = mainFinder.healthCheck();
|
|
980
1089
|
if (!health.ok) {
|
|
981
1090
|
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
|
|
982
1091
|
return;
|
|
983
1092
|
}
|
|
984
1093
|
|
|
985
|
-
const h = health.value;
|
|
986
1094
|
const lines = [
|
|
987
|
-
`FFF v${
|
|
1095
|
+
`FFF v${health.value.version}`,
|
|
988
1096
|
`Mode: ${getMode()}`,
|
|
989
|
-
`Git: ${
|
|
990
|
-
`Picker: ${
|
|
991
|
-
`Frecency: ${
|
|
992
|
-
`Query tracker: ${
|
|
1097
|
+
`Git: ${health.value.git.repositoryFound ? `yes (${health.value.git.workdir ?? "unknown"})` : "no"}`,
|
|
1098
|
+
`Picker: ${health.value.filePicker.initialized ? `${health.value.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
1099
|
+
`Frecency: ${health.value.frecency.initialized ? "active" : "disabled"}`,
|
|
1100
|
+
`Query tracker: ${health.value.queryTracker.initialized ? "active" : "disabled"}`,
|
|
993
1101
|
];
|
|
994
1102
|
|
|
995
|
-
const progress =
|
|
1103
|
+
const progress = mainFinder.getScanProgress();
|
|
996
1104
|
if (progress.ok) {
|
|
997
1105
|
lines.push(
|
|
998
1106
|
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
|
@@ -1006,12 +1114,12 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
1006
1114
|
pi.registerCommand("fff-rescan", {
|
|
1007
1115
|
description: "Trigger FFF to rescan files",
|
|
1008
1116
|
handler: async (_args, ctx) => {
|
|
1009
|
-
if (!
|
|
1117
|
+
if (!mainFinder || mainFinder.isDestroyed) {
|
|
1010
1118
|
ctx.ui.notify("FFF not initialized", "warning");
|
|
1011
1119
|
return;
|
|
1012
1120
|
}
|
|
1013
1121
|
|
|
1014
|
-
const result =
|
|
1122
|
+
const result = mainFinder.scanFiles();
|
|
1015
1123
|
if (!result.ok) {
|
|
1016
1124
|
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
|
|
1017
1125
|
return;
|
package/src/query.ts
CHANGED
|
@@ -10,7 +10,11 @@ export function normalizePathConstraint(
|
|
|
10
10
|
if (path.isAbsolute(trimmed)) {
|
|
11
11
|
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
|
|
12
12
|
if (relative === "") return null;
|
|
13
|
-
if (
|
|
13
|
+
if (
|
|
14
|
+
relative.startsWith("../") ||
|
|
15
|
+
relative === ".." ||
|
|
16
|
+
path.isAbsolute(relative)
|
|
17
|
+
) {
|
|
14
18
|
throw new Error(
|
|
15
19
|
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
|
16
20
|
);
|
|
@@ -22,6 +26,9 @@ export function normalizePathConstraint(
|
|
|
22
26
|
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
|
|
23
27
|
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
|
24
28
|
|
|
29
|
+
// wif we left with the ** it means anything so treat it as a cwd path
|
|
30
|
+
if (trimmed === "**" || trimmed === "**/" || trimmed === "**/*") return null;
|
|
31
|
+
|
|
25
32
|
// FFF's glob matcher can treat a hidden directory root glob such as
|
|
26
33
|
// `.agents/**` as empty, while the tool contract says this means "inside
|
|
27
34
|
// this directory". Collapse simple trailing recursive directory globs to the
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FileFinderApi, InitOptions, Result } from "@ff-labs/fff-node";
|
|
2
|
+
|
|
3
|
+
export const SCAN_TIMEOUT_MS = 15_000;
|
|
4
|
+
|
|
5
|
+
/** pi can be run either under node or sdk, we resolve correct SDK version at runtime */
|
|
6
|
+
export type FileFinderStatic = {
|
|
7
|
+
create(options: InitOptions): Result<FileFinderApi>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null;
|
|
11
|
+
|
|
12
|
+
function detectRuntime(): "bun" | "node" {
|
|
13
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun";
|
|
14
|
+
if (
|
|
15
|
+
typeof process !== "undefined" &&
|
|
16
|
+
(process as { versions?: { bun?: string } }).versions?.bun
|
|
17
|
+
)
|
|
18
|
+
return "bun";
|
|
19
|
+
return "node";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
|
|
23
|
+
if (sdkPromise) return sdkPromise;
|
|
24
|
+
|
|
25
|
+
// default to node as it seems like default option
|
|
26
|
+
const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node";
|
|
27
|
+
sdkPromise = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>;
|
|
28
|
+
return sdkPromise;
|
|
29
|
+
}
|