diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 3633e6c6..5b344371 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -33,11 +33,12 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout'; +import { useKeybinds } from '@/hooks/useKeybind'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; -import { cn, hasModifier } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { McpIcon } from '@/components/icons/McpIcon'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; @@ -46,7 +47,11 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; import { updateDesktopSettings } from '@/lib/persistence'; import { formatTimeForPreference } from '@/lib/timeFormat'; -import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, + type ShortcutActionId, +} from '@/lib/shortcuts'; import type { TimeFormatPreference } from '@/stores/useUIStore'; import { getAllModelFamilies, @@ -285,7 +290,7 @@ type DesktopServicesMenuProps = { rateLimitGroups: RateLimitGroup[]; expandedFamilies: Record; toggleFamilyExpanded: (providerId: string, familyId: string) => void; - shortcutLabel: (actionId: string) => string; + shortcutLabel: (actionId: ShortcutActionId) => string; showDevShutdown: boolean; isDevShutdownInFlight: boolean; onDevShutdown: () => Promise; @@ -1935,7 +1940,7 @@ export const Header: React.FC = ({ return []; }, [isMobile, showPlanTab, t]); - const shortcutLabel = React.useCallback((actionId: string) => { + const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); }, [shortcutOverrides]); @@ -2043,82 +2048,53 @@ export const Header: React.FC = ({ ]; }, [t]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (hasModifier(e) && !e.shiftKey && !e.altKey) { - const num = parseInt(e.key, 10); - if (num >= 1 && num <= tabs.length) { - e.preventDefault(); - if (isMobile) { - blurActiveElement(); - closeMobileHeaderPanels(); - } - setActiveMainTab(tabs[num - 1].id); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [blurActiveElement, closeMobileHeaderPanels, isMobile, setActiveMainTab, tabs]); + const switchToIndexedTab = (index: number) => { + const tab = tabs[index]; + if (!tab) return false; + if (isMobile) { + blurActiveElement(); + closeMobileHeaderPanels(); + } + setActiveMainTab(tab.id); + }; - 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(); - } - } + useKeybinds({ + switch_tab_1: () => switchToIndexedTab(0), + switch_tab_2: () => switchToIndexedTab(1), + switch_tab_3: () => switchToIndexedTab(2), + switch_tab_4: () => switchToIndexedTab(3), + switch_tab_5: () => switchToIndexedTab(4), + switch_tab_6: () => switchToIndexedTab(5), + switch_tab_7: () => switchToIndexedTab(6), + switch_tab_8: () => switchToIndexedTab(7), + switch_tab_9: () => switchToIndexedTab(8), + toggle_services_menu: () => { + if (isDesktopServicesOpen) { + setIsDesktopServicesOpen(false); 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; + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + if (desktopServicesTab === 'usage' && quotaResults.length === 0) { + void fetchAllQuotas(); } - - const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides); - if (eventMatchesShortcut(e, toggleContextPlanCombo)) { - e.preventDefault(); - handleOpenContextPlan(); + }, + cycle_services_tab: () => { + const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>; + if (tabValues.length === 0) return false; + const currentIndex = tabValues.indexOf(desktopServicesTab); + const nextTab = tabValues[currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length]; + setDesktopServicesTab(nextTab); + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + if (nextTab === 'usage' && quotaResults.length === 0) { + void fetchAllQuotas(); } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ - shortcutOverrides, - isDesktopServicesOpen, - desktopServicesTab, - servicesTabs, - quotaResults.length, - fetchAllQuotas, - refreshCurrentInstanceLabel, - handleOpenContextPlan, - ]); + }, + toggle_context_plan: () => { + handleOpenContextPlan(); + }, + }); const renderTab = (tab: TabConfig) => { const isActive = activeMainTab === tab.id; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 951e9dd2..c11fc5cc 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -43,7 +43,7 @@ import { import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; -import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; +import { cn, getModifierLabel, getRevealLabelKey } from '@/lib/utils'; import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; @@ -52,6 +52,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getOutsideFileGrant } from '@/lib/outsideFileGrants'; import { DiagramEditor } from '@/components/diagram'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useKeybind, useKeybinds } from '@/hooks/useKeybind'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -72,7 +73,6 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; -import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -1022,7 +1022,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); @@ -1767,35 +1766,28 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setAutoSaveStatus('idle'); }, [selectedFile?.path]); - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!hasModifier(e)) { - return; - } + useKeybinds({ + save_file: (event) => { + if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false; - if (e.key.toLowerCase() === 's') { - e.preventDefault(); - // Cancel pending auto-save; user wants immediate save - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = null; - } - if (!isSaving) { - void saveDraft().then((saved) => { - if (!saved) return; - setAutoSaveStatus('saved'); - setTimeout(() => setAutoSaveStatus('idle'), 2000); - }); - } - } else if (e.key.toLowerCase() === 'f') { - e.preventDefault(); - setIsSearchOpen(true); + // Cancel pending auto-save because the explicit save should run immediately. + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isSaving, saveDraft]); + if (!isSaving) { + void saveDraft().then((saved) => { + if (!saved) return; + setAutoSaveStatus('saved'); + setTimeout(() => setAutoSaveStatus('idle'), 2000); + }); + } + }, + find_in_file: (event) => { + if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false; + setIsSearchOpen(true); + }, + }); const loadSelectedFile = React.useCallback(async (node: FileNode) => { const loadId = activeFileLoadIdRef.current + 1; @@ -2908,42 +2900,21 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, [isMobile, nudgeEditorSelectionAboveKeyboard]); - React.useEffect(() => { + useKeybind('open_go_to_line', (event) => { if (!canEdit || textViewMode !== 'edit' || isMobile) { - return; + return false; } - const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides); + const target = event.target as Element | null; + if (target?.closest('[role="dialog"]')) return false; + if (!(target instanceof Node) || !editorWrapperRef.current?.contains(target)) return false; - const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as Element | null; - if (target?.closest('[role="dialog"]')) { - return; - } + const isEditorTarget = Boolean(target?.closest('.cm-editor')); + const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]')); + if (isTypingTarget && !isEditorTarget) return false; - const isEditorTarget = Boolean(target?.closest('.cm-editor')); - const isTypingTarget = Boolean( - target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]') - ); - if (isTypingTarget && !isEditorTarget) { - return; - } - - const activeElement = document.activeElement as Element | null; - const editorHasFocus = Boolean(activeElement?.closest('.cm-editor')); - if (!editorHasFocus) { - return; - } - - if (eventMatchesShortcut(event, goToLineCombo)) { - event.preventDefault(); - setIsGoToLineOpen(true); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [canEdit, isMobile, shortcutOverrides, textViewMode]); + setIsGoToLineOpen(true); + }); const editorFontSize = useUIStore((state) => state.editorFontSize); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 8e3ac00a..a4989491 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -6,6 +6,7 @@ import * as sessionActions from '@/sync/session-actions'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; +import { useKeybinds } from '@/hooks/useKeybind'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { useConfigStore } from '@/stores/useConfigStore'; import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; @@ -16,71 +17,55 @@ import { getEffectiveShortcutCombo, getEffectiveShortcutPrefix, normalizeCombo, + type ShortcutActionId, } from '@/lib/shortcuts'; +import { ShortcutDispatcher } from '@/lib/shortcutDispatcher'; +import { shortcutRegistry } from '@/lib/shortcutRegistry'; import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry'; import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { addSelectionToChat } from '@/lib/addSelectionToChat'; import { hasOpenDropdown } from './keyboard-shortcut-dom'; +const dropdownTargetSelector = [ + '[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]', + '[role="listbox"]', '[role="menu"]', '[role="menuitem"]', '[role="option"]', + '[data-radix-popper-content-wrapper]', +].join(','); + export const useKeyboardShortcuts = () => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const armAbortPrompt = useSessionUIStore((s) => s.armAbortPrompt); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const abortCurrentOperation = sessionActions.abortCurrentOperation; - const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); - const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); - const toggleSidebar = useUIStore((s) => s.toggleSidebar); - const currentShortcutDirectory = useDirectoryStore((s) => s.currentDirectory); - const effectiveDirectory = useEffectiveDirectory(); - - // The terminal lives in the context panel; these mirror the rail behavior. - const toggleTerminalSurface = React.useCallback(() => { - if (!currentShortcutDirectory) return; - useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(currentShortcutDirectory), 'terminal'); - }, [currentShortcutDirectory]); - - const toggleTerminalSurfaceExpanded = React.useCallback(() => { - if (!currentShortcutDirectory) return; - const key = normalizeContextPanelDirectoryKey(currentShortcutDirectory); - const state = useUIStore.getState(); - const panel = state.contextPanelByDirectory[key]; - const activeMode = panel?.isOpen ? panel.tabs.find((tab) => tab.id === panel.activeTabId)?.mode : null; - if (activeMode !== 'terminal') { - state.openContextSurface(key, 'terminal'); - } - state.toggleContextPanelExpanded(key); - }, [currentShortcutDirectory]); - const isMobile = useUIStore((s) => s.isMobile); - const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); - const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); - const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); - const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen); - const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen); - const togglePromptNavigatorPanel = useUIStore((s) => s.togglePromptNavigatorPanel); - const setPromptNavigatorPanelOpen = useUIStore((s) => s.setPromptNavigatorPanelOpen); - const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput); - const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const effectiveDirectory = useEffectiveDirectory(); const activeProject = useProjectsStore((s) => s.getActiveProject()); const { themeMode, setThemeMode } = useThemeSystem(); const { phase: sessionPhase } = useCurrentSessionActivity(); const abortPrimedUntilRef = React.useRef(null); const abortPrimedTimeoutRef = React.useRef | null>(null); const themeModeRef = React.useRef(themeMode); - // Currently held physical keys (lowercased), used to match chord prefixes - // whose primary key must be held while the activating key is pressed. + const dispatcherRef = React.useRef(null); const heldKeysRef = React.useRef>(new Set()); - React.useEffect(() => { - themeModeRef.current = themeMode; - }, [themeMode]); + if (!dispatcherRef.current) { + dispatcherRef.current = new ShortcutDispatcher({ + registry: shortcutRegistry, + getBinding: (actionId) => getEffectiveShortcutCombo( + actionId, + useUIStore.getState().shortcutOverrides, + ), + }); + } + const dispatcher = dispatcherRef.current; + + React.useEffect(() => { themeModeRef.current = themeMode; }, [themeMode]); const resetAbortPriming = React.useCallback(() => { if (abortPrimedTimeoutRef.current) { @@ -91,630 +76,387 @@ export const useKeyboardShortcuts = () => { clearAbortPrompt(); }, [clearAbortPrompt]); + const toggleTerminalSurface = () => { + if (!currentDirectory) return; + useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'terminal'); + }; + + const toggleTerminalSurfaceExpanded = () => { + if (!currentDirectory) return; + const key = normalizeContextPanelDirectoryKey(currentDirectory); + const state = useUIStore.getState(); + const panel = state.contextPanelByDirectory[key]; + if (panel?.isOpen ? panel.tabs.find((tab) => tab.id === panel.activeTabId)?.mode !== 'terminal' : true) { + state.openContextSurface(key, 'terminal'); + } + state.toggleContextPanelExpanded(key); + }; + + useKeybinds({ + open_command_palette: () => { + useUIStore.getState().toggleCommandPalette(); + }, + open_timeline_dialog: () => { + useUIStore.getState().setTimelineDialogOpen(true); + }, + toggle_prompt_navigator: () => { + const state = useUIStore.getState(); + const hasOverlay = state.isSettingsDialogOpen + || state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen + || state.isTimelineDialogOpen + || state.isMultiRunLauncherOpen + || state.isImagePreviewOpen; + if ( + !state.promptNavigatorEnabled + || state.isMobile + || isVSCodeRuntime() + || state.activeMainTab !== 'chat' + || hasOverlay + ) { + return false; + } + state.togglePromptNavigatorPanel(); + }, + open_status: () => { + void showOpenCodeStatus(); + }, + open_help: () => { + useUIStore.getState().toggleHelpDialog(); + }, + new_mini_chat: () => { + if (!canUseElectronDesktopIPC()) return false; + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: currentDirectory || activeProject?.path || '', + projectId: activeProject?.id ?? null, + }).catch((error) => { + console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error); + }); + }, + new_chat: () => { + const state = useUIStore.getState(); + state.setActiveMainTab('chat'); + state.setSessionSwitcherOpen(false); + openNewSessionDraft(); + }, + new_chat_worktree: () => { + const state = useUIStore.getState(); + state.setActiveMainTab('chat'); + state.setSessionSwitcherOpen(false); + if (!isVSCodeRuntime()) { + createWorktreeSession(); + return; + } + openNewSessionDraft(); + }, + cycle_theme: () => { + if (readEmbeddedThemeSearchParams() !== null && window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'openchamber:cycle-theme-request' }, window.location.origin); + return; + } + const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; + const activeElement = document.activeElement as HTMLElement | null; + setThemeMode(modes[(modes.indexOf(themeModeRef.current) + 1) % modes.length]); + requestAnimationFrame(() => { + if (!document.hasFocus()) window.focus(); + if (activeElement && document.contains(activeElement)) activeElement.focus({ preventScroll: true }); + }); + }, + open_settings: () => { + const state = useUIStore.getState(); + state.setSettingsDialogOpen(!state.isSettingsDialogOpen); + }, + add_selection_to_chat: () => { + addSelectionToChat(); + }, + toggle_sidebar: () => { + const state = useUIStore.getState(); + if (state.isMobile) state.setSessionSwitcherOpen(!state.isSessionSwitcherOpen); + else state.toggleSidebar(); + }, + focus_input: () => { + focusChatInput(); + }, + cycle_agent: (event) => { + const state = useUIStore.getState(); + const hasOverlay = state.isSettingsDialogOpen + || state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + const isChatInputTarget = event.target instanceof Element + && Boolean(event.target.closest('[data-chat-input="true"]')); + if (hasOverlay || state.activeMainTab !== 'chat' || !isChatInputTarget) return false; + const combo = getEffectiveShortcutCombo('cycle_agent', state.shortcutOverrides); + const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : ''; + const direction = backward && eventMatchesShortcut(event, backward) ? -1 : 1; + const config = useConfigStore.getState(); + const next = getCycledPrimaryAgentName(config.getVisibleAgents(), config.currentAgentName, direction); + if (!next) return false; + config.setAgent(next); + state.addRecentAgent(next); + const sessionId = useSessionUIStore.getState().currentSessionId; + if (sessionId) { + useSelectionStore.getState().saveSessionAgentSelection(sessionId, next); + } + }, + toggle_right_sidebar: () => { + const state = useUIStore.getState(); + if (state.isMobile || !currentDirectory) return false; + const directory = normalizeContextPanelDirectoryKey(currentDirectory); + const panel = state.contextPanelByDirectory[directory]; + if (panel?.isOpen) state.closeContextPanel(directory); + else if (panel?.activeTabId) state.setActiveContextPanelTab(directory, panel.activeTabId); + else state.openContextSurface(directory, 'git'); + }, + open_right_sidebar_git: () => { + const state = useUIStore.getState(); + if (state.isMobile || !currentDirectory) return false; + state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'git'); + }, + open_right_sidebar_files: () => { + const state = useUIStore.getState(); + if (state.isMobile || !currentDirectory) return false; + state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'file'); + }, + toggle_terminal: () => { + if (useUIStore.getState().isMobile) return false; + return toggleTerminalSurface(); + }, + toggle_terminal_expanded: () => { + if (useUIStore.getState().isMobile) return false; + return toggleTerminalSurfaceExpanded(); + }, + open_model_selector: () => { + const state = useUIStore.getState(); + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + if (state.isSettingsDialogOpen || hasOverlay || state.activeMainTab !== 'chat') return false; + state.setModelSelectorOpen(!state.isModelSelectorOpen); + }, + cycle_thinking_variant: () => { + const state = useUIStore.getState(); + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + if (state.isSettingsDialogOpen || hasOverlay || state.activeMainTab !== 'chat') return false; + const config = useConfigStore.getState(); + if (config.getCurrentModelVariants().length === 0) return false; + config.cycleCurrentVariant(); + const sessionId = useSessionUIStore.getState().currentSessionId; + const { currentVariant, currentAgentName, currentProviderId, currentModelId } = useConfigStore.getState(); + if (sessionId && currentAgentName && currentProviderId && currentModelId) { + useSelectionStore.getState().saveAgentModelVariantForSession( + sessionId, + currentAgentName, + currentProviderId, + currentModelId, + currentVariant, + ); + } + }, + cycle_favorite_model_forward: () => cycleFavoriteModel(1), + cycle_favorite_model_backward: () => cycleFavoriteModel(-1), + expand_input: () => { + if (useUIStore.getState().isMobile) return false; + useUIStore.getState().toggleExpandedInput(); + }, + toggle_dictation: () => { + const state = useUIStore.getState(); + if ( + state.activeMainTab !== 'chat' + || state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isSettingsDialogOpen + ) { + return false; + } + window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle')); + }, + }); + + function cycleFavoriteModel(delta: number): boolean | void { + const state = useUIStore.getState(); + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + if ( + state.isSettingsDialogOpen + || hasOverlay + || state.activeMainTab !== 'chat' + || state.favoriteModels.length === 0 + ) { + return false; + } + const config = useConfigStore.getState(); + const index = state.favoriteModels.findIndex((model) => ( + model.providerID === config.currentProviderId && model.modelID === config.currentModelId + )); + const next = state.favoriteModels[(index + delta + state.favoriteModels.length) % state.favoriteModels.length]; + config.setProvider(next.providerID); + config.setModel(next.modelID); + state.addRecentModel(next.providerID, next.modelID); + } + React.useEffect(() => { - const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides); - const switchSurfacePrefix = getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides); - const dropdownTargetSelector = [ - '[data-slot="dropdown-menu-content"]', - '[data-slot="select-content"]', - '[role="combobox"]', - '[role="listbox"]', - '[role="menu"]', - '[role="menuitem"]', - '[role="option"]', - '[data-radix-popper-content-wrapper]', - ].join(','); - - const isDropdownEventTarget = (target: EventTarget | null) => { - return target instanceof Element && Boolean(target.closest(dropdownTargetSelector)); + const invokeRegistered = (actionId: ShortcutActionId, event: KeyboardEvent): boolean => { + const handler = shortcutRegistry.get(actionId); + return handler ? handler(event) !== false : false; }; - - const handleTerminalShortcutCapture = (e: KeyboardEvent) => { - if (!isTerminalEventTarget(e.target)) { - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - e.stopPropagation(); - toggleTerminalSurface(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - e.stopPropagation(); - toggleTerminalSurfaceExpanded(); - return; + const handleTerminalShortcutCapture = (event: KeyboardEvent) => { + if (!isTerminalEventTarget(event.target)) return; + const getBinding = (actionId: ShortcutActionId) => getEffectiveShortcutCombo( + actionId, + useUIStore.getState().shortcutOverrides, + ); + const actionId = eventMatchesShortcut(event, getBinding('toggle_terminal')) ? 'toggle_terminal' + : eventMatchesShortcut(event, getBinding('toggle_terminal_expanded')) ? 'toggle_terminal_expanded' : null; + if (actionId && invokeRegistered(actionId, event)) { + event.preventDefault(); + event.stopPropagation(); } }; - - const handleEscapeKeyDownCapture = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - - const target = e.target as Element | null; - const isInsideDialog = Boolean(target?.closest('[role="dialog"]')); - const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]')); - const isInsideTerminal = isTerminalEventTarget(target); - const hasDropdownInteraction = isDropdownEventTarget(target) || hasOpenDropdown(); - - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - isMultiRunLauncherOpen, - isImagePreviewOpen, - activeMainTab, - isPromptNavigatorPanelOpen, - } = useUIStore.getState(); - - if (isInsideDialog || isInsideTerminal || hasDropdownInteraction) { + const handleEscapeKeyDownCapture = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + if (dispatcher.handleEscape()) { + event.preventDefault(); resetAbortPriming(); return; } - - if (isPromptNavigatorPanelOpen) { - e.preventDefault(); - setPromptNavigatorPanelOpen(false); + const target = event.target as Element | null; + const state = useUIStore.getState(); + const isDropdownTarget = target instanceof Element + && target.closest(dropdownTargetSelector); + if ( + target?.closest('[role="dialog"]') + || isTerminalEventTarget(target) + || isDropdownTarget + || hasOpenDropdown() + ) { resetAbortPriming(); return; } - - if (isSettingsDialogOpen) { - e.preventDefault(); - setSettingsDialogOpen(false); + if (state.isPromptNavigatorPanelOpen) { + event.preventDefault(); + state.setPromptNavigatorPanelOpen(false); resetAbortPriming(); return; } - - if (isSettingsMounted) { + if (state.isSettingsDialogOpen) { + event.preventDefault(); + state.setSettingsDialogOpen(false); resetAbortPriming(); return; } - - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive) { + if (document.querySelector('[data-settings-view="true"]')) { resetAbortPriming(); return; } - - const sessionId = currentSessionId; - if (sessionPhase === 'idle' || !sessionId) { + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen + || state.isMultiRunLauncherOpen + || state.isImagePreviewOpen; + if ( + hasOverlay + || state.activeMainTab !== 'chat' + || sessionPhase === 'idle' + || !currentSessionId + ) { resetAbortPriming(); return; } - const now = Date.now(); - const primedUntil = abortPrimedUntilRef.current; - - if (primedUntil && now < primedUntil) { - e.preventDefault(); + if (abortPrimedUntilRef.current && now < abortPrimedUntilRef.current) { + event.preventDefault(); resetAbortPriming(); - void abortCurrentOperation(sessionId); + void sessionActions.abortCurrentOperation(currentSessionId); return; } - - e.preventDefault(); + event.preventDefault(); const expiresAt = armAbortPrompt(3000) ?? now + 3000; abortPrimedUntilRef.current = expiresAt; - - if (abortPrimedTimeoutRef.current) { - clearTimeout(abortPrimedTimeoutRef.current); - } - - const delay = Math.max(expiresAt - now, 0); + if (abortPrimedTimeoutRef.current) clearTimeout(abortPrimedTimeoutRef.current); abortPrimedTimeoutRef.current = setTimeout(() => { if (abortPrimedUntilRef.current && Date.now() >= abortPrimedUntilRef.current) { resetAbortPriming(); } - }, delay || 0); + }, Math.max(expiresAt - now, 0)); }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' || isTerminalEventTarget(e.target)) { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' || isTerminalEventTarget(event.target)) return; + const combo = getEffectiveShortcutCombo('cycle_agent', useUIStore.getState().shortcutOverrides); + const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : ''; + if (backward && eventMatchesShortcut(event, backward)) { + if (invokeRegistered('cycle_agent', event)) event.preventDefault(); return; } - const isChatInputTarget = (target: EventTarget | null) => { - return target instanceof Element && Boolean(target.closest('[data-chat-input="true"]')); - }; - - if (eventMatchesShortcut(e, combo('open_command_palette'))) { - e.preventDefault(); - toggleCommandPalette(); - return; - } - - if (eventMatchesShortcut(e, combo('open_timeline_dialog'))) { - e.preventDefault(); - setTimelineDialogOpen(true); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_prompt_navigator'))) { - const { - activeMainTab, - promptNavigatorEnabled, - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - isTimelineDialogOpen, - isMultiRunLauncherOpen, - isImagePreviewOpen, - } = useUIStore.getState(); - - if (!promptNavigatorEnabled || isMobile || isVSCodeRuntime() || activeMainTab !== 'chat') { - return; - } - - const hasOverlay = isSettingsDialogOpen - || isCommandPaletteOpen - || isHelpDialogOpen - || isSessionSwitcherOpen - || isAboutDialogOpen - || isTimelineDialogOpen - || isMultiRunLauncherOpen - || isImagePreviewOpen; - - if (hasOverlay) { - return; - } - - e.preventDefault(); - togglePromptNavigatorPanel(); - return; - } - - if (eventMatchesShortcut(e, combo('open_status'))) { - e.preventDefault(); - void showOpenCodeStatus(); - return; - } - - if (eventMatchesShortcut(e, combo('open_help'))) { - e.preventDefault(); - toggleHelpDialog(); - return; - } - - if (canUseElectronDesktopIPC() && eventMatchesShortcut(e, combo('new_mini_chat'))) { - e.preventDefault(); - void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDirectory || activeProject?.path || '', - projectId: activeProject?.id ?? null, - }).catch((error) => { - console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error); - }); - return; - } - - const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat')); - const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree')); - - if (matchedNewSessionShortcut || matchedWorktreeShortcut) { - e.preventDefault(); - - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); - - if (!isVSCodeRuntime() && matchedWorktreeShortcut) { - createWorktreeSession(); - return; - } - - openNewSessionDraft(); - return; - } - - if (eventMatchesShortcut(e, combo('cycle_theme'))) { - e.preventDefault(); - if (readEmbeddedThemeSearchParams() !== null && window.parent && window.parent !== window) { - window.parent.postMessage({ type: 'openchamber:cycle-theme-request' }, window.location.origin); - return; - } - const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; - 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 (eventMatchesShortcut(e, combo('open_settings'))) { - e.preventDefault(); - const { isSettingsDialogOpen } = useUIStore.getState(); - setSettingsDialogOpen(!isSettingsDialogOpen); - return; - } - - if (eventMatchesShortcut(e, combo('add_selection_to_chat'))) { - e.preventDefault(); - addSelectionToChat(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_sidebar'))) { - e.preventDefault(); - const { isMobile, isSessionSwitcherOpen } = useUIStore.getState(); - if (isMobile) { - setSessionSwitcherOpen(!isSessionSwitcherOpen); - } else { - toggleSidebar(); - } - return; - } - - if (eventMatchesShortcut(e, combo('focus_input'))) { - e.preventDefault(); - focusChatInput(); - return; - } - - const cycleAgentCombo = combo('cycle_agent'); - const cycleAgentBackwardCombo = cycleAgentCombo && !cycleAgentCombo.includes('shift') - ? normalizeCombo(`shift+${cycleAgentCombo}`) - : ''; - const cycleAgentDirection = cycleAgentBackwardCombo && eventMatchesShortcut(e, cycleAgentBackwardCombo) - ? -1 - : eventMatchesShortcut(e, cycleAgentCombo) - ? 1 - : 0; - - if (cycleAgentDirection !== 0) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - } = useUIStore.getState(); - - const hasOverlay = isSettingsDialogOpen || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - if (hasOverlay || activeMainTab !== 'chat' || !isChatInputTarget(e.target)) { - return; - } - - const configState = useConfigStore.getState(); - const nextAgentName = getCycledPrimaryAgentName( - configState.getVisibleAgents(), - configState.currentAgentName, - cycleAgentDirection, - ); - - if (!nextAgentName) { - return; - } - - e.preventDefault(); - configState.setAgent(nextAgentName); - useUIStore.getState().addRecentAgent(nextAgentName); - - const sessionId = useSessionUIStore.getState().currentSessionId; - if (sessionId) { - useSelectionStore.getState().saveSessionAgentSelection(sessionId, nextAgentName); - } - return; - } - - // Legacy right-sidebar shortcuts now target the context surfaces that - // replaced the sidebar's tabs. - if (eventMatchesShortcut(e, combo('toggle_right_sidebar'))) { - const state = useUIStore.getState(); - if (state.isMobile || !currentDirectory) { - return; - } - e.preventDefault(); - const directory = normalizeContextPanelDirectoryKey(currentDirectory); - const panelState = state.contextPanelByDirectory[directory]; - if (panelState?.isOpen) { - state.closeContextPanel(directory); - } else if (panelState?.activeTabId) { - state.setActiveContextPanelTab(directory, panelState.activeTabId); - } else { - state.openContextSurface(directory, 'git'); - } - return; - } - - if (eventMatchesShortcut(e, combo('open_right_sidebar_git'))) { - const state = useUIStore.getState(); - if (state.isMobile || !currentDirectory) { - return; - } - e.preventDefault(); - state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'git'); - return; - } - - if (eventMatchesShortcut(e, combo('open_right_sidebar_files'))) { - const state = useUIStore.getState(); - if (state.isMobile || !currentDirectory) { - return; - } - e.preventDefault(); - state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'file'); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - toggleTerminalSurface(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - toggleTerminalSurfaceExpanded(); - return; - } - - // Configured prefix + digit (default: Cmd/Ctrl + 1..9, with 0 for the - // 10th surface): open/close the matching context panel rail surface. The - // digit maps to the currently visible rail order, matching the number - // badges shown while holding the modifier. `e.repeat` guard keeps - // holding a digit from toggling. - const switchSurfaceDigit = e.key.length === 1 && e.key >= '0' && e.key <= '9' - ? (e.key === '0' ? 10 : Number(e.key)) + const switchSurfaceDigit = event.key.length === 1 && event.key >= '0' && event.key <= '9' + ? (event.key === '0' ? 10 : Number(event.key)) : null; - if (switchSurfaceDigit !== null - && !e.repeat - && eventMatchesShortcutPrefix(e, switchSurfacePrefix, heldKeysRef.current)) { + const switchSurfacePrefix = getEffectiveShortcutPrefix( + 'switch_context_surface', + useUIStore.getState().shortcutOverrides, + ); + if ( + switchSurfaceDigit !== null + && !event.repeat + && eventMatchesShortcutPrefix(event, switchSurfacePrefix, heldKeysRef.current) + ) { const state = useUIStore.getState(); - if (state.isMobile || !effectiveDirectory) { - return; - } + if (state.isMobile || !effectiveDirectory) return; const directory = normalizeContextPanelDirectoryKey(effectiveDirectory); - const panelState = state.contextPanelByDirectory[directory]; + const panel = state.contextPanelByDirectory[directory]; const visibleSurfaces = getVisibleContextRailSurfaces({ railOrder: state.contextRailOrder, planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth: window.innerWidth, - tabs: panelState?.tabs ?? [], + tabs: panel?.tabs ?? [], }); const target = visibleSurfaces[switchSurfaceDigit - 1]; - if (!target) { - return; - } - e.preventDefault(); + if (!target) return; + event.preventDefault(); state.openContextSurface(directory, target.mode); return; } - // Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays) - if (eventMatchesShortcut(e, combo('open_model_selector'))) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - isModelSelectorOpen, - } = useUIStore.getState(); - - // Skip if settings open - if (isSettingsDialogOpen) { - return; - } - - // Skip if any overlay open or not on chat tab - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive) { - return; - } - - e.preventDefault(); - setModelSelectorOpen(!isModelSelectorOpen); - return; - } - - // Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M) - if (eventMatchesShortcut(e, combo('cycle_thinking_variant'))) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - } = useUIStore.getState(); - - if (isSettingsDialogOpen) { - return; - } - - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive) { - return; - } - - const configState = useConfigStore.getState(); - const variants = configState.getCurrentModelVariants(); - if (variants.length === 0) { - return; - } - - e.preventDefault(); - configState.cycleCurrentVariant(); - - const nextVariant = useConfigStore.getState().currentVariant; - const sessionId = useSessionUIStore.getState().currentSessionId; - const agentName = useConfigStore.getState().currentAgentName; - const providerId = useConfigStore.getState().currentProviderId; - const modelId = useConfigStore.getState().currentModelId; - - if (sessionId && agentName && providerId && modelId) { - useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); - } - - return; - } - - // Ctrl+] / Ctrl+[: Cycle through starred models (same gating as Shift+M) - if ( - eventMatchesShortcut(e, combo('cycle_favorite_model_forward')) || - eventMatchesShortcut(e, combo('cycle_favorite_model_backward')) - ) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - favoriteModels, - addRecentModel, - } = useUIStore.getState(); - - if (isSettingsDialogOpen) { - return; - } - - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive || favoriteModels.length === 0) { - return; - } - - e.preventDefault(); - - const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState(); - const len = favoriteModels.length; - const currentIdx = favoriteModels.findIndex( - (f) => f.providerID === currentProviderId && f.modelID === currentModelId, - ); - const delta = eventMatchesShortcut(e, combo('cycle_favorite_model_forward')) ? 1 : -1; - const next = favoriteModels[(currentIdx + delta + len) % len]; - - setProvider(next.providerID); - setModel(next.modelID); - addRecentModel(next.providerID, next.modelID); - return; - } - - if (eventMatchesShortcut(e, combo('expand_input'))) { - if (isMobile) { - return; - } - e.preventDefault(); - toggleExpandedInput(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_dictation'))) { - const { activeMainTab, isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, isSettingsDialogOpen } = useUIStore.getState(); - if (activeMainTab !== 'chat' || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSettingsDialogOpen) { - return; - } - e.preventDefault(); - // Dictation state lives inside the composer's isolated component; - // toggle it via an event instead of subscribing this hot hook to it. - window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle')); - return; - } - + if (dispatcher.dispatch(event)) event.preventDefault(); }; - - // Track held physical keys so chord prefixes (e.g. a configured - // `mod+p`) can require their primary key to stay held. Capture phase runs - // before handleKeyDown, so the set is current when chord matching runs. - const handleKeyHoldDown = (e: KeyboardEvent) => { - heldKeysRef.current.add(e.key.toLowerCase()); + const handleKeyHoldDown = (event: KeyboardEvent) => { + heldKeysRef.current.add(event.key.toLowerCase()); }; - const handleKeyUp = (e: KeyboardEvent) => { - heldKeysRef.current.delete(e.key.toLowerCase()); + const handleKeyUp = (event: KeyboardEvent) => { + heldKeysRef.current.delete(event.key.toLowerCase()); }; - const handleWindowBlur = () => { + const handleBlur = () => { heldKeysRef.current.clear(); + dispatcher.handleBlur(); }; - window.addEventListener('keydown', handleKeyHoldDown, true); window.addEventListener('keyup', handleKeyUp, true); - window.addEventListener('blur', handleWindowBlur); window.addEventListener('keydown', handleTerminalShortcutCapture, true); window.addEventListener('keydown', handleEscapeKeyDownCapture, true); window.addEventListener('keydown', handleKeyDown); - + window.addEventListener('blur', handleBlur); return () => { window.removeEventListener('keydown', handleKeyHoldDown, true); window.removeEventListener('keyup', handleKeyUp, true); - window.removeEventListener('blur', handleWindowBlur); window.removeEventListener('keydown', handleTerminalShortcutCapture, true); window.removeEventListener('keydown', handleEscapeKeyDownCapture, true); window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('blur', handleBlur); }; - }, [ - openNewSessionDraft, - abortCurrentOperation, - toggleCommandPalette, - toggleHelpDialog, - toggleSidebar, - toggleTerminalSurface, - toggleTerminalSurfaceExpanded, - isMobile, - setSessionSwitcherOpen, - setActiveMainTab, - setSettingsDialogOpen, - setModelSelectorOpen, - setTimelineDialogOpen, - togglePromptNavigatorPanel, - setPromptNavigatorPanelOpen, - toggleExpandedInput, - setThemeMode, - sessionPhase, - armAbortPrompt, - resetAbortPriming, - currentSessionId, - currentDirectory, - effectiveDirectory, - activeProject?.id, - activeProject?.path, - shortcutOverrides, - ]); + }, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, resetAbortPriming, sessionPhase]); - React.useEffect(() => { - return () => { - resetAbortPriming(); - }; - }, [resetAbortPriming]); + React.useEffect(() => () => resetAbortPriming(), [resetAbortPriming]); }; diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index f3b24003..98a6d480 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -1,102 +1,115 @@ import React from 'react'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; -import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { ShortcutDispatcher } from '@/lib/shortcutDispatcher'; +import { shortcutRegistry } from '@/lib/shortcutRegistry'; +import { getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useKeybinds } from './useKeybind'; export const useMiniChatKeyboardShortcuts = () => { - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const activeProject = useProjectsStore((state) => state.getActiveProject()); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const dispatcherRef = React.useRef(null); + + if (!dispatcherRef.current) { + dispatcherRef.current = new ShortcutDispatcher({ + registry: shortcutRegistry, + getBinding: (actionId) => getEffectiveShortcutCombo( + actionId, + useUIStore.getState().shortcutOverrides, + ), + }); + } + const dispatcher = dispatcherRef.current; + + const cycleFavoriteModel = (delta: number): boolean | void => { + const { favoriteModels, addRecentModel } = useUIStore.getState(); + if (favoriteModels.length === 0) return false; + + const { + currentProviderId, + currentModelId, + setProvider, + setModel, + } = useConfigStore.getState(); + const currentIndex = favoriteModels.findIndex( + (favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId, + ); + const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length]; + setProvider(next.providerID); + setModel(next.modelID); + addRecentModel(next.providerID, next.modelID); + }; + + useKeybinds({ + focus_input: () => { + focusChatInput(); + }, + new_mini_chat: () => { + if (!canUseElectronDesktopIPC()) return false; + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: currentDirectory || activeProject?.path || '', + projectId: activeProject?.id ?? null, + })?.catch((error) => { + console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error); + }); + }, + new_chat: () => { + openNewSessionDraft({ + selectedProjectId: activeProject?.id ?? null, + directoryOverride: currentDirectory || activeProject?.path || null, + preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path), + }); + focusChatInput(); + }, + open_model_selector: () => { + const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState(); + setModelSelectorOpen(!isModelSelectorOpen); + }, + cycle_thinking_variant: () => { + const configState = useConfigStore.getState(); + if (configState.getCurrentModelVariants().length === 0) return false; + + configState.cycleCurrentVariant(); + const sessionId = useSessionUIStore.getState().currentSessionId; + const { + currentVariant, + currentAgentName, + currentProviderId, + currentModelId, + } = useConfigStore.getState(); + if (sessionId && currentAgentName && currentProviderId && currentModelId) { + useSelectionStore.getState().saveAgentModelVariantForSession( + sessionId, + currentAgentName, + currentProviderId, + currentModelId, + currentVariant, + ); + } + }, + cycle_favorite_model_forward: () => cycleFavoriteModel(1), + cycle_favorite_model_backward: () => cycleFavoriteModel(-1), + }); React.useEffect(() => { - const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides); - const handleKeyDown = (event: KeyboardEvent) => { - if (eventMatchesShortcut(event, combo('focus_input'))) { - event.preventDefault(); - focusChatInput(); - return; - } - - if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) { - event.preventDefault(); - void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDirectory || activeProject?.path || '', - projectId: activeProject?.id ?? null, - })?.catch((error) => { - console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error); - }); - return; - } - - if (eventMatchesShortcut(event, combo('new_chat'))) { - event.preventDefault(); - openNewSessionDraft({ - selectedProjectId: activeProject?.id ?? null, - directoryOverride: currentDirectory || activeProject?.path || null, - preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path), - }); - focusChatInput(); - return; - } - - if (eventMatchesShortcut(event, combo('open_model_selector'))) { - event.preventDefault(); - const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState(); - setModelSelectorOpen(!isModelSelectorOpen); - return; - } - - if (eventMatchesShortcut(event, combo('cycle_thinking_variant'))) { - const configState = useConfigStore.getState(); - const variants = configState.getCurrentModelVariants(); - if (variants.length === 0) { - return; - } - - event.preventDefault(); - configState.cycleCurrentVariant(); - - const nextVariant = useConfigStore.getState().currentVariant; - const sessionId = useSessionUIStore.getState().currentSessionId; - const agentName = useConfigStore.getState().currentAgentName; - const providerId = useConfigStore.getState().currentProviderId; - const modelId = useConfigStore.getState().currentModelId; - - if (sessionId && agentName && providerId && modelId) { - useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); - } - return; - } - - const cyclesForward = eventMatchesShortcut(event, combo('cycle_favorite_model_forward')); - const cyclesBackward = eventMatchesShortcut(event, combo('cycle_favorite_model_backward')); - if (cyclesForward || cyclesBackward) { - const { favoriteModels, addRecentModel } = useUIStore.getState(); - if (favoriteModels.length === 0) { - return; - } - - event.preventDefault(); - const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState(); - const currentIndex = favoriteModels.findIndex((favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId); - const delta = cyclesForward ? 1 : -1; - const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length]; - - setProvider(next.providerID); - setModel(next.modelID); - addRecentModel(next.providerID, next.modelID); - } + if (dispatcher.dispatch(event)) event.preventDefault(); }; + const handleBlur = () => dispatcher.handleBlur(); window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [activeProject?.id, activeProject?.path, currentDirectory, openNewSessionDraft, shortcutOverrides]); + window.addEventListener('blur', handleBlur); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('blur', handleBlur); + }; + }, [dispatcher]); };