From b54d689ec1f0a166023f836d5fdf40816b0db828 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Thu, 29 Jan 2026 05:48:09 +0400 Subject: [PATCH] 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. --- packages/tui/src/autocomplete.ts | 6 +- packages/tui/src/components/editor.ts | 38 +++--- packages/tui/test/editor.test.ts | 164 ++++++++++++++++++++------ 3 files changed, 152 insertions(+), 56 deletions(-) diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 0a944468..cd7b17bd 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -312,9 +312,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return pathPrefix; } - // Return empty string only if we're at the beginning of the line or after a space - // (not after quotes or other delimiters that don't suggest file paths) - if (pathPrefix === "" && (text === "" || text.endsWith(" "))) { + // Return empty string only after a space (not for completely empty text) + // Empty text should not trigger file suggestions - that's for forced Tab completion + if (pathPrefix === "" && text.endsWith(" ")) { return pathPrefix; } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 98cd110f..8d61b12e 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -174,7 +174,7 @@ export class Editor implements Component, Focusable { // Autocomplete support private autocompleteProvider?: AutocompleteProvider; private autocompleteList?: SelectList; - private isAutocompleting: boolean = false; + private autocompleteState: "regular" | "force" | null = null; private autocompletePrefix: string = ""; // Paste tracking for large pastes @@ -351,7 +351,7 @@ export class Editor implements Component, Focusable { // Render each visible layout line // 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) { let displayText = layoutLine.text; @@ -406,7 +406,7 @@ export class Editor implements Component, Focusable { } // Add autocomplete list if active - if (this.isAutocompleting && this.autocompleteList) { + if (this.autocompleteState && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(contentWidth); for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); @@ -459,7 +459,7 @@ export class Editor implements Component, Focusable { } // Handle autocomplete mode - if (this.isAutocompleting && this.autocompleteList) { + if (this.autocompleteState && this.autocompleteList) { if (kb.matches(data, "selectCancel")) { this.cancelAutocomplete(); return; @@ -520,7 +520,7 @@ export class Editor implements Component, Focusable { } // Tab - trigger completion - if (kb.matches(data, "tab") && !this.isAutocompleting) { + if (kb.matches(data, "tab") && !this.autocompleteState) { this.handleTabCompletion(); return; } @@ -893,7 +893,7 @@ export class Editor implements Component, Focusable { } // 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) if (char === "/" && this.isAtStartOfMessage()) { this.tryTriggerAutocomplete(); @@ -1050,7 +1050,7 @@ export class Editor implements Component, Focusable { } // Update or re-trigger autocomplete after backspace - if (this.isAutocompleting) { + if (this.autocompleteState) { this.updateAutocomplete(); } else { // 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 - if (this.isAutocompleting) { + if (this.autocompleteState) { this.updateAutocomplete(); } else { const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1735,7 +1735,7 @@ export class Editor implements Component, Focusable { if (suggestions && suggestions.items.length > 0) { this.autocompletePrefix = suggestions.prefix; this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); - this.isAutocompleting = true; + this.autocompleteState = "regular"; } else { this.cancelAutocomplete(); } @@ -1751,7 +1751,7 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) { this.handleSlashCommandCompletion(); } 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 536643416/job/55932288317 havea look at .gi */ - private forceFileAutocomplete(): void { + private forceFileAutocomplete(explicitTab: boolean = false): void { if (!this.autocompleteProvider) return; // 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 there's exactly one suggestion, apply it immediately - if (suggestions.items.length === 1) { + if (explicitTab && suggestions.items.length === 1) { const item = suggestions.items[0]!; this.pushUndoSnapshot(); this.lastAction = null; @@ -1804,31 +1804,35 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.autocompletePrefix = suggestions.prefix; this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList); - this.isAutocompleting = true; + this.autocompleteState = "force"; } else { this.cancelAutocomplete(); } } private cancelAutocomplete(): void { - this.isAutocompleting = false; + this.autocompleteState = null; this.autocompleteList = undefined; this.autocompletePrefix = ""; } public isShowingAutocomplete(): boolean { - return this.isAutocompleting; + return this.autocompleteState !== null; } 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( this.state.lines, this.state.cursorLine, this.state.cursorCol, ); - if (suggestions && suggestions.items.length > 0) { this.autocompletePrefix = suggestions.prefix; // Always create new SelectList to ensure update diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 61341d3b..9dee4de3 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -13,6 +13,26 @@ function createTestTUI(cols = 80, rows = 24): TUI { 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("Prompt history navigation", () => { it("does nothing on Up arrow when history is empty", () => { @@ -1728,18 +1748,7 @@ describe("Editor component", () => { } return null; }, - applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => { - 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, - }; - }, + applyCompletion, }; editor.setAutocompleteProvider(mockProvider); @@ -1785,18 +1794,7 @@ describe("Editor component", () => { } return null; }, - applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => { - 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, - }; - }, + applyCompletion, }; editor.setAutocompleteProvider(mockProvider); @@ -1840,18 +1838,7 @@ describe("Editor component", () => { } return null; }, - applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => { - 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, - }; - }, + applyCompletion, }; editor.setAutocompleteProvider(mockProvider); @@ -1872,5 +1859,110 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "src/"); 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); + }); }); });