@ff-labs/pi-fff 0.10.2-nightly.fde8c52 → 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 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. Home directory scanning is always enabled for pi.
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.2-nightly.fde8c52",
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": "*",
@@ -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: maybeRoot,
107
+ basePath: root,
75
108
  aiMode: true,
76
- enableHomeDirScanning: true,
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
- this.entries.push({ root: maybeRoot, finder: result.value, lastUsed: Date.now() });
86
- return { finder: result.value, root: maybeRoot };
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(os.homedir(), candidate.slice(1));
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
- // Root scanning opt-in: flag (boolean) > env ("1"/"true") > false.
320
- // FFF refuses to init at / unless this is set. Home dir scanning is on by
321
- // default for pi — launching pi from $HOME is a normal flow.
322
- function resolveBoolOpt(flagName: string, envName: string): boolean {
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
- return env === "1" || env === "true";
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: true,
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
- .slice(0, MENTION_MAX_RESULTS)
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.fileName,
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
- (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
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
- "Prefer bare identifiers as patterns. Literal queries are most efficient.",
645
- "Use path for include ('src/', '*.ts') and exclude for noise ('test/,*.min.js').",
646
- "caseSensitive: true when you need exact case (smart-case otherwise).",
647
- "After 1-2 greps, read the top match instead of more greps.",
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
- // automatic fuzzy fallback allows to broad the queries and find different cases
724
- if (result.items.length === 0 && !params.cursor && mode !== "regex") {
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
- "Matches the WHOLE path, not just the filename — `profile` hits `chrome/browser/profiles/x.cc` too.",
832
- "Keep queries to 1-2 terms; extra words narrow.",
833
- "Use for paths, not content. Use grep for content.",
834
- "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.",
835
- "To list everything inside a directory, pass path: 'dir/**' with an empty or wildcard pattern instead of using pattern alone.",
836
- "Use exclude: 'test/,*.min.js' to cut noise in large repos.",
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
- "Use when searching for several identifiers at once.",
978
- "Include all naming-convention variants (snake/camel/Pascal).",
979
- "Patterns are literal. Use constraints for file filters.",
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
  );