import { createSignal, createEffect, createMemo, Show, For, on, onCleanup } from "solid-js"; import { cn } from "../utils/cn"; import { usePromptInput } from "./prompt-input"; // --- Types --- export interface SlashCommandItem { id: string; label: string; description?: string; category?: string; } export interface SlashCommandProps { commands: SlashCommandItem[]; activeIds?: string[]; // currently active command IDs — selecting again removes onSelect: (command: SlashCommandItem) => void; compact?: boolean; // single line: label + description side by side (default: true) class?: string; } // --- Component --- function SlashCommand(props: SlashCommandProps) { const ctx = usePromptInput(); const [open, setOpen] = createSignal(false); const [selectedIndex, setSelectedIndex] = createSignal(0); const [query, setQuery] = createSignal(""); const isCompact = props.compact !== false; // default true let listRef: HTMLDivElement | undefined; // Detect slash at the start of input const slashMatch = createMemo(() => { const val = ctx.value(); const match = val.match(/^\/(\S*)$/); return match ? match[1] : null; }); // Filter and sort commands alphabetically const filtered = createMemo(() => { const q = slashMatch(); if (q === null) return []; const items = q === "" ? [...props.commands] : props.commands.filter( (cmd) => cmd.label.toLowerCase().includes(q.toLowerCase()) || (cmd.description?.toLowerCase().includes(q.toLowerCase()) ?? false), ); return items.sort((a, b) => a.label.localeCompare(b.label)); }); // Group by category const grouped = createMemo(() => { const items = filtered(); const groups = new Map(); for (const item of items) { const cat = item.category ?? ""; if (!groups.has(cat)) groups.set(cat, []); groups.get(cat)!.push(item); } return groups; }); // Flat list for keyboard navigation const flatList = createMemo(() => { const items: SlashCommandItem[] = []; for (const group of grouped().values()) { items.push(...group); } return items; }); // Open/close based on slash detection createEffect( on(slashMatch, (match) => { if (match !== null) { setOpen(true); setQuery(match); setSelectedIndex(0); } else { setOpen(false); } }), ); // Keep selected index in bounds createEffect(() => { const max = flatList().length; if (selectedIndex() >= max) setSelectedIndex(Math.max(0, max - 1)); }); // Scroll selected item into view createEffect(() => { const idx = selectedIndex(); if (!listRef) return; const el = listRef.querySelector(`[data-index="${idx}"]`) as HTMLElement; el?.scrollIntoView({ block: "nearest" }); }); function selectItem(item: SlashCommandItem) { // Insert the chosen command into the prompt (e.g. "/summarize ") so it // appears in the input ready to send or edit. The trailing space ends the // slash token, which closes the palette. Still fire onSelect so consumers // can react to the selection. ctx.setValue(item.label + " "); setOpen(false); props.onSelect(item); // Refocus the textarea and place the caret at the end. setTimeout(() => { const ta = ctx.textareaRef; if (!ta) return; ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }, 0); } function handleKeyDown(e: KeyboardEvent) { if (!open()) return; const list = flatList(); if (list.length === 0) return; switch (e.key) { case "ArrowDown": e.preventDefault(); setSelectedIndex((i) => (i + 1) % list.length); break; case "ArrowUp": e.preventDefault(); setSelectedIndex((i) => (i - 1 + list.length) % list.length); break; case "Tab": case "Enter": e.preventDefault(); e.stopPropagation(); selectItem(list[selectedIndex()]); break; case "Escape": e.preventDefault(); ctx.setValue(""); setOpen(false); break; } } // Attach keyboard listener to textarea createEffect(() => { const textarea = ctx.textareaRef; if (!textarea) return; textarea.addEventListener("keydown", handleKeyDown, true); onCleanup(() => textarea.removeEventListener("keydown", handleKeyDown, true)); }); let flatIndex = 0; return ( 0}>
{(() => { flatIndex = 0; return null; })()} {([category, items]) => ( <>
{category}
{(item) => { const idx = flatIndex++; const isActive = () => props.activeIds?.includes(item.id) ?? false; return (
}> {item.label} {item.description} ); }} )}
); } export { SlashCommand };