Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = {
fontSmoothing: true,
glassOpacity: 80,
planModeEnabled: false,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleOnMerge: true,
Expand Down
54 changes: 31 additions & 23 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,11 @@ import type { SessionPhase, Thread } from "../../types";
import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
import type { PendingApproval, PendingUserInput } from "../../session-logic";
import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
import {
formatProviderSkillDisplayName,
getProviderSlashCommandsForSlashMenu,
getProviderSkillsForSlashMenu,
} from "@t3tools/client-runtime/providerSkills";
import { searchProviderSkills } from "../../providerSkillSearch";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import type { ReviewCommentContext } from "../../reviewCommentContext";
Expand Down Expand Up @@ -1107,30 +1111,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
] as const)
: []),
] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map(
(command) => ({
id: `provider-slash-command:${selectedProvider}:${command.name}`,
type: "provider-slash-command" as const,
provider: selectedProvider,
command,
label: `/${command.name}`,
description: command.description ?? command.input?.hint ?? "Run provider command",
}),
const slashMenuSkills = getProviderSkillsForSlashMenu(
selectedProviderStatus?.skills ?? [],
settings.showSkillsInSlashMenu,
);
const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu(
selectedProviderStatus?.slashCommands ?? [],
slashMenuSkills,
).map((command) => ({
id: `provider-slash-command:${selectedProvider}:${command.name}`,
type: "provider-slash-command" as const,
provider: selectedProvider,
command,
label: `/${command.name}`,
description: command.description ?? command.input?.hint ?? "Run provider command",
}));
const query = composerTrigger.query.trim().toLowerCase();
const skillItems = (selectedProviderStatus?.skills ?? [])
.filter((skill) => skill.enabled)
.map((skill) => ({
id: `skill:${selectedProvider}:${skill.name}`,
type: "skill" as const,
provider: selectedProvider,
skill,
label: `skill:${skill.name}`,
description:
skill.shortDescription ??
skill.description ??
(skill.scope ? `${skill.scope} skill` : ""),
}));
const skillItems = slashMenuSkills.map((skill) => ({
id: `skill:${selectedProvider}:${skill.name}`,
type: "skill" as const,
provider: selectedProvider,
skill,
label: `/skill:${skill.name}`,
description:
skill.shortDescription ??
skill.description ??
(skill.scope ? `${skill.scope} skill` : ""),
}));
const slashCommandItems = [
...builtInSlashCommandItems,
...providerSlashCommandItems,
Expand Down Expand Up @@ -1159,6 +1166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
planModeUiEnabled,
selectedProvider,
selectedProviderStatus,
settings.showSkillsInSlashMenu,
workspaceEntries.entries,
]);

Expand Down
43 changes: 29 additions & 14 deletions apps/web/src/components/chat/ComposerCommandMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ describe("ComposerCommandMenu", () => {
expect(markup).not.toContain("<svg");
expect(markup).toContain("font-sans text-xs font-medium");
expect(markup).not.toContain("font-mono");
expect(markup).toContain("text-right");
expect(markup).not.toContain("grid-cols-");
expect(markup).toContain("max-w-[45%]");
expect(markup).toContain("text-left");
});

it("renders a skill source icon with an accessible source label", () => {
it("renders the skill source icon inside its badge", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
items={[
Expand Down Expand Up @@ -82,40 +84,53 @@ describe("ComposerCommandMenu", () => {
);

expect(markup).toContain("Browser");
expect(markup).toContain('<span class="sr-only">App skill</span>');
expect(markup).toContain('data-slot="badge"');
expect(markup).toContain(">App Skill</span>");
expect(markup).toContain("Open and control the in-app browser");
expect(markup).toContain("max-w-[48ch]");
expect(markup).toContain("text-secondary-label text-xs");
expect(markup).toContain("ms-auto");
expect(markup).toContain("text-current");
expect(markup.indexOf("Open and control the in-app browser")).toBeLessThan(
markup.indexOf(">App Skill</span>"),
);
expect(markup).toContain("<svg");
expect(markup).toContain("text-icon-muted");
expect(markup.indexOf('data-slot="badge"')).toBeLessThan(markup.indexOf("<svg"));
});

it("renders slash skill results with only the skill prefix dimmed", () => {
it("keeps slash skills aligned with the source icon inside the badge", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
items={[
{
id: "skill:codex:browser",
id: "skill:codex:ask-matt",
type: "skill",
provider: ProviderDriverKind.make("codex"),
skill: {
name: "browser",
path: "/skills/browser/SKILL.md",
name: "ask-matt",
displayName: "Ask Matt",
path: "/skills/ask-matt/SKILL.md",
scope: "repo",
enabled: true,
},
label: "skill:browser",
description: "Open and control the in-app browser",
label: "/skill:ask-matt",
description: "Find the right skill or workflow",
},
]}
resolvedTheme="dark"
isLoading={false}
triggerKind="slash-command"
activeItemId="skill:codex:browser"
activeItemId="skill:codex:ask-matt"
onHighlightedItemChange={() => {}}
onSelect={() => {}}
/>,
);

expect(markup).toContain('<span class="text-secondary-label">skill:</span>browser');
expect(markup).toContain("Open and control the in-app browser");
expect(markup).toContain('/<span class="text-secondary-label">skill:</span>Ask Matt');
expect(markup).toContain('data-slot="badge"');
expect(markup).toContain("lucide-folder");
expect(markup).toContain(">Repo</span>");
expect(markup).toContain("Find the right skill or workflow");
expect(markup).not.toContain("font-medium text-secondary-label");
expect(markup).not.toContain("<svg");
});
});
40 changes: 23 additions & 17 deletions apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
formatProviderSkillDisplayName,
resolveProviderSkillSourceKind,
type ProviderSkillSourceKind,
} from "@t3tools/client-runtime/providerSkills";
Expand All @@ -10,7 +11,6 @@ import {
} from "@t3tools/contracts";
import {
BlocksIcon,
FolderGit2Icon,
FolderIcon,
PackageIcon,
SettingsIcon,
Expand All @@ -21,6 +21,7 @@ import { memo, useLayoutEffect, useRef } from "react";

import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
import { cn } from "~/lib/utils";
import { Badge } from "../ui/badge";
import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
import { PierreEntryIcon } from "./PierreEntryIcon";

Expand Down Expand Up @@ -139,7 +140,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
}) {
const skillSourceKind =
props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
const slashSkill =
const isSlashSkill =
props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;

return (
Expand All @@ -166,31 +167,35 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
kind={props.item.pathKind}
theme={props.resolvedTheme}
/>
) : skillSourceKind && !slashSkill ? (
<SkillSourceIcon kind={skillSourceKind} />
) : null}
<span className="flex min-w-0 flex-1 items-baseline gap-3">
<span className="shrink-0 font-sans text-xs font-medium">
{slashSkill ? (
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 max-w-[45%] shrink-0 truncate font-sans text-xs font-medium">
{isSlashSkill ? (
<>
<span className="text-secondary-label">skill:</span>
{slashSkill.name}
/<span className="text-secondary-label">skill:</span>
{formatProviderSkillDisplayName(isSlashSkill)}
</>
) : (
props.item.label
)}
</span>
<span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">
<span className="min-w-0 max-w-[48ch] flex-1 truncate text-left text-secondary-label text-xs">
{props.item.description}
</span>
{skillSourceKind ? (
<SkillSourceBadge
kind={skillSourceKind}
showSkillSuffix={props.triggerKind === "skill"}
/>
) : null}
</span>
</CommandItem>
);
});

const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
app: BlocksIcon,
repo: FolderGit2Icon,
repo: FolderIcon,
project: FolderIcon,
personal: UserRoundIcon,
system: SettingsIcon,
Expand All @@ -203,15 +208,16 @@ const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {
project: "Project",
personal: "Personal",
system: "System",
other: "Other",
other: "Provider",
};

function SkillSourceIcon(props: { kind: ProviderSkillSourceKind }) {
function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) {
const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];
return (
<>
<Icon aria-hidden="true" className="size-4 shrink-0 text-icon-muted" />
<span className="sr-only">{SKILL_SOURCE_LABEL_BY_KIND[props.kind]} skill</span>
</>
<Badge className="ms-auto" variant="secondary">
<Icon aria-hidden="true" className="text-current" />
{SKILL_SOURCE_LABEL_BY_KIND[props.kind]}
{props.showSkillSuffix ? " Skill" : null}
</Badge>
);
}
31 changes: 18 additions & 13 deletions apps/web/src/components/chat/composerSlashCommandSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe("searchSlashCommandItems", () => {
enabled: true,
shortDescription: "Open and control the in-app browser",
},
label: "skill:browser",
label: "/skill:browser",
description: "Open and control the in-app browser",
},
] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;
Expand All @@ -97,23 +97,26 @@ describe("searchSlashCommandItems", () => {
it("matches skills by display name", () => {
const items = [
{
id: "skill:claudeAgent:browser",
id: "skill:claudeAgent:ask-matt",
type: "skill",
provider: claudeDriver,
skill: {
name: "browser",
displayName: "Web Navigator",
path: "/skills/browser/SKILL.md",
name: "ask-matt",
displayName: "Ask Matt",
path: "/skills/ask-matt/SKILL.md",
enabled: true,
shortDescription: "Open and control the in-app browser",
shortDescription: "Find the right skill or workflow",
},
label: "skill:browser",
description: "Open and control the in-app browser",
label: "/skill:ask-matt",
description: "Find the right skill or workflow",
},
] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;

expect(searchSlashCommandItems(items, "navigator").map((item) => item.id)).toEqual([
"skill:claudeAgent:browser",
expect(searchSlashCommandItems(items, "ask matt").map((item) => item.id)).toEqual([
"skill:claudeAgent:ask-matt",
]);
expect(searchSlashCommandItems(items, "/skill:ask-matt").map((item) => item.id)).toEqual([
"skill:claudeAgent:ask-matt",
]);
});

Expand All @@ -128,15 +131,17 @@ describe("searchSlashCommandItems", () => {
path: "/skills/browser/SKILL.md",
enabled: true,
},
label: "skill:browser",
label: "/skill:browser",
description: "Open and control the in-app browser",
},
] satisfies Array<Extract<ComposerCommandItem, { type: "skill" }>>;

expect(searchSlashCommandItems(items, "/skill:brow").map((item) => item.id)).toEqual([
"skill:claudeAgent:browser",
]);
expect(searchSlashCommandItems(items, "/sk")).toEqual([]);
expect(searchSlashCommandItems(items, "/sk").map((item) => item.id)).toEqual([
"skill:claudeAgent:browser",
]);
expect(searchSlashCommandItems(items, "/ill")).toEqual([]);
});

Expand All @@ -158,7 +163,7 @@ describe("searchSlashCommandItems", () => {
path: "/skills/unslop/SKILL.md",
enabled: true,
},
label: "skill:unslop",
label: "/skill:unslop",
description: "Cut AI tells from writing",
},
] satisfies Array<Extract<ComposerCommandItem, { type: "slash-command" | "skill" }>>;
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/chat/composerSlashCommandSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ type SlashSearchItem = Extract<

function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null {
if (item.type === "skill") {
const skillQuery =
query === "skill" ? "" : query.startsWith("skill:") ? query.slice("skill:".length) : query;
if ("skill".startsWith(query)) {
return 0;
}
Comment thread
cursor[bot] marked this conversation as resolved.
const skillQuery = query.startsWith("skill:") ? query.slice("skill:".length) : query;
return skillQuery ? scoreProviderSkill(item.skill, skillQuery) : 0;
}

Expand Down
Loading
Loading