From 30613871d9cae29d33ac9b8bb981c7c28744565a Mon Sep 17 00:00:00 2001 From: LinYS77 Date: Thu, 4 Jun 2026 17:11:29 +0800 Subject: [PATCH] Use composable autocomplete provider in pi-fff --- packages/pi-fff/src/index.ts | 116 ++++------ packages/pi-fff/test/extension.test.ts | 300 +++++++++++++++++++++++++ 2 files changed, 339 insertions(+), 77 deletions(-) create mode 100644 packages/pi-fff/test/extension.test.ts diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 56d1e3de..405b5e92 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -1,26 +1,25 @@ /** * pi-fff: FFF-powered file search extension for pi * - * Overrides built-in `find` and `grep` tools with FFF and can also replace - * @-mention autocomplete suggestions in the interactive editor. + * Overrides built-in `find` and `grep` tools with FFF and adds FFF-backed + * @-mention autocomplete suggestions to the interactive editor. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { CustomEditor } from "@earendil-works/pi-coding-agent"; import { - Text, type AutocompleteItem, type AutocompleteProvider, + Text, } from "@earendil-works/pi-tui"; -import { Type } from "@sinclair/typebox"; -import { FileFinder } from "@ff-labs/fff-node"; import type { GrepCursor, GrepMode, GrepResult, - SearchResult, MixedItem, + SearchResult, } from "@ff-labs/fff-node"; +import { FileFinder } from "@ff-labs/fff-node"; +import { Type } from "@sinclair/typebox"; import { buildQuery } from "./query"; // --------------------------------------------------------------------------- @@ -273,12 +272,6 @@ function createFffMentionProvider( }; } -// FffEditor is defined inside fffExtension() so it can capture `getMentionItems` -// via closure rather than via a 4th constructor parameter. This makes the class -// safe to subclass via `new SubClass(tui, theme, keybindings)` -- the pattern -// pi-vim and pi-image-attachments use to compose editors. See: -// https://github.com/badlogic/pi-mono/issues/3935 - // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- @@ -391,72 +384,44 @@ export default function fffExtension(pi: ExtensionAPI) { }); } - // Editor wrapper that injects FFF @-mention autocomplete alongside base provider. - // Defined inside fffExtension() so the class methods capture `getMentionItems` - // via closure. Subclasses constructed as `new Sub(tui, theme, keybindings)` by - // composability wrappers (pi-vim, pi-image-attachments) still get a working - // mention provider because the closure binding is preserved across subclassing. - class FffEditor extends CustomEditor { - private baseProvider: AutocompleteProvider | undefined; - - override setAutocompleteProvider(provider: AutocompleteProvider): void { - this.baseProvider = provider; - // Create composite provider that handles @-mentions and falls back to base + function registerAutocompleteProvider(ctx: { + ui: { + addAutocompleteProvider: ( + factory: (current: AutocompleteProvider) => AutocompleteProvider, + ) => void; + }; + }) { + ctx.ui.addAutocompleteProvider((current) => { const mentionProvider = createFffMentionProvider(getMentionItems); - const compositeProvider: AutocompleteProvider = { - getSuggestions: async (lines, cursorLine, cursorCol, options) => { - // Try @-mention first - const mentionResult = await mentionProvider.getSuggestions( - lines, - cursorLine, - cursorCol, - options, - ); - if (mentionResult) return mentionResult; - // Fall back to base provider - return ( - this.baseProvider?.getSuggestions(lines, cursorLine, cursorCol, options) ?? - null - ); - }, - applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => { - // Let mention provider handle @ completions, base provider for others - if (prefix?.startsWith("@")) { - return mentionProvider.applyCompletion!( - lines, - cursorLine, - cursorCol, - item, - prefix, - ); + + return { + async getSuggestions(lines, cursorLine, cursorCol, options) { + if (shouldEnableMentions()) { + try { + const mentionResult = await mentionProvider.getSuggestions( + lines, + cursorLine, + cursorCol, + options, + ); + if (mentionResult) return mentionResult; + } catch { + // Delegate when FFF lookup is unavailable. + } } + + return current.getSuggestions(lines, cursorLine, cursorCol, options); + }, + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { return ( - this.baseProvider?.applyCompletion?.( - lines, - cursorLine, - cursorCol, - item, - prefix, - ) ?? { lines, cursorLine, cursorCol } + current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true ); }, }; - super.setAutocompleteProvider(compositeProvider); - } - } - - function applyEditorMode(ctx: { - ui: { - setEditorComponent: ( - factory: ((tui: any, theme: any, keybindings: any) => any) | undefined, - ) => void; - }; - }) { - if (!shouldEnableMentions()) return; - - ctx.ui.setEditorComponent( - (tui: any, theme: any, keybindings: any) => new FffEditor(tui, theme, keybindings), - ); + }); } // --- Flags / lifecycle --- @@ -479,7 +444,7 @@ export default function fffExtension(pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { try { activeCwd = ctx.cwd; - if (shouldEnableMentions()) applyEditorMode(ctx); + registerAutocompleteProvider(ctx); await ensureFinder(activeCwd); } catch (e: unknown) { ctx.ui.notify( @@ -947,9 +912,6 @@ export default function fffExtension(pi: ExtensionAPI) { const oldMode = getMode(); setMode(newMode); - // Apply immediately using the shared function - applyEditorMode(ctx); - const note = (oldMode === "override") !== (newMode === "override") ? " (tool name change requires restart)" diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts new file mode 100644 index 00000000..96c25af9 --- /dev/null +++ b/packages/pi-fff/test/extension.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +type MockFinder = { + isDestroyed: boolean; + waitForScan: ReturnType; + mixedSearch: ReturnType; + destroy: ReturnType; +}; + +const createCalls: unknown[] = []; +let finders: MockFinder[] = []; +let mixedSearchImpl: ((query: string, options: unknown) => unknown) | undefined; + +function createMockFinder(): MockFinder { + return { + isDestroyed: false, + waitForScan: mock(async () => undefined), + mixedSearch: mock((query: string, options: unknown) => { + if (mixedSearchImpl) return mixedSearchImpl(query, options); + return { + ok: true, + value: { + items: [], + scores: [], + totalMatched: 0, + totalFiles: 0, + totalDirs: 0, + }, + }; + }), + destroy: mock(function (this: MockFinder) { + this.isDestroyed = true; + }), + }; +} + +mock.module("@ff-labs/fff-node", () => ({ + FileFinder: { + create: mock((options: unknown) => { + createCalls.push(options); + const finder = createMockFinder(); + finders.push(finder); + return { ok: true, value: finder }; + }), + }, +})); + +mock.module("@earendil-works/pi-tui", () => ({ + Text: class Text { + text: string; + constructor(text: string) { + this.text = text; + } + setText(text: string) { + this.text = text; + } + }, +})); + +const schema = (type: string) => (options?: unknown) => ({ type, options }); + +mock.module("@sinclair/typebox", () => ({ + Type: { + Array: (items: unknown, options?: unknown) => ({ type: "array", items, options }), + Boolean: schema("boolean"), + Number: schema("number"), + Object: (properties: unknown, options?: unknown) => ({ + type: "object", + properties, + options, + }), + Optional: (value: unknown) => ({ ...value, optional: true }), + String: schema("string"), + Union: (items: unknown[], options?: unknown) => ({ type: "union", items, options }), + }, +})); + +const { default: fffExtension } = await import("../src/index"); + +type EventHandler = (...args: any[]) => unknown; + +function createPi(mode?: string) { + const events = new Map(); + const commands = new Map(); + + const pi = { + getFlag: mock((name: string) => (name === "fff-mode" ? mode : undefined)), + on: mock((event: string, handler: EventHandler) => { + events.set(event, handler); + }), + registerCommand: mock((name: string, command: any) => { + commands.set(name, command); + }), + registerFlag: mock(() => undefined), + registerTool: mock(() => undefined), + }; + + return { pi, events, commands }; +} + +function createContext() { + return { + cwd: "/tmp/workspace", + ui: { + addAutocompleteProvider: mock(() => undefined), + notify: mock(() => undefined), + setEditorComponent: mock(() => undefined), + }, + }; +} + +async function start(mode?: string) { + const setup = createPi(mode); + const ctx = createContext(); + fffExtension(setup.pi as any); + + const sessionStart = setup.events.get("session_start"); + expect(sessionStart).toBeDefined(); + await sessionStart?.({ reason: "startup" }, ctx); + + return { ...setup, ctx }; +} + +function currentProvider( + result = { items: [{ value: "base", label: "base" }], prefix: "ba" }, +) { + return { + getSuggestions: mock(async () => result), + applyCompletion: mock(() => ({ lines: ["applied"], cursorLine: 0, cursorCol: 7 })), + shouldTriggerFileCompletion: mock(() => false), + }; +} + +function abortOptions() { + return { signal: new AbortController().signal }; +} + +beforeEach(() => { + createCalls.length = 0; + finders = []; + mixedSearchImpl = undefined; + delete process.env.PI_FFF_MODE; +}); + +describe("pi-fff autocomplete registration", () => { + test("session_start registers a provider without replacing the editor", async () => { + const { ctx } = await start(); + + expect(ctx.ui.addAutocompleteProvider).toHaveBeenCalledTimes(1); + expect(ctx.ui.setEditorComponent).not.toHaveBeenCalled(); + expect(createCalls).toEqual([ + { + basePath: "/tmp/workspace", + frecencyDbPath: undefined, + historyDbPath: undefined, + aiMode: true, + }, + ]); + }); + + test("delegates non-@ completions to the current provider", async () => { + const { ctx } = await start(); + const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0]; + const current = currentProvider(); + const provider = factory(current); + + const result = await provider.getSuggestions(["hello"], 0, 5, abortOptions()); + + expect(result).toEqual({ items: [{ value: "base", label: "base" }], prefix: "ba" }); + expect(current.getSuggestions).toHaveBeenCalledTimes(1); + expect(finders[0].mixedSearch).not.toHaveBeenCalled(); + }); + + test("returns FFF-backed @ mention suggestions", async () => { + mixedSearchImpl = (query, options) => { + expect(query).toBe("src"); + expect(options).toEqual({ pageSize: 20 }); + return { + ok: true, + value: { + items: [ + { + type: "file", + item: { + relativePath: "src/index.ts", + fileName: "index.ts", + size: 1, + modified: 1, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + totalFrecencyScore: 0, + gitStatus: "clean", + }, + }, + { + type: "directory", + item: { + relativePath: "src/components/", + dirName: "components/", + maxAccessFrecency: 0, + }, + }, + ], + scores: [], + totalMatched: 2, + totalFiles: 1, + totalDirs: 1, + }, + }; + }; + + const { ctx } = await start(); + const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0]; + const current = currentProvider(); + const provider = factory(current); + + const result = await provider.getSuggestions(["open @src"], 0, 9, abortOptions()); + + expect(result).toEqual({ + prefix: "@src", + items: [ + { + value: "@src/index.ts", + label: "index.ts", + description: "src/index.ts", + }, + { + value: "@src/components/", + label: "components/", + description: "src/components/", + }, + ], + }); + expect(current.getSuggestions).not.toHaveBeenCalled(); + }); + + test("delegates when FFF lookup fails", async () => { + mixedSearchImpl = () => { + throw new Error("native lookup failed"); + }; + + const { ctx } = await start(); + 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(result).toEqual({ items: [{ value: "base", label: "base" }], prefix: "ba" }); + expect(current.getSuggestions).toHaveBeenCalledTimes(1); + }); + + test("tools-only mode bypasses FFF mentions and delegates", async () => { + const { ctx } = await start("tools-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(result).toEqual({ items: [{ value: "base", label: "base" }], prefix: "ba" }); + expect(current.getSuggestions).toHaveBeenCalledTimes(1); + expect(finders[0].mixedSearch).not.toHaveBeenCalled(); + }); + + test("/fff-mode changes mention behavior without touching the editor", async () => { + const { commands, ctx } = await start(); + const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0]; + const current = currentProvider(); + const provider = factory(current); + + await commands.get("fff-mode").handler("tools-only", ctx); + await provider.getSuggestions(["@src"], 0, 4, abortOptions()); + + expect(current.getSuggestions).toHaveBeenCalledTimes(1); + expect(finders[0].mixedSearch).not.toHaveBeenCalled(); + expect(ctx.ui.setEditorComponent).not.toHaveBeenCalled(); + }); + + test("completion application and file-completion trigger delegate to current provider", async () => { + const { ctx } = await start(); + const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0]; + const current = currentProvider(); + const provider = factory(current); + + const applied = provider.applyCompletion( + ["@src"], + 0, + 4, + { value: "@src/index.ts", label: "index.ts" }, + "@src", + ); + const shouldTrigger = provider.shouldTriggerFileCompletion(["@src"], 0, 4); + + expect(applied).toEqual({ lines: ["applied"], cursorLine: 0, cursorCol: 7 }); + expect(shouldTrigger).toBe(false); + expect(current.applyCompletion).toHaveBeenCalledTimes(1); + expect(current.shouldTriggerFileCompletion).toHaveBeenCalledTimes(1); + }); +});