@@ -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)} />
@@ -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;
@@ -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;
@@ -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;
+ }
+}