import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, ReactNode, RefObject } from 'react'; import { createPortal } from 'react-dom'; import { ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface SlashCommandItem { name: string; // One-liner shown always; also the fallback body when the row is expanded. description?: string; // Optional fuller explanation revealed when the row's chevron is expanded. // When absent, the expanded panel falls back to `description` (un-truncated) // — we never fabricate detail for commands that don't carry it (e.g. raw // agent `/help` passthrough rows). details?: string; } export interface SlashCommandGroup { label: string; items: SlashCommandItem[]; icon?: ReactNode; } interface Props { query: string; items: SlashCommandItem[]; // Optional segmented rendering. When provided, items are shown under labeled // group headers (in order). `items` is ignored. BooChat passes only `items` // (flat) so its menu is unchanged — grouping is opt-in. groups?: SlashCommandGroup[]; inputRef: RefObject; onSelect: (name: string) => void; onClose: () => void; emptyLabel?: string; } const DROPDOWN_HEIGHT_BUDGET = 320; function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandItem[] { const q = query.toLowerCase(); const filtered = q ? items.filter((s) => s.name.toLowerCase().startsWith(q)) : items; return [...filtered].sort((a, b) => a.name.localeCompare(b.name)); } export function SlashCommandPicker({ query, items, groups, inputRef, onSelect, onClose, emptyLabel = 'No commands available', }: Props) { const [highlightIndex, setHighlightIndex] = useState(0); // At most one row's detail panel is open at a time; null = all collapsed. // Indexed against the flat `filtered` list (same index space as highlight). const [expandedIndex, setExpandedIndex] = useState(null); const popoverRef = useRef(null); // When grouped, filter each group and drop empties; otherwise the flat list. const filteredGroups = useMemo( () => groups ? groups .map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) })) .filter((g) => g.items.length > 0) : null, [groups, query], ); // Flat list drives keyboard nav + Enter selection across all groups. const filtered = useMemo( () => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)), [filteredGroups, items, query], ); const [rect, setRect] = useState( () => inputRef.current?.getBoundingClientRect() ?? null, ); const [vvTick, setVvTick] = useState(0); // Filtering reshuffles indices, so any open detail panel is stale — collapse it. useEffect(() => { setHighlightIndex(0); setExpandedIndex(null); }, [query]); useEffect(() => { function recalc() { setRect(inputRef.current?.getBoundingClientRect() ?? null); setVvTick((t) => t + 1); } recalc(); const vv = window.visualViewport; vv?.addEventListener('resize', recalc); vv?.addEventListener('scroll', recalc); window.addEventListener('resize', recalc); return () => { vv?.removeEventListener('resize', recalc); vv?.removeEventListener('scroll', recalc); window.removeEventListener('resize', recalc); }; }, [inputRef]); 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 === 'ArrowRight') { // Expand the highlighted row in place (separate affordance from select). // Only acts when the row has something to reveal, so caret-right still // works on non-expandable rows; Enter/Tab selection is untouched. const target = filtered[highlightIndex]; if (!target || !(target.details ?? target.description)) return; e.preventDefault(); setExpandedIndex(highlightIndex); } else if (e.key === 'ArrowLeft') { // Collapse the highlighted row if it's the open one; otherwise let the // caret move as usual. if (expandedIndex !== highlightIndex) return; e.preventDefault(); setExpandedIndex(null); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [filtered, highlightIndex, expandedIndex, 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]); useEffect(() => { if (expandedIndex == null) return; const el = popoverRef.current?.querySelector('[data-expanded="true"]'); if (el) el.scrollIntoView({ block: 'nearest' }); }, [expandedIndex]); const style = useMemo(() => { if (!rect) return { display: 'none' }; const vv = window.visualViewport; const vvOffsetTop = vv?.offsetTop ?? 0; const vvHeight = vv?.height ?? window.innerHeight; // Visible region in layout-viewport coords (what position:fixed uses) const visibleTop = vvOffsetTop; const visibleBottom = vvOffsetTop + vvHeight; const spaceAbove = rect.top - visibleTop; const spaceBelow = visibleBottom - rect.bottom; if (spaceAbove >= Math.min(DROPDOWN_HEIGHT_BUDGET, spaceBelow)) { // Place above: clamp to visible top const popupTop = Math.max(visibleTop, rect.top - DROPDOWN_HEIGHT_BUDGET); return { position: 'fixed', top: popupTop, left: rect.left, maxHeight: rect.top - popupTop, }; } // Place below: clamp to visible bottom return { position: 'fixed', top: rect.bottom, left: rect.left, maxHeight: Math.min(DROPDOWN_HEIGHT_BUDGET, visibleBottom - rect.bottom), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rect, vvTick]); const renderItem = (item: SlashCommandItem, i: number) => { const expanded = i === expandedIndex; // Fuller text when provided, else the one-liner shown un-truncated. Never // invented — a row with neither shows an honest placeholder. const detailText = item.details ?? item.description ?? null; return (
setHighlightIndex(i)} onClick={() => onSelect(item.name)} >
/{item.name}
{item.description && (
{item.description}
)}
{/* Chevron is a SEPARATE affordance: stopPropagation keeps the row's click (select) from firing; preventDefault on mousedown keeps focus on the composer so typing/arrow-nav continues uninterrupted. */}
{/* grid-rows 0fr→1fr animates height with no fixed value; motion-reduce disables the transition for users who ask for less motion. */}
{detailText ?? No description provided.}
); }; let runningIndex = -1; const popover = filtered.length === 0 ? (
{query ? `No command starts with "/${query}"` : emptyLabel}
) : (
{filteredGroups ? filteredGroups.map((g) => (
{g.icon} {g.label}
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
)) : filtered.map((item, i) => renderItem(item, i))}
); return createPortal(popover, document.body); }