mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +00:00
A couple of autocomplete improvements (#1024)
* fix(tui): keep file suggestions open when typing in Tab-triggered mode Previously, pressing Tab on an empty prompt or after a space would show file suggestions, but typing a letter would dismiss them because the text didn't look like a path pattern. Now the editor tracks whether autocomplete was triggered via Tab (force mode) or naturally (regular mode), and uses the appropriate suggestion method when updating. * fix(tui): hide autocomplete when backspacing slash command to empty Previously, typing / showed slash command suggestions, but pressing Backspace to delete it showed file suggestions instead of hiding all suggestions.
This commit is contained in:
parent
d57a26c88b
commit
b54d689ec1
3 changed files with 152 additions and 56 deletions
|
|
@ -312,9 +312,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
return pathPrefix;
|
return pathPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return empty string only if we're at the beginning of the line or after a space
|
// Return empty string only after a space (not for completely empty text)
|
||||||
// (not after quotes or other delimiters that don't suggest file paths)
|
// Empty text should not trigger file suggestions - that's for forced Tab completion
|
||||||
if (pathPrefix === "" && (text === "" || text.endsWith(" "))) {
|
if (pathPrefix === "" && text.endsWith(" ")) {
|
||||||
return pathPrefix;
|
return pathPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ export class Editor implements Component, Focusable {
|
||||||
// Autocomplete support
|
// Autocomplete support
|
||||||
private autocompleteProvider?: AutocompleteProvider;
|
private autocompleteProvider?: AutocompleteProvider;
|
||||||
private autocompleteList?: SelectList;
|
private autocompleteList?: SelectList;
|
||||||
private isAutocompleting: boolean = false;
|
private autocompleteState: "regular" | "force" | null = null;
|
||||||
private autocompletePrefix: string = "";
|
private autocompletePrefix: string = "";
|
||||||
|
|
||||||
// Paste tracking for large pastes
|
// Paste tracking for large pastes
|
||||||
|
|
@ -351,7 +351,7 @@ export class Editor implements Component, Focusable {
|
||||||
|
|
||||||
// Render each visible layout line
|
// Render each visible layout line
|
||||||
// Emit hardware cursor marker only when focused and not showing autocomplete
|
// Emit hardware cursor marker only when focused and not showing autocomplete
|
||||||
const emitCursorMarker = this.focused && !this.isAutocompleting;
|
const emitCursorMarker = this.focused && !this.autocompleteState;
|
||||||
|
|
||||||
for (const layoutLine of visibleLines) {
|
for (const layoutLine of visibleLines) {
|
||||||
let displayText = layoutLine.text;
|
let displayText = layoutLine.text;
|
||||||
|
|
@ -406,7 +406,7 @@ export class Editor implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add autocomplete list if active
|
// Add autocomplete list if active
|
||||||
if (this.isAutocompleting && this.autocompleteList) {
|
if (this.autocompleteState && this.autocompleteList) {
|
||||||
const autocompleteResult = this.autocompleteList.render(contentWidth);
|
const autocompleteResult = this.autocompleteList.render(contentWidth);
|
||||||
for (const line of autocompleteResult) {
|
for (const line of autocompleteResult) {
|
||||||
const lineWidth = visibleWidth(line);
|
const lineWidth = visibleWidth(line);
|
||||||
|
|
@ -459,7 +459,7 @@ export class Editor implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle autocomplete mode
|
// Handle autocomplete mode
|
||||||
if (this.isAutocompleting && this.autocompleteList) {
|
if (this.autocompleteState && this.autocompleteList) {
|
||||||
if (kb.matches(data, "selectCancel")) {
|
if (kb.matches(data, "selectCancel")) {
|
||||||
this.cancelAutocomplete();
|
this.cancelAutocomplete();
|
||||||
return;
|
return;
|
||||||
|
|
@ -520,7 +520,7 @@ export class Editor implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab - trigger completion
|
// Tab - trigger completion
|
||||||
if (kb.matches(data, "tab") && !this.isAutocompleting) {
|
if (kb.matches(data, "tab") && !this.autocompleteState) {
|
||||||
this.handleTabCompletion();
|
this.handleTabCompletion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -893,7 +893,7 @@ export class Editor implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should trigger or update autocomplete
|
// Check if we should trigger or update autocomplete
|
||||||
if (!this.isAutocompleting) {
|
if (!this.autocompleteState) {
|
||||||
// Auto-trigger for "/" at the start of a line (slash commands)
|
// Auto-trigger for "/" at the start of a line (slash commands)
|
||||||
if (char === "/" && this.isAtStartOfMessage()) {
|
if (char === "/" && this.isAtStartOfMessage()) {
|
||||||
this.tryTriggerAutocomplete();
|
this.tryTriggerAutocomplete();
|
||||||
|
|
@ -1050,7 +1050,7 @@ export class Editor implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or re-trigger autocomplete after backspace
|
// Update or re-trigger autocomplete after backspace
|
||||||
if (this.isAutocompleting) {
|
if (this.autocompleteState) {
|
||||||
this.updateAutocomplete();
|
this.updateAutocomplete();
|
||||||
} else {
|
} else {
|
||||||
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
||||||
|
|
@ -1270,7 +1270,7 @@ export class Editor implements Component, Focusable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or re-trigger autocomplete after forward delete
|
// Update or re-trigger autocomplete after forward delete
|
||||||
if (this.isAutocompleting) {
|
if (this.autocompleteState) {
|
||||||
this.updateAutocomplete();
|
this.updateAutocomplete();
|
||||||
} else {
|
} else {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
@ -1735,7 +1735,7 @@ export class Editor implements Component, Focusable {
|
||||||
if (suggestions && suggestions.items.length > 0) {
|
if (suggestions && suggestions.items.length > 0) {
|
||||||
this.autocompletePrefix = suggestions.prefix;
|
this.autocompletePrefix = suggestions.prefix;
|
||||||
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
||||||
this.isAutocompleting = true;
|
this.autocompleteState = "regular";
|
||||||
} else {
|
} else {
|
||||||
this.cancelAutocomplete();
|
this.cancelAutocomplete();
|
||||||
}
|
}
|
||||||
|
|
@ -1751,7 +1751,7 @@ export class Editor implements Component, Focusable {
|
||||||
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
|
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
|
||||||
this.handleSlashCommandCompletion();
|
this.handleSlashCommandCompletion();
|
||||||
} else {
|
} else {
|
||||||
this.forceFileAutocomplete();
|
this.forceFileAutocomplete(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1764,7 +1764,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
||||||
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
||||||
536643416/job/55932288317 havea look at .gi
|
536643416/job/55932288317 havea look at .gi
|
||||||
*/
|
*/
|
||||||
private forceFileAutocomplete(): void {
|
private forceFileAutocomplete(explicitTab: boolean = false): void {
|
||||||
if (!this.autocompleteProvider) return;
|
if (!this.autocompleteProvider) return;
|
||||||
|
|
||||||
// Check if provider supports force file suggestions via runtime check
|
// Check if provider supports force file suggestions via runtime check
|
||||||
|
|
@ -1784,7 +1784,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
||||||
|
|
||||||
if (suggestions && suggestions.items.length > 0) {
|
if (suggestions && suggestions.items.length > 0) {
|
||||||
// If there's exactly one suggestion, apply it immediately
|
// If there's exactly one suggestion, apply it immediately
|
||||||
if (suggestions.items.length === 1) {
|
if (explicitTab && suggestions.items.length === 1) {
|
||||||
const item = suggestions.items[0]!;
|
const item = suggestions.items[0]!;
|
||||||
this.pushUndoSnapshot();
|
this.pushUndoSnapshot();
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
|
|
@ -1804,31 +1804,35 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
||||||
|
|
||||||
this.autocompletePrefix = suggestions.prefix;
|
this.autocompletePrefix = suggestions.prefix;
|
||||||
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
||||||
this.isAutocompleting = true;
|
this.autocompleteState = "force";
|
||||||
} else {
|
} else {
|
||||||
this.cancelAutocomplete();
|
this.cancelAutocomplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelAutocomplete(): void {
|
private cancelAutocomplete(): void {
|
||||||
this.isAutocompleting = false;
|
this.autocompleteState = null;
|
||||||
this.autocompleteList = undefined;
|
this.autocompleteList = undefined;
|
||||||
this.autocompletePrefix = "";
|
this.autocompletePrefix = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public isShowingAutocomplete(): boolean {
|
public isShowingAutocomplete(): boolean {
|
||||||
return this.isAutocompleting;
|
return this.autocompleteState !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAutocomplete(): void {
|
private updateAutocomplete(): void {
|
||||||
if (!this.isAutocompleting || !this.autocompleteProvider) return;
|
if (!this.autocompleteState || !this.autocompleteProvider) return;
|
||||||
|
|
||||||
|
if (this.autocompleteState === "force") {
|
||||||
|
this.forceFileAutocomplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
const suggestions = this.autocompleteProvider.getSuggestions(
|
||||||
this.state.lines,
|
this.state.lines,
|
||||||
this.state.cursorLine,
|
this.state.cursorLine,
|
||||||
this.state.cursorCol,
|
this.state.cursorCol,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (suggestions && suggestions.items.length > 0) {
|
if (suggestions && suggestions.items.length > 0) {
|
||||||
this.autocompletePrefix = suggestions.prefix;
|
this.autocompletePrefix = suggestions.prefix;
|
||||||
// Always create new SelectList to ensure update
|
// Always create new SelectList to ensure update
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,26 @@ function createTestTUI(cols = 80, rows = 24): TUI {
|
||||||
return new TUI(new VirtualTerminal(cols, rows));
|
return new TUI(new VirtualTerminal(cols, rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Standard applyCompletion that replaces prefix with item.value */
|
||||||
|
function applyCompletion(
|
||||||
|
lines: string[],
|
||||||
|
cursorLine: number,
|
||||||
|
cursorCol: number,
|
||||||
|
item: { value: string },
|
||||||
|
prefix: string,
|
||||||
|
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
||||||
|
const line = lines[cursorLine] || "";
|
||||||
|
const before = line.slice(0, cursorCol - prefix.length);
|
||||||
|
const after = line.slice(cursorCol);
|
||||||
|
const newLines = [...lines];
|
||||||
|
newLines[cursorLine] = before + item.value + after;
|
||||||
|
return {
|
||||||
|
lines: newLines,
|
||||||
|
cursorLine,
|
||||||
|
cursorCol: cursorCol - prefix.length + item.value.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("Editor component", () => {
|
describe("Editor component", () => {
|
||||||
describe("Prompt history navigation", () => {
|
describe("Prompt history navigation", () => {
|
||||||
it("does nothing on Up arrow when history is empty", () => {
|
it("does nothing on Up arrow when history is empty", () => {
|
||||||
|
|
@ -1728,18 +1748,7 @@ describe("Editor component", () => {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
applyCompletion,
|
||||||
const line = lines[cursorLine] || "";
|
|
||||||
const before = line.slice(0, cursorCol - prefix.length);
|
|
||||||
const after = line.slice(cursorCol);
|
|
||||||
const newLines = [...lines];
|
|
||||||
newLines[cursorLine] = before + item.value + after;
|
|
||||||
return {
|
|
||||||
lines: newLines,
|
|
||||||
cursorLine,
|
|
||||||
cursorCol: cursorCol - prefix.length + item.value.length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.setAutocompleteProvider(mockProvider);
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
@ -1785,18 +1794,7 @@ describe("Editor component", () => {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
applyCompletion,
|
||||||
const line = lines[cursorLine] || "";
|
|
||||||
const before = line.slice(0, cursorCol - prefix.length);
|
|
||||||
const after = line.slice(cursorCol);
|
|
||||||
const newLines = [...lines];
|
|
||||||
newLines[cursorLine] = before + item.value + after;
|
|
||||||
return {
|
|
||||||
lines: newLines,
|
|
||||||
cursorLine,
|
|
||||||
cursorCol: cursorCol - prefix.length + item.value.length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.setAutocompleteProvider(mockProvider);
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
@ -1840,18 +1838,7 @@ describe("Editor component", () => {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
applyCompletion,
|
||||||
const line = lines[cursorLine] || "";
|
|
||||||
const before = line.slice(0, cursorCol - prefix.length);
|
|
||||||
const after = line.slice(cursorCol);
|
|
||||||
const newLines = [...lines];
|
|
||||||
newLines[cursorLine] = before + item.value + after;
|
|
||||||
return {
|
|
||||||
lines: newLines,
|
|
||||||
cursorLine,
|
|
||||||
cursorCol: cursorCol - prefix.length + item.value.length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.setAutocompleteProvider(mockProvider);
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
@ -1872,5 +1859,110 @@ describe("Editor component", () => {
|
||||||
assert.strictEqual(editor.getText(), "src/");
|
assert.strictEqual(editor.getText(), "src/");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps suggestions open when typing in force mode (Tab-triggered)", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
// Mock provider with both getSuggestions and getForceFileSuggestions
|
||||||
|
// getSuggestions only returns results for path-like patterns
|
||||||
|
// getForceFileSuggestions always extracts prefix and filters
|
||||||
|
const allFiles = [
|
||||||
|
{ value: "readme.md", label: "readme.md" },
|
||||||
|
{ value: "package.json", label: "package.json" },
|
||||||
|
{ value: "src/", label: "src/" },
|
||||||
|
{ value: "dist/", label: "dist/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockProvider: AutocompleteProvider & {
|
||||||
|
getForceFileSuggestions: (
|
||||||
|
lines: string[],
|
||||||
|
cursorLine: number,
|
||||||
|
cursorCol: number,
|
||||||
|
) => { items: { value: string; label: string }[]; prefix: string } | null;
|
||||||
|
} = {
|
||||||
|
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
||||||
|
const text = lines[0] || "";
|
||||||
|
const prefix = text.slice(0, cursorCol);
|
||||||
|
// Only return suggestions for path-like patterns (contains / or starts with .)
|
||||||
|
if (prefix.includes("/") || prefix.startsWith(".")) {
|
||||||
|
const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase()));
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
return { items: filtered, prefix };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getForceFileSuggestions: (lines, _cursorLine, cursorCol) => {
|
||||||
|
const text = lines[0] || "";
|
||||||
|
const prefix = text.slice(0, cursorCol);
|
||||||
|
// Always filter files by prefix
|
||||||
|
const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase()));
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
return { items: filtered, prefix };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyCompletion,
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
||||||
|
// Press Tab on empty prompt - should show all files (force mode)
|
||||||
|
editor.handleInput("\t");
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
|
// Type "r" - should narrow to "readme.md" (force mode keeps suggestions open)
|
||||||
|
editor.handleInput("r");
|
||||||
|
assert.strictEqual(editor.getText(), "r");
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
|
// Type "e" - should still show "readme.md"
|
||||||
|
editor.handleInput("e");
|
||||||
|
assert.strictEqual(editor.getText(), "re");
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
|
// Accept with Tab
|
||||||
|
editor.handleInput("\t");
|
||||||
|
assert.strictEqual(editor.getText(), "readme.md");
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides autocomplete when backspacing slash command to empty", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
// Mock provider with slash commands
|
||||||
|
const mockProvider: AutocompleteProvider = {
|
||||||
|
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
||||||
|
const text = lines[0] || "";
|
||||||
|
const prefix = text.slice(0, cursorCol);
|
||||||
|
// Only return slash command suggestions when line starts with /
|
||||||
|
if (prefix.startsWith("/")) {
|
||||||
|
const commands = [
|
||||||
|
{ value: "/model", label: "model", description: "Change model" },
|
||||||
|
{ value: "/help", label: "help", description: "Show help" },
|
||||||
|
];
|
||||||
|
const query = prefix.slice(1); // Remove leading /
|
||||||
|
const filtered = commands.filter((c) => c.value.startsWith(query));
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
return { items: filtered, prefix };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyCompletion,
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
||||||
|
// Type "/" - should show slash command suggestions
|
||||||
|
editor.handleInput("/");
|
||||||
|
assert.strictEqual(editor.getText(), "/");
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
|
// Backspace to delete "/" - should hide autocomplete completely
|
||||||
|
editor.handleInput("\x7f"); // Backspace
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue