import { useInput } from "ink"; import { useCallback, useMemo, useRef } from "react"; type KeyHandler = (input: string, key: Parameters[0]>[1]) => void; type ShortcutDefinition = { key: string | null; ctrl: boolean; meta: boolean; shift: boolean; }; type Shortcut = string | string[]; const HOME_SEQUENCES = new Set(["\u001b[H", "\u001b[1~", "\u001bOH"]); const END_SEQUENCES = new Set(["\u001b[F", "\u001b[4~", "\u001bOF"]); const PAGE_UP_SEQUENCES = new Set(["\u001b[5~"]); const PAGE_DOWN_SEQUENCES = new Set(["\u001b[6~"]); const KEY_ALIASES: Record = { leftarrow: "left", rightarrow: "right", uparrow: "up", downarrow: "down", return: "enter", enter: "enter", esc: "escape", spacebar: "space", pagedown: "pagedown", pageup: "pageup", }; // Keys that are considered non-printable/navigation and for which we ignore extra modifiers // when matching (i.e., allow escape/home/end/pageup/pagedown/tab/enter/delete/backspace to // match even if the terminal sets stray meta/shift/ctrl flags). const RELAXED_KEYS = new Set([ "escape", "left", "right", "up", "down", "home", "end", "pageup", "pagedown", "tab", "enter", "delete", "backspace", ]); function parseShortcut(shortcut: string): ShortcutDefinition { let tokens = shortcut .trim() .toLowerCase() .split("+") .map((token) => token.trim()) .filter(Boolean); let definition: ShortcutDefinition = { key: null, ctrl: false, meta: false, shift: false, }; for (let token of tokens) { if (token === "ctrl" || token === "control") { definition.ctrl = true; continue; } if (token === "meta" || token === "cmd" || token === "command") { definition.meta = true; continue; } if (token === "shift") { definition.shift = true; continue; } let alias = KEY_ALIASES[token]; definition.key = alias ?? token; } return definition; } function getInputKey(input: string, key: Parameters[1]): string | null { if (key.escape) { return "escape"; } if (key.leftArrow) { return "left"; } if (key.rightArrow) { return "right"; } if (key.upArrow) { return "up"; } if (key.downArrow) { return "down"; } if (key.pageUp || PAGE_UP_SEQUENCES.has(input)) { return "pageup"; } if (key.pageDown || PAGE_DOWN_SEQUENCES.has(input)) { return "pagedown"; } if (key.return) { return "enter"; } if (key.delete) { return "delete"; } if (key.backspace) { return "backspace"; } if (key.tab) { return "tab"; } if (key.shift && !input) { return null; } if (input === " ") { return "space"; } if (HOME_SEQUENCES.has(input) || ((key as { home?: boolean }).home ?? false)) { return "home"; } if (END_SEQUENCES.has(input) || ((key as { end?: boolean }).end ?? false)) { return "end"; } if (input) { return input.toLowerCase(); } return null; } function matchesShortcut(definition: ShortcutDefinition, input: string, key: Parameters[1]): boolean { let actualKey = getInputKey(input, key); // If the terminal reports a key we treat as non-printable/navigation, // relax extra modifiers (only require those explicitly specified), // but for printable keys we require an exact modifier match. let relaxed = actualKey !== null && RELAXED_KEYS.has(actualKey); // Require specified modifiers to be present if (definition.ctrl && !key.ctrl) return false; if (definition.meta && !key.meta) return false; if (definition.shift && !key.shift) return false; // For printable keys (non-relaxed), disallow extra modifiers if (!relaxed) { if (!definition.ctrl && key.ctrl) return false; if (!definition.meta && key.meta) return false; if (!definition.shift && key.shift) return false; } if (definition.key === null) { // Modifiers-only shortcut: match only if we identified some key press // (prevents bare modifier from triggering) return actualKey !== null; } return actualKey === definition.key; } function normalizeShortcuts(shortcuts: Shortcut): ShortcutDefinition[] { let entries = Array.isArray(shortcuts) ? shortcuts : [shortcuts]; return entries.map(parseShortcut); } export function useCommand(shortcut: Shortcut, handler: () => void | Promise, enabled = true) { let definitions = useMemo(() => normalizeShortcuts(shortcut), [shortcut]); let callback = useRef(handler); callback.current = handler; let inputHandler: KeyHandler = useCallback( (input, key) => { for (let definition of definitions) { if (matchesShortcut(definition, input, key)) { callback.current(); return; } } }, [definitions], ); useInput(inputHandler, { isActive: enabled }); }