import { createContext, createMemo, createSignal, onCleanup, useContext, type Accessor, type ParentProps, For, Show, } from "solid-js"; import { useKeyboard, useTerminalDimensions } from "@opentui/solid"; import { KeybindsResolved, useKeybinds } from "../context/KeybindContext"; import { useDialog } from "./dialog"; import { useTheme } from "../context/ThemeContext"; import { TextAttributes } from "@opentui/core"; import { emit } from "../utils/event-bus"; import { SelectableBox, SelectableText } from "@/components/Selectable"; /** * Command option for the command palette. */ export type CommandOption = { /** Display title */ title: string; /** Unique identifier */ value: string; /** Description shown below title */ description?: string; /** Category for grouping */ category?: string; /** Keybind reference */ keybind?: keyof KeybindsResolved; /** Whether this command is suggested */ suggested?: boolean; /** Slash command configuration */ slash?: { name: string; aliases?: string[]; }; /** Whether to hide from command list */ hidden?: boolean; /** Whether command is enabled */ enabled?: boolean; /** Footer text (usually keybind display) */ footer?: string; /** Handler when command is selected */ onSelect?: (dialog: ReturnType) => void; }; type CommandContext = ReturnType; const ctx = createContext(); function init() { const [registrations, setRegistrations] = createSignal< Accessor[] >([]); const [suspendCount, setSuspendCount] = createSignal(0); const dialog = useDialog(); const keybind = useKeybinds(); const entries = createMemo(() => { const all = registrations().flatMap((x) => x()); return all.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined, })); }); const isEnabled = (option: CommandOption) => option.enabled !== false; const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden; const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)), ); const suggestedOptions = createMemo(() => visibleOptions() .filter((option) => option.suggested) .map((option) => ({ ...option, value: `suggested:${option.value}`, category: "Suggested", })), ); const suspended = () => suspendCount() > 0; // Handle keybind shortcuts useKeyboard((evt) => { if (suspended()) return; if (dialog.isOpen) return; for (const option of entries()) { if (!isEnabled(option)) continue; if (option.keybind && keybind.match(option.keybind, evt)) { evt.preventDefault(); option.onSelect?.(dialog); emit("command.execute", { command: option.value }); return; } } }); const result = { /** * Trigger a command by its value. */ trigger(name: string) { for (const option of entries()) { if (option.value === name) { if (!isEnabled(option)) return; option.onSelect?.(dialog); emit("command.execute", { command: name }); return; } } }, /** * Get all slash commands. */ slashes() { return visibleOptions().flatMap((option) => { const slash = option.slash; if (!slash) return []; return { display: "/" + slash.name, description: option.description ?? option.title, aliases: slash.aliases?.map((alias) => "/" + alias), onSelect: () => result.trigger(option.value), }; }); }, /** * Enable/disable keybinds temporarily. */ keybinds(enabled: boolean) { setSuspendCount((count) => count + (enabled ? -1 : 1)); }, suspended, /** * Show the command palette dialog. */ show() { dialog.replace(() => ( )); }, /** * Register commands. Returns cleanup function. */ register(cb: () => CommandOption[]) { const results = createMemo(cb); setRegistrations((arr) => [results, ...arr]); onCleanup(() => { setRegistrations((arr) => arr.filter((x) => x !== results)); }); }, /** * Get all visible options. */ get options() { return visibleOptions(); }, }; return result; } export function useCommandDialog() { const value = useContext(ctx); if (!value) { throw new Error("useCommandDialog must be used within a CommandProvider"); } return value; } export function CommandProvider(props: ParentProps) { const value = init(); const dialog = useDialog(); const keybind = useKeybinds(); // Open command palette on ctrl+p or command_list keybind useKeyboard((evt) => { if (value.suspended()) return; if (dialog.isOpen) return; if (evt.defaultPrevented) return; if (keybind.match("command_list", evt)) { evt.preventDefault(); value.show(); return; } }); return {props.children}; } /** * Command palette dialog component. */ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[]; }) { const { theme } = useTheme(); const dialog = useDialog(); const dimensions = useTerminalDimensions(); const [filter, setFilter] = createSignal(""); const [selectedIndex, setSelectedIndex] = createSignal(0); const filteredOptions = createMemo(() => { const query = filter().toLowerCase(); if (!query) { return [...props.suggestedOptions, ...props.options]; } return props.options.filter( (option) => option.title.toLowerCase().includes(query) || option.description?.toLowerCase().includes(query) || option.category?.toLowerCase().includes(query), ); }); // Reset selection when filter changes createMemo(() => { filter(); setSelectedIndex(0); }); useKeyboard((evt) => { if (evt.name === "escape") { dialog.clear(); evt.preventDefault(); return; } if (evt.name === "return" || evt.name === "enter") { const option = filteredOptions()[selectedIndex()]; if (option) { option.onSelect?.(dialog); dialog.clear(); } evt.preventDefault(); return; } if (evt.name === "up" || (evt.ctrl && evt.name === "p")) { setSelectedIndex((i) => Math.max(0, i - 1)); evt.preventDefault(); return; } if (evt.name === "down" || (evt.ctrl && evt.name === "n")) { setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1)); evt.preventDefault(); return; } // Handle text input if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { setFilter((f) => f + evt.name); return; } if (evt.name === "backspace") { setFilter((f) => f.slice(0, -1)); return; } }); const maxHeight = Math.floor(dimensions().height * 0.6); return ( {/* Search input */} {"> "} {filter() || "Type to search commands..."} {/* Command list */} {(option, index) => ( index() === selectedIndex()} flexDirection="column" padding={1} onMouseDown={() => { setSelectedIndex(index()); const selectedOption = filteredOptions()[index()]; if (selectedOption) { selectedOption.onSelect?.(dialog); dialog.clear(); } }} > index() === selectedIndex()} primary > {option.title} index() === selectedIndex()} tertiary > {option.footer} index() === selectedIndex()} tertiary > {option.description} )} No commands found ); }