From 982122608a2d2dcebb0492ec0389fbadd734a872 Mon Sep 17 00:00:00 2001 From: salmanabdurrahman Date: Sun, 25 Oct 2026 21:51:39 +0700 Subject: [PATCH 1/2] fix(chat): prioritize slash command name matches and strip skill prefix - strip skill prefix in getCleanSlashName for prefix and token ranking - rank exact, prefix, token, and substring name matches ahead of descriptions - suppress noisy description-only matches when any command matches by name - retain description matching as fallback when no names match query - guard getSlashDescription against undefined builtin command descriptions - add unit tests for slash command ranking across all command sources --- components/ChatInput.slash-filter.test.mjs | 243 +++++++++++++++++++++ components/ChatInput.tsx | 86 ++++++-- demo/components/ChatInput.tsx | 86 ++++++-- 3 files changed, 375 insertions(+), 40 deletions(-) create mode 100644 components/ChatInput.slash-filter.test.mjs diff --git a/components/ChatInput.slash-filter.test.mjs b/components/ChatInput.slash-filter.test.mjs new file mode 100644 index 0000000000..b710aae0fd --- /dev/null +++ b/components/ChatInput.slash-filter.test.mjs @@ -0,0 +1,243 @@ +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, + slashMatchRank, + getCleanSlashName, + buildSlashCommandLayout, +} = await jiti.import("./ChatInput.tsx"); + +test("getCleanSlashName strips skill: prefix but preserves builtin, prompt, and extension names", () => { + assert.equal(getCleanSlashName({ name: "skill:copy-text", source: "skill" }), "copy-text"); + assert.equal(getCleanSlashName({ name: "copy", source: "builtin" }), "copy"); + assert.equal(getCleanSlashName({ name: "auto-copy", source: "prompt" }), "auto-copy"); + assert.equal(getCleanSlashName({ name: "weather-check", source: "extension" }), "weather-check"); + assert.equal(getCleanSlashName({ name: "mcp", source: "extension" }), "mcp"); +}); + +test("slashMatchRank orders exact, prefix, token, and substring matches", () => { + const exact = { name: "copy", source: "builtin" }; + const prefix = { name: "copy-session", source: "builtin" }; + const tokenHyphen = { name: "skill:deep-copy", source: "skill" }; + const tokenUnderscore = { name: "skill:deep_copy", source: "skill" }; + const substring = { name: "photocopy", source: "builtin" }; + const descriptionOnly = { + name: "skill:better-writing", + description: "Polish UI product copy, verb-first buttons, and error recovery", + source: "skill", + }; + + assert.equal(slashMatchRank(exact, "copy"), 0); + assert.equal(slashMatchRank(prefix, "copy"), 1); + assert.equal(slashMatchRank(tokenHyphen, "copy"), 2); + assert.equal(slashMatchRank(tokenUnderscore, "copy"), 2); + assert.equal(slashMatchRank(substring, "copy"), 3); + assert.equal(slashMatchRank(descriptionOnly, "copy"), 4); +}); + +test("slashMatchRank supports matching skills with or without explicit skill: prefix", () => { + const skill = { name: "skill:better-writing", source: "skill" }; + + // Matching without prefix + assert.equal(slashMatchRank(skill, "better-writing"), 0); + assert.equal(slashMatchRank(skill, "better"), 1); + assert.equal(slashMatchRank(skill, "writing"), 2); + + // Matching with explicit skill: prefix + assert.equal(slashMatchRank(skill, "skill:better-writing"), 0); + assert.equal(slashMatchRank(skill, "skill:better"), 1); +}); + +test("filterSlashCommands suppresses noisy description-only matches when name matches exist across all sources", () => { + const commands = [ + { + name: "copy", + description: "Copy the last assistant message", + source: "builtin", + }, + { + name: "skill:better-writing", + description: "Polish UI product copy, verb-first buttons, contextual error recovery, and forward-pointing empty states", + source: "skill", + }, + { + name: "skill:create-agents-md", + description: "Create, update, or audit project AGENTS.md from repository evidence without guessing or copying outdated templates", + source: "skill", + }, + { + name: "skill:pen-design", + description: "Design high-fidelity UI screens and copy layout systems in pen.dev using the 5-doc system", + source: "skill", + }, + { + name: "custom-prompt", + description: "A prompt template that mentions copying files", + source: "prompt", + }, + { + name: "ext-tool", + description: "Extension that produces a copy of the report", + source: "extension", + }, + ]; + + const results = filterSlashCommands(commands, "copy"); + + // Only the exact/name match /copy should be returned; all description mentions across skills, prompts, and extensions are suppressed + assert.deepEqual( + results.map((c) => c.name), + ["copy"], + ); +}); + +test("filterSlashCommands preserves source priority order: builtin -> extension -> prompt -> skill on equal rank", () => { + const commands = [ + { name: "skill:sync", source: "skill" }, + { name: "sync", source: "prompt" }, + { name: "sync", source: "builtin" }, + { name: "sync", source: "extension" }, + ]; + + const results = filterSlashCommands(commands, "sync"); + + // All have exact match (rank 0); must sort strictly by SLASH_SOURCE_ORDER + assert.deepEqual( + results.map((c) => `${c.source}:${c.name}`), + [ + "builtin:sync", + "extension:sync", + "prompt:sync", + "skill:skill:sync", + ], + ); +}); + +test("filterSlashCommands falls back to description matches across all sources when no names match", () => { + const commands = [ + { + name: "copy", + description: "Copy the last assistant message", + source: "builtin", + }, + { + name: "weather", + description: "Fetch assistant weather report", + source: "extension", + }, + { + name: "daily-brief", + description: "Prepare assistant morning brief", + source: "prompt", + }, + { + name: "skill:code-review", + description: "Assistant review for pull requests", + source: "skill", + }, + { + name: "skill:unrelated", + description: "Something else entirely", + source: "skill", + }, + ]; + + const results = filterSlashCommands(commands, "assistant"); + + // No command named "assistant"; fallback activates for all matching descriptions in source order + assert.deepEqual( + results.map((c) => `${c.source}:${c.name}`), + [ + "builtin:copy", + "extension:weather", + "prompt:daily-brief", + "skill:skill:code-review", + ], + ); +}); + +test("filterSlashCommands returns all commands in source order when query is empty", () => { + const commands = [ + { name: "skill:pen-design", source: "skill" }, + { name: "reload", source: "builtin" }, + { name: "mcp", source: "extension" }, + { name: "generate-report", source: "prompt" }, + { name: "skill:better-writing", source: "skill" }, + { name: "copy", source: "builtin" }, + ]; + + const results = filterSlashCommands(commands, ""); + + // Must group by source order and alphabetize within source + assert.deepEqual( + results.map((c) => `${c.source}:${c.name}`), + [ + "builtin:copy", + "builtin:reload", + "extension:mcp", + "prompt:generate-report", + "skill:skill:better-writing", + "skill:skill:pen-design", + ], + ); +}); + +test("filterSlashCommands integrates with buildSlashCommandLayout and dormancy partitioning", () => { + const commands = [ + { name: "copy", source: "builtin" }, + { name: "skill:copy-active", source: "skill" }, + { name: "skill:copy-dormant", source: "skill" }, + { name: "copy-prompt", source: "prompt" }, + { name: "copy-ext", source: "extension" }, + ]; + + const filtered = filterSlashCommands(commands, "copy"); + const layout = buildSlashCommandLayout(filtered, { + "copy-dormant": true, + "copy-active": false, + }); + + // Verify group sources present and in order + assert.deepEqual( + layout.groups.map((g) => g.source), + ["builtin", "extension", "prompt", "skill"], + ); + + // In skill group, active skill must precede dormant skill + const skillGroup = layout.groups.find((g) => g.source === "skill"); + assert.deepEqual( + skillGroup.items.map((i) => i.command.name), + ["skill:copy-active", "skill:copy-dormant"], + ); + + // Layout commands array matches flattened groups + assert.deepEqual( + layout.commands.map((c) => c.name), + ["copy", "copy-ext", "copy-prompt", "skill:copy-active", "skill:copy-dormant"], + ); +}); + +test("filterSlashCommands handles uppercase queries, missing descriptions, and regex special characters safely", () => { + const commands = [ + { name: "copy", source: "builtin" }, // description undefined + { name: "skill:deep-copy", source: "skill", description: undefined }, + { name: "mcp", source: "extension", description: null }, + ]; + + // Uppercase query + const upperResults = filterSlashCommands(commands, "COPY"); + assert.deepEqual( + upperResults.map((c) => c.name), + ["copy", "skill:deep-copy"], + ); + + // Special regex characters should not crash or throw + const regexResults = filterSlashCommands(commands, "copy[a-z]*+?\\"); + assert.deepEqual(regexResults, []); +}); diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index babcefe850..39d21708b9 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -376,18 +376,75 @@ const SLASH_SOURCE_ORDER: Record = { skill: 3, }; -function slashMatchRank(command: SlashCommandPaletteItem, query: string, t: (key: string) => string): number { - const name = command.name.toLowerCase(); +export function getCleanSlashName(command: SlashCommandPaletteItem): string { + return command.source === "skill" && command.name.startsWith("skill:") + ? command.name.slice("skill:".length).toLowerCase() + : command.name.toLowerCase(); +} + +export function slashMatchRank( + command: SlashCommandPaletteItem, + query: string, + t: (key: string) => string = (k) => k, +): number { + const cleanName = getCleanSlashName(command); + const rawName = command.name.toLowerCase(); + const q = query.toLowerCase(); + + if (cleanName === q || rawName === q) return 0; + if (cleanName.startsWith(q) || rawName.startsWith(q)) return 1; + if (cleanName.includes(`-${q}`) || cleanName.includes(`_${q}`) || rawName.includes(`-${q}`) || rawName.includes(`_${q}`)) return 2; + if (cleanName.includes(q) || rawName.includes(q)) return 3; + const description = getSlashDescription(command, t).toLowerCase(); - if (name === query) return 0; - if (name.startsWith(query)) return 1; - if (name.includes(query)) return 2; - if (description.includes(query)) return 3; - return 4; + if (description.includes(q)) return 4; + return 5; } function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string { - return command.source === "builtin" ? t(command.description) : command.description ?? ""; + if (command.source === "builtin") { + return command.description ? (t(command.description) ?? "") : ""; + } + return command.description ?? ""; +} + +export function filterSlashCommands( + commands: SlashCommandPaletteItem[], + query: string, + t: (key: string) => string = (k) => k, +): SlashCommandPaletteItem[] { + if (!query) { + return [...commands].sort((a, b) => { + return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] + || TEXT_COLLATOR.compare(a.name, b.name); + }); + } + + const normalizedQuery = query.toLowerCase(); + + const hasNameMatch = commands.some((command) => { + const cleanName = getCleanSlashName(command); + const rawName = command.name.toLowerCase(); + return cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); + }); + + return [...commands] + .filter((command) => { + const cleanName = getCleanSlashName(command); + const rawName = command.name.toLowerCase(); + const matchesName = cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); + + if (hasNameMatch) return matchesName; + + const description = getSlashDescription(command, t).toLowerCase(); + return matchesName || description.includes(normalizedQuery); + }) + .sort((a, b) => { + const rankDelta = slashMatchRank(a, normalizedQuery, t) - slashMatchRank(b, normalizedQuery, t); + if (rankDelta !== 0) return rankDelta; + return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] + || TEXT_COLLATOR.compare(a.name, b.name); + }); } // Skill slash commands are named "skill:"; look the skill up in the @@ -1103,18 +1160,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..23db72a6a3 100644 --- a/demo/components/ChatInput.tsx +++ b/demo/components/ChatInput.tsx @@ -266,18 +266,75 @@ const SLASH_SOURCE_ORDER: Record = { skill: 3, }; -function slashMatchRank(command: SlashCommandPaletteItem, query: string, t: (key: string) => string): number { - const name = command.name.toLowerCase(); +export function getCleanSlashName(command: SlashCommandPaletteItem): string { + return command.source === "skill" && command.name.startsWith("skill:") + ? command.name.slice("skill:".length).toLowerCase() + : command.name.toLowerCase(); +} + +export function slashMatchRank( + command: SlashCommandPaletteItem, + query: string, + t: (key: string) => string = (k) => k, +): number { + const cleanName = getCleanSlashName(command); + const rawName = command.name.toLowerCase(); + const q = query.toLowerCase(); + + if (cleanName === q || rawName === q) return 0; + if (cleanName.startsWith(q) || rawName.startsWith(q)) return 1; + if (cleanName.includes(`-${q}`) || cleanName.includes(`_${q}`) || rawName.includes(`-${q}`) || rawName.includes(`_${q}`)) return 2; + if (cleanName.includes(q) || rawName.includes(q)) return 3; + const description = getSlashDescription(command, t).toLowerCase(); - if (name === query) return 0; - if (name.startsWith(query)) return 1; - if (name.includes(query)) return 2; - if (description.includes(query)) return 3; - return 4; + if (description.includes(q)) return 4; + return 5; } function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string { - return command.source === "builtin" ? t(command.description) : command.description ?? ""; + if (command.source === "builtin") { + return command.description ? (t(command.description) ?? "") : ""; + } + return command.description ?? ""; +} + +export function filterSlashCommands( + commands: SlashCommandPaletteItem[], + query: string, + t: (key: string) => string = (k) => k, +): SlashCommandPaletteItem[] { + if (!query) { + return [...commands].sort((a, b) => { + return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] + || TEXT_COLLATOR.compare(a.name, b.name); + }); + } + + const normalizedQuery = query.toLowerCase(); + + const hasNameMatch = commands.some((command) => { + const cleanName = getCleanSlashName(command); + const rawName = command.name.toLowerCase(); + return cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); + }); + + return [...commands] + .filter((command) => { + const cleanName = getCleanSlashName(command); + const rawName = command.name.toLowerCase(); + const matchesName = cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); + + if (hasNameMatch) return matchesName; + + const description = getSlashDescription(command, t).toLowerCase(); + return matchesName || description.includes(normalizedQuery); + }) + .sort((a, b) => { + const rankDelta = slashMatchRank(a, normalizedQuery, t) - slashMatchRank(b, normalizedQuery, t); + if (rankDelta !== 0) return rankDelta; + return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] + || TEXT_COLLATOR.compare(a.name, b.name); + }); } // Skill slash commands are named "skill:"; look the skill up in the @@ -970,18 +1027,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 { From c17b4c59ea1f1937421052496e2f0afce9756f12 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Thu, 29 Oct 2026 15:42:39 +0900 Subject: [PATCH 2/2] fix(chat): slim the slash palette's name-first filter Match a skill by its bare name as pi's TUI does, keep pi-web's description search as the fallback when no name matches, and drop what the issue does not need: the token-boundary rank, the empty-query branch, the description guard for built-ins (they always have one) and the exported helpers. Co-Authored-By: Claude Opus 5.5 --- components/ChatInput.slash-filter.test.mjs | 254 +++------------------ components/ChatInput.tsx | 87 +++---- demo/components/ChatInput.tsx | 87 +++---- 3 files changed, 83 insertions(+), 345 deletions(-) diff --git a/components/ChatInput.slash-filter.test.mjs b/components/ChatInput.slash-filter.test.mjs index b710aae0fd..372b80c907 100644 --- a/components/ChatInput.slash-filter.test.mjs +++ b/components/ChatInput.slash-filter.test.mjs @@ -6,238 +6,42 @@ const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true, }); -const { - filterSlashCommands, - slashMatchRank, - getCleanSlashName, - buildSlashCommandLayout, -} = await jiti.import("./ChatInput.tsx"); +const { filterSlashCommands } = await jiti.import("./ChatInput.tsx"); -test("getCleanSlashName strips skill: prefix but preserves builtin, prompt, and extension names", () => { - assert.equal(getCleanSlashName({ name: "skill:copy-text", source: "skill" }), "copy-text"); - assert.equal(getCleanSlashName({ name: "copy", source: "builtin" }), "copy"); - assert.equal(getCleanSlashName({ name: "auto-copy", source: "prompt" }), "auto-copy"); - assert.equal(getCleanSlashName({ name: "weather-check", source: "extension" }), "weather-check"); - assert.equal(getCleanSlashName({ name: "mcp", source: "extension" }), "mcp"); -}); +const t = (key) => key; +const names = (commands) => commands.map((command) => command.name); -test("slashMatchRank orders exact, prefix, token, and substring matches", () => { - const exact = { name: "copy", source: "builtin" }; - const prefix = { name: "copy-session", source: "builtin" }; - const tokenHyphen = { name: "skill:deep-copy", source: "skill" }; - const tokenUnderscore = { name: "skill:deep_copy", source: "skill" }; - const substring = { name: "photocopy", source: "builtin" }; - const descriptionOnly = { - name: "skill:better-writing", - description: "Polish UI product copy, verb-first buttons, and error recovery", - source: "skill", - }; +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" }, +]; - assert.equal(slashMatchRank(exact, "copy"), 0); - assert.equal(slashMatchRank(prefix, "copy"), 1); - assert.equal(slashMatchRank(tokenHyphen, "copy"), 2); - assert.equal(slashMatchRank(tokenUnderscore, "copy"), 2); - assert.equal(slashMatchRank(substring, "copy"), 3); - assert.equal(slashMatchRank(descriptionOnly, "copy"), 4); +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("slashMatchRank supports matching skills with or without explicit skill: prefix", () => { - const skill = { name: "skill:better-writing", source: "skill" }; - - // Matching without prefix - assert.equal(slashMatchRank(skill, "better-writing"), 0); - assert.equal(slashMatchRank(skill, "better"), 1); - assert.equal(slashMatchRank(skill, "writing"), 2); - - // Matching with explicit skill: prefix - assert.equal(slashMatchRank(skill, "skill:better-writing"), 0); - assert.equal(slashMatchRank(skill, "skill:better"), 1); -}); - -test("filterSlashCommands suppresses noisy description-only matches when name matches exist across all sources", () => { - const commands = [ - { - name: "copy", - description: "Copy the last assistant message", - source: "builtin", - }, - { - name: "skill:better-writing", - description: "Polish UI product copy, verb-first buttons, contextual error recovery, and forward-pointing empty states", - source: "skill", - }, - { - name: "skill:create-agents-md", - description: "Create, update, or audit project AGENTS.md from repository evidence without guessing or copying outdated templates", - source: "skill", - }, - { - name: "skill:pen-design", - description: "Design high-fidelity UI screens and copy layout systems in pen.dev using the 5-doc system", - source: "skill", - }, - { - name: "custom-prompt", - description: "A prompt template that mentions copying files", - source: "prompt", - }, - { - name: "ext-tool", - description: "Extension that produces a copy of the report", - source: "extension", - }, - ]; - - const results = filterSlashCommands(commands, "copy"); - - // Only the exact/name match /copy should be returned; all description mentions across skills, prompts, and extensions are suppressed - assert.deepEqual( - results.map((c) => c.name), - ["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("filterSlashCommands preserves source priority order: builtin -> extension -> prompt -> skill on equal rank", () => { - const commands = [ - { name: "skill:sync", source: "skill" }, - { name: "sync", source: "prompt" }, - { name: "sync", source: "builtin" }, - { name: "sync", source: "extension" }, - ]; - - const results = filterSlashCommands(commands, "sync"); - - // All have exact match (rank 0); must sort strictly by SLASH_SOURCE_ORDER - assert.deepEqual( - results.map((c) => `${c.source}:${c.name}`), - [ - "builtin:sync", - "extension:sync", - "prompt:sync", - "skill:skill:sync", - ], - ); -}); - -test("filterSlashCommands falls back to description matches across all sources when no names match", () => { - const commands = [ - { - name: "copy", - description: "Copy the last assistant message", - source: "builtin", - }, - { - name: "weather", - description: "Fetch assistant weather report", - source: "extension", - }, - { - name: "daily-brief", - description: "Prepare assistant morning brief", - source: "prompt", - }, - { - name: "skill:code-review", - description: "Assistant review for pull requests", - source: "skill", - }, - { - name: "skill:unrelated", - description: "Something else entirely", - source: "skill", - }, - ]; - - const results = filterSlashCommands(commands, "assistant"); - - // No command named "assistant"; fallback activates for all matching descriptions in source order - assert.deepEqual( - results.map((c) => `${c.source}:${c.name}`), - [ - "builtin:copy", - "extension:weather", - "prompt:daily-brief", - "skill:skill:code-review", - ], - ); +test("descriptions are searched only when no name matches", () => { + assert.deepEqual(names(filterSlashCommands(commands, "assistant", t)), ["copy", "daily-brief"]); }); -test("filterSlashCommands returns all commands in source order when query is empty", () => { - const commands = [ - { name: "skill:pen-design", source: "skill" }, - { name: "reload", source: "builtin" }, - { name: "mcp", source: "extension" }, - { name: "generate-report", source: "prompt" }, - { name: "skill:better-writing", source: "skill" }, - { name: "copy", source: "builtin" }, - ]; - - const results = filterSlashCommands(commands, ""); - - // Must group by source order and alphabetize within source - assert.deepEqual( - results.map((c) => `${c.source}:${c.name}`), - [ - "builtin:copy", - "builtin:reload", - "extension:mcp", - "prompt:generate-report", - "skill:skill:better-writing", - "skill:skill:pen-design", - ], - ); -}); - -test("filterSlashCommands integrates with buildSlashCommandLayout and dormancy partitioning", () => { - const commands = [ - { name: "copy", source: "builtin" }, - { name: "skill:copy-active", source: "skill" }, - { name: "skill:copy-dormant", source: "skill" }, - { name: "copy-prompt", source: "prompt" }, - { name: "copy-ext", source: "extension" }, - ]; - - const filtered = filterSlashCommands(commands, "copy"); - const layout = buildSlashCommandLayout(filtered, { - "copy-dormant": true, - "copy-active": false, - }); - - // Verify group sources present and in order - assert.deepEqual( - layout.groups.map((g) => g.source), - ["builtin", "extension", "prompt", "skill"], - ); - - // In skill group, active skill must precede dormant skill - const skillGroup = layout.groups.find((g) => g.source === "skill"); - assert.deepEqual( - skillGroup.items.map((i) => i.command.name), - ["skill:copy-active", "skill:copy-dormant"], - ); - - // Layout commands array matches flattened groups - assert.deepEqual( - layout.commands.map((c) => c.name), - ["copy", "copy-ext", "copy-prompt", "skill:copy-active", "skill:copy-dormant"], - ); -}); - -test("filterSlashCommands handles uppercase queries, missing descriptions, and regex special characters safely", () => { - const commands = [ - { name: "copy", source: "builtin" }, // description undefined - { name: "skill:deep-copy", source: "skill", description: undefined }, - { name: "mcp", source: "extension", description: null }, - ]; - - // Uppercase query - const upperResults = filterSlashCommands(commands, "COPY"); - assert.deepEqual( - upperResults.map((c) => c.name), - ["copy", "skill:deep-copy"], - ); - - // Special regex characters should not crash or throw - const regexResults = filterSlashCommands(commands, "copy[a-z]*+?\\"); - assert.deepEqual(regexResults, []); +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 39d21708b9..abdfbcf1fb 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -376,75 +376,42 @@ const SLASH_SOURCE_ORDER: Record = { skill: 3, }; -export function getCleanSlashName(command: SlashCommandPaletteItem): string { - return command.source === "skill" && command.name.startsWith("skill:") - ? command.name.slice("skill:".length).toLowerCase() - : command.name.toLowerCase(); -} - -export function slashMatchRank( - command: SlashCommandPaletteItem, - query: string, - t: (key: string) => string = (k) => k, -): number { - const cleanName = getCleanSlashName(command); - const rawName = command.name.toLowerCase(); - const q = query.toLowerCase(); - - if (cleanName === q || rawName === q) return 0; - if (cleanName.startsWith(q) || rawName.startsWith(q)) return 1; - if (cleanName.includes(`-${q}`) || cleanName.includes(`_${q}`) || rawName.includes(`-${q}`) || rawName.includes(`_${q}`)) return 2; - if (cleanName.includes(q) || rawName.includes(q)) return 3; - - const description = getSlashDescription(command, t).toLowerCase(); - if (description.includes(q)) return 4; - return 5; +// 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 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; + return 3; } function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string { - if (command.source === "builtin") { - return command.description ? (t(command.description) ?? "") : ""; - } - return command.description ?? ""; + 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 = (k) => k, + t: (key: string) => string, ): SlashCommandPaletteItem[] { - if (!query) { - return [...commands].sort((a, b) => { - return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] - || TEXT_COLLATOR.compare(a.name, b.name); - }); - } - - const normalizedQuery = query.toLowerCase(); - - const hasNameMatch = commands.some((command) => { - const cleanName = getCleanSlashName(command); - const rawName = command.name.toLowerCase(); - return cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); - }); - - return [...commands] - .filter((command) => { - const cleanName = getCleanSlashName(command); - const rawName = command.name.toLowerCase(); - const matchesName = cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); - - if (hasNameMatch) return matchesName; - - const description = getSlashDescription(command, t).toLowerCase(); - return matchesName || description.includes(normalizedQuery); - }) - .sort((a, b) => { - const rankDelta = slashMatchRank(a, normalizedQuery, t) - slashMatchRank(b, normalizedQuery, t); - if (rankDelta !== 0) return rankDelta; - return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] - || TEXT_COLLATOR.compare(a.name, b.name); - }); + 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 diff --git a/demo/components/ChatInput.tsx b/demo/components/ChatInput.tsx index 23db72a6a3..bdbe91278d 100644 --- a/demo/components/ChatInput.tsx +++ b/demo/components/ChatInput.tsx @@ -266,75 +266,42 @@ const SLASH_SOURCE_ORDER: Record = { skill: 3, }; -export function getCleanSlashName(command: SlashCommandPaletteItem): string { - return command.source === "skill" && command.name.startsWith("skill:") - ? command.name.slice("skill:".length).toLowerCase() - : command.name.toLowerCase(); -} - -export function slashMatchRank( - command: SlashCommandPaletteItem, - query: string, - t: (key: string) => string = (k) => k, -): number { - const cleanName = getCleanSlashName(command); - const rawName = command.name.toLowerCase(); - const q = query.toLowerCase(); - - if (cleanName === q || rawName === q) return 0; - if (cleanName.startsWith(q) || rawName.startsWith(q)) return 1; - if (cleanName.includes(`-${q}`) || cleanName.includes(`_${q}`) || rawName.includes(`-${q}`) || rawName.includes(`_${q}`)) return 2; - if (cleanName.includes(q) || rawName.includes(q)) return 3; - - const description = getSlashDescription(command, t).toLowerCase(); - if (description.includes(q)) return 4; - return 5; +// 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 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; + return 3; } function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string { - if (command.source === "builtin") { - return command.description ? (t(command.description) ?? "") : ""; - } - return command.description ?? ""; + 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 = (k) => k, + t: (key: string) => string, ): SlashCommandPaletteItem[] { - if (!query) { - return [...commands].sort((a, b) => { - return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] - || TEXT_COLLATOR.compare(a.name, b.name); - }); - } - - const normalizedQuery = query.toLowerCase(); - - const hasNameMatch = commands.some((command) => { - const cleanName = getCleanSlashName(command); - const rawName = command.name.toLowerCase(); - return cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); - }); - - return [...commands] - .filter((command) => { - const cleanName = getCleanSlashName(command); - const rawName = command.name.toLowerCase(); - const matchesName = cleanName.includes(normalizedQuery) || rawName.includes(normalizedQuery); - - if (hasNameMatch) return matchesName; - - const description = getSlashDescription(command, t).toLowerCase(); - return matchesName || description.includes(normalizedQuery); - }) - .sort((a, b) => { - const rankDelta = slashMatchRank(a, normalizedQuery, t) - slashMatchRank(b, normalizedQuery, t); - if (rankDelta !== 0) return rankDelta; - return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] - || TEXT_COLLATOR.compare(a.name, b.name); - }); + 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