Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand All @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions packages/pi-fff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mode>` — switch mode (tool name changes require `/reload`)
- `/fff-mode <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:
Expand All @@ -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

Expand All @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-fff/pi-fff.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-fff/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
33 changes: 14 additions & 19 deletions packages/pi-fff/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ export default function fffExtension(pi: ExtensionAPI) {
};

const pendingTools: (() => string)[] = [];
let toolsRegistered = false;
let sessionPrepared = false;

function queueTool<TParams extends TSchema, TDetails = unknown, TState = any>(
resolveName: () => string,
Expand All @@ -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",
});

Expand Down Expand Up @@ -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]
Expand All @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/pi-fff/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":');

Expand Down
62 changes: 60 additions & 2 deletions packages/pi-fff/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);

Expand All @@ -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.
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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];
Expand Down