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;
|
mockState.lastParams = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies Anthropic-style cache markers for built-in opencode-go Qwen models", async () => {
|
it("applies Anthropic-style cache markers when model compat enables them", async () => {
|
||||||
const model = getModel("opencode-go", "qwen3.5-plus");
|
const model: Model<"openai-completions"> = {
|
||||||
expect(model.compat?.cacheControlFormat).toBe("anthropic");
|
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);
|
const params = await capturePayload(model);
|
||||||
expectAnthropicCacheMarkers(params);
|
expectAnthropicCacheMarkers(params);
|
||||||
@@ -141,7 +159,26 @@ describe("openai-completions cacheControlFormat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("omits Anthropic-style cache markers when cacheRetention is none", async () => {
|
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 params = await capturePayload(model, { cacheRetention: "none" });
|
||||||
const instructionMessage = getInstructionMessage(params);
|
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 `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 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
|
### Breaking Changes
|
||||||
|
|
||||||
|
|||||||
@@ -2063,6 +2063,7 @@ Extensions can interact with users via `ctx.ui` methods and customize how messag
|
|||||||
- Status indicators (setStatus)
|
- Status indicators (setStatus)
|
||||||
- Working message and indicator during streaming (`setWorkingMessage`, `setWorkingIndicator`)
|
- Working message and indicator during streaming (`setWorkingMessage`, `setWorkingIndicator`)
|
||||||
- Widgets above/below editor (setWidget)
|
- Widgets above/below editor (setWidget)
|
||||||
|
- Autocomplete providers layered on top of built-in slash/path completion (addAutocompleteProvider)
|
||||||
- Custom footers (setFooter)
|
- Custom footers (setFooter)
|
||||||
|
|
||||||
### Dialogs
|
### Dialogs
|
||||||
@@ -2184,6 +2185,28 @@ const current = ctx.ui.getEditorText();
|
|||||||
// Paste into editor (triggers paste handling, including collapse for large content)
|
// Paste into editor (triggers paste handling, including collapse for large content)
|
||||||
ctx.ui.pasteToEditor("pasted 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
|
// Tool output expansion
|
||||||
const wasExpanded = ctx.ui.getToolsExpanded();
|
const wasExpanded = ctx.ui.getToolsExpanded();
|
||||||
ctx.ui.setToolsExpanded(true);
|
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(...)`.
|
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
|
### Custom Components
|
||||||
|
|
||||||
For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:
|
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** |||
|
| **UI Components** |||
|
||||||
| `status-line.ts` | Footer status indicator | `setStatus`, session events |
|
| `status-line.ts` | Footer status indicator | `setStatus`, session events |
|
||||||
| `working-indicator.ts` | Customize the streaming working indicator | `setWorkingIndicator`, `registerCommand` |
|
| `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-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` |
|
||||||
| `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` |
|
| `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` |
|
||||||
| `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` |
|
| `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>` |
|
| `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()` |
|
| `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 |
|
| `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 |
|
| `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()` |
|
| `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()` |
|
| `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,
|
AppendEntryHandler,
|
||||||
// App keybindings (for custom editors)
|
// App keybindings (for custom editors)
|
||||||
AppKeybinding,
|
AppKeybinding,
|
||||||
|
AutocompleteProviderFactory,
|
||||||
// Events - Tool (ToolCallEvent types)
|
// Events - Tool (ToolCallEvent types)
|
||||||
BashToolCallEvent,
|
BashToolCallEvent,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ const noOpUIContext: ExtensionUIContext = {
|
|||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
editor: async () => undefined,
|
editor: async () => undefined,
|
||||||
|
addAutocompleteProvider: () => {},
|
||||||
setEditorComponent: () => {},
|
setEditorComponent: () => {},
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
} from "@mariozechner/pi-ai";
|
} from "@mariozechner/pi-ai";
|
||||||
import type {
|
import type {
|
||||||
AutocompleteItem,
|
AutocompleteItem,
|
||||||
|
AutocompleteProvider,
|
||||||
Component,
|
Component,
|
||||||
EditorComponent,
|
EditorComponent,
|
||||||
EditorTheme,
|
EditorTheme,
|
||||||
@@ -112,6 +113,9 @@ export interface WorkingIndicatorOptions {
|
|||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wrap the current autocomplete provider with additional behavior. */
|
||||||
|
export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI context for extensions to request interactive UI.
|
* UI context for extensions to request interactive UI.
|
||||||
* Each mode (interactive, RPC, print) provides its own implementation.
|
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||||
@@ -206,6 +210,9 @@ export interface ExtensionUIContext {
|
|||||||
/** Show a multi-line editor for text editing. */
|
/** Show a multi-line editor for text editing. */
|
||||||
editor(title: string, prefill?: string): Promise<string | undefined>;
|
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.
|
* Set a custom editor component via factory function.
|
||||||
* Pass undefined to restore the default editor.
|
* Pass undefined to restore the default editor.
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type {
|
|||||||
AgentToolResult,
|
AgentToolResult,
|
||||||
AgentToolUpdateCallback,
|
AgentToolUpdateCallback,
|
||||||
AppKeybinding,
|
AppKeybinding,
|
||||||
|
AutocompleteProviderFactory,
|
||||||
BashToolCallEvent,
|
BashToolCallEvent,
|
||||||
BeforeAgentStartEvent,
|
BeforeAgentStartEvent,
|
||||||
BeforeAgentStartEventResult,
|
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 { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@mariozechner/pi-ai";
|
||||||
import type {
|
import type {
|
||||||
AutocompleteItem,
|
AutocompleteItem,
|
||||||
|
AutocompleteProvider,
|
||||||
EditorComponent,
|
EditorComponent,
|
||||||
EditorTheme,
|
EditorTheme,
|
||||||
Keybinding,
|
Keybinding,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
|
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
|
||||||
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
|
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
|
||||||
import type {
|
import type {
|
||||||
|
AutocompleteProviderFactory,
|
||||||
ExtensionCommandContext,
|
ExtensionCommandContext,
|
||||||
ExtensionContext,
|
ExtensionContext,
|
||||||
ExtensionRunner,
|
ExtensionRunner,
|
||||||
@@ -191,7 +193,8 @@ export class InteractiveMode {
|
|||||||
private statusContainer: Container;
|
private statusContainer: Container;
|
||||||
private defaultEditor: CustomEditor;
|
private defaultEditor: CustomEditor;
|
||||||
private editor: EditorComponent;
|
private editor: EditorComponent;
|
||||||
private autocompleteProvider: CombinedAutocompleteProvider | undefined;
|
private autocompleteProvider: AutocompleteProvider | undefined;
|
||||||
|
private autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];
|
||||||
private fdPath: string | undefined;
|
private fdPath: string | undefined;
|
||||||
private editorContainer: Container;
|
private editorContainer: Container;
|
||||||
private footer: FooterComponent;
|
private footer: FooterComponent;
|
||||||
@@ -391,7 +394,7 @@ export class InteractiveMode {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupAutocomplete(fdPath: string | undefined): void {
|
private createBaseAutocompleteProvider(): AutocompleteProvider {
|
||||||
// Define commands for autocomplete
|
// Define commands for autocomplete
|
||||||
const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
|
const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
|
||||||
name: command.name,
|
name: command.name,
|
||||||
@@ -461,15 +464,23 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup autocomplete
|
return new CombinedAutocompleteProvider(
|
||||||
this.autocompleteProvider = new CombinedAutocompleteProvider(
|
|
||||||
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
|
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
|
||||||
this.sessionManager.getCwd(),
|
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) {
|
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);
|
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
||||||
this.setupAutocomplete(this.fdPath);
|
this.setupAutocompleteProvider();
|
||||||
|
|
||||||
const extensionRunner = this.session.extensionRunner;
|
const extensionRunner = this.session.extensionRunner;
|
||||||
this.setupExtensionShortcuts(extensionRunner);
|
this.setupExtensionShortcuts(extensionRunner);
|
||||||
@@ -1708,7 +1719,9 @@ export class InteractiveMode {
|
|||||||
this.clearExtensionWidgets();
|
this.clearExtensionWidgets();
|
||||||
this.footerDataProvider.clearExtensionStatuses();
|
this.footerDataProvider.clearExtensionStatuses();
|
||||||
this.footer.invalidate();
|
this.footer.invalidate();
|
||||||
|
this.autocompleteProviderWrappers = [];
|
||||||
this.setCustomEditorComponent(undefined);
|
this.setCustomEditorComponent(undefined);
|
||||||
|
this.setupAutocompleteProvider();
|
||||||
this.defaultEditor.onExtensionShortcut = undefined;
|
this.defaultEditor.onExtensionShortcut = undefined;
|
||||||
this.updateTerminalTitle();
|
this.updateTerminalTitle();
|
||||||
this.pendingWorkingMessage = undefined;
|
this.pendingWorkingMessage = undefined;
|
||||||
@@ -1886,6 +1899,10 @@ export class InteractiveMode {
|
|||||||
setEditorText: (text) => this.editor.setText(text),
|
setEditorText: (text) => this.editor.setText(text),
|
||||||
getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(),
|
getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(),
|
||||||
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
|
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
|
||||||
|
addAutocompleteProvider: (factory) => {
|
||||||
|
this.autocompleteProviderWrappers.push(factory);
|
||||||
|
this.setupAutocompleteProvider();
|
||||||
|
},
|
||||||
setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
|
setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
@@ -3705,7 +3722,7 @@ export class InteractiveMode {
|
|||||||
},
|
},
|
||||||
onEnableSkillCommandsChange: (enabled) => {
|
onEnableSkillCommandsChange: (enabled) => {
|
||||||
this.settingsManager.setEnableSkillCommands(enabled);
|
this.settingsManager.setEnableSkillCommands(enabled);
|
||||||
this.setupAutocomplete(this.fdPath);
|
this.setupAutocompleteProvider();
|
||||||
},
|
},
|
||||||
onSteeringModeChange: (mode) => {
|
onSteeringModeChange: (mode) => {
|
||||||
this.session.setSteeringMode(mode);
|
this.session.setSteeringMode(mode);
|
||||||
@@ -4493,7 +4510,7 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
|
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
|
||||||
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
||||||
this.setupAutocomplete(this.fdPath);
|
this.setupAutocompleteProvider();
|
||||||
const runner = this.session.extensionRunner;
|
const runner = this.session.extensionRunner;
|
||||||
this.setupExtensionShortcuts(runner);
|
this.setupExtensionShortcuts(runner);
|
||||||
this.rebuildChatFromMessages();
|
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 {
|
setEditorComponent(): void {
|
||||||
// Custom editor components not supported in RPC mode
|
// Custom editor components not supported in RPC mode
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import * as path from "node:path";
|
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 { 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 type { SourceInfo } from "../src/core/source-info.js";
|
||||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||||
import { initTheme } from "../src/modes/interactive/theme/theme.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", () => {
|
describe("InteractiveMode.showLoadedResources", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
initTheme("dark");
|
initTheme("dark");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added `setProgress(active: boolean)` to the `Terminal` interface for OSC 9;4 progress indicator support
|
- 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
|
## [0.68.1] - 2026-04-22
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ export interface AutocompleteProvider {
|
|||||||
cursorLine: number;
|
cursorLine: number;
|
||||||
cursorCol: 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
|
// 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 { getKeybindings } from "../keybindings.js";
|
||||||
import { decodePrintableKey, matchesKey } from "../keys.js";
|
import { decodePrintableKey, matchesKey } from "../keys.js";
|
||||||
import { KillRing } from "../kill-ring.js";
|
import { KillRing } from "../kill-ring.js";
|
||||||
@@ -1054,17 +1054,16 @@ export class Editor implements Component, Focusable {
|
|||||||
if (char === "/" && this.isAtStartOfMessage()) {
|
if (char === "/" && this.isAtStartOfMessage()) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// Auto-trigger for "@" file reference (fuzzy search)
|
// Auto-trigger for symbol-based completion like @ or # at token boundaries
|
||||||
else if (char === "@") {
|
else if (char === "@" || char === "#") {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
// Only trigger if @ is after whitespace or at start of line
|
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
||||||
const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2];
|
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
||||||
if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") {
|
|
||||||
this.tryTriggerAutocomplete();
|
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)) {
|
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
@@ -1072,8 +1071,8 @@ export class Editor implements Component, Focusable {
|
|||||||
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// Check if we're in an @ file reference context
|
// Check if we're in a symbol-based completion context like @ or #
|
||||||
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1240,8 +1239,8 @@ export class Editor implements Component, Focusable {
|
|||||||
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// @ file reference context
|
// Symbol-based completion context like @ or #
|
||||||
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1604,8 +1603,8 @@ export class Editor implements Component, Focusable {
|
|||||||
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// @ file reference context
|
// Symbol-based completion context like @ or #
|
||||||
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2108,10 +2107,13 @@ export class Editor implements Component, Focusable {
|
|||||||
if (!this.autocompleteProvider) return;
|
if (!this.autocompleteProvider) return;
|
||||||
|
|
||||||
if (options.force) {
|
if (options.force) {
|
||||||
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
|
||||||
const shouldTrigger =
|
const shouldTrigger =
|
||||||
!provider.shouldTriggerFileCompletion ||
|
!this.autocompleteProvider.shouldTriggerFileCompletion ||
|
||||||
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
this.autocompleteProvider.shouldTriggerFileCompletion(
|
||||||
|
this.state.lines,
|
||||||
|
this.state.cursorLine,
|
||||||
|
this.state.cursorCol,
|
||||||
|
);
|
||||||
if (!shouldTrigger) {
|
if (!shouldTrigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2162,8 +2164,8 @@ export class Editor implements Component, Focusable {
|
|||||||
|
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor);
|
const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
|
||||||
return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runAutocompleteRequest(
|
private async runAutocompleteRequest(
|
||||||
|
|||||||
@@ -2132,6 +2132,39 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
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 () => {
|
it("aborts active @ autocomplete when typing continues", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
let aborts = 0;
|
let aborts = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user