import React from 'react'; import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { opencodeClient } from '@/lib/opencode/client'; import { useSessionStore } from '@/stores/useSessionStore'; import { useShallow } from 'zustand/react/shallow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface CommandInfo { name: string; description?: string; agent?: string; model?: string; isBuiltIn?: boolean; } export interface CommandAutocompleteHandle { handleKeyDown: (key: string) => void; } interface CommandAutocompleteProps { searchQuery: string; onCommandSelect: (command: CommandInfo) => void; onClose: () => void; } export const CommandAutocomplete = React.forwardRef(({ searchQuery, onCommandSelect, onClose }, ref) => { const { hasMessagesInCurrentSession } = useSessionStore( useShallow((state) => { const sessionId = state.currentSessionId; const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0; return { hasMessagesInCurrentSession: messageCount > 0, }; }) ); const [commands, setCommands] = React.useState([]); const [loading, setLoading] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const containerRef = React.useRef(null); 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(() => { const loadCommands = async () => { setLoading(true); try { const apiCommands = await opencodeClient.listCommands(); const customCommands: CommandInfo[] = apiCommands.map(cmd => ({ name: cmd.name, description: cmd.description, agent: cmd.agent, model: cmd.model, isBuiltIn: false })); const builtInCommands: CommandInfo[] = [ ...(hasMessagesInCurrentSession ? [] : [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]), { name: 'summarize', description: 'Generate a summary of the current session', 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 => cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) || (cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase())) ) : 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[] = [ ...(hasMessagesInCurrentSession ? [] : [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]), { name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true }, ]; const filtered = (searchQuery ? builtInCommands.filter(cmd => cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) || (cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase())) ) : builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init'); setCommands(filtered); } finally { setLoading(false); } }; loadCommands(); }, [searchQuery, hasMessagesInCurrentSession]); 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 'summarize': return ; case 'test': case 'build': case 'run': return ; default: if (command.isBuiltIn) { return ; } return ; } }; return (
{loading ? (
) : (
{commands.map((command, index) => (
{ itemRefs.current[index] = el; }} className={cn( "flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg", index === selectedIndex && "bg-accent" )} onClick={() => onCommandSelect(command)} onMouseEnter={() => setSelectedIndex(index)} >
{getCommandIcon(command)}
/{command.name} {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 };