1import {
2 insertRankedSearchResult,
3 normalizeSearchQuery,
4 scoreQueryMatch,
5} from "@t3tools/shared/searchRanking";
6
7import type { ComposerCommandItem } from "./ComposerCommandMenu";
8import { scoreProviderSkill } from "../../providerSkillSearch";
9
10type SlashSearchItem = Extract<
11 ComposerCommandItem,
12 { type: "slash-command" | "provider-slash-command" | "skill" }
13>;
14
15/**
16 * A provider expands a slash command only when it opens the whole message;
17 * anywhere else it reaches the agent as literal text, so it is not offered
18 * there. Built-ins apply locally on selection and skills insert a `$` mention
19 * the server dispatches from any position, so both stay available.
20 */
21export function slashCommandItemsForPromptPosition(
22 items: ReadonlyArray<SlashSearchItem>,
23 isAtPromptStart: boolean,
24): SlashSearchItem[] {
25 if (isAtPromptStart) {
26 return [...items];
27 }
28 return items.filter((item) => item.type !== "provider-slash-command");
29}
30
31function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {
32 if (item.type === "skill") {
33 if (query === "skill") {
34 return 0;
35 }
36 const skillQuery = query.startsWith("skill:") ? query.slice("skill:".length) : query;
37 const skillScore = skillQuery ? scoreProviderSkill(item.skill, skillQuery) : 0;
38 if (skillScore !== null) {
39 return skillScore;
40 }
41 return "skill".startsWith(query) ? Number.MAX_SAFE_INTEGER : null;
42 }
43
44 const primaryValue =
45 item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase();
46 const description = item.description.toLowerCase();
47
48 const scores = [
49 scoreQueryMatch({
50 value: primaryValue,
51 query,
52 exactBase: 0,
53 prefixBase: 2,
54 boundaryBase: 4,
55 includesBase: 6,
56 fuzzyBase: 100,
57 boundaryMarkers: ["-", "_", "/"],
58 }),
59 scoreQueryMatch({
60 value: description,
61 query,
62 exactBase: 20,
63 prefixBase: 22,
64 boundaryBase: 24,
65 includesBase: 26,
66 }),
67 ].filter((score): score is number => score !== null);
68
69 if (scores.length === 0) {
70 return null;
71 }
72
73 return Math.min(...scores);
74}
75
76export function searchSlashCommandItems(
77 items: ReadonlyArray<SlashSearchItem>,
78 query: string,
79): SlashSearchItem[] {
80 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ });
81 if (!normalizedQuery) {
82 return [...items];
83 }
84
85 const ranked: Array<{
86 item: SlashSearchItem;
87 score: number;
88 tieBreaker: string;
89 }> = [];
90
91 for (const item of items) {
92 const score = scoreSlashCommandItem(item, normalizedQuery);
93 if (score === null) {
94 continue;
95 }
96
97 insertRankedSearchResult(
98 ranked,
99 {
100 item,
101 score,
102 tieBreaker:
103 item.type === "slash-command"
104 ? `0\u0000${item.command}`
105 : item.type === "provider-slash-command"
106 ? `1\u0000${item.command.name}\u0000${item.provider}`
107 : `2\u0000${item.skill.name}\u0000${item.provider}`,
108 },
109 Number.POSITIVE_INFINITY,
110 );
111 }
112
113 return ranked.map((entry) => entry.item);
114}