From 60810034ec0d00b617d5f686079616b72b222f2c Mon Sep 17 00:00:00 2001 From: Luciano Oliveira Date: Sat, 4 Apr 2026 17:14:34 -0300 Subject: [PATCH 1/2] feat: extensible slash command registry with dynamic project skill discovery Replace the hardcoded slash command system (/model, /plan, /default) with a dynamic SlashCommandRegistry that supports runtime registration. Commands are defined as SlashCommandDefinition objects with typed actions (set-interaction-mode, trigger-transition, prompt-prefix, callback). Add server-side scanning of .agents/skills/ and .agent/skills/ directories to discover project-specific skills from SKILL.md frontmatter. Skills with user-invocable: true are automatically registered as slash commands when a project is active, and unregistered when switching projects. Slash commands are now detected at any word boundary (not just line start), so typing "/plan" mid-sentence shows the command menu. --- apps/server/src/workspace/skillScanner.ts | 58 +++++++++ apps/server/src/ws.ts | 15 +++ apps/web/src/components/ChatView.tsx | 102 +++++++++------- .../components/chat/ComposerCommandMenu.tsx | 13 +- apps/web/src/composer-logic.test.ts | 51 ++++++-- apps/web/src/composer-logic.ts | 36 ++++-- apps/web/src/lib/projectReactQuery.ts | 22 +++- apps/web/src/slashCommandRegistry.test.ts | 99 +++++++++++++++ apps/web/src/slashCommandRegistry.ts | 114 ++++++++++++++++++ apps/web/src/slashCommandSkills.ts | 29 +++++ apps/web/src/wsNativeApi.ts | 1 + apps/web/src/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/project.ts | 29 +++++ packages/contracts/src/rpc.ts | 11 ++ 15 files changed, 520 insertions(+), 66 deletions(-) create mode 100644 apps/server/src/workspace/skillScanner.ts create mode 100644 apps/web/src/slashCommandRegistry.test.ts create mode 100644 apps/web/src/slashCommandRegistry.ts create mode 100644 apps/web/src/slashCommandSkills.ts diff --git a/apps/server/src/workspace/skillScanner.ts b/apps/server/src/workspace/skillScanner.ts new file mode 100644 index 0000000000..1498fb7601 --- /dev/null +++ b/apps/server/src/workspace/skillScanner.ts @@ -0,0 +1,58 @@ +import * as fsPromises from "node:fs/promises"; +import * as nodePath from "node:path"; + +import type { ProjectListSkillsResult } from "@t3tools/contracts"; + +const SKILL_DIRS = [".agents/skills", ".agent/skills"] as const; + +function parseFrontmatter(content: string): Record { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content); + if (!match?.[1]) return {}; + const result: Record = {}; + for (const line of match[1].split("\n")) { + const colonIndex = line.indexOf(":"); + if (colonIndex < 0) continue; + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + if (key) result[key] = value; + } + return result; +} + +export async function scanProjectSkills(cwd: string): Promise { + const seen = new Set(); + const skills: Array = []; + + for (const dir of SKILL_DIRS) { + const skillsRoot = nodePath.join(cwd, dir); + let entries: string[]; + try { + entries = await fsPromises.readdir(skillsRoot); + } catch { + continue; + } + + for (const entry of entries) { + const skillMdPath = nodePath.join(skillsRoot, entry, "SKILL.md"); + let content: string; + try { + content = await fsPromises.readFile(skillMdPath, "utf-8"); + } catch { + continue; + } + + const fm = parseFrontmatter(content); + const name = fm.name || entry; + if (seen.has(name)) continue; + seen.add(name); + + skills.push({ + name, + description: fm.description ?? "", + userInvocable: fm["user-invocable"] !== "false", + }); + } + } + + return { skills }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 33a0518611..b5b3940def 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -11,6 +11,7 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + ProjectListSkillsError, ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, @@ -44,6 +45,7 @@ import { ServerSettingsService } from "./serverSettings"; import { TerminalManager } from "./terminal/Services/Manager"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; +import { scanProjectSkills } from "./workspace/skillScanner"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; @@ -555,6 +557,19 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListSkills]: (input) => + observeRpcEffect( + WS_METHODS.projectsListSkills, + Effect.tryPromise({ + try: () => scanProjectSkills(input.cwd), + catch: (cause) => + new ProjectListSkillsError({ + message: `Failed to scan project skills: ${String(cause)}`, + cause: cause instanceof Error ? cause : undefined, + }), + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9d51fa5061..0035ea54ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,7 +28,10 @@ import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; -import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { + projectListSkillsQueryOptions, + projectSearchEntriesQueryOptions, +} from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -40,6 +43,8 @@ import { parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; +import { slashCommandRegistry, useSlashCommands } from "../slashCommandRegistry"; +import { registerProjectSkills } from "../slashCommandSkills"; import { deriveCompletionDividerBeforeEntryId, derivePendingApprovals, @@ -1399,6 +1404,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); + const slashCommands = useSlashCommands(); + const projectSkillsQuery = useQuery(projectListSkillsQueryOptions({ cwd: gitCwd })); + const projectSkills = projectSkillsQuery.data?.skills; + useEffect(() => registerProjectSkills(projectSkills), [projectSkills]); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( @@ -1455,36 +1464,16 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ - { - id: "slash:model", - type: "slash-command", - command: "model", - label: "/model", - description: "Switch response model for this thread", - }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal chat mode", - }, - ] satisfies ReadonlyArray>; - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) { - return [...slashCommandItems]; - } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); + const q = composerTrigger.query.trim().toLowerCase(); + const matched = q ? slashCommands.filter((cmd) => cmd.name.includes(q)) : slashCommands; + return matched.map((cmd) => ({ + id: `slash:${cmd.name}`, + type: "slash-command" as const, + command: cmd.name, + ...(cmd.icon ? { icon: cmd.icon } : {}), + label: `/${cmd.name}`, + description: cmd.description, + })); } return searchableModelOptions @@ -1503,7 +1492,7 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} ยท ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [composerTrigger, searchableModelOptions, slashCommands, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -2883,7 +2872,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { - handleInteractionModeChange(standaloneSlashCommand); + const def = slashCommandRegistry.get(standaloneSlashCommand); + if (def?.action.type === "set-interaction-mode") { + handleInteractionModeChange(def.action.mode); + } promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); @@ -3708,8 +3700,11 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } if (item.type === "slash-command") { - if (item.command === "model") { - const replacement = "/model "; + const def = slashCommandRegistry.get(item.command); + if (!def) return; + const action = def.action; + if (action.type === "trigger-transition") { + const replacement = action.replacement; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, @@ -3726,12 +3721,37 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); - if (applied) { - setComposerHighlightedItemId(null); + if (action.type === "set-interaction-mode") { + void handleInteractionModeChange(action.mode); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (action.type === "prompt-prefix") { + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + action.prefix, + { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (action.type === "callback") { + action.execute(); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; } return; } diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29..da2b98abd2 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,6 +1,6 @@ import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useRef } from "react"; -import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { type ComponentType, memo, useLayoutEffect, useRef } from "react"; +import { type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; @@ -19,7 +19,8 @@ export type ComposerCommandItem = | { id: string; type: "slash-command"; - command: ComposerSlashCommand; + command: string; + icon?: ComponentType<{ className?: string }>; label: string; description: string; } @@ -124,7 +125,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { /> ) : null} {props.item.type === "slash-command" ? ( - + props.item.icon ? ( + + ) : ( + + ) ) : null} {props.item.type === "model" ? ( diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 44f32bef9a..f4c28858f4 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -11,10 +11,13 @@ import { } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +const BUILTIN_NAMES = ["model", "plan", "default"] as const; +const STANDALONE_NAMES = ["plan", "default"] as const; + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; - const trigger = detectComposerTrigger(text, text.length); + const trigger = detectComposerTrigger(text, text.length, BUILTIN_NAMES); expect(trigger).toEqual({ kind: "path", @@ -26,7 +29,7 @@ describe("detectComposerTrigger", () => { it("detects slash command token while typing command name", () => { const text = "/mo"; - const trigger = detectComposerTrigger(text, text.length); + const trigger = detectComposerTrigger(text, text.length, BUILTIN_NAMES); expect(trigger).toEqual({ kind: "slash-command", @@ -38,7 +41,7 @@ describe("detectComposerTrigger", () => { it("detects slash model query after /model", () => { const text = "/model spark"; - const trigger = detectComposerTrigger(text, text.length); + const trigger = detectComposerTrigger(text, text.length, BUILTIN_NAMES); expect(trigger).toEqual({ kind: "slash-model", @@ -50,7 +53,7 @@ describe("detectComposerTrigger", () => { it("detects non-model slash commands while typing", () => { const text = "/pl"; - const trigger = detectComposerTrigger(text, text.length); + const trigger = detectComposerTrigger(text, text.length, BUILTIN_NAMES); expect(trigger).toEqual({ kind: "slash-command", @@ -65,7 +68,7 @@ describe("detectComposerTrigger", () => { const text = "Please inspect @in this sentence"; const cursorAfterAt = "Please inspect @".length; - const trigger = detectComposerTrigger(text, cursorAfterAt); + const trigger = detectComposerTrigger(text, cursorAfterAt, BUILTIN_NAMES); expect(trigger).toEqual({ kind: "path", query: "", @@ -79,7 +82,7 @@ describe("detectComposerTrigger", () => { const text = "Please inspect @srin this sentence"; const cursorAfterQuery = "Please inspect @sr".length; - const trigger = detectComposerTrigger(text, cursorAfterQuery); + const trigger = detectComposerTrigger(text, cursorAfterQuery, BUILTIN_NAMES); expect(trigger).toEqual({ kind: "path", query: "sr", @@ -94,7 +97,7 @@ describe("detectComposerTrigger", () => { const text = "Please inspect @in this sentence"; const cursorAfterAt = "Please inspect @".length; - const trigger = detectComposerTrigger(text, cursorAfterAt); + const trigger = detectComposerTrigger(text, cursorAfterAt, BUILTIN_NAMES); expect(trigger).not.toBeNull(); expect(trigger?.kind).toBe("path"); expect(trigger?.query).toBe(""); @@ -131,7 +134,7 @@ describe("expandCollapsedComposerCursor", () => { const collapsedCursorAfterMention = "what's in my ".length + 2; const expandedCursor = expandCollapsedComposerCursor(text, collapsedCursorAfterMention); - expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); + expect(detectComposerTrigger(text, expandedCursor, BUILTIN_NAMES)).toBeNull(); }); }); @@ -237,14 +240,40 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => { describe("parseStandaloneComposerSlashCommand", () => { it("parses standalone /plan command", () => { - expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan"); + expect(parseStandaloneComposerSlashCommand(" /plan ", STANDALONE_NAMES)).toBe("plan"); }); it("parses standalone /default command", () => { - expect(parseStandaloneComposerSlashCommand("/default")).toBe("default"); + expect(parseStandaloneComposerSlashCommand("/default", STANDALONE_NAMES)).toBe("default"); }); it("ignores slash commands with extra message text", () => { - expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); + expect(parseStandaloneComposerSlashCommand("/plan explain this", STANDALONE_NAMES)).toBeNull(); + }); +}); + +describe("mid-text slash detection", () => { + it("detects slash command after whitespace mid-text", () => { + const text = "hello /pl"; + const trigger = detectComposerTrigger(text, text.length, BUILTIN_NAMES); + expect(trigger).toEqual({ + kind: "slash-command", + query: "pl", + rangeStart: "hello ".length, + rangeEnd: text.length, + }); + }); +}); + +describe("custom command names", () => { + it("detects a custom command name when provided", () => { + const text = "/myskill"; + const trigger = detectComposerTrigger(text, text.length, [...BUILTIN_NAMES, "myskill"]); + expect(trigger).toEqual({ + kind: "slash-command", + query: "myskill", + rangeStart: 0, + rangeEnd: text.length, + }); }); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c8e62ebdcc..7d55117eb7 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,8 +1,8 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +import { slashCommandRegistry } from "./slashCommandRegistry"; export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; -export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -10,8 +10,6 @@ export interface ComposerTrigger { rangeStart: number; rangeEnd: number; } - -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; const isInlineTokenSegment = ( segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, ): boolean => segment.type !== "text"; @@ -184,10 +182,15 @@ export function isCollapsedCursorAdjacentToInlineToken( export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; -export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { +export function detectComposerTrigger( + text: string, + cursorInput: number, + commandNames?: readonly string[], +): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; const linePrefix = text.slice(lineStart, cursor); + const names = commandNames ?? slashCommandRegistry.getNames(); if (linePrefix.startsWith("/")) { const commandMatch = /^\/(\S*)$/.exec(linePrefix); @@ -201,7 +204,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { + if (names.some((command) => command.startsWith(commandQuery.toLowerCase()))) { return { kind: "slash-command", query: commandQuery, @@ -223,8 +226,21 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos } } + // Mid-text slash detection: check for `/` preceded by whitespace const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); + if (token.startsWith("/")) { + const slashQuery = token.slice(1); + if (names.some((command) => command.startsWith(slashQuery.toLowerCase()))) { + return { + kind: "slash-command", + query: slashQuery, + rangeStart: tokenStart, + rangeEnd: cursor, + }; + } + } + if (!token.startsWith("@")) { return null; } @@ -239,14 +255,16 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, -): Exclude | null { - const match = /^\/(plan|default)\s*$/i.exec(text.trim()); + standaloneNames?: readonly string[], +): string | null { + const names = standaloneNames ?? slashCommandRegistry.getStandaloneNames(); + const match = /^\/(\S+)\s*$/i.exec(text.trim()); if (!match) { return null; } const command = match[1]?.toLowerCase(); - if (command === "plan") return "plan"; - return "default"; + if (command && names.includes(command)) return command; + return null; } export function replaceTextRange( diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 20aa265b87..34244c932d 100644 --- a/apps/web/src/lib/projectReactQuery.ts +++ b/apps/web/src/lib/projectReactQuery.ts @@ -1,4 +1,4 @@ -import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; +import type { ProjectListSkillsResult, ProjectSearchEntriesResult } from "@t3tools/contracts"; import { queryOptions } from "@tanstack/react-query"; import { ensureNativeApi } from "~/nativeApi"; @@ -6,6 +6,7 @@ export const projectQueryKeys = { all: ["projects"] as const, searchEntries: (cwd: string | null, query: string, limit: number) => ["projects", "search-entries", cwd, query, limit] as const, + listSkills: (cwd: string | null) => ["projects", "skills", cwd] as const, }; const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; @@ -41,3 +42,22 @@ export function projectSearchEntriesQueryOptions(input: { placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, }); } + +const SKILLS_STALE_TIME = 60_000; +const EMPTY_SKILLS_RESULT: ProjectListSkillsResult = { skills: [] }; + +export function projectListSkillsQueryOptions(input: { cwd: string | null }) { + return queryOptions({ + queryKey: projectQueryKeys.listSkills(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd) { + throw new Error("Skill listing is unavailable without a project."); + } + return api.projects.listSkills({ cwd: input.cwd }); + }, + enabled: input.cwd !== null, + staleTime: SKILLS_STALE_TIME, + placeholderData: (previous) => previous ?? EMPTY_SKILLS_RESULT, + }); +} diff --git a/apps/web/src/slashCommandRegistry.test.ts b/apps/web/src/slashCommandRegistry.test.ts new file mode 100644 index 0000000000..880e77ec22 --- /dev/null +++ b/apps/web/src/slashCommandRegistry.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { slashCommandRegistry } from "./slashCommandRegistry"; + +describe("slashCommandRegistry", () => { + describe("built-in commands", () => { + it("registers /model, /plan, and /default on import", () => { + expect(slashCommandRegistry.has("model")).toBe(true); + expect(slashCommandRegistry.has("plan")).toBe(true); + expect(slashCommandRegistry.has("default")).toBe(true); + }); + + it("marks /plan and /default as standalone", () => { + expect(slashCommandRegistry.getStandaloneNames()).toContain("plan"); + expect(slashCommandRegistry.getStandaloneNames()).toContain("default"); + expect(slashCommandRegistry.getStandaloneNames()).not.toContain("model"); + }); + + it("exposes all registered command names", () => { + const names = slashCommandRegistry.getNames(); + expect(names).toContain("model"); + expect(names).toContain("plan"); + expect(names).toContain("default"); + }); + + it("/model uses trigger-transition action", () => { + const def = slashCommandRegistry.get("model"); + expect(def?.action.type).toBe("trigger-transition"); + }); + + it("/plan uses set-interaction-mode action with mode plan", () => { + const def = slashCommandRegistry.get("plan"); + expect(def?.action).toEqual({ type: "set-interaction-mode", mode: "plan" }); + }); + + it("/default uses set-interaction-mode action with mode default", () => { + const def = slashCommandRegistry.get("default"); + expect(def?.action).toEqual({ type: "set-interaction-mode", mode: "default" }); + }); + }); + + describe("register/unregister", () => { + it("registers a custom command and returns an unregister function", () => { + const unregister = slashCommandRegistry.register({ + name: "test-cmd", + description: "A test command", + action: { type: "callback", execute: () => {} }, + }); + expect(slashCommandRegistry.has("test-cmd")).toBe(true); + unregister(); + expect(slashCommandRegistry.has("test-cmd")).toBe(false); + }); + + it("overwrites a command with the same name", () => { + const unregister1 = slashCommandRegistry.register({ + name: "overwrite-test", + description: "First", + action: { type: "callback", execute: () => {} }, + }); + const unregister2 = slashCommandRegistry.register({ + name: "overwrite-test", + description: "Second", + action: { type: "callback", execute: () => {} }, + }); + expect(slashCommandRegistry.get("overwrite-test")?.description).toBe("Second"); + unregister1(); + expect(slashCommandRegistry.has("overwrite-test")).toBe(true); + unregister2(); + expect(slashCommandRegistry.has("overwrite-test")).toBe(false); + }); + }); + + describe("match", () => { + it("returns all commands for empty query", () => { + expect(slashCommandRegistry.match("")).toEqual(slashCommandRegistry.getAll()); + }); + + it("filters commands by partial name match", () => { + const results = slashCommandRegistry.match("pl"); + expect(results.some((c) => c.name === "plan")).toBe(true); + }); + }); + + describe("subscribe", () => { + it("notifies listeners on register", () => { + let called = 0; + const unsub = slashCommandRegistry.subscribe(() => { + called++; + }); + const unreg = slashCommandRegistry.register({ + name: "sub-test", + description: "sub", + action: { type: "callback", execute: () => {} }, + }); + expect(called).toBe(1); + unreg(); + unsub(); + }); + }); +}); diff --git a/apps/web/src/slashCommandRegistry.ts b/apps/web/src/slashCommandRegistry.ts new file mode 100644 index 0000000000..96279da752 --- /dev/null +++ b/apps/web/src/slashCommandRegistry.ts @@ -0,0 +1,114 @@ +import type { ProviderInteractionMode } from "@t3tools/contracts"; +import type { ComponentType } from "react"; +import { useSyncExternalStore } from "react"; + +export type SlashCommandAction = + | { readonly type: "set-interaction-mode"; readonly mode: ProviderInteractionMode } + | { readonly type: "trigger-transition"; readonly replacement: string } + | { readonly type: "prompt-prefix"; readonly prefix: string } + | { readonly type: "callback"; readonly execute: () => void }; + +export interface SlashCommandDefinition { + readonly name: string; + readonly description: string; + readonly icon?: ComponentType<{ className?: string }>; + readonly action: SlashCommandAction; + readonly standalone?: boolean; +} + +type Listener = () => void; + +class SlashCommandRegistry { + private _commands = new Map(); + private _snapshot: readonly SlashCommandDefinition[] = []; + private _names: readonly string[] = []; + private _standaloneNames: readonly string[] = []; + private _listeners = new Set(); + + register(definition: SlashCommandDefinition): () => void { + this._commands.set(definition.name, definition); + this._rebuild(); + return () => { + if (this._commands.get(definition.name) === definition) { + this._commands.delete(definition.name); + this._rebuild(); + } + }; + } + + get(name: string): SlashCommandDefinition | undefined { + return this._commands.get(name); + } + + getAll(): readonly SlashCommandDefinition[] { + return this._snapshot; + } + + getNames(): readonly string[] { + return this._names; + } + + getStandaloneNames(): readonly string[] { + return this._standaloneNames; + } + + has(name: string): boolean { + return this._commands.has(name); + } + + match(query: string): readonly SlashCommandDefinition[] { + const q = query.toLowerCase(); + if (!q) return this._snapshot; + return this._snapshot.filter((cmd) => cmd.name.includes(q)); + } + + subscribe = (listener: Listener): (() => void) => { + this._listeners.add(listener); + return () => { + this._listeners.delete(listener); + }; + }; + + getSnapshot = (): readonly SlashCommandDefinition[] => { + return this._snapshot; + }; + + private _rebuild(): void { + this._snapshot = Array.from(this._commands.values()); + this._names = this._snapshot.map((c) => c.name); + this._standaloneNames = this._snapshot.filter((c) => c.standalone).map((c) => c.name); + for (const listener of this._listeners) { + listener(); + } + } +} + +export const slashCommandRegistry = new SlashCommandRegistry(); + +slashCommandRegistry.register({ + name: "model", + description: "Switch response model for this thread", + action: { type: "trigger-transition", replacement: "/model " }, +}); + +slashCommandRegistry.register({ + name: "plan", + description: "Switch this thread into plan mode", + action: { type: "set-interaction-mode", mode: "plan" }, + standalone: true, +}); + +slashCommandRegistry.register({ + name: "default", + description: "Switch this thread back to normal chat mode", + action: { type: "set-interaction-mode", mode: "default" }, + standalone: true, +}); + +export function useSlashCommands(): readonly SlashCommandDefinition[] { + return useSyncExternalStore( + slashCommandRegistry.subscribe, + slashCommandRegistry.getSnapshot, + slashCommandRegistry.getSnapshot, + ); +} diff --git a/apps/web/src/slashCommandSkills.ts b/apps/web/src/slashCommandSkills.ts new file mode 100644 index 0000000000..8840b80d0d --- /dev/null +++ b/apps/web/src/slashCommandSkills.ts @@ -0,0 +1,29 @@ +import type { ProjectSkill } from "@t3tools/contracts"; +import { WandIcon } from "lucide-react"; +import { slashCommandRegistry } from "./slashCommandRegistry"; + +export function registerProjectSkills(skills: readonly ProjectSkill[] | undefined): () => void { + if (!skills || skills.length === 0) return () => {}; + + const unregisterFns: Array<() => void> = []; + for (const skill of skills) { + if (!skill.userInvocable) continue; + unregisterFns.push( + slashCommandRegistry.register({ + name: skill.name, + description: skill.description || `Use the ${skill.name} skill`, + icon: WandIcon, + action: { + type: "prompt-prefix", + prefix: `Use the /${skill.name} skill.\n\n`, + }, + }), + ); + } + + return () => { + for (const unregister of unregisterFns) { + unregister(); + } + }; +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 31160dfa1c..ccf02e9ed5 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -44,6 +44,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: rpcClient.projects.searchEntries, writeFile: rpcClient.projects.writeFile, + listSkills: rpcClient.projects.listSkills, }, shell: { openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 60f51ba707..8f65e75209 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -49,6 +49,7 @@ export interface WsRpcClient { readonly projects: { readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; + readonly listSkills: RpcUnaryMethod; }; readonly shell: { readonly openInEditor: (input: { @@ -128,6 +129,8 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), writeFile: (input) => transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), + listSkills: (input) => + transport.request((client) => client[WS_METHODS.projectsListSkills](input)), }, shell: { openInEditor: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3114f6f5be..e9d13301a0 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -17,6 +17,8 @@ import type { GitStatusResult, } from "./git"; import type { + ProjectListSkillsInput, + ProjectListSkillsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -138,6 +140,7 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + listSkills: (input: ProjectListSkillsInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 2851120d1d..2ac135a01f 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -53,3 +53,32 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass()( + "ProjectListSkillsError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 34968e66ec..c43be4a183 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -42,6 +42,9 @@ import { OrchestrationRpcSchemas, } from "./orchestration"; import { + ProjectListSkillsError, + ProjectListSkillsInput, + ProjectListSkillsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -77,6 +80,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsListSkills: "projects.listSkills", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -157,6 +161,12 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: ProjectWriteFileError, }); +export const WsProjectsListSkillsRpc = Rpc.make(WS_METHODS.projectsListSkills, { + payload: ProjectListSkillsInput, + success: ProjectListSkillsResult, + error: ProjectListSkillsError, +}); + export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: OpenInEditorInput, error: OpenError, @@ -329,6 +339,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpdateSettingsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, + WsProjectsListSkillsRpc, WsShellOpenInEditorRpc, WsGitStatusRpc, WsGitPullRpc, From 6688d833af83d872b42afef2897d3abb4356866f Mon Sep 17 00:00:00 2001 From: Luciano Oliveira Date: Sat, 4 Apr 2026 17:14:41 -0300 Subject: [PATCH 2/2] fix: preserve model selection when changing effort in sticky state When changing effort via TraitsPicker, setProviderModelOptions persists the change to sticky state. The fallback chain for resolving the sticky base skipped the just-updated thread draft entry and fell through to DEFAULT_MODEL_BY_PROVIDER, which is claude-sonnet-4-6. This caused changing effort on Opus 4.6 to silently switch the model to Sonnet. Add nextMap[normalizedProvider] to the sticky fallback chain so the current thread's model is preserved. --- apps/web/src/composerDraftStore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0da..ba6623057e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1749,6 +1749,7 @@ export const useComposerDraftStore = create()( nextStickyMap = { ...state.stickyModelSelectionByProvider }; const stickyBase = nextStickyMap[normalizedProvider] ?? + nextMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? ({ provider: normalizedProvider,