diff --git a/components/ChatInput.slash-filter.test.mjs b/components/ChatInput.slash-filter.test.mjs new file mode 100644 index 0000000000..372b80c907 --- /dev/null +++ b/components/ChatInput.slash-filter.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { filterSlashCommands } = await jiti.import("./ChatInput.tsx"); + +const t = (key) => key; +const names = (commands) => commands.map((command) => command.name); + +const commands = [ + { name: "copy", description: "Copy the last assistant message", source: "builtin" }, + { name: "compact", description: "Compact the context", source: "builtin" }, + { name: "skill:better-writing", description: "Polish UI product copy", source: "skill" }, + { name: "skill:copy-text", description: "Copy text", source: "skill" }, + { name: "skill:deep-copy", description: "Clone objects", source: "skill" }, + { name: "daily-brief", description: "Prepare an assistant brief", source: "prompt" }, +]; + +test("names decide: a description match is dropped while any name matches (#1413)", () => { + assert.deepEqual(names(filterSlashCommands(commands, "copy", t)), ["copy", "skill:copy-text", "skill:deep-copy"]); +}); + +test("a skill matches by its bare name, and by its full name with the prefix typed", () => { + // Bare-name prefix (rank 1) comes before a plain substring (rank 2), like pi's TUI. + assert.deepEqual(names(filterSlashCommands(commands.slice(2), "copy", t)), ["skill:copy-text", "skill:deep-copy"]); + assert.deepEqual(names(filterSlashCommands(commands, "skill:copy", t)), ["skill:copy-text"]); + assert.deepEqual(names(filterSlashCommands(commands, "better-writing", t)), ["skill:better-writing"]); +}); + +test("descriptions are searched only when no name matches", () => { + assert.deepEqual(names(filterSlashCommands(commands, "assistant", t)), ["copy", "daily-brief"]); +}); + +test("an empty query lists everything by source, then name", () => { + assert.deepEqual(names(filterSlashCommands(commands, "", t)), [ + "compact", + "copy", + "daily-brief", + "skill:better-writing", + "skill:copy-text", + "skill:deep-copy", + ]); +}); diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index babcefe850..abdfbcf1fb 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -376,20 +376,44 @@ const SLASH_SOURCE_ORDER: Record = { skill: 3, }; -function slashMatchRank(command: SlashCommandPaletteItem, query: string, t: (key: string) => string): number { +// pi's TUI matches a skill by its bare name (pi-tui's +// CombinedAutocompleteProvider), so `/copy` finds `skill:copy-text` by prefix; +// the full `skill:` still matches a query that spells the prefix. +function slashMatchRank(command: SlashCommandPaletteItem, query: string): number { const name = command.name.toLowerCase(); - const description = getSlashDescription(command, t).toLowerCase(); - if (name === query) return 0; - if (name.startsWith(query)) return 1; + const bareName = command.source === "skill" && name.startsWith("skill:") ? name.slice("skill:".length) : name; + if (bareName === query || name === query) return 0; + if (bareName.startsWith(query) || name.startsWith(query)) return 1; if (name.includes(query)) return 2; - if (description.includes(query)) return 3; - return 4; + return 3; } function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string { return command.source === "builtin" ? t(command.description) : command.description ?? ""; } +/** + * The palette's matches for a lower-cased query. pi's TUI matches names only; + * the palette searches descriptions too, but only when no name matches, so + * `/copy` lists the built-in, not every skill whose description says "copy". + */ +export function filterSlashCommands( + commands: SlashCommandPaletteItem[], + query: string, + t: (key: string) => string, +): SlashCommandPaletteItem[] { + const ranked = commands.map((command) => ({ command, rank: slashMatchRank(command, query) })); + const byName = ranked.filter(({ rank }) => rank < 3); + const matches = byName.length > 0 + ? byName + : ranked.filter(({ command }) => getSlashDescription(command, t).toLowerCase().includes(query)); + return matches + .sort((a, b) => a.rank - b.rank + || SLASH_SOURCE_ORDER[a.command.source] - SLASH_SOURCE_ORDER[b.command.source] + || TEXT_COLLATOR.compare(a.command.name, b.command.name)) + .map(({ command }) => command); +} + // Skill slash commands are named "skill:"; look the skill up in the // dormancy map fetched from /api/skills. Unknown skills are treated as active. function isDormantSkillCommand(command: SlashCommandPaletteItem, dormancy: Record): boolean { @@ -1103,18 +1127,7 @@ export const ChatInput = memo(forwardRef(function ChatIn ? BUILTIN_SLASH_COMMANDS.filter((command) => command.availableWhileStreaming) : BUILTIN_SLASH_COMMANDS; const commands = [...builtinCommands, ...(slashCommands ?? [])]; - return [...commands] - .filter((command) => { - const name = command.name.toLowerCase(); - const description = getSlashDescription(command, t).toLowerCase(); - return name.includes(slashQuery) || description.includes(slashQuery); - }) - .sort((a, b) => { - const rankDelta = slashMatchRank(a, slashQuery, t) - slashMatchRank(b, slashQuery, t); - if (rankDelta !== 0) return rankDelta; - return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] - || TEXT_COLLATOR.compare(a.name, b.name); - }); + return filterSlashCommands(commands, slashQuery, t); })(); const { diff --git a/demo/components/ChatInput.tsx b/demo/components/ChatInput.tsx index 8ed141598e..bdbe91278d 100644 --- a/demo/components/ChatInput.tsx +++ b/demo/components/ChatInput.tsx @@ -266,20 +266,44 @@ const SLASH_SOURCE_ORDER: Record = { skill: 3, }; -function slashMatchRank(command: SlashCommandPaletteItem, query: string, t: (key: string) => string): number { +// pi's TUI matches a skill by its bare name (pi-tui's +// CombinedAutocompleteProvider), so `/copy` finds `skill:copy-text` by prefix; +// the full `skill:` still matches a query that spells the prefix. +function slashMatchRank(command: SlashCommandPaletteItem, query: string): number { const name = command.name.toLowerCase(); - const description = getSlashDescription(command, t).toLowerCase(); - if (name === query) return 0; - if (name.startsWith(query)) return 1; + const bareName = command.source === "skill" && name.startsWith("skill:") ? name.slice("skill:".length) : name; + if (bareName === query || name === query) return 0; + if (bareName.startsWith(query) || name.startsWith(query)) return 1; if (name.includes(query)) return 2; - if (description.includes(query)) return 3; - return 4; + return 3; } function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string { return command.source === "builtin" ? t(command.description) : command.description ?? ""; } +/** + * The palette's matches for a lower-cased query. pi's TUI matches names only; + * the palette searches descriptions too, but only when no name matches, so + * `/copy` lists the built-in, not every skill whose description says "copy". + */ +export function filterSlashCommands( + commands: SlashCommandPaletteItem[], + query: string, + t: (key: string) => string, +): SlashCommandPaletteItem[] { + const ranked = commands.map((command) => ({ command, rank: slashMatchRank(command, query) })); + const byName = ranked.filter(({ rank }) => rank < 3); + const matches = byName.length > 0 + ? byName + : ranked.filter(({ command }) => getSlashDescription(command, t).toLowerCase().includes(query)); + return matches + .sort((a, b) => a.rank - b.rank + || SLASH_SOURCE_ORDER[a.command.source] - SLASH_SOURCE_ORDER[b.command.source] + || TEXT_COLLATOR.compare(a.command.name, b.command.name)) + .map(({ command }) => command); +} + // Skill slash commands are named "skill:"; look the skill up in the // dormancy map fetched from /api/skills. Unknown skills are treated as active. function isDormantSkillCommand(command: SlashCommandPaletteItem, dormancy: Record): boolean { @@ -970,18 +994,7 @@ export const ChatInput = forwardRef(function ChatInput({ ? BUILTIN_SLASH_COMMANDS.filter((command) => command.availableWhileStreaming) : BUILTIN_SLASH_COMMANDS; const commands = [...builtinCommands, ...(slashCommands ?? [])]; - return [...commands] - .filter((command) => { - const name = command.name.toLowerCase(); - const description = getSlashDescription(command, t).toLowerCase(); - return name.includes(slashQuery) || description.includes(slashQuery); - }) - .sort((a, b) => { - const rankDelta = slashMatchRank(a, slashQuery, t) - slashMatchRank(b, slashQuery, t); - if (rankDelta !== 0) return rankDelta; - return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] - || TEXT_COLLATOR.compare(a.name, b.name); - }); + return filterSlashCommands(commands, slashQuery, t); })(); const {