import { useEffect, useState } from "react"; import { Command } from "cmdk"; import { useAtom } from "jotai"; import { useLiveQuery } from "dexie-react-hooks"; import { useNavigate, useLocation } from "react-router"; import db from "@/services/db"; import { useGrimoire } from "@/core/state"; import { manPages } from "@/types/man"; import { parseCommandInput, executeCommandParser } from "@/lib/command-parser"; import { commandLauncherEditModeAtom } from "@/core/command-launcher-state"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { VisuallyHidden } from "@/components/ui/visually-hidden"; import "./command-launcher.css"; /** Check if current path doesn't have the window system (should navigate to / when launching commands) */ function isNonDashboardRoute(pathname: string): boolean { // /run route - pop-out command page if (pathname.startsWith("/run")) return true; // NIP-19 preview routes are single-segment paths starting with npub1, note1, nevent1, naddr1 const segment = pathname.slice(1); // Remove leading / if (segment.includes("/")) return false; // Multi-segment paths are not NIP-19 previews return ( segment.startsWith("npub1") || segment.startsWith("note1") || segment.startsWith("nevent1") || segment.startsWith("naddr1") ); } interface CommandLauncherProps { open: boolean; onOpenChange: (open: boolean) => void; } export default function CommandLauncher({ open, onOpenChange, }: CommandLauncherProps) { const [input, setInput] = useState(""); const [editMode, setEditMode] = useAtom(commandLauncherEditModeAtom); const { state, addWindow, updateWindow } = useGrimoire(); const navigate = useNavigate(); const location = useLocation(); // Fetch spells with aliases const aliasedSpells = useLiveQuery(() => db.spells .toArray() .then((spells) => spells.filter((s) => s.alias !== undefined && s.alias !== ""), ), ) || []; // Prefill input when entering edit mode useEffect(() => { if (open && editMode) { setInput(editMode.initialCommand); } else if (!open) { // Clear input and edit mode when dialog closes setInput(""); setEditMode(null); } }, [open, editMode, setEditMode]); // Parse input into command and arguments const parsed = parseCommandInput(input); const { commandName } = parsed; // Check if it's a spell alias const activeSpell = aliasedSpells.find( (s) => s.alias?.toLowerCase() === commandName.toLowerCase(), ); // Re-parse if it's a spell const effectiveParsed = activeSpell ? parseCommandInput( activeSpell.command + (input.trim().includes(" ") ? " " + input.trim().split(/\s+/).slice(1).join(" ") : ""), ) : parsed; const recognizedCommand = effectiveParsed.command; // Filter commands by partial match on command name only const filteredCommands = [ ...Object.entries(manPages), ...aliasedSpells.map((s) => [ s.alias!, { name: s.alias!, synopsis: s.alias!, description: s.name || s.description || "", category: "Spells", appId: "req", spellCommand: s.command, } as any, ]), ].filter(([name]) => name.toLowerCase().includes(commandName.toLowerCase())); // Execute command (async to support async argParsers) const executeCommand = async () => { if (!recognizedCommand) return; // Execute argParser and get props/title const result = await executeCommandParser( effectiveParsed, state.activeAccount?.pubkey, ); if (result.error || !result.props) { console.error("Failed to parse command:", result.error); return; } // Edit mode: update existing window if (editMode) { updateWindow(editMode.windowId, { props: result.props, commandString: activeSpell ? effectiveParsed.fullInput : input.trim(), appId: recognizedCommand.appId, customTitle: result.globalFlags?.windowProps?.title, }); setEditMode(null); // Clear edit mode } else { // If on a non-dashboard route (no window system), navigate to dashboard first // The window will appear after navigation since state persists if (isNonDashboardRoute(location.pathname)) { navigate("/"); } // Normal mode: create new window addWindow( recognizedCommand.appId, result.props, activeSpell ? effectiveParsed.fullInput : input.trim(), result.globalFlags?.windowProps?.title, activeSpell?.id, ); } onOpenChange(false); }; // Handle item selection (populate input, don't execute) const handleSelect = (selectedCommand: string) => { setInput(selectedCommand + " "); }; // Handle Enter key const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); executeCommand(); } }; // Define category order: Nostr first, then Spells, then Documentation, then System const categoryOrder = ["Nostr", "Spells", "Documentation", "System"]; const categories = Array.from( new Set(filteredCommands.map(([_, cmd]) => cmd.category)), ).sort((a, b) => { const indexA = categoryOrder.indexOf(a as string); const indexB = categoryOrder.indexOf(b as string); return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB); }); // Dynamic placeholder const placeholder = recognizedCommand ? activeSpell ? activeSpell.command : recognizedCommand.synopsis : "Type a command..."; return ( Command Launcher
{commandName ? `No command found: ${commandName}` : "Start typing..."} {categories.map((category) => ( {filteredCommands .filter(([_, cmd]) => cmd.category === category) .map(([name, cmd]) => { const isExactMatch = name === commandName; return ( handleSelect(name)} className="command-item" data-exact-match={isExactMatch} >
{name} {cmd.synopsis !== name && ( {cmd.synopsis.replace(name, "").trim()} )} {isExactMatch && ( )}
{cmd.description && (
{cmd.description.split(".")[0]}
)} {cmd.spellCommand && (
{cmd.spellCommand}
)}
); })}
))}
↑↓ navigate execute esc close
{recognizedCommand && (
Ready to execute
)}
); }