@ff-labs/pi-fff 0.10.2-nightly.fbee146 → 0.10.3
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/README.md +2 -1
- package/package.json +3 -3
- package/src/aux-finders.ts +48 -15
- package/src/index.ts +157 -105
- package/src/paths.ts +9 -0
- package/src/query.ts +1 -5
package/README.md
CHANGED
|
@@ -134,7 +134,8 @@ Mode precedence:
|
|
|
134
134
|
- `--fff-mode <mode>` — set mode (see above)
|
|
135
135
|
- `--fff-frecency-db <path>` — path to frecency database (also: `FFF_FRECENCY_DB` env)
|
|
136
136
|
- `--fff-history-db <path>` — path to query history database (also: `FFF_HISTORY_DB` env)
|
|
137
|
-
- `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default.
|
|
137
|
+
- `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default.
|
|
138
|
+
- `--fff-enable-home-scan` — index the home directory when launched from `$HOME` (also: `FFF_ENABLE_HOME_SCAN` env). Enabled by default. Disable with `--fff-enable-home-scan=false` or `FFF_ENABLE_HOME_SCAN=0` if your `$HOME` contains huge trees (toolchains, kernel sources, build outputs) that make the background index run for a long time. When launched from `$HOME` with this enabled, pi shows a warning that the whole home tree is being indexed.
|
|
138
139
|
|
|
139
140
|
## Data
|
|
140
141
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ff-labs/pi-fff",
|
|
3
3
|
"public": true,
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.3",
|
|
5
5
|
"description": "pi extension: FFF-powered fuzzy file and content search",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"typecheck": "tsc --noEmit"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ff-labs/fff-bun": "
|
|
44
|
-
"@ff-labs/fff-node": "
|
|
43
|
+
"@ff-labs/fff-bun": "0.10.3",
|
|
44
|
+
"@ff-labs/fff-node": "0.10.3"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/aux-finders.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import type { FileFinderApi } from "@ff-labs/fff-node";
|
|
4
|
+
import { HOME_DIR } from "./paths";
|
|
5
5
|
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
|
|
6
6
|
|
|
7
7
|
export const MAX_AUX = 3;
|
|
@@ -15,10 +15,17 @@ interface AuxPicker {
|
|
|
15
15
|
|
|
16
16
|
export interface AuxOpts {
|
|
17
17
|
enableFsRootScanning: boolean;
|
|
18
|
+
enableHomeDirScanning?: boolean;
|
|
19
|
+
// Called before a newly spawned aux picker starts a scan that covers $HOME.
|
|
20
|
+
onHomeDirScan?: (root: string) => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export class AuxFinderPool {
|
|
21
24
|
private entries: AuxPicker[] = [];
|
|
25
|
+
// In-flight creations keyed by root. Concurrent acquire() calls for the same
|
|
26
|
+
// (or a covering) root share one finder/scan instead of each starting a full
|
|
27
|
+
// duplicate traversal — issue #746. Mirrors the main finder's finderPromise.
|
|
28
|
+
private pending = new Map<string, Promise<AuxPicker>>();
|
|
22
29
|
constructor(private opts: AuxOpts) {}
|
|
23
30
|
|
|
24
31
|
destroy(): void {
|
|
@@ -27,6 +34,7 @@ export class AuxFinderPool {
|
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
this.entries = [];
|
|
37
|
+
this.pending.clear();
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
private sweepIdle(now = Date.now()): void {
|
|
@@ -58,32 +66,60 @@ export class AuxFinderPool {
|
|
|
58
66
|
return { finder: covering.finder, root: covering.root };
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
// Coalesce concurrent creations for the same root so we scan once. A slow
|
|
70
|
+
// full-home scan started by one call is awaited by the others instead of
|
|
71
|
+
// each spawning its own traversal (#746).
|
|
72
|
+
const inflight = this.pending.get(maybeRoot);
|
|
73
|
+
if (inflight) {
|
|
74
|
+
const e = await inflight;
|
|
75
|
+
e.lastUsed = Date.now();
|
|
76
|
+
return { finder: e.finder, root: e.root };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const creation = this.create(maybeRoot).finally(() => {
|
|
80
|
+
this.pending.delete(maybeRoot);
|
|
81
|
+
});
|
|
82
|
+
this.pending.set(maybeRoot, creation);
|
|
83
|
+
const entry = await creation;
|
|
84
|
+
return { finder: entry.finder, root: entry.root };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async create(root: string): Promise<AuxPicker> {
|
|
61
88
|
if (this.entries.length >= MAX_AUX) {
|
|
62
89
|
let oldest = this.entries[0];
|
|
63
|
-
for (const e of this.entries)
|
|
64
|
-
if (e.lastUsed < oldest.lastUsed) oldest = e;
|
|
90
|
+
for (const e of this.entries) if (e.lastUsed < oldest.lastUsed) oldest = e;
|
|
65
91
|
if (!oldest.finder.isDestroyed) oldest.finder.destroy();
|
|
66
92
|
this.entries = this.entries.filter((e) => e !== oldest);
|
|
67
93
|
}
|
|
68
94
|
|
|
95
|
+
const enableHomeDirScanning = this.opts.enableHomeDirScanning ?? true;
|
|
96
|
+
// A fresh picker rooted at (or above) $HOME walks the whole home tree, so
|
|
97
|
+
// the user gets told every time the agent spawns one — see issue #743.
|
|
98
|
+
if (enableHomeDirScanning && rootCovers(root, HOME_DIR)) {
|
|
99
|
+
this.opts.onHomeDirScan?.(root);
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
const { FileFinder } = await loadSdk();
|
|
70
103
|
// LMDB env can only be opened once per process; the main finder already
|
|
71
104
|
// owns the frecency/history DBs. Aux finders are transient and run without
|
|
72
105
|
// persistent scoring — see issue #700.
|
|
73
106
|
const result = FileFinder.create({
|
|
74
|
-
basePath:
|
|
107
|
+
basePath: root,
|
|
75
108
|
aiMode: true,
|
|
76
|
-
enableHomeDirScanning
|
|
109
|
+
enableHomeDirScanning,
|
|
77
110
|
enableFsRootScanning: this.opts.enableFsRootScanning,
|
|
78
111
|
});
|
|
79
112
|
if (!result.ok)
|
|
80
|
-
throw new Error(
|
|
81
|
-
`Failed to create aux file finder for ${maybeRoot}: ${result.error}`,
|
|
82
|
-
);
|
|
113
|
+
throw new Error(`Failed to create aux file finder for ${root}: ${result.error}`);
|
|
83
114
|
|
|
84
115
|
await result.value.waitForScan(SCAN_TIMEOUT_MS);
|
|
85
|
-
|
|
86
|
-
|
|
116
|
+
const entry: AuxPicker = {
|
|
117
|
+
root,
|
|
118
|
+
finder: result.value,
|
|
119
|
+
lastUsed: Date.now(),
|
|
120
|
+
};
|
|
121
|
+
this.entries.push(entry);
|
|
122
|
+
return entry;
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
size(): number {
|
|
@@ -96,9 +132,7 @@ export class AuxFinderPool {
|
|
|
96
132
|
// remainder usable as a fuzzy path constraint relative to that root. Glob and
|
|
97
133
|
// nonexistent segments both go into the suffix: we walk up to the nearest
|
|
98
134
|
// existing ancestor so partially-wrong paths still resolve to a search root.
|
|
99
|
-
export function resolveAuxRoot(
|
|
100
|
-
absPath: string,
|
|
101
|
-
): { root: string; suffix: string } | null {
|
|
135
|
+
export function resolveAuxRoot(absPath: string): { root: string; suffix: string } | null {
|
|
102
136
|
const trimmed = path.normalize(absPath.trim()).replace(/\/+$/, "") || "/";
|
|
103
137
|
if (!path.isAbsolute(trimmed)) return null;
|
|
104
138
|
if (trimmed === path.sep) return { root: path.sep, suffix: "" };
|
|
@@ -147,7 +181,7 @@ export function routePathConstraint(
|
|
|
147
181
|
let candidate = pathConstraint.trim();
|
|
148
182
|
if (!candidate) return null;
|
|
149
183
|
if (candidate === "~" || candidate.startsWith("~/"))
|
|
150
|
-
candidate = path.join(
|
|
184
|
+
candidate = path.join(HOME_DIR, candidate.slice(1));
|
|
151
185
|
if (!path.isAbsolute(candidate)) {
|
|
152
186
|
// Plain workspace-relative constraints stay on the workspace finder.
|
|
153
187
|
if (candidate !== ".." && !candidate.startsWith("../")) return null;
|
|
@@ -158,7 +192,6 @@ export function routePathConstraint(
|
|
|
158
192
|
return resolveAuxRoot(candidate);
|
|
159
193
|
}
|
|
160
194
|
|
|
161
|
-
|
|
162
195
|
export function rootCovers(root: string, target: string): boolean {
|
|
163
196
|
if (root === target) return true;
|
|
164
197
|
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { Type } from "@sinclair/typebox";
|
|
24
24
|
import { AuxFinderPool, routePathConstraint } from "./aux-finders";
|
|
25
25
|
import { buildQuery } from "./query";
|
|
26
|
+
import { isHomeDir } from "./paths";
|
|
26
27
|
import { loadSdk, SCAN_TIMEOUT_MS } from "./sdk";
|
|
27
28
|
|
|
28
29
|
export { SCAN_TIMEOUT_MS } from "./sdk";
|
|
@@ -36,6 +37,14 @@ const DEFAULT_FIND_LIMIT = 30;
|
|
|
36
37
|
const GREP_MAX_LINE_LENGTH = 500;
|
|
37
38
|
const MENTION_MAX_RESULTS = 20;
|
|
38
39
|
|
|
40
|
+
// If we exceed 10 seconds for indexed grep - something is definitely off
|
|
41
|
+
const GREP_TIME_BUDGET_MS = 10_000;
|
|
42
|
+
|
|
43
|
+
const HOME_SCAN_STATUS_KEY = "fff";
|
|
44
|
+
const HOME_SCAN_POLL_MS = 1_000;
|
|
45
|
+
const HOME_SCAN_DISABLE_HINT =
|
|
46
|
+
"You can prevent home dir indexing with --fff-enable-home-scan=false (or FFF_ENABLE_HOME_SCAN=0).";
|
|
47
|
+
|
|
39
48
|
type FffMode = "tools-and-ui" | "tools-only" | "override";
|
|
40
49
|
|
|
41
50
|
const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
|
|
@@ -261,9 +270,7 @@ function createFffMentionProvider(
|
|
|
261
270
|
|
|
262
271
|
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
263
272
|
const items = await getItems(query, options.signal);
|
|
264
|
-
return options.signal.aborted || items.length === 0
|
|
265
|
-
? null
|
|
266
|
-
: { items, prefix };
|
|
273
|
+
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
|
|
267
274
|
},
|
|
268
275
|
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
269
276
|
const currentLine = _lines[cursorLine] || "";
|
|
@@ -272,11 +279,7 @@ function createFffMentionProvider(
|
|
|
272
279
|
const newLine = before + item.value + after;
|
|
273
280
|
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
274
281
|
return {
|
|
275
|
-
lines: [
|
|
276
|
-
..._lines.slice(0, cursorLine),
|
|
277
|
-
newLine,
|
|
278
|
-
..._lines.slice(cursorLine + 1),
|
|
279
|
-
],
|
|
282
|
+
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
|
|
280
283
|
cursorLine,
|
|
281
284
|
cursorCol: newCursorCol,
|
|
282
285
|
};
|
|
@@ -316,20 +319,32 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
316
319
|
process.env.FFF_HISTORY_DB ??
|
|
317
320
|
undefined;
|
|
318
321
|
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
322
|
+
// flag (boolean) > env ("1"/"true", or "0"/"false") > default.
|
|
323
|
+
function resolveBoolOpt(
|
|
324
|
+
flagName: string,
|
|
325
|
+
envName: string,
|
|
326
|
+
fallback = false,
|
|
327
|
+
): boolean {
|
|
323
328
|
const flag = pi.getFlag(flagName);
|
|
324
329
|
if (typeof flag === "boolean") return flag;
|
|
325
330
|
if (typeof flag === "string") return flag === "true" || flag === "1";
|
|
326
331
|
const env = process.env[envName];
|
|
327
|
-
|
|
332
|
+
if (env === "1" || env === "true") return true;
|
|
333
|
+
if (env === "0" || env === "false") return false;
|
|
334
|
+
return fallback;
|
|
328
335
|
}
|
|
336
|
+
// Root scanning opt-in: FFF refuses to init at / unless this is set.
|
|
329
337
|
const enableFsRootScanning = resolveBoolOpt(
|
|
330
338
|
"fff-enable-root-scan",
|
|
331
339
|
"FFF_ENABLE_ROOT_SCAN",
|
|
332
340
|
);
|
|
341
|
+
// Home dir scanning is on by default (launching pi from $HOME is a normal
|
|
342
|
+
// flow), but configurable so users with huge $HOME trees can opt out.
|
|
343
|
+
const enableHomeDirScanning = resolveBoolOpt(
|
|
344
|
+
"fff-enable-home-scan",
|
|
345
|
+
"FFF_ENABLE_HOME_SCAN",
|
|
346
|
+
true,
|
|
347
|
+
);
|
|
333
348
|
|
|
334
349
|
function getMode(): FffMode {
|
|
335
350
|
return currentMode;
|
|
@@ -343,8 +358,27 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
343
358
|
return currentMode !== "tools-only";
|
|
344
359
|
}
|
|
345
360
|
|
|
361
|
+
// Set on session_start; the only handle to the UI outside an event handler.
|
|
362
|
+
// setStatus is TUI/RPC-only, hence optional.
|
|
363
|
+
let uiCtx: {
|
|
364
|
+
ui: {
|
|
365
|
+
notify: (message: string, type?: "info" | "warning" | "error") => void;
|
|
366
|
+
setStatus?: (key: string, text: string | undefined) => void;
|
|
367
|
+
};
|
|
368
|
+
} | null = null;
|
|
369
|
+
let homeScanTimer: ReturnType<typeof setInterval> | null = null;
|
|
370
|
+
|
|
371
|
+
function warnHomeDirScan(root: string): void {
|
|
372
|
+
uiCtx?.ui.notify(
|
|
373
|
+
`(fff): Your cwd (${root}) is too large. Indexing will take additional time and resources.\n${HOME_SCAN_DISABLE_HINT}`,
|
|
374
|
+
"warning",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
346
378
|
let auxPool = new AuxFinderPool({
|
|
347
379
|
enableFsRootScanning,
|
|
380
|
+
enableHomeDirScanning,
|
|
381
|
+
onHomeDirScan: warnHomeDirScan,
|
|
348
382
|
});
|
|
349
383
|
|
|
350
384
|
// in case cwd changes we need to figure this out
|
|
@@ -367,7 +401,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
367
401
|
frecencyDbPath,
|
|
368
402
|
historyDbPath,
|
|
369
403
|
aiMode: true,
|
|
370
|
-
enableHomeDirScanning
|
|
404
|
+
enableHomeDirScanning,
|
|
371
405
|
enableFsRootScanning,
|
|
372
406
|
});
|
|
373
407
|
|
|
@@ -385,7 +419,40 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
385
419
|
return finderPromise;
|
|
386
420
|
}
|
|
387
421
|
|
|
422
|
+
function stopHomeScanStatus(): void {
|
|
423
|
+
if (homeScanTimer) {
|
|
424
|
+
clearInterval(homeScanTimer);
|
|
425
|
+
homeScanTimer = null;
|
|
426
|
+
}
|
|
427
|
+
uiCtx?.ui.setStatus?.(HOME_SCAN_STATUS_KEY, undefined);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// waitForScan() resolves on timeout too, so the scan can still be running.
|
|
431
|
+
// Poll the live progress until it settles, then clear the footer.
|
|
432
|
+
function trackHomeScanStatus(): void {
|
|
433
|
+
stopHomeScanStatus();
|
|
434
|
+
if (!uiCtx?.ui.setStatus) return;
|
|
435
|
+
|
|
436
|
+
const tick = () => {
|
|
437
|
+
const progress = mainFinder?.getScanProgress?.();
|
|
438
|
+
if (!progress?.ok || !progress.value.isScanning) {
|
|
439
|
+
stopHomeScanStatus();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
uiCtx?.ui.setStatus?.(
|
|
443
|
+
HOME_SCAN_STATUS_KEY,
|
|
444
|
+
`Agent is indexing $HOME (${progress.value.scannedFilesCount} files), this can lead to high CPU`,
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
homeScanTimer = setInterval(tick, HOME_SCAN_POLL_MS);
|
|
449
|
+
// Must not hold the process open once pi is done.
|
|
450
|
+
(homeScanTimer as { unref?: () => void }).unref?.();
|
|
451
|
+
tick();
|
|
452
|
+
}
|
|
453
|
+
|
|
388
454
|
function destroyFinder() {
|
|
455
|
+
stopHomeScanStatus();
|
|
389
456
|
if (mainFinder && !mainFinder.isDestroyed) {
|
|
390
457
|
mainFinder.destroy();
|
|
391
458
|
mainFinder = null;
|
|
@@ -407,9 +474,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
407
474
|
const aux = await auxPool.acquire(route.root);
|
|
408
475
|
// A broader covering picker may have been reused; rebase the suffix so the
|
|
409
476
|
// constraint stays relative to the picker's actual root.
|
|
410
|
-
const rebase = nodePath
|
|
411
|
-
.relative(aux.root, route.root)
|
|
412
|
-
.replaceAll(nodePath.sep, "/");
|
|
477
|
+
const rebase = nodePath.relative(aux.root, route.root).replaceAll(nodePath.sep, "/");
|
|
413
478
|
const suffix = [rebase, route.suffix].filter(Boolean).join("/");
|
|
414
479
|
const query = buildQuery(suffix || undefined, pattern, exclude, aux.root);
|
|
415
480
|
return { finder: aux.finder, query, root: aux.root };
|
|
@@ -426,22 +491,20 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
426
491
|
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
427
492
|
if (!result.ok) return [];
|
|
428
493
|
|
|
429
|
-
return result.value.items
|
|
430
|
-
.
|
|
431
|
-
.map((mixed: MixedItem) => {
|
|
432
|
-
if (mixed.type === "directory") {
|
|
433
|
-
return {
|
|
434
|
-
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
435
|
-
label: mixed.item.dirName,
|
|
436
|
-
description: mixed.item.relativePath,
|
|
437
|
-
};
|
|
438
|
-
}
|
|
494
|
+
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
|
|
495
|
+
if (mixed.type === "directory") {
|
|
439
496
|
return {
|
|
440
497
|
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
441
|
-
label: mixed.item.
|
|
498
|
+
label: mixed.item.dirName,
|
|
442
499
|
description: mixed.item.relativePath,
|
|
443
500
|
};
|
|
444
|
-
}
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
504
|
+
label: mixed.item.fileName,
|
|
505
|
+
description: mixed.item.relativePath,
|
|
506
|
+
};
|
|
507
|
+
});
|
|
445
508
|
}
|
|
446
509
|
|
|
447
510
|
function registerAutocompleteProvider(ctx: {
|
|
@@ -477,21 +540,11 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
477
540
|
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
478
541
|
},
|
|
479
542
|
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
480
|
-
return current.applyCompletion(
|
|
481
|
-
lines,
|
|
482
|
-
cursorLine,
|
|
483
|
-
cursorCol,
|
|
484
|
-
item,
|
|
485
|
-
prefix,
|
|
486
|
-
);
|
|
543
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
487
544
|
},
|
|
488
545
|
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
489
546
|
return (
|
|
490
|
-
current.shouldTriggerFileCompletion?.(
|
|
491
|
-
lines,
|
|
492
|
-
cursorLine,
|
|
493
|
-
cursorCol,
|
|
494
|
-
) ?? true
|
|
547
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true
|
|
495
548
|
);
|
|
496
549
|
},
|
|
497
550
|
};
|
|
@@ -506,14 +559,12 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
506
559
|
});
|
|
507
560
|
|
|
508
561
|
pi.registerFlag("fff-frecency-db", {
|
|
509
|
-
description:
|
|
510
|
-
"Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
562
|
+
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
511
563
|
type: "string",
|
|
512
564
|
});
|
|
513
565
|
|
|
514
566
|
pi.registerFlag("fff-history-db", {
|
|
515
|
-
description:
|
|
516
|
-
"Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
567
|
+
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
517
568
|
type: "string",
|
|
518
569
|
});
|
|
519
570
|
|
|
@@ -523,9 +574,16 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
523
574
|
type: "boolean",
|
|
524
575
|
});
|
|
525
576
|
|
|
577
|
+
pi.registerFlag("fff-enable-home-scan", {
|
|
578
|
+
description:
|
|
579
|
+
"Index the home dir when launched from $HOME (default true; disable with --fff-enable-home-scan=false or FFF_ENABLE_HOME_SCAN=0)",
|
|
580
|
+
type: "boolean",
|
|
581
|
+
});
|
|
582
|
+
|
|
526
583
|
pi.on("session_start", async (_event, ctx) => {
|
|
527
584
|
try {
|
|
528
585
|
activeCwd = ctx.cwd;
|
|
586
|
+
uiCtx = ctx as unknown as typeof uiCtx;
|
|
529
587
|
|
|
530
588
|
// Restore persisted mode from session entries. This handles session
|
|
531
589
|
// resume after process restart where env vars are lost, and ensures
|
|
@@ -552,6 +610,21 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
552
610
|
|
|
553
611
|
registerAutocompleteProvider(ctx);
|
|
554
612
|
await ensureFinder(activeCwd);
|
|
613
|
+
|
|
614
|
+
// Warn when launched from $HOME with home scanning on: indexing a large
|
|
615
|
+
// home tree can run for a long time in the background (issue #743).
|
|
616
|
+
const atHome = enableHomeDirScanning && isHomeDir(activeCwd);
|
|
617
|
+
if (atHome) {
|
|
618
|
+
warnHomeDirScan(activeCwd);
|
|
619
|
+
ctx.ui.setStatus?.(
|
|
620
|
+
HOME_SCAN_STATUS_KEY,
|
|
621
|
+
"Agent is indexing $HOME, this can lead to high CPU",
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// waitForScan() also resolves on timeout, so poll until the scan really
|
|
626
|
+
// settles before clearing the footer.
|
|
627
|
+
if (atHome) trackHomeScanStatus();
|
|
555
628
|
} catch (e: unknown) {
|
|
556
629
|
ctx.ui.notify(
|
|
557
630
|
`FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -573,20 +646,15 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
573
646
|
context: any,
|
|
574
647
|
maxLines = 15,
|
|
575
648
|
) => {
|
|
576
|
-
const text =
|
|
577
|
-
|
|
578
|
-
const output =
|
|
579
|
-
result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
649
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
650
|
+
const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
580
651
|
if (!output) {
|
|
581
652
|
text.setText(theme.fg("muted", "No output"));
|
|
582
653
|
return text;
|
|
583
654
|
}
|
|
584
655
|
|
|
585
656
|
const lines = output.split("\n");
|
|
586
|
-
const displayLines = lines.slice(
|
|
587
|
-
0,
|
|
588
|
-
options.expanded ? lines.length : maxLines,
|
|
589
|
-
);
|
|
657
|
+
const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
|
|
590
658
|
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
591
659
|
if (lines.length > displayLines.length) {
|
|
592
660
|
content += theme.fg(
|
|
@@ -641,10 +709,10 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
641
709
|
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}.`,
|
|
642
710
|
promptSnippet: "Grep contents",
|
|
643
711
|
promptGuidelines: [
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
712
|
+
`${toolNames.grep}: prefer bare identifiers as patterns. Literal queries are most efficient.`,
|
|
713
|
+
`${toolNames.grep}: use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').`,
|
|
714
|
+
`${toolNames.grep}: caseSensitive: true when you need exact case (smart-case otherwise).`,
|
|
715
|
+
`${toolNames.grep}: after 1-2 greps, read the top match instead of more greps.`,
|
|
648
716
|
],
|
|
649
717
|
parameters: grepSchema,
|
|
650
718
|
|
|
@@ -652,11 +720,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
652
720
|
if (signal?.aborted) throw new Error("Operation aborted");
|
|
653
721
|
|
|
654
722
|
const pattern = params.pattern;
|
|
655
|
-
const aux = await resolveFinderForPath(
|
|
656
|
-
params.path,
|
|
657
|
-
pattern,
|
|
658
|
-
params.exclude,
|
|
659
|
-
);
|
|
723
|
+
const aux = await resolveFinderForPath(params.path, pattern, params.exclude);
|
|
660
724
|
|
|
661
725
|
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
|
|
662
726
|
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
@@ -667,8 +731,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
667
731
|
// Auto-detect: regex if the pattern has regex metacharacters AND parses
|
|
668
732
|
// as a valid regex, otherwise plain literal. The fuzzy fallback below
|
|
669
733
|
// only kicks in for plain mode — regex queries are intentional.
|
|
670
|
-
const hasRegexSyntax =
|
|
671
|
-
pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
734
|
+
const hasRegexSyntax = pattern !== pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
672
735
|
|
|
673
736
|
let mode: GrepMode = hasRegexSyntax ? "regex" : "plain";
|
|
674
737
|
if (mode === "regex") {
|
|
@@ -713,6 +776,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
713
776
|
beforeContext: params.context ?? 0,
|
|
714
777
|
afterContext: params.context ?? 0,
|
|
715
778
|
classifyDefinitions: true,
|
|
779
|
+
timeBudgetMs: GREP_TIME_BUDGET_MS,
|
|
716
780
|
});
|
|
717
781
|
|
|
718
782
|
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
@@ -720,8 +784,14 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
720
784
|
let result = grepResult.value;
|
|
721
785
|
let fuzzyNotice: string | null = null;
|
|
722
786
|
|
|
723
|
-
//
|
|
724
|
-
|
|
787
|
+
// if we hit the timeout do not run the fuzzy fallback
|
|
788
|
+
// cause it will only consumer more time
|
|
789
|
+
if (
|
|
790
|
+
result.items.length === 0 &&
|
|
791
|
+
!result.nextCursor &&
|
|
792
|
+
!params.cursor &&
|
|
793
|
+
mode !== "regex"
|
|
794
|
+
) {
|
|
725
795
|
// When the caller pinned a specific file (path has an extension), the
|
|
726
796
|
// fuzzy fallback broadens across the whole picker — the file may just
|
|
727
797
|
// be misnamed. For directory constraints (or no path), we keep the
|
|
@@ -738,6 +808,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
738
808
|
beforeContext: 0,
|
|
739
809
|
afterContext: 0,
|
|
740
810
|
classifyDefinitions: true,
|
|
811
|
+
timeBudgetMs: GREP_TIME_BUDGET_MS,
|
|
741
812
|
});
|
|
742
813
|
|
|
743
814
|
if (fuzzy.ok && fuzzy.value.items.length > 0) {
|
|
@@ -749,14 +820,10 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
749
820
|
let output = formatGrepOutput(result);
|
|
750
821
|
const notices: string[] = [];
|
|
751
822
|
if (result.regexFallbackError) {
|
|
752
|
-
notices.push(
|
|
753
|
-
`Invalid regex: ${result.regexFallbackError}, used literal match`,
|
|
754
|
-
);
|
|
823
|
+
notices.push(`Invalid regex: ${result.regexFallbackError}, used literal match`);
|
|
755
824
|
}
|
|
756
825
|
if (result.nextCursor) {
|
|
757
|
-
notices.push(
|
|
758
|
-
`Continue with cursor="${storeCursor(result.nextCursor)}"`,
|
|
759
|
-
);
|
|
826
|
+
notices.push(`Continue with cursor="${storeCursor(result.nextCursor)}"`);
|
|
760
827
|
}
|
|
761
828
|
|
|
762
829
|
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
@@ -772,8 +839,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
772
839
|
},
|
|
773
840
|
|
|
774
841
|
renderCall(args, theme, context) {
|
|
775
|
-
const text =
|
|
776
|
-
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
842
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
777
843
|
const pattern = args?.pattern ?? "";
|
|
778
844
|
const path = args?.path ?? ".";
|
|
779
845
|
let content =
|
|
@@ -828,12 +894,12 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
828
894
|
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}.`,
|
|
829
895
|
promptSnippet: "Find files by path or glob",
|
|
830
896
|
promptGuidelines: [
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
897
|
+
`${toolNames.find}: matches the WHOLE path, not just the filename — \`profile\` hits \`chrome/browser/profiles/x.cc\` too.`,
|
|
898
|
+
`${toolNames.find}: keep queries to 1-2 terms; extra words narrow.`,
|
|
899
|
+
`${toolNames.find}: use for paths, not content. Use ${toolNames.grep} for content.`,
|
|
900
|
+
`${toolNames.find}: 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.`,
|
|
901
|
+
`${toolNames.find}: to list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.`,
|
|
902
|
+
`${toolNames.find}: use exclude: 'test/,*.min.js' to cut noise in large repos.`,
|
|
837
903
|
],
|
|
838
904
|
parameters: findSchema,
|
|
839
905
|
|
|
@@ -845,16 +911,11 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
845
911
|
const aux = resumed
|
|
846
912
|
? resumed.auxRoot
|
|
847
913
|
? {
|
|
848
|
-
finder: (await auxPool.acquire(resumed.auxRoot, { exact: true }))
|
|
849
|
-
.finder,
|
|
914
|
+
finder: (await auxPool.acquire(resumed.auxRoot, { exact: true })).finder,
|
|
850
915
|
root: resumed.auxRoot,
|
|
851
916
|
}
|
|
852
917
|
: null
|
|
853
|
-
: await resolveFinderForPath(
|
|
854
|
-
params.path,
|
|
855
|
-
params.pattern,
|
|
856
|
-
params.exclude,
|
|
857
|
-
);
|
|
918
|
+
: await resolveFinderForPath(params.path, params.pattern, params.exclude);
|
|
858
919
|
|
|
859
920
|
const picker = aux ? aux.finder : await ensureFinder(activeCwd);
|
|
860
921
|
const effectiveLimit = resumed
|
|
@@ -886,8 +947,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
886
947
|
// shown so far there's another page to fetch.
|
|
887
948
|
const shownSoFar = pageIndex * effectiveLimit + result.items.length;
|
|
888
949
|
const hasMore =
|
|
889
|
-
result.items.length >= effectiveLimit &&
|
|
890
|
-
result.totalMatched > shownSoFar;
|
|
950
|
+
result.items.length >= effectiveLimit && result.totalMatched > shownSoFar;
|
|
891
951
|
|
|
892
952
|
const notices: string[] = [];
|
|
893
953
|
if (formatted.weak && formatted.shownCount > 0)
|
|
@@ -922,8 +982,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
922
982
|
},
|
|
923
983
|
|
|
924
984
|
renderCall(args, theme, context) {
|
|
925
|
-
const text =
|
|
926
|
-
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
985
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
927
986
|
const pattern = args?.pattern ?? "";
|
|
928
987
|
const path = args?.path ?? ".";
|
|
929
988
|
let content =
|
|
@@ -956,9 +1015,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
956
1015
|
constraints: Type.Optional(
|
|
957
1016
|
Type.String({ description: "File filter, e.g. '*.{ts,tsx} !test/'" }),
|
|
958
1017
|
),
|
|
959
|
-
context: Type.Optional(
|
|
960
|
-
Type.Number({ description: "Context lines before+after" }),
|
|
961
|
-
),
|
|
1018
|
+
context: Type.Optional(Type.Number({ description: "Context lines before+after" })),
|
|
962
1019
|
limit: Type.Optional(
|
|
963
1020
|
Type.Number({
|
|
964
1021
|
description: `Max matches (default ${DEFAULT_GREP_LIMIT})`,
|
|
@@ -974,9 +1031,9 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
974
1031
|
"Search file contents for ANY of multiple literal patterns (OR, SIMD Aho-Corasick). Faster than regex alternation.",
|
|
975
1032
|
promptSnippet: "Multi-pattern OR content search",
|
|
976
1033
|
promptGuidelines: [
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1034
|
+
`${toolNames.multiGrep}: use when searching for several identifiers at once.`,
|
|
1035
|
+
`${toolNames.multiGrep}: include all naming-convention variants (snake/camel/Pascal).`,
|
|
1036
|
+
`${toolNames.multiGrep}: patterns are literal. Use constraints for file filters.`,
|
|
980
1037
|
],
|
|
981
1038
|
parameters: multiGrepSchema,
|
|
982
1039
|
|
|
@@ -1024,8 +1081,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
1024
1081
|
},
|
|
1025
1082
|
|
|
1026
1083
|
renderCall(args, theme, context) {
|
|
1027
|
-
const text =
|
|
1028
|
-
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
1084
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
1029
1085
|
const patterns = args?.patterns ?? [];
|
|
1030
1086
|
const constraints = args?.constraints;
|
|
1031
1087
|
let content =
|
|
@@ -1047,8 +1103,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
1047
1103
|
// --- commands ---
|
|
1048
1104
|
|
|
1049
1105
|
pi.registerCommand("fff-mode", {
|
|
1050
|
-
description:
|
|
1051
|
-
"Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
1106
|
+
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
1052
1107
|
handler: async (args, ctx) => {
|
|
1053
1108
|
const arg = (args || "").trim();
|
|
1054
1109
|
|
|
@@ -1062,10 +1117,7 @@ export default function fffExtension(pi: ExtensionAPI) {
|
|
|
1062
1117
|
|
|
1063
1118
|
// Validate and set mode
|
|
1064
1119
|
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
1065
|
-
ctx.ui.notify(
|
|
1066
|
-
`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`,
|
|
1067
|
-
"warning",
|
|
1068
|
-
);
|
|
1120
|
+
ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
|
|
1069
1121
|
return;
|
|
1070
1122
|
}
|
|
1071
1123
|
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Resolved once per process: os.homedir() hits the env/passwd on every call.
|
|
5
|
+
export const HOME_DIR = path.resolve(os.homedir());
|
|
6
|
+
|
|
7
|
+
export function isHomeDir(dir: string): boolean {
|
|
8
|
+
return path.resolve(dir) === HOME_DIR;
|
|
9
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -10,11 +10,7 @@ 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 (
|
|
14
|
-
relative.startsWith("../") ||
|
|
15
|
-
relative === ".." ||
|
|
16
|
-
path.isAbsolute(relative)
|
|
17
|
-
) {
|
|
13
|
+
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
|
|
18
14
|
throw new Error(
|
|
19
15
|
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
|
20
16
|
);
|