authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-20 21:27:36-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 21:27:36-04:00
loge723501227b7d1d0b960eb8fcfd9c8d6099d805e
tree85fc282ffef0555b8a96dfbf7d98b3d3ccc4e2c4
parent68966c1e6603d6f5d52a4ee227e2b0ee03b7109f
signature Signed by PGP key B5690EEEBB952194

feat(composer): list skills with slash commands (#7737)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>

11 files changed, 255 insertions(+), 21 deletions(-)

apps/mobile/src/features/threads/ComposerCommandPopover.tsx+10-1
...@@ -119,6 +119,7 @@ const CommandRow = memo(function CommandRow(props: {...@@ -119,6 +119,7 @@ const CommandRow = memo(function CommandRow(props: {
119 readonly item: ComposerCommandItem;119 readonly item: ComposerCommandItem;
120 readonly onPress: () => void;120 readonly onPress: () => void;
121 readonly isLast: boolean;121 readonly isLast: boolean;
122 readonly isSlashSkill: boolean;
122}) {123}) {
123 const iconName = itemIcon(props.item);124 const iconName = itemIcon(props.item);
124 const iconColor = useThemeColor("--color-icon-subtle");125 const iconColor = useThemeColor("--color-icon-subtle");
...@@ -144,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: {...@@ -144,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: {
144 <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" />145 <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" />
145 ) : null}146 ) : null}
146 <Text className="shrink-0 text-base font-t3-medium text-foreground" numberOfLines={1}>147 <Text className="shrink-0 text-base font-t3-medium text-foreground" numberOfLines={1}>
147 {props.item.label}148 {props.isSlashSkill && props.item.type === "skill" ? (
149 <>
150 <Text className="text-foreground-muted">skill:</Text>
151 {props.item.skill.name}
152 </>
153 ) : (
154 props.item.label
155 )}
148 </Text>156 </Text>
149 {props.item.description ? (157 {props.item.description ? (
150 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>158 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
...@@ -181,6 +189,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(...@@ -181,6 +189,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(
181 item={item}189 item={item}
182 onPress={() => props.onSelect(item)}190 onPress={() => props.onSelect(item)}
183 isLast={index === props.items.length - 1}191 isLast={index === props.items.length - 1}
192 isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"}
184 />193 />
185 ))}194 ))}
186 </ScrollView>195 </ScrollView>
apps/mobile/src/features/threads/ThreadComposer.tsx+12-1
...@@ -67,6 +67,7 @@ import {...@@ -67,6 +67,7 @@ import {
67import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";67import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
68import { useComposerPathSearch } from "../../state/use-composer-path-search";68import { useComposerPathSearch } from "../../state/use-composer-path-search";
69import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";69import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
70import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
70import {71import {
71 type ExistingThreadSettingsRouteSession,72 type ExistingThreadSettingsRouteSession,
72 useExistingThreadSettingsRoutePresentation,73 useExistingThreadSettingsRoutePresentation,
...@@ -430,7 +431,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -430,7 +431,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
430 });431 });
431 }432 }
432433
433 return [...builtIn, ...providerCommands];434 const skillItems = (selectedProviderStatus?.skills ?? [])
435 .filter((skill) => matchesSlashSkillQuery(skill, q))
436 .map((skill) => ({
437 id: `skill:${skill.name}`,
438 type: "skill" as const,
439 skill,
440 label: `skill:${skill.name}`,
441 description: skill.shortDescription ?? skill.description ?? "",
442 }));
443
444 return [...builtIn, ...providerCommands, ...skillItems];
434 }445 }
435446
436 if (composerTrigger.kind === "skill") {447 if (composerTrigger.kind === "skill") {
apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts created+17
...@@ -0,0 +1,17 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
4
5const browserSkill = {
6 name: "browser",
7 path: "/skills/browser/SKILL.md",
8 enabled: true,
9 shortDescription: "Open and control the in-app browser",
10};
11
12describe("matchesSlashSkillQuery", () => {
13 it("matches the rendered skill prefix", () => {
14 expect(matchesSlashSkillQuery(browserSkill, "skill")).toBe(true);
15 expect(matchesSlashSkillQuery(browserSkill, "skill:brow")).toBe(true);
16 });
17});
apps/mobile/src/features/threads/composerSlashSkillSearch.ts created+16
...@@ -0,0 +1,16 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2
3export function matchesSlashSkillQuery(skill: ServerProviderSkill, query: string): boolean {
4 if (!skill.enabled) return false;
5 const normalizedQuery = query.toLowerCase();
6 const skillQuery =
7 normalizedQuery === "skill"
8 ? ""
9 : normalizedQuery.startsWith("skill:")
10 ? normalizedQuery.slice("skill:".length)
11 : normalizedQuery;
12 if (!skillQuery) return true;
13 return [skill.name, skill.displayName, skill.shortDescription, skill.description].some((value) =>
14 value?.toLowerCase().includes(skillQuery),
15 );
16}
apps/web/src/components/chat/ChatComposer.tsx+18-4
...@@ -1117,10 +1117,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1117,10 +1117,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1117 }),1117 }),
1118 );1118 );
1119 const query = composerTrigger.query.trim().toLowerCase();1119 const query = composerTrigger.query.trim().toLowerCase();
1120 const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems];1120 const skillItems = (selectedProviderStatus?.skills ?? [])
1121 if (!query) {1121 .filter((skill) => skill.enabled)
1122 return slashCommandItems;1122 .map((skill) => ({
1123 }1123 id: `skill:${selectedProvider}:${skill.name}`,
1124 type: "skill" as const,
1125 provider: selectedProvider,
1126 skill,
1127 label: `skill:${skill.name}`,
1128 description:
1129 skill.shortDescription ??
1130 skill.description ??
1131 (skill.scope ? `${skill.scope} skill` : ""),
1132 }));
1133 const slashCommandItems = [
1134 ...builtInSlashCommandItems,
1135 ...providerSlashCommandItems,
1136 ...skillItems,
1137 ];
1124 return searchSlashCommandItems(slashCommandItems, query);1138 return searchSlashCommandItems(slashCommandItems, query);
1125 }1139 }
1126 if (composerTrigger.kind === "skill") {1140 if (composerTrigger.kind === "skill") {
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+32
...@@ -86,4 +86,36 @@ describe("ComposerCommandMenu", () => {...@@ -86,4 +86,36 @@ describe("ComposerCommandMenu", () => {
86 expect(markup).toContain("<svg");86 expect(markup).toContain("<svg");
87 expect(markup).toContain("text-icon-muted");87 expect(markup).toContain("text-icon-muted");
88 });88 });
89
90 it("renders slash skill results with only the skill prefix dimmed", () => {
91 const markup = renderToStaticMarkup(
92 <ComposerCommandMenu
93 items={[
94 {
95 id: "skill:codex:browser",
96 type: "skill",
97 provider: ProviderDriverKind.make("codex"),
98 skill: {
99 name: "browser",
100 path: "/skills/browser/SKILL.md",
101 enabled: true,
102 },
103 label: "skill:browser",
104 description: "Open and control the in-app browser",
105 },
106 ]}
107 resolvedTheme="dark"
108 isLoading={false}
109 triggerKind="slash-command"
110 activeItemId="skill:codex:browser"
111 onHighlightedItemChange={() => {}}
112 onSelect={() => {}}
113 />,
114 );
115
116 expect(markup).toContain('<span class="text-secondary-label">skill:</span>browser');
117 expect(markup).toContain("Open and control the in-app browser");
118 expect(markup).not.toContain("font-medium text-secondary-label");
119 expect(markup).not.toContain("<svg");
120 });
89});121});
apps/web/src/components/chat/ComposerCommandMenu.tsx+15-2
...@@ -99,6 +99,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -99,6 +99,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
99 <ComposerCommandMenuItem99 <ComposerCommandMenuItem
100 key={item.id}100 key={item.id}
101 item={item}101 item={item}
102 triggerKind={props.triggerKind}
102 resolvedTheme={props.resolvedTheme}103 resolvedTheme={props.resolvedTheme}
103 isActive={props.activeItemId === item.id}104 isActive={props.activeItemId === item.id}
104 onHighlight={props.onHighlightedItemChange}105 onHighlight={props.onHighlightedItemChange}
...@@ -130,6 +131,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -130,6 +131,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
130131
131const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {132const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
132 item: ComposerCommandItem;133 item: ComposerCommandItem;
134 triggerKind: ComposerTriggerKind | null;
133 resolvedTheme: "light" | "dark";135 resolvedTheme: "light" | "dark";
134 isActive: boolean;136 isActive: boolean;
135 onHighlight: (itemId: string | null) => void;137 onHighlight: (itemId: string | null) => void;
...@@ -137,6 +139,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -137,6 +139,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
137}) {139}) {
138 const skillSourceKind =140 const skillSourceKind =
139 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;141 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
142 const slashSkill =
143 props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;
140144
141 return (145 return (
142 <CommandItem146 <CommandItem
...@@ -162,11 +166,20 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -162,11 +166,20 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
162 kind={props.item.pathKind}166 kind={props.item.pathKind}
163 theme={props.resolvedTheme}167 theme={props.resolvedTheme}
164 />168 />
165 ) : skillSourceKind ? (169 ) : skillSourceKind && !slashSkill ? (
166 <SkillSourceIcon kind={skillSourceKind} />170 <SkillSourceIcon kind={skillSourceKind} />
167 ) : null}171 ) : null}
168 <span className="flex min-w-0 flex-1 items-baseline gap-3">172 <span className="flex min-w-0 flex-1 items-baseline gap-3">
169 <span className="shrink-0 font-sans text-xs font-medium">{props.item.label}</span>173 <span className="shrink-0 font-sans text-xs font-medium">
174 {slashSkill ? (
175 <>
176 <span className="text-secondary-label">skill:</span>
177 {slashSkill.name}
178 </>
179 ) : (
180 props.item.label
181 )}
182 </span>
170 <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">183 <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">
171 {props.item.description}184 {props.item.description}
172 </span>185 </span>
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+102-2
...@@ -33,7 +33,7 @@ describe("searchSlashCommandItems", () => {...@@ -33,7 +33,7 @@ describe("searchSlashCommandItems", () => {
33 description: "Create distinctive, production-grade frontend interfaces",33 description: "Create distinctive, production-grade frontend interfaces",
34 },34 },
35 ] satisfies Array<35 ] satisfies Array<
36 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>36 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" | "skill" }>
37 >;37 >;
3838
39 expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([39 expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([
...@@ -61,11 +61,111 @@ describe("searchSlashCommandItems", () => {...@@ -61,11 +61,111 @@ describe("searchSlashCommandItems", () => {
61 description: "General GitHub help",61 description: "General GitHub help",
62 },62 },
63 ] satisfies Array<63 ] satisfies Array<
64 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>64 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" | "skill" }>
65 >;65 >;
6666
67 expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([67 expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([
68 "provider-slash-command:claudeAgent:gh-fix-ci",68 "provider-slash-command:claudeAgent:gh-fix-ci",
69 ]);69 ]);
70 });70 });
71
72 it("includes skills by name and description", () => {
73 const items = [
74 {
75 id: "skill:claudeAgent:browser",
76 type: "skill",
77 provider: claudeDriver,
78 skill: {
79 name: "browser",
80 path: "/skills/browser/SKILL.md",
81 enabled: true,
82 shortDescription: "Open and control the in-app browser",
83 },
84 label: "skill:browser",
85 description: "Open and control the in-app browser",
86 },
87 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;
88
89 expect(searchSlashCommandItems(items, "browser").map((item) => item.id)).toEqual([
90 "skill:claudeAgent:browser",
91 ]);
92 expect(searchSlashCommandItems(items, "control").map((item) => item.id)).toEqual([
93 "skill:claudeAgent:browser",
94 ]);
95 });
96
97 it("matches skills by display name", () => {
98 const items = [
99 {
100 id: "skill:claudeAgent:browser",
101 type: "skill",
102 provider: claudeDriver,
103 skill: {
104 name: "browser",
105 displayName: "Web Navigator",
106 path: "/skills/browser/SKILL.md",
107 enabled: true,
108 shortDescription: "Open and control the in-app browser",
109 },
110 label: "skill:browser",
111 description: "Open and control the in-app browser",
112 },
113 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;
114
115 expect(searchSlashCommandItems(items, "navigator").map((item) => item.id)).toEqual([
116 "skill:claudeAgent:browser",
117 ]);
118 });
119
120 it("matches skills by their rendered prefix", () => {
121 const items = [
122 {
123 id: "skill:claudeAgent:browser",
124 type: "skill",
125 provider: claudeDriver,
126 skill: {
127 name: "browser",
128 path: "/skills/browser/SKILL.md",
129 enabled: true,
130 },
131 label: "skill:browser",
132 description: "Open and control the in-app browser",
133 },
134 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;
135
136 expect(searchSlashCommandItems(items, "/skill:brow").map((item) => item.id)).toEqual([
137 "skill:claudeAgent:browser",
138 ]);
139 expect(searchSlashCommandItems(items, "/sk")).toEqual([]);
140 expect(searchSlashCommandItems(items, "/ill")).toEqual([]);
141 });
142
143 it("keeps skills alongside commands for an empty slash query", () => {
144 const items = [
145 {
146 id: "slash:model",
147 type: "slash-command",
148 command: "model",
149 label: "/model",
150 description: "Switch model",
151 },
152 {
153 id: "skill:claudeAgent:unslop",
154 type: "skill",
155 provider: claudeDriver,
156 skill: {
157 name: "unslop",
158 path: "/skills/unslop/SKILL.md",
159 enabled: true,
160 },
161 label: "skill:unslop",
162 description: "Cut AI tells from writing",
163 },
164 ] satisfies Array<Extract<ComposerCommandItem, { type: "slash-command" | "skill" }>>;
165
166 expect(searchSlashCommandItems(items, "").map((item) => item.id)).toEqual([
167 "slash:model",
168 "skill:claudeAgent:unslop",
169 ]);
170 });
71});171});
apps/web/src/components/chat/composerSlashCommandSearch.ts+19-10
...@@ -5,11 +5,20 @@ import {...@@ -5,11 +5,20 @@ import {
5} from "@t3tools/shared/searchRanking";5} from "@t3tools/shared/searchRanking";
66
7import type { ComposerCommandItem } from "./ComposerCommandMenu";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
15function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {
16 if (item.type === "skill") {
17 const skillQuery =
18 query === "skill" ? "" : query.startsWith("skill:") ? query.slice("skill:".length) : query;
19 return skillQuery ? scoreProviderSkill(item.skill, skillQuery) : 0;
20 }
821
9function scoreSlashCommandItem(
10 item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>,
11 query: string,
12): number | null {
13 const primaryValue =22 const primaryValue =
14 item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase();23 item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase();
15 const description = item.description.toLowerCase();24 const description = item.description.toLowerCase();
...@@ -43,18 +52,16 @@ function scoreSlashCommandItem(...@@ -43,18 +52,16 @@ function scoreSlashCommandItem(
43}52}
4453
45export function searchSlashCommandItems(54export function searchSlashCommandItems(
46 items: ReadonlyArray<55 items: ReadonlyArray<SlashSearchItem>,
47 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
48 >,
49 query: string,56 query: string,
50): Array<Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>> {57): SlashSearchItem[] {
51 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ });58 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ });
52 if (!normalizedQuery) {59 if (!normalizedQuery) {
53 return [...items];60 return [...items];
54 }61 }
5562
56 const ranked: Array<{63 const ranked: Array<{
57 item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>;64 item: SlashSearchItem;
58 score: number;65 score: number;
59 tieBreaker: string;66 tieBreaker: string;
60 }> = [];67 }> = [];
...@@ -73,7 +80,9 @@ export function searchSlashCommandItems(...@@ -73,7 +80,9 @@ export function searchSlashCommandItems(
73 tieBreaker:80 tieBreaker:
74 item.type === "slash-command"81 item.type === "slash-command"
75 ? `0\u0000${item.command}`82 ? `0\u0000${item.command}`
76 : `1\u0000${item.command.name}\u0000${item.provider}`,83 : item.type === "provider-slash-command"
84 ? `1\u0000${item.command.name}\u0000${item.provider}`
85 : `2\u0000${item.skill.name}\u0000${item.provider}`,
77 },86 },
78 Number.POSITIVE_INFINITY,87 Number.POSITIVE_INFINITY,
79 );88 );
apps/web/src/providerSkillSearch.test.ts+13
...@@ -56,4 +56,17 @@ describe("searchProviderSkills", () => {...@@ -56,4 +56,17 @@ describe("searchProviderSkills", () => {
5656
57 expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]);57 expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]);
58 });58 });
59
60 it("returns every enabled skill for an empty query", () => {
61 const skills = [
62 makeSkill({ name: "unslop" }),
63 makeSkill({ name: "browser" }),
64 makeSkill({ name: "disabled", enabled: false }),
65 ];
66
67 expect(searchProviderSkills(skills, "").map((skill) => skill.name)).toEqual([
68 "unslop",
69 "browser",
70 ]);
71 });
59});72});
apps/web/src/providerSkillSearch.ts+1-1
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 scoreQueryMatch,6 scoreQueryMatch,
7} from "@t3tools/shared/searchRanking";7} from "@t3tools/shared/searchRanking";
88
9function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {9export function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {
10 const normalizedName = skill.name.toLowerCase();10 const normalizedName = skill.name.toLowerCase();
11 const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase();11 const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase();
12 const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? "";12 const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? "";