diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6a5ecd59..7bdf6bcd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,12 +4,10 @@ ### Added -<<<<<<< Updated upstream - Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features. - Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). -======= - Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. ->>>>>>> Stashed changes +- Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)). ### Fixed diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index ab5747ab..cb02960f 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2281,6 +2281,7 @@ ctx.ui.pasteToEditor("pasted content"); // Stack custom autocomplete behavior on top of the built-in provider ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, line, col, options) { const beforeCursor = (lines[line] ?? "").slice(0, col); const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); @@ -2329,7 +2330,7 @@ Custom working-indicator frames are rendered verbatim. If you want colors, add t ### Autocomplete Providers -Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. +Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`. Typical pattern: @@ -2341,6 +2342,7 @@ Typical pattern: ```typescript pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, cursorLine, cursorCol, options) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4bacb19a..d50611af 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -555,8 +555,13 @@ export class InteractiveMode { private setupAutocompleteProvider(): void { let provider = this.createBaseAutocompleteProvider(); + const triggerCharacters: string[] = []; for (const wrapProvider of this.autocompleteProviderWrappers) { provider = wrapProvider(provider); + triggerCharacters.push(...(provider.triggerCharacters ?? [])); + } + if (triggerCharacters.length > 0) { + provider.triggerCharacters = [...new Set(triggerCharacters)]; } this.autocompleteProvider = provider; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 5f4a8e21..7ec54c7b 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -324,6 +324,36 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true); expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]); }); + + test("merges triggerCharacters from wrapper factories", () => { + const defaultEditor = { setAutocompleteProvider: vi.fn() }; + const customEditor = { setAutocompleteProvider: vi.fn() }; + const passThrough = + (triggerCharacters: string[]): AutocompleteProviderFactory => + (current) => ({ + triggerCharacters, + getSuggestions: (lines, cursorLine, cursorCol, options) => + current.getSuggestions(lines, cursorLine, cursorCol, options), + applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => + current.applyCompletion(lines, cursorLine, cursorCol, item, prefix), + }); + + const fakeThis = { + createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined), + defaultEditor, + editor: customEditor, + autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])], + }; + + ( + InteractiveMode as unknown as { + prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void }; + } + ).prototype.setupAutocompleteProvider.call(fakeThis); + + const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider; + expect(provider.triggerCharacters).toEqual(["$", "!"]); + }); }); describe("InteractiveMode.showLoadedResources", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 289b1a4c..b3cfcef2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `AutocompleteProvider.triggerCharacters` so editor autocomplete can naturally trigger on provider-defined token prefixes ([#4703](https://github.com/earendil-works/pi/issues/4703)). + ### Fixed - Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 5408967d..205748d8 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -239,6 +239,9 @@ export interface AutocompleteSuggestions { } export interface AutocompleteProvider { + /** Characters that should naturally trigger this provider at token boundaries. */ + triggerCharacters?: string[]; + // Get autocomplete suggestions for current text/cursor position // Returns null if no suggestions available getSuggestions( diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 3b3350b1..128254b2 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -219,6 +219,20 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { }; const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; +const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; + +function escapeCharacterClass(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&"); +} + +function buildTriggerPattern(triggerCharacters: string[]): RegExp { + return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`); +} + +function buildDebouncePattern(triggerCharacters: string[]): RegExp { + const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass); + return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); +} export class Editor implements Component, Focusable { private state: EditorState = { @@ -245,6 +259,9 @@ export class Editor implements Component, Focusable { // Autocomplete support private autocompleteProvider?: AutocompleteProvider; + private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); + private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; private autocompleteState: "regular" | "force" | null = null; private autocompletePrefix: string = ""; @@ -339,6 +356,7 @@ export class Editor implements Component, Focusable { setAutocompleteProvider(provider: AutocompleteProvider): void { this.cancelAutocomplete(); this.autocompleteProvider = provider; + this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []); } /** @@ -1072,8 +1090,8 @@ export class Editor implements Component, Focusable { if (char === "/" && this.isAtStartOfMessage()) { this.tryTriggerAutocomplete(); } - // Auto-trigger for symbol-based completion like @ or # at token boundaries - else if (char === "@" || char === "#") { + // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries + else if (this.autocompleteTriggerCharacters.includes(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; @@ -1089,8 +1107,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Check if we're in a symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Check if we're in a symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1269,8 +1287,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1633,8 +1651,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -2132,6 +2150,19 @@ export class Editor implements Component, Focusable { await this.autocompleteRequestTask; } + private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void { + const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + for (const character of triggerCharacters) { + if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) { + continue; + } + next.push(character); + } + this.autocompleteTriggerCharacters = next; + this.autocompleteTriggerPattern = buildTriggerPattern(next); + this.autocompleteDebouncePattern = buildDebouncePattern(next); + } + private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number { if (options.explicitTab || options.force) { return 0; @@ -2139,8 +2170,7 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor); - return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + return this.autocompleteDebouncePattern.test(textBeforeCursor) ? 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 f7c391f0..2c8a7b62 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2347,6 +2347,58 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("debounces custom triggerCharacters autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const prefix = (lines[0] || "").slice(0, cursorCol); + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + editor.handleInput("k"); + + assert.strictEqual(suggestionCalls, 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("resets custom triggerCharacters when provider changes", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async () => ({ items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }), + applyCompletion, + }); + editor.setAutocompleteProvider({ + getSuggestions: async () => { + suggestionCalls += 1; + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + it("aborts active @ autocomplete when typing continues", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let aborts = 0;