import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode, } from 'react'; import { useCommands } from './registry.js'; import { scoreCommand } from './fuzzy.js'; import type { Command, CommandMode } from './types.js'; export interface CommandPaletteProps { open: boolean; onClose: () => void; /** Initial mode tab. Default: 'actions'. */ initialMode?: CommandMode; /** Hide the mode tabs (only render the active mode). */ hideModeTabs?: boolean; /** Render a custom Ask-AI panel (replaces the default suggestion). */ askAiPanel?: (query: string) => ReactNode; /** Called when the user picks a navigate-mode command. */ onNavigate?: (href: string) => void; /** Force-merge external commands without registering them. */ extraCommands?: Command[]; /** localStorage key for recents. Default: 'bl-cmdk-recents'. */ recentsKey?: string; /** How many recents to remember. Default: 8. */ recentsLimit?: number; /** ARIA label for the dialog. */ ariaLabel?: string; className?: string; style?: CSSProperties; } /** * `` — Cmd-K dialog with three modes: * - **actions** — invoke a registered command (calls its `run`) * - **navigate** — jump to a registered route (uses `onNavigate` or * `window.location.assign` as fallback) * - **ask-ai** — delegate the query to an AI panel (host-supplied) * * Commands are sourced from `useCommands()` (provider context) plus any * `extraCommands` prop. Recents persist to `localStorage` under * `recentsKey` and float to the top of the actions list when the query * is empty. * * Keyboard: * ↑ ↓ — move selection * Enter — run selection * Tab — cycle mode tabs * Esc — close */ export function CommandPalette({ open, onClose, initialMode = 'actions', hideModeTabs = false, askAiPanel, onNavigate, extraCommands = [], recentsKey = 'bl-cmdk-recents', recentsLimit = 8, ariaLabel = 'Command palette', className, style, }: CommandPaletteProps) { const registryCommands = useCommands(); const allCommands = useMemo( () => [...registryCommands, ...extraCommands], [registryCommands, extraCommands], ); const [mode, setMode] = useState(initialMode); const [query, setQuery] = useState(''); const [selected, setSelected] = useState(0); const inputRef = useRef(null); // Reset state every time the palette opens. useEffect(() => { if (open) { setQuery(''); setSelected(0); setMode(initialMode); // Defer focus until after the dialog mounts. queueMicrotask(() => inputRef.current?.focus()); } }, [open, initialMode]); // Esc to close — handled at the document level so clicks outside the // dialog don't need to bubble. useEffect(() => { if (!open) return; const handler = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [open, onClose]); // ── Filter + score ────────────────────────────────────────────────────── const recents = useRecents(recentsKey, recentsLimit); const filtered = useMemo(() => { const bucketed = allCommands.filter(c => { if (c.requires && !c.requires()) return false; // ask-ai bucket is virtual — no commands live in it. if (mode === 'navigate') return c.mode === 'navigate'; if (mode === 'actions') return c.mode !== 'navigate'; return false; }); if (!query) { // No query: float recents to the top in actions mode. if (mode === 'actions' && recents.length) { const recentSet = new Set(recents); const recentItems = bucketed.filter(c => recentSet.has(c.id)); const rest = bucketed.filter(c => !recentSet.has(c.id)); return recentItems.concat(rest).map(command => ({ command, score: 0 })); } return bucketed.map(command => ({ command, score: 0 })); } return bucketed .map(command => ({ command, score: scoreCommand(command, query) })) .filter((row): row is { command: Command; score: number } => row.score !== null) .sort((a, b) => b.score - a.score); }, [allCommands, mode, query, recents]); useEffect(() => { if (selected >= filtered.length) setSelected(0); }, [filtered.length, selected]); // ── Activation ────────────────────────────────────────────────────────── const activate = (cmd: Command) => { if (cmd.enabled === false) return; recents.push(cmd.id); onClose(); if (cmd.run) { void cmd.run(); return; } if (cmd.href) { if (onNavigate) onNavigate(cmd.href); else if (typeof window !== 'undefined') window.location.assign(cmd.href); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, Math.max(filtered.length - 1, 0))); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const row = filtered[selected]; if (row) activate(row.command); } else if (e.key === 'Tab') { e.preventDefault(); setMode(m => nextMode(m, e.shiftKey ? -1 : 1)); } }; if (!open) return null; return (
{ // Close on backdrop click — but never when the inner panel is clicked. if (e.target === e.currentTarget) onClose(); }} >
{/* Search input */}
{ setQuery(e.target.value); setSelected(0); }} placeholder={ mode === 'navigate' ? 'Jump to…' : mode === 'ask-ai' ? 'Ask AI anything…' : 'Type a command, or search…' } aria-label="Search commands" style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'inherit', fontSize: '1rem', }} /> {!hideModeTabs && }
{/* Body */}
{mode === 'ask-ai' ? ( askAiPanel ? ( askAiPanel(query) ) : ( ) ) : filtered.length === 0 ? ( ) : (
    {filtered.map((row, i) => ( activate(row.command)} onHover={() => setSelected(i)} /> ))}
)}
); } // ─── Sub-components ────────────────────────────────────────────────────── function ModeTabs({ mode, onChange, }: { mode: CommandMode; onChange: (m: CommandMode) => void; }) { const tabs: Array<{ id: CommandMode; label: string }> = [ { id: 'actions', label: 'Actions' }, { id: 'navigate', label: 'Navigate' }, { id: 'ask-ai', label: 'Ask AI' }, ]; return (
{tabs.map(t => { const active = t.id === mode; return ( ); })}
); } function CommandRow({ command, selected, onSelect, onHover, }: { command: Command; selected: boolean; onSelect: () => void; onHover: () => void; }) { const disabled = command.enabled === false; return (
  • { if (!disabled) onSelect(); }} style={{ display: 'flex', alignItems: 'center', gap: 'var(--bl-space-3, 12px)', padding: 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)', background: selected ? 'var(--bl-accent-muted, rgba(99,102,241,0.12))' : 'transparent', borderRadius: 'var(--bl-radius-control, 6px)', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1, }} > {command.icon && ( {command.icon} )}
    {command.label}
    {command.description && (
    {command.description}
    )}
    {command.shortcut && ( {command.shortcut.map(k => ( {k} ))} )}
  • ); } function EmptyState({ query, mode }: { query: string; mode: CommandMode }) { return (
    {query ? `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} match "${query}".` : `No ${mode === 'navigate' ? 'navigation targets' : 'commands'} registered.`}
    ); } function DefaultAskAiPanel({ query }: { query: string }) { return (
    {query ? `Ask AI: "${query}"` : 'Type a question to ask AI'}
    Provide an askAiPanel prop to wire this to{' '} @bytelyst/ai-ui.
    ); } function Footer({ mode, hits }: { mode: CommandMode; hits: number }) { return (
    ↑↓ navigate activate Tab switch mode Esc close {mode !== 'ask-ai' && ( {hits} result{hits === 1 ? '' : 's'} )}
    ); } const kbdStyle: CSSProperties = { fontFamily: 'inherit', fontSize: '0.7rem', padding: '0 4px', background: 'var(--bl-surface-muted, rgba(0,0,0,0.06))', border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.08))', borderRadius: 3, }; function nextMode(current: CommandMode, direction: 1 | -1): CommandMode { const order: CommandMode[] = ['actions', 'navigate', 'ask-ai']; const idx = order.indexOf(current); const next = (idx + direction + order.length) % order.length; return order[next]!; } // ─── localStorage-backed recents ───────────────────────────────────────── function useRecents(key: string, limit: number) { const [recents, setRecents] = useState(() => { if (typeof window === 'undefined') return []; try { const raw = window.localStorage.getItem(key); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.slice(0, limit) : []; } catch { return []; } }); return useMemo( () => ({ get list() { return recents; }, // Iterable convenience. [Symbol.iterator]: () => recents[Symbol.iterator](), get length() { return recents.length; }, includes: (id: string) => recents.includes(id), filter: (predicate: (id: string) => boolean) => recents.filter(predicate), push: (id: string) => { setRecents(prev => { const next = [id, ...prev.filter(x => x !== id)].slice(0, limit); try { window.localStorage.setItem(key, JSON.stringify(next)); } catch { /* quota / private mode — best-effort */ } return next; }); }, }), [recents, key, limit], ); }