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: {
119119 readonly item: ComposerCommandItem;
120120 readonly onPress: () => void;
121121 readonly isLast: boolean;
122 readonly isSlashSkill: boolean;
122123}) {
123124 const iconName = itemIcon(props.item);
124125 const iconColor = useThemeColor("--color-icon-subtle");
......@@ -144,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: {
144145 <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" />
145146 ) : null}
146147 <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 )}
148156 </Text>
149157 {props.item.description ? (
150158 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
......@@ -181,6 +189,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(
181189 item={item}
182190 onPress={() => props.onSelect(item)}
183191 isLast={index === props.items.length - 1}
192 isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"}
184193 />
185194 ))}
186195 </ScrollView>
apps/mobile/src/features/threads/ThreadComposer.tsx+12-1
......@@ -67,6 +67,7 @@ import {
6767import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
6868import { useComposerPathSearch } from "../../state/use-composer-path-search";
6969import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
70import { matchesSlashSkillQuery } from "./composerSlashSkillSearch";
7071import {
7172 type ExistingThreadSettingsRouteSession,
7273 useExistingThreadSettingsRoutePresentation,
......@@ -430,7 +431,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
430431 });
431432 }
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];
434445 }
435446
436447 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)
11171117 }),
11181118 );
11191119 const query = composerTrigger.query.trim().toLowerCase();
1120 const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems];
1121 if (!query) {
1122 return slashCommandItems;
1123 }
1120 const skillItems = (selectedProviderStatus?.skills ?? [])
1121 .filter((skill) => skill.enabled)
1122 .map((skill) => ({
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 ];
11241138 return searchSlashCommandItems(slashCommandItems, query);
11251139 }
11261140 if (composerTrigger.kind === "skill") {
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+32
......@@ -86,4 +86,36 @@ describe("ComposerCommandMenu", () => {
8686 expect(markup).toContain("<svg");
8787 expect(markup).toContain("text-icon-muted");
8888 });
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 });
89121});
apps/web/src/components/chat/ComposerCommandMenu.tsx+15-2
......@@ -99,6 +99,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
9999 <ComposerCommandMenuItem
100100 key={item.id}
101101 item={item}
102 triggerKind={props.triggerKind}
102103 resolvedTheme={props.resolvedTheme}
103104 isActive={props.activeItemId === item.id}
104105 onHighlight={props.onHighlightedItemChange}
......@@ -130,6 +131,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
130131
131132const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
132133 item: ComposerCommandItem;
134 triggerKind: ComposerTriggerKind | null;
133135 resolvedTheme: "light" | "dark";
134136 isActive: boolean;
135137 onHighlight: (itemId: string | null) => void;
......@@ -137,6 +139,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
137139}) {
138140 const skillSourceKind =
139141 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
141145 return (
142146 <CommandItem
......@@ -162,11 +166,20 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
162166 kind={props.item.pathKind}
163167 theme={props.resolvedTheme}
164168 />
165 ) : skillSourceKind ? (
169 ) : skillSourceKind && !slashSkill ? (
166170 <SkillSourceIcon kind={skillSourceKind} />
167171 ) : null}
168172 <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>
170183 <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">
171184 {props.item.description}
172185 </span>
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+102-2
......@@ -33,7 +33,7 @@ describe("searchSlashCommandItems", () => {
3333 description: "Create distinctive, production-grade frontend interfaces",
3434 },
3535 ] satisfies Array<
36 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
36 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" | "skill" }>
3737 >;
3838
3939 expect(searchSlashCommandItems(items, "ui").map((item) => item.id)).toEqual([
......@@ -61,11 +61,111 @@ describe("searchSlashCommandItems", () => {
6161 description: "General GitHub help",
6262 },
6363 ] satisfies Array<
64 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
64 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" | "skill" }>
6565 >;
6666
6767 expect(searchSlashCommandItems(items, "gfc").map((item) => item.id)).toEqual([
6868 "provider-slash-command:claudeAgent:gh-fix-ci",
6969 ]);
7070 });
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 });
71171});
apps/web/src/components/chat/composerSlashCommandSearch.ts+19-10
......@@ -5,11 +5,20 @@ import {
55} from "@t3tools/shared/searchRanking";
66
77import 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 {
1322 const primaryValue =
1423 item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase();
1524 const description = item.description.toLowerCase();
......@@ -43,18 +52,16 @@ function scoreSlashCommandItem(
4352}
4453
4554export function searchSlashCommandItems(
46 items: ReadonlyArray<
47 Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>
48 >,
55 items: ReadonlyArray<SlashSearchItem>,
4956 query: string,
50): Array<Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>> {
57): SlashSearchItem[] {
5158 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ });
5259 if (!normalizedQuery) {
5360 return [...items];
5461 }
5562
5663 const ranked: Array<{
57 item: Extract<ComposerCommandItem, { type: "slash-command" | "provider-slash-command" }>;
64 item: SlashSearchItem;
5865 score: number;
5966 tieBreaker: string;
6067 }> = [];
......@@ -73,7 +80,9 @@ export function searchSlashCommandItems(
7380 tieBreaker:
7481 item.type === "slash-command"
7582 ? `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}`,
7786 },
7887 Number.POSITIVE_INFINITY,
7988 );
apps/web/src/providerSkillSearch.test.ts+13
......@@ -56,4 +56,17 @@ describe("searchProviderSkills", () => {
5656
5757 expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]);
5858 });
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 });
5972});
apps/web/src/providerSkillSearch.ts+1-1
......@@ -6,7 +6,7 @@ import {
66 scoreQueryMatch,
77} from "@t3tools/shared/searchRanking";
88
9function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {
9export function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {
1010 const normalizedName = skill.name.toLowerCase();
1111 const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase();
1212 const normalizedShortDescription = skill.shortDescription?.toLowerCase() ?? "";