import { isMacOS } from '@/lib/utils'; import { isDesktopShell } from '@/lib/desktop'; type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; type ShortcutKey = string; export type ShortcutCombo = string; export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; export interface ShortcutAction { id: string; defaultCombo: ShortcutCombo; label: string; description?: string; customizable?: boolean; } interface ParsedShortcut { modifiers: Set; key: ShortcutKey; } const MODIFIER_KEY_MAP: Record = { 'mod': 'mod', 'shift': 'shift', 'alt': 'alt', 'option': 'alt', 'ctrl': 'ctrl', 'meta': 'mod', 'cmd': 'mod', 'command': 'mod', }; const DISPLAY_LABEL_MAP: Record = { 'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl', 'shift': '⇧', 'alt': '⌥', 'option': '⌥', 'ctrl': '⌃', }; // Physical `event.key` values (lowercased) that satisfy each modifier while a // chord is being held. `mod` maps to the platform primary key; on web macOS it // accepts either Meta or Ctrl, matching eventMatchesShortcut. const MODIFIER_KEY_ALIASES: Record = { 'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'], 'shift': ['shift'], 'alt': ['alt'], 'option': ['alt'], 'ctrl': ['control'], }; const KEY_LABEL_MAP: Record = { 'comma': ',', 'period': '.', 'enter': 'Enter', 'escape': 'Esc', 'tab': 'Tab', 'space': 'Space', 'backspace': '⌫', 'delete': '⌦', 'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→', 'home': 'Home', 'end': 'End', 'pageup': 'Page Up', 'pagedown': 'Page Down', }; const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt']; const SHIFTED_KEY_BASE_MAP: Record = { '{': '[', '}': ']', ':': ';', '"': "'", '<': ',', '>': '.', '?': '/', '|': '\\', '~': '`', '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', }; function isUnassignedShortcut(combo: ShortcutCombo): boolean { return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT; } export function keyToShortcutToken(key: string): string { const lowered = key.toLowerCase(); if (lowered === ',') return 'comma'; if (lowered === '.') return 'period'; if (lowered === ' ') return 'space'; if (lowered === 'esc') return 'escape'; if (lowered === '+') return 'plus'; if (lowered === '-' || lowered === '_') return 'minus'; if (lowered === 'arrowup') return 'arrowup'; if (lowered === 'arrowdown') return 'arrowdown'; if (lowered === 'arrowleft') return 'arrowleft'; if (lowered === 'arrowright') return 'arrowright'; return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered; } const SHORTCUT_ACTIONS: ReadonlyArray = [ { id: 'open_go_to_line', defaultCombo: 'alt+g', label: 'Go to line (files editor)', description: 'Open go to line in the files editor', customizable: true, }, { id: 'open_command_palette', defaultCombo: 'mod+p', label: 'Open command palette', description: 'Open the command palette', customizable: true, }, { id: 'focus_input', defaultCombo: 'mod+i', label: 'Focus input', description: 'Focus the chat input field', customizable: true, }, { id: 'open_status', defaultCombo: 'mod+shift+o', label: 'Open OpenCode status', description: 'Open the OpenCode status dialog', }, { id: 'open_settings', defaultCombo: 'mod+comma', label: 'Open settings', description: 'Open the settings panel', customizable: true, }, { id: 'toggle_terminal', defaultCombo: 'mod+j', label: 'Toggle terminal dock', description: 'Toggle the bottom terminal dock', customizable: true, }, { id: 'toggle_terminal_expanded', defaultCombo: 'mod+shift+j', label: 'Toggle terminal expanded', description: 'Toggle terminal expanded or collapsed', customizable: true, }, { id: 'toggle_files', defaultCombo: 'mod+shift+f', label: 'Toggle files', description: 'Toggle the files panel', }, { id: 'add_selection_to_chat', defaultCombo: 'mod+l', label: 'Add selection to chat', description: 'Add the selected text to the chat input', customizable: true, }, { id: 'toggle_sidebar', defaultCombo: 'mod+alt+l', label: 'Toggle sidebar', description: 'Toggle the session sidebar', customizable: true, }, { id: 'open_timeline_dialog', defaultCombo: 'mod+t', label: 'Open conversation timeline', description: 'Search and navigate within current conversation', customizable: true, }, { id: 'toggle_prompt_navigator', defaultCombo: 'mod+alt+p', label: 'Toggle prompt navigator', description: 'Show or hide the prompt navigator panel in chat', customizable: true, }, { id: 'toggle_right_sidebar', defaultCombo: 'mod+b', label: 'Toggle right sidebar', description: 'Toggle the right sidebar', customizable: true, }, { id: 'open_right_sidebar_git', defaultCombo: 'mod+shift+g', label: 'Open right sidebar Git tab', description: 'Open right sidebar and select Git', customizable: true, }, { id: 'open_right_sidebar_files', defaultCombo: 'mod+shift+f', label: 'Open right sidebar Files tab', description: 'Open right sidebar and select Files', customizable: true, }, { id: 'switch_context_surface', defaultCombo: 'mod', label: 'Switch context panel surface', description: 'Hold the modifier and press a number to open or close the matching rail icon', customizable: true, }, { id: 'new_chat', defaultCombo: 'mod+n', label: 'New session', description: 'Start a new session', customizable: true, }, { id: 'new_chat_worktree', defaultCombo: 'mod+shift+n', label: 'New worktree draft', description: 'Create a new worktree and open a draft in it', customizable: true, }, { id: 'close_session_tab', defaultCombo: 'alt+w', label: 'Close session tab', description: 'Close the active session tab in the header (the session itself stays)', customizable: true, }, { id: 'new_mini_chat', defaultCombo: 'mod+alt+n', label: 'New Mini Chat window', description: 'Open a new Mini Chat draft window', customizable: true, }, { id: 'submit_message', defaultCombo: 'mod+enter', label: 'Submit message', description: 'Submit the current message', }, { id: 'clear_input', defaultCombo: 'escape', label: 'Clear input', description: 'Clear the input field', }, { id: 'open_help', defaultCombo: 'mod+.', label: 'Open keyboard shortcuts', description: 'Show the keyboard shortcuts help', customizable: true, }, { id: 'toggle_context_plan', defaultCombo: 'mod+shift+p', label: 'Toggle plan context panel', description: 'Open or close plan in the context panel', customizable: true, }, { id: 'toggle_services_menu', defaultCombo: 'mod+shift+s', label: 'Toggle services menu', description: 'Open or close the services menu', customizable: true, }, { id: 'cycle_services_tab', defaultCombo: 'mod+shift+[', label: 'Cycle services tab', description: 'Cycle through tabs in the services menu', customizable: true, }, { id: 'cycle_theme', defaultCombo: 'mod+/', label: 'Cycle theme', description: 'Cycle between light, dark, and system theme', customizable: true, }, { id: 'open_model_selector', defaultCombo: 'mod+shift+m', label: 'Open model selector', description: 'Open model selector while in chat', customizable: true, }, { id: 'cycle_thinking_variant', defaultCombo: 'mod+shift+t', label: 'Cycle thinking variant', description: 'Cycle thinking variant while in chat', }, { id: 'cycle_agent', defaultCombo: 'tab', label: 'Cycle agent', description: 'Cycle agent while the model selector is open', customizable: true, }, { id: 'cycle_favorite_model_forward', defaultCombo: 'ctrl+]', label: 'Cycle favorite model forward', description: 'Cycle forward through starred models without opening the picker', customizable: true, }, { id: 'cycle_favorite_model_backward', defaultCombo: 'ctrl+[', label: 'Cycle favorite model backward', description: 'Cycle backward through starred models without opening the picker', customizable: true, }, { id: 'expand_input', defaultCombo: 'mod+shift+e', label: 'Expand input', description: 'Toggle focus mode for the chat input', customizable: true, }, { id: 'toggle_dictation', defaultCombo: 'mod+alt+v', label: 'Voice input', description: 'Start dictation; press again to confirm and insert the transcript', customizable: true, }, { id: 'abort_run', defaultCombo: 'escape', label: 'Abort active run', description: 'Abort the currently running task (double press)', }, ] as const; export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { if (isUnassignedShortcut(combo)) { return UNASSIGNED_SHORTCUT; } const rawParts = combo .toLowerCase() .trim() .split('+') .map((part) => part.trim()) .filter(Boolean); const modifiers = new Set(); let key = ''; for (const rawPart of rawParts) { const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart; const modifier = MODIFIER_KEY_MAP[part]; if (modifier) { modifiers.add(modifier); continue; } key = part; } const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier)); return [...orderedModifiers, key].filter(Boolean).join('+'); } function isValidShortcutCombo(combo: ShortcutCombo): boolean { if (isUnassignedShortcut(combo)) { return true; } const parsed = parseShortcut(combo); return parsed.key.trim().length > 0; } function parseShortcut(combo: ShortcutCombo): ParsedShortcut { if (isUnassignedShortcut(combo)) { return { modifiers: new Set(), key: UNASSIGNED_SHORTCUT }; } const normalized = normalizeCombo(combo); const parts = normalized.split('+'); const modifiers: Set = new Set(); let key: ShortcutKey = ''; for (const part of parts) { const modifier = MODIFIER_KEY_MAP[part]; if (modifier) { modifiers.add(modifier); } else { key = part; } } return { modifiers, key }; } export function formatShortcutForDisplay(combo: ShortcutCombo): string { if (isUnassignedShortcut(combo)) { return 'Unassigned'; } const parsed = parseShortcut(combo); if (!parsed.key && parsed.modifiers.size === 0) { return 'Unassigned'; } const parts: string[] = []; for (const modifier of MODIFIER_PRIORITY) { if (parsed.modifiers.has(modifier)) { parts.push(DISPLAY_LABEL_MAP[modifier]); } } if (parsed.key) { const keyLabel = KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase(); parts.push(keyLabel); } return parts.join(' + '); } export function getShortcutAction(id: string): ShortcutAction | undefined { return SHORTCUT_ACTIONS.find((action) => action.id === id); } export function getCustomizableShortcutActions(): ReadonlyArray { return SHORTCUT_ACTIONS.filter((action) => action.customizable === true); } export function getEffectiveShortcutCombo( actionId: string, overrides?: Record ): ShortcutCombo { const action = getShortcutAction(actionId); if (!action) { return ''; } const override = overrides?.[actionId]; if (typeof override === 'string') { if (override.trim().toLowerCase() === UNASSIGNED_SHORTCUT) { return ''; } const normalized = normalizeCombo(override); if (normalized === UNASSIGNED_SHORTCUT) { return UNASSIGNED_SHORTCUT; } if (isValidShortcutCombo(normalized)) { return normalized; } } return action.defaultCombo; } export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { if (isUnassignedShortcut(combo)) { return false; } const parsed = parseShortcut(combo); if (!parsed.modifiers.has('mod')) { return false; } const key = parsed.key.toLowerCase(); const dangerousPrimary = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); return dangerousPrimary.has(key) && !parsed.modifiers.has('shift') && !parsed.modifiers.has('alt'); } export function eventMatchesShortcut( event: KeyboardEvent | React.KeyboardEvent, shortcut: ShortcutAction | ShortcutCombo ): boolean { const combo = typeof shortcut === 'string' ? shortcut : shortcut.defaultCombo; if (isUnassignedShortcut(combo)) { return false; } const parsed = parseShortcut(combo); const expectedMod = parsed.modifiers.has('mod'); const expectedShift = parsed.modifiers.has('shift'); const expectedAlt = parsed.modifiers.has('alt'); const expectedCtrl = parsed.modifiers.has('ctrl'); const isDesktopMac = isMacOS() && isDesktopShell(); const isMac = isMacOS(); const modMatches = isDesktopMac ? event.metaKey : isMac ? (event.metaKey || event.ctrlKey) : event.ctrlKey; if (expectedMod && !modMatches) { return false; } if (!expectedMod && event.metaKey) { return false; } if (expectedShift !== event.shiftKey) { return false; } if (expectedAlt !== event.altKey) { return false; } if (expectedCtrl) { if (!event.ctrlKey) { return false; } } else { const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; if (event.ctrlKey && !ctrlUsedAsMod) { return false; } } let eventKeyRaw = event.key; if (event.altKey) { if (event.code.startsWith('Key') && event.code.length === 4) { eventKeyRaw = event.code.slice(3).toLowerCase(); } else if (event.code.startsWith('Digit') && event.code.length === 6) { eventKeyRaw = event.code.slice(5); } } const eventKey = keyToShortcutToken(eventKeyRaw); const expectedKey = keyToShortcutToken(parsed.key); return eventKey === expectedKey; } export function getModifierLabel(): string { return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; } /** * Resolves the configurable prefix for chord-style shortcuts such as * "switch context panel surface", where a trailing digit key completes the * combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the * bare `mod` primary key) are honored so the prefix can omit a primary key. * Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix. */ export function getEffectiveShortcutPrefix( actionId: string, overrides?: Record, ): ShortcutCombo { const action = getShortcutAction(actionId); if (!action) { return ''; } const override = overrides?.[actionId]; if (typeof override === 'string' && override.trim() !== '') { const normalized = normalizeCombo(override); if (normalized === UNASSIGNED_SHORTCUT) { return UNASSIGNED_SHORTCUT; } if (normalized) { const parsed = parseShortcut(normalized); if (parsed.modifiers.size > 0 || parsed.key) { return normalized; } } } return action.defaultCombo; } /** * True when the physical keys required to "arm" a prefix combo are currently * held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at * least one alias must be held. */ export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet): boolean { if (isUnassignedShortcut(prefixCombo)) { return false; } const parsed = parseShortcut(prefixCombo); for (const modifier of parsed.modifiers) { const aliases = MODIFIER_KEY_ALIASES[modifier]; if (!aliases.some((alias) => heldKeys.has(alias))) { return false; } } if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) { return false; } return true; } /** * Matches an activating keydown (the caller checks the event's own key, e.g. a * digit) against a chord prefix: the event's modifier state must match the * prefix's modifiers, and when the prefix has a primary key that key must * currently be held. */ export function eventMatchesShortcutPrefix( event: KeyboardEvent | React.KeyboardEvent, prefixCombo: ShortcutCombo, heldKeys?: ReadonlySet, ): boolean { if (isUnassignedShortcut(prefixCombo)) { return false; } const parsed = parseShortcut(prefixCombo); const expectedMod = parsed.modifiers.has('mod'); const expectedShift = parsed.modifiers.has('shift'); const expectedAlt = parsed.modifiers.has('alt'); const expectedCtrl = parsed.modifiers.has('ctrl'); const isDesktopMac = isMacOS() && isDesktopShell(); const isMac = isMacOS(); const modMatches = isDesktopMac ? event.metaKey : isMac ? (event.metaKey || event.ctrlKey) : event.ctrlKey; if (expectedMod && !modMatches) { return false; } if (!expectedMod && event.metaKey) { return false; } if (expectedShift !== event.shiftKey) { return false; } if (expectedAlt !== event.altKey) { return false; } if (expectedCtrl) { if (!event.ctrlKey) { return false; } } else { const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; if (event.ctrlKey && !ctrlUsedAsMod) { return false; } } if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) { return false; } return true; }