diff --git a/apps/server/src/slashCommandScanner.ts b/apps/server/src/slashCommandScanner.ts new file mode 100644 index 0000000000..cf157aba19 --- /dev/null +++ b/apps/server/src/slashCommandScanner.ts @@ -0,0 +1,171 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import type { SlashCommandEntry, SlashCommandListResult } from "@t3tools/contracts"; + +const CACHE_TTL_MS = 30_000; +const cache = new Map(); + +/** Scan a commands/ directory for .md files (recurses into subdirectories). */ +async function scanCommandsDir( + dir: string, + source: "user" | "project", + prefix = "", +): Promise { + const entries: SlashCommandEntry[] = []; + let dirEntries; + try { + dirEntries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return entries; + } + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.isDirectory()) { + const nested = await scanCommandsDir( + path.join(dir, entry.name), + source, + prefix ? `${prefix}/${entry.name}` : entry.name, + ); + entries.push(...nested); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + const name = prefix + ? `${prefix}/${entry.name.replace(/\.md$/, "")}` + : entry.name.replace(/\.md$/, ""); + + const description = await readFirstLine(path.join(dir, entry.name)); + entries.push({ name, source, ...(description ? { description } : {}) }); + } + } + return entries; +} + +/** Scan a skills/ directory for subdirectories containing SKILL.md with YAML frontmatter. */ +async function scanSkillsDir( + dir: string, + source: "user" | "project", +): Promise { + const entries: SlashCommandEntry[] = []; + let dirEntries; + try { + dirEntries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return entries; + } + for (const entry of dirEntries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const skillMdPath = path.join(dir, entry.name, "SKILL.md"); + try { + const content = await readHead(skillMdPath, 1024); + if (!content) continue; + const parsed = parseSkillFrontmatter(content); + if (!parsed) continue; + entries.push({ + name: parsed.name, + source, + ...(parsed.description ? { description: parsed.description.slice(0, 120) } : {}), + }); + // Skills can contain sub-skills as .md files alongside SKILL.md + const subFiles = await fs.readdir(path.join(dir, entry.name), { withFileTypes: true }); + for (const sub of subFiles) { + if (!sub.isFile() || !sub.name.endsWith(".md") || sub.name === "SKILL.md") continue; + const subName = `${parsed.name}/${sub.name.replace(/\.md$/, "")}`; + const subDesc = await readFirstLine(path.join(dir, entry.name, sub.name)); + entries.push({ name: subName, source, ...(subDesc ? { description: subDesc } : {}) }); + } + } catch { + // Skip unreadable skills + } + } + return entries; +} + +/** Read the first N bytes of a file, returning the string or undefined on error. */ +async function readHead(filePath: string, bytes: number): Promise { + try { + const fh = await fs.open(filePath, "r"); + try { + const buf = Buffer.alloc(bytes); + const { bytesRead } = await fh.read(buf, 0, bytes, 0); + return buf.toString("utf-8", 0, bytesRead); + } finally { + await fh.close(); + } + } catch { + return undefined; + } +} + +/** Read the first non-empty, non-heading, non-template-variable line as a description. */ +async function readFirstLine(filePath: string): Promise { + const head = await readHead(filePath, 256); + if (!head) return undefined; + const firstLine = head.split("\n")[0]?.trim(); + if (firstLine && !firstLine.startsWith("$") && !firstLine.startsWith("#") && !firstLine.startsWith("---")) { + return firstLine.slice(0, 120); + } + return undefined; +} + +/** Parse YAML frontmatter from a SKILL.md file to extract name and description. */ +function parseSkillFrontmatter(content: string): { name: string; description?: string } | null { + const fmMatch = /^---\s*\n([\s\S]*?)\n---/.exec(content); + if (!fmMatch) return null; + const fm = fmMatch[1] ?? ""; + const nameMatch = /^name:\s*(.+)$/m.exec(fm); + if (!nameMatch) return null; + const name = nameMatch[1]?.trim().replace(/^["']|["']$/g, ""); + if (!name) return null; + const descMatch = /^description:\s*(.+)$/m.exec(fm); + const description = descMatch?.[1]?.trim().replace(/^["']|["']$/g, ""); + return { name, ...(description ? { description } : {}) }; +} + +/** Built-in Claude Code skills that are bundled inside the CLI binary. */ +const BUILTIN_CLAUDE_SKILLS: SlashCommandEntry[] = [ + { name: "batch", source: "user", description: "Research and plan a large-scale change, then execute it in parallel across isolated worktree agents" }, + { name: "claude-api", source: "user", description: "Build apps with the Claude API or Anthropic SDK" }, + { name: "claude-in-chrome", source: "user", description: "Automate your Chrome browser to interact with web pages" }, + { name: "debug", source: "user", description: "Enable debug logging for this session and help diagnose issues" }, + { name: "loop", source: "user", description: "Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo)" }, + { name: "schedule", source: "user", description: "Create, update, list, or run scheduled remote agents on a cron schedule" }, + { name: "simplify", source: "user", description: "Review changed code for reuse, quality, and efficiency, then fix any issues found" }, +]; + +export async function listSlashCommands(cwd: string): Promise { + const cached = cache.get(cwd); + if (cached && Date.now() - cached.scannedAt < CACHE_TTL_MS) { + return cached.result; + } + + const homeDir = os.homedir(); + const claudeDir = path.join(homeDir, ".claude"); + const projectClaudeDir = path.join(cwd, ".claude"); + + const [userCommands, projectCommands, userSkills, projectSkills] = await Promise.all([ + scanCommandsDir(path.join(claudeDir, "commands"), "user"), + scanCommandsDir(path.join(projectClaudeDir, "commands"), "project"), + scanSkillsDir(path.join(claudeDir, "skills"), "user"), + scanSkillsDir(path.join(projectClaudeDir, "skills"), "project"), + ]); + + // Built-ins < user < project (later entries override earlier ones) + const byName = new Map(); + for (const cmd of BUILTIN_CLAUDE_SKILLS) byName.set(cmd.name, cmd); + for (const cmd of userCommands) byName.set(cmd.name, cmd); + for (const cmd of userSkills) byName.set(cmd.name, cmd); + for (const cmd of projectCommands) byName.set(cmd.name, cmd); + for (const cmd of projectSkills) byName.set(cmd.name, cmd); + + const result: SlashCommandListResult = { + commands: Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)), + }; + + cache.set(cwd, { result, scannedAt: Date.now() }); + if (cache.size > 8) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + + return result; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bcb3850e7a..cc4d876eb0 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -49,6 +49,7 @@ import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; +import { listSlashCommands } from "./slashCommandScanner"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; @@ -782,6 +783,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { relativePath: target.relativePath }; } + case WS_METHODS.projectsListCommands: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => listSlashCommands(body.cwd), + catch: (cause) => + new RouteRequestError({ + message: `Failed to list slash commands: ${String(cause)}`, + }), + }); + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbc887bf62..0d85bb4d31 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -32,7 +32,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; -import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { + projectSearchEntriesQueryOptions, + projectSlashCommandsQueryOptions, +} from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -191,6 +194,8 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; +const EMPTY_CUSTOM_COMMANDS: readonly { name: string; source: string; description?: string }[] = []; +const BUILTIN_COMMAND_NAMES = new Set(["model", "plan", "default"]); const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; @@ -1040,6 +1045,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }), ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const slashCommandsQuery = useQuery( + projectSlashCommandsQueryOptions(activeProject?.cwd ?? null), + ); + const customCommands = slashCommandsQuery.data?.commands ?? EMPTY_CUSTOM_COMMANDS; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1054,7 +1063,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ + const builtInItems: Array> = [ { id: "slash:model", type: "slash-command", @@ -1076,13 +1085,25 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/default", description: "Switch this thread back to normal chat mode", }, - ] satisfies ReadonlyArray>; + ]; + const customItems: Array> = + customCommands + .filter((cmd) => !BUILTIN_COMMAND_NAMES.has(cmd.name)) + .map((cmd) => ({ + id: `slash:custom:${cmd.name}`, + type: "slash-command", + command: cmd.name, + label: `/${cmd.name}`, + description: + cmd.description ?? (cmd.source === "project" ? "Project command" : "User command"), + })); + const allItems = [...builtInItems, ...customItems]; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { - return [...slashCommandItems]; + return allItems; } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), + return allItems.filter( + (item) => item.command.toLowerCase().includes(query) || item.label.slice(1).toLowerCase().includes(query), ); } @@ -1102,7 +1123,7 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} · ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [composerTrigger, searchableModelOptions, workspaceEntries, customCommands]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -3292,10 +3313,28 @@ 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 (item.command === "plan" || item.command === "default") { + 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); + } + return; + } + const replacement = `/${item.command} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); if (applied) { setComposerHighlightedItemId(null); } diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 818c3c20f8..54697e9fcc 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,5 +1,5 @@ import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { memo } from "react"; +import { memo, useEffect, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -82,8 +82,15 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { isActive: boolean; onSelect: (item: ComposerCommandItem) => void; }) { + const ref = useRef(null); + useEffect(() => { + if (props.isActive) { + ref.current?.scrollIntoView({ block: "nearest" }); + } + }, [props.isActive]); return ( segment.type !== "text"; @@ -201,15 +200,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { - return { - kind: "slash-command", - query: commandQuery, - rangeStart: lineStart, - rangeEnd: cursor, - }; - } - return null; + return { + kind: "slash-command", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); @@ -239,7 +235,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, -): Exclude | null { +): "plan" | "default" | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 20aa265b87..b4c9c8315c 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 { ProjectSearchEntriesResult, SlashCommandListResult } 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, + slashCommands: (cwd: string | null) => ["projects", "slashCommands", cwd] as const, }; const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; @@ -41,3 +42,21 @@ export function projectSearchEntriesQueryOptions(input: { placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, }); } + +const EMPTY_SLASH_COMMANDS_RESULT: SlashCommandListResult = { commands: [] }; + +export function projectSlashCommandsQueryOptions(cwd: string | null) { + return queryOptions({ + queryKey: projectQueryKeys.slashCommands(cwd), + queryFn: async () => { + const api = ensureNativeApi(); + if (!cwd) { + throw new Error("Slash command listing is unavailable."); + } + return api.projects.listCommands({ cwd }); + }, + enabled: cwd !== null, + staleTime: 30_000, + placeholderData: (previous) => previous ?? EMPTY_SLASH_COMMANDS_RESULT, + }); +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 042875f6f7..2a83f2e696 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -126,6 +126,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + listCommands: (input) => transport.request(WS_METHODS.projectsListCommands, input), }, shell: { openInEditor: (cwd, editor) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea73024de3..1a2107dd11 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,6 +24,8 @@ import type { ProjectSearchEntriesResult, ProjectWriteFileInput, ProjectWriteFileResult, + SlashCommandListInput, + SlashCommandListResult, } from "./project"; import type { ServerConfig } from "./server"; import type { @@ -129,6 +131,7 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + listCommands: (input: SlashCommandListInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0903253301..a88dafa000 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -37,3 +37,22 @@ export const ProjectWriteFileResult = Schema.Struct({ relativePath: TrimmedNonEmptyString, }); export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; + +// ── Slash Command Discovery ───────────────────────────────────────── + +export const SlashCommandEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + source: Schema.Literals(["user", "project"]), + description: Schema.optional(TrimmedNonEmptyString), +}); +export type SlashCommandEntry = typeof SlashCommandEntry.Type; + +export const SlashCommandListInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, +}); +export type SlashCommandListInput = typeof SlashCommandListInput.Type; + +export const SlashCommandListResult = Schema.Struct({ + commands: Schema.Array(SlashCommandEntry), +}); +export type SlashCommandListResult = typeof SlashCommandListResult.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 45ef0512da..e025c64d2d 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -35,7 +35,7 @@ import { TerminalWriteInput, } from "./terminal"; import { KeybindingRule } from "./keybindings"; -import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; +import { ProjectSearchEntriesInput, ProjectWriteFileInput, SlashCommandListInput } from "./project"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; @@ -48,6 +48,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsListCommands: "projects.listCommands", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -113,6 +114,7 @@ const WebSocketRequestBody = Schema.Union([ // Project Search tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), + tagRequestBody(WS_METHODS.projectsListCommands, SlashCommandListInput), // Shell methods tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),