CommandPalette.tsx

  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;