diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 382c88aed..f485218cd 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1742,6 +1742,18 @@ export class Editor implements Component, Focusable { } } } + + // Keep an open autocomplete picker in sync with the new cursor + // position: cursor movement changes the text before the cursor, so a + // picker computed for the old position is stale. Re-query so it + // refreshes — or closes when the new position yields no suggestions — + // mirroring insertCharacter()/handleBackspace(). Without this, arrowing + // left from `/cmd ` back into the command name leaves the argument + // picker showing against a `/cmd` prefix (and a Tab there would + // concatenate the stale suggestion onto the partial command name). + if (this.autocompleteState) { + this.updateAutocomplete(); + } } /** diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 938f96469..9f92643ac 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2250,6 +2250,65 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("re-queries the autocomplete picker when the cursor moves back into the command name", async () => { + // Regression for earendil-works/pi#5496: arrowing left out of a slash + // command's argument region must re-query the picker, not leave the + // stale argument list showing. Before the fix, moveCursor() never + // called updateAutocomplete(), so `/cmd ` (argument menu) + Left kept + // displaying the arguments against a `/cmd` prefix — and a Tab there + // would concatenate the stale suggestion onto the partial command name. + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const before = (lines[0] || "").slice(0, cursorCol); + if (!before.startsWith("/")) return null; + // Past the command name (a space before the cursor): offer arguments. + if (before.includes(" ")) { + return { + items: [ + { value: "repo", label: "repo" }, + { value: "message", label: "message" }, + { value: "help", label: "help" }, + ], + prefix: before.slice(before.indexOf(" ") + 1), + }; + } + // Inside the command name: offer the command name only. + return { items: [{ value: "cmd", label: "cmd" }], prefix: before }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type `/cmd ` so the picker ends up showing the argument list. + for (const ch of "/cmd ") { + editor.handleInput(ch); + await flushAutocomplete(); + } + assert.strictEqual(editor.getText(), "/cmd "); + assert.strictEqual(editor.isShowingAutocomplete(), true); + const atArg = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(atArg.includes("repo"), "argument menu should be visible at `/cmd `"); + + // Arrow Left back into the command name (`/cmd`). + editor.handleInput("\x1b[D"); + await flushAutocomplete(); + + // The picker must have re-queried: the stale argument items are gone + // (replaced by the command-name suggestion, or the picker closed). + const afterMove = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(!afterMove.includes("repo"), "stale argument menu must not survive the cursor move"); + assert.ok(!afterMove.includes("message"), "stale argument menu must not survive the cursor move"); + }); + it("debounces # autocomplete while typing", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let suggestionCalls = 0;