diff --git a/desktop/frontend/src/components/CommandPalette.tsx b/desktop/frontend/src/components/CommandPalette.tsx new file mode 100644 index 000000000..98743fbb3 --- /dev/null +++ b/desktop/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,229 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +// CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's +// long-tail navigation surface. Tabs through sessions, slash-commands, and +// recent files via a single fuzzy search. The list of items is provided by +// the caller (App) so the palette stays decoupled from the controller — the +// same component will work for skills, MCP servers, and future surfaces +// once a buildItems() helper is added for them. +// +// Interaction model: +// - Input is auto-focused on open; the first match is highlighted. +// - ↑/↓ move the highlight (wraps at the edges). +// - Enter runs the highlighted item's action. +// - Esc closes. +// - Mouse hover sets the highlight (so a click can be "pre-thought"); the +// click itself runs the action. +// +// Fuzzy match is a small case-insensitive substring scorer — every query +// token must appear in the candidate's title or hint, in order, but they +// may overlap (a real fuzzy matcher would be overkill for 50-200 items). +export interface PaletteItem { + // id is stable and unique within a single open of the palette. + id: string; + // title is the primary label. + title: string; + // hint is the secondary line (a path, a command's source, etc.). + hint?: string; + // group is the section header this item belongs to. + group: string; + // keywords add to the searchable text (e.g. slash-command aliases). + keywords?: string[]; + // run closes the palette and dispatches the action. + run: () => void | Promise; +} + +export function CommandPalette({ + open, + onClose, + items, + placeholder, + emptyText, +}: { + open: boolean; + onClose: () => void; + items: PaletteItem[]; + placeholder: string; + emptyText: string; +}) { + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const inputRef = useRef(null); + + // Re-init whenever the palette opens: clear the query, reset the + // highlight, and steal focus. Doing it on the open edge (not on every + // render) means a previously-typed query doesn't leak across opens. + useEffect(() => { + if (open) { + setQuery(""); + setActive(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + // score is the fuzzy match: every space-separated query token must + // appear (case-insensitively) in the candidate's haystack, in the order + // given. The score is the sum of the inverse lengths of the matching + // substrings (smaller span → higher rank) so a tight prefix match wins + // over a spread match. + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items; + const tokens = q.split(/\s+/); + const scored: { item: PaletteItem; score: number }[] = []; + for (const it of items) { + const hay = [it.title, it.hint ?? "", ...(it.keywords ?? [])].join("\n").toLowerCase(); + let cursor = 0; + let score = 0; + let ok = true; + for (const tok of tokens) { + const at = hay.indexOf(tok, cursor); + if (at < 0) { + ok = false; + break; + } + // Reward tight matches (smaller span) and matches early in the string. + score += 1000 - (at - cursor) - at; + cursor = at + tok.length; + } + if (ok) scored.push({ item: it, score }); + } + scored.sort((a, b) => b.score - a.score); + return scored.map((s) => s.item); + }, [query, items]); + + // Group the filtered items by their `group` field, preserving the order + // the groups first appear (so a "Sessions" group with a hit is shown + // before a "Commands" group with a hit, even if the commands' raw + // scores would outrank it). This matches the user's mental model: + // sessions are the most frequent target. + const grouped = useMemo(() => { + const out: { group: string; items: PaletteItem[] }[] = []; + const indexOf = (g: string) => out.findIndex((o) => o.group === g); + for (const it of filtered) { + const at = indexOf(it.group); + if (at < 0) out.push({ group: it.group, items: [it] }); + else out[at].items.push(it); + } + return out; + }, [filtered]); + + // Flat index -> grouped item lookup. The keyboard handler only needs + // the linear index, so we keep a parallel array to avoid a quadratic + // walk on every keypress. + const flat = useMemo(() => grouped.flatMap((g) => g.items), [grouped]); + + // Clamp the active index whenever the result set shrinks (e.g. user + // typed something that filtered out the previously-highlighted item). + useEffect(() => { + if (active >= flat.length) setActive(Math.max(0, flat.length - 1)); + }, [flat.length, active]); + + // Reset the highlight to 0 on every query change — the user just refined + // their search, the old highlight is rarely still interesting. + useEffect(() => { + setActive(0); + }, [query]); + + // Esc closes; ↑/↓ move the highlight; Enter runs. We use a document-level + // listener so the palette is responsive even when focus drifts (e.g. the + // user clicks a result row, then presses ↑). + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (flat.length === 0 ? 0 : (i + 1) % flat.length)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (flat.length === 0 ? 0 : (i - 1 + flat.length) % flat.length)); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + const it = flat[active]; + if (it) void it.run(); + onClose(); + return; + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, flat, active, onClose]); + + if (!open) return null; + + // The running counter maps a flat-index back to its group header so we + // can render the section dividers in order. + let running = 0; + + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={placeholder}> +
+ setQuery(e.target.value)} + placeholder={placeholder} + spellCheck={false} + autoComplete="off" + /> + esc +
+
+ {flat.length === 0 ? ( +
{emptyText}
+ ) : ( + grouped.map((g) => ( +
+
{g.group}
+ {g.items.map((it) => { + const idx = running++; + const on = idx === active; + return ( + + ); + })} +
+ )) + )} +
+
+ + + navigate + + + run + + + esc close + +
+
+
+ ); +}