import { useEffect, useMemo, useState } from "react"; import { fmtRelative, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { useStore, getState } from "../store"; import { cycleThemeMode } from "../theme"; import { Icon } from "./Icon"; interface Item { id: string; icon: string; label: string; hint?: string; keys?: string; run: () => void; } const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() || p; const isMac = /Mac|iPhone|iPad/.test(navigator.platform); export const MOD = isMac ? "⌘" : "Ctrl"; /** Root sessions, most recent first. */ function recent() { return getState() .sessions.filter((s) => !s.parent_id) .sort((a, b) => b.updated_at - a.updated_at); } /** Move to the next/previous session relative to the open one. */ function step(dir: 1 | -1) { const list = recent(); const cur = location.hash.match(/^#\/s\/([^/+]+)/)?.[1]; const i = list.findIndex((s) => s.id === cur); const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)]; if (next) navigate(`/s/${next.id}`); } /** Global keyboard shortcuts and the command palette (Ctrl/⌘+K). */ export function Palette() { const t = useT(); const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [active, setActive] = useState(0); const sessions = useStore((s) => s.sessions); const isAdmin = useStore((s) => s.me?.role === "admin"); useEffect(() => { const h = (e: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((o) => !o); setQ(""); setActive(0); } else if (e.altKey && !mod && e.key.toLowerCase() === "n") { e.preventDefault(); navigate("/new"); } else if (e.altKey && !mod && (e.key === "ArrowUp" || e.key === "ArrowDown")) { e.preventDefault(); step(e.key === "ArrowUp" ? -1 : 1); } else if (mod && e.key === ",") { e.preventDefault(); navigate("/settings"); } }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); const items = useMemo(() => { const nav = (id: string, icon: string, label: Key, path: string, keys?: string): Item => ({ id, icon, label: t(label), keys, run: () => navigate(path) }); const actions: Item[] = [ nav("new", "plus", "palette.newSession", "/new", "Alt+N"), nav("files", "folder", "nav.files", "/files"), nav("terminal", "terminal", "nav.terminal", "/terminal"), nav("jobs", "clock", "nav.jobs", "/jobs"), nav("images", "image", "nav.images", "/images"), nav("stats", "chart", "nav.stats", "/stats"), nav("settings", "gear", "nav.settings", "/settings", `${MOD}+,`), nav("templates", "template", "templates.manage", "/settings/templates"), nav("account", "user", "settings.tab.account", "/settings/account"), ...(isAdmin ? [nav("providers", "brain", "settings.tab.providers", "/settings/providers"), nav("agents", "robot", "settings.tab.agents", "/settings/agents"), nav("users", "users", "settings.tab.users", "/settings/users")] : []), { id: "theme", icon: "theme-auto", label: t("palette.toggleTheme"), run: () => cycleThemeMode() }, ]; const sess: Item[] = sessions .filter((s) => !s.parent_id) .sort((a, b) => Number(!!a.archived) - Number(!!b.archived) || b.updated_at - a.updated_at) .map((s) => ({ id: s.id, icon: "file-text", label: s.title || t("session.untitled"), hint: `${basename(s.cwd)} · ${fmtRelative(s.updated_at)}`, run: () => navigate(`/s/${s.id}`) })); const needle = q.trim().toLowerCase(); // Empty query: new session, the most recent sessions, then everything else. if (!needle) return [actions[0], ...sess.slice(0, 5), ...actions.slice(1)]; const match = (x: Item) => `${x.label} ${x.hint ?? ""}`.toLowerCase().includes(needle); return [...actions.filter(match), ...sess.filter(match).slice(0, 30)]; }, [q, sessions, isAdmin, t]); if (!open) return null; const run = (x?: Item) => { if (!x) return; setOpen(false); x.run(); }; const close = () => setOpen(false); return (
e.stopPropagation()}> (setQ(e.target.value), setActive(0))} onKeyDown={(e) => { if (e.key === "Escape") close(); else if (e.key === "ArrowDown") (e.preventDefault(), setActive((a) => Math.min(a + 1, items.length - 1))); else if (e.key === "ArrowUp") (e.preventDefault(), setActive((a) => Math.max(a - 1, 0))); else if (e.key === "Enter") (e.preventDefault(), run(items[active])); }} />
{items.length === 0 &&
{t("palette.nothing")}
} {items.map((x, i) => ( ))}
{t("palette.move")} Enter {t("palette.open")} Alt+Alt+ {t("palette.prevNext")} Esc {t("palette.close")}
); }