From 8234ebf9ee8436a60cdd0329c6d186b07253ab49 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 22 Apr 2026 15:44:08 +0200 Subject: [PATCH] feat(coding-agent,tui): add stacked autocomplete providers closes #2983 --- ...i-completions-cache-control-format.test.ts | 45 ++++- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/extensions.md | 68 +++++++ .../examples/extensions/README.md | 1 + .../extensions/github-issue-autocomplete.ts | 185 ++++++++++++++++++ .../coding-agent/src/core/extensions/index.ts | 1 + .../src/core/extensions/runner.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 7 + packages/coding-agent/src/index.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 37 +++- .../coding-agent/src/modes/rpc/rpc-mode.ts | 4 + .../test/interactive-mode-status.test.ts | 72 ++++++- packages/tui/CHANGELOG.md | 1 + packages/tui/src/autocomplete.ts | 3 + packages/tui/src/components/editor.ts | 38 ++-- packages/tui/test/editor.test.ts | 33 ++++ 16 files changed, 465 insertions(+), 33 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/github-issue-autocomplete.ts diff --git a/packages/ai/test/openai-completions-cache-control-format.test.ts b/packages/ai/test/openai-completions-cache-control-format.test.ts index 0d133bb7..92ae6c39 100644 --- a/packages/ai/test/openai-completions-cache-control-format.test.ts +++ b/packages/ai/test/openai-completions-cache-control-format.test.ts @@ -126,9 +126,27 @@ describe("openai-completions cacheControlFormat", () => { mockState.lastParams = undefined; }); - it("applies Anthropic-style cache markers for built-in opencode-go Qwen models", async () => { - const model = getModel("opencode-go", "qwen3.5-plus"); - expect(model.compat?.cacheControlFormat).toBe("anthropic"); + it("applies Anthropic-style cache markers when model compat enables them", async () => { + const model: Model<"openai-completions"> = { + id: "custom-qwen", + name: "Custom Qwen", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://example.com/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32000, + compat: { + cacheControlFormat: "anthropic", + }, + }; const params = await capturePayload(model); expectAnthropicCacheMarkers(params); @@ -141,7 +159,26 @@ describe("openai-completions cacheControlFormat", () => { }); it("omits Anthropic-style cache markers when cacheRetention is none", async () => { - const model = getModel("opencode-go", "qwen3.5-plus"); + const model: Model<"openai-completions"> = { + id: "custom-qwen", + name: "Custom Qwen", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://example.com/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 32000, + compat: { + cacheControlFormat: "anthropic", + }, + }; const params = await capturePayload(model, { cacheRetention: "none" }); const instructionMessage = getInstructionMessage(params); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4ce1addb..4c91f900 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Added `structured-output.ts` extension example plus extension docs for terminating tool results, showing how a custom tool can return `terminate: true` so the agent ends on the tool call without an extra follow-up LLM turn ([#3525](https://github.com/badlogic/pi-mono/issues/3525)) - Added OSC 9;4 terminal progress indicators during agent streaming and compaction, so terminals like iTerm2, WezTerm, Windows Terminal, and Kitty show activity in their tab bar +- Added `ctx.ui.addAutocompleteProvider(...)` for stacking extension autocomplete providers on top of the built-in slash/path provider, plus a `github-issue-autocomplete.ts` example and extension docs ([#2983](https://github.com/badlogic/pi-mono/issues/2983)) ### Breaking Changes diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index b94555f2..8aa49eff 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2063,6 +2063,7 @@ Extensions can interact with users via `ctx.ui` methods and customize how messag - Status indicators (setStatus) - Working message and indicator during streaming (`setWorkingMessage`, `setWorkingIndicator`) - Widgets above/below editor (setWidget) +- Autocomplete providers layered on top of built-in slash/path completion (addAutocompleteProvider) - Custom footers (setFooter) ### Dialogs @@ -2184,6 +2185,28 @@ const current = ctx.ui.getEditorText(); // Paste into editor (triggers paste handling, including collapse for large content) ctx.ui.pasteToEditor("pasted content"); +// Stack custom autocomplete behavior on top of the built-in provider +ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, line, col, options) { + const beforeCursor = (lines[line] ?? "").slice(0, col); + const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); + if (!match) { + return current.getSuggestions(lines, line, col, options); + } + + return { + prefix: `#${match[1] ?? ""}`, + items: [{ value: "#2983", label: "#2983", description: "Extension API for autocomplete" }], + }; + }, + applyCompletion(lines, line, col, item, prefix) { + return current.applyCompletion(lines, line, col, item, prefix); + }, + shouldTriggerFileCompletion(lines, line, col) { + return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true; + }, +})); + // Tool output expansion const wasExpanded = ctx.ui.getToolsExpanded(); ctx.ui.setToolsExpanded(true); @@ -2206,6 +2229,50 @@ ctx.ui.theme.fg("accent", "styled text"); // Access current theme Custom working-indicator frames are rendered verbatim. If you want colors, add them to the frame strings yourself, for example with `ctx.ui.theme.fg(...)`. +### Autocomplete Providers + +Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. + +Typical pattern: + +- inspect the text before the cursor +- return your own suggestions when your extension-specific syntax matches +- otherwise delegate to `current.getSuggestions(...)` +- delegate `applyCompletion(...)` unless you need custom insertion behavior + +```typescript +pi.on("session_start", (_event, ctx) => { + ctx.ui.addAutocompleteProvider((current) => ({ + async getSuggestions(lines, cursorLine, cursorCol, options) { + const line = lines[cursorLine] ?? ""; + const beforeCursor = line.slice(0, cursorCol); + const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); + if (!match) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + return { + prefix: `#${match[1] ?? ""}`, + items: [ + { value: "#2983", label: "#2983", description: "Extension API for registering custom @ autocomplete providers" }, + { value: "#2753", label: "#2753", description: "Reload stale resource settings" }, + ], + }; + }, + + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + })); +}); +``` + +See [github-issue-autocomplete.ts](../examples/extensions/github-issue-autocomplete.ts) for a complete example that preloads the latest open GitHub issues with `gh issue list` and filters them locally for fast `#...` completion. It requires GitHub CLI (`gh`) and a GitHub repository checkout. + ### Custom Components For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called: @@ -2429,6 +2496,7 @@ All examples in [examples/extensions/](../examples/extensions/). | **UI Components** ||| | `status-line.ts` | Footer status indicator | `setStatus`, session events | | `working-indicator.ts` | Customize the streaming working indicator | `setWorkingIndicator`, `registerCommand` | +| `github-issue-autocomplete.ts` | Add `#1234` issue completions on top of built-in autocomplete by preloading recent open issues from `gh issue list` | `addAutocompleteProvider`, `on("session_start")`, `exec` | | `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` | | `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` | | `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` | diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 5654162e..8fdbdbee 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -52,6 +52,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `handoff.ts` | Transfer context to a new focused session via `/handoff ` | | `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | | `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | +| `github-issue-autocomplete.ts` | Adds `#1234` issue completions by stacking a custom autocomplete provider that preloads open issues from `gh issue list` | | `widget-placement.ts` | Shows widgets above and below the editor via `ctx.ui.setWidget()` placement | | `hidden-thinking-label.ts` | Customizes the collapsed thinking label via `ctx.ui.setHiddenThinkingLabel()` | | `working-indicator.ts` | Customizes the streaming working indicator via `ctx.ui.setWorkingIndicator()` | diff --git a/packages/coding-agent/examples/extensions/github-issue-autocomplete.ts b/packages/coding-agent/examples/extensions/github-issue-autocomplete.ts new file mode 100644 index 00000000..c5c55e88 --- /dev/null +++ b/packages/coding-agent/examples/extensions/github-issue-autocomplete.ts @@ -0,0 +1,185 @@ +// Requires GitHub CLI (`gh`) and a GitHub repository checkout. +// Preloads the latest open issues once per session, then filters them locally for fast `#...` completion. + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, + fuzzyFilter, +} from "@mariozechner/pi-tui"; + +type GitHubIssue = { + number: number; + title: string; + state: string; +}; + +type RepoResolution = { ok: true; repo: string } | { ok: false; error: string }; + +const MAX_ISSUES = 100; +const MAX_SUGGESTIONS = 20; + +function extractIssueToken(textBeforeCursor: string): string | undefined { + const match = textBeforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); + return match?.[1]; +} + +function parseGitHubRepo(remoteUrl: string): string | undefined { + const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/); + if (sshMatch) { + return sshMatch[1]; + } + + const httpsMatch = remoteUrl.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/); + if (httpsMatch) { + return httpsMatch[1]; + } + + return undefined; +} + +async function resolveGitHubRepo(pi: ExtensionAPI, cwd: string): Promise { + const result = await pi.exec("git", ["remote", "-v"], { cwd, timeout: 5_000 }); + if (result.code !== 0) { + return { ok: false, error: "github-issue-autocomplete: cwd is not a git repository" }; + } + + for (const line of result.stdout.split("\n")) { + const columns = line.trim().split(/\s+/); + const remoteUrl = columns[1]; + if (!remoteUrl) { + continue; + } + const repo = parseGitHubRepo(remoteUrl); + if (repo) { + return { ok: true, repo }; + } + } + + return { ok: false, error: "github-issue-autocomplete: cwd is not a GitHub repository" }; +} + +function formatIssueItem(issue: GitHubIssue): AutocompleteItem { + return { + value: `#${issue.number}`, + label: `#${issue.number}`, + description: `[${issue.state.toLowerCase()}] ${issue.title}`, + }; +} + +function filterIssues(issues: GitHubIssue[], query: string): AutocompleteItem[] { + if (!query.trim()) { + return issues.slice(0, MAX_SUGGESTIONS).map(formatIssueItem); + } + + if (/^\d+$/.test(query)) { + const numericMatches = issues + .filter((issue) => String(issue.number).startsWith(query)) + .slice(0, MAX_SUGGESTIONS) + .map(formatIssueItem); + if (numericMatches.length > 0) { + return numericMatches; + } + } + + return fuzzyFilter(issues, query, (issue) => `${issue.number} ${issue.title}`) + .slice(0, MAX_SUGGESTIONS) + .map(formatIssueItem); +} + +function createIssueAutocompleteProvider( + current: AutocompleteProvider, + getIssues: () => Promise, +): AutocompleteProvider { + return { + async getSuggestions(lines, cursorLine, cursorCol, options): Promise { + const currentLine = lines[cursorLine] ?? ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + const token = extractIssueToken(textBeforeCursor); + if (token === undefined) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const issues = await getIssues(); + if (options.signal.aborted || !issues || issues.length === 0) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const suggestions = filterIssues(issues, token); + if (suggestions.length === 0) { + return current.getSuggestions(lines, cursorLine, cursorCol, options); + } + + return { + items: suggestions, + prefix: `#${token}`, + }; + }, + + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + }; +} + +export default function (pi: ExtensionAPI): void { + pi.on("session_start", async (_event, ctx) => { + const resolvedRepo = await resolveGitHubRepo(pi, ctx.cwd); + if (!resolvedRepo.ok) { + ctx.ui.notify(resolvedRepo.error, "error"); + return; + } + + const repo = resolvedRepo.repo; + let issuesPromise: Promise | undefined; + let loadErrorShown = false; + + const getIssues = async (): Promise => { + issuesPromise ||= (async () => { + const result = await pi.exec( + "gh", + [ + "issue", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + String(MAX_ISSUES), + "--json", + "number,title,state", + ], + { cwd: ctx.cwd, timeout: 5_000 }, + ); + if (result.code !== 0) { + if (!loadErrorShown) { + loadErrorShown = true; + const details = result.stderr.trim() || `exit code ${result.code}`; + ctx.ui.notify(`github-issue-autocomplete: failed to load issues: ${details}`, "error"); + } + return undefined; + } + + try { + return JSON.parse(result.stdout) as GitHubIssue[]; + } catch { + if (!loadErrorShown) { + loadErrorShown = true; + ctx.ui.notify("github-issue-autocomplete: failed to parse gh issue list output", "error"); + } + return undefined; + } + })(); + return issuesPromise; + }; + + void getIssues(); + ctx.ui.addAutocompleteProvider((current) => createIssueAutocompleteProvider(current, getIssues)); + }); +} diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 0820f47b..d6158fa9 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -29,6 +29,7 @@ export type { AppendEntryHandler, // App keybindings (for custom editors) AppKeybinding, + AutocompleteProviderFactory, // Events - Tool (ToolCallEvent types) BashToolCallEvent, BashToolResultEvent, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 15c571bf..14278d79 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -204,6 +204,7 @@ const noOpUIContext: ExtensionUIContext = { setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, + addAutocompleteProvider: () => {}, setEditorComponent: () => {}, get theme() { return theme; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 185195b8..0ec39187 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -30,6 +30,7 @@ import type { } from "@mariozechner/pi-ai"; import type { AutocompleteItem, + AutocompleteProvider, Component, EditorComponent, EditorTheme, @@ -112,6 +113,9 @@ export interface WorkingIndicatorOptions { intervalMs?: number; } +/** Wrap the current autocomplete provider with additional behavior. */ +export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider; + /** * UI context for extensions to request interactive UI. * Each mode (interactive, RPC, print) provides its own implementation. @@ -206,6 +210,9 @@ export interface ExtensionUIContext { /** Show a multi-line editor for text editing. */ editor(title: string, prefill?: string): Promise; + /** Stack additional autocomplete behavior on top of the built-in provider. */ + addAutocompleteProvider(factory: AutocompleteProviderFactory): void; + /** * Set a custom editor component via factory function. * Pass undefined to restore the default editor. diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 809870ed..f506adde 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -54,6 +54,7 @@ export type { AgentToolResult, AgentToolUpdateCallback, AppKeybinding, + AutocompleteProviderFactory, BashToolCallEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 24a700a1..fae1c02b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -11,6 +11,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@mariozechner/pi-ai"; import type { AutocompleteItem, + AutocompleteProvider, EditorComponent, EditorTheme, Keybinding, @@ -50,6 +51,7 @@ import { import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js"; import type { + AutocompleteProviderFactory, ExtensionCommandContext, ExtensionContext, ExtensionRunner, @@ -191,7 +193,8 @@ export class InteractiveMode { private statusContainer: Container; private defaultEditor: CustomEditor; private editor: EditorComponent; - private autocompleteProvider: CombinedAutocompleteProvider | undefined; + private autocompleteProvider: AutocompleteProvider | undefined; + private autocompleteProviderWrappers: AutocompleteProviderFactory[] = []; private fdPath: string | undefined; private editorContainer: Container; private footer: FooterComponent; @@ -391,7 +394,7 @@ export class InteractiveMode { })); } - private setupAutocomplete(fdPath: string | undefined): void { + private createBaseAutocompleteProvider(): AutocompleteProvider { // Define commands for autocomplete const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ name: command.name, @@ -461,15 +464,23 @@ export class InteractiveMode { } } - // Setup autocomplete - this.autocompleteProvider = new CombinedAutocompleteProvider( + return new CombinedAutocompleteProvider( [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], this.sessionManager.getCwd(), - fdPath, + this.fdPath, ); - this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider); + } + + private setupAutocompleteProvider(): void { + let provider = this.createBaseAutocompleteProvider(); + for (const wrapProvider of this.autocompleteProviderWrappers) { + provider = wrapProvider(provider); + } + + this.autocompleteProvider = provider; + this.defaultEditor.setAutocompleteProvider(provider); if (this.editor !== this.defaultEditor) { - this.editor.setAutocompleteProvider?.(this.autocompleteProvider); + this.editor.setAutocompleteProvider?.(provider); } } @@ -1492,7 +1503,7 @@ export class InteractiveMode { }); setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - this.setupAutocomplete(this.fdPath); + this.setupAutocompleteProvider(); const extensionRunner = this.session.extensionRunner; this.setupExtensionShortcuts(extensionRunner); @@ -1708,7 +1719,9 @@ export class InteractiveMode { this.clearExtensionWidgets(); this.footerDataProvider.clearExtensionStatuses(); this.footer.invalidate(); + this.autocompleteProviderWrappers = []; this.setCustomEditorComponent(undefined); + this.setupAutocompleteProvider(); this.defaultEditor.onExtensionShortcut = undefined; this.updateTerminalTitle(); this.pendingWorkingMessage = undefined; @@ -1886,6 +1899,10 @@ export class InteractiveMode { setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(), editor: (title, prefill) => this.showExtensionEditor(title, prefill), + addAutocompleteProvider: (factory) => { + this.autocompleteProviderWrappers.push(factory); + this.setupAutocompleteProvider(); + }, setEditorComponent: (factory) => this.setCustomEditorComponent(factory), get theme() { return theme; @@ -3705,7 +3722,7 @@ export class InteractiveMode { }, onEnableSkillCommandsChange: (enabled) => { this.settingsManager.setEnableSkillCommands(enabled); - this.setupAutocomplete(this.fdPath); + this.setupAutocompleteProvider(); }, onSteeringModeChange: (mode) => { this.session.setSteeringMode(mode); @@ -4493,7 +4510,7 @@ export class InteractiveMode { } this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); - this.setupAutocomplete(this.fdPath); + this.setupAutocompleteProvider(); const runner = this.session.extensionRunner; this.setupExtensionShortcuts(runner); this.rebuildChatFromMessages(); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index bfe49703..5c1d24d1 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -259,6 +259,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { }); }); +describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => { + test("stores wrapper factories and rebuilds autocomplete immediately", () => { + const wrapper: AutocompleteProviderFactory = (current) => current; + const fakeThis = { + autocompleteProviderWrappers: [] as AutocompleteProviderFactory[], + setupAutocompleteProvider: vi.fn(), + }; + + const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis); + uiContext.addAutocompleteProvider(wrapper); + + expect(fakeThis.autocompleteProviderWrappers).toEqual([wrapper]); + expect(fakeThis.setupAutocompleteProvider).toHaveBeenCalledTimes(1); + }); +}); + +describe("InteractiveMode.setupAutocompleteProvider", () => { + test("stacks wrapper factories over a fresh base provider", () => { + const defaultEditor = { setAutocompleteProvider: vi.fn() }; + const customEditor = { setAutocompleteProvider: vi.fn() }; + const calls: string[] = []; + + const wrap1: AutocompleteProviderFactory = (current): AutocompleteProvider => ({ + async getSuggestions(lines, cursorLine, cursorCol, options) { + calls.push("getSuggestions:wrap1"); + return current.getSuggestions(lines, cursorLine, cursorCol, options); + }, + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + calls.push("applyCompletion:wrap1"); + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + calls.push("shouldTrigger:wrap1"); + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + }); + const wrap2: AutocompleteProviderFactory = (current): AutocompleteProvider => ({ + async getSuggestions(lines, cursorLine, cursorCol, options) { + calls.push("getSuggestions:wrap2"); + return current.getSuggestions(lines, cursorLine, cursorCol, options); + }, + applyCompletion(lines, cursorLine, cursorCol, item, prefix) { + calls.push("applyCompletion:wrap2"); + return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + }, + shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { + calls.push("shouldTrigger:wrap2"); + return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; + }, + }); + + const fakeThis = { + createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined), + defaultEditor, + editor: customEditor, + autocompleteProviderWrappers: [wrap1, wrap2], + }; + + (InteractiveMode as any).prototype.setupAutocompleteProvider.call(fakeThis); + + expect(defaultEditor.setAutocompleteProvider).toHaveBeenCalledTimes(1); + expect(customEditor.setAutocompleteProvider).toHaveBeenCalledTimes(1); + const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider; + expect(provider).toBe(customEditor.setAutocompleteProvider.mock.calls[0]?.[0]); + expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true); + expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]); + }); +}); + describe("InteractiveMode.showLoadedResources", () => { beforeAll(() => { initTheme("dark"); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 650967dd..8e19d62b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `setProgress(active: boolean)` to the `Terminal` interface for OSC 9;4 progress indicator support +- Added generic stacked autocomplete support for extension wrappers via `AutocompleteProvider.shouldTriggerFileCompletion?()` and `#` as a natural autocomplete trigger alongside `@` ([#2983](https://github.com/badlogic/pi-mono/issues/2983)) ## [0.68.1] - 2026-04-22 diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 80795192..965dc303 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -261,6 +261,9 @@ export interface AutocompleteProvider { cursorLine: number; cursorCol: number; }; + + // Check if file completion should trigger for explicit Tab completion + shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean; } // Combined provider that handles both slash commands and file paths diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 419de50a..a0337d60 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,4 +1,4 @@ -import type { AutocompleteProvider, AutocompleteSuggestions, CombinedAutocompleteProvider } from "../autocomplete.js"; +import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.js"; import { getKeybindings } from "../keybindings.js"; import { decodePrintableKey, matchesKey } from "../keys.js"; import { KillRing } from "../kill-ring.js"; @@ -1054,17 +1054,16 @@ export class Editor implements Component, Focusable { if (char === "/" && this.isAtStartOfMessage()) { this.tryTriggerAutocomplete(); } - // Auto-trigger for "@" file reference (fuzzy search) - else if (char === "@") { + // Auto-trigger for symbol-based completion like @ or # at token boundaries + else if (char === "@" || char === "#") { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - // Only trigger if @ is after whitespace or at start of line - const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2]; - if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") { + const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; + if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") { this.tryTriggerAutocomplete(); } } - // Also auto-trigger when typing letters in a slash command context + // Also auto-trigger when typing letters in a slash command or symbol completion context else if (/[a-zA-Z0-9.\-_]/.test(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); @@ -1072,8 +1071,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Check if we're in an @ file reference context - else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { + // Check if we're in a symbol-based completion context like @ or # + else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { this.tryTriggerAutocomplete(); } } @@ -1240,8 +1239,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // @ file reference context - else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { + // Symbol-based completion context like @ or # + else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { this.tryTriggerAutocomplete(); } } @@ -1604,8 +1603,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // @ file reference context - else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { + // Symbol-based completion context like @ or # + else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { this.tryTriggerAutocomplete(); } } @@ -2108,10 +2107,13 @@ export class Editor implements Component, Focusable { if (!this.autocompleteProvider) return; if (options.force) { - const provider = this.autocompleteProvider as CombinedAutocompleteProvider; const shouldTrigger = - !provider.shouldTriggerFileCompletion || - provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol); + !this.autocompleteProvider.shouldTriggerFileCompletion || + this.autocompleteProvider.shouldTriggerFileCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + ); if (!shouldTrigger) { return; } @@ -2162,8 +2164,8 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor); - return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor); + return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; } private async runAutocompleteRequest( diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 666d9059..c3805129 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2132,6 +2132,39 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("debounces # autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const text = (lines[0] || "").slice(0, cursorCol); + return { + items: [{ value: "#2983", label: "#2983" }], + prefix: text, + }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + editor.handleInput("#"); + editor.handleInput("2"); + editor.handleInput("9"); + editor.handleInput("8"); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + it("aborts active @ autocomplete when typing continues", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let aborts = 0;