1import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
2import { Conversation } from "../types";
3import { api } from "../services/api";
4
5interface CommandItem {
6 id: string;
7 type: "action" | "conversation";
8 title: string;
9 subtitle?: string;
10 icon?: React.ReactNode;
11 action: () => void;
12 keywords?: string[]; // Additional keywords for search
13}
14
15interface CommandPaletteProps {
16 isOpen: boolean;
17 onClose: () => void;
18 conversations: Conversation[];
19 onNewConversation: () => void;
20 onSelectConversation: (conversation: Conversation) => void;
21 onOpenDiffViewer: () => void;
22 onOpenModelsModal: () => void;
23 hasCwd: boolean;
24}
25
26// Simple fuzzy match for actions - returns score (higher is better), -1 if no match
27function fuzzyMatch(query: string, text: string): number {
28 const lowerQuery = query.toLowerCase();
29 const lowerText = text.toLowerCase();
30
31 // Exact match gets highest score
32 if (lowerText === lowerQuery) return 1000;
33
34 // Starts with gets high score
35 if (lowerText.startsWith(lowerQuery)) return 500 + (lowerQuery.length / lowerText.length) * 100;
36
37 // Contains gets medium score
38 if (lowerText.includes(lowerQuery)) return 100 + (lowerQuery.length / lowerText.length) * 50;
39
40 // Fuzzy match - all query chars must appear in order
41 let queryIdx = 0;
42 let score = 0;
43 let consecutiveBonus = 0;
44
45 for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
46 if (lowerText[i] === lowerQuery[queryIdx]) {
47 score += 1 + consecutiveBonus;
48 consecutiveBonus += 0.5;
49 queryIdx++;
50 } else {
51 consecutiveBonus = 0;
52 }
53 }
54
55 // All query chars must be found
56 if (queryIdx !== lowerQuery.length) return -1;
57
58 return score;
59}
60
61function CommandPalette({
62 isOpen,
63 onClose,
64 conversations,
65 onNewConversation,
66 onSelectConversation,
67 onOpenDiffViewer,
68 onOpenModelsModal,
69 hasCwd,
70}: CommandPaletteProps) {
71 const [query, setQuery] = useState("");
72 const [selectedIndex, setSelectedIndex] = useState(0);
73 const [searchResults, setSearchResults] = useState<Conversation[]>([]);
74 const [isSearching, setIsSearching] = useState(false);
75 const inputRef = useRef<HTMLInputElement>(null);
76 const listRef = useRef<HTMLDivElement>(null);
77 const searchTimeoutRef = useRef<number | null>(null);
78
79 // Search conversations on the server
80 const searchConversations = useCallback(async (searchQuery: string) => {
81 if (!searchQuery.trim()) {
82 setSearchResults([]);
83 setIsSearching(false);
84 return;
85 }
86
87 setIsSearching(true);
88 try {
89 const results = await api.searchConversations(searchQuery);
90 setSearchResults(results);
91 } catch (err) {
92 console.error("Failed to search conversations:", err);
93 setSearchResults([]);
94 } finally {
95 setIsSearching(false);
96 }
97 }, []);
98
99 // Debounced search when query changes
100 useEffect(() => {
101 if (searchTimeoutRef.current) {
102 clearTimeout(searchTimeoutRef.current);
103 }
104
105 if (query.trim()) {
106 searchTimeoutRef.current = window.setTimeout(() => {
107 searchConversations(query);
108 }, 150); // 150ms debounce
109 } else {
110 setSearchResults([]);
111 setIsSearching(false);
112 }
113
114 return () => {
115 if (searchTimeoutRef.current) {
116 clearTimeout(searchTimeoutRef.current);
117 }
118 };
119 }, [query, searchConversations]);
120
121 // Build action items (these are always available)
122 const actionItems: CommandItem[] = useMemo(() => {
123 const items: CommandItem[] = [];
124
125 items.push({
126 id: "new-conversation",
127 type: "action",
128 title: "New Conversation",
129 subtitle: "Start a new conversation",
130 icon: (
131 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
132 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
133 </svg>
134 ),
135 action: () => {
136 onNewConversation();
137 onClose();
138 },
139 keywords: ["new", "create", "start", "conversation", "chat"],
140 });
141
142 if (hasCwd) {
143 items.push({
144 id: "open-diffs",
145 type: "action",
146 title: "View Diffs",
147 subtitle: "Open the git diff viewer",
148 icon: (
149 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
150 <path
151 strokeLinecap="round"
152 strokeLinejoin="round"
153 strokeWidth={2}
154 d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
155 />
156 </svg>
157 ),
158 action: () => {
159 onOpenDiffViewer();
160 onClose();
161 },
162 keywords: ["diff", "git", "changes", "view", "compare"],
163 });
164 }
165
166 items.push({
167 id: "manage-models",
168 type: "action",
169 title: "Add/Remove Models/Keys",
170 subtitle: "Configure custom AI models and API keys",
171 icon: (
172 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
173 <path
174 strokeLinecap="round"
175 strokeLinejoin="round"
176 strokeWidth={2}
177 d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
178 />
179 <path
180 strokeLinecap="round"
181 strokeLinejoin="round"
182 strokeWidth={2}
183 d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
184 />
185 </svg>
186 ),
187 action: () => {
188 onOpenModelsModal();
189 onClose();
190 },
191 keywords: [
192 "model",
193 "key",
194 "api",
195 "configure",
196 "settings",
197 "anthropic",
198 "openai",
199 "gemini",
200 "custom",
201 ],
202 });
203
204 return items;
205 }, [onNewConversation, onOpenDiffViewer, onOpenModelsModal, onClose, hasCwd]);
206
207 // Convert conversations to command items
208 const conversationToItem = useCallback(
209 (conv: Conversation): CommandItem => ({
210 id: `conv-${conv.conversation_id}`,
211 type: "conversation",
212 title: conv.slug || conv.conversation_id,
213 subtitle: conv.cwd || undefined,
214 icon: (
215 <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
216 <path
217 strokeLinecap="round"
218 strokeLinejoin="round"
219 strokeWidth={2}
220 d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
221 />
222 </svg>
223 ),
224 action: () => {
225 onSelectConversation(conv);
226 onClose();
227 },
228 }),
229 [onSelectConversation, onClose],
230 );
231
232 // Compute the final list of items to display
233 const displayItems = useMemo(() => {
234 const trimmedQuery = query.trim();
235
236 // Filter actions based on query (client-side fuzzy match)
237 let filteredActions = actionItems;
238 if (trimmedQuery) {
239 filteredActions = actionItems.filter((item) => {
240 let maxScore = fuzzyMatch(trimmedQuery, item.title);
241 if (item.subtitle) {
242 const subtitleScore = fuzzyMatch(trimmedQuery, item.subtitle);
243 if (subtitleScore > maxScore) maxScore = subtitleScore * 0.8;
244 }
245 if (item.keywords) {
246 for (const keyword of item.keywords) {
247 const keywordScore = fuzzyMatch(trimmedQuery, keyword);
248 if (keywordScore > maxScore) maxScore = keywordScore * 0.7;
249 }
250 }
251 return maxScore > 0;
252 });
253 }
254
255 // Use search results if we have a query, otherwise use initial conversations
256 const conversationsToShow = trimmedQuery ? searchResults : conversations;
257 const conversationItems = conversationsToShow.map(conversationToItem);
258
259 return [...filteredActions, ...conversationItems];
260 }, [query, actionItems, searchResults, conversations, conversationToItem]);
261
262 // Reset selection when items change
263 useEffect(() => {
264 setSelectedIndex(0);
265 }, [displayItems]);
266
267 // Focus input when opened
268 useEffect(() => {
269 if (isOpen) {
270 setQuery("");
271 setSelectedIndex(0);
272 setSearchResults([]);
273 setTimeout(() => inputRef.current?.focus(), 0);
274 }
275 }, [isOpen]);
276
277 // Scroll selected item into view
278 useEffect(() => {
279 if (!listRef.current) return;
280 const selectedElement = listRef.current.querySelector(`[data-index="${selectedIndex}"]`);
281 selectedElement?.scrollIntoView({ block: "nearest" });
282 }, [selectedIndex]);
283
284 // Handle keyboard navigation
285 const handleKeyDown = (e: React.KeyboardEvent) => {
286 switch (e.key) {
287 case "ArrowDown":
288 e.preventDefault();
289 setSelectedIndex((prev) => Math.min(prev + 1, displayItems.length - 1));
290 break;
291 case "ArrowUp":
292 e.preventDefault();
293 setSelectedIndex((prev) => Math.max(prev - 1, 0));
294 break;
295 case "Enter":
296 e.preventDefault();
297 if (displayItems[selectedIndex]) {
298 displayItems[selectedIndex].action();
299 }
300 break;
301 case "Escape":
302 e.preventDefault();
303 onClose();
304 break;
305 }
306 };
307
308 if (!isOpen) return null;
309
310 return (
311 <div className="command-palette-overlay" onClick={onClose}>
312 <div className="command-palette" onClick={(e) => e.stopPropagation()}>
313 <div className="command-palette-input-wrapper">
314 <svg
315 className="command-palette-search-icon"
316 fill="none"
317 stroke="currentColor"
318 viewBox="0 0 24 24"
319 width="20"
320 height="20"
321 >
322 <path
323 strokeLinecap="round"
324 strokeLinejoin="round"
325 strokeWidth={2}
326 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
327 />
328 </svg>
329 <input
330 ref={inputRef}
331 type="text"
332 className="command-palette-input"
333 placeholder="Search conversations or actions..."
334 value={query}
335 onChange={(e) => setQuery(e.target.value)}
336 onKeyDown={handleKeyDown}
337 />
338 {isSearching && <div className="command-palette-spinner" />}
339 <div className="command-palette-shortcut">
340 <kbd>esc</kbd>
341 </div>
342 </div>
343
344 <div className="command-palette-list" ref={listRef}>
345 {displayItems.length === 0 ? (
346 <div className="command-palette-empty">
347 {isSearching ? "Searching..." : "No results found"}
348 </div>
349 ) : (
350 displayItems.map((item, index) => (
351 <div
352 key={item.id}
353 data-index={index}
354 className={`command-palette-item ${index === selectedIndex ? "selected" : ""}`}
355 onClick={() => item.action()}
356 onMouseEnter={() => setSelectedIndex(index)}
357 >
358 <div className="command-palette-item-icon">{item.icon}</div>
359 <div className="command-palette-item-content">
360 <div className="command-palette-item-title">{item.title}</div>
361 {item.subtitle && (
362 <div className="command-palette-item-subtitle">{item.subtitle}</div>
363 )}
364 </div>
365 {item.type === "action" && <div className="command-palette-item-badge">Action</div>}
366 </div>
367 ))
368 )}
369 </div>
370
371 <div className="command-palette-footer">
372 <span>
373 <kbd>↑</kbd>
374 <kbd>↓</kbd> to navigate
375 </span>
376 <span>
377 <kbd>↵</kbd> to select
378 </span>
379 <span>
380 <kbd>esc</kbd> to close
381 </span>
382 </div>
383 </div>
384 </div>
385 );
386}
387
388export default CommandPalette;