import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { Conversation } from "../types"; import { api } from "../services/api"; interface CommandItem { id: string; type: "action" | "conversation"; title: string; subtitle?: string; icon?: React.ReactNode; action: () => void; keywords?: string[]; // Additional keywords for search } interface CommandPaletteProps { isOpen: boolean; onClose: () => void; conversations: Conversation[]; onNewConversation: () => void; onSelectConversation: (conversation: Conversation) => void; onOpenDiffViewer: () => void; onOpenModelsModal: () => void; hasCwd: boolean; } // Simple fuzzy match for actions - returns score (higher is better), -1 if no match function fuzzyMatch(query: string, text: string): number { const lowerQuery = query.toLowerCase(); const lowerText = text.toLowerCase(); // Exact match gets highest score if (lowerText === lowerQuery) return 1000; // Starts with gets high score if (lowerText.startsWith(lowerQuery)) return 500 + (lowerQuery.length / lowerText.length) * 100; // Contains gets medium score if (lowerText.includes(lowerQuery)) return 100 + (lowerQuery.length / lowerText.length) * 50; // Fuzzy match - all query chars must appear in order let queryIdx = 0; let score = 0; let consecutiveBonus = 0; for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) { if (lowerText[i] === lowerQuery[queryIdx]) { score += 1 + consecutiveBonus; consecutiveBonus += 0.5; queryIdx++; } else { consecutiveBonus = 0; } } // All query chars must be found if (queryIdx !== lowerQuery.length) return -1; return score; } function CommandPalette({ isOpen, onClose, conversations, onNewConversation, onSelectConversation, onOpenDiffViewer, onOpenModelsModal, hasCwd, }: CommandPaletteProps) { const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); const listRef = useRef(null); const searchTimeoutRef = useRef(null); // Search conversations on the server const searchConversations = useCallback(async (searchQuery: string) => { if (!searchQuery.trim()) { setSearchResults([]); setIsSearching(false); return; } setIsSearching(true); try { const results = await api.searchConversations(searchQuery); setSearchResults(results); } catch (err) { console.error("Failed to search conversations:", err); setSearchResults([]); } finally { setIsSearching(false); } }, []); // Debounced search when query changes useEffect(() => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } if (query.trim()) { searchTimeoutRef.current = window.setTimeout(() => { searchConversations(query); }, 150); // 150ms debounce } else { setSearchResults([]); setIsSearching(false); } return () => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } }; }, [query, searchConversations]); // Build action items (these are always available) const actionItems: CommandItem[] = useMemo(() => { const items: CommandItem[] = []; items.push({ id: "new-conversation", type: "action", title: "New Conversation", subtitle: "Start a new conversation", icon: ( ), action: () => { onNewConversation(); onClose(); }, keywords: ["new", "create", "start", "conversation", "chat"], }); if (hasCwd) { items.push({ id: "open-diffs", type: "action", title: "View Diffs", subtitle: "Open the git diff viewer", icon: ( ), action: () => { onOpenDiffViewer(); onClose(); }, keywords: ["diff", "git", "changes", "view", "compare"], }); } items.push({ id: "manage-models", type: "action", title: "Add/Remove Models/Keys", subtitle: "Configure custom AI models and API keys", icon: ( ), action: () => { onOpenModelsModal(); onClose(); }, keywords: [ "model", "key", "api", "configure", "settings", "anthropic", "openai", "gemini", "custom", ], }); return items; }, [onNewConversation, onOpenDiffViewer, onOpenModelsModal, onClose, hasCwd]); // Convert conversations to command items const conversationToItem = useCallback( (conv: Conversation): CommandItem => ({ id: `conv-${conv.conversation_id}`, type: "conversation", title: conv.slug || conv.conversation_id, subtitle: conv.cwd || undefined, icon: ( ), action: () => { onSelectConversation(conv); onClose(); }, }), [onSelectConversation, onClose], ); // Compute the final list of items to display const displayItems = useMemo(() => { const trimmedQuery = query.trim(); // Filter actions based on query (client-side fuzzy match) let filteredActions = actionItems; if (trimmedQuery) { filteredActions = actionItems.filter((item) => { let maxScore = fuzzyMatch(trimmedQuery, item.title); if (item.subtitle) { const subtitleScore = fuzzyMatch(trimmedQuery, item.subtitle); if (subtitleScore > maxScore) maxScore = subtitleScore * 0.8; } if (item.keywords) { for (const keyword of item.keywords) { const keywordScore = fuzzyMatch(trimmedQuery, keyword); if (keywordScore > maxScore) maxScore = keywordScore * 0.7; } } return maxScore > 0; }); } // Use search results if we have a query, otherwise use initial conversations const conversationsToShow = trimmedQuery ? searchResults : conversations; const conversationItems = conversationsToShow.map(conversationToItem); return [...filteredActions, ...conversationItems]; }, [query, actionItems, searchResults, conversations, conversationToItem]); // Reset selection when items change useEffect(() => { setSelectedIndex(0); }, [displayItems]); // Focus input when opened useEffect(() => { if (isOpen) { setQuery(""); setSelectedIndex(0); setSearchResults([]); setTimeout(() => inputRef.current?.focus(), 0); } }, [isOpen]); // Scroll selected item into view useEffect(() => { if (!listRef.current) return; const selectedElement = listRef.current.querySelector(`[data-index="${selectedIndex}"]`); selectedElement?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, displayItems.length - 1)); break; case "ArrowUp": e.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); break; case "Enter": e.preventDefault(); if (displayItems[selectedIndex]) { displayItems[selectedIndex].action(); } break; case "Escape": e.preventDefault(); onClose(); break; } }; if (!isOpen) return null; return (
e.stopPropagation()}>
setQuery(e.target.value)} onKeyDown={handleKeyDown} /> {isSearching &&
}
esc
{displayItems.length === 0 ? (
{isSearching ? "Searching..." : "No results found"}
) : ( displayItems.map((item, index) => (
item.action()} onMouseEnter={() => setSelectedIndex(index)} >
{item.icon}
{item.title}
{item.subtitle && (
{item.subtitle}
)}
{item.type === "action" &&
Action
}
)) )}
to navigate to select esc to close
); } export default CommandPalette;