| author | |
| committer | |
| log | 9da0fab08e0ee444affdfb4916e294c579158bb6 |
| tree | 759221c5b3c41836e939db57c59ef49689b9073c |
| parent | 55c9093344a5fdbc390d713675429599fff27dbd |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 219 insertions(+), 71 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts+1| ... | ... | @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = { |
| 36 | 36 | fontSmoothing: true, |
| 37 | 37 | glassOpacity: 80, |
| 38 | 38 | planModeEnabled: false, |
| 39 | showSkillsInSlashMenu: false, | |
| 39 | 40 | providerModelPreferences: {}, |
| 40 | 41 | sidebarAutoSettleAfterDays: 3, |
| 41 | 42 | sidebarAutoSettleOnMerge: true, |
apps/web/src/components/chat/ChatComposer.tsx+31-23| ... | ... | @@ -252,7 +252,11 @@ import type { SessionPhase, Thread } from "../../types"; |
| 252 | 252 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| 253 | 253 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; |
| 254 | 254 | import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; |
| 255 | import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; | |
| 255 | import { | |
| 256 | formatProviderSkillDisplayName, | |
| 257 | getProviderSlashCommandsForSlashMenu, | |
| 258 | getProviderSkillsForSlashMenu, | |
| 259 | } from "@t3tools/client-runtime/providerSkills"; | |
| 256 | 260 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 257 | 261 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 258 | 262 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| ... | ... | @@ -1107,30 +1111,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1107 | 1111 | ] as const) |
| 1108 | 1112 | : []), |
| 1109 | 1113 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| 1110 | const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( | |
| 1111 | (command) => ({ | |
| 1112 | id: `provider-slash-command:${selectedProvider}:${command.name}`, | |
| 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 | }), | |
| 1114 | const slashMenuSkills = getProviderSkillsForSlashMenu( | |
| 1115 | selectedProviderStatus?.skills ?? [], | |
| 1116 | settings.showSkillsInSlashMenu, | |
| 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 | 1129 | const query = composerTrigger.query.trim().toLowerCase(); |
| 1121 | const skillItems = (selectedProviderStatus?.skills ?? []) | |
| 1122 | .filter((skill) => skill.enabled) | |
| 1123 | .map((skill) => ({ | |
| 1124 | id: `skill:${selectedProvider}:${skill.name}`, | |
| 1125 | type: "skill" as const, | |
| 1126 | provider: selectedProvider, | |
| 1127 | skill, | |
| 1128 | label: `skill:${skill.name}`, | |
| 1129 | description: | |
| 1130 | skill.shortDescription ?? | |
| 1131 | skill.description ?? | |
| 1132 | (skill.scope ? `${skill.scope} skill` : ""), | |
| 1133 | })); | |
| 1130 | const skillItems = slashMenuSkills.map((skill) => ({ | |
| 1131 | id: `skill:${selectedProvider}:${skill.name}`, | |
| 1132 | type: "skill" as const, | |
| 1133 | provider: selectedProvider, | |
| 1134 | skill, | |
| 1135 | label: `/skill:${skill.name}`, | |
| 1136 | description: | |
| 1137 | skill.shortDescription ?? | |
| 1138 | skill.description ?? | |
| 1139 | (skill.scope ? `${skill.scope} skill` : ""), | |
| 1140 | })); | |
| 1134 | 1141 | const slashCommandItems = [ |
| 1135 | 1142 | ...builtInSlashCommandItems, |
| 1136 | 1143 | ...providerSlashCommandItems, |
| ... | ... | @@ -1159,6 +1166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1159 | 1166 | planModeUiEnabled, |
| 1160 | 1167 | selectedProvider, |
| 1161 | 1168 | selectedProviderStatus, |
| 1169 | settings.showSkillsInSlashMenu, | |
| 1162 | 1170 | workspaceEntries.entries, |
| 1163 | 1171 | ]); |
| 1164 | 1172 |
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+29-14| ... | ... | @@ -51,10 +51,12 @@ describe("ComposerCommandMenu", () => { |
| 51 | 51 | expect(markup).not.toContain("<svg"); |
| 52 | 52 | expect(markup).toContain("font-sans text-xs font-medium"); |
| 53 | 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 | }); |
| 56 | 58 | |
| 57 | it("renders a skill source icon with an accessible source label", () => { | |
| 59 | it("renders the skill source icon inside its badge", () => { | |
| 58 | 60 | const markup = renderToStaticMarkup( |
| 59 | 61 | <ComposerCommandMenu |
| 60 | 62 | items={[ |
| ... | ... | @@ -82,40 +84,53 @@ describe("ComposerCommandMenu", () => { |
| 82 | 84 | ); |
| 83 | 85 | |
| 84 | 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 | 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 | }); |
| 89 | 100 | |
| 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 | 102 | const markup = renderToStaticMarkup( |
| 92 | 103 | <ComposerCommandMenu |
| 93 | 104 | items={[ |
| 94 | 105 | { |
| 95 | id: "skill:codex:browser", | |
| 106 | id: "skill:codex:ask-matt", | |
| 96 | 107 | type: "skill", |
| 97 | 108 | provider: ProviderDriverKind.make("codex"), |
| 98 | 109 | skill: { |
| 99 | name: "browser", | |
| 100 | path: "/skills/browser/SKILL.md", | |
| 110 | name: "ask-matt", | |
| 111 | displayName: "Ask Matt", | |
| 112 | path: "/skills/ask-matt/SKILL.md", | |
| 113 | scope: "repo", | |
| 101 | 114 | enabled: true, |
| 102 | 115 | }, |
| 103 | label: "skill:browser", | |
| 104 | description: "Open and control the in-app browser", | |
| 116 | label: "/skill:ask-matt", | |
| 117 | description: "Find the right skill or workflow", | |
| 105 | 118 | }, |
| 106 | 119 | ]} |
| 107 | 120 | resolvedTheme="dark" |
| 108 | 121 | isLoading={false} |
| 109 | 122 | triggerKind="slash-command" |
| 110 | activeItemId="skill:codex:browser" | |
| 123 | activeItemId="skill:codex:ask-matt" | |
| 111 | 124 | onHighlightedItemChange={() => {}} |
| 112 | 125 | onSelect={() => {}} |
| 113 | 126 | />, |
| 114 | 127 | ); |
| 115 | 128 | |
| 116 | expect(markup).toContain('<span class="text-secondary-label">skill:</span>browser'); | |
| 117 | expect(markup).toContain("Open and control the in-app browser"); | |
| 129 | expect(markup).toContain('<span class="text-secondary-label">/skill:</span>Ask Matt'); | |
| 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 | 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 | 1 | import { |
| 2 | formatProviderSkillDisplayName, | |
| 2 | 3 | resolveProviderSkillSourceKind, |
| 3 | 4 | type ProviderSkillSourceKind, |
| 4 | 5 | } from "@t3tools/client-runtime/providerSkills"; |
| ... | ... | @@ -10,7 +11,6 @@ import { |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | import { |
| 12 | 13 | BlocksIcon, |
| 13 | FolderGit2Icon, | |
| 14 | 14 | FolderIcon, |
| 15 | 15 | PackageIcon, |
| 16 | 16 | SettingsIcon, |
| ... | ... | @@ -21,6 +21,7 @@ import { memo, useLayoutEffect, useRef } from "react"; |
| 21 | 21 | |
| 22 | 22 | import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; |
| 23 | 23 | import { cn } from "~/lib/utils"; |
| 24 | import { Badge } from "../ui/badge"; | |
| 24 | 25 | import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; |
| 25 | 26 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 26 | 27 | |
| ... | ... | @@ -139,7 +140,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 139 | 140 | }) { |
| 140 | 141 | const skillSourceKind = |
| 141 | 142 | props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null; |
| 142 | const slashSkill = | |
| 143 | const isSlashSkill = | |
| 143 | 144 | props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null; |
| 144 | 145 | |
| 145 | 146 | return ( |
| ... | ... | @@ -166,23 +167,27 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 166 | 167 | kind={props.item.pathKind} |
| 167 | 168 | theme={props.resolvedTheme} |
| 168 | 169 | /> |
| 169 | ) : skillSourceKind && !slashSkill ? ( | |
| 170 | <SkillSourceIcon kind={skillSourceKind} /> | |
| 171 | 170 | ) : null} |
| 172 | <span className="flex min-w-0 flex-1 items-baseline gap-3"> | |
| 173 | <span className="shrink-0 font-sans text-xs font-medium"> | |
| 174 | {slashSkill ? ( | |
| 171 | <span className="flex min-w-0 flex-1 items-center gap-2"> | |
| 172 | <span className="min-w-0 max-w-[45%] shrink-0 truncate font-sans text-xs font-medium"> | |
| 173 | {isSlashSkill ? ( | |
| 175 | 174 | <> |
| 176 | <span className="text-secondary-label">skill:</span> | |
| 177 | {slashSkill.name} | |
| 175 | <span className="text-secondary-label">/skill:</span> | |
| 176 | {formatProviderSkillDisplayName(isSlashSkill)} | |
| 178 | 177 | </> |
| 179 | 178 | ) : ( |
| 180 | 179 | props.item.label |
| 181 | 180 | )} |
| 182 | 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 | 183 | {props.item.description} |
| 185 | 184 | </span> |
| 185 | {skillSourceKind ? ( | |
| 186 | <SkillSourceBadge | |
| 187 | kind={skillSourceKind} | |
| 188 | showSkillSuffix={props.triggerKind === "skill"} | |
| 189 | /> | |
| 190 | ) : null} | |
| 186 | 191 | </span> |
| 187 | 192 | </CommandItem> |
| 188 | 193 | ); |
| ... | ... | @@ -190,7 +195,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 190 | 195 | |
| 191 | 196 | const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = { |
| 192 | 197 | app: BlocksIcon, |
| 193 | repo: FolderGit2Icon, | |
| 198 | repo: FolderIcon, | |
| 194 | 199 | project: FolderIcon, |
| 195 | 200 | personal: UserRoundIcon, |
| 196 | 201 | system: SettingsIcon, |
| ... | ... | @@ -203,15 +208,16 @@ const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = { |
| 203 | 208 | project: "Project", |
| 204 | 209 | personal: "Personal", |
| 205 | 210 | system: "System", |
| 206 | other: "Other", | |
| 211 | other: "Provider", | |
| 207 | 212 | }; |
| 208 | 213 | |
| 209 | function SkillSourceIcon(props: { kind: ProviderSkillSourceKind }) { | |
| 214 | function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) { | |
| 210 | 215 | const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind]; |
| 211 | 216 | return ( |
| 212 | <> | |
| 213 | <Icon aria-hidden="true" className="size-4 shrink-0 text-icon-muted" /> | |
| 214 | <span className="sr-only">{SKILL_SOURCE_LABEL_BY_KIND[props.kind]} skill</span> | |
| 215 | </> | |
| 217 | <Badge className="ms-auto" variant="secondary"> | |
| 218 | <Icon aria-hidden="true" className="text-current" /> | |
| 219 | {SKILL_SOURCE_LABEL_BY_KIND[props.kind]} | |
| 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 | 81 | enabled: true, |
| 82 | 82 | shortDescription: "Open and control the in-app browser", |
| 83 | 83 | }, |
| 84 | label: "skill:browser", | |
| 84 | label: "/skill:browser", | |
| 85 | 85 | description: "Open and control the in-app browser", |
| 86 | 86 | }, |
| 87 | 87 | ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>; |
| ... | ... | @@ -97,23 +97,26 @@ describe("searchSlashCommandItems", () => { |
| 97 | 97 | it("matches skills by display name", () => { |
| 98 | 98 | const items = [ |
| 99 | 99 | { |
| 100 | id: "skill:claudeAgent:browser", | |
| 100 | id: "skill:claudeAgent:ask-matt", | |
| 101 | 101 | type: "skill", |
| 102 | 102 | provider: claudeDriver, |
| 103 | 103 | skill: { |
| 104 | name: "browser", | |
| 105 | displayName: "Web Navigator", | |
| 106 | path: "/skills/browser/SKILL.md", | |
| 104 | name: "ask-matt", | |
| 105 | displayName: "Ask Matt", | |
| 106 | path: "/skills/ask-matt/SKILL.md", | |
| 107 | 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", | |
| 111 | description: "Open and control the in-app browser", | |
| 110 | label: "/skill:ask-matt", | |
| 111 | description: "Find the right skill or workflow", | |
| 112 | 112 | }, |
| 113 | 113 | ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>; |
| 114 | 114 | |
| 115 | expect(searchSlashCommandItems(items, "navigator").map((item) => item.id)).toEqual([ | |
| 116 | "skill:claudeAgent:browser", | |
| 115 | expect(searchSlashCommandItems(items, "ask matt").map((item) => item.id)).toEqual([ | |
| 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 | }); |
| 119 | 122 | |
| ... | ... | @@ -128,7 +131,7 @@ describe("searchSlashCommandItems", () => { |
| 128 | 131 | path: "/skills/browser/SKILL.md", |
| 129 | 132 | enabled: true, |
| 130 | 133 | }, |
| 131 | label: "skill:browser", | |
| 134 | label: "/skill:browser", | |
| 132 | 135 | description: "Open and control the in-app browser", |
| 133 | 136 | }, |
| 134 | 137 | ] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>; |
| ... | ... | @@ -136,7 +139,9 @@ describe("searchSlashCommandItems", () => { |
| 136 | 139 | expect(searchSlashCommandItems(items, "/skill:brow").map((item) => item.id)).toEqual([ |
| 137 | 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 | 145 | expect(searchSlashCommandItems(items, "/ill")).toEqual([]); |
| 141 | 146 | }); |
| 142 | 147 | |
| ... | ... | @@ -158,7 +163,7 @@ describe("searchSlashCommandItems", () => { |
| 158 | 163 | path: "/skills/unslop/SKILL.md", |
| 159 | 164 | enabled: true, |
| 160 | 165 | }, |
| 161 | label: "skill:unslop", | |
| 166 | label: "/skill:unslop", | |
| 162 | 167 | description: "Cut AI tells from writing", |
| 163 | 168 | }, |
| 164 | 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 | 14 | |
| 15 | 15 | function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { |
| 16 | 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; | |
| 17 | if (query === "skill") { | |
| 18 | return 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 | } |
| 21 | 27 | |
| 22 | 28 | const primaryValue = |
apps/web/src/components/settings/SettingsPanels.tsx+31| ... | ... | @@ -507,6 +507,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 507 | 507 | ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace |
| 508 | 508 | ? ["Diff whitespace changes"] |
| 509 | 509 | : []), |
| 510 | ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu | |
| 511 | ? ["Show skills in slash menu"] | |
| 512 | : []), | |
| 510 | 513 | ...(settings.enableLegacyTokenStreaming !== |
| 511 | 514 | DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming |
| 512 | 515 | ? ["Stream token by token"] |
| ... | ... | @@ -573,6 +576,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 573 | 576 | settings.sidebarAutoSettleOnMerge, |
| 574 | 577 | settings.sidebarProjectGroupingMode, |
| 575 | 578 | settings.sidebarThreadPreviewCount, |
| 579 | settings.showSkillsInSlashMenu, | |
| 576 | 580 | settings.timestampFormat, |
| 577 | 581 | settings.wordWrap, |
| 578 | 582 | followSystem, |
| ... | ... | @@ -648,6 +652,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 648 | 652 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 649 | 653 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, |
| 650 | 654 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, |
| 655 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, | |
| 651 | 656 | environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, |
| 652 | 657 | glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, |
| 653 | 658 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| ... | ... | @@ -2076,6 +2081,32 @@ export function GeneralSettingsPanel() { |
| 2076 | 2081 | } |
| 2077 | 2082 | /> |
| 2078 | 2083 | |
| 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 | 2110 | <SettingsRow |
| 2080 | 2111 | {...searchableSetting("provider-update-checks")} |
| 2081 | 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 | 131 | title: "Hide whitespace changes", |
| 132 | 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 | 140 | id: "provider-update-checks", |
| 136 | 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 | 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into |
| 5 | 5 | multiple messages, then send again in the same thread. |
| 6 | 6 | |
| 7 | ## Commands and skills | |
| 8 | ||
| 9 | Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, | |
| 10 | such as System, Personal, Project, or App. | |
| 11 | ||
| 12 | By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in | |
| 13 | slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the | |
| 14 | same `$name` skill token to your message. The original skill name remains searchable. If the provider | |
| 15 | also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps | |
| 16 | the `/skill:Skill Name` label. | |
| 17 | ||
| 7 | 18 | On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to |
| 8 | 19 | start it in the background. T3 Code opens another new thread and shows an **Open** action for the |
| 9 | 20 | thread 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 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | formatProviderSkillDisplayName, |
| 5 | getProviderSlashCommandsForSlashMenu, | |
| 6 | getProviderSkillsForSlashMenu, | |
| 5 | 7 | resolveProviderSkillSourceKind, |
| 6 | 8 | } from "./providerSkills.ts"; |
| 7 | 9 | |
| ... | ... | @@ -24,6 +26,47 @@ describe("formatProviderSkillDisplayName", () => { |
| 24 | 26 | }); |
| 25 | 27 | }); |
| 26 | 28 | |
| 29 | describe("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 | ||
| 42 | describe("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 | ||
| 27 | 70 | describe("resolveProviderSkillSourceKind", () => { |
| 28 | 71 | it("marks plugin-backed skills as app installs", () => { |
| 29 | 72 | expect( |
packages/client-runtime/src/providerSkills.ts+16-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | |
| 1 | import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; |
| 4 | 4 | |
| ... | ... | @@ -25,6 +25,21 @@ export function formatProviderSkillDisplayName( |
| 25 | 25 | return titleCaseWords(skill.name); |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | export function getProviderSkillsForSlashMenu( | |
| 29 | skills: ReadonlyArray<ServerProviderSkill>, | |
| 30 | showSkillsInSlashMenu: boolean, | |
| 31 | ): ServerProviderSkill[] { | |
| 32 | return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : []; | |
| 33 | } | |
| 34 | ||
| 35 | export 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 | ||
| 28 | 43 | export function resolveProviderSkillSourceKind( |
| 29 | 44 | skill: Pick<ServerProviderSkill, "path" | "scope">, |
| 30 | 45 | ): ProviderSkillSourceKind { |
packages/contracts/src/settings.ts+2| ... | ... | @@ -225,6 +225,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 225 | 225 | // default UI; this beta flag restores it (plus the /plan and /default slash |
| 226 | 226 | // commands) for users who still rely on the old workflow. |
| 227 | 227 | planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 228 | showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 228 | 229 | // Legacy sidebar (the original per-project tree). Deliberately a fresh key |
| 229 | 230 | // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the |
| 230 | 231 | // old keys, so everyone, including prior beta opt-outs, resets to the new |
| ... | ... | @@ -913,6 +914,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 913 | 914 | ), |
| 914 | 915 | ), |
| 915 | 916 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 917 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), | |
| 916 | 918 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
| 917 | 919 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 918 | 920 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), |