shelley: add Command+K command palette

Philip Zeyliger and Shelley created

Prompt: Command K should bring up, in Shelley, a modal that lets you navigate Shelley. Default is new conversation, but all other views (like diff) and conversation search should be available with fuzzy search.

Add a command palette modal (Cmd+K or Ctrl+K) that provides:
- Quick search for conversations with fuzzy matching
- Navigation to start a new conversation (default selected)
- Opening the git diff viewer
- Keyboard navigation with arrow keys, Enter, and Escape

The palette shows actions first, then conversations, and filters
results as you type with a simple fuzzy matching algorithm that
scores exact matches, prefix matches, contains matches, and
character-by-character fuzzy matches.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

ui/src/App.tsx                       |  36 +++
ui/src/components/ChatInterface.tsx  |   9 
ui/src/components/CommandPalette.tsx | 303 ++++++++++++++++++++++++++++++
ui/src/styles.css                    | 168 ++++++++++++++++
4 files changed, 516 insertions(+)

Detailed changes

ui/src/App.tsx 🔗

@@ -1,6 +1,7 @@
 import React, { useState, useEffect, useCallback, useRef } from "react";
 import ChatInterface from "./components/ChatInterface";
 import ConversationDrawer from "./components/ConversationDrawer";
+import CommandPalette from "./components/CommandPalette";
 import { Conversation, ConversationListUpdate } from "./types";
 import { api } from "./services/api";
 
@@ -62,6 +63,8 @@ function App() {
   const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
   const [drawerOpen, setDrawerOpen] = useState(false);
   const [drawerCollapsed, setDrawerCollapsed] = useState(false);
+  const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
+  const [diffViewerTrigger, setDiffViewerTrigger] = useState(0);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const initialSlugResolved = useRef(false);
@@ -102,6 +105,18 @@ function App() {
     loadConversations();
   }, []);
 
+  // Global keyboard shortcut for command palette (Cmd+K / Ctrl+K)
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if ((e.metaKey || e.ctrlKey) && e.key === "k") {
+        e.preventDefault();
+        setCommandPaletteOpen((prev) => !prev);
+      }
+    };
+    document.addEventListener("keydown", handleKeyDown);
+    return () => document.removeEventListener("keydown", handleKeyDown);
+  }, []);
+
   // Handle conversation list updates from the message stream
   const handleConversationListUpdate = useCallback((update: ConversationListUpdate) => {
     if (update.type === "update" && update.conversation) {
@@ -292,9 +307,30 @@ function App() {
           mostRecentCwd={mostRecentCwd}
           isDrawerCollapsed={drawerCollapsed}
           onToggleDrawerCollapse={toggleDrawerCollapsed}
+          openDiffViewerTrigger={diffViewerTrigger}
         />
       </div>
 
+      {/* Command Palette */}
+      <CommandPalette
+        isOpen={commandPaletteOpen}
+        onClose={() => setCommandPaletteOpen(false)}
+        conversations={conversations}
+        onNewConversation={() => {
+          startNewConversation();
+          setCommandPaletteOpen(false);
+        }}
+        onSelectConversation={(id) => {
+          selectConversation(id);
+          setCommandPaletteOpen(false);
+        }}
+        onOpenDiffViewer={() => {
+          setDiffViewerTrigger((prev) => prev + 1);
+          setCommandPaletteOpen(false);
+        }}
+        hasCwd={!!(currentConversation?.cwd || mostRecentCwd)}
+      />
+
       {/* Backdrop for mobile drawer */}
       {drawerOpen && (
         <div className="backdrop hide-on-desktop" onClick={() => setDrawerOpen(false)} />

ui/src/components/ChatInterface.tsx 🔗

@@ -363,6 +363,7 @@ interface ChatInterfaceProps {
   mostRecentCwd?: string | null;
   isDrawerCollapsed?: boolean;
   onToggleDrawerCollapse?: () => void;
+  openDiffViewerTrigger?: number; // increment to trigger opening diff viewer
 }
 
 function ChatInterface({
@@ -376,6 +377,7 @@ function ChatInterface({
   mostRecentCwd,
   isDrawerCollapsed,
   onToggleDrawerCollapse,
+  openDiffViewerTrigger,
 }: ChatInterfaceProps) {
   const [messages, setMessages] = useState<Message[]>([]);
   const [loading, setLoading] = useState(true);
@@ -761,6 +763,13 @@ function ChatInterface({
     };
   }, [isDisconnected, conversationId]);
 
+  // Handle external trigger to open diff viewer
+  useEffect(() => {
+    if (openDiffViewerTrigger && openDiffViewerTrigger > 0) {
+      setShowDiffViewer(true);
+    }
+  }, [openDiffViewerTrigger]);
+
   const handleCancel = async () => {
     if (!conversationId || cancelling) return;
 

ui/src/components/CommandPalette.tsx 🔗

@@ -0,0 +1,303 @@
+import React, { useState, useEffect, useRef, useMemo } from "react";
+import { Conversation } from "../types";
+
+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: (id: string) => void;
+  onOpenDiffViewer: () => void;
+  hasCwd: boolean;
+}
+
+// Simple fuzzy match - 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,
+  hasCwd,
+}: CommandPaletteProps) {
+  const [query, setQuery] = useState("");
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const listRef = useRef<HTMLDivElement>(null);
+
+  // Build list of command items
+  const allItems: CommandItem[] = useMemo(() => {
+    const items: CommandItem[] = [];
+
+    // Actions
+    items.push({
+      id: "new-conversation",
+      type: "action",
+      title: "New Conversation",
+      subtitle: "Start a new conversation",
+      icon: (
+        <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+        </svg>
+      ),
+      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: (
+          <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              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"
+            />
+          </svg>
+        ),
+        action: () => {
+          onOpenDiffViewer();
+          onClose();
+        },
+        keywords: ["diff", "git", "changes", "view", "compare"],
+      });
+    }
+
+    // Add conversations
+    conversations.forEach((conv) => {
+      items.push({
+        id: `conv-${conv.conversation_id}`,
+        type: "conversation",
+        title: conv.slug || conv.conversation_id,
+        subtitle: conv.cwd || undefined,
+        icon: (
+          <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              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"
+            />
+          </svg>
+        ),
+        action: () => {
+          onSelectConversation(conv.conversation_id);
+          onClose();
+        },
+        keywords: [conv.slug || "", conv.cwd || ""].filter(Boolean),
+      });
+    });
+
+    return items;
+  }, [conversations, onNewConversation, onSelectConversation, onOpenDiffViewer, onClose, hasCwd]);
+
+  // Filter and sort items based on query
+  const filteredItems = useMemo(() => {
+    if (!query.trim()) {
+      // No query - show actions first, then recent conversations
+      return allItems;
+    }
+
+    // Score and filter items
+    const scored = allItems
+      .map((item) => {
+        let maxScore = fuzzyMatch(query, item.title);
+
+        // Check subtitle
+        if (item.subtitle) {
+          const subtitleScore = fuzzyMatch(query, item.subtitle);
+          if (subtitleScore > maxScore) maxScore = subtitleScore * 0.8; // Slightly lower weight
+        }
+
+        // Check keywords
+        if (item.keywords) {
+          for (const keyword of item.keywords) {
+            const keywordScore = fuzzyMatch(query, keyword);
+            if (keywordScore > maxScore) maxScore = keywordScore * 0.7;
+          }
+        }
+
+        return { item, score: maxScore };
+      })
+      .filter(({ score }) => score > 0)
+      .sort((a, b) => b.score - a.score);
+
+    return scored.map(({ item }) => item);
+  }, [allItems, query]);
+
+  // Reset selection when query changes
+  useEffect(() => {
+    setSelectedIndex(0);
+  }, [query]);
+
+  // Focus input when opened
+  useEffect(() => {
+    if (isOpen) {
+      setQuery("");
+      setSelectedIndex(0);
+      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, filteredItems.length - 1));
+        break;
+      case "ArrowUp":
+        e.preventDefault();
+        setSelectedIndex((prev) => Math.max(prev - 1, 0));
+        break;
+      case "Enter":
+        e.preventDefault();
+        if (filteredItems[selectedIndex]) {
+          filteredItems[selectedIndex].action();
+        }
+        break;
+      case "Escape":
+        e.preventDefault();
+        onClose();
+        break;
+    }
+  };
+
+  if (!isOpen) return null;
+
+  return (
+    <div className="command-palette-overlay" onClick={onClose}>
+      <div className="command-palette" onClick={(e) => e.stopPropagation()}>
+        <div className="command-palette-input-wrapper">
+          <svg
+            className="command-palette-search-icon"
+            fill="none"
+            stroke="currentColor"
+            viewBox="0 0 24 24"
+            width="20"
+            height="20"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
+            />
+          </svg>
+          <input
+            ref={inputRef}
+            type="text"
+            className="command-palette-input"
+            placeholder="Search conversations or actions..."
+            value={query}
+            onChange={(e) => setQuery(e.target.value)}
+            onKeyDown={handleKeyDown}
+          />
+          <div className="command-palette-shortcut">
+            <kbd>esc</kbd>
+          </div>
+        </div>
+
+        <div className="command-palette-list" ref={listRef}>
+          {filteredItems.length === 0 ? (
+            <div className="command-palette-empty">No results found</div>
+          ) : (
+            filteredItems.map((item, index) => (
+              <div
+                key={item.id}
+                data-index={index}
+                className={`command-palette-item ${index === selectedIndex ? "selected" : ""}`}
+                onClick={() => item.action()}
+                onMouseEnter={() => setSelectedIndex(index)}
+              >
+                <div className="command-palette-item-icon">{item.icon}</div>
+                <div className="command-palette-item-content">
+                  <div className="command-palette-item-title">{item.title}</div>
+                  {item.subtitle && (
+                    <div className="command-palette-item-subtitle">{item.subtitle}</div>
+                  )}
+                </div>
+                {item.type === "action" && <div className="command-palette-item-badge">Action</div>}
+              </div>
+            ))
+          )}
+        </div>
+
+        <div className="command-palette-footer">
+          <span>
+            <kbd>↑</kbd>
+            <kbd>↓</kbd> to navigate
+          </span>
+          <span>
+            <kbd>↵</kbd> to select
+          </span>
+          <span>
+            <kbd>esc</kbd> to close
+          </span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export default CommandPalette;

ui/src/styles.css 🔗

@@ -3405,3 +3405,171 @@ svg {
 .text-link:hover {
   text-decoration-color: var(--blue-text);
 }
+
+/* Command Palette */
+.command-palette-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 100;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding-top: 15vh;
+}
+
+.command-palette {
+  background: var(--bg-base);
+  border: 1px solid var(--border-color);
+  border-radius: 0.75rem;
+  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+  width: 100%;
+  max-width: 32rem;
+  max-height: 60vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.command-palette-input-wrapper {
+  display: flex;
+  align-items: center;
+  padding: 0.75rem 1rem;
+  border-bottom: 1px solid var(--border-color);
+  gap: 0.75rem;
+}
+
+.command-palette-search-icon {
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.command-palette-input {
+  flex: 1;
+  border: none;
+  background: transparent;
+  font-size: 1rem;
+  color: var(--text-primary);
+  outline: none;
+}
+
+.command-palette-input::placeholder {
+  color: var(--text-secondary);
+}
+
+.command-palette-shortcut {
+  flex-shrink: 0;
+}
+
+.command-palette-shortcut kbd,
+.command-palette-footer kbd {
+  display: inline-block;
+  padding: 0.125rem 0.375rem;
+  font-size: 0.75rem;
+  font-family: inherit;
+  background: var(--bg-tertiary);
+  border: 1px solid var(--border-color);
+  border-radius: 0.25rem;
+  color: var(--text-secondary);
+}
+
+.command-palette-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 0.5rem;
+}
+
+.command-palette-empty {
+  padding: 2rem 1rem;
+  text-align: center;
+  color: var(--text-secondary);
+}
+
+.command-palette-item {
+  display: flex;
+  align-items: center;
+  padding: 0.625rem 0.75rem;
+  border-radius: 0.5rem;
+  cursor: pointer;
+  gap: 0.75rem;
+}
+
+.command-palette-item:hover,
+.command-palette-item.selected {
+  background: var(--bg-secondary);
+}
+
+.command-palette-item-icon {
+  flex-shrink: 0;
+  color: var(--text-secondary);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.command-palette-item.selected .command-palette-item-icon {
+  color: var(--primary);
+}
+
+.command-palette-item-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.command-palette-item-title {
+  font-size: 0.875rem;
+  color: var(--text-primary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.command-palette-item-subtitle {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-top: 0.125rem;
+}
+
+.command-palette-item-badge {
+  flex-shrink: 0;
+  font-size: 0.625rem;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  padding: 0.125rem 0.375rem;
+  background: var(--bg-tertiary);
+  border-radius: 0.25rem;
+  color: var(--text-secondary);
+}
+
+.command-palette-footer {
+  display: flex;
+  gap: 1rem;
+  padding: 0.5rem 1rem;
+  border-top: 1px solid var(--border-color);
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+}
+
+.command-palette-footer span {
+  display: flex;
+  align-items: center;
+  gap: 0.25rem;
+}
+
+@media (max-width: 640px) {
+  .command-palette-overlay {
+    padding: 1rem;
+    padding-top: 5vh;
+  }
+
+  .command-palette {
+    max-height: 80vh;
+  }
+
+  .command-palette-footer {
+    display: none;
+  }
+}