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

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
| `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()` |

View File

@@ -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<RepoResolution> {
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<GitHubIssue[] | undefined>,
): AutocompleteProvider {
return {
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
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<GitHubIssue[] | undefined> | undefined;
let loadErrorShown = false;
const getIssues = async (): Promise<GitHubIssue[] | undefined> => {
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));
});
}

View File

@@ -29,6 +29,7 @@ export type {
AppendEntryHandler,
// App keybindings (for custom editors)
AppKeybinding,
AutocompleteProviderFactory,
// Events - Tool (ToolCallEvent types)
BashToolCallEvent,
BashToolResultEvent,

View File

@@ -204,6 +204,7 @@ const noOpUIContext: ExtensionUIContext = {
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
addAutocompleteProvider: () => {},
setEditorComponent: () => {},
get theme() {
return theme;

View File

@@ -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<string | undefined>;
/** 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.

View File

@@ -54,6 +54,7 @@ export type {
AgentToolResult,
AgentToolUpdateCallback,
AppKeybinding,
AutocompleteProviderFactory,
BashToolCallEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,

View File

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

View File

@@ -259,6 +259,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
});
},
addAutocompleteProvider(): void {
// Autocomplete provider composition is not supported in RPC mode
},
setEditorComponent(): void {
// Custom editor components not supported in RPC mode
},

View File

@@ -1,7 +1,8 @@
import { homedir } from "node:os";
import * as path from "node:path";
import { Container } from "@mariozechner/pi-tui";
import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@mariozechner/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import type { AutocompleteProviderFactory } from "../src/core/extensions/types.js";
import type { SourceInfo } from "../src/core/source-info.js";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
import { initTheme } from "../src/modes/interactive/theme/theme.js";
@@ -147,6 +148,75 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
});
});
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");

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;