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
42 changes: 23 additions & 19 deletions src/browser/utils/agentSkills/inlineSkillSuggestions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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];
}
36 changes: 36 additions & 0 deletions src/browser/utils/slashCommands/suggestions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
63 changes: 32 additions & 31 deletions src/browser/utils/slashCommands/suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,12 +24,10 @@ function filterAndMapSuggestions<T extends SuggestionDefinition>(
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(
Expand Down Expand Up @@ -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`
Expand Down
37 changes: 36 additions & 1 deletion src/browser/utils/suggestionMatching.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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",
]);
});
});
61 changes: 54 additions & 7 deletions src/browser/utils/suggestionMatching.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,63 @@
/**
* 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.
*
* Returns true when `partial` is a prefix of the full name or any
* 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<T>(
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);
}