diff --git a/src/tests/utils/suggestionManager.test.ts b/src/tests/utils/suggestionManager.test.ts index e3a11ee..1b4e751 100644 --- a/src/tests/utils/suggestionManager.test.ts +++ b/src/tests/utils/suggestionManager.test.ts @@ -5,6 +5,7 @@ import type { ISTerm } from "../../isterm/pty.js"; import type { SuggestionBlob } from "../../runtime/model.js"; import { Shell } from "../../utils/shell.js"; import { SuggestionManager } from "../../ui/suggestionManager.js"; +import { getConfig } from "../../utils/config.js"; const deferred = () => { let resolve!: (value: T) => void; @@ -114,3 +115,73 @@ test("does not consume suggestion bindings when the UI is hidden", async () => { expect(manager.update({ name: "tab", sequence: "\t", shift: false, ctrl: false }, false)).toBe(false); expect(writes).toEqual([]); }); + +describe("enter as the accept binding", () => { + let previousAcceptBinding: ReturnType["bindings"]["acceptSuggestion"]; + + beforeEach(() => { + previousAcceptBinding = getConfig().bindings.acceptSuggestion; + getConfig().bindings.acceptSuggestion = { key: "return" }; + }); + + afterEach(() => { + getConfig().bindings.acceptSuggestion = previousAcceptBinding; + }); + + const setup = async (commandText: string, token: string) => { + const writes: string[] = []; + let clearCommandCalls = 0; + const terminal = { + cwd: process.cwd(), + cols: 80, + write: (data: string) => writes.push(data), + clearCommand: () => { + clearCommandCalls += 1; + }, + getCommandState: () => ({ commandText, cursorTerminated: true, hasOutput: false }), + getCursorState: () => ({ cursorX: 0 }), + } as unknown as ISTerm; + const runtime = Promise.resolve({ + getSuggestions: async () => ({ ...blob("checkout"), activeToken: { token, tokenLength: token.length, complete: false, isOption: false } }), + clearTransientSuggestionState: () => {}, + }); + const manager = new SuggestionManager(terminal, Shell.Bash, runtime); + await manager.exec(); + return { manager, writes, clearCommandCalls: () => clearCommandCalls }; + }; + + const enter = { name: "return", sequence: "\r", shift: false, ctrl: false }; + + test("keeps tracking the command when enter accepts a suggestion", async () => { + const { manager, writes, clearCommandCalls } = await setup("git chec", "chec"); + + expect(manager.update(enter, true)).toBe(true); + expect(writes).not.toEqual([]); + expect(clearCommandCalls()).toBe(0); + }); + + test("clears the command when enter is not consumed by a binding", async () => { + const { manager, writes, clearCommandCalls } = await setup("git chec", "chec"); + + expect(manager.update(enter, false)).toBe(false); + expect(writes).toEqual([]); + expect(clearCommandCalls()).toBe(1); + }); + + test("runs the command when enter is pressed on an empty token without a selection", async () => { + const { manager, writes, clearCommandCalls } = await setup("git checkout ", ""); + + expect(manager.update(enter, true)).toBe(false); + expect(writes).toEqual([]); + expect(clearCommandCalls()).toBe(1); + }); + + test("accepts the selected suggestion when enter is pressed on an empty token after navigating", async () => { + const { manager, writes, clearCommandCalls } = await setup("git checkout ", ""); + + manager.update({ name: "down", sequence: "\u001b[B", shift: false, ctrl: false }, true); + expect(manager.update(enter, true)).toBe(true); + expect(writes).not.toEqual([]); + expect(clearCommandCalls()).toBe(0); + }); +}); diff --git a/src/ui/suggestionManager.ts b/src/ui/suggestionManager.ts index 2589b01..618c167 100644 --- a/src/ui/suggestionManager.ts +++ b/src/ui/suggestionManager.ts @@ -36,6 +36,7 @@ export class SuggestionManager { #term: ISTerm; #commandKey: string; #activeSuggestionIdx: number; + #suggestionSelected = false; #suggestBlob?: SuggestionBlob; #shell: Shell; #hideSuggestions: boolean = false; @@ -72,6 +73,7 @@ export class SuggestionManager { this.#commandKey = ""; this.#suggestBlob = undefined; this.#activeSuggestionIdx = 0; + this.#suggestionSelected = false; this.#hideSuggestions = false; if (commandCleared) { try { @@ -105,6 +107,7 @@ export class SuggestionManager { const changed = this.#suggestBlob != null || this.#activeSuggestionIdx !== 0; this.#suggestBlob = undefined; this.#activeSuggestionIdx = 0; + this.#suggestionSelected = false; return changed; } const abortController = new AbortController(); @@ -119,6 +122,7 @@ export class SuggestionManager { this.#commandKey = commandKey; this.#suggestBlob = suggestionBlob; this.#activeSuggestionIdx = 0; + this.#suggestionSelected = false; return true; } catch (e) { if (e instanceof Error && e.name === "AbortError") { @@ -220,10 +224,15 @@ export class SuggestionManager { } update(keyPress: KeyPress, allowBindings = true): boolean { - const { name, shift, ctrl } = keyPress; - if (name == "return") { - this.#term.clearCommand(); // clear the current command on enter + const handled = this.#handleKeyPress(keyPress, allowBindings); + if (!handled && keyPress.name == "return") { + this.#term.clearCommand(); // clear the current command on enter, unless enter was consumed by a binding } + return handled; + } + + #handleKeyPress(keyPress: KeyPress, allowBindings: boolean): boolean { + const { name, shift, ctrl } = keyPress; // if suggestions are hidden, keep them hidden until during command navigation if (this.#hideSuggestions) { @@ -245,19 +254,25 @@ export class SuggestionManager { this.#hideSuggestions = true; } else if (name == prevKey && shift == !!prevShift && ctrl == !!prevCtrl) { this.#activeSuggestionIdx = Math.max(0, this.#activeSuggestionIdx - 1); + this.#suggestionSelected = true; } else if (name == nextKey && shift == !!nextShift && ctrl == !!nextCtrl) { this.#activeSuggestionIdx = Math.min(this.#activeSuggestionIdx + 1, (this.#suggestBlob?.suggestions.length ?? 1) - 1); + this.#suggestionSelected = true; } else if (name == acceptKey && shift == !!acceptShift && ctrl == !!acceptCtrl) { const suggestion = this.#suggestBlob?.suggestions.at(this.#activeSuggestionIdx); if (suggestion == null || this.#suggestBlob?.suggestions.length == 0) { return false; } + // enter on an empty token runs the command unless a suggestion was explicitly selected + if (name == "return" && !this.#suggestionSelected && !this.#suggestBlob?.activeToken?.token) { + return false; + } const action = calculateReplacement(this.#suggestBlob?.activeToken, suggestion); if (action == null) { return false; } this.#term.write(applyReplacement(action)); - } else if (name == "return" || (name == "c" && ctrl)) { + } else if (name == "c" && ctrl) { this.#term.clearCommand(); return false; } else {