import React from 'react'; import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useSessionStore } from '@/stores/useSessionStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useShallow } from 'zustand/react/shallow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface CommandInfo { name: string; description?: string; agent?: string; model?: string; isBuiltIn?: boolean; isSkill?: boolean; scope?: string; } export interface CommandAutocompleteHandle { handleKeyDown: (key: string) => void; } type AutocompleteTab = 'commands' | 'agents' | 'files'; interface CommandAutocompleteProps { searchQuery: string; onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void; onClose: () => void; showTabs?: boolean; activeTab?: AutocompleteTab; onTabSelect?: (tab: AutocompleteTab) => void; style?: React.CSSProperties; } export const CommandAutocomplete = React.forwardRef(({ searchQuery, onCommandSelect, onClose, showTabs, activeTab = 'commands', onTabSelect, style, }, ref) => { const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore( useShallow((state) => { const sessionId = state.currentSessionId; const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0; return { hasMessagesInCurrentSession: messageCount > 0, currentSessionId: sessionId, }; }) ); const hasSession = Boolean(currentSessionId); const [commands, setCommands] = React.useState([]); const [loading, setLoading] = React.useState(false); const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore(); const { skills, loadSkills: refreshSkills } = useSkillsStore(); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const containerRef = React.useRef(null); const ignoreClickRef = React.useRef(false); const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null); const pointerMovedRef = React.useRef(false); const ignoreTabClickRef = React.useRef(false); React.useEffect(() => { const handlePointerDown = (event: MouseEvent | TouchEvent) => { const target = event.target as Node | null; if (!target || !containerRef.current) { return; } if (containerRef.current.contains(target)) { return; } onClose(); }; document.addEventListener('pointerdown', handlePointerDown, true); return () => { document.removeEventListener('pointerdown', handlePointerDown, true); }; }, [onClose]); React.useEffect(() => { // Force refresh to get latest project context when mounting void refreshCommands(); void refreshSkills(); }, [refreshCommands, refreshSkills]); React.useEffect(() => { const loadCommands = async () => { setLoading(true); try { const skillNames = new Set(skills.map((skill) => skill.name)); const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({ name: cmd.name, description: cmd.description, agent: cmd.agent ?? undefined, model: cmd.model ?? undefined, isBuiltIn: cmd.name === 'init' || cmd.name === 'review', isSkill: skillNames.has(cmd.name), scope: cmd.scope, })); const builtInCommands: CommandInfo[] = [ ...(hasSession && !hasMessagesInCurrentSession ? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }] : [] ), ...(hasSession // Show when session exists, not when hasMessages ? [ { name: 'undo', description: 'Undo the last message', isBuiltIn: true }, { name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true }, { name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true }, ] : [] ), { name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true }, ]; const commandMap = new Map(); builtInCommands.forEach(cmd => commandMap.set(cmd.name, cmd)); customCommands.forEach(cmd => commandMap.set(cmd.name, cmd)); const allCommands = Array.from(commandMap.values()); const allowInitCommand = !hasMessagesInCurrentSession; const filtered = (searchQuery ? allCommands.filter(cmd => fuzzyMatch(cmd.name, searchQuery) || (cmd.description && fuzzyMatch(cmd.description, searchQuery)) ) : allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init'); filtered.sort((a, b) => { const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase()); const bStartsWith = b.name.toLowerCase().startsWith(searchQuery.toLowerCase()); if (aStartsWith && !bStartsWith) return -1; if (!aStartsWith && bStartsWith) return 1; return a.name.localeCompare(b.name); }); setCommands(filtered); } catch { const allowInitCommand = !hasMessagesInCurrentSession; const builtInCommands: CommandInfo[] = [ ...(hasSession && !hasMessagesInCurrentSession ? [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }] : [] ), ...(hasSession // Show when session exists, not when hasMessages ? [ { name: 'undo', description: 'Undo the last message', isBuiltIn: true }, { name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true }, { name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true }, ] : [] ), { name: 'compact', description: 'Compress session history using AI to reduce context size', isBuiltIn: true }, ]; const filtered = (searchQuery ? builtInCommands.filter(cmd => fuzzyMatch(cmd.name, searchQuery) || (cmd.description && fuzzyMatch(cmd.description, searchQuery)) ) : builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init'); setCommands(filtered); } finally { setLoading(false); } }; loadCommands(); }, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills]); React.useEffect(() => { setSelectedIndex(0); }, [commands]); React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [selectedIndex]); React.useImperativeHandle(ref, () => ({ handleKeyDown: (key: string) => { const total = commands.length; if (key === 'Escape') { onClose(); return; } if (total === 0) { return; } if (key === 'ArrowDown') { setSelectedIndex((prev) => (prev + 1) % total); return; } if (key === 'ArrowUp') { setSelectedIndex((prev) => (prev - 1 + total) % total); return; } if (key === 'Enter' || key === 'Tab') { const safeIndex = ((selectedIndex % total) + total) % total; const command = commands[safeIndex]; if (command) { onCommandSelect(command); } } } }), [commands, selectedIndex, onClose, onCommandSelect]); const getCommandIcon = (command: CommandInfo) => { switch (command.name) { case 'init': return ; case 'undo': return ; case 'redo': return ; case 'timeline': return ; case 'compact': return ; case 'test': case 'build': case 'run': return ; default: if (command.isBuiltIn) { return ; } return ; } }; return (
{showTabs ? (
{([ { id: 'commands' as const, label: 'Commands' }, { id: 'agents' as const, label: 'Agents' }, { id: 'files' as const, label: 'Files' }, ]).map((tab) => ( ))}
) : null} {loading ? (
) : (
{commands.map((command, index) => { const isSystem = command.isBuiltIn; const isProject = command.scope === 'project'; return (
{ itemRefs.current[index] = el; }} className={cn( "flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg", index === selectedIndex && "bg-interactive-selection" )} onPointerDown={(event) => { if (event.pointerType !== 'touch') { return; } pointerStartRef.current = { x: event.clientX, y: event.clientY }; pointerMovedRef.current = false; }} onPointerMove={(event) => { if (event.pointerType !== 'touch' || !pointerStartRef.current) { return; } const dx = event.clientX - pointerStartRef.current.x; const dy = event.clientY - pointerStartRef.current.y; if (Math.hypot(dx, dy) > 6) { pointerMovedRef.current = true; } }} onPointerUp={(event) => { if (event.pointerType !== 'touch') { return; } const didMove = pointerMovedRef.current; pointerStartRef.current = null; pointerMovedRef.current = false; if (didMove) { return; } event.preventDefault(); event.stopPropagation(); ignoreClickRef.current = true; onCommandSelect(command, { dismissKeyboard: true }); }} onPointerCancel={() => { pointerStartRef.current = null; pointerMovedRef.current = false; }} onClick={() => { if (ignoreClickRef.current) { ignoreClickRef.current = false; return; } onCommandSelect(command); }} onMouseEnter={() => setSelectedIndex(index)} >
{getCommandIcon(command)}
/{command.name} {command.isSkill ? ( skill ) : null} {isSystem ? ( system ) : command.scope ? ( {command.scope} ) : null} {command.agent && ( {command.agent} )}
{command.description && (
{command.description}
)}
); })} {commands.length === 0 && (
No commands found
)}
)}
↑↓ navigate • Enter select • Esc close
); }); CommandAutocomplete.displayName = 'CommandAutocomplete'; export type { CommandInfo };