diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 436d437f..6983131f 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -36,6 +36,7 @@ import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calcu import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; import { updateDesktopSettings } from '@/lib/persistence'; +import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { getAllModelFamilies, getDisplayModelName, @@ -148,6 +149,7 @@ export const Header: React.FC = () => { const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const activeMainTab = useUIStore((state) => state.activeMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const { getCurrentModel } = useConfigStore(); const runtimeApis = useRuntimeAPIs(); @@ -1064,6 +1066,10 @@ export const Header: React.FC = () => { return base; }, [diffFileCount, isMobile, showPlanTab]); + const shortcutLabel = React.useCallback((actionId: string) => { + return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); + }, [shortcutOverrides]); + useEffect(() => { if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files')) { setActiveMainTab('chat'); @@ -1114,6 +1120,65 @@ export const Header: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [tabs, setActiveMainTab, showProjectTabs, projects, activeProjectId, setActiveProject]); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides); + if (eventMatchesShortcut(e, toggleServicesCombo)) { + e.preventDefault(); + + if (isDesktopServicesOpen) { + setIsDesktopServicesOpen(false); + } else { + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + if (desktopServicesTab === 'usage' && quotaResults.length === 0) { + void fetchAllQuotas(); + } + } + return; + } + + const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides); + if (eventMatchesShortcut(e, cycleServicesCombo)) { + e.preventDefault(); + + const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>; + if (tabValues.length === 0) { + return; + } + + const currentIndex = tabValues.indexOf(desktopServicesTab); + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length; + const nextTab = tabValues[nextIndex]; + setDesktopServicesTab(nextTab); + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + if (nextTab === 'usage' && quotaResults.length === 0) { + void fetchAllQuotas(); + } + return; + } + + const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides); + if (eventMatchesShortcut(e, toggleContextPlanCombo)) { + e.preventDefault(); + handleOpenContextPlan(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + shortcutOverrides, + isDesktopServicesOpen, + desktopServicesTab, + servicesTabs, + quotaResults.length, + fetchAllQuotas, + refreshCurrentInstanceLabel, + handleOpenContextPlan, + ]); + const renderTab = (tab: TabConfig) => { const isActive = activeMainTab === tab.id; const isDiffTab = tab.icon === 'diff'; @@ -1453,14 +1518,21 @@ export const Header: React.FC = () => { role="tablist" aria-label="Main navigation" > - + + + + + +

Open sessions ({shortcutLabel('toggle_sidebar')})

+
+
{/* Project tabs */} {showProjectTabs && ( @@ -1683,7 +1755,7 @@ export const Header: React.FC = () => { -

Plan

+

Plan ({shortcutLabel('toggle_context_plan')})

)} @@ -1721,7 +1793,9 @@ export const Header: React.FC = () => { -

{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'}

+

+ {isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')}) +

{ -

Terminal panel

+

Terminal panel ({shortcutLabel('toggle_terminal')})

@@ -1970,7 +2044,7 @@ export const Header: React.FC = () => { -

Right sidebar

+

Right sidebar ({shortcutLabel('toggle_right_sidebar')})

@@ -2407,7 +2481,7 @@ export const Header: React.FC = () => { -

{updateAvailable ? 'Settings (Update available)' : 'Settings'}

+

{updateAvailable ? `Settings (Update available) (${shortcutLabel('open_settings')})` : `Settings (${shortcutLabel('open_settings')})`}

diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx new file mode 100644 index 00000000..70e1c6a1 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useUIStore } from '@/stores/useUIStore'; +import { + formatShortcutForDisplay, + getCustomizableShortcutActions, + getEffectiveShortcutCombo, + isRiskyBrowserShortcut, + keyToShortcutToken, + normalizeCombo, + UNASSIGNED_SHORTCUT, + type ShortcutCombo, +} from '@/lib/shortcuts'; + +const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); + +const keyboardEventToCombo = (event: React.KeyboardEvent): ShortcutCombo | null => { + if (MODIFIER_KEYS.has(event.key.toLowerCase())) { + return null; + } + + const parts: string[] = []; + + if (event.metaKey || event.ctrlKey) { + parts.push('mod'); + } + if (event.shiftKey) { + parts.push('shift'); + } + if (event.altKey) { + parts.push('alt'); + } + + const keyToken = keyToShortcutToken(event.key); + if (!keyToken) { + return null; + } + + parts.push(keyToken); + return normalizeCombo(parts.join('+')); +}; + +export const KeyboardShortcutsSettings: React.FC = () => { + const { + shortcutOverrides, + setShortcutOverride, + clearShortcutOverride, + resetAllShortcutOverrides, + } = useUIStore(); + + const actions = React.useMemo(() => getCustomizableShortcutActions(), []); + + const [capturingActionId, setCapturingActionId] = React.useState(null); + const [draftByAction, setDraftByAction] = React.useState>({}); + const [errorText, setErrorText] = React.useState(''); + const [warningText, setWarningText] = React.useState(''); + const [pendingOverwrite, setPendingOverwrite] = React.useState<{ + actionId: string; + combo: ShortcutCombo; + conflictActionId: string; + } | null>(null); + + const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => { + const normalized = normalizeCombo(combo); + for (const action of actions) { + if (action.id === actionId) { + continue; + } + const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides); + if (normalizeCombo(existing) === normalized) { + return action.id; + } + } + return null; + }, [actions, shortcutOverrides]); + + const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => { + const normalized = normalizeCombo(combo); + const conflictActionId = findConflict(actionId, normalized); + if (conflictActionId) { + setPendingOverwrite({ actionId, combo: normalized, conflictActionId }); + setErrorText(''); + return; + } + + setShortcutOverride(actionId, normalized); + setPendingOverwrite(null); + setErrorText(''); + setWarningText(isRiskyBrowserShortcut(normalized) ? 'This shortcut can conflict with browser defaults. It is still saved.' : ''); + setDraftByAction((current) => { + const rest = { ...current }; + delete rest[actionId]; + return rest; + }); + }, [findConflict, setShortcutOverride]); + + const confirmOverwrite = React.useCallback(() => { + if (!pendingOverwrite) { + return; + } + + setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT); + setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo); + setPendingOverwrite(null); + setErrorText(''); + setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? 'This shortcut can conflict with browser defaults. It is still saved.' : ''); + setDraftByAction((current) => { + const rest = { ...current }; + delete rest[pendingOverwrite.actionId]; + return rest; + }); + }, [pendingOverwrite, setShortcutOverride]); + + const resetOne = React.useCallback((actionId: string) => { + clearShortcutOverride(actionId); + setDraftByAction((current) => { + const rest = { ...current }; + delete rest[actionId]; + return rest; + }); + setPendingOverwrite(null); + setErrorText(''); + setWarningText(''); + }, [clearShortcutOverride]); + + return ( +
+
+

Keyboard Shortcuts

+

+ Capture a new key combo, save it, and the runtime/help/palette bindings update together. +

+
+ +
+ {actions.map((action) => { + const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides); + const draft = draftByAction[action.id]; + const displayCombo = draft ?? effective; + const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective); + + return ( +
+
+
+

{action.label}

+ {action.description && ( +

{action.description}

+ )} +
+
+ { + setCapturingActionId(action.id); + setErrorText(''); + }} + onBlur={() => { + if (capturingActionId === action.id) { + setCapturingActionId(null); + } + }} + onKeyDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === 'Escape') { + setCapturingActionId(null); + return; + } + + const combo = keyboardEventToCombo(event); + if (!combo) { + return; + } + + setDraftByAction((current) => ({ + ...current, + [action.id]: combo, + })); + setCapturingActionId(null); + setPendingOverwrite(null); + setErrorText(''); + }} + className="w-52" + /> + + +
+
+
+ ); + })} +
+ + {pendingOverwrite && ( +
+

+ This combo is already used by another shortcut. Overwrite and clear that other mapping? +

+
+ + +
+
+ )} + + {errorText && ( +
+ {errorText} +
+ )} + + {warningText && ( +
+ {warningText} +
+ )} + +
+ +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 4b3490d2..a1d54552 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -10,6 +10,7 @@ import { NotificationSettings } from './NotificationSettings'; import { GitHubSettings } from './GitHubSettings'; import { VoiceSettings } from './VoiceSettings'; import { OpenCodeCliSettings } from './OpenCodeCliSettings'; +import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; @@ -65,6 +66,8 @@ export const OpenChamberPage: React.FC = ({ section }) => return ; case 'sessions': return ; + case 'shortcuts': + return ; case 'git': return ; case 'github': @@ -91,6 +94,10 @@ export const OpenChamberPage: React.FC = ({ section }) => ); }; +const ShortcutsSectionContent: React.FC = () => { + return ; +}; + // Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile) const VisualSectionContent: React.FC = () => { return ; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx index 45e41c40..1249396c 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -8,7 +8,7 @@ import { AboutSettings } from './AboutSettings'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { cn } from '@/lib/utils'; -export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'github' | 'notifications' | 'voice'; +export type OpenChamberSection = 'visual' | 'chat' | 'shortcuts' | 'sessions' | 'git' | 'github' | 'notifications' | 'voice'; interface OpenChamberSidebarProps { selectedSection: OpenChamberSection; @@ -35,6 +35,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ label: 'Chat', items: ['Tools', 'Diff', 'Reasoning'], }, + { + id: 'shortcuts', + label: 'Shortcuts', + items: ['Keyboard', 'Overrides'], + }, { id: 'sessions', label: 'Sessions', diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 620b41ba..ef1671f9 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -15,9 +15,9 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useDeviceInfo } from '@/lib/device'; -import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react'; -import { getModifierLabel } from '@/lib/utils'; +import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; export const CommandPalette: React.FC = () => { const { @@ -29,6 +29,13 @@ export const CommandPalette: React.FC = () => { setSessionSwitcherOpen, setTimelineDialogOpen, toggleSidebar, + toggleRightSidebar, + setRightSidebarOpen, + setRightSidebarTab, + toggleBottomTerminal, + setBottomTerminalExpanded, + isBottomTerminalExpanded, + shortcutOverrides, } = useUIStore(); const { @@ -105,6 +112,33 @@ export const CommandPalette: React.FC = () => { handleClose(); }; + const handleToggleRightSidebar = () => { + toggleRightSidebar(); + handleClose(); + }; + + const handleOpenRightSidebarGit = () => { + setRightSidebarOpen(true); + setRightSidebarTab('git'); + handleClose(); + }; + + const handleOpenRightSidebarFiles = () => { + setRightSidebarOpen(true); + setRightSidebarTab('files'); + handleClose(); + }; + + const handleToggleTerminalDock = () => { + toggleBottomTerminal(); + handleClose(); + }; + + const handleToggleTerminalExpanded = () => { + setBottomTerminalExpanded(!isBottomTerminalExpanded); + handleClose(); + }; + const handleOpenTimeline = () => { setTimelineDialogOpen(true); handleClose(); @@ -115,6 +149,10 @@ export const CommandPalette: React.FC = () => { return directorySessions.slice(0, 5); }, [directorySessions]); + const shortcut = React.useCallback((actionId: string) => { + return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); + }, [shortcutOverrides]); + return ( @@ -125,51 +163,76 @@ export const CommandPalette: React.FC = () => { Open Session List - {getModifierLabel()} + L + {shortcut('toggle_sidebar')} New Session - {settingsAutoCreateWorktree ? `Shift + ${getModifierLabel()} + N` : `${getModifierLabel()} + N`} + {settingsAutoCreateWorktree ? shortcut('new_chat_worktree') : shortcut('new_chat')} New Session with Worktree - {settingsAutoCreateWorktree ? `${getModifierLabel()} + N` : `Shift + ${getModifierLabel()} + N`} + {settingsAutoCreateWorktree ? shortcut('new_chat') : shortcut('new_chat_worktree')} + + + Toggle Right Sidebar + {shortcut('toggle_right_sidebar')} + + + + Open Right Sidebar Git + {shortcut('open_right_sidebar_git')} + + + + Open Right Sidebar Files + {shortcut('open_right_sidebar_files')} + + + + Toggle Terminal Dock + {shortcut('toggle_terminal')} + + + + Toggle Terminal Expanded + {shortcut('toggle_terminal_expanded')} + Keyboard Shortcuts - {getModifierLabel()} + . + {shortcut('open_help')} Open Diff Panel - {getModifierLabel()} + 2 + {shortcut('open_diff_panel')} Open Terminal - {getModifierLabel()} + 3 + {shortcut('open_terminal_panel')} Open Git Panel - {getModifierLabel()} + 4 + {shortcut('open_git_panel')} Open Timeline - {getModifierLabel()} + T + {shortcut('open_timeline')} Open Settings - {getModifierLabel()} + , + {shortcut('open_settings')} diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index e4456872..e51293de 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -12,66 +12,31 @@ import { RiAddLine, RiAiAgentLine, RiAiGenerate2, - RiArrowUpSLine, RiBrainAi3Line, RiCloseCircleLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, + RiLayoutRightLine, RiPaletteLine, RiQuestionLine, RiSettings3Line, + RiStackLine, RiText, RiTimeLine, RiWindowLine, } from "@remixicon/react"; -import { getModifierLabel } from "@/lib/utils"; - -const renderKeyToken = (token: string, index: number) => { - const normalized = token.trim().toLowerCase(); - - if (normalized === "ctrl" || normalized === "control") { - return ; - } - - if ( - normalized === "⌘" || - normalized === "cmd" || - normalized === "command" || - normalized === "meta" - ) { - return ; - } - - return ( - - {token.trim()} - - ); -}; - -const renderKeyCombo = (combo: string) => { - const tokens = combo - .split("+") - .map((token) => token.trim()) - .filter(Boolean); - if (tokens.length === 0) { - return combo.trim(); - } - - return tokens.map((token, index) => ( - - {index > 0 && ( - + - )} - {renderKeyToken(token, index)} - - )); -}; +import { + getEffectiveShortcutCombo, + getShortcutAction, + getModifierLabel, + formatShortcutForDisplay, +} from "@/lib/shortcuts"; type ShortcutIcon = React.ComponentType<{ className?: string }>; type ShortcutItem = { + id?: string; keys: string | string[]; description: string; icon: ShortcutIcon | null; @@ -82,10 +47,14 @@ type ShortcutSection = { items: ShortcutItem[]; }; -export const HelpDialog: React.FC = () => { - const { isHelpDialogOpen, setHelpDialogOpen } = useUIStore(); - const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); +const renderShortcut = (id: string, fallbackCombo: string, overrides: Record) => { + const action = getShortcutAction(id); + return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo; +}; +export const HelpDialog: React.FC = () => { + const { isHelpDialogOpen, setHelpDialogOpen, shortcutOverrides } = useUIStore(); + const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); const mod = getModifierLabel(); const shortcuts: ShortcutSection[] = [ @@ -93,34 +62,39 @@ export const HelpDialog: React.FC = () => { category: "Navigation & Commands", items: [ { - keys: [`${mod} + K`], + id: 'open_command_palette', description: "Open Command Palette", icon: RiCommandLine, + keys: '', }, { - keys: [`${mod} + .`], + id: 'open_help', description: "Show Keyboard Shortcuts (this dialog)", icon: RiQuestionLine, + keys: '', }, { - keys: [`${mod} + L`], + id: 'toggle_sidebar', description: "Toggle Session Sidebar", icon: RiLayoutLeftLine, + keys: '', }, { - keys: ["Shift + Tab"], + keys: ["Tab"], description: "Cycle Agent (chat input)", icon: RiAiAgentLine, }, { - keys: [`Shift + ${mod} + M`], + id: 'open_model_selector', description: "Open Model Selector", icon: RiAiGenerate2, + keys: '', }, { - keys: [`Shift + ${mod} + T`], + id: 'cycle_thinking_variant', description: "Cycle Thinking Variant", icon: RiBrainAi3Line, + keys: '', }, { keys: [`Shift + Alt + ${mod} + N`], @@ -133,20 +107,70 @@ export const HelpDialog: React.FC = () => { category: "Session Management", items: [ { - keys: [`${mod} + N`], + id: 'new_chat', description: settingsAutoCreateWorktree ? "Create New Session in Worktree" : "Create New Session", icon: settingsAutoCreateWorktree ? RiGitBranchLine : RiAddLine, + keys: '', }, { - keys: [`Shift + ${mod} + N`], + id: 'new_chat_worktree', description: settingsAutoCreateWorktree ? "Create New Session" : "Create New Session in Worktree", icon: settingsAutoCreateWorktree ? RiAddLine : RiGitBranchLine, + keys: '', }, - { keys: [`${mod} + I`], description: "Focus Chat Input", icon: RiText }, + { id: 'focus_input', description: "Focus Chat Input", icon: RiText, keys: '' }, { - keys: ["Esc + Esc"], + id: 'abort_run', description: "Abort active run (double press)", icon: RiCloseCircleLine, + keys: '', + }, + ], + }, + { + category: "Panels", + items: [ + { + id: 'toggle_right_sidebar', + description: 'Toggle Right Sidebar', + icon: RiLayoutRightLine, + keys: '', + }, + { + id: 'open_right_sidebar_git', + description: 'Open Right Sidebar Git Tab', + icon: RiGitBranchLine, + keys: '', + }, + { + id: 'open_right_sidebar_files', + description: 'Open Right Sidebar Files Tab', + icon: RiLayoutRightLine, + keys: '', + }, + { + id: 'cycle_right_sidebar_tab', + description: 'Cycle Right Sidebar Tab', + icon: RiLayoutRightLine, + keys: '', + }, + { + id: 'toggle_terminal', + description: 'Toggle Terminal Dock', + icon: RiWindowLine, + keys: '', + }, + { + id: 'toggle_terminal_expanded', + description: 'Toggle Terminal Expanded', + icon: RiWindowLine, + keys: '', + }, + { + id: 'toggle_context_plan', + description: 'Toggle Plan Context Panel', + icon: RiTimeLine, + keys: '', }, ], }, @@ -154,9 +178,10 @@ export const HelpDialog: React.FC = () => { category: "Interface", items: [ { - keys: [`${mod} + /`], + id: 'cycle_theme', description: "Cycle Theme (Light → Dark → System)", icon: RiPaletteLine, + keys: '', }, { keys: [`${mod} + 1...9`], @@ -164,14 +189,28 @@ export const HelpDialog: React.FC = () => { icon: RiLayoutLeftLine, }, { - keys: [`${mod} + T`], + id: 'open_timeline', description: "Open Timeline", icon: RiTimeLine, + keys: '', }, { - keys: [`${mod} + ,`], + id: 'toggle_services_menu', + description: 'Toggle Services Menu', + icon: RiStackLine, + keys: '', + }, + { + id: 'cycle_services_tab', + description: 'Cycle Services Tab', + icon: RiStackLine, + keys: '', + }, + { + id: 'open_settings', description: "Open Settings", icon: RiSettings3Line, + keys: '', }, ], }, @@ -198,38 +237,41 @@ export const HelpDialog: React.FC = () => { {section.category}
- {section.items.map((shortcut, index) => ( -
-
- {shortcut.icon && ( - - )} - - {shortcut.description} - + {section.items.map((shortcut, index) => { + const displayKeys = shortcut.id + ? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides) + : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); + + return ( +
+
+ {shortcut.icon && ( + + )} + + {shortcut.description} + +
+
+ {(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => ( + + {i > 0 && ( + + or + + )} + + {keyCombo} + + + ))} +
-
- {(Array.isArray(shortcut.keys) - ? shortcut.keys - : shortcut.keys.split(" / ") - ).map((keyCombo: string, i: number) => ( - - {i > 0 && ( - - or - - )} - - {renderKeyCombo(keyCombo)} - - - ))} -
-
- ))} + ); + })}
))} @@ -242,7 +284,7 @@ export const HelpDialog: React.FC = () => {

Pro Tips:

  • - • Use Command Palette ({mod} + K) to quickly access all + • Use Command Palette ({renderShortcut('open_command_palette', `${mod} K`, shortcutOverrides)}) to quickly access all actions
  • diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 826430ad..94d0c1e0 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -3,11 +3,11 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; -import { hasModifier } from '@/lib/utils'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { useConfigStore } from '@/stores/useConfigStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { showOpenCodeStatus } from '@/lib/openCodeStatus'; +import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; export const useKeyboardShortcuts = () => { const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore(); @@ -15,15 +15,26 @@ export const useKeyboardShortcuts = () => { toggleCommandPalette, toggleHelpDialog, toggleSidebar, + toggleRightSidebar, + setRightSidebarOpen, + setRightSidebarTab, + toggleBottomTerminal, + setBottomTerminalExpanded, setSessionSwitcherOpen, setActiveMainTab, setSettingsDialogOpen, setModelSelectorOpen, + shortcutOverrides, } = useUIStore(); const { themeMode, setThemeMode } = useThemeSystem(); const { working } = useAssistantStatus(); const abortPrimedUntilRef = React.useRef(null); const abortPrimedTimeoutRef = React.useRef | null>(null); + const themeModeRef = React.useRef(themeMode); + + React.useEffect(() => { + themeModeRef.current = themeMode; + }, [themeMode]); const resetAbortPriming = React.useCallback(() => { if (abortPrimedTimeoutRef.current) { @@ -35,70 +46,86 @@ export const useKeyboardShortcuts = () => { }, [clearAbortPrompt]); React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { + const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides); - if (hasModifier(e) && e.key === 'k') { + const handleKeyDown = (e: KeyboardEvent) => { + if (eventMatchesShortcut(e, combo('open_command_palette'))) { e.preventDefault(); toggleCommandPalette(); + return; } - if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') { + if (eventMatchesShortcut(e, combo('open_status'))) { e.preventDefault(); void showOpenCodeStatus(); return; } - if (hasModifier(e) && e.key === '.') { + if (eventMatchesShortcut(e, combo('open_help'))) { e.preventDefault(); toggleHelpDialog(); + return; } - if (hasModifier(e) && e.key.toLowerCase() === 'n') { + if (eventMatchesShortcut(e, combo('new_chat')) || eventMatchesShortcut(e, combo('new_chat_worktree'))) { e.preventDefault(); - + const isVSCode = isVSCodeRuntime(); const autoWorktree = useConfigStore.getState().settingsAutoCreateWorktree; - // If autoWorktree is true: Cmd+N -> Worktree, Cmd+Shift+N -> Standard - // If autoWorktree is false: Cmd+N -> Standard, Cmd+Shift+N -> Worktree - // VS Code: always open standard session (no worktree support) - const shouldCreateWorktree = isVSCode ? false : (autoWorktree ? !e.shiftKey : e.shiftKey); + const matchedPrimaryShortcut = eventMatchesShortcut(e, combo('new_chat')); + const shouldCreateWorktree = isVSCode + ? false + : (matchedPrimaryShortcut ? autoWorktree : !autoWorktree); if (shouldCreateWorktree) { - // Create new session with auto-generated worktree setActiveMainTab('chat'); setSessionSwitcherOpen(false); createWorktreeSession(); return; } - // Open a new session without worktree + setActiveMainTab('chat'); setSessionSwitcherOpen(false); openNewSessionDraft(); + return; } - if (hasModifier(e) && e.key === '/') { + if (eventMatchesShortcut(e, combo('cycle_theme'))) { e.preventDefault(); const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; - const currentIndex = modes.indexOf(themeMode); + const activeElement = document.activeElement as HTMLElement | null; + const currentIndex = modes.indexOf(themeModeRef.current); const nextIndex = (currentIndex + 1) % modes.length; setThemeMode(modes[nextIndex]); + requestAnimationFrame(() => { + if (typeof document === 'undefined' || typeof window === 'undefined') { + return; + } + if (!document.hasFocus()) { + window.focus(); + } + if (activeElement && document.contains(activeElement)) { + activeElement.focus({ preventScroll: true }); + } + }); + return; } - if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') { + if (eventMatchesShortcut(e, combo('open_timeline'))) { e.preventDefault(); const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState(); setTimelineDialogOpen(!isTimelineDialogOpen); return; } - if (hasModifier(e) && !e.shiftKey && e.key === ',') { + if (eventMatchesShortcut(e, combo('open_settings'))) { e.preventDefault(); const { isSettingsDialogOpen } = useUIStore.getState(); setSettingsDialogOpen(!isSettingsDialogOpen); return; } - if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'l') { + if (eventMatchesShortcut(e, combo('toggle_sidebar'))) { e.preventDefault(); const { isMobile, isSessionSwitcherOpen } = useUIStore.getState(); if (isMobile) { @@ -109,15 +136,83 @@ export const useKeyboardShortcuts = () => { return; } - if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'i') { + if (eventMatchesShortcut(e, combo('focus_input'))) { e.preventDefault(); const textarea = document.querySelector('textarea[data-chat-input="true"]'); textarea?.focus(); return; } + if (eventMatchesShortcut(e, combo('toggle_right_sidebar'))) { + const { isMobile } = useUIStore.getState(); + if (isMobile) { + return; + } + e.preventDefault(); + toggleRightSidebar(); + return; + } + + if (eventMatchesShortcut(e, combo('open_right_sidebar_git'))) { + const { isMobile } = useUIStore.getState(); + if (isMobile) { + return; + } + e.preventDefault(); + setRightSidebarOpen(true); + setRightSidebarTab('git'); + return; + } + + if (eventMatchesShortcut(e, combo('open_right_sidebar_files'))) { + const { isMobile } = useUIStore.getState(); + if (isMobile) { + return; + } + e.preventDefault(); + setRightSidebarOpen(true); + setRightSidebarTab('files'); + return; + } + + if (eventMatchesShortcut(e, combo('cycle_right_sidebar_tab'))) { + const { isMobile, rightSidebarTab } = useUIStore.getState(); + if (isMobile) { + return; + } + + const tabs = ['git', 'files'] as const; + const currentIndex = tabs.indexOf(rightSidebarTab); + const nextTab = tabs[(currentIndex + 1) % tabs.length]; + + e.preventDefault(); + setRightSidebarOpen(true); + setRightSidebarTab(nextTab); + return; + } + + if (eventMatchesShortcut(e, combo('toggle_terminal'))) { + const { isMobile } = useUIStore.getState(); + if (isMobile) { + return; + } + e.preventDefault(); + toggleBottomTerminal(); + return; + } + + if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) { + const { isMobile, isBottomTerminalExpanded } = useUIStore.getState(); + if (isMobile) { + return; + } + e.preventDefault(); + setBottomTerminalExpanded(!isBottomTerminalExpanded); + return; + } + // Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays) - if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'm') { + if (eventMatchesShortcut(e, combo('open_model_selector'))) { const { isSettingsDialogOpen, isCommandPaletteOpen, @@ -147,7 +242,7 @@ export const useKeyboardShortcuts = () => { } // Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M) - if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 't') { + if (eventMatchesShortcut(e, combo('cycle_thinking_variant'))) { const { isSettingsDialogOpen, isCommandPaletteOpen, @@ -276,16 +371,21 @@ export const useKeyboardShortcuts = () => { toggleCommandPalette, toggleHelpDialog, toggleSidebar, + toggleRightSidebar, + setRightSidebarOpen, + setRightSidebarTab, + toggleBottomTerminal, + setBottomTerminalExpanded, setSessionSwitcherOpen, setActiveMainTab, setSettingsDialogOpen, setModelSelectorOpen, setThemeMode, - themeMode, working, armAbortPrompt, resetAbortPriming, currentSessionId, + shortcutOverrides, ]); React.useEffect(() => { diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts new file mode 100644 index 00000000..9c6e392e --- /dev/null +++ b/packages/ui/src/lib/shortcuts.ts @@ -0,0 +1,569 @@ +import { isMacOS } from '@/lib/utils'; +import { isTauriShell } from '@/lib/desktop'; + +export type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; +export 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; +} + +export 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() && isTauriShell() ? '⌘' : 'Ctrl', + 'shift': '⇧', + 'alt': '⌥', + 'option': '⌥', + 'ctrl': '⌃', +}; + +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_command_palette', + defaultCombo: 'mod+k', + 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+l', + 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: 'toggle_sidebar', + defaultCombo: 'mod+l', + label: 'Toggle sidebar', + description: 'Toggle the session sidebar', + 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: 'cycle_right_sidebar_tab', + defaultCombo: 'mod+shift+]', + label: 'Cycle right sidebar tab', + description: 'Cycle through right sidebar tabs', + 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 session with worktree', + description: 'Start a new session in a worktree', + 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_diff_panel', + defaultCombo: 'mod+2', + label: 'Open diff panel', + description: 'Switch to the diff panel', + }, + { + id: 'open_terminal_panel', + defaultCombo: 'mod+3', + label: 'Open terminal panel', + description: 'Switch to the terminal panel', + }, + { + id: 'open_git_panel', + defaultCombo: 'mod+4', + label: 'Open git panel', + description: 'Switch to the git panel', + }, + { + id: 'open_timeline', + defaultCombo: 'mod+t', + label: 'Open timeline', + description: 'Open the timeline dialog', + customizable: true, + }, + { + 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', + }, + { + id: 'cycle_thinking_variant', + defaultCombo: 'mod+shift+t', + label: 'Cycle thinking variant', + description: 'Cycle thinking variant while in chat', + }, + { + id: 'abort_run', + defaultCombo: 'escape', + label: 'Abort active run', + description: 'Abort the currently running task (double press)', + }, + { + id: 'switch_tab_1', + defaultCombo: 'mod+1', + label: 'Switch to tab 1', + description: 'Switch to the first tab or project', + }, + { + id: 'switch_tab_2', + defaultCombo: 'mod+2', + label: 'Switch to tab 2', + description: 'Switch to the second tab or project', + }, + { + id: 'switch_tab_3', + defaultCombo: 'mod+3', + label: 'Switch to tab 3', + description: 'Switch to the third tab or project', + }, + { + id: 'switch_tab_4', + defaultCombo: 'mod+4', + label: 'Switch to tab 4', + description: 'Switch to the fourth tab or project', + }, + { + id: 'switch_tab_5', + defaultCombo: 'mod+5', + label: 'Switch to tab 5', + description: 'Switch to the fifth tab or project', + }, + { + id: 'switch_tab_6', + defaultCombo: 'mod+6', + label: 'Switch to tab 6', + description: 'Switch to the sixth tab or project', + }, + { + id: 'switch_tab_7', + defaultCombo: 'mod+7', + label: 'Switch to tab 7', + description: 'Switch to the seventh tab or project', + }, + { + id: 'switch_tab_8', + defaultCombo: 'mod+8', + label: 'Switch to tab 8', + description: 'Switch to the eighth tab or project', + }, + { + id: 'switch_tab_9', + defaultCombo: 'mod+9', + label: 'Switch to tab 9', + description: 'Switch to the ninth tab or project', + }, +] 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('+'); +} + +export function isValidShortcutCombo(combo: ShortcutCombo): boolean { + if (isUnassignedShortcut(combo)) { + return true; + } + + const parsed = parseShortcut(combo); + return parsed.key.trim().length > 0; +} + +export 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 getAllShortcutActions(): ReadonlyArray { + return SHORTCUT_ACTIONS; +} + +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 getEffectiveShortcutLabel( + actionId: string, + overrides?: Record +): string { + const combo = getEffectiveShortcutCombo(actionId, overrides); + if (!combo) { + return ''; + } + return formatShortcutForDisplay(combo); +} + +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() && isTauriShell(); + 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; + } + } + + const eventKey = keyToShortcutToken(event.key); + const expectedKey = keyToShortcutToken(parsed.key); + + return eventKey === expectedKey; +} + +export function getShortcutLabel(id: string): string { + const action = getShortcutAction(id); + if (!action) return ''; + + const displayCombo = formatShortcutForDisplay(action.defaultCombo); + return `${displayCombo} - ${action.label}`; +} + +export function getModifierLabel(): string { + return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl'; +} diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 2c6be52d..a8e0a4ef 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -3,6 +3,7 @@ import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import type { SidebarSection } from '@/constants/sidebar'; import { getSafeStorage } from './utils/safeStorage'; import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography'; +import type { ShortcutCombo } from '@/lib/shortcuts'; export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files'; export type RightSidebarTab = 'git' | 'files'; @@ -214,6 +215,8 @@ interface UIStore { persistChatDraft: boolean; isMobileSessionStatusBarCollapsed: boolean; + shortcutOverrides: Record; + setTheme: (theme: 'light' | 'dark' | 'system') => void; toggleSidebar: () => void; setSidebarOpen: (open: boolean) => void; @@ -297,6 +300,9 @@ interface UIStore { setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; openMultiRunLauncher: () => void; openMultiRunLauncherWithPrompt: (prompt: string) => void; + setShortcutOverride: (actionId: string, combo: ShortcutCombo) => void; + clearShortcutOverride: (actionId: string) => void; + resetAllShortcutOverrides: () => void; } @@ -384,6 +390,7 @@ export const useUIStore = create()( showTerminalQuickKeysOnDesktop: false, persistChatDraft: true, isMobileSessionStatusBarCollapsed: false, + shortcutOverrides: {}, setTheme: (theme) => { set({ theme }); @@ -1103,11 +1110,32 @@ export const useUIStore = create()( setIsMobileSessionStatusBarCollapsed: (value) => { set({ isMobileSessionStatusBarCollapsed: value }); }, + + setShortcutOverride: (actionId, combo) => { + set((state) => ({ + shortcutOverrides: { + ...state.shortcutOverrides, + [actionId]: combo, + }, + })); + }, + + clearShortcutOverride: (actionId) => { + set((state) => { + const rest = { ...state.shortcutOverrides }; + delete rest[actionId]; + return { shortcutOverrides: rest }; + }); + }, + + resetAllShortcutOverrides: () => { + set({ shortcutOverrides: {} }); + }, }), { name: 'ui-store', storage: createJSONStorage(() => getSafeStorage()), - version: 4, + version: 5, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -1155,6 +1183,21 @@ export const useUIStore = create()( state.contextPanelByDirectory = {}; } + if (version < 5) { + if (!state.shortcutOverrides || typeof state.shortcutOverrides !== 'object') { + state.shortcutOverrides = {}; + } else { + const overrides = state.shortcutOverrides as Record; + const cleaned: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (typeof key === 'string' && typeof value === 'string') { + cleaned[key] = value; + } + } + state.shortcutOverrides = cleaned; + } + } + return state; }, partialize: (state) => ({ @@ -1205,6 +1248,7 @@ export const useUIStore = create()( maxLastMessageLength: state.maxLastMessageLength, persistChatDraft: state.persistChatDraft, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, + shortcutOverrides: state.shortcutOverrides, }) } ),