feat(coding-agent,tui): add stacked autocomplete providers closes #2983

This commit is contained in:
Mario Zechner
2026-04-22 15:44:08 +02:00
parent 4e919868f6
commit 8234ebf9ee
16 changed files with 465 additions and 33 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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;