diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9880f38c..5b6c2687 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed autocomplete selection ignoring typed text: highlight now follows the first prefix match as the user types, and exact matches are always selected on Enter ([#1931](https://github.com/badlogic/pi-mono/pull/1931) by [@aliou](https://github.com/aliou)) + ## [0.57.0] - 2026-03-07 ### Added diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 6aea0bfe..e9248f2f 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1828,6 +1828,35 @@ export class Editor implements Component, Focusable { } // Autocomplete methods + /** + * Find the best autocomplete item index for the given prefix. + * Returns -1 if no match is found. + * + * Match priority: + * 1. Exact match (prefix === item.value) -> always selected + * 2. Prefix match -> first item whose value starts with prefix + * 3. No match -> -1 (keep default highlight) + * + * Matching is case-sensitive and checks item.value only. + */ + private getBestAutocompleteMatchIndex(items: Array<{ value: string; label: string }>, prefix: string): number { + if (!prefix) return -1; + + let firstPrefixIndex = -1; + + for (let i = 0; i < items.length; i++) { + const value = items[i]!.value; + if (value === prefix) { + return i; // Exact match always wins + } + if (firstPrefixIndex === -1 && value.startsWith(prefix)) { + firstPrefixIndex = i; + } + } + + return firstPrefixIndex; + } + private tryTriggerAutocomplete(explicitTab: boolean = false): void { if (!this.autocompleteProvider) return; @@ -1851,6 +1880,13 @@ export class Editor implements Component, Focusable { if (suggestions && suggestions.items.length > 0) { this.autocompletePrefix = suggestions.prefix; this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); + + // If typed prefix exactly matches one of the suggestions, select that item + const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); + if (bestMatchIndex >= 0) { + this.autocompleteList.setSelectedIndex(bestMatchIndex); + } + this.autocompleteState = "regular"; } else { this.cancelAutocomplete(); @@ -1920,6 +1956,13 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.autocompletePrefix = suggestions.prefix; this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); + + // If typed prefix exactly matches one of the suggestions, select that item + const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); + if (bestMatchIndex >= 0) { + this.autocompleteList.setSelectedIndex(bestMatchIndex); + } + this.autocompleteState = "force"; } else { this.cancelAutocomplete(); @@ -1953,6 +1996,12 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.autocompletePrefix = suggestions.prefix; // Always create new SelectList to ensure update this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); + + // If typed prefix exactly matches one of the suggestions, select that item + const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); + if (bestMatchIndex >= 0) { + this.autocompleteList.setSelectedIndex(bestMatchIndex); + } } else { this.cancelAutocomplete(); } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 1d6e1261..e26349a8 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1994,6 +1994,266 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), ""); assert.strictEqual(editor.isShowingAutocomplete(), false); }); + + it("applies exact typed slash-argument value on Enter even when first item is highlighted", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider for /argtest command with argument completions + const mockProvider: AutocompleteProvider = { + getSuggestions: (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + // Check if we're in argument completion context: "/argtest " + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + const allArguments = [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + { value: "three", label: "three" }, + ]; + // Return all arguments that start with the typed prefix + const filtered = allArguments.filter((arg) => arg.value.startsWith(argumentText)); + if (filtered.length > 0) { + return { items: filtered, prefix: argumentText }; + } + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest two" + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + editor.handleInput("w"); + editor.handleInput("o"); + + assert.strictEqual(editor.getText(), "/argtest two"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - should apply the exact typed value "two", not the first item + editor.handleInput("\r"); + + // The exact typed value "two" should be retained + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("selects first prefix match on Enter when typed arg is not exact match", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider for /argtest command with argument completions + const mockProvider: AutocompleteProvider = { + getSuggestions: (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + // Check if we're in argument completion context + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + const allArguments = [ + { value: "two", label: "two" }, // First item + { value: "three", label: "three" }, // Second item + { value: "twelve", label: "twelve" }, // Third item + ]; + // Return all items that start with the typed prefix + const filtered = allArguments.filter((arg) => arg.value.startsWith(argumentText)); + if (filtered.length > 0) { + return { items: filtered, prefix: argumentText }; + } + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest t" - filtered to [two, three, twelve], prefix "t" matches "two" first + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - "t" prefix matches "two" (first in list), so "two" is applied + editor.handleInput("\r"); + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("highlights unique prefix match as user types (before full exact match)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider that returns all items unfiltered (like real extensions do) + const mockProvider: AutocompleteProvider = { + getSuggestions: (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + // Return all items - provider does not filter + const allArguments = [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + { value: "three", label: "three" }, + ]; + return { items: allArguments, prefix: argumentText }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest tw" - "tw" is a prefix of only "two" + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + editor.handleInput("w"); + + assert.strictEqual(editor.getText(), "/argtest tw"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - "tw" uniquely matches "two", so "two" should be applied + editor.handleInput("\r"); + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("selects first prefix match when multiple items match", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider that returns all items unfiltered + const mockProvider: AutocompleteProvider = { + getSuggestions: (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + const allArguments = [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + { value: "three", label: "three" }, + ]; + return { items: allArguments, prefix: argumentText }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest t" - "t" is a prefix of both "two" and "three" + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - "t" matches "two" first, so "two" is selected + editor.handleInput("\r"); + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("works for built-in-style command argument completion path (model-like)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider for /model command with model completions + const mockProvider: AutocompleteProvider = { + getSuggestions: (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + // Check if we're in /model argument completion context + // Use [^ ]+ to match any non-space characters (including hyphens) + const modelMatch = beforeCursor.match(/^\/model\s+(\S+)$/); + if (modelMatch) { + const modelText = modelMatch[1]!; + const allModels = [ + { value: "gpt-4o", label: "gpt-4o" }, + { value: "gpt-4o-mini", label: "gpt-4o-mini" }, + { value: "claude-sonnet", label: "claude-sonnet" }, + ]; + // Return all models that start with the typed prefix + const filtered = allModels.filter((m) => m.value.startsWith(modelText)); + if (filtered.length > 0) { + return { items: filtered, prefix: modelText }; + } + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/model gpt-4o-mini" - exact match for second item in list + editor.handleInput("/"); + editor.handleInput("m"); + editor.handleInput("o"); + editor.handleInput("d"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput(" "); + editor.handleInput("g"); + editor.handleInput("p"); + editor.handleInput("t"); + editor.handleInput("-"); + editor.handleInput("4"); + editor.handleInput("o"); + editor.handleInput("-"); + editor.handleInput("m"); + editor.handleInput("i"); + editor.handleInput("n"); + editor.handleInput("i"); + + assert.strictEqual(editor.getText(), "/model gpt-4o-mini"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - should retain exact typed value, not apply first highlighted item + editor.handleInput("\r"); + + // The exact typed value should be retained + assert.strictEqual(editor.getText(), "/model gpt-4o-mini"); + }); }); describe("Character jump (Ctrl+])", () => {