feat(web): /skill slash command with autocomplete
Trigger /<name>, dropdown lists all skills filtered by name prefix, arg passthrough sends the rest as the user message. Synthetic skill_use tool_use renders identically to model-invoked skills.
This commit is contained in:
137
apps/web/src/components/SkillSlashCommand.tsx
Normal file
137
apps/web/src/components/SkillSlashCommand.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Skill } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
skills: Skill[];
|
||||
anchorRect: { top: number; left: number };
|
||||
onSelect: (skillName: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
|
||||
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
|
||||
// `Command` (cmdk) isn't installed in this project; per the addendum we use
|
||||
// a plain div + Tailwind instead of pulling a new primitive autonomously.
|
||||
|
||||
// Case-insensitive prefix match on `name` only. Description is display-only
|
||||
// in v1 (substring search across description is deferred to a polish batch).
|
||||
function filterByPrefix(skills: Skill[], query: string): Skill[] {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = q
|
||||
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
|
||||
: skills;
|
||||
// Stable alphabetical ordering matches the server's cache order (skills.ts
|
||||
// sorts on name asc) but we re-sort here so a stale client cache doesn't
|
||||
// surprise the user.
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
|
||||
|
||||
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||
|
||||
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
|
||||
// textarea reach the popover even though focus stays in the textarea.
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (filtered.length === 0) return;
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightIndex] ?? filtered[0];
|
||||
if (target) onSelect(target.name);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
// Anchor sits above the input — translate(-100%) on Y so the dropdown
|
||||
// expands upward from the anchor point rather than over the textarea.
|
||||
const style = {
|
||||
top: anchorRect.top,
|
||||
left: anchorRect.left,
|
||||
transform: 'translateY(-100%)',
|
||||
} as const;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
||||
style={style}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{query ? `No skill starts with "/${query}"` : 'No skills available'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((skill, i) => (
|
||||
<button
|
||||
key={skill.name}
|
||||
type="button"
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
// mousedown not click — click runs after blur/focus shuffles which
|
||||
// can race with the textarea's onBlur close path.
|
||||
e.preventDefault();
|
||||
onSelect(skill.name);
|
||||
}}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{skill.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user