From 4ce014c70282254dc6e55bde9887bae6a60f69a2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 26 May 2026 17:04:36 +0900 Subject: [PATCH] fix(tui): rank slash command prefixes --- packages/tui/src/autocomplete.ts | 20 +------ .../tui/src/slash-command-autocomplete.ts | 54 +++++++++++++++++++ packages/tui/test/autocomplete-slash.test.ts | 48 +++++++++++++++++ 3 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 packages/tui/src/slash-command-autocomplete.ts create mode 100644 packages/tui/test/autocomplete-slash.test.ts diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 5408967d1..de2e5af1c 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -2,7 +2,7 @@ import { spawn } from "child_process"; import { readdirSync, statSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; -import { fuzzyFilter } from "./fuzzy.ts"; +import { getSlashCommandSuggestions } from "./slash-command-autocomplete.ts"; const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); @@ -307,23 +307,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (spaceIndex === -1) { const prefix = textBeforeCursor.slice(1); - const commandItems = this.commands.map((cmd) => { - const name = "name" in cmd ? cmd.name : cmd.value; - const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined; - const desc = cmd.description ?? ""; - const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc; - return { - name, - label: name, - description: fullDesc || undefined, - }; - }); - - const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({ - value: item.name, - label: item.label, - ...(item.description && { description: item.description }), - })); + const filtered = getSlashCommandSuggestions(this.commands, prefix); if (filtered.length === 0) return null; diff --git a/packages/tui/src/slash-command-autocomplete.ts b/packages/tui/src/slash-command-autocomplete.ts new file mode 100644 index 000000000..f457157b4 --- /dev/null +++ b/packages/tui/src/slash-command-autocomplete.ts @@ -0,0 +1,54 @@ +import type { AutocompleteItem, SlashCommand } from "./autocomplete.ts"; +import { fuzzyFilter } from "./fuzzy.ts"; + +type CommandItem = { + readonly name: string; + readonly label: string; + readonly description?: string; +}; + +type RankedCommandItem = AutocompleteItem & { + readonly index: number; +}; + +function compareSlashCommandSuggestion(prefix: string, left: RankedCommandItem, right: RankedCommandItem): number { + const leftExact = left.value === prefix; + const rightExact = right.value === prefix; + if (leftExact !== rightExact) return leftExact ? -1 : 1; + + const leftPrefix = left.value.startsWith(prefix); + const rightPrefix = right.value.startsWith(prefix); + if (leftPrefix !== rightPrefix) return leftPrefix ? -1 : 1; + if (leftPrefix && rightPrefix && left.value.length !== right.value.length) { + return right.value.length - left.value.length; + } + + return left.index - right.index; +} + +export function getSlashCommandSuggestions( + commands: readonly (SlashCommand | AutocompleteItem)[], + prefix: string, +): AutocompleteItem[] { + const commandItems: CommandItem[] = commands.map((cmd) => { + const name = "name" in cmd ? cmd.name : cmd.value; + const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined; + const desc = cmd.description ?? ""; + const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc; + return { + name, + label: name, + description: fullDesc || undefined, + }; + }); + + return fuzzyFilter(commandItems, prefix, (item) => item.name) + .map((item, index) => ({ + value: item.name, + label: item.label, + ...(item.description && { description: item.description }), + index, + })) + .sort((left, right) => compareSlashCommandSuggestion(prefix, left, right)) + .map(({ index: _index, ...item }) => item); +} diff --git a/packages/tui/test/autocomplete-slash.test.ts b/packages/tui/test/autocomplete-slash.test.ts new file mode 100644 index 000000000..498d72f4e --- /dev/null +++ b/packages/tui/test/autocomplete-slash.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { CombinedAutocompleteProvider } from "../src/autocomplete.ts"; + +const getSuggestions = (provider: CombinedAutocompleteProvider, line: string) => + provider.getSuggestions([line], 0, line.length, { signal: new AbortController().signal }); + +describe("CombinedAutocompleteProvider slash command suggestions", () => { + it("ranks longer prefix matches before shorter commands when the typed slash command is ambiguous", async () => { + // Given + const provider = new CombinedAutocompleteProvider( + [ + { name: "session", description: "Show session info and stats" }, + { name: "sessions", description: "Peek at previous session transcripts in a HUD" }, + ], + "/tmp", + ); + + // When + const result = await getSuggestions(provider, "/sessio"); + + // Then + assert.deepStrictEqual( + result?.items.map((item) => item.value), + ["sessions", "session"], + ); + }); + + it("keeps exact slash command matches before longer commands", async () => { + // Given + const provider = new CombinedAutocompleteProvider( + [ + { name: "session", description: "Show session info and stats" }, + { name: "sessions", description: "Peek at previous session transcripts in a HUD" }, + ], + "/tmp", + ); + + // When + const result = await getSuggestions(provider, "/session"); + + // Then + assert.deepStrictEqual( + result?.items.map((item) => item.value), + ["session", "sessions"], + ); + }); +});