feat(coding-agent,tui): add stacked autocomplete providers closes #2983
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user