authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-08-23 21:19:13+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-23 14:19:13-04:00
log9da0fab08e0ee444affdfb4916e294c579158bb6
tree759221c5b3c41836e939db57c59ef49689b9073c
parent55c9093344a5fdbc390d713675429599fff27dbd
signature Signed by PGP key B5690EEEBB952194

feat(web): redesign skills in `$` menu and in `/` menu (#8009)


12 files changed, 219 insertions(+), 71 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -36,6 +36,7 @@ const clientSettings: ClientSettings = {...@@ -36,6 +36,7 @@ const clientSettings: ClientSettings = {
36 fontSmoothing: true,36 fontSmoothing: true,
37 glassOpacity: 80,37 glassOpacity: 80,
38 planModeEnabled: false,38 planModeEnabled: false,
39 showSkillsInSlashMenu: false,
39 providerModelPreferences: {},40 providerModelPreferences: {},
40 sidebarAutoSettleAfterDays: 3,41 sidebarAutoSettleAfterDays: 3,
41 sidebarAutoSettleOnMerge: true,42 sidebarAutoSettleOnMerge: true,
apps/web/src/components/chat/ChatComposer.tsx+31-23
...@@ -252,7 +252,11 @@ import type { SessionPhase, Thread } from "../../types";...@@ -252,7 +252,11 @@ import type { SessionPhase, Thread } from "../../types";
252import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";252import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
253import type { PendingApproval, PendingUserInput } from "../../session-logic";253import type { PendingApproval, PendingUserInput } from "../../session-logic";
254import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";254import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
255import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";255import {
256 formatProviderSkillDisplayName,
257 getProviderSlashCommandsForSlashMenu,
258 getProviderSkillsForSlashMenu,
259} from "@t3tools/client-runtime/providerSkills";
256import { searchProviderSkills } from "../../providerSkillSearch";260import { searchProviderSkills } from "../../providerSkillSearch";
257import { useMediaQuery } from "../../hooks/useMediaQuery";261import { useMediaQuery } from "../../hooks/useMediaQuery";
258import type { ReviewCommentContext } from "../../reviewCommentContext";262import type { ReviewCommentContext } from "../../reviewCommentContext";
...@@ -1107,30 +1111,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1107,30 +1111,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1107 ] as const)1111 ] as const)
1108 : []),1112 : []),
1109 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;1113 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
1110 const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map(1114 const slashMenuSkills = getProviderSkillsForSlashMenu(
1111 (command) => ({1115 selectedProviderStatus?.skills ?? [],
1112 id: `provider-slash-command:${selectedProvider}:${command.name}`,1116 settings.showSkillsInSlashMenu,
1113 type: "provider-slash-command" as const,
1114 provider: selectedProvider,
1115 command,
1116 label: `/${command.name}`,
1117 description: command.description ?? command.input?.hint ?? "Run provider command",
1118 }),
1119 );1117 );
1118 const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu(
1119 selectedProviderStatus?.slashCommands ?? [],
1120 slashMenuSkills,
1121 ).map((command) => ({
1122 id: `provider-slash-command:${selectedProvider}:${command.name}`,
1123 type: "provider-slash-command" as const,
1124 provider: selectedProvider,
1125 command,
1126 label: `/${command.name}`,
1127 description: command.description ?? command.input?.hint ?? "Run provider command",
1128 }));
1120 const query = composerTrigger.query.trim().toLowerCase();1129 const query = composerTrigger.query.trim().toLowerCase();
1121 const skillItems = (selectedProviderStatus?.skills ?? [])1130 const skillItems = slashMenuSkills.map((skill) => ({
1122 .filter((skill) => skill.enabled)1131 id: `skill:${selectedProvider}:${skill.name}`,
1123 .map((skill) => ({1132 type: "skill" as const,
1124 id: `skill:${selectedProvider}:${skill.name}`,1133 provider: selectedProvider,
1125 type: "skill" as const,1134 skill,
1126 provider: selectedProvider,1135 label: `/skill:${skill.name}`,
1127 skill,1136 description:
1128 label: `skill:${skill.name}`,1137 skill.shortDescription ??
1129 description:1138 skill.description ??
1130 skill.shortDescription ??1139 (skill.scope ? `${skill.scope} skill` : ""),
1131 skill.description ??1140 }));
1132 (skill.scope ? `${skill.scope} skill` : ""),
1133 }));
1134 const slashCommandItems = [1141 const slashCommandItems = [
1135 ...builtInSlashCommandItems,1142 ...builtInSlashCommandItems,
1136 ...providerSlashCommandItems,1143 ...providerSlashCommandItems,
...@@ -1159,6 +1166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1159,6 +1166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1159 planModeUiEnabled,1166 planModeUiEnabled,
1160 selectedProvider,1167 selectedProvider,
1161 selectedProviderStatus,1168 selectedProviderStatus,
1169 settings.showSkillsInSlashMenu,
1162 workspaceEntries.entries,1170 workspaceEntries.entries,
1163 ]);1171 ]);
11641172
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+29-14
...@@ -51,10 +51,12 @@ describe("ComposerCommandMenu", () => {...@@ -51,10 +51,12 @@ describe("ComposerCommandMenu", () => {
51 expect(markup).not.toContain("<svg");51 expect(markup).not.toContain("<svg");
52 expect(markup).toContain("font-sans text-xs font-medium");52 expect(markup).toContain("font-sans text-xs font-medium");
53 expect(markup).not.toContain("font-mono");53 expect(markup).not.toContain("font-mono");
54 expect(markup).toContain("text-right");54 expect(markup).not.toContain("grid-cols-");
55 expect(markup).toContain("max-w-[45%]");
56 expect(markup).toContain("text-left");
55 });57 });
5658
57 it("renders a skill source icon with an accessible source label", () => {59 it("renders the skill source icon inside its badge", () => {
58 const markup = renderToStaticMarkup(60 const markup = renderToStaticMarkup(
59 <ComposerCommandMenu61 <ComposerCommandMenu
60 items={[62 items={[
...@@ -82,40 +84,53 @@ describe("ComposerCommandMenu", () => {...@@ -82,40 +84,53 @@ describe("ComposerCommandMenu", () => {
82 );84 );
8385
84 expect(markup).toContain("Browser");86 expect(markup).toContain("Browser");
85 expect(markup).toContain('<span class="sr-only">App skill</span>');87 expect(markup).toContain('data-slot="badge"');
88 expect(markup).toContain(">App Skill</span>");
89 expect(markup).toContain("Open and control the in-app browser");
90 expect(markup).toContain("max-w-[48ch]");
91 expect(markup).toContain("text-secondary-label text-xs");
92 expect(markup).toContain("ms-auto");
93 expect(markup).toContain("text-current");
94 expect(markup.indexOf("Open and control the in-app browser")).toBeLessThan(
95 markup.indexOf(">App Skill</span>"),
96 );
86 expect(markup).toContain("<svg");97 expect(markup).toContain("<svg");
87 expect(markup).toContain("text-icon-muted");98 expect(markup.indexOf('data-slot="badge"')).toBeLessThan(markup.indexOf("<svg"));
88 });99 });
89100
90 it("renders slash skill results with only the skill prefix dimmed", () => {101 it("keeps slash skills aligned with the source icon inside the badge", () => {
91 const markup = renderToStaticMarkup(102 const markup = renderToStaticMarkup(
92 <ComposerCommandMenu103 <ComposerCommandMenu
93 items={[104 items={[
94 {105 {
95 id: "skill:codex:browser",106 id: "skill:codex:ask-matt",
96 type: "skill",107 type: "skill",
97 provider: ProviderDriverKind.make("codex"),108 provider: ProviderDriverKind.make("codex"),
98 skill: {109 skill: {
99 name: "browser",110 name: "ask-matt",
100 path: "/skills/browser/SKILL.md",111 displayName: "Ask Matt",
112 path: "/skills/ask-matt/SKILL.md",
113 scope: "repo",
101 enabled: true,114 enabled: true,
102 },115 },
103 label: "skill:browser",116 label: "/skill:ask-matt",
104 description: "Open and control the in-app browser",117 description: "Find the right skill or workflow",
105 },118 },
106 ]}119 ]}
107 resolvedTheme="dark"120 resolvedTheme="dark"
108 isLoading={false}121 isLoading={false}
109 triggerKind="slash-command"122 triggerKind="slash-command"
110 activeItemId="skill:codex:browser"123 activeItemId="skill:codex:ask-matt"
111 onHighlightedItemChange={() => {}}124 onHighlightedItemChange={() => {}}
112 onSelect={() => {}}125 onSelect={() => {}}
113 />,126 />,
114 );127 );
115128
116 expect(markup).toContain('<span class="text-secondary-label">skill:</span>browser');129 expect(markup).toContain('<span class="text-secondary-label">/skill:</span>Ask Matt');
117 expect(markup).toContain("Open and control the in-app browser");130 expect(markup).toContain('data-slot="badge"');
131 expect(markup).toContain("lucide-folder");
132 expect(markup).toContain(">Repo</span>");
133 expect(markup).toContain("Find the right skill or workflow");
118 expect(markup).not.toContain("font-medium text-secondary-label");134 expect(markup).not.toContain("font-medium text-secondary-label");
119 expect(markup).not.toContain("<svg");
120 });135 });
121});136});
apps/web/src/components/chat/ComposerCommandMenu.tsx+23-17
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 formatProviderSkillDisplayName,
2 resolveProviderSkillSourceKind,3 resolveProviderSkillSourceKind,
3 type ProviderSkillSourceKind,4 type ProviderSkillSourceKind,
4} from "@t3tools/client-runtime/providerSkills";5} from "@t3tools/client-runtime/providerSkills";
...@@ -10,7 +11,6 @@ import {...@@ -10,7 +11,6 @@ import {
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import {12import {
12 BlocksIcon,13 BlocksIcon,
13 FolderGit2Icon,
14 FolderIcon,14 FolderIcon,
15 PackageIcon,15 PackageIcon,
16 SettingsIcon,16 SettingsIcon,
...@@ -21,6 +21,7 @@ import { memo, useLayoutEffect, useRef } from "react";...@@ -21,6 +21,7 @@ import { memo, useLayoutEffect, useRef } from "react";
2121
22import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";22import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
23import { cn } from "~/lib/utils";23import { cn } from "~/lib/utils";
24import { Badge } from "../ui/badge";
24import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";25import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
25import { PierreEntryIcon } from "./PierreEntryIcon";26import { PierreEntryIcon } from "./PierreEntryIcon";
2627
...@@ -139,7 +140,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -139,7 +140,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
139}) {140}) {
140 const skillSourceKind =141 const skillSourceKind =
141 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;142 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
142 const slashSkill =143 const isSlashSkill =
143 props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;144 props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;
144145
145 return (146 return (
...@@ -166,23 +167,27 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -166,23 +167,27 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
166 kind={props.item.pathKind}167 kind={props.item.pathKind}
167 theme={props.resolvedTheme}168 theme={props.resolvedTheme}
168 />169 />
169 ) : skillSourceKind && !slashSkill ? (
170 <SkillSourceIcon kind={skillSourceKind} />
171 ) : null}170 ) : null}
172 <span className="flex min-w-0 flex-1 items-baseline gap-3">171 <span className="flex min-w-0 flex-1 items-center gap-2">
173 <span className="shrink-0 font-sans text-xs font-medium">172 <span className="min-w-0 max-w-[45%] shrink-0 truncate font-sans text-xs font-medium">
174 {slashSkill ? (173 {isSlashSkill ? (
175 <>174 <>
176 <span className="text-secondary-label">skill:</span>175 <span className="text-secondary-label">/skill:</span>
177 {slashSkill.name}176 {formatProviderSkillDisplayName(isSlashSkill)}
178 </>177 </>
179 ) : (178 ) : (
180 props.item.label179 props.item.label
181 )}180 )}
182 </span>181 </span>
183 <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">182 <span className="min-w-0 max-w-[48ch] flex-1 truncate text-left text-secondary-label text-xs">
184 {props.item.description}183 {props.item.description}
185 </span>184 </span>
185 {skillSourceKind ? (
186 <SkillSourceBadge
187 kind={skillSourceKind}
188 showSkillSuffix={props.triggerKind === "skill"}
189 />
190 ) : null}
186 </span>191 </span>
187 </CommandItem>192 </CommandItem>
188 );193 );
...@@ -190,7 +195,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -190,7 +195,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
190195
191const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {196const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
192 app: BlocksIcon,197 app: BlocksIcon,
193 repo: FolderGit2Icon,198 repo: FolderIcon,
194 project: FolderIcon,199 project: FolderIcon,
195 personal: UserRoundIcon,200 personal: UserRoundIcon,
196 system: SettingsIcon,201 system: SettingsIcon,
...@@ -203,15 +208,16 @@ const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {...@@ -203,15 +208,16 @@ const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {
203 project: "Project",208 project: "Project",
204 personal: "Personal",209 personal: "Personal",
205 system: "System",210 system: "System",
206 other: "Other",211 other: "Provider",
207};212};
208213
209function SkillSourceIcon(props: { kind: ProviderSkillSourceKind }) {214function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) {
210 const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];215 const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];
211 return (216 return (
212 <>217 <Badge className="ms-auto" variant="secondary">
213 <Icon aria-hidden="true" className="size-4 shrink-0 text-icon-muted" />218 <Icon aria-hidden="true" className="text-current" />
214 <span className="sr-only">{SKILL_SOURCE_LABEL_BY_KIND[props.kind]} skill</span>219 {SKILL_SOURCE_LABEL_BY_KIND[props.kind]}
215 </>220 {props.showSkillSuffix ? " Skill" : null}
221 </Badge>
216 );222 );
217}223}
apps/web/src/components/chat/composerSlashCommandSearch.test.ts+18-13
...@@ -81,7 +81,7 @@ describe("searchSlashCommandItems", () => {...@@ -81,7 +81,7 @@ describe("searchSlashCommandItems", () => {
81 enabled: true,81 enabled: true,
82 shortDescription: "Open and control the in-app browser",82 shortDescription: "Open and control the in-app browser",
83 },83 },
84 label: "skill:browser",84 label: "/skill:browser",
85 description: "Open and control the in-app browser",85 description: "Open and control the in-app browser",
86 },86 },
87 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;87 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;
...@@ -97,23 +97,26 @@ describe("searchSlashCommandItems", () => {...@@ -97,23 +97,26 @@ describe("searchSlashCommandItems", () => {
97 it("matches skills by display name", () => {97 it("matches skills by display name", () => {
98 const items = [98 const items = [
99 {99 {
100 id: "skill:claudeAgent:browser",100 id: "skill:claudeAgent:ask-matt",
101 type: "skill",101 type: "skill",
102 provider: claudeDriver,102 provider: claudeDriver,
103 skill: {103 skill: {
104 name: "browser",104 name: "ask-matt",
105 displayName: "Web Navigator",105 displayName: "Ask Matt",
106 path: "/skills/browser/SKILL.md",106 path: "/skills/ask-matt/SKILL.md",
107 enabled: true,107 enabled: true,
108 shortDescription: "Open and control the in-app browser",108 shortDescription: "Find the right skill or workflow",
109 },109 },
110 label: "skill:browser",110 label: "/skill:ask-matt",
111 description: "Open and control the in-app browser",111 description: "Find the right skill or workflow",
112 },112 },
113 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;113 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;
114114
115 expect(searchSlashCommandItems(items, "navigator").map((item) => item.id)).toEqual([115 expect(searchSlashCommandItems(items, "ask matt").map((item) => item.id)).toEqual([
116 "skill:claudeAgent:browser",116 "skill:claudeAgent:ask-matt",
117 ]);
118 expect(searchSlashCommandItems(items, "/skill:ask-matt").map((item) => item.id)).toEqual([
119 "skill:claudeAgent:ask-matt",
117 ]);120 ]);
118 });121 });
119122
...@@ -128,7 +131,7 @@ describe("searchSlashCommandItems", () => {...@@ -128,7 +131,7 @@ describe("searchSlashCommandItems", () => {
128 path: "/skills/browser/SKILL.md",131 path: "/skills/browser/SKILL.md",
129 enabled: true,132 enabled: true,
130 },133 },
131 label: "skill:browser",134 label: "/skill:browser",
132 description: "Open and control the in-app browser",135 description: "Open and control the in-app browser",
133 },136 },
134 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;137 ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;
...@@ -136,7 +139,9 @@ describe("searchSlashCommandItems", () => {...@@ -136,7 +139,9 @@ describe("searchSlashCommandItems", () => {
136 expect(searchSlashCommandItems(items, "/skill:brow").map((item) => item.id)).toEqual([139 expect(searchSlashCommandItems(items, "/skill:brow").map((item) => item.id)).toEqual([
137 "skill:claudeAgent:browser",140 "skill:claudeAgent:browser",
138 ]);141 ]);
139 expect(searchSlashCommandItems(items, "/sk")).toEqual([]);142 expect(searchSlashCommandItems(items, "/sk").map((item) => item.id)).toEqual([
143 "skill:claudeAgent:browser",
144 ]);
140 expect(searchSlashCommandItems(items, "/ill")).toEqual([]);145 expect(searchSlashCommandItems(items, "/ill")).toEqual([]);
141 });146 });
142147
...@@ -158,7 +163,7 @@ describe("searchSlashCommandItems", () => {...@@ -158,7 +163,7 @@ describe("searchSlashCommandItems", () => {
158 path: "/skills/unslop/SKILL.md",163 path: "/skills/unslop/SKILL.md",
159 enabled: true,164 enabled: true,
160 },165 },
161 label: "skill:unslop",166 label: "/skill:unslop",
162 description: "Cut AI tells from writing",167 description: "Cut AI tells from writing",
163 },168 },
164 ] satisfies Array<Extract<ComposerCommandItem, { type: "slash-command" | "skill" }>>;169 ] satisfies Array<Extract<ComposerCommandItem, { type: "slash-command" | "skill" }>>;
apps/web/src/components/chat/composerSlashCommandSearch.ts+9-3
...@@ -14,9 +14,15 @@ type SlashSearchItem = Extract<...@@ -14,9 +14,15 @@ type SlashSearchItem = Extract<
1414
15function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {15function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {
16 if (item.type === "skill") {16 if (item.type === "skill") {
17 const skillQuery =17 if (query === "skill") {
18 query === "skill" ? "" : query.startsWith("skill:") ? query.slice("skill:".length) : query;18 return 0;
19 return skillQuery ? scoreProviderSkill(item.skill, skillQuery) : 0;19 }
20 const skillQuery = query.startsWith("skill:") ? query.slice("skill:".length) : query;
21 const skillScore = skillQuery ? scoreProviderSkill(item.skill, skillQuery) : 0;
22 if (skillScore !== null) {
23 return skillScore;
24 }
25 return "skill".startsWith(query) ? Number.MAX_SAFE_INTEGER : null;
20 }26 }
2127
22 const primaryValue =28 const primaryValue =
apps/web/src/components/settings/SettingsPanels.tsx+31
...@@ -507,6 +507,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -507,6 +507,9 @@ export function useSettingsRestore(onRestored?: () => void) {
507 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace507 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
508 ? ["Diff whitespace changes"]508 ? ["Diff whitespace changes"]
509 : []),509 : []),
510 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu
511 ? ["Show skills in slash menu"]
512 : []),
510 ...(settings.enableLegacyTokenStreaming !==513 ...(settings.enableLegacyTokenStreaming !==
511 DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming514 DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming
512 ? ["Stream token by token"]515 ? ["Stream token by token"]
...@@ -573,6 +576,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -573,6 +576,7 @@ export function useSettingsRestore(onRestored?: () => void) {
573 settings.sidebarAutoSettleOnMerge,576 settings.sidebarAutoSettleOnMerge,
574 settings.sidebarProjectGroupingMode,577 settings.sidebarProjectGroupingMode,
575 settings.sidebarThreadPreviewCount,578 settings.sidebarThreadPreviewCount,
579 settings.showSkillsInSlashMenu,
576 settings.timestampFormat,580 settings.timestampFormat,
577 settings.wordWrap,581 settings.wordWrap,
578 followSystem,582 followSystem,
...@@ -648,6 +652,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -648,6 +652,7 @@ export function useSettingsRestore(onRestored?: () => void) {
648 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,652 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
649 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,653 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
650 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,654 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
655 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
651 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,656 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
652 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,657 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
653 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,658 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
...@@ -2076,6 +2081,32 @@ export function GeneralSettingsPanel() {...@@ -2076,6 +2081,32 @@ export function GeneralSettingsPanel() {
2076 }2081 }
2077 />2082 />
20782083
2084 <SettingsRow
2085 {...searchableSetting("skills-in-slash-menu")}
2086 description="Also include skills in the / command menu. Skills always appear when you type $."
2087 resetAction={
2088 settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? (
2089 <SettingResetButton
2090 label="skills in slash menu"
2091 onClick={() =>
2092 updateSettings({
2093 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
2094 })
2095 }
2096 />
2097 ) : null
2098 }
2099 control={
2100 <Switch
2101 checked={settings.showSkillsInSlashMenu}
2102 onCheckedChange={(checked) =>
2103 updateSettings({ showSkillsInSlashMenu: Boolean(checked) })
2104 }
2105 aria-label="Show skills in slash menu"
2106 />
2107 }
2108 />
2109
2079 <SettingsRow2110 <SettingsRow
2080 {...searchableSetting("provider-update-checks")}2111 {...searchableSetting("provider-update-checks")}
2081 description="Check installed provider CLIs for newer available versions."2112 description="Check installed provider CLIs for newer available versions."
apps/web/src/components/settings/settingsSearch.ts+5
...@@ -131,6 +131,11 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -131,6 +131,11 @@ export const SETTINGS_SEARCH_ITEMS = [
131 title: "Hide whitespace changes",131 title: "Hide whitespace changes",
132 to: "/settings/general",132 to: "/settings/general",
133 },133 },
134 {
135 id: "skills-in-slash-menu",
136 title: "Show skills in slash menu",
137 to: "/settings/general",
138 },
134 {139 {
135 id: "provider-update-checks",140 id: "provider-update-checks",
136 title: "Provider update checks",141 title: "Provider update checks",
docs/user/composer.md+11
...@@ -4,6 +4,17 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee...@@ -4,6 +4,17 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee
4composer and shows how many characters need to be removed. Shorten the draft or split it into4composer and shows how many characters need to be removed. Shorten the draft or split it into
5multiple messages, then send again in the same thread.5multiple messages, then send again in the same thread.
66
7## Commands and skills
8
9Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source,
10such as System, Personal, Project, or App.
11
12By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in
13slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the
14same `$name` skill token to your message. The original skill name remains searchable. If the provider
15also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps
16the `/skill:Skill Name` label.
17
7On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to18On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to
8start it in the background. T3 Code opens another new thread and shows an **Open** action for the19start it in the background. T3 Code opens another new thread and shows an **Open** action for the
9thread that started. The new thread keeps the selected workspace mode and base branch. If **New20thread that started. The new thread keeps the selected workspace mode and base branch. If **New
packages/client-runtime/src/providerSkills.test.ts+43
...@@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 formatProviderSkillDisplayName,4 formatProviderSkillDisplayName,
5 getProviderSlashCommandsForSlashMenu,
6 getProviderSkillsForSlashMenu,
5 resolveProviderSkillSourceKind,7 resolveProviderSkillSourceKind,
6} from "./providerSkills.ts";8} from "./providerSkills.ts";
79
...@@ -24,6 +26,47 @@ describe("formatProviderSkillDisplayName", () => {...@@ -24,6 +26,47 @@ describe("formatProviderSkillDisplayName", () => {
24 });26 });
25});27});
2628
29describe("getProviderSkillsForSlashMenu", () => {
30 it("keeps the skill alias when the provider also exposes it as a slash command", () => {
31 const askMatt = {
32 name: "ask-matt",
33 path: "/Users/matt/.agents/skills/ask-matt/SKILL.md",
34 enabled: true,
35 };
36 expect(getProviderSkillsForSlashMenu([askMatt], true).map((skill) => skill.name)).toEqual([
37 "ask-matt",
38 ]);
39 });
40});
41
42describe("getProviderSlashCommandsForSlashMenu", () => {
43 const commands = [
44 { name: "ask-matt", description: "Ask which skill fits your situation." },
45 { name: "compact", description: "Compact the conversation." },
46 ];
47 const skills = [
48 {
49 name: "ask-matt",
50 path: "/Users/matt/.agents/skills/ask-matt/SKILL.md",
51 enabled: true,
52 },
53 ];
54
55 it("lets the skill alias win when a provider command has the same name", () => {
56 expect(
57 getProviderSlashCommandsForSlashMenu(commands, skills).map((command) => command.name),
58 ).toEqual(["compact"]);
59 });
60
61 it("keeps the provider command when the matching skill alias is hidden", () => {
62 const visibleSkills = getProviderSkillsForSlashMenu(skills, false);
63
64 expect(
65 getProviderSlashCommandsForSlashMenu(commands, visibleSkills).map((command) => command.name),
66 ).toEqual(["ask-matt", "compact"]);
67 });
68});
69
27describe("resolveProviderSkillSourceKind", () => {70describe("resolveProviderSkillSourceKind", () => {
28 it("marks plugin-backed skills as app installs", () => {71 it("marks plugin-backed skills as app installs", () => {
29 expect(72 expect(
packages/client-runtime/src/providerSkills.ts+16-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";1import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
22
3export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";3export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
44
...@@ -25,6 +25,21 @@ export function formatProviderSkillDisplayName(...@@ -25,6 +25,21 @@ export function formatProviderSkillDisplayName(
25 return titleCaseWords(skill.name);25 return titleCaseWords(skill.name);
26}26}
2727
28export function getProviderSkillsForSlashMenu(
29 skills: ReadonlyArray<ServerProviderSkill>,
30 showSkillsInSlashMenu: boolean,
31): ServerProviderSkill[] {
32 return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : [];
33}
34
35export function getProviderSlashCommandsForSlashMenu(
36 slashCommands: ReadonlyArray<ServerProviderSlashCommand>,
37 visibleSkills: ReadonlyArray<ServerProviderSkill>,
38): ServerProviderSlashCommand[] {
39 const skillNames = new Set(visibleSkills.map((skill) => skill.name.trim().toLowerCase()));
40 return slashCommands.filter((command) => !skillNames.has(command.name.trim().toLowerCase()));
41}
42
28export function resolveProviderSkillSourceKind(43export function resolveProviderSkillSourceKind(
29 skill: Pick<ServerProviderSkill, "path" | "scope">,44 skill: Pick<ServerProviderSkill, "path" | "scope">,
30): ProviderSkillSourceKind {45): ProviderSkillSourceKind {
packages/contracts/src/settings.ts+2
...@@ -225,6 +225,7 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -225,6 +225,7 @@ export const ClientSettingsSchema = Schema.Struct({
225 // default UI; this beta flag restores it (plus the /plan and /default slash225 // default UI; this beta flag restores it (plus the /plan and /default slash
226 // commands) for users who still rely on the old workflow.226 // commands) for users who still rely on the old workflow.
227 planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),227 planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
228 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
228 // Legacy sidebar (the original per-project tree). Deliberately a fresh key229 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
229 // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the230 // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the
230 // old keys, so everyone, including prior beta opt-outs, resets to the new231 // old keys, so everyone, including prior beta opt-outs, resets to the new
...@@ -913,6 +914,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -913,6 +914,7 @@ export const ClientSettingsPatch = Schema.Struct({
913 ),914 ),
914 ),915 ),
915 planModeEnabled: Schema.optionalKey(Schema.Boolean),916 planModeEnabled: Schema.optionalKey(Schema.Boolean),
917 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
916 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),918 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
917 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),919 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
918 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),920 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),