diff --git a/src/browser/utils/agentSkills/inlineSkillSuggestions.ts b/src/browser/utils/agentSkills/inlineSkillSuggestions.ts index aaa71fcec56..ddd18ef7586 100644 --- a/src/browser/utils/agentSkills/inlineSkillSuggestions.ts +++ b/src/browser/utils/agentSkills/inlineSkillSuggestions.ts @@ -1,4 +1,4 @@ -import { matchesNameBySegmentPrefix } from "@/browser/utils/suggestionMatching"; +import { filterAndRankByNameMatch } from "@/browser/utils/suggestionMatching"; import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { MCPPromptDescriptor } from "@/common/orpc/schemas/mcp"; @@ -46,23 +46,27 @@ export function getInlineSkillInsertionTrailingText(after: string): "" | " " { export function getInlineSkillSuggestions( context: InlineSkillSuggestionContext ): SlashSuggestion[] { - const skills = context.descriptors - .filter((descriptor) => descriptor.userInvocable !== false) - .filter((descriptor) => matchesNameBySegmentPrefix(descriptor.name, context.partial)) - .map((descriptor) => ({ - id: `inline-skill:${descriptor.name}`, - display: `$${descriptor.name}`, - description: descriptor.description ?? "", - replacement: `$${descriptor.name}`, - })); - const prompts = (context.mcpPrompts ?? []) - .filter((prompt) => !(prompt.arguments ?? []).some((argument) => argument.required)) - .filter((prompt) => matchesNameBySegmentPrefix(prompt.commandKey, context.partial)) - .map((prompt) => ({ - id: `inline-mcp-prompt:${prompt.commandKey}`, - display: `$${prompt.commandKey}`, - description: prompt.description ?? `MCP prompt from ${prompt.serverName}`, - replacement: `$${prompt.commandKey}`, - })); + const skills = filterAndRankByNameMatch( + context.descriptors.filter((descriptor) => descriptor.userInvocable !== false), + context.partial, + (descriptor) => descriptor.name + ).map((descriptor) => ({ + id: `inline-skill:${descriptor.name}`, + display: `$${descriptor.name}`, + description: descriptor.description ?? "", + replacement: `$${descriptor.name}`, + })); + const prompts = filterAndRankByNameMatch( + (context.mcpPrompts ?? []).filter( + (prompt) => !(prompt.arguments ?? []).some((argument) => argument.required) + ), + context.partial, + (prompt) => prompt.commandKey + ).map((prompt) => ({ + id: `inline-mcp-prompt:${prompt.commandKey}`, + display: `$${prompt.commandKey}`, + description: prompt.description ?? `MCP prompt from ${prompt.serverName}`, + replacement: `$${prompt.commandKey}`, + })); return [...skills, ...prompts]; } diff --git a/src/browser/utils/slashCommands/suggestions.test.ts b/src/browser/utils/slashCommands/suggestions.test.ts index 82897d8d3f4..b4fd290ee6f 100644 --- a/src/browser/utils/slashCommands/suggestions.test.ts +++ b/src/browser/utils/slashCommands/suggestions.test.ts @@ -280,3 +280,39 @@ describe("getSlashCommandSuggestions", () => { }); }); }); + +describe("suggestion ranking", () => { + const agentSkills = [ + { + name: "auto-lint", + description: "Run the linters automatically", + scope: "project" as const, + }, + { + name: "lint", + description: "Run the linters", + scope: "project" as const, + }, + { + name: "lint-fix", + description: "Run the linters and fix findings", + scope: "project" as const, + }, + ]; + + it("ranks an exact skill name above prefix and segment matches", () => { + const suggestions = getSlashCommandSuggestions("/lint", { agentSkills }); + const skillIds = suggestions + .filter((suggestion) => suggestion.id.startsWith("skill:")) + .map((suggestion) => suggestion.id); + expect(skillIds).toEqual(["skill:lint", "skill:lint-fix", "skill:auto-lint"]); + }); + + it("keeps discovery order for a bare slash", () => { + const suggestions = getSlashCommandSuggestions("/", { agentSkills }); + const skillIds = suggestions + .filter((suggestion) => suggestion.id.startsWith("skill:")) + .map((suggestion) => suggestion.id); + expect(skillIds).toEqual(["skill:auto-lint", "skill:lint", "skill:lint-fix"]); + }); +}); diff --git a/src/browser/utils/slashCommands/suggestions.ts b/src/browser/utils/slashCommands/suggestions.ts index 5d5545124c9..e9d331fa11e 100644 --- a/src/browser/utils/slashCommands/suggestions.ts +++ b/src/browser/utils/slashCommands/suggestions.ts @@ -2,7 +2,7 @@ * Slash command suggestions generation */ -import { matchesNameBySegmentPrefix } from "@/browser/utils/suggestionMatching"; +import { filterAndRankByNameMatch } from "@/browser/utils/suggestionMatching"; import { MODEL_ABBREVIATIONS } from "@/common/constants/knownModels"; import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay"; import { getSlashCommandDefinitions } from "./parser"; @@ -24,12 +24,10 @@ function filterAndMapSuggestions( build: (definition: T) => SlashSuggestion, filter?: (definition: T) => boolean ): SlashSuggestion[] { - return definitions - .filter((definition) => { - if (filter && !filter(definition)) return false; - return matchesNameBySegmentPrefix(definition.key, partial); - }) - .map((definition) => build(definition)); + const candidates = filter ? definitions.filter(filter) : [...definitions]; + return filterAndRankByNameMatch(candidates, partial, (definition) => definition.key).map( + (definition) => build(definition) + ); } function buildTopLevelSuggestions( @@ -96,32 +94,35 @@ function buildTopLevelSuggestions( // whose replacement IS the expansion text (no parser/send-path involvement). // Built-in command keys and skill names take precedence on collision. const claimedSkillNames = new Set(skillDefinitions.map((definition) => definition.key)); - const pluginCommandSuggestions = (context.pluginCommands ?? []) - .filter( + const pluginCommandSuggestions = filterAndRankByNameMatch( + (context.pluginCommands ?? []).filter( (command) => !SLASH_COMMAND_DEFINITION_MAP.has(command.name) && !claimedSkillNames.has(command.name) - ) - .filter((command) => matchesNameBySegmentPrefix(command.name, partial)) - .map((command) => ({ - id: `plugin-command:${command.name}`, - display: `/${command.name}`, - description: `${command.description ?? "Plugin command"} (plugin:${command.pluginName})`, - replacement: command.expansion, - })); - - const promptSuggestions = (context.mcpPrompts ?? []) - .filter((prompt) => matchesNameBySegmentPrefix(prompt.commandKey, partial)) - .map((prompt) => { - const argumentHint = (prompt.arguments ?? []) - .map((argument) => `[${argument.name}${argument.required ? "" : "?"}]`) - .join(" "); - return { - id: `mcp-prompt:${prompt.commandKey}`, - display: `/${prompt.commandKey}${argumentHint ? ` ${argumentHint}` : ""}`, - description: `${prompt.description ?? "MCP prompt"} (${prompt.serverName})`, - replacement: `/${prompt.commandKey} `, - }; - }); + ), + partial, + (command) => command.name + ).map((command) => ({ + id: `plugin-command:${command.name}`, + display: `/${command.name}`, + description: `${command.description ?? "Plugin command"} (plugin:${command.pluginName})`, + replacement: command.expansion, + })); + + const promptSuggestions = filterAndRankByNameMatch( + context.mcpPrompts ?? [], + partial, + (prompt) => prompt.commandKey + ).map((prompt) => { + const argumentHint = (prompt.arguments ?? []) + .map((argument) => `[${argument.name}${argument.required ? "" : "?"}]`) + .join(" "); + return { + id: `mcp-prompt:${prompt.commandKey}`, + display: `/${prompt.commandKey}${argumentHint ? ` ${argumentHint}` : ""}`, + description: `${prompt.description ?? "MCP prompt"} (${prompt.serverName})`, + replacement: `/${prompt.commandKey} `, + }; + }); // Model alias one-shot suggestions (e.g., /haiku, /sonnet, /opus+high). // The build callback below hardcodes the trailing space, so `appendSpace` diff --git a/src/browser/utils/suggestionMatching.test.ts b/src/browser/utils/suggestionMatching.test.ts index 61b9bc5cedd..c93154f44e7 100644 --- a/src/browser/utils/suggestionMatching.test.ts +++ b/src/browser/utils/suggestionMatching.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { matchesNameBySegmentPrefix } from "./suggestionMatching"; +import { + filterAndRankByNameMatch, + matchesNameBySegmentPrefix, + NAME_MATCH_RANK, + rankNameMatch, +} from "./suggestionMatching"; describe("matchesNameBySegmentPrefix", () => { test("matches empty and whitespace-only partials", () => { @@ -20,3 +25,33 @@ describe("matchesNameBySegmentPrefix", () => { expect(matchesNameBySegmentPrefix("deep-review", "view")).toBe(false); }); }); + +describe("rankNameMatch", () => { + test("ranks exact above whole-name prefix above segment prefix", () => { + expect(rankNameMatch("lint", "lint")).toBe(NAME_MATCH_RANK.exact); + expect(rankNameMatch("lint-fix", "lint")).toBe(NAME_MATCH_RANK.namePrefix); + expect(rankNameMatch("auto-lint", "lint")).toBe(NAME_MATCH_RANK.segmentPrefix); + expect(rankNameMatch("deep-review", "view")).toBe(null); + }); + + test("is case-insensitive and trims the partial", () => { + expect(rankNameMatch("LINT", " lint ")).toBe(NAME_MATCH_RANK.exact); + }); + + test("empty partials match everything at equal rank", () => { + expect(rankNameMatch("deep-review", "")).toBe(rankNameMatch("lint", " ")); + }); +}); + +describe("filterAndRankByNameMatch", () => { + test("orders matches by tier and keeps original order within a tier", () => { + const names = ["auto-lint", "run-lint", "lint", "lint-fix", "unrelated", "lint-staged"]; + expect(filterAndRankByNameMatch(names, "lint", (name) => name)).toEqual([ + "lint", + "lint-fix", + "lint-staged", + "auto-lint", + "run-lint", + ]); + }); +}); diff --git a/src/browser/utils/suggestionMatching.ts b/src/browser/utils/suggestionMatching.ts index 9fd42344244..a82b43d2531 100644 --- a/src/browser/utils/suggestionMatching.ts +++ b/src/browser/utils/suggestionMatching.ts @@ -1,3 +1,39 @@ +/** + * Match-quality tiers for suggestion name matching. Lower ranks first; ties + * keep the caller's original order (discovery lists are alphabetical), so an + * exact name beats a whole-name prefix beats a mid-name segment prefix — + * typing "/lint" must rank a `lint` skill above `auto-lint`. + */ +export const NAME_MATCH_RANK = { + exact: 0, + namePrefix: 1, + segmentPrefix: 2, +} as const; + +/** + * Case-insensitive match quality of `partial` against a hyphenated name, or + * null when it doesn't match. Empty or whitespace-only partials match every + * name at equal rank (a bare "/" lists everything in original order). + */ +export function rankNameMatch(name: string, partial: string): number | null { + const normalizedPartial = partial.trim().toLowerCase(); + const normalizedName = name.toLowerCase(); + + if (normalizedPartial.length === 0) { + return NAME_MATCH_RANK.namePrefix; + } + if (normalizedName === normalizedPartial) { + return NAME_MATCH_RANK.exact; + } + if (normalizedName.startsWith(normalizedPartial)) { + return NAME_MATCH_RANK.namePrefix; + } + if (normalizedName.split("-").some((segment) => segment.startsWith(normalizedPartial))) { + return NAME_MATCH_RANK.segmentPrefix; + } + return null; +} + /** * Case-insensitive prefix match against a hyphenated name. * @@ -5,12 +41,23 @@ * hyphen-delimited segment. Empty or whitespace-only partials match everything. */ export function matchesNameBySegmentPrefix(name: string, partial: string): boolean { - const normalizedPartial = partial.trim().toLowerCase(); - const normalizedName = name.toLowerCase(); + return rankNameMatch(name, partial) !== null; +} - return ( - normalizedPartial.length === 0 || - normalizedName.startsWith(normalizedPartial) || - normalizedName.split("-").some((segment) => segment.startsWith(normalizedPartial)) - ); +/** + * Filter `items` to those whose name matches `partial`, best match first. + * The sort is stable, so items within a tier keep their original order. + */ +export function filterAndRankByNameMatch( + items: readonly T[], + partial: string, + getName: (item: T) => string +): T[] { + return items + .flatMap((item) => { + const rank = rankNameMatch(getName(item), partial); + return rank === null ? [] : [{ item, rank }]; + }) + .sort((a, b) => a.rank - b.rank) + .map((entry) => entry.item); }