import React from "react"; import { createPortal } from "react-dom"; import { Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { useNavigation } from "#/context/navigation-context"; import { useCommandMenuStore } from "#/stores/command-menu-store"; import { useSidebarStore } from "#/stores/sidebar-store"; import { cn } from "#/utils/utils"; import { COMMAND_MENU_GROUP_LABELS, COMMAND_MENU_GROUP_ORDER, type CommandMenuItemDefinition, commandMenuItemCopy, createCommandMenuItems, } from "./command-menu-items"; const COMMAND_MENU_SEARCH_INPUT_ID = "command-menu-search"; const COMMAND_MENU_LISTBOX_ID = "command-menu-results"; const COMMAND_MENU_OPTION_ID_PREFIX = "command-menu-option"; const COMMAND_MENU_TEST_ID = "command-menu"; const COMMAND_MENU_SHORTCUT_KEY = "k"; const COMMAND_MENU_ARROW_DOWN_KEY = "ArrowDown"; const COMMAND_MENU_ARROW_UP_KEY = "ArrowUp"; const COMMAND_MENU_ENTER_KEY = "Enter"; const COMMAND_MENU_ESCAPE_KEY = "Escape"; const EMPTY_QUERY = ""; const EMPTY_RESULTS_ACTIVE_INDEX = -1; function getOptionId(item: CommandMenuItemDefinition) { return `${COMMAND_MENU_OPTION_ID_PREFIX}-${item.id}`; } function matchesQuery({ item, query, translate, }: { item: CommandMenuItemDefinition; query: string; translate: (key: I18nKey) => string; }) { const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); if (terms.length === 0) { return true; } const searchableText = [ commandMenuItemCopy(item.title, item.titleKey, translate), commandMenuItemCopy(item.description, item.descriptionKey, translate), commandMenuItemCopy(item.keywords, item.keywordsKey, translate), ] .join(" ") .toLocaleLowerCase(); return terms.every((term) => searchableText.includes(term)); } export function CommandMenu() { const { t } = useTranslation("openhands"); const { navigate } = useNavigation(); const isOpen = useCommandMenuStore((state) => state.isOpen); const open = useCommandMenuStore((state) => state.open); const close = useCommandMenuStore((state) => state.close); const [query, setQuery] = React.useState(EMPTY_QUERY); const [activeIndex, setActiveIndex] = React.useState(0); const inputRef = React.useRef(null); const optionRefs = React.useRef(new Map()); React.useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ( (event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === COMMAND_MENU_SHORTCUT_KEY ) { event.preventDefault(); open(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [open]); React.useEffect(() => { if (!isOpen) { setQuery(EMPTY_QUERY); setActiveIndex(0); optionRefs.current.clear(); return undefined; } const frame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [isOpen]); const items = React.useMemo( () => createCommandMenuItems({ toggleSidebar: () => useSidebarStore.getState().toggleCollapsed(), }), [], ); const filteredItems = React.useMemo( () => items.filter((item) => matchesQuery({ item, query, translate: (key) => t(key) }), ), [items, query, t], ); React.useEffect(() => { setActiveIndex((currentIndex) => { if (filteredItems.length === 0) { return EMPTY_RESULTS_ACTIVE_INDEX; } return Math.min(Math.max(currentIndex, 0), filteredItems.length - 1); }); }, [filteredItems.length]); React.useEffect(() => { const activeItem = filteredItems[activeIndex]; if (!activeItem) { return; } const activeNode = optionRefs.current.get(activeItem.id); if (typeof activeNode?.scrollIntoView === "function") { activeNode.scrollIntoView({ block: "nearest", }); } }, [activeIndex, filteredItems]); const runItem = React.useCallback( (item: CommandMenuItemDefinition | undefined) => { if (!item) { return; } close(); if (item.to) { navigate(item.to); return; } item.perform?.(); }, [close, navigate], ); const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.key === COMMAND_MENU_ARROW_DOWN_KEY) { event.preventDefault(); setActiveIndex((index) => filteredItems.length === 0 ? index : (index + 1) % filteredItems.length, ); return; } if (event.key === COMMAND_MENU_ARROW_UP_KEY) { event.preventDefault(); setActiveIndex((index) => filteredItems.length === 0 ? index : (index - 1 + filteredItems.length) % filteredItems.length, ); return; } if (event.key === COMMAND_MENU_ENTER_KEY) { event.preventDefault(); runItem(filteredItems[activeIndex]); return; } if (event.key === COMMAND_MENU_ESCAPE_KEY) { event.preventDefault(); close(); } }; if (!isOpen || typeof document === "undefined") { return null; } const activeItem = filteredItems[activeIndex]; return createPortal(
) : null} {t(I18nKey.COMMAND_MENU$SHORTCUT)}
{filteredItems.length === 0 ? (

{t(I18nKey.COMMAND_MENU$NO_RESULTS_TITLE)}

{t(I18nKey.COMMAND_MENU$NO_RESULTS_DESCRIPTION)}

) : ( COMMAND_MENU_GROUP_ORDER.map((groupId) => { const groupItems = filteredItems.filter( (item) => item.group === groupId, ); if (groupItems.length === 0) { return null; } return (
{t(COMMAND_MENU_GROUP_LABELS[groupId])}
{groupItems.map((item) => { const itemIndex = filteredItems.indexOf(item); const isActive = itemIndex === activeIndex; const to = item.to; const assignRef = (node: HTMLElement | null) => { if (node) { optionRefs.current.set(item.id, node); } else { optionRefs.current.delete(item.id); } }; const optionClassName = cn( "group flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150", isActive ? "bg-white/[0.09] text-white shadow-[0_0_0_1px_rgba(255,255,255,0.08)_inset]" : "text-[var(--oh-muted)] hover:bg-white/[0.05] hover:text-white", ); const content = ( <> {commandMenuItemCopy( item.title, item.titleKey, t, )} {commandMenuItemCopy( item.description, item.descriptionKey, t, )} {to ? t(I18nKey.COMMAND_MENU$GO_HINT) : t(I18nKey.COMMAND_MENU$RUN_HINT)} ); if (to) { return ( setActiveIndex(itemIndex)} onClick={(event) => { if ( event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } event.preventDefault(); runItem(item); }} className={optionClassName} > {content} ); } return ( ); })}
); }) )}
{t(I18nKey.COMMAND_MENU$FOOTER_HINT)}
, document.body, ); }