Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions apps/server/src/slashCommandScanner.ts
Original file line number Diff line number Diff line change
@@ -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<string, { result: SlashCommandListResult; scannedAt: number }>();

/** Scan a commands/ directory for .md files (recurses into subdirectories). */
async function scanCommandsDir(
dir: string,
source: "user" | "project",
prefix = "",
): Promise<SlashCommandEntry[]> {
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<SlashCommandEntry[]> {
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<string | undefined> {
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<string | undefined> {
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;
Comment on lines +100 to +107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low src/slashCommandScanner.ts:100

readFirstLine only checks the first line of the file, so files that start with an empty line, heading, template variable, or YAML frontmatter return undefined even when a valid description exists on a subsequent line. For example, a file starting with # Title followed by A description here returns no description.

+  if (!head) return undefined;
+  const lines = head.split("\n");
+  for (const line of lines) {
+    const trimmed = line.trim();
+    if (trimmed && !trimmed.startsWith("$") && !trimmed.startsWith("#") && !trimmed.startsWith("---")) {
+      return trimmed.slice(0, 120);
+    }
+  }
🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/slashCommandScanner.ts around lines 100-107:

`readFirstLine` only checks the first line of the file, so files that start with an empty line, heading, template variable, or YAML frontmatter return `undefined` even when a valid description exists on a subsequent line. For example, a file starting with `# Title` followed by `A description here` returns no description.

Evidence trail:
apps/server/src/slashCommandScanner.ts lines 99-107 at REVIEWED_COMMIT - The function `readFirstLine` has a docstring claiming it reads 'the first non-empty, non-heading, non-template-variable line' but implementation only checks `head.split("\n")[0]` (line 103), returning undefined if first line doesn't match criteria instead of checking subsequent lines.

}

/** 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<SlashCommandListResult> {
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<string, SlashCommandEntry>();
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;
}
12 changes: 12 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 50 additions & 11 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, PendingUserInputDraftAnswer> = {};
Expand Down Expand Up @@ -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<ComposerCommandItem[]>(() => {
if (!composerTrigger) return [];
if (composerTrigger.kind === "path") {
Expand All @@ -1054,7 +1063,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
}

if (composerTrigger.kind === "slash-command") {
const slashCommandItems = [
const builtInItems: Array<Extract<ComposerCommandItem, { type: "slash-command" }>> = [
{
id: "slash:model",
type: "slash-command",
Expand All @@ -1076,13 +1085,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
label: "/default",
description: "Switch this thread back to normal chat mode",
},
] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
];
const customItems: Array<Extract<ComposerCommandItem, { type: "slash-command" }>> =
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),
);
}

Expand All @@ -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(
() =>
Expand Down Expand Up @@ -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);
}
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -82,8 +82,15 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
isActive: boolean;
onSelect: (item: ComposerCommandItem) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (props.isActive) {
ref.current?.scrollIntoView({ block: "nearest" });
}
}, [props.isActive]);
return (
<CommandItem
ref={ref}
value={props.item.id}
className={cn(
"cursor-pointer select-none gap-2",
Expand Down
20 changes: 8 additions & 12 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";

export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
export type ComposerSlashCommand = "model" | "plan" | "default";
export type ComposerSlashCommand = "model" | "plan" | "default" | (string & {});

export interface ComposerTrigger {
kind: ComposerTriggerKind;
Expand All @@ -11,7 +11,6 @@ export interface ComposerTrigger {
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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -239,7 +235,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos

export function parseStandaloneComposerSlashCommand(
text: string,
): Exclude<ComposerSlashCommand, "model"> | null {
): "plan" | "default" | null {
const match = /^\/(plan|default)\s*$/i.exec(text.trim());
if (!match) {
return null;
Expand Down
Loading