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
58 changes: 58 additions & 0 deletions apps/server/src/workspace/skillScanner.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the way i was going to do this was having each Provider return a list of skills from the backend. For Claude, we get the data back from this probe here: https://github.com/pingdotgg/t3code/blob/main/apps/server/src/provider/Layers/ClaudeProvider.ts#L413

Original file line number Diff line number Diff line change
@@ -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<string, string> {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
if (!match?.[1]) return {};
const result: Record<string, string> = {};
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<ProjectListSkillsResult> {
const seen = new Set<string>();
const skills: Array<ProjectListSkillsResult["skills"][number]> = [];

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 };
}
15 changes: 15 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
OrchestrationGetSnapshotError,
OrchestrationGetTurnDiffError,
ORCHESTRATION_WS_METHODS,
ProjectListSkillsError,
ProjectSearchEntriesError,
ProjectWriteFileError,
OrchestrationReplayEventsError,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down
102 changes: 61 additions & 41 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -40,6 +43,8 @@ import {
parseStandaloneComposerSlashCommand,
replaceTextRange,
} from "../composer-logic";
import { slashCommandRegistry, useSlashCommands } from "../slashCommandRegistry";
import { registerProjectSkills } from "../slashCommandSkills";
import {
deriveCompletionDividerBeforeEntryId,
derivePendingApprovals,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<Extract<ComposerCommandItem, { type: "slash-command" }>>;
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
Expand All @@ -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(
() =>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,7 +19,8 @@ export type ComposerCommandItem =
| {
id: string;
type: "slash-command";
command: ComposerSlashCommand;
command: string;
icon?: ComponentType<{ className?: string }>;
label: string;
description: string;
}
Expand Down Expand Up @@ -124,7 +125,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
/>
) : null}
{props.item.type === "slash-command" ? (
<BotIcon className="size-4 text-muted-foreground/80" />
props.item.icon ? (
<props.item.icon className="size-4 text-muted-foreground/80" />
) : (
<BotIcon className="size-4 text-muted-foreground/80" />
)
) : null}
{props.item.type === "model" ? (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
Expand Down
Loading
Loading