From 4351dbce5004d6b1f7f5fccb701ccc5f7c179b35 Mon Sep 17 00:00:00 2001 From: pokedotdev Date: Tue, 25 Aug 2026 09:58:08 -0700 Subject: [PATCH] feat(pi-fff): add ui-only mode --- README.md | 5 ++- packages/pi-fff/README.md | 7 +-- packages/pi-fff/pi-fff.schema.json | 2 +- packages/pi-fff/src/config.ts | 2 +- packages/pi-fff/src/index.ts | 33 ++++++-------- packages/pi-fff/test/config.test.ts | 6 +++ packages/pi-fff/test/extension.test.ts | 62 +++++++++++++++++++++++++- 7 files changed, 89 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 581b38896..ee4e203e4 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,13 @@ pi install npm:@ff-labs/pi-fff ### Modes -Three operating modes, switchable at runtime with `/fff-mode`: +Four operating modes, switchable at runtime with `/fff-mode`: | Mode | What it does | | ------------------------ | --------------------------------------------------------------------------------- | | `tools-and-ui` (default) | Adds `ffgrep` and `fffind` tools, replaces `@`-mention autocomplete with FFF. | | `tools-only` | Only tool injection. Keeps pi's native editor autocomplete. | +| `ui-only` | Only `@`-file autocomplete with FFF; no FFF agent tools. | | `override` | Replaces pi's built-in `grep`, `find`, and `multi_grep` with FFF implementations. | Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode`, `--fff-frecency-db`, `--fff-history-db`. The databases default to your existing fff.nvim ones when present, otherwise `~/.pi/agent/fff/`. @@ -150,7 +151,7 @@ Env vars: `PI_FFF_MODE`, `FFF_FRECENCY_DB`, `FFF_HISTORY_DB`. Flags: `--fff-mode ### Commands -- `/fff-mode [tools-and-ui | tools-only | override]`. Show or switch the mode. +- `/fff-mode [tools-and-ui | tools-only | ui-only | override]`. Show or switch the mode. - `/fff-health`. Picker, frecency, and git integration status. - `/fff-rescan`. Force a rescan. diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 7951206db..0d75f2bb5 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -116,12 +116,13 @@ Parameters: - `/fff-health` — show FFF status (indexed files, git info, frecency/history DB status) - `/fff-rescan` — trigger a file rescan -- `/fff-mode ` — switch mode (tool name changes require `/reload`) +- `/fff-mode ` — switch mode (tool registration changes require `/reload`) ## Modes - `tools-and-ui` (default): registers `fffind`, `ffgrep`, `fff-multi-grep` as additional tools + FFF-backed `@` autocomplete - `tools-only`: additional tools only; keep pi's default `@` autocomplete +- `ui-only`: Only `@`-file autocomplete with FFF; no FFF agent tools - `override`: replaces pi's built-in `find`, `grep` and adds `multi_grep` + FFF-backed `@` autocomplete Startup mode precedence: @@ -130,7 +131,7 @@ Startup mode precedence: 3. `mode` in the global config file 4. default (`tools-and-ui`) -When a session resumes, its most recent `/fff-mode` selection takes precedence over the startup resolution above. Switching to or from `override` takes effect after `/reload`, when the tools are registered again. +When a session resumes, its most recent `/fff-mode` selection takes precedence over the startup resolution above. Switching to or from `ui-only` or `override` takes effect after `/reload`, when tool registration is applied. ## Configuration @@ -153,7 +154,7 @@ All fields are optional: | Field | Type | Default | |---|---|---| | `$schema` | non-empty string | none | -| `mode` | `tools-and-ui`, `tools-only`, or `override` | `tools-and-ui` | +| `mode` | `tools-and-ui`, `tools-only`, `ui-only`, or `override` | `tools-and-ui` | | `frecencyDbPath` | non-empty string | See [Data](#data) | | `historyDbPath` | non-empty string | See [Data](#data) | | `enableFsRootScanning` | boolean | `false` | diff --git a/packages/pi-fff/pi-fff.schema.json b/packages/pi-fff/pi-fff.schema.json index 56f1d25bf..e284bc93f 100644 --- a/packages/pi-fff/pi-fff.schema.json +++ b/packages/pi-fff/pi-fff.schema.json @@ -13,7 +13,7 @@ }, "mode": { "type": "string", - "enum": ["tools-and-ui", "tools-only", "override"], + "enum": ["tools-and-ui", "tools-only", "ui-only", "override"], "default": "tools-and-ui", "description": "Controls which FFF tools and autocomplete integrations are enabled." }, diff --git a/packages/pi-fff/src/config.ts b/packages/pi-fff/src/config.ts index f95d34f70..3e3893585 100644 --- a/packages/pi-fff/src/config.ts +++ b/packages/pi-fff/src/config.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { piDataDir } from "./paths"; export const CONFIG_FILE_NAME = "pi-fff.json"; -export const VALID_MODES = ["tools-and-ui", "tools-only", "override"] as const; +export const VALID_MODES = ["tools-and-ui", "tools-only", "ui-only", "override"] as const; export type FffMode = (typeof VALID_MODES)[number]; diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 5a7e64cca..bb7f5305b 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -621,7 +621,7 @@ export default function fffExtension(pi: ExtensionAPI) { }; const pendingTools: (() => string)[] = []; - let toolsRegistered = false; + let sessionPrepared = false; function queueTool( resolveName: () => string, @@ -641,17 +641,14 @@ export default function fffExtension(pi: ExtensionAPI) { } function registerPendingTools(): void { - if (toolsRegistered) return; - const registeredNames = pendingTools.map((register) => register()); pi.setActiveTools([...new Set([...pi.getActiveTools(), ...registeredNames])]); - toolsRegistered = true; } // --- Flags / lifecycle --- pi.registerFlag("fff-mode", { - description: "FFF mode: tools-and-ui | tools-only | override", + description: `FFF mode: ${VALID_MODES.join(" | ")}`, type: "string", }); @@ -693,13 +690,12 @@ export default function fffExtension(pi: ExtensionAPI) { function prepareSession(ctx: ExtensionContext): void { activeCwd = ctx.cwd; uiCtx = ctx; - if (toolsRegistered) return; + if (sessionPrepared) return; // Pi populates extension flag values after loading extensions. resolveStartupConfig(); - // Restore persisted mode before registering tools so a saved override - // can safely change their names after /reload or session resume. + // Restore the persisted mode before deciding whether and how to register tools. const entries = ctx.sessionManager?.getEntries(); if (entries) { const modeEntry = [...entries] @@ -719,7 +715,8 @@ export default function fffExtension(pi: ExtensionAPI) { } initializeFinderFactories(); - registerPendingTools(); + if (currentMode !== "ui-only") registerPendingTools(); + sessionPrepared = true; } pi.on("session_start", async (_event, ctx) => { @@ -747,10 +744,9 @@ export default function fffExtension(pi: ExtensionAPI) { } }); - // SDK callers can prompt without binding session_start. Prepare on the first - // agent turn as a fallback so the tools still reach that turn's tool set. + // SDK callers can prompt without session_start, so prepare on the first turn. pi.on("before_agent_start", (_event, ctx) => { - if (toolsRegistered) return; + if (sessionPrepared) return; try { prepareSession(ctx); } catch (error: unknown) { @@ -1239,9 +1235,9 @@ export default function fffExtension(pi: ExtensionAPI) { // --- commands --- pi.registerCommand("fff-mode", { - description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]", + description: `Show or set FFF mode: /fff-mode [${VALID_MODES.join(" | ")}]`, handler: async (args, ctx) => { - if (!toolsRegistered) { + if (!sessionPrepared) { try { prepareSession(ctx); } catch (error: unknown) { @@ -1270,11 +1266,10 @@ export default function fffExtension(pi: ExtensionAPI) { const oldMode = getMode(); pi.appendEntry("fff-mode", { mode: newMode }); - if ((oldMode === "override") !== (newMode === "override")) { - ctx.ui.notify( - `Mode '${newMode}' saved. Run /reload to apply the tool name change.`, - "info", - ); + const changesToolRegistration = (oldMode === "ui-only") !== (newMode === "ui-only"); + const changesToolNames = (oldMode === "override") !== (newMode === "override"); + if (changesToolRegistration || changesToolNames) { + ctx.ui.notify(`Mode '${newMode}' saved. Run /reload to apply it.`, "info"); return; } diff --git a/packages/pi-fff/test/config.test.ts b/packages/pi-fff/test/config.test.ts index 85ade4f84..c16fa5067 100644 --- a/packages/pi-fff/test/config.test.ts +++ b/packages/pi-fff/test/config.test.ts @@ -38,6 +38,12 @@ describe("loadConfig", () => { expect(loadConfig(agentDir)).toEqual(config); }); + test("accepts ui-only mode", () => { + writeConfig({ mode: "ui-only" }); + + expect(loadConfig(agentDir)).toEqual({ mode: "ui-only" }); + }); + test("rejects malformed JSON", () => { fs.writeFileSync(configPath, '{"mode":'); diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index c6820f207..c7f20da2b 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -353,7 +353,7 @@ describe("pi-fff session mode", () => { mode: "override", }); expect(setup.ctx.ui.notify).toHaveBeenLastCalledWith( - "Mode 'override' saved. Run /reload to apply the tool name change.", + "Mode 'override' saved. Run /reload to apply it.", "info", ); @@ -364,6 +364,29 @@ describe("pi-fff session mode", () => { ); await shutdown(setup); }); + + test("ui-only keeps tools unregistered and requires reload to add them", async () => { + const setup = await start("ui-only"); + + expect(setup.pi.registerTool).not.toHaveBeenCalled(); + expect(setup.pi.setActiveTools).not.toHaveBeenCalled(); + + await setup.commands.get("fff-mode").handler("tools-only", setup.ctx); + + expect(setup.pi.appendEntry).toHaveBeenCalledWith("fff-mode", { + mode: "tools-only", + }); + expect(setup.ctx.ui.notify).toHaveBeenLastCalledWith( + "Mode 'tools-only' saved. Run /reload to apply it.", + "info", + ); + await setup.commands.get("fff-mode").handler("", setup.ctx); + expect(setup.ctx.ui.notify).toHaveBeenLastCalledWith( + "Current mode: 'ui-only' (flag: ui-only)", + "info", + ); + await shutdown(setup); + }); }); // Regression for #743: launching from $HOME must be visible and interruptible. @@ -519,7 +542,7 @@ describe("pi-fff autocomplete registration", () => { expect(finders[0].mixedSearch).not.toHaveBeenCalled(); }); - test("returns FFF-backed @ mention suggestions", async () => { + test("returns @ mention suggestions with FFF", async () => { mixedSearchImpl = (query, options) => { expect(query).toBe("src"); expect(options).toEqual({ pageSize: 20 }); @@ -611,6 +634,41 @@ describe("pi-fff autocomplete registration", () => { expect(finders[0].mixedSearch).not.toHaveBeenCalled(); }); + test("ui-only keeps FFF mentions without registering agent tools", async () => { + mixedSearchImpl = () => ({ + ok: true, + value: { + items: [ + { + type: "file", + item: { relativePath: "src/index.ts", fileName: "index.ts" }, + }, + ], + }, + }); + + const { ctx, pi } = await start("ui-only"); + const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0]; + const current = currentProvider(); + const provider = factory(current); + + const result = await provider.getSuggestions(["@src"], 0, 4, abortOptions()); + + expect(pi.registerTool).not.toHaveBeenCalled(); + expect(pi.setActiveTools).not.toHaveBeenCalled(); + expect(result).toEqual({ + prefix: "@src", + items: [ + { + value: "@src/index.ts", + label: "index.ts", + description: "src/index.ts", + }, + ], + }); + expect(current.getSuggestions).not.toHaveBeenCalled(); + }); + test("/fff-mode changes mention behavior without touching the editor", async () => { const { commands, ctx, pi } = await start(); const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0];