import React, { useState, useEffect, useRef } from 'react'; import { Sparkles, Zap, Plus } from 'lucide-react'; import { Badge } from './ui/badge'; import type { PromptInfo as CorePromptInfo } from '@dexto/core'; import { usePrompts } from './hooks/usePrompts'; // Use canonical types from @dexto/core for alignment type PromptInfo = CorePromptInfo; // PromptItem component for rendering individual prompts const PromptItem = ({ prompt, isSelected, onClick, onMouseEnter, dataIndex, }: { prompt: Prompt; isSelected: boolean; onClick: () => void; onMouseEnter?: () => void; dataIndex?: number; }) => (
{prompt.source === 'mcp' ? ( ) : prompt.source === 'config' ? ( 📋 ) : ( )}
{/* Command name with inline arguments */}
{/* Use commandName (collision-resolved) for display, fall back to displayName/name */} /{prompt.commandName || prompt.displayName || prompt.name} {prompt.arguments && prompt.arguments.length > 0 && ( {prompt.arguments.map((arg) => ( <{arg.name} {arg.required ? '' : '?'}> {/* Tooltip on hover */} {arg.description && ( {arg.description} )} ))} )}
{/* Source badges */} {prompt.source === 'mcp' && ( MCP )} {prompt.source === 'config' && ( Config )} {prompt.source === 'custom' && ( Custom )}
{/* Show title if available */} {prompt.title && (
{prompt.title}
)} {/* Show description if available and different from title */} {prompt.description && prompt.description !== prompt.title && (
{prompt.description}
)}
); // Define UI-specific Prompt interface extending core PromptInfo interface Prompt extends PromptInfo { // UI-specific fields that may come from metadata starterPrompt?: boolean; category?: string; icon?: string; priority?: number; } interface SlashCommandAutocompleteProps { isVisible: boolean; searchQuery: string; onSelectPrompt: (prompt: Prompt) => void; onClose: () => void; onCreatePrompt?: () => void; refreshKey?: number; } export default function SlashCommandAutocomplete({ isVisible, searchQuery, onSelectPrompt, onClose, onCreatePrompt, refreshKey, }: SlashCommandAutocompleteProps) { const [selectedIndex, setSelectedIndex] = useState(0); const selectedIndexRef = useRef(0); const containerRef = useRef(null); const scrollContainerRef = useRef(null); const lastRefreshKeyRef = useRef(0); // Fetch prompts using TanStack Query const { data: prompts = [], isLoading, refetch } = usePrompts({ enabled: isVisible }); // Keep the latest selected index accessible in callbacks without needing extra effect deps selectedIndexRef.current = selectedIndex; // Refetch when refreshKey changes useEffect(() => { if (!isVisible) return; const effectiveKey = refreshKey ?? 0; if (effectiveKey > 0 && effectiveKey !== lastRefreshKeyRef.current) { refetch(); lastRefreshKeyRef.current = effectiveKey; } }, [isVisible, refreshKey, refetch]); // Filter prompts based on search query - memoized to avoid infinite loops const filteredPrompts = React.useMemo(() => { if (!searchQuery.trim() || searchQuery === '/') { return prompts; } // Extract just the command name (first word after /) for filtering // E.g., "/summarize technical 100 'text'" -> "summarize" const withoutSlash = searchQuery.startsWith('/') ? searchQuery.slice(1) : searchQuery; const commandName = withoutSlash.split(/\s+/)[0] || ''; return prompts.filter( (prompt) => prompt.name.toLowerCase().includes(commandName.toLowerCase()) || (prompt.description && prompt.description.toLowerCase().includes(commandName.toLowerCase())) || (prompt.title && prompt.title.toLowerCase().includes(commandName.toLowerCase())) ); }, [searchQuery, prompts]); const showCreateOption = React.useMemo(() => { const trimmed = searchQuery.trim(); if (!trimmed) return false; if (trimmed === '/') return true; if (trimmed.startsWith('/') && filteredPrompts.length === 0) return true; return false; }, [searchQuery, filteredPrompts.length]); const combinedItems = React.useMemo(() => { const items: Array<{ kind: 'create' } | { kind: 'prompt'; prompt: Prompt }> = []; if (showCreateOption) { items.push({ kind: 'create' }); } filteredPrompts.forEach((prompt) => items.push({ kind: 'prompt', prompt })); return items; }, [showCreateOption, filteredPrompts]); // Note: mcp:prompts-list-changed DOM listener removed (was dead code - never dispatched as DOM event) // Prompts are refreshed via React Query's built-in mechanisms when needed // Reset selected index when filtered results change useEffect(() => { const shouldShowCreate = searchQuery === '/'; const defaultIndex = shouldShowCreate && filteredPrompts.length > 0 ? 1 : 0; setSelectedIndex(defaultIndex); }, [searchQuery, filteredPrompts.length]); const itemsLength = combinedItems.length; useEffect(() => { setSelectedIndex((prevIndex) => { if (itemsLength === 0) { return 0; } if (prevIndex >= itemsLength) { return itemsLength - 1; } return prevIndex; }); }, [itemsLength]); // Handle keyboard navigation useEffect(() => { if (!isVisible) return; const handleKeyDown = (e: KeyboardEvent) => { const items = combinedItems; const stop = () => { e.preventDefault(); e.stopPropagation(); // Some environments support stopImmediatePropagation on DOM events if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }; // Check if user has typed arguments after the command name // E.g., "/summarize technical 100 'text'" -> has arguments, so Enter should submit const withoutSlash = searchQuery.startsWith('/') ? searchQuery.slice(1) : searchQuery; const parts = withoutSlash.split(/\s+/); const hasArguments = parts.length > 1 && parts.slice(1).some((p) => p.trim().length > 0); switch (e.key) { case 'ArrowDown': if (items.length === 0) return; stop(); setSelectedIndex((prev) => (prev + 1) % items.length); break; case 'ArrowUp': if (items.length === 0) return; stop(); setSelectedIndex((prev) => (prev - 1 + items.length) % items.length); break; case 'Enter': // If user has typed arguments, let Enter pass through to submit the message if (hasArguments) { return; // Don't intercept - let InputArea handle submission } stop(); if (items.length === 0) { onCreatePrompt?.(); return; } { const item = items[selectedIndexRef.current]; if (item.kind === 'create') { onCreatePrompt?.(); } else { onSelectPrompt(item.prompt); } } break; case 'Escape': stop(); onClose(); break; case 'Tab': stop(); if (items.length === 0) { onCreatePrompt?.(); return; } { const item = items[selectedIndexRef.current]; if (item.kind === 'create') { onCreatePrompt?.(); } else { onSelectPrompt(item.prompt); } } break; } }; // Use capture phase so we can intercept Enter before input handlers stop propagation document.addEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener('keydown', handleKeyDown, true); }, [isVisible, combinedItems, onSelectPrompt, onClose, onCreatePrompt, searchQuery]); // Scroll selected item into view when selectedIndex changes useEffect(() => { if (!scrollContainerRef.current) return; const scrollContainer = scrollContainerRef.current; const selectedItem = scrollContainer.querySelector( `[data-index="${selectedIndex}"]` ) as HTMLElement; if (selectedItem) { const containerRect = scrollContainer.getBoundingClientRect(); const itemRect = selectedItem.getBoundingClientRect(); // Check if item is visible in container const isAbove = itemRect.top < containerRect.top; const isBelow = itemRect.bottom > containerRect.bottom; if (isAbove || isBelow) { selectedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest', }); } } }, [selectedIndex]); // Close on click outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { onClose(); } }; if (isVisible) { document.addEventListener('mousedown', handleClickOutside); } return () => document.removeEventListener('mousedown', handleClickOutside); }, [isVisible, onClose]); if (!isVisible) return null; return (
{/* Header - Compact with prompt count */}
Available Prompts (hover over arguments for more info) {prompts.length}
{/* Prompts List */}
{isLoading ? (
Loading prompts...
) : ( <> {showCreateOption && (
onCreatePrompt?.()} onMouseEnter={() => setSelectedIndex(0)} data-index={0} >
Create new prompt
Define a reusable prompt. Press Enter to continue.
)} {filteredPrompts.length === 0 ? !showCreateOption && (
No prompts available.
) : filteredPrompts.map((prompt, index) => { const itemIndex = showCreateOption ? index + 1 : index; return ( onSelectPrompt(prompt)} onMouseEnter={() => setSelectedIndex(itemIndex)} dataIndex={itemIndex} /> ); })} )}
{/* Footer - Compact with navigation hints */}
↑↓ Navigate • Tab/Enter Select • Esc Close
); }