@@ -4,12 +4,10 @@
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
<<<<<<< Updated upstream
|
|
||||||
- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features.
|
- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features.
|
||||||
- Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)).
|
- Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)).
|
||||||
=======
|
|
||||||
- Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default.
|
- Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default.
|
||||||
>>>>>>> Stashed changes
|
- Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -2281,6 +2281,7 @@ ctx.ui.pasteToEditor("pasted content");
|
|||||||
|
|
||||||
// Stack custom autocomplete behavior on top of the built-in provider
|
// Stack custom autocomplete behavior on top of the built-in provider
|
||||||
ctx.ui.addAutocompleteProvider((current) => ({
|
ctx.ui.addAutocompleteProvider((current) => ({
|
||||||
|
triggerCharacters: ["#"],
|
||||||
async getSuggestions(lines, line, col, options) {
|
async getSuggestions(lines, line, col, options) {
|
||||||
const beforeCursor = (lines[line] ?? "").slice(0, col);
|
const beforeCursor = (lines[line] ?? "").slice(0, col);
|
||||||
const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);
|
const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/);
|
||||||
@@ -2329,7 +2330,7 @@ Custom working-indicator frames are rendered verbatim. If you want colors, add t
|
|||||||
|
|
||||||
### Autocomplete Providers
|
### Autocomplete Providers
|
||||||
|
|
||||||
Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider.
|
Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`.
|
||||||
|
|
||||||
Typical pattern:
|
Typical pattern:
|
||||||
|
|
||||||
@@ -2341,6 +2342,7 @@ Typical pattern:
|
|||||||
```typescript
|
```typescript
|
||||||
pi.on("session_start", (_event, ctx) => {
|
pi.on("session_start", (_event, ctx) => {
|
||||||
ctx.ui.addAutocompleteProvider((current) => ({
|
ctx.ui.addAutocompleteProvider((current) => ({
|
||||||
|
triggerCharacters: ["#"],
|
||||||
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
||||||
const line = lines[cursorLine] ?? "";
|
const line = lines[cursorLine] ?? "";
|
||||||
const beforeCursor = line.slice(0, cursorCol);
|
const beforeCursor = line.slice(0, cursorCol);
|
||||||
|
|||||||
@@ -555,8 +555,13 @@ export class InteractiveMode {
|
|||||||
|
|
||||||
private setupAutocompleteProvider(): void {
|
private setupAutocompleteProvider(): void {
|
||||||
let provider = this.createBaseAutocompleteProvider();
|
let provider = this.createBaseAutocompleteProvider();
|
||||||
|
const triggerCharacters: string[] = [];
|
||||||
for (const wrapProvider of this.autocompleteProviderWrappers) {
|
for (const wrapProvider of this.autocompleteProviderWrappers) {
|
||||||
provider = wrapProvider(provider);
|
provider = wrapProvider(provider);
|
||||||
|
triggerCharacters.push(...(provider.triggerCharacters ?? []));
|
||||||
|
}
|
||||||
|
if (triggerCharacters.length > 0) {
|
||||||
|
provider.triggerCharacters = [...new Set(triggerCharacters)];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.autocompleteProvider = provider;
|
this.autocompleteProvider = provider;
|
||||||
|
|||||||
@@ -324,6 +324,36 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
|
|||||||
expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true);
|
expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true);
|
||||||
expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]);
|
expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("merges triggerCharacters from wrapper factories", () => {
|
||||||
|
const defaultEditor = { setAutocompleteProvider: vi.fn() };
|
||||||
|
const customEditor = { setAutocompleteProvider: vi.fn() };
|
||||||
|
const passThrough =
|
||||||
|
(triggerCharacters: string[]): AutocompleteProviderFactory =>
|
||||||
|
(current) => ({
|
||||||
|
triggerCharacters,
|
||||||
|
getSuggestions: (lines, cursorLine, cursorCol, options) =>
|
||||||
|
current.getSuggestions(lines, cursorLine, cursorCol, options),
|
||||||
|
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) =>
|
||||||
|
current.applyCompletion(lines, cursorLine, cursorCol, item, prefix),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeThis = {
|
||||||
|
createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined),
|
||||||
|
defaultEditor,
|
||||||
|
editor: customEditor,
|
||||||
|
autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])],
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
InteractiveMode as unknown as {
|
||||||
|
prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void };
|
||||||
|
}
|
||||||
|
).prototype.setupAutocompleteProvider.call(fakeThis);
|
||||||
|
|
||||||
|
const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider;
|
||||||
|
expect(provider.triggerCharacters).toEqual(["$", "!"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("InteractiveMode.showLoadedResources", () => {
|
describe("InteractiveMode.showLoadedResources", () => {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `AutocompleteProvider.triggerCharacters` so editor autocomplete can naturally trigger on provider-defined token prefixes ([#4703](https://github.com/earendil-works/pi/issues/4703)).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)).
|
- Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)).
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ export interface AutocompleteSuggestions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AutocompleteProvider {
|
export interface AutocompleteProvider {
|
||||||
|
/** Characters that should naturally trigger this provider at token boundaries. */
|
||||||
|
triggerCharacters?: string[];
|
||||||
|
|
||||||
// Get autocomplete suggestions for current text/cursor position
|
// Get autocomplete suggestions for current text/cursor position
|
||||||
// Returns null if no suggestions available
|
// Returns null if no suggestions available
|
||||||
getSuggestions(
|
getSuggestions(
|
||||||
|
|||||||
@@ -219,6 +219,20 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
||||||
|
const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
|
||||||
|
|
||||||
|
function escapeCharacterClass(value: string): string {
|
||||||
|
return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTriggerPattern(triggerCharacters: string[]): RegExp {
|
||||||
|
return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDebouncePattern(triggerCharacters: string[]): RegExp {
|
||||||
|
const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
|
||||||
|
return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
|
||||||
|
}
|
||||||
|
|
||||||
export class Editor implements Component, Focusable {
|
export class Editor implements Component, Focusable {
|
||||||
private state: EditorState = {
|
private state: EditorState = {
|
||||||
@@ -245,6 +259,9 @@ export class Editor implements Component, Focusable {
|
|||||||
|
|
||||||
// Autocomplete support
|
// Autocomplete support
|
||||||
private autocompleteProvider?: AutocompleteProvider;
|
private autocompleteProvider?: AutocompleteProvider;
|
||||||
|
private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
|
||||||
|
private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
|
||||||
|
private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
|
||||||
private autocompleteList?: SelectList;
|
private autocompleteList?: SelectList;
|
||||||
private autocompleteState: "regular" | "force" | null = null;
|
private autocompleteState: "regular" | "force" | null = null;
|
||||||
private autocompletePrefix: string = "";
|
private autocompletePrefix: string = "";
|
||||||
@@ -339,6 +356,7 @@ export class Editor implements Component, Focusable {
|
|||||||
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
||||||
this.cancelAutocomplete();
|
this.cancelAutocomplete();
|
||||||
this.autocompleteProvider = provider;
|
this.autocompleteProvider = provider;
|
||||||
|
this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1072,8 +1090,8 @@ export class Editor implements Component, Focusable {
|
|||||||
if (char === "/" && this.isAtStartOfMessage()) {
|
if (char === "/" && this.isAtStartOfMessage()) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// Auto-trigger for symbol-based completion like @ or # at token boundaries
|
// Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
|
||||||
else if (char === "@" || char === "#") {
|
else if (this.autocompleteTriggerCharacters.includes(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);
|
||||||
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
||||||
@@ -1089,8 +1107,8 @@ export class Editor implements Component, Focusable {
|
|||||||
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// Check if we're in a symbol-based completion context like @ or #
|
// Check if we're in a symbol-based completion context like @, #, or provider triggers
|
||||||
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1269,8 +1287,8 @@ export class Editor implements Component, Focusable {
|
|||||||
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// Symbol-based completion context like @ or #
|
// Symbol-based completion context like @, #, or provider triggers
|
||||||
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1633,8 +1651,8 @@ export class Editor implements Component, Focusable {
|
|||||||
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
// Symbol-based completion context like @ or #
|
// Symbol-based completion context like @, #, or provider triggers
|
||||||
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2132,6 +2150,19 @@ export class Editor implements Component, Focusable {
|
|||||||
await this.autocompleteRequestTask;
|
await this.autocompleteRequestTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void {
|
||||||
|
const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
|
||||||
|
for (const character of triggerCharacters) {
|
||||||
|
if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.push(character);
|
||||||
|
}
|
||||||
|
this.autocompleteTriggerCharacters = next;
|
||||||
|
this.autocompleteTriggerPattern = buildTriggerPattern(next);
|
||||||
|
this.autocompleteDebouncePattern = buildDebouncePattern(next);
|
||||||
|
}
|
||||||
|
|
||||||
private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
|
private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
|
||||||
if (options.explicitTab || options.force) {
|
if (options.explicitTab || options.force) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -2139,8 +2170,7 @@ 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 isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
|
return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
||||||
return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runAutocompleteRequest(
|
private async runAutocompleteRequest(
|
||||||
|
|||||||
@@ -2347,6 +2347,58 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("debounces custom triggerCharacters autocomplete while typing", async () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let suggestionCalls = 0;
|
||||||
|
|
||||||
|
editor.setAutocompleteProvider({
|
||||||
|
triggerCharacters: ["$"],
|
||||||
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
|
suggestionCalls += 1;
|
||||||
|
const prefix = (lines[0] || "").slice(0, cursorCol);
|
||||||
|
return { items: [{ value: "$skill-name", label: "skill-name" }], prefix };
|
||||||
|
},
|
||||||
|
applyCompletion,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.handleInput("$");
|
||||||
|
editor.handleInput("s");
|
||||||
|
editor.handleInput("k");
|
||||||
|
|
||||||
|
assert.strictEqual(suggestionCalls, 0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
await flushAutocomplete();
|
||||||
|
|
||||||
|
assert.strictEqual(suggestionCalls, 1);
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets custom triggerCharacters when provider changes", async () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let suggestionCalls = 0;
|
||||||
|
|
||||||
|
editor.setAutocompleteProvider({
|
||||||
|
triggerCharacters: ["$"],
|
||||||
|
getSuggestions: async () => ({ items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }),
|
||||||
|
applyCompletion,
|
||||||
|
});
|
||||||
|
editor.setAutocompleteProvider({
|
||||||
|
getSuggestions: async () => {
|
||||||
|
suggestionCalls += 1;
|
||||||
|
return { items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" };
|
||||||
|
},
|
||||||
|
applyCompletion,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.handleInput("$");
|
||||||
|
editor.handleInput("s");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
await flushAutocomplete();
|
||||||
|
|
||||||
|
assert.strictEqual(suggestionCalls, 0);
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
|
});
|
||||||
|
|
||||||
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