feat(coding-agent,tui): add stacked autocomplete providers closes #2983
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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()` |
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type {
|
||||
AppendEntryHandler,
|
||||
// App keybindings (for custom editors)
|
||||
AppKeybinding,
|
||||
AutocompleteProviderFactory,
|
||||
// Events - Tool (ToolCallEvent types)
|
||||
BashToolCallEvent,
|
||||
BashToolResultEvent,
|
||||
|
||||
@@ -204,6 +204,7 @@ const noOpUIContext: ExtensionUIContext = {
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
addAutocompleteProvider: () => {},
|
||||
setEditorComponent: () => {},
|
||||
get theme() {
|
||||
return theme;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,6 +54,7 @@ export type {
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
AppKeybinding,
|
||||
AutocompleteProviderFactory,
|
||||
BashToolCallEvent,
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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