Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/tui/src/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
41 changes: 41 additions & 0 deletions packages/tui/test/component/autocomplete.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading