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/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, 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,