From 9b746c9f26d06ee838c0687ce991455fa7e51b79 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 28 Sep 2026 02:24:03 +0800 Subject: [PATCH] fix(tui): submit file-defined slash commands on the first enter --- .../tui/src/component/prompt/autocomplete.tsx | 20 +++++++++ .../tui/test/component/autocomplete.test.ts | 41 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 packages/tui/test/component/autocomplete.test.ts diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 099fa9d83eb7..a967cdbf288d 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -72,6 +72,18 @@ export type AutocompleteOption = { path?: string } +/** + * The typed text already names a whole command, so pressing Enter should submit it rather than + * re-select it from the list. List entries pad `display` for column alignment, so compare against + * the trimmed display instead of the raw one. + */ +export function isCompleteCommand(value: string, options: readonly AutocompleteOption[]) { + const typed = value.trimEnd() + return options.some( + (option) => (option.value ?? option.display).trimEnd() === typed || (option.aliases?.includes(typed) ?? false), + ) +} + export function Autocomplete(props: { value: string sessionID?: string @@ -685,6 +697,14 @@ export function Autocomplete(props: { ) { hide() } + + // The typed text already names a whole command, so collapse the list and let the next + // Enter reach the input's own submit. Unlike hide(), this leaves the text alone, because + // the command is complete rather than a half-typed trigger. Typing another character + // reopens the list through the "/" branch below. + if (store.visible === "/" && isCompleteCommand(value, commands())) { + setStore("visible", false) + } return } diff --git a/packages/tui/test/component/autocomplete.test.ts b/packages/tui/test/component/autocomplete.test.ts new file mode 100644 index 000000000000..4c6547339e0e --- /dev/null +++ b/packages/tui/test/component/autocomplete.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { isCompleteCommand, type AutocompleteOption } from "../../src/component/prompt/autocomplete" + +// `commands()` pads every display to the width of the longest entry, so the fixtures below keep the +// padding: matching it is the whole point of the predicate. +const commands: AutocompleteOption[] = [ + { display: "/debug ", onSelect: () => {} }, + { display: "/efficiency ", aliases: ["/eff"], onSelect: () => {} }, + { display: "/good ", onSelect: () => {} }, + { display: "/good-thing ", onSelect: () => {} }, + { display: "/session:list ", onSelect: () => {} }, +] + +describe("isCompleteCommand", () => { + test("matches a display that only differs from the typed name by list padding", () => { + expect(isCompleteCommand("/good", commands)).toBe(true) + expect(isCompleteCommand("/debug", commands)).toBe(true) + expect(isCompleteCommand("/session:list", commands)).toBe(true) + }) + + test("matches an alias", () => { + expect(isCompleteCommand("/eff", commands)).toBe(true) + }) + + test("keeps the list open while the name is still a prefix", () => { + expect(isCompleteCommand("/goo", commands)).toBe(false) + expect(isCompleteCommand("/good-", commands)).toBe(false) + expect(isCompleteCommand("/", commands)).toBe(false) + }) + + test("keeps the list open once the name is followed by arguments", () => { + expect(isCompleteCommand("/good clean up", commands)).toBe(false) + expect(isCompleteCommand("/good-thing now", commands)).toBe(false) + }) + + test("does not match unrelated text or an empty list", () => { + expect(isCompleteCommand("good", commands)).toBe(false) + expect(isCompleteCommand("", commands)).toBe(false) + expect(isCompleteCommand("/good", [])).toBe(false) + }) +})