import { formatProviderSkillDisplayName, resolveProviderSkillSourceKind, type ProviderSkillSourceKind,} from "@t3tools/client-runtime/providerSkills";import { type ProjectEntry, type ProviderDriverKind, type ServerProviderSkill, type ServerProviderSlashCommand,} from "@t3tools/contracts";import { BlocksIcon, FolderIcon, PackageIcon, SettingsIcon, UserRoundIcon, type LucideIcon,} from "lucide-react";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";import { ComposerBanner } from "./ComposerBanner"; export type ComposerCommandItem = | { id: string; type: "path"; path: string; pathKind: ProjectEntry["kind"]; label: string; description: string; } | { id: string; type: "slash-command"; command: ComposerSlashCommand; label: string; description: string; } | { id: string; type: "provider-slash-command"; provider: ProviderDriverKind; command: ServerProviderSlashCommand; label: string; description: string; } | { id: string; type: "skill"; provider: ProviderDriverKind; skill: ServerProviderSkill; label: string; description: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; emptyStateText?: string; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void;}) { const listRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { if (!props.activeItemId || !listRef.current) return; const el = listRef.current.querySelector<HTMLElement>( `[data-composer-item-id="${CSS.escape(props.activeItemId)}"]`, ); el?.scrollIntoView({ block: "nearest" }); }, [props.activeItemId]); return ( <Command autoHighlight={false} mode="none" onItemHighlighted={(highlightedValue) => { props.onHighlightedItemChange( typeof highlightedValue === "string" ? highlightedValue : null, ); }} > <ComposerBanner.Surface ref={listRef} className="flex min-h-0 w-full flex-col overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4" data-composer-command-drawer="true" > {props.items.length > 0 ? ( <CommandList className="max-h-72 min-h-0 scroll-pb-6"> <CommandGroup> {props.items.map((item) => ( <ComposerCommandMenuItem key={item.id} item={item} triggerKind={props.triggerKind} resolvedTheme={props.resolvedTheme} isActive={props.activeItemId === item.id} onHighlight={props.onHighlightedItemChange} onSelect={props.onSelect} /> ))} </CommandGroup> </CommandList> ) : ( <div className="px-5 pt-3.5 pb-7"> <p className="text-secondary-label text-xs"> {props.isLoading ? props.triggerKind === "skill" ? "Searching workspace skills..." : "Searching workspace files..." : (props.emptyStateText ?? (props.triggerKind === "skill" ? "No skills found. Try / to browse provider commands." : props.triggerKind === "path" ? "No matching files or folders." : "No matching command."))} </p> </div> )} </ComposerBanner.Surface> </Command> );}); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; triggerKind: ComposerTriggerKind | null; resolvedTheme: "light" | "dark"; isActive: boolean; onHighlight: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void;}) { const skillSourceKind = props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null; const isSlashSkill = props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null; return ( <CommandItem value={props.item.id} data-composer-item-id={props.item.id} className={cn( "cursor-pointer select-none gap-3 rounded-lg px-3 py-2! hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit", props.isActive && "bg-accent! text-accent-foreground!", )} onMouseMove={() => { if (!props.isActive) props.onHighlight(props.item.id); }} onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { props.onSelect(props.item); }} > {props.item.type === "path" ? ( <PierreEntryIcon pathValue={props.item.path} kind={props.item.pathKind} theme={props.resolvedTheme} /> ) : null} <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> {formatProviderSkillDisplayName(isSlashSkill)} </> ) : ( props.item.label )} </span> <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: FolderIcon, project: FolderIcon, personal: UserRoundIcon, system: SettingsIcon, other: PackageIcon,}; const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = { app: "App", repo: "Repo", project: "Project", personal: "Personal", system: "System", other: "Provider",}; function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) { const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind]; return ( <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> );}
import {
formatProviderSkillDisplayName,
resolveProviderSkillSourceKind,
type ProviderSkillSourceKind,
} from "@t3tools/client-runtime/providerSkills";
import {
type ProjectEntry,
type ProviderDriverKind,
type ServerProviderSkill,
type ServerProviderSlashCommand,
} from "@t3tools/contracts";
import {
BlocksIcon,
FolderIcon,
PackageIcon,
SettingsIcon,
UserRoundIcon,
type LucideIcon,
} from "lucide-react";
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";
import { ComposerBanner } from "./ComposerBanner";
export type ComposerCommandItem =
| {
id: string;
type: "path";
path: string;
pathKind: ProjectEntry["kind"];
label: string;
description: string;
}
| {
id: string;
type: "slash-command";
command: ComposerSlashCommand;
label: string;
description: string;
}
| {
id: string;
type: "provider-slash-command";
provider: ProviderDriverKind;
command: ServerProviderSlashCommand;
label: string;
description: string;
}
| {
id: string;
type: "skill";
provider: ProviderDriverKind;
skill: ServerProviderSkill;
label: string;
description: string;
};
export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
items: ComposerCommandItem[];
resolvedTheme: "light" | "dark";
isLoading: boolean;
triggerKind: ComposerTriggerKind | null;
emptyStateText?: string;
activeItemId: string | null;
onHighlightedItemChange: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
}) {
const listRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!props.activeItemId || !listRef.current) return;
const el = listRef.current.querySelector<HTMLElement>(
`[data-composer-item-id="${CSS.escape(props.activeItemId)}"]`,
);
el?.scrollIntoView({ block: "nearest" });
}, [props.activeItemId]);
return (
<Command
autoHighlight={false}
mode="none"
onItemHighlighted={(highlightedValue) => {
props.onHighlightedItemChange(
typeof highlightedValue === "string" ? highlightedValue : null,
);
}}
>
<ComposerBanner.Surface
ref={listRef}
className="flex min-h-0 w-full flex-col overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
data-composer-command-drawer="true"
>
{props.items.length > 0 ? (
<CommandList className="max-h-72 min-h-0 scroll-pb-6">
<CommandGroup>
{props.items.map((item) => (
<ComposerCommandMenuItem
key={item.id}
item={item}
triggerKind={props.triggerKind}
resolvedTheme={props.resolvedTheme}
isActive={props.activeItemId === item.id}
onHighlight={props.onHighlightedItemChange}
onSelect={props.onSelect}
/>
))}
</CommandGroup>
</CommandList>
) : (
<div className="px-5 pt-3.5 pb-7">
<p className="text-secondary-label text-xs">
{props.isLoading
? props.triggerKind === "skill"
? "Searching workspace skills..."
: "Searching workspace files..."
: (props.emptyStateText ??
(props.triggerKind === "skill"
? "No skills found. Try / to browse provider commands."
: props.triggerKind === "path"
? "No matching files or folders."
: "No matching command."))}
</p>
</div>
)}
</ComposerBanner.Surface>
</Command>
);
});
const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
item: ComposerCommandItem;
triggerKind: ComposerTriggerKind | null;
resolvedTheme: "light" | "dark";
isActive: boolean;
onHighlight: (itemId: string | null) => void;
onSelect: (item: ComposerCommandItem) => void;
}) {
const skillSourceKind =
props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
const isSlashSkill =
props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;
return (
<CommandItem
value={props.item.id}
data-composer-item-id={props.item.id}
className={cn(
"cursor-pointer select-none gap-3 rounded-lg px-3 py-2! hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",
props.isActive && "bg-accent! text-accent-foreground!",
)}
onMouseMove={() => {
if (!props.isActive) props.onHighlight(props.item.id);
}}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
props.onSelect(props.item);
}}
>
{props.item.type === "path" ? (
<PierreEntryIcon
pathValue={props.item.path}
kind={props.item.pathKind}
theme={props.resolvedTheme}
/>
) : null}
<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>
{formatProviderSkillDisplayName(isSlashSkill)}
</>
) : (
props.item.label
)}
</span>
<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: FolderIcon,
project: FolderIcon,
personal: UserRoundIcon,
system: SettingsIcon,
other: PackageIcon,
};
const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {
app: "App",
repo: "Repo",
project: "Project",
personal: "Personal",
system: "System",
other: "Provider",
};
function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) {
const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];
return (
<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>
);
}