/* Copyright 2026 Marimo. All rights reserved. */ import { atom, useAtom, useAtomValue } from "jotai"; import { AlertTriangleIcon, EditIcon, XIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Kbd } from "@/components/ui/kbd"; import { hotkeysAtom, useResolvedMarimoConfig } from "@/core/config/config"; import type { UserConfig } from "@/core/config/config-schema"; import { getDefaultHotkey, type HotkeyAction, type HotkeyGroup, } from "@/core/hotkeys/hotkeys"; import { isPlatformMac } from "@/core/hotkeys/shortcuts"; import { useRequestClient } from "@/core/network/requests"; import { useDuplicateShortcuts } from "../../../hooks/useDuplicateShortcuts"; import { useHotkey } from "../../../hooks/useHotkey"; import { KeyboardHotkeys } from "../../shortcuts/renderShortcut"; import { Dialog, DialogContent, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, } from "../../ui/dialog"; import { DuplicateShortcutBanner } from "./duplicate-shortcut-banner"; export const keyboardShortcutsAtom = atom(false); export const KeyboardShortcuts: React.FC = () => { const [isOpen, setIsOpen] = useAtom(keyboardShortcutsAtom); const [editingShortcut, setEditingShortcut] = useState( null, ); const [newShortcut, setNewShortcut] = useState([]); const [config, setConfig] = useResolvedMarimoConfig(); const hotkeys = useAtomValue(hotkeysAtom); const { saveUserConfig } = useRequestClient(); const { duplicates, hasDuplicate, getDuplicatesFor } = useDuplicateShortcuts( hotkeys, "Markdown", ); useHotkey("global.showHelp", () => setIsOpen((v) => !v)); const saveConfigOptimistic = async (newConfig: Partial) => { const prevConfig = { ...config }; setConfig((prev) => ({ ...prev, ...newConfig })); await saveUserConfig({ config: newConfig }).catch((error) => { setConfig(prevConfig); throw error; }); }; const handleNewShortcut = async (shortcut: string[]) => { if (!editingShortcut) { return; } const shortcutString = shortcut.join("-"); const newConfig = { keymap: { ...config.keymap, overrides: { ...config.keymap.overrides, [editingShortcut]: shortcutString, }, }, }; setEditingShortcut(null); setNewShortcut([]); await saveConfigOptimistic(newConfig); }; const handleResetShortcut = async () => { if (!editingShortcut) { return; } const newConfig = { keymap: { ...config.keymap, overrides: { ...config.keymap.overrides, }, }, }; // oxlint-disable-next-line typescript/no-dynamic-delete delete newConfig.keymap.overrides[editingShortcut]; setEditingShortcut(null); setNewShortcut([]); await saveConfigOptimistic(newConfig); }; const handleResetAllShortcuts = async () => { if ( !window.confirm( "Are you sure you want to reset all shortcuts to their default values?", ) ) { return; } const newConfig: Partial = { keymap: { ...config.keymap, overrides: {}, }, }; setEditingShortcut(null); setNewShortcut([]); await saveConfigOptimistic(newConfig); }; if (!isOpen) { return null; } const renderItem = (action: HotkeyAction) => { const hotkey = hotkeys.getHotkey(action); if (editingShortcut === action) { const defaultHotkey = getDefaultHotkey(action); return (
{ e.preventDefault(); const next: string[] = []; // Skip if the key is a modifier key if ( e.key === "Meta" || e.key === "Control" || e.key === "Alt" || e.key === "Shift" ) { return; } if (e.metaKey) { next.push(isPlatformMac() ? "Cmd" : "Meta"); } if (e.ctrlKey) { next.push("Ctrl"); } if (e.altKey) { next.push("Alt"); } if (e.shiftKey) { next.push("Shift"); } // We don't allow `-` to be a shortcut key, since it's used to // separate keys in the shortcut string if (e.key === "-") { return; } // If escape is pressed, without any modifier keys, cancel editing // We don't allow escape to be a shortcut key along, since it's used to // remove focus from many elements if (e.key === "Escape" && next.length === 0) { setEditingShortcut(null); setNewShortcut([]); return; } // Single character keys are always lowercase (e.g. "a", "b", "c") // But we should preserve the original case for other keys (e.g. "Enter", "Escape") let key = e.key.length === 1 ? e.key.toLowerCase() : e.key; // Handle edge cases if (e.key === " ") { key = "Space"; } next.push(key); handleNewShortcut(next); }} autoFocus={true} endAdornment={ } />
Press a key combination {defaultHotkey.key !== hotkey.key && ( Reset to default:{" "} {defaultHotkey.key} )}
); } const isDuplicate = hasDuplicate(action); const duplicateActions = isDuplicate ? getDuplicatesFor(action) : []; return (
{hotkeys.isEditable(action) ? ( { setNewShortcut([]); setEditingShortcut(action); }} /> ) : (
)}
{hotkey.name.toLowerCase()} {isDuplicate && (
Also used by:{" "} {duplicateActions .map((a) => hotkeys.getHotkey(a).name.toLowerCase()) .join(", ")}
)}
); }; const groups = hotkeys.getHotkeyGroups(); const renderGroup = (group: HotkeyGroup, subHeader?: React.ReactNode) => { const items = groups[group]; return (

{group}

{subHeader}
{items.map(renderItem)}
); }; const renderCommandGroup = (group: HotkeyGroup) => renderGroup( group,

Press{" "} {config.keymap.preset === "vim" ? ( <> Esc ) : ( Esc )}{" "} in a cell to enter command mode

, ); return ( setIsOpen(open)}> {/* Manually portal so we can adjust positioning: shortcuts modal is too large to offset from top for some screens. */} Shortcuts
{renderGroup("Editing")} {renderGroup("Markdown")}
{renderGroup("Navigation")} {renderGroup("Running Cells")} {renderGroup("Creation and Ordering")} {renderCommandGroup("Command")} {renderGroup("Other")}
); };