From 9e3f8e3ac68435292933bc770253043f3b2199b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20Pat=C4=B1r?= Date: Thu, 15 Oct 2026 12:02:13 +0300 Subject: [PATCH] fix: support enter as the accept suggestion binding When acceptSuggestion is bound to "return", accepting a suggestion also cleared the tracked command because update() reset the command state on every enter before evaluating bindings. Suggestions then stopped showing for the rest of the line. Clear the command on enter only when the key press was not consumed by a binding. With enter bound to accept, pressing it on an empty token now runs the command unless a suggestion was explicitly selected with the navigation keys, so a completed line can still be executed with enter. --- src/tests/utils/suggestionManager.test.ts | 71 +++++++++++++++++++++++ src/ui/suggestionManager.ts | 23 ++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) 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 {