@ff-labs/pi-fff 0.10.7-nightly.f3647b7 → 0.11.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/README.md CHANGED
@@ -162,6 +162,8 @@ All fields are optional:
162
162
  | `warnOnHomeDirScan` | boolean | `true` |
163
163
  | `followSymlinks` | boolean | `true` |
164
164
 
165
+ Starting a session in a directory the config opts out of indexing (`$HOME` with `enableHomeDirScanning: false`, `/` with `enableFsRootScanning: false`) disables FFF workspace search for that session: the extension reports it once as a warning and keeps the `ffgrep`/`fffind` names even in `override` mode, so pi's built-in `grep`/`find` stay reachable.
166
+
165
167
  CLI flags take precedence over environment variables, which take precedence over this file. A missing file is ignored. Malformed JSON, unknown fields, and invalid values stop the extension from loading and report the file path and error. `/fff-mode` changes the current session; it does not edit this file.
166
168
 
167
169
  The file is global only. Project-level config cannot safely control tool names because pi decides which tools an extension registers before project configuration can be trusted.
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.7-nightly.f3647b7",
4
+ "version": "0.11.0",
5
5
  "description": "pi extension: FFF-powered fuzzy file and content search",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -41,8 +41,8 @@
41
41
  "typecheck": "tsc --noEmit"
42
42
  },
43
43
  "dependencies": {
44
- "@ff-labs/fff-bun": "0.10.7-nightly.f3647b7",
45
- "@ff-labs/fff-node": "0.10.7-nightly.f3647b7"
44
+ "@ff-labs/fff-bun": "0.11.0",
45
+ "@ff-labs/fff-node": "0.11.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "*",
package/src/index.ts CHANGED
@@ -14,7 +14,10 @@ import type {
14
14
  import {
15
15
  type AutocompleteItem,
16
16
  type AutocompleteProvider,
17
+ type Component,
18
+ sliceByColumn,
17
19
  Text,
20
+ visibleWidth,
18
21
  } from "@earendil-works/pi-tui";
19
22
  import type {
20
23
  FileFinderApi,
@@ -28,7 +31,7 @@ import { Type, type TSchema } from "@sinclair/typebox";
28
31
  import { AuxFinderPool, routePathConstraint } from "./aux-finders";
29
32
  import { type FffMode, loadConfig, VALID_MODES } from "./config";
30
33
  import { FilePickerFactory } from "./file-picker";
31
- import { isHomeDir, resolveDbPaths } from "./paths";
34
+ import { isFsRoot, isHomeDir, resolveDbPaths } from "./paths";
32
35
  import { buildQuery } from "./query";
33
36
 
34
37
  export { SCAN_TIMEOUT_MS } from "./sdk";
@@ -78,6 +81,10 @@ function resolveToolNames(mode: FffMode): ToolNames {
78
81
  return mode === "override" ? OVERRIDE_TOOL_NAMES : FFF_TOOL_NAMES;
79
82
  }
80
83
 
84
+ function toolNameList(names: ToolNames): string[] {
85
+ return [names.grep, names.find, names.multiGrep];
86
+ }
87
+
81
88
  // ---------------------------------------------------------------------------
82
89
  // Cursor store — simple bounded Map for pagination cursors
83
90
  // ---------------------------------------------------------------------------
@@ -460,8 +467,22 @@ export default function fffExtension(pi: ExtensionAPI) {
460
467
  });
461
468
  }
462
469
 
470
+ // The native layer refuses a picker rooted at $HOME / the fs root unless the
471
+ // matching opt-in is set (crates/fff-core/src/file_picker.rs). Detect that here
472
+ // so the opt-out reads as "search off" instead of an init failure (issue #857).
473
+ function scanOptOutReason(cwd: string): string | null {
474
+ if (!enableHomeDirScanning && isHomeDir(cwd))
475
+ return `(fff): cwd is $HOME and "enableHomeDirScanning" is false, so FFF search is disabled for this session. Start pi from a project directory, or set "enableHomeDirScanning": true / --fff-enable-home-scan=true to index $HOME.`;
476
+ if (!enableFsRootScanning && isFsRoot(cwd))
477
+ return `(fff): cwd is the filesystem root and "enableFsRootScanning" is false, so FFF search is disabled for this session. Start pi from a project directory, or set "enableFsRootScanning": true / --fff-enable-root-scan=true to index it.`;
478
+ return null;
479
+ }
480
+
463
481
  // in case cwd changes we need to figure this out
464
482
  function ensureFinder(cwd: string): Promise<FileFinderApi> {
483
+ const optOut = scanOptOutReason(cwd);
484
+ if (optOut) return Promise.reject(new Error(optOut));
485
+
465
486
  if (mainFinder && !mainFinder.isDestroyed && finderCwd === cwd)
466
487
  return Promise.resolve(mainFinder);
467
488
 
@@ -637,30 +658,66 @@ export default function fffExtension(pi: ExtensionAPI) {
637
658
  };
638
659
 
639
660
  const pendingTools: (() => string)[] = [];
661
+ const registeredToolNames = new Set<string>();
662
+ // A renderer is attached to a concrete registered name. Keep that name outside
663
+ // row state so old fffgrep rows retain their title after mode changes to override.
664
+ const renderToolNames = new WeakMap<object, string>();
640
665
  let toolsRegistered = false;
641
666
 
667
+ function getRenderToolName(context: object, fallback: string): string {
668
+ return renderToolNames.get(context) ?? fallback;
669
+ }
670
+
671
+ function registerTool<TParams extends TSchema, TDetails = unknown, TState = any>(
672
+ resolveName: () => string,
673
+ definition: PendingToolDefinition<TParams, TDetails, TState>,
674
+ ): string {
675
+ const resolvedName = resolveName();
676
+ if (registeredToolNames.has(resolvedName)) return resolvedName;
677
+
678
+ const { promptGuidelines, renderCall, ...tool } = definition;
679
+ pi.registerTool({
680
+ ...tool,
681
+ name: resolvedName,
682
+ label: resolvedName,
683
+ promptGuidelines: promptGuidelines?.(toolNames),
684
+ renderCall: renderCall
685
+ ? (args, theme, context) => {
686
+ renderToolNames.set(context, resolvedName);
687
+ return renderCall(args, theme, context);
688
+ }
689
+ : undefined,
690
+ });
691
+ registeredToolNames.add(resolvedName);
692
+ return resolvedName;
693
+ }
694
+
642
695
  function queueTool<TParams extends TSchema, TDetails = unknown, TState = any>(
643
696
  resolveName: () => string,
644
697
  definition: PendingToolDefinition<TParams, TDetails, TState>,
645
698
  ): void {
646
- pendingTools.push(() => {
647
- const { promptGuidelines, ...tool } = definition;
648
- const resolvedName = resolveName();
649
- pi.registerTool({
650
- ...tool,
651
- name: resolvedName,
652
- label: resolvedName,
653
- promptGuidelines: promptGuidelines?.(toolNames),
654
- });
655
- return resolvedName;
656
- });
699
+ pendingTools.push(() => registerTool(resolveName, definition));
700
+
701
+ // Pi restores historical tool rows before session_start. Register the
702
+ // FFF-named tools now so their renderers resolve. Pi activates every
703
+ // newly registered tool, so registerPendingTools prunes the names the
704
+ // final mode did not select.
705
+ registerTool(resolveName, definition);
657
706
  }
658
707
 
659
- function registerPendingTools(): void {
708
+ // Pi carries the active tool list across /reload, so names activated under a
709
+ // previously used mode stay active unless we drop them here (#855).
710
+ function registerPendingTools(staleNames: readonly string[]): void {
660
711
  if (toolsRegistered) return;
661
712
 
662
- const registeredNames = pendingTools.map((register) => register());
663
- pi.setActiveTools([...new Set([...pi.getActiveTools(), ...registeredNames])]);
713
+ const registeredNames = new Set(pendingTools.map((register) => register()));
714
+ const stale = new Set(staleNames.filter((name) => !registeredNames.has(name)));
715
+ pi.setActiveTools([
716
+ ...new Set([
717
+ ...pi.getActiveTools().filter((name) => !stale.has(name)),
718
+ ...registeredNames,
719
+ ]),
720
+ ]);
664
721
  toolsRegistered = true;
665
722
  }
666
723
 
@@ -720,34 +777,51 @@ export default function fffExtension(pi: ExtensionAPI) {
720
777
  // Pi populates extension flag values after loading extensions.
721
778
  resolveStartupConfig();
722
779
 
780
+ // FFF-named tools are ours alone, so they are always safe to drop. Override
781
+ // names collide with pi's builtins and are only stale once this session ran
782
+ // in override mode, which is the sole way we could have activated them.
783
+ const staleNames = toolNameList(FFF_TOOL_NAMES);
784
+ let usedOverride = currentMode === "override";
785
+
723
786
  // Restore persisted mode before registering tools so a saved override
724
787
  // can safely change their names after /reload or session resume.
725
- const entries = ctx.sessionManager?.getEntries();
726
- if (entries) {
727
- const modeEntry = [...entries]
728
- .reverse()
729
- .find(
730
- (e: { type: string; customType?: string }) =>
731
- e.type === "custom" && e.customType === "fff-mode",
732
- );
733
- if (
734
- modeEntry &&
735
- typeof (modeEntry as any).data?.mode === "string" &&
736
- VALID_MODES.includes((modeEntry as any).data.mode as FffMode)
737
- ) {
738
- const restored = (modeEntry as any).data.mode as FffMode;
739
- if (restored !== currentMode) setMode(restored);
740
- }
788
+ const modes = sessionModes(ctx.sessionManager?.getEntries());
789
+ if (modes.length > 0) {
790
+ const restored = modes[modes.length - 1];
791
+ if (restored !== currentMode) setMode(restored);
792
+ usedOverride = usedOverride || modes.includes("override");
741
793
  }
742
794
 
743
795
  initializeFinderFactories();
744
- registerPendingTools();
796
+
797
+ // `override` replaces pi's built-in grep/find. With the cwd opted out of
798
+ // indexing that would leave the session without any working workspace
799
+ // search, so keep the FFF names and let the built-ins stand (issue #857).
800
+ const keepBuiltins =
801
+ currentMode === "override" && scanOptOutReason(activeCwd) !== null;
802
+ if (keepBuiltins) toolNames = FFF_TOOL_NAMES;
803
+
804
+ // Pruning the override names here would deactivate the built-ins #857 just
805
+ // chose to keep, so only prune once FFF really takes those names over.
806
+ if (usedOverride && !keepBuiltins)
807
+ staleNames.push(...toolNameList(OVERRIDE_TOOL_NAMES));
808
+
809
+ registerPendingTools(staleNames);
745
810
  }
746
811
 
747
812
  pi.on("session_start", async (_event, ctx) => {
748
813
  try {
749
814
  prepareSession(ctx);
750
815
  registerAutocompleteProvider(ctx);
816
+
817
+ // The user opted out of indexing this cwd, so skip the picker entirely
818
+ // instead of letting the native refusal surface as an error (issue #857).
819
+ const optOut = scanOptOutReason(activeCwd);
820
+ if (optOut) {
821
+ ctx.ui.notify(optOut, "warning");
822
+ return;
823
+ }
824
+
751
825
  await ensureFinder(activeCwd);
752
826
 
753
827
  // Warn when launched from $HOME with home scanning on: indexing a large
@@ -786,7 +860,72 @@ export default function fffExtension(pi: ExtensionAPI) {
786
860
 
787
861
  // --- Shared render helpers ---
788
862
 
789
- const renderTextResult = (
863
+ class CollapsedText implements Component {
864
+ constructor(
865
+ private readonly preview: string,
866
+ private readonly suffix: string,
867
+ private readonly marker: string,
868
+ ) {}
869
+
870
+ render(width: number): string[] {
871
+ const availableWidth = Math.max(1, width);
872
+ if (!this.suffix) {
873
+ if (visibleWidth(this.preview) <= availableWidth) return [this.preview];
874
+ const markerWidth = visibleWidth(this.marker);
875
+ if (markerWidth >= availableWidth) {
876
+ return [sliceByColumn(this.marker, 0, availableWidth, true)];
877
+ }
878
+ return [
879
+ `${sliceByColumn(this.preview, 0, availableWidth - markerWidth, true)}${this.marker}`,
880
+ ];
881
+ }
882
+
883
+ const suffixWidth = visibleWidth(this.suffix);
884
+ if (suffixWidth >= availableWidth) {
885
+ return [sliceByColumn(this.suffix, 0, availableWidth, true)];
886
+ }
887
+
888
+ const previewWidth = availableWidth - suffixWidth - 1;
889
+ if (visibleWidth(this.preview) <= previewWidth) {
890
+ return [`${this.preview} ${this.suffix}`];
891
+ }
892
+
893
+ const markerWidth = visibleWidth(this.marker);
894
+ return [
895
+ `${sliceByColumn(this.preview, 0, Math.max(0, previewWidth - markerWidth), true)}${this.marker} ${this.suffix}`,
896
+ ];
897
+ }
898
+
899
+ invalidate(): void {}
900
+ }
901
+
902
+ // Pi wraps returned components in its own click-to-expand MouseRegion and passes
903
+ // the toggled state via options.expanded, so no custom mouse handling is needed.
904
+ const renderCompactTextResult = (
905
+ result: { content?: { type: string; text?: string }[] },
906
+ options: { expanded?: boolean },
907
+ theme: any,
908
+ context: any,
909
+ ): Component => {
910
+ const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
911
+ if (!output) return new Text(theme.fg("muted", "No output"), 0, 0);
912
+
913
+ const lines = output.split("\n");
914
+ const color = context.isError ? "error" : "toolOutput";
915
+ if (options.expanded) {
916
+ return new Text(lines.map((line) => theme.fg(color, line)).join("\n"), 0, 0);
917
+ }
918
+
919
+ const suffix =
920
+ lines.length > 1 ? theme.fg("muted", `... (${lines.length - 1} more lines)`) : "";
921
+ return new CollapsedText(
922
+ theme.fg(color, lines[0] ?? ""),
923
+ suffix,
924
+ theme.fg("muted", "..."),
925
+ );
926
+ };
927
+
928
+ const renderPreviewResult = (
790
929
  result: { content?: { type: string; text?: string }[] },
791
930
  options: { expanded?: boolean },
792
931
  theme: any,
@@ -993,23 +1132,24 @@ export default function fffExtension(pi: ExtensionAPI) {
993
1132
  },
994
1133
 
995
1134
  renderCall(args, theme, context) {
996
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
997
1135
  const pattern = args?.pattern ?? "";
998
1136
  const path = args?.path ?? ".";
999
1137
  let content =
1000
- theme.fg("toolTitle", theme.bold(toolNames.grep)) +
1138
+ theme.fg("toolTitle", theme.bold(getRenderToolName(context, toolNames.grep))) +
1001
1139
  " " +
1002
1140
  theme.fg("accent", `/${pattern}/`) +
1003
1141
  theme.fg("toolOutput", ` in ${path}`);
1004
- if (args?.limit !== undefined)
1005
- content += theme.fg("toolOutput", ` limit ${args.limit}`);
1142
+ const options: string[] = [];
1143
+ if (args?.limit !== undefined) options.push(`limit ${args.limit}`);
1144
+ if (args?.context !== undefined) options.push(`context ${args.context}`);
1145
+ if (options.length > 0)
1146
+ content += theme.fg("toolOutput", ` (${options.join(", ")})`);
1006
1147
  if (args?.cursor) content += theme.fg("muted", ` (page)`);
1007
- text.setText(content);
1008
- return text;
1148
+ return new CollapsedText(content, "", theme.fg("muted", "..."));
1009
1149
  },
1010
1150
 
1011
1151
  renderResult(result, options, theme, context) {
1012
- return renderTextResult(result, options, theme, context, 15);
1152
+ return renderCompactTextResult(result, options, theme, context);
1013
1153
  },
1014
1154
  });
1015
1155
 
@@ -1136,23 +1276,21 @@ export default function fffExtension(pi: ExtensionAPI) {
1136
1276
  },
1137
1277
 
1138
1278
  renderCall(args, theme, context) {
1139
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
1140
1279
  const pattern = args?.pattern ?? "";
1141
1280
  const path = args?.path ?? ".";
1142
1281
  let content =
1143
- theme.fg("toolTitle", theme.bold(toolNames.find)) +
1282
+ theme.fg("toolTitle", theme.bold(getRenderToolName(context, toolNames.find))) +
1144
1283
  " " +
1145
1284
  theme.fg("accent", pattern) +
1146
1285
  theme.fg("toolOutput", ` in ${path}`);
1147
1286
  if (args?.limit !== undefined)
1148
1287
  content += theme.fg("toolOutput", ` (limit ${args.limit})`);
1149
1288
  if (args?.cursor) content += theme.fg("muted", ` (page)`);
1150
- text.setText(content);
1151
- return text;
1289
+ return new CollapsedText(content, "", theme.fg("muted", "..."));
1152
1290
  },
1153
1291
 
1154
1292
  renderResult(result, options, theme, context) {
1155
- return renderTextResult(result, options, theme, context, 20);
1293
+ return renderCompactTextResult(result, options, theme, context);
1156
1294
  },
1157
1295
  });
1158
1296
 
@@ -1244,7 +1382,10 @@ export default function fffExtension(pi: ExtensionAPI) {
1244
1382
  const patterns = args?.patterns ?? [];
1245
1383
  const constraints = args?.constraints;
1246
1384
  let content =
1247
- theme.fg("toolTitle", theme.bold(toolNames.multiGrep)) +
1385
+ theme.fg(
1386
+ "toolTitle",
1387
+ theme.bold(getRenderToolName(context, toolNames.multiGrep)),
1388
+ ) +
1248
1389
  " " +
1249
1390
  theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
1250
1391
  if (constraints) content += theme.fg("toolOutput", ` (${constraints})`);
@@ -1254,7 +1395,7 @@ export default function fffExtension(pi: ExtensionAPI) {
1254
1395
  },
1255
1396
 
1256
1397
  renderResult(result, options, theme, context) {
1257
- return renderTextResult(result, options, theme, context, 15);
1398
+ return renderPreviewResult(result, options, theme, context, 15);
1258
1399
  },
1259
1400
  });
1260
1401
  } // end if (enableMultiGrep)
@@ -1358,3 +1499,22 @@ export default function fffExtension(pi: ExtensionAPI) {
1358
1499
  },
1359
1500
  });
1360
1501
  }
1502
+
1503
+ // Every mode this session selected via /fff-mode, oldest first.
1504
+ function sessionModes(entries: unknown): FffMode[] {
1505
+ if (!Array.isArray(entries)) return [];
1506
+
1507
+ const modes: FffMode[] = [];
1508
+ for (const entry of entries as {
1509
+ type?: string;
1510
+ customType?: string;
1511
+ data?: unknown;
1512
+ }[]) {
1513
+ if (entry?.type !== "custom" || entry.customType !== "fff-mode") continue;
1514
+ const mode = (entry.data as { mode?: unknown } | undefined)?.mode;
1515
+ if (typeof mode === "string" && VALID_MODES.includes(mode as FffMode)) {
1516
+ modes.push(mode as FffMode);
1517
+ }
1518
+ }
1519
+ return modes;
1520
+ }
package/src/paths.ts CHANGED
@@ -18,6 +18,12 @@ export function isHomeDir(dir: string): boolean {
18
18
  return path.resolve(dir) === HOME_DIR;
19
19
  }
20
20
 
21
+ // Mirrors the native `path.parent().is_none()` check: a path that is its own parent.
22
+ export function isFsRoot(dir: string): boolean {
23
+ const resolved = path.resolve(dir);
24
+ return path.dirname(resolved) === resolved;
25
+ }
26
+
21
27
  // Resolution order: explicit override > existing fff.nvim db > pi-local data dir.
22
28
  // Reusing the nvim db lets pi rank files by the frecency the user built in their editor.
23
29
  export function resolveDbPaths(overrides: {
package/src/sdk.ts CHANGED
@@ -9,6 +9,22 @@ export type FileFinderStatic = {
9
9
 
10
10
  let sdkPromise: Promise<{ FileFinder: FileFinderStatic }> | null = null;
11
11
 
12
+ const SDK_ORDER: Record<"bun" | "node", readonly [string, string]> = {
13
+ // fff-bun is TS-source only and cannot be imported by Bun-compiled hosts
14
+ // (e.g. omp) whose module resolver rejects .ts under node_modules, so the
15
+ // JS-compiled fff-node is kept as a fallback for every runtime.
16
+ bun: ["@ff-labs/fff-bun", "@ff-labs/fff-node"],
17
+ node: ["@ff-labs/fff-node", "@ff-labs/fff-bun"],
18
+ };
19
+
20
+ // Literal dynamic imports so hosts that statically scan extension graphs
21
+ // (omp's legacy-pi-compat loader) discover and hook both SDK packages;
22
+ // a variable `import(pkg)` would bypass that scan and fail at runtime.
23
+ const SDK_IMPORTS = {
24
+ "@ff-labs/fff-bun": () => import("@ff-labs/fff-bun"),
25
+ "@ff-labs/fff-node": () => import("@ff-labs/fff-node"),
26
+ } as const;
27
+
12
28
  function detectRuntime(): "bun" | "node" {
13
29
  if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") return "bun";
14
30
  if (
@@ -19,6 +35,26 @@ function detectRuntime(): "bun" | "node" {
19
35
  return "node";
20
36
  }
21
37
 
38
+ /** Preferred SDK order for the detected runtime. */
39
+ export function sdkCandidates(): readonly [string, string] {
40
+ return SDK_ORDER[detectRuntime()];
41
+ }
42
+
43
+ export async function loadFirst(
44
+ candidates: readonly [string, string],
45
+ loaders: Record<string, () => Promise<unknown>> = SDK_IMPORTS,
46
+ ): Promise<{ FileFinder: FileFinderStatic }> {
47
+ let lastError: unknown;
48
+ for (const pkg of candidates) {
49
+ try {
50
+ return (await loaders[pkg]()) as { FileFinder: FileFinderStatic };
51
+ } catch (error) {
52
+ lastError = error;
53
+ }
54
+ }
55
+ throw lastError;
56
+ }
57
+
22
58
  export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
23
59
  if (sdkPromise) return sdkPromise;
24
60
 
@@ -34,8 +70,7 @@ export function loadSdk(): Promise<{ FileFinder: FileFinderStatic }> {
34
70
  }
35
71
 
36
72
  // default to node as it seems like default option
37
- const pkg = detectRuntime() === "bun" ? "@ff-labs/fff-bun" : "@ff-labs/fff-node";
38
- const p = import(pkg) as Promise<{ FileFinder: FileFinderStatic }>;
73
+ const p = loadFirst(sdkCandidates());
39
74
  sdkPromise = p;
40
75
  (globalThis as Record<string, unknown>).__fffSdkPromiseGlobal = p;
41
76
  return p;