From a9fe0f2c465ce80605863349c37542e104aa4282 Mon Sep 17 00:00:00 2001 From: Tyler Brown Date: Sat, 29 Aug 2026 13:42:09 -0700 Subject: [PATCH 1/7] feat: shared keyboard shortcut catalog with sparse overrides and effective-binding dispatch Phase 1 of configurable keybindings: one typed catalog of every registry-backed command, keyboardShortcutOverrides in the global config (sparse: override or null), a chord->candidates hotkey index that runs neither command on an ambiguous chord, terminal release and webview forwarding driven by the configured catalog, a config:updated relay for live propagation, and agent launch presets referencing catalog ids instead of literal hotkeys. Claude-Session: https://claude.ai/code/session_016CuGxyYX4yEZ1dfUZZDm36 --- docs/ADDING_NEW_CLI_TOOLS.md | 13 +- frontend/src/App.tsx | 14 +- frontend/src/components/ProjectView.tsx | 1 + frontend/src/components/SessionView.tsx | 69 ++- .../src/components/panels/PanelTabBar.tsx | 2 - .../src/components/panels/TerminalPanel.tsx | 92 +--- frontend/src/components/usage/UsageView.tsx | 2 - .../src/hooks/useFocusedSurfaceScrolling.ts | 13 +- frontend/src/hooks/useHotkey.ts | 3 +- .../src/hooks/useSessionNavigationHotkeys.ts | 11 +- frontend/src/hooks/useSessionView.ts | 6 - frontend/src/hooks/useTerminalShortcuts.ts | 4 +- frontend/src/stores/configStore.ts | 16 + frontend/src/stores/hotkeyStore.test.ts | 122 ++++- frontend/src/stores/hotkeyStore.ts | 430 ++++++++---------- .../src/stores/projectViewActionsStore.ts | 2 + frontend/src/types/config.ts | 5 + frontend/src/types/electron.d.ts | 1 + frontend/src/utils/hotkeyUtils.ts | 6 +- .../src/utils/terminalKeyHandling.test.ts | 86 ++++ frontend/src/utils/terminalKeyHandling.ts | 32 +- main/src/index.ts | 62 +-- main/src/ipc/config.test.ts | 25 +- main/src/ipc/config.ts | 4 + main/src/ipc/daemonRegistryBindings.test.ts | 4 +- main/src/preload.ts | 5 + .../agents/agentLaunchPresets.test.ts | 7 +- main/src/services/configManager.test.ts | 96 ++++ main/src/services/configManager.ts | 58 ++- main/src/types/config.ts | 5 + main/src/utils/keyboardBindings.test.ts | 53 +++ main/src/utils/keyboardChords.test.ts | 58 +++ .../src/utils/keyboardShortcutCatalog.test.ts | 71 +++ main/src/utils/keyboardShortcuts.test.ts | 138 ++++-- main/src/utils/keyboardShortcuts.ts | 40 +- shared/constants/agentLaunchPresets.ts | 8 +- shared/constants/keyboardShortcuts.ts | 174 +++++++ shared/utils/keyboardBindings.ts | 220 +++++++++ shared/utils/keyboardChords.ts | 158 +++++++ tests/electronApiMock.ts | 3 + 40 files changed, 1613 insertions(+), 506 deletions(-) create mode 100644 main/src/services/configManager.test.ts create mode 100644 main/src/utils/keyboardBindings.test.ts create mode 100644 main/src/utils/keyboardChords.test.ts create mode 100644 main/src/utils/keyboardShortcutCatalog.test.ts create mode 100644 shared/constants/keyboardShortcuts.ts create mode 100644 shared/utils/keyboardBindings.ts create mode 100644 shared/utils/keyboardChords.ts diff --git a/docs/ADDING_NEW_CLI_TOOLS.md b/docs/ADDING_NEW_CLI_TOOLS.md index 2db4d25d5..e1f016dd7 100644 --- a/docs/ADDING_NEW_CLI_TOOLS.md +++ b/docs/ADDING_NEW_CLI_TOOLS.md @@ -82,10 +82,12 @@ binary. ## 7. Frontend -`shared/constants/agentLaunchPresets.ts` is the single list behind the toolbar pills, -Add Tool dropdowns (desktop + remote), and `mod+alt+N` hotkeys. Add one entry -(`platforms` gates unsupported OSes); `agentLaunchPresets.test.ts` pins the list -against the RunPane contract. Add the brand icon to +`shared/constants/agentLaunchPresets.ts` is the single list behind the toolbar pills +and Add Tool dropdowns (desktop + remote). Add one preset entry with its stable +`hotkeyId` (`platforms` gates unsupported OSes), then add the matching command and +default chord to `shared/constants/keyboardShortcuts.ts`. +`agentLaunchPresets.test.ts` and `keyboardShortcutCatalog.test.ts` pin preset, +catalog, platform, and default-chord parity. Add the brand icon to `frontend/src/components/ui/BrandIcons.tsx` (`CLI_BRAND_ICONS`) and a search alias in `frontend/src/components/settings/catalog.tsx`. @@ -107,4 +109,5 @@ guide in a format the CLI actually reads (Cursor: `.cursor/rules/*.mdc`). Every step above lands test-first: `agentIdentity.test.ts`, `Launch.test.ts`, `terminalPanelManager.test.ts`, `agentResume.test.ts`, `manifests.test.ts` + `agentStatusPipeline.test.ts` (real captured bytes), `runpane.test.ts` (agent matrix + -doctor), `agentLaunchPresets.test.ts`, `scripts/test-runpane-contract.js`. +doctor), `agentLaunchPresets.test.ts`, `keyboardShortcutCatalog.test.ts`, +`scripts/test-runpane-contract.js`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8dcbf9983..5c7eca4b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -133,7 +133,7 @@ function App() { const { currentError, clearError } = useErrorStore(); const { sessions, isLoaded } = useSessionStore(); const activeSessionId = useSessionStore(state => state.activeSessionId); - const { fetchConfig, config: appConfig } = useConfigStore(); + const { fetchConfig, subscribeToUpdates, config: appConfig } = useConfigStore(); const terminalShortcuts = appConfig?.terminalShortcuts ?? EMPTY_TERMINAL_SHORTCUTS; const { isVisible: shortcutHintsVisible } = useShortcutHintsOverlay(); useFocusedSurfaceScrolling(activeSessionId); @@ -218,7 +218,6 @@ function App() { useHotkey({ id: 'open-command-palette', label: 'Open Command Palette', - keys: 'mod+shift+p', category: 'navigation', action: () => setIsCommandPaletteOpen(true), }); @@ -226,7 +225,6 @@ function App() { useHotkey({ id: 'toggle-sidebar', label: 'Toggle Sidebar', - keys: 'mod+b', category: 'view', action: handleToggleSidebar, }); @@ -234,7 +232,6 @@ function App() { useHotkey({ id: 'open-settings', label: 'Open Settings', - keys: 'mod+,', category: 'navigation', action: () => openSettings(), }); @@ -242,7 +239,6 @@ function App() { useHotkey({ id: 'focus-sidebar', label: 'Focus Sidebar', - keys: 'mod+shift+e', category: 'navigation', action: () => { if (sidebarCollapsed) handleToggleSidebar(); @@ -259,7 +255,6 @@ function App() { useHotkey({ id: 'open-shortcut-settings', label: 'Open Shortcut Settings', - keys: 'mod+alt+/', category: 'shortcuts', action: () => { openSettings({ category: 'shortcuts', setting: 'terminal-shortcuts' }); @@ -269,7 +264,6 @@ function App() { useHotkey({ id: 'new-session', label: 'New Pane', - keys: 'mod+n', category: 'session', action: () => { if (activeProject) setShowCreateSessionDialog(true); @@ -279,7 +273,6 @@ function App() { useHotkey({ id: 'new-project', label: 'New Project', - keys: 'mod+shift+n', category: 'navigation', action: () => setShowAddProjectDialog(true), }); @@ -289,8 +282,9 @@ function App() { // Load config on app startup useEffect(() => { - fetchConfig(); - }, [fetchConfig]); + void fetchConfig(); + return subscribeToUpdates(); + }, [fetchConfig, subscribeToUpdates]); // Detect unclean shutdown from previous session and notify user useEffect(() => { diff --git a/frontend/src/components/ProjectView.tsx b/frontend/src/components/ProjectView.tsx index 3d551edd7..cc4b04c63 100644 --- a/frontend/src/components/ProjectView.tsx +++ b/frontend/src/components/ProjectView.tsx @@ -251,6 +251,7 @@ export const ProjectView: React.FC = ({ toggleDetail: () => setDetailVisible((v) => !v), showInspector: (tab) => { setInspectorTab(tab); setDetailVisible(true); }, addTerminal: () => { void handlePanelCreate('terminal'); }, + addTerminalWithOptions: (options) => { void handlePanelCreate('terminal', options); }, tabCount: () => workingPanels.length, selectTab: (index) => { const panel = workingPanels[index]; if (panel) handlePanelSelect(panel); }, cycleTab: (direction) => { diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index f685fb3f7..d7a9a7252 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -61,6 +61,7 @@ import { Kbd } from './ui/Kbd'; import type { InspectorTab } from './InspectorTabs'; import { useErrorStore } from '../stores/errorStore'; import ProjectSettings from './ProjectSettings'; +import type { CustomCommandId, HotkeyId } from '../../../shared/constants/keyboardShortcuts'; function pickDefaultPanel(panelList: ToolPanel[], hasReviewPr: boolean): ToolPanel | undefined { return (hasReviewPr ? panelList.find(p => p.type === 'diff') : undefined) @@ -672,7 +673,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'cycle-tab-prev-a', label: 'Previous Tab', - keys: 'mod+a', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? focusedGroupPanels.length) > 1, action: () => cycleTab('prev'), @@ -682,7 +682,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'cycle-tab-next-d', label: 'Next Tab', - keys: 'mod+d', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? focusedGroupPanels.length) > 1, action: () => cycleTab('next'), @@ -696,15 +695,15 @@ export const SessionView = memo(() => { const name = p.type === 'diff' ? 'Review' : p.title; return `Switch to ${name}`; }; - useHotkey({ id: 'panel-tab-1', label: panelLabel(0), keys: 'mod+shift+1', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 0 || !!focusedGroupPanels[0], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(0); return; } const p = focusedGroupPanels[0]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-2', label: panelLabel(1), keys: 'mod+shift+2', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 1 || !!focusedGroupPanels[1], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(1); return; } const p = focusedGroupPanels[1]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-3', label: panelLabel(2), keys: 'mod+shift+3', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 2 || !!focusedGroupPanels[2], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(2); return; } const p = focusedGroupPanels[2]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-4', label: panelLabel(3), keys: 'mod+shift+4', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 3 || !!focusedGroupPanels[3], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(3); return; } const p = focusedGroupPanels[3]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-5', label: panelLabel(4), keys: 'mod+shift+5', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 4 || !!focusedGroupPanels[4], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(4); return; } const p = focusedGroupPanels[4]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-6', label: panelLabel(5), keys: 'mod+shift+6', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 5 || !!focusedGroupPanels[5], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(5); return; } const p = focusedGroupPanels[5]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-7', label: panelLabel(6), keys: 'mod+shift+7', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 6 || !!focusedGroupPanels[6], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(6); return; } const p = focusedGroupPanels[6]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-8', label: panelLabel(7), keys: 'mod+shift+8', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 7 || !!focusedGroupPanels[7], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(7); return; } const p = focusedGroupPanels[7]; if (p) handlePanelSelect(p); } }); - useHotkey({ id: 'panel-tab-9', label: panelLabel(8), keys: 'mod+shift+9', category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 8 || !!focusedGroupPanels[8], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(8); return; } const p = focusedGroupPanels[8]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-1', label: panelLabel(0), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 0 || !!focusedGroupPanels[0], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(0); return; } const p = focusedGroupPanels[0]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-2', label: panelLabel(1), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 1 || !!focusedGroupPanels[1], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(1); return; } const p = focusedGroupPanels[1]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-3', label: panelLabel(2), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 2 || !!focusedGroupPanels[2], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(2); return; } const p = focusedGroupPanels[2]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-4', label: panelLabel(3), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 3 || !!focusedGroupPanels[3], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(3); return; } const p = focusedGroupPanels[3]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-5', label: panelLabel(4), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 4 || !!focusedGroupPanels[4], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(4); return; } const p = focusedGroupPanels[4]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-6', label: panelLabel(5), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 5 || !!focusedGroupPanels[5], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(5); return; } const p = focusedGroupPanels[5]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-7', label: panelLabel(6), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 6 || !!focusedGroupPanels[6], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(6); return; } const p = focusedGroupPanels[6]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-8', label: panelLabel(7), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 7 || !!focusedGroupPanels[7], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(7); return; } const p = focusedGroupPanels[7]; if (p) handlePanelSelect(p); } }); + useHotkey({ id: 'panel-tab-9', label: panelLabel(8), category: 'tabs', enabled: () => (projectActions()?.tabCount() ?? 0) > 8 || !!focusedGroupPanels[8], action: () => { const bridged = projectActions(); if (bridged) { bridged.selectTab(8); return; } const p = focusedGroupPanels[8]; if (p) handlePanelSelect(p); } }); // --- Add Tool commands (palette-only, no keybindings) --- // Only enabled in session view (not project view) to prevent hidden panel mutations @@ -714,7 +713,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'add-tool-terminal', label: 'Add Terminal', - keys: 'mod+alt+1', category: 'tools', enabled: () => isInSessionView, action: () => { const bridged = projectActions(); if (bridged) bridged.addTerminal(); else void handlePanelCreate('terminal'); }, @@ -723,7 +721,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'add-tool-explorer', label: 'Show Files', - keys: 'mod+alt+2', category: 'tools', enabled: () => isInSessionView, action: () => { const bridged = projectActions(); if (bridged) bridged.showInspector('files'); else openInspector('files'); }, @@ -746,7 +743,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'close-active-tab', label: 'Close active tab', - keys: 'mod+w', category: 'tabs', enabled: closeTabEnabled, action: closeTabAction, @@ -755,7 +751,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'archive-active-session', label: 'Archive Pane', - keys: 'mod+shift+w', category: 'session', enabled: () => !!activeSession && !activeSession.archived, action: () => hook.setShowArchiveConfirm(true), @@ -766,7 +761,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'split-right', label: 'Split Right', - keys: 'mod+\\', category: 'tabs', enabled: () => { if (!activeSession || !focusedGroup) return false; @@ -800,7 +794,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'split-down', label: 'Split Down', - keys: 'mod+shift+\\', category: 'tabs', enabled: () => { if (!activeSession || !focusedGroup) return false; @@ -833,7 +826,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'focus-group-left', label: 'Focus Group Left', - keys: 'mod+alt+ArrowLeft', category: 'tabs', enabled: () => !!sessionLayout && allGroups(sessionLayout.root).length > 1, action: () => { @@ -846,7 +838,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'focus-group-right', label: 'Focus Group Right', - keys: 'mod+alt+ArrowRight', category: 'tabs', enabled: () => !!sessionLayout && allGroups(sessionLayout.root).length > 1, action: () => { @@ -859,7 +850,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'focus-group-up', label: 'Focus Group Up', - keys: 'mod+alt+ArrowUp', category: 'tabs', enabled: () => !!sessionLayout && allGroups(sessionLayout.root).length > 1, action: () => { @@ -872,7 +862,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'focus-group-down', label: 'Focus Group Down', - keys: 'mod+alt+ArrowDown', category: 'tabs', enabled: () => !!sessionLayout && allGroups(sessionLayout.root).length > 1, action: () => { @@ -887,7 +876,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'zoom-toggle', label: 'Toggle Zoom', - keys: 'mod+shift+z', category: 'tabs', enabled: () => !!sessionLayout && allGroups(sessionLayout.root).length > 1, action: () => { @@ -1229,55 +1217,58 @@ export const SessionView = memo(() => { handleDropTab, handleDragStart, handleDragEnd, handleStripDrop, getPanelTabPresentation, emptyStage, ]); - // Dynamic shortcuts for custom commands (mod+shift+5, 6, 7, ...) + // Register agent and custom-command actions; their default chords live in the catalog. const registerHotkey = useHotkeyStore((s) => s.register); const unregisterHotkey = useHotkeyStore((s) => s.unregister); const handlePanelCreateRef = useCommittedRef(handlePanelCreate); const isInSessionViewRef = useCommittedRef(isInSessionView); useEffect(() => { - const ids: string[] = []; + const ids: HotkeyId[] = []; for (const preset of agentPresets) { ids.push(preset.hotkeyId); registerHotkey({ id: preset.hotkeyId, label: `Add ${preset.title}`, - keys: preset.hotkey, category: 'tools', enabled: () => isInSessionViewRef.current, - action: () => handlePanelCreateRef.current('terminal', { - initialCommand: preset.command, - title: preset.title, - }), + action: () => { + const options = { initialCommand: preset.command, title: preset.title }; + const bridged = projectActions(); + if (bridged) bridged.addTerminalWithOptions(options); + else void handlePanelCreateRef.current('terminal', options); + }, }); } return () => { ids.forEach(id => unregisterHotkey(id)); }; - }, [agentPresets, handlePanelCreateRef, isInSessionViewRef, registerHotkey, unregisterHotkey]); + }, [agentPresets, handlePanelCreateRef, isInSessionViewRef, projectActions, registerHotkey, unregisterHotkey]); useEffect(() => { const CUSTOM_CMD_START = 6; // mod+alt+3-5 stay reserved for built-in agents on every platform const maxSlots = Math.min(customCommands.length, 10 - CUSTOM_CMD_START); - const ids: string[] = []; + const ids: CustomCommandId[] = []; for (let i = 0; i < maxSlots; i++) { const cmd = customCommands[i]; - const id = `add-tool-custom-${i}`; + // SAFETY: maxSlots is capped at four, so i is one of the catalog's 0..3 slots. + const id = `add-tool-custom-${i}` as CustomCommandId; ids.push(id); registerHotkey({ id, label: `Add ${cmd.name}`, - keys: `mod+alt+${CUSTOM_CMD_START + i}`, category: 'tools', enabled: () => isInSessionViewRef.current, - action: () => handlePanelCreateRef.current('terminal', { - initialCommand: cmd.command, - title: cmd.name, - }), + action: () => { + const options = { initialCommand: cmd.command, title: cmd.name }; + const bridged = projectActions(); + if (bridged) bridged.addTerminalWithOptions(options); + else void handlePanelCreateRef.current('terminal', options); + }, }); } return () => { ids.forEach(id => unregisterHotkey(id)); }; - }, [customCommands, handlePanelCreateRef, isInSessionViewRef, registerHotkey, unregisterHotkey]); + }, [customCommands, handlePanelCreateRef, isInSessionViewRef, projectActions, registerHotkey, unregisterHotkey]); // Load project data for active session useEffect(() => { @@ -1560,7 +1551,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'toggle-terminal', label: 'Toggle Terminal', - keys: 'mod+`', category: 'view', enabled: () => isInSessionView, action: toggleTerminalCollapse, @@ -1570,7 +1560,6 @@ export const SessionView = memo(() => { useHotkey({ id: 'toggle-detail-panel', label: 'Toggle Detail Panel', - keys: 'mod+shift+b', category: 'view', enabled: () => isInSessionView && !immersiveMode, action: () => { const bridged = projectActions(); if (bridged) bridged.toggleDetail(); else handleToggleDetailPanel(); }, diff --git a/frontend/src/components/panels/PanelTabBar.tsx b/frontend/src/components/panels/PanelTabBar.tsx index fd8dc1839..502a25e04 100644 --- a/frontend/src/components/panels/PanelTabBar.tsx +++ b/frontend/src/components/panels/PanelTabBar.tsx @@ -349,7 +349,6 @@ export const PanelTabBar: React.FC = memo(({ useHotkey({ id: 'open-add-tool', label: 'Open Add Tool menu', - keys: 'mod+t', category: 'tabs', action: () => setShowDropdown(true), }); @@ -394,7 +393,6 @@ export const PanelTabBar: React.FC = memo(({ useHotkey({ id: 'run-dev-server', label: 'Run Dev Server', - keys: 'mod+shift+d', category: 'tools', action: handleRunDevServer, enabled: () => !!session, diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index f1f173f5e..e291bfd7d 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -9,13 +9,19 @@ import type { ImageAddon, IImageAddonOptions } from '@xterm/addon-image'; import { useSession } from '../../contexts/SessionContext'; import { useTheme } from '../../contexts/ThemeContext'; import { TerminalPanelProps } from '../../types/panelComponents'; -import { isHotkeyEnabledForEvent, useHotkeyStore } from '../../stores/hotkeyStore'; +import { + isBoundChordForEvent, + isHotkeyEnabledForEvent, + isTuiReleasableChordForEvent, +} from '../../stores/hotkeyStore'; import { renderLog, devLog } from '../../utils/console'; import { getTerminalTheme } from '../../utils/terminalTheme'; import { isFineSurfaceScrollKey, isPageSurfaceScrollKey, resolveTerminalKeyHandling, + isTerminalReservedChord, + shouldReleaseToApplication, shouldOpenTerminalSearch, terminalClaimsFineSurfaceScroll, } from '../../utils/terminalKeyHandling'; @@ -1051,6 +1057,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv isCliPanel: isCliPanelRef.current, isMac: isMac(), keyboardShortcutsEnabled: keyboardShortcutsEnabledRef.current, + isTuiReleasableChord: isTuiReleasableChordForEvent, }); // Shift+Enter sends the same ESC+CR sequence as Alt+Enter for CLI @@ -1068,73 +1075,6 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv } if (terminalKeyDecision.action === 'pass-through') return true; - // Ctrl/Cmd+1-9: switch sessions - if (ctrlOrMeta && e.key >= '1' && e.key <= '9') return false; - // Ctrl+Alt+1-9: switch panel tabs - if (ctrlOrMeta && e.altKey && e.key >= '1' && e.key <= '9') return false; - // Ctrl/Cmd+Alt+letter: terminal shortcuts — only release if a matching hotkey is registered - // Use e.code instead of e.key because macOS Option key modifies e.key to special chars - // (e.g. Option+A produces e.key='å' but e.code='KeyA') - // Skip AltGr — on Windows/Linux international layouts AltGr sets both ctrlKey+altKey - // but is used for character input (e.g. AltGr+Q = '@' on German keyboards) - if (ctrlOrMeta && e.altKey && !e.getModifierState('AltGraph') && /^Key[A-Z]$/.test(e.code)) { - const pressed = `mod+alt+${e.code.slice(3).toLowerCase()}`; - const hotkeys = useHotkeyStore.getState().hotkeys; - for (const def of hotkeys.values()) { - if (def.keys === pressed) return false; - } - } - // Ctrl/Cmd+Alt+/: open shortcut settings - // Check e.code too: macOS Option modifies e.key (e.g. '/' becomes '÷') - if (ctrlOrMeta && e.altKey && (e.key === '/' || (!e.getModifierState('AltGraph') && e.code === 'Slash'))) return false; - // Ctrl/Cmd+W or Ctrl/Cmd+Q: close active tab - if (ctrlOrMeta && (e.key.toLowerCase() === 'w' || e.key.toLowerCase() === 'q')) return false; - // Ctrl/Cmd+T: open Add Tool dropdown - if (ctrlOrMeta && e.key.toLowerCase() === 't') return false; - // Ctrl/Cmd+P: prompt history; Ctrl/Cmd+Shift+P: command palette - if (ctrlOrMeta && e.key.toLowerCase() === 'p') return false; - // Ctrl/Cmd+N: new workspace - if (ctrlOrMeta && e.key.toLowerCase() === 'n') return false; - // Ctrl/Cmd+Shift+D: toggle diff - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'd') return false; - // Ctrl/Cmd+Shift+R: toggle run - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'r') return false; - // Git shortcuts - release to DOM for hotkeyStore - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'm') return false; - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'u') return false; - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'l') return false; - // Ctrl/Cmd+Shift+N: new project - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'n') return false; - - // Session cycling - Tab - if (ctrlOrMeta && e.key === 'Tab') return false; - // Session cycling - Ctrl+Up/Down arrows - if (ctrlOrMeta && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) return false; - // Tab cycling - Ctrl+A/D - if (ctrlOrMeta && (e.key.toLowerCase() === 'a' || e.key.toLowerCase() === 'd')) return false; - // Ctrl/Cmd+B: toggle sidebar - if (ctrlOrMeta && e.key.toLowerCase() === 'b') return false; - // Ctrl/Cmd+Shift+digit: panel tab switching (use e.code for layout independence) - if (ctrlOrMeta && e.shiftKey && /^Digit[1-9]$/.test(e.code)) return false; - // Ctrl/Cmd+Alt+digit: add tool shortcuts (skip AltGr — used for @/€ etc. on EU layouts) - if (ctrlOrMeta && e.altKey && !e.getModifierState('AltGraph') && /^Digit[1-9]$/.test(e.code)) return false; - // Ctrl/Cmd+`: toggle bottom terminal - if (ctrlOrMeta && e.key === '`') return false; - // Ctrl/Cmd+,: open settings - if (ctrlOrMeta && e.key === ',') return false; - // Ctrl/Cmd+Shift+E: focus sidebar - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'e') return false; - - // Split tab groups: Mod+\ and Mod+Shift+\ (Ctrl+\ is SIGQUIT - must release!) - // ISO/international keyboards report the key as IntlBackslash. - // On macOS the app hotkey is Cmd+\, so only release metaKey there - // and let Ctrl+\ keep delivering SIGQUIT to the PTY. - if ((isMac() ? e.metaKey : e.ctrlKey) && (e.code === 'Backslash' || e.code === 'IntlBackslash')) return false; - // Zoom toggle: Mod+Shift+Z - if (ctrlOrMeta && e.shiftKey && e.key.toLowerCase() === 'z') return false; - // Directional group focus: Mod+Alt+Arrows (all four directions) - if (ctrlOrMeta && e.altKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return false; - // Detect AltGr+key producing '@' (e.g. German AltGr+Q) — set flag so the // interceptor skips activation for this keystroke. AltGr sets both ctrlKey+altKey // on Windows/Linux, or e.getModifierState('AltGraph') on some platforms. @@ -1142,17 +1082,11 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv skipNextInterceptRef.current = true; } - // Right Alt: let OS/browser handle (e.g. voice transcription, IME) - // Use e.code for physical key (e.key may report 'AltGraph' on some layouts) - if (e.code === 'AltRight') return false; - - // Ctrl/Cmd+F: terminal search - if (ctrlOrMeta && e.key.toLowerCase() === 'f') return false; - - // Ctrl/Cmd+V: stop xterm from sending raw \x16 to PTY - // Returning false lets the browser trigger a native paste event instead, - // which is handled by our paste event listener on the terminal container - if (ctrlOrMeta && e.key.toLowerCase() === 'v') return false; + if (isTerminalReservedChord(e)) return false; + if (shouldReleaseToApplication(e, { + isMac: isMac(), + isBound: isBoundChordForEvent(e), + })) return false; return true; // Let terminal handle everything else }); diff --git a/frontend/src/components/usage/UsageView.tsx b/frontend/src/components/usage/UsageView.tsx index 0abeaa63a..56a483444 100644 --- a/frontend/src/components/usage/UsageView.tsx +++ b/frontend/src/components/usage/UsageView.tsx @@ -260,7 +260,6 @@ export function UsageView() { useHotkey({ id: 'usage-download', label: 'Download usage image', - keys: 'mod+shift+d', category: 'tools', action: () => { void handleDownload(); }, }); @@ -268,7 +267,6 @@ export function UsageView() { useHotkey({ id: 'usage-share', label: 'Share usage image', - keys: 'mod+shift+s', category: 'tools', action: () => { void handleShare(); }, }); diff --git a/frontend/src/hooks/useFocusedSurfaceScrolling.ts b/frontend/src/hooks/useFocusedSurfaceScrolling.ts index 09e0f90b7..1e0139ff4 100644 --- a/frontend/src/hooks/useFocusedSurfaceScrolling.ts +++ b/frontend/src/hooks/useFocusedSurfaceScrolling.ts @@ -1,18 +1,17 @@ import { useEffect } from 'react'; import { useHotkey } from './useHotkey'; import { focusedSurfaceScroll, type ScrollDirection } from '../services/focusedSurfaceScroll'; +import type { ScrollSurfaceId } from '../../../shared/constants/keyboardShortcuts'; function useScrollHotkey( - id: string, + id: ScrollSurfaceId, label: string, - keys: string, direction: ScrollDirection, page: boolean, ): void { useHotkey({ id, label, - keys, category: 'view', action: () => { if (page) focusedSurfaceScroll.page(direction); @@ -25,10 +24,10 @@ function useScrollHotkey( } export function useFocusedSurfaceScrolling(activeSessionId: string | null): void { - useScrollHotkey('scroll-focused-surface-up', 'Scroll focused surface up', 'shift+ArrowUp', -1, false); - useScrollHotkey('scroll-focused-surface-down', 'Scroll focused surface down', 'shift+ArrowDown', 1, false); - useScrollHotkey('page-focused-surface-up', 'Page focused surface up', 'shift+PageUp', -1, true); - useScrollHotkey('page-focused-surface-down', 'Page focused surface down', 'shift+PageDown', 1, true); + useScrollHotkey('scroll-focused-surface-up', 'Scroll focused surface up', -1, false); + useScrollHotkey('scroll-focused-surface-down', 'Scroll focused surface down', 1, false); + useScrollHotkey('page-focused-surface-up', 'Page focused surface up', -1, true); + useScrollHotkey('page-focused-surface-down', 'Page focused surface down', 1, true); useEffect(() => { focusedSurfaceScroll.setActiveSession(activeSessionId); diff --git a/frontend/src/hooks/useHotkey.ts b/frontend/src/hooks/useHotkey.ts index 88990655f..428df5e88 100644 --- a/frontend/src/hooks/useHotkey.ts +++ b/frontend/src/hooks/useHotkey.ts @@ -12,9 +12,8 @@ import { useCommittedRef } from './useCommittedRef'; * * Usage: * useHotkey({ - * id: 'open-prompt-history', + * id: 'open-command-palette', * label: 'Open Prompt History', - * keys: 'mod+p', * category: 'navigation', * action: () => setIsPromptHistoryOpen(true), * }); diff --git a/frontend/src/hooks/useSessionNavigationHotkeys.ts b/frontend/src/hooks/useSessionNavigationHotkeys.ts index e560ecb9b..b1521c696 100644 --- a/frontend/src/hooks/useSessionNavigationHotkeys.ts +++ b/frontend/src/hooks/useSessionNavigationHotkeys.ts @@ -13,6 +13,7 @@ import { groupSessionsByProject, } from '../utils/sessionOrdering'; import type { Project } from '../types/project'; +import type { SwitchSessionId } from '../../../shared/constants/keyboardShortcuts'; interface UseSessionNavigationHotkeysOptions { projects: Project[]; @@ -120,7 +121,6 @@ export function useSessionNavigationHotkeys({ useHotkey({ id: 'cycle-session-next-0', label: 'Next Pane', - keys: 'mod+Tab', category: 'session', enabled: () => allActiveSessionsRef.current.length > 1, action: () => cycleSession('next'), @@ -129,7 +129,6 @@ export function useSessionNavigationHotkeys({ useHotkey({ id: 'cycle-session-prev-0', label: 'Previous Pane', - keys: 'mod+shift+Tab', category: 'session', enabled: () => allActiveSessionsRef.current.length > 1, action: () => cycleSession('prev'), @@ -138,7 +137,6 @@ export function useSessionNavigationHotkeys({ useHotkey({ id: 'cycle-sidebar-session-next', label: 'Next Pane in Sidebar', - keys: 'mod+ArrowDown', category: 'session', enabled: () => { const sessions = chooseSidebarCycleSessions( @@ -156,7 +154,6 @@ export function useSessionNavigationHotkeys({ useHotkey({ id: 'cycle-sidebar-session-prev', label: 'Previous Pane in Sidebar', - keys: 'mod+ArrowUp', category: 'session', enabled: () => { const sessions = chooseSidebarCycleSessions( @@ -181,9 +178,10 @@ export function useSessionNavigationHotkeys({ const sessionLabelKey = visibleSessions.slice(0, 9).map(s => `${s.name}:${s.projectId}`).join('|'); useEffect(() => { - const ids: string[] = []; + const ids: SwitchSessionId[] = []; for (let i = 1; i <= 9; i++) { - const id = `switch-session-${i}`; + // SAFETY: The loop is explicitly bounded to the catalog's 1..9 session slots. + const id = `switch-session-${i}` as SwitchSessionId; ids.push(id); const session = visibleSessionsRef.current[i - 1]; let label = `Switch to pane ${i}`; @@ -197,7 +195,6 @@ export function useSessionNavigationHotkeys({ register({ id, label, - keys: `mod+${i}`, category: 'session', enabled: () => !!visibleSessionsRef.current[idx], action: () => { diff --git a/frontend/src/hooks/useSessionView.ts b/frontend/src/hooks/useSessionView.ts index 9bcb9f1c1..fd9e97e7c 100644 --- a/frontend/src/hooks/useSessionView.ts +++ b/frontend/src/hooks/useSessionView.ts @@ -731,7 +731,6 @@ export const useSessionView = ( useHotkey({ id: 'git-commit', label: 'Git: Commit', - keys: 'mod+shift+k', category: 'session', action: () => { setDialogType('commit'); @@ -745,7 +744,6 @@ export const useSessionView = ( useHotkey({ id: 'git-push', label: 'Git: Push', - keys: 'mod+shift+u', category: 'session', action: () => handleGitPush(), enabled: () => !!activeSession && !isMerging && !isSessionBusy && !activeSession.isMainRepo && (activeSession.gitStatus?.ahead ?? 0) > 0, @@ -755,7 +753,6 @@ export const useSessionView = ( useHotkey({ id: 'git-soft-reset', label: 'Git: Undo Last Commit', - keys: 'mod+alt+z', category: 'session', action: () => handleGitSoftReset(), enabled: () => !!activeSession && !isMerging && !isSessionBusy && !activeSession.isMainRepo && (activeSession.gitStatus?.ahead ?? 0) > 0, @@ -765,7 +762,6 @@ export const useSessionView = ( useHotkey({ id: 'git-pull', label: 'Git: Pull', - keys: 'mod+shift+l', category: 'session', action: () => handleGitPull(), enabled: () => !!activeSession && !isMerging && !isSessionBusy && !activeSession.isMainRepo, @@ -775,7 +771,6 @@ export const useSessionView = ( useHotkey({ id: 'git-rebase-from-main', label: 'Git: Rebase from Main', - keys: 'mod+shift+r', category: 'session', action: () => handleRebaseMainIntoWorktree(), enabled: () => !!activeSession && !isMerging && !isSessionBusy && !activeSession.isMainRepo && hasChangesToRebase, @@ -785,7 +780,6 @@ export const useSessionView = ( useHotkey({ id: 'git-merge-to-main', label: 'Git: Merge to Main', - keys: 'mod+shift+m', category: 'session', action: () => handleSquashAndRebaseToMain(), enabled: () => !!activeSession && !isMerging && !isSessionBusy && !activeSession.isMainRepo && diff --git a/frontend/src/hooks/useTerminalShortcuts.ts b/frontend/src/hooks/useTerminalShortcuts.ts index 388fcb101..2a9206264 100644 --- a/frontend/src/hooks/useTerminalShortcuts.ts +++ b/frontend/src/hooks/useTerminalShortcuts.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { useConfigStore } from '../stores/configStore'; import { useHotkeyStore } from '../stores/hotkeyStore'; +import type { HotkeyId } from '../../../shared/constants/keyboardShortcuts'; export function useTerminalShortcuts(): void { const config = useConfigStore((s) => s.config); @@ -18,7 +19,8 @@ export function useTerminalShortcuts(): void { const shortcuts = config?.terminalShortcuts ?? []; for (const shortcut of shortcuts) { if (!shortcut.enabled) continue; - const hotkeyId = `terminal-shortcut-${shortcut.id}`; + // SAFETY: Dynamic terminal shortcut ids are defined by this exact prefix family. + const hotkeyId = `terminal-shortcut-${shortcut.id}` as HotkeyId; register({ id: hotkeyId, label: shortcut.label || `Shortcut (${shortcut.key})`, diff --git a/frontend/src/stores/configStore.ts b/frontend/src/stores/configStore.ts index 06ea34ffc..a8633c44a 100644 --- a/frontend/src/stores/configStore.ts +++ b/frontend/src/stores/configStore.ts @@ -8,8 +8,11 @@ interface ConfigStore { error: string | null; fetchConfig: () => Promise; updateConfig: (updates: UpdateConfigRequest) => Promise; + subscribeToUpdates: () => () => void; } +let configUpdateUnsubscribe: (() => void) | null = null; + export function areKeyboardShortcutsEnabled(config: AppConfig | null): boolean { return config !== null && config.keyboardShortcutsEnabled !== false; } @@ -65,4 +68,17 @@ export const useConfigStore = create((set, get) => ({ throw new Error('Failed to update config'); } }, + + subscribeToUpdates: () => { + if (!configUpdateUnsubscribe) { + configUpdateUnsubscribe = window.electronAPI.events.onConfigUpdated((config) => { + const current = get().config; + if (JSON.stringify(current) !== JSON.stringify(config)) set({ config }); + }); + } + return () => { + configUpdateUnsubscribe?.(); + configUpdateUnsubscribe = null; + }; + }, })); diff --git a/frontend/src/stores/hotkeyStore.test.ts b/frontend/src/stores/hotkeyStore.test.ts index 8783fa724..45fc27ee8 100644 --- a/frontend/src/stores/hotkeyStore.test.ts +++ b/frontend/src/stores/hotkeyStore.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { areKeyboardShortcutsEnabled, isCommandPaletteShortcutEnabled, useConfigStore } from './configStore'; -import { useHotkeyStore } from './hotkeyStore'; +import { + isBoundChordForEvent, + isTuiReleasableChordForEvent, + useHotkeyStore, +} from './hotkeyStore'; interface HotkeyTestTarget { tagName: string; @@ -52,8 +56,9 @@ describe('hotkeyStore keyboard shortcut preference', () => { }); afterEach(() => { - useHotkeyStore.getState().unregister('test-shortcut'); - useHotkeyStore.getState().unregister('open-command-palette'); + for (const id of useHotkeyStore.getState().hotkeys.keys()) { + useHotkeyStore.getState().unregister(id); + } useConfigStore.setState({ config: null }); vi.unstubAllGlobals(); }); @@ -63,7 +68,7 @@ describe('hotkeyStore keyboard shortcut preference', () => { const preventDefault = vi.fn(); useConfigStore.setState({ config: { keyboardShortcutsEnabled: false } }); useHotkeyStore.getState().register({ - id: 'test-shortcut', + id: 'terminal-shortcut-test', label: 'Test shortcut', keys: 'mod+w', category: 'tabs', @@ -138,7 +143,7 @@ describe('hotkeyStore keyboard shortcut preference', () => { const preventDefault = vi.fn(); useConfigStore.setState({ config: {} }); useHotkeyStore.getState().register({ - id: 'test-shortcut', + id: 'terminal-shortcut-test', label: 'Scroll terminal', keys: 'shift+ArrowDown', category: 'view', @@ -182,7 +187,7 @@ describe('hotkeyStore keyboard shortcut preference', () => { const preventDefault = vi.fn(); useConfigStore.setState({ config: {} }); const definition = { - id: 'test-shortcut', + id: 'terminal-shortcut-test' as const, label: 'Scroll modal', keys: 'shift+ArrowUp', category: 'view' as const, @@ -209,4 +214,109 @@ describe('hotkeyStore keyboard shortcut preference', () => { expect(action).toHaveBeenCalledOnce(); expect(preventDefault).toHaveBeenCalledOnce(); }); + + it('remaps dispatch immediately and unassigns without removing the command', () => { + const action = vi.fn(); + const target = { tagName: 'DIV', isContentEditable: false, closest: () => null }; + useConfigStore.setState({ + config: { keyboardShortcutOverrides: { 'open-settings': 'mod+alt+7' } }, + }); + useHotkeyStore.getState().register({ + id: 'open-settings', label: 'Open Settings', category: 'navigation', action, + }); + const oldEvent = keyboardEvent({ key: ',', code: 'Comma', ctrlKey: true }, target, vi.fn()); + const newEvent = keyboardEvent({ key: '7', code: 'Digit7', ctrlKey: true, altKey: true }, target, vi.fn()); + keydownListener?.(oldEvent); + keydownListener?.(newEvent); + expect(action).toHaveBeenCalledOnce(); + expect(useHotkeyStore.getState().hotkeys.get('open-settings')?.keys).toBe('mod+alt+7'); + + useConfigStore.setState({ + config: { keyboardShortcutOverrides: { 'open-settings': null } }, + }); + keydownListener?.(newEvent); + expect(action).toHaveBeenCalledOnce(); + expect(useHotkeyStore.getState().hotkeys.get('open-settings')?.keys).toBe(''); + }); + + it('runs neither command when enabled candidates share a chord', () => { + const first = vi.fn(); + const second = vi.fn(); + const preventDefault = vi.fn(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + useConfigStore.setState({ config: { keyboardShortcutOverrides: { + 'open-settings': 'mod+x', 'new-session': 'mod+x', + } } }); + useHotkeyStore.getState().register({ id: 'open-settings', label: 'Settings', category: 'navigation', action: first }); + useHotkeyStore.getState().register({ id: 'new-session', label: 'New Pane', category: 'session', action: second }); + keydownListener?.(keyboardEvent( + { key: 'x', code: 'KeyX', ctrlKey: true }, + { tagName: 'DIV', isContentEditable: false, closest: () => null }, + preventDefault, + )); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith('[hotkeyStore] Ambiguous chord', 'mod+x', ['open-settings', 'new-session']); + }); + + it('keeps terminal interception mount-independent and follows remaps', () => { + useConfigStore.setState({ config: { keyboardShortcutOverrides: { + 'add-tool-terminal-claude': 'mod+alt+j', 'git-push': 'mod+alt+g', + } } }); + const event = (keyName: string, code: string) => keyboardEvent( + { key: keyName, code, ctrlKey: true, altKey: true }, + { tagName: 'DIV', isContentEditable: false, closest: () => null }, + vi.fn(), + ); + expect(isBoundChordForEvent(event('7', 'Digit7'))).toBe(true); + expect(isBoundChordForEvent(event('5', 'Digit5'))).toBe(true); + expect(isTuiReleasableChordForEvent(event('j', 'KeyJ'))).toBe(true); + expect(isTuiReleasableChordForEvent(event('g', 'KeyG'))).toBe(false); + expect(isBoundChordForEvent(event('y', 'KeyY'))).toBe(false); + }); + + it.each([ + ['mod+shift+Tab', { key: 'Tab', code: 'Tab', ctrlKey: true, shiftKey: true }, true, true], + ['mod+Tab', { key: 'Tab', code: 'Tab', ctrlKey: true }, true, true], + ['mod+shift+3', { key: '#', code: 'Digit3', ctrlKey: true, shiftKey: true }, true, true], + ['mod+shift+z', { key: 'Z', code: 'KeyZ', ctrlKey: true, shiftKey: true }, true, true], + ['mod+\\', { key: '\\', code: 'Backslash', ctrlKey: true }, true, true], + ['mod+alt+ArrowLeft', { key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true, altKey: true }, true, true], + ['mod+alt+/', { key: '/', code: 'Slash', ctrlKey: true, altKey: true }, false, true], + ['mod+alt+3', { key: '3', code: 'Digit3', ctrlKey: true, altKey: true }, true, true], + ['mod+`', { key: '`', code: 'Backquote', ctrlKey: true }, false, true], + ['mod+shift+u', { key: 'U', code: 'KeyU', ctrlKey: true, shiftKey: true }, false, true], + ['mod+shift+s', { key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true }, false, false], + ] satisfies readonly [string, KeyboardEventInit, boolean, boolean][])( + 'joins the %s event to catalog interception sets', + (_chord, init, expectedTuiReleasable, expectedBound) => { + useConfigStore.setState({ config: {} }); + const event = keyboardEvent( + init, + { tagName: 'DIV', isContentEditable: false, closest: () => null }, + vi.fn(), + ); + + expect(isTuiReleasableChordForEvent(event)).toBe(expectedTuiReleasable); + expect(isBoundChordForEvent(event)).toBe(expectedBound); + }, + ); + + it('keeps the palette exception attached to its remapped id', () => { + const action = vi.fn(); + useConfigStore.setState({ config: { + keyboardShortcutsEnabled: false, + keyboardShortcutOverrides: { 'open-command-palette': 'mod+alt+p' }, + } }); + useHotkeyStore.getState().register({ + id: 'open-command-palette', label: 'Palette', category: 'navigation', action, + }); + keydownListener?.(keyboardEvent( + { key: 'p', code: 'KeyP', ctrlKey: true, altKey: true }, + { tagName: 'DIV', isContentEditable: false, closest: () => null }, + vi.fn(), + )); + expect(action).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/stores/hotkeyStore.ts b/frontend/src/stores/hotkeyStore.ts index 4066a0314..c814d8ca3 100644 --- a/frontend/src/stores/hotkeyStore.ts +++ b/frontend/src/stores/hotkeyStore.ts @@ -1,304 +1,274 @@ -/** - * Global hotkey registry store using Zustand. - * - * Manages application-wide keyboard shortcuts with features: - * - Centralized registration/unregistration via `register()` and `unregister()` - * - Automatic conflict detection with console warnings in dev mode - * - Category-based organization for Help dialog grouping - * - Search/filter functionality for Command Palette - * - Conditional enabling via `enabled` callbacks checked on every keypress - * - Platform-aware key normalization (Ctrl/Cmd → 'mod') - * - Support for hiding alternative shortcuts from UI via `showInPalette` - * - * @example - * ```tsx - * const { register, unregister } = useHotkeyStore(); - * - * useEffect(() => { - * register({ - * id: 'my-action', - * label: 'Do Something', - * keys: 'mod+k', - * category: 'navigation', - * action: () => console.log('triggered'), - * }); - * return () => unregister('my-action'); - * }, [register, unregister]); - * ``` - * - * @module hotkeyStore - */ +/** Global hotkey registry backed by the shared shortcut catalog. */ import { create } from 'zustand'; +import { + getCatalogEntry, + type HotkeyId, + type ShortcutCategory, +} from '../../../shared/constants/keyboardShortcuts'; +import { + buildInterceptionSets, + normalizeKeyboardShortcutOverrides, + resolveEffectiveChord, +} from '../../../shared/utils/keyboardBindings'; +import { chordFromKeyboardEvent, type KeyboardEventLike } from '../../../shared/utils/keyboardChords'; import { areKeyboardShortcutsEnabled, isCommandPaletteShortcutEnabled, useConfigStore } from './configStore'; export interface HotkeyDefinition { - /** Unique identifier, e.g. 'open-prompt-history' */ - id: string; - /** Human-readable description for help/command palette */ + id: HotkeyId; label: string; - /** Key combination string, e.g. 'mod+p', 'mod+shift+n', 'mod+alt+ArrowLeft' */ - keys: string; - /** Grouping for help dialog display */ - category: 'navigation' | 'session' | 'tabs' | 'view' | 'tools' | 'debug' | 'shortcuts'; - /** The function to execute */ + keys?: string; + category: ShortcutCategory; action: () => void; - /** Only register in development mode? */ devOnly?: boolean; - /** Is this hotkey currently enabled? Checked on every keypress. */ enabled?: () => boolean; - /** Explanation shown when a command is present but unavailable. */ disabledReason?: () => string | null; - /** If false, hotkey works but doesn't appear in Command Palette/Help. Defaults to true. */ showInPalette?: boolean; - /** Allow this command to run inside a modal focus scope. */ allowInModal?: boolean; - /** Allow an unmodified command to run from xterm's helper textarea. */ allowInXterm?: boolean; } +interface EffectiveHotkeyDefinition extends Omit { + keys: string; + registeredKeys?: string; +} + interface GetAllOptions { paletteOnly?: boolean; } interface HotkeyStore { - hotkeys: Map; + hotkeys: Map; register: (def: HotkeyDefinition) => void; unregister: (id: string) => void; - getAll: (options?: GetAllOptions) => HotkeyDefinition[]; - getByCategory: (category: HotkeyDefinition['category']) => HotkeyDefinition[]; - search: (query: string, options?: GetAllOptions) => HotkeyDefinition[]; + getAll: (options?: GetAllOptions) => EffectiveHotkeyDefinition[]; + getByCategory: (category: ShortcutCategory) => EffectiveHotkeyDefinition[]; + search: (query: string, options?: GetAllOptions) => EffectiveHotkeyDefinition[]; } -// --- Key matching logic (module-level, not in store) --- - -// Canonical modifier order — MUST be identical in both normalize functions -const MODIFIER_ORDER = ['mod', 'alt', 'shift'] as const; - -// Punctuation codes resolved via e.code when Alt is held; macOS Option modifies -// e.key for these too (e.g. Option+/ produces '÷' on some layouts) -interface AlternatePunctuationCodes { - [code: string]: string; +interface RebuiltHotkeyIndex { + next: Map; + index: Map; } -const ALT_PUNCTUATION_CODES: AlternatePunctuationCodes = { - Slash: '/', - Comma: ',', - Period: '.', - Semicolon: ';', - Quote: "'", - BracketLeft: '[', - BracketRight: ']', - Backquote: '`', - Minus: '-', - Equal: '=', -}; - -/** - * Resolve the logical key from e.code for Alt-held combos, where macOS Option - * translates e.key into a special character (e.g. Option+A produces 'å', - * Option+1 produces '¡', Option+/ produces '÷' on some layouts). - * Covers letters, digits, and common punctuation. Returns null when the code - * isn't one we normalize. - */ -function altKeyFromCode(code: string): string | null { - const letterMatch = code.match(/^Key([A-Z])$/); - if (letterMatch) return letterMatch[1].toLowerCase(); - const digitMatch = code.match(/^Digit(\d)$/); - if (digitMatch) return digitMatch[1]; - return ALT_PUNCTUATION_CODES[code] ?? null; +let listenerAttached = false; +let lookupIndex = new Map(); +const initialConfig = useConfigStore.getState().config; +let interceptionSets = buildInterceptionSets({ + overrides: initialConfig?.keyboardShortcutOverrides, + terminalShortcuts: initialConfig?.terminalShortcuts, + customCommands: initialConfig?.customCommands, +}); + +function currentOverrides() { + return normalizeKeyboardShortcutOverrides( + useConfigStore.getState().config?.keyboardShortcutOverrides, + ).overrides; } -function normalizeKeyEvent(e: KeyboardEvent): string { - const parts: string[] = []; - if (e.metaKey || e.ctrlKey) parts.push('mod'); - if (e.altKey) parts.push('alt'); - if (e.shiftKey) parts.push('shift'); - // parts is already in canonical order because we push in that order - // Use e.code for letters/digits/punctuation when alt is held; macOS Option - // key modifies e.key (e.g. Option+A produces 'å' instead of 'a') - // Skip AltGr: on Windows/Linux international layouts AltGr sets both ctrlKey+altKey - // but is used for character input (e.g. AltGr+Q = '@' on German keyboards) - const isAltGr = e.getModifierState('AltGraph'); - const altCodeKey = e.altKey && !isAltGr ? altKeyFromCode(e.code) : null; - let key = altCodeKey ?? (e.key.length === 1 ? e.key.toLowerCase() : e.key); - // Use e.code for digits when shift is held — e.key is layout-dependent - // (e.g. Shift+2 produces '@' on US, '"' on UK, different on AZERTY) - const digitMatch = e.shiftKey && e.code.match(/^Digit(\d)$/); - if (digitMatch) { - key = digitMatch[1]; - } - // Use e.code for Backslash — Shift+\ produces '|' on US layout, breaking - // registration of mod+shift+\ if we use e.key. ISO/international keyboards - // report the physical key as IntlBackslash instead. - if (e.code === 'Backslash' || e.code === 'IntlBackslash') { - key = '\\'; +function rebuildIndex(hotkeys: Map): RebuiltHotkeyIndex { + const overrides = currentOverrides(); + const next = new Map(); + const index = new Map(); + for (const [id, definition] of hotkeys) { + const catalogDefault = getCatalogEntry(id)?.defaultChord; + const chord = resolveEffectiveChord( + id, + overrides, + catalogDefault === undefined ? definition.registeredKeys ?? null : catalogDefault, + ); + const effective = { ...definition, keys: chord ?? '' }; + next.set(id, effective); + if (!chord) continue; + const candidates = index.get(chord) ?? []; + candidates.push(definition.id); + index.set(chord, candidates); } - parts.push(key); - return parts.join('+'); + return { next, index }; } -function normalizeHotkeyString(keys: string): string { - const parts = keys.split('+'); - const modifiers: string[] = []; - let key = ''; - for (const part of parts) { - const lower = part.toLowerCase(); - // SAFETY: The value comes from the adjacent finite domain definition. - if ((MODIFIER_ORDER as readonly string[]).includes(lower)) { - modifiers.push(lower); - } else { - key = part.length === 1 ? part.toLowerCase() : part; - } - } - modifiers.sort( - (a, b) => - // SAFETY: The value comes from the adjacent finite domain definition. - (MODIFIER_ORDER as readonly string[]).indexOf(a) - - // SAFETY: The value comes from the adjacent finite domain definition. - (MODIFIER_ORDER as readonly string[]).indexOf(b) - ); - return [...modifiers, key].join('+'); +function isGloballyAllowed(id: HotkeyId): boolean { + const config = useConfigStore.getState().config; + return areKeyboardShortcutsEnabled(config) + || (id === 'open-command-palette' && isCommandPaletteShortcutEnabled(config)); } -let listenerAttached = false; -let lookupIndex: Map = new Map(); // normalized keys → hotkey id +function isDefinitionEnabled(definition: EffectiveHotkeyDefinition): boolean { + if (definition.devOnly && process.env.NODE_ENV !== 'development') return false; + return !definition.enabled || definition.enabled(); +} -export function isHotkeyEnabledForEvent(e: KeyboardEvent): boolean { - const hotkeyId = lookupIndex.get(normalizeKeyEvent(e)); - if (!hotkeyId) return false; +function enabledCandidates(event: KeyboardEvent): EffectiveHotkeyDefinition[] { + const chord = chordFromKeyboardEvent(event); + const ids = lookupIndex.get(chord) ?? []; + const hotkeys = useHotkeyStore.getState().hotkeys; + return ids.flatMap(id => { + const definition = hotkeys.get(id); + return definition && isGloballyAllowed(id) && isDefinitionEnabled(definition) + ? [definition] + : []; + }); +} +export function isHotkeyEnabledForEvent(event: KeyboardEvent): boolean { + return enabledCandidates(event).length === 1; +} + +export function isBoundChordForEvent(event: KeyboardEventLike): boolean { + const chord = chordFromKeyboardEvent(event); + if (!chord || !interceptionSets.bound.has(chord)) return false; const config = useConfigStore.getState().config; - if (!areKeyboardShortcutsEnabled(config)) { - if (hotkeyId !== 'open-command-palette' || !isCommandPaletteShortcutEnabled(config)) return false; - } + if (areKeyboardShortcutsEnabled(config)) return true; + const paletteChord = resolveEffectiveChord( + 'open-command-palette', + currentOverrides(), + getCatalogEntry('open-command-palette')?.defaultChord ?? null, + ); + return isCommandPaletteShortcutEnabled(config) && chord === paletteChord; +} - const def = useHotkeyStore.getState().hotkeys.get(hotkeyId); - if (!def) return false; - if (def.devOnly && process.env.NODE_ENV !== 'development') return false; - return !def.enabled || def.enabled(); +export function isTuiReleasableChordForEvent(event: KeyboardEventLike): boolean { + const chord = chordFromKeyboardEvent(event); + return Boolean(chord && interceptionSets.tuiReleasable.has(chord)); } function isXtermHelperTarget(target: HTMLElement): boolean { return target.classList.contains('xterm-helper-textarea') || target.closest('.xterm') !== null; } -function handleKeyDown(e: KeyboardEvent) { - // SAFETY: The registered DOM/custom-event source establishes this target and detail shape. - const target = e.target as HTMLElement; - const isInput = - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable; - - const pressed = normalizeKeyEvent(e); - const hotkeyId = lookupIndex.get(pressed); - if (!hotkeyId) return; - - const store = useHotkeyStore.getState(); - const def = store.hotkeys.get(hotkeyId); - if (!def) return; - - // Modal-local commands can opt in, but all other application hotkeys remain - // suppressed while focus is trapped in a dialog. - const isInsideModal = target.closest('[aria-modal="true"]') !== null; - if (isInsideModal && !def.allowInModal) return; - - // Let native text editing win for shortcuts users expect in focused inputs. - // In particular, tab cycling uses mod+a/mod+d, but inputs need mod+a - // for select-all and mod+d for normal browser/text-field behavior. - if (isInput && !isXtermHelperTarget(target) && (pressed === 'mod+a' || pressed === 'mod+d')) return; - - // Skip if typing in input and shortcut doesn't use mod key - if ( +function passesFocusRules( + event: KeyboardEvent, + pressed: string, + definition: EffectiveHotkeyDefinition, +): boolean { + // SAFETY: This handler is installed only on the DOM window keydown event. + const target = event.target as HTMLElement; + const isInput = target.tagName === 'INPUT' + || target.tagName === 'TEXTAREA' + || target.isContentEditable; + if (target.closest('[aria-modal="true"]') !== null && !definition.allowInModal) return false; + if (isInput && !isXtermHelperTarget(target) && (pressed === 'mod+a' || pressed === 'mod+d')) return false; + return !( isInput && !pressed.includes('mod') - && !(def.allowInXterm && isXtermHelperTarget(target)) - ) return; - - if (!isHotkeyEnabledForEvent(e)) return; - - e.preventDefault(); - def.action(); + && !(definition.allowInXterm && isXtermHelperTarget(target)) + ); } -function rebuildIndex(hotkeys: Map) { - lookupIndex = new Map(); - for (const [id, def] of hotkeys) { - if (!def.keys) continue; // Skip palette-only commands with no keybinding - const normalized = normalizeHotkeyString(def.keys); - if (process.env.NODE_ENV === 'development' && lookupIndex.has(normalized)) { - const existingId = lookupIndex.get(normalized); - console.warn( - `[hotkeyStore] Conflict: "${def.keys}" registered by "${id}" overwrites "${existingId}"` - ); - } - lookupIndex.set(normalized, id); +function handleKeyDown(event: KeyboardEvent): void { + const pressed = chordFromKeyboardEvent(event); + if (!pressed) return; + const ids = lookupIndex.get(pressed) ?? []; + const hotkeys = useHotkeyStore.getState().hotkeys; + const candidates = ids.flatMap(id => { + const definition = hotkeys.get(id); + return definition + && passesFocusRules(event, pressed, definition) + && isGloballyAllowed(id) + && isDefinitionEnabled(definition) + ? [definition] + : []; + }); + + if (candidates.length > 1) { + console.warn('[hotkeyStore] Ambiguous chord', pressed, candidates.map(candidate => candidate.id)); + return; } + const definition = candidates[0]; + if (!definition) return; + event.preventDefault(); + definition.action(); } -function attachListener() { - if (!listenerAttached) { - window.addEventListener('keydown', handleKeyDown); - listenerAttached = true; - } +function attachListener(): void { + if (listenerAttached) return; + window.addEventListener('keydown', handleKeyDown); + listenerAttached = true; } -function detachListener() { - if (listenerAttached) { - window.removeEventListener('keydown', handleKeyDown); - listenerAttached = false; - } +function detachListener(): void { + if (!listenerAttached) return; + window.removeEventListener('keydown', handleKeyDown); + listenerAttached = false; } export const useHotkeyStore = create((set, get) => ({ hotkeys: new Map(), - register: (def) => { - set((state) => { - const next = new Map(state.hotkeys); - next.set(def.id, def); - rebuildIndex(next); + register: (definition) => { + set(state => { + const registered = new Map(state.hotkeys); + registered.set(definition.id, { + ...definition, + keys: '', + registeredKeys: definition.keys, + }); + const rebuilt = rebuildIndex(registered); + lookupIndex = rebuilt.index; attachListener(); - return { hotkeys: next }; + return { hotkeys: rebuilt.next }; }); }, unregister: (id) => { - set((state) => { - const next = new Map(state.hotkeys); - next.delete(id); - rebuildIndex(next); - if (next.size === 0) detachListener(); - return { hotkeys: next }; + set(state => { + const registered = new Map(state.hotkeys); + registered.delete(id); + const rebuilt = rebuildIndex(registered); + lookupIndex = rebuilt.index; + if (rebuilt.next.size === 0) detachListener(); + return { hotkeys: rebuilt.next }; }); }, - getAll: (options?: GetAllOptions) => { - const state = get(); - let results = Array.from(state.hotkeys.values()).filter( - (def) => !def.devOnly || process.env.NODE_ENV === 'development' + getAll: (options) => { + let results = [...get().hotkeys.values()].filter( + definition => !definition.devOnly || process.env.NODE_ENV === 'development', ); if (options?.paletteOnly) { - results = results.filter((def) => def.showInPalette !== false); + results = results.filter(definition => definition.showInPalette !== false); } return results; }, - getByCategory: (category) => { - return get() - .getAll() - .filter((def) => def.category === category); - }, + getByCategory: (category) => get().getAll().filter(definition => definition.category === category), - search: (query, options?: GetAllOptions) => { + search: (query, options) => { const lower = query.toLowerCase(); - return get() - .getAll(options) - .filter( - (def) => - def.label.toLowerCase().includes(lower) || - def.keys.toLowerCase().includes(lower) || - def.id.toLowerCase().includes(lower) - ); + return get().getAll(options).filter(definition => + definition.label.toLowerCase().includes(lower) + || definition.keys?.toLowerCase().includes(lower) + || definition.id.toLowerCase().includes(lower) + ); }, })); + +let previousOverrides = initialConfig?.keyboardShortcutOverrides; +let previousTerminalShortcuts = initialConfig?.terminalShortcuts; +let previousCustomCommands = initialConfig?.customCommands; + +function rebuildForConfig(): void { + const config = useConfigStore.getState().config; + interceptionSets = buildInterceptionSets({ + overrides: config?.keyboardShortcutOverrides, + terminalShortcuts: config?.terminalShortcuts, + customCommands: config?.customCommands, + }); + const rebuilt = rebuildIndex(useHotkeyStore.getState().hotkeys); + lookupIndex = rebuilt.index; + useHotkeyStore.setState({ hotkeys: rebuilt.next }); +} + +useConfigStore.subscribe(state => { + const overrides = state.config?.keyboardShortcutOverrides; + const terminalShortcuts = state.config?.terminalShortcuts; + const customCommands = state.config?.customCommands; + if ( + overrides === previousOverrides + && terminalShortcuts === previousTerminalShortcuts + && customCommands === previousCustomCommands + ) return; + previousOverrides = overrides; + previousTerminalShortcuts = terminalShortcuts; + previousCustomCommands = customCommands; + rebuildForConfig(); +}); diff --git a/frontend/src/stores/projectViewActionsStore.ts b/frontend/src/stores/projectViewActionsStore.ts index c2a999703..b68c624c3 100644 --- a/frontend/src/stores/projectViewActionsStore.ts +++ b/frontend/src/stores/projectViewActionsStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import type { InspectorTab } from '../components/InspectorTabs'; +import type { PanelCreateOptions } from '../types/panelComponents'; /** * What a main-repo pane (ProjectView) can do in response to the global tab / @@ -11,6 +12,7 @@ interface ProjectViewActions { toggleDetail: () => void; showInspector: (tab: InspectorTab) => void; addTerminal: () => void; + addTerminalWithOptions: (options: PanelCreateOptions) => void; tabCount: () => number; selectTab: (index: number) => void; cycleTab: (direction: 'next' | 'prev') => void; diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 3f07a7fe5..c04538a8e 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -3,6 +3,7 @@ import type { RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; import type { PaneChatAgent } from '../../../shared/types/paneChat'; import type { VoiceTranscriptionMode } from '../../../shared/types/voiceTranscription'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; +import type { KeyboardShortcutOverrides } from '../../../shared/utils/keyboardBindings'; export interface TerminalShortcut { id: string; @@ -134,6 +135,8 @@ export interface AppConfig { customCommands?: CustomCommand[]; // Terminal shortcuts — hotkey-triggered clipboard paste snippets terminalShortcuts?: TerminalShortcut[]; + // Missing follows defaults; null unassigns; an empty update resets all. + keyboardShortcutOverrides?: KeyboardShortcutOverrides; // Whether Pane intercepts application keyboard shortcuts keyboardShortcutsEnabled?: boolean; // Whether the Command Palette shortcut remains active when other shortcuts are disabled @@ -190,6 +193,8 @@ export interface UpdateConfigRequest { analytics?: AnalyticsConfig; customCommands?: CustomCommand[]; terminalShortcuts?: TerminalShortcut[]; + // Replaces the sparse override map wholesale; {} deletes it. + keyboardShortcutOverrides?: KeyboardShortcutOverrides; keyboardShortcutsEnabled?: boolean; commandPaletteShortcutEnabled?: boolean; kittyKeyboardEnabled?: boolean; diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index aacb9f8d9..d2a3c83ef 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -441,6 +441,7 @@ interface ElectronAPI { // Terminal font config events onTerminalFontUpdated: (callback: (data: { terminalFontFamily: string; terminalFontSize: number }) => void) => () => void; + onConfigUpdated: (callback: (config: AppConfig) => void) => () => void; removeAllListeners: (channel: string) => void; }; diff --git a/frontend/src/utils/hotkeyUtils.ts b/frontend/src/utils/hotkeyUtils.ts index 023fc29ee..76d042e5b 100644 --- a/frontend/src/utils/hotkeyUtils.ts +++ b/frontend/src/utils/hotkeyUtils.ts @@ -10,11 +10,11 @@ * * @module hotkeyUtils */ -import type { HotkeyDefinition } from '../stores/hotkeyStore'; +import type { ShortcutCategory } from '../../../shared/constants/keyboardShortcuts'; import { isMac } from './platformUtils'; /** Canonical display order for hotkey categories */ -export const CATEGORY_ORDER: HotkeyDefinition['category'][] = [ +export const CATEGORY_ORDER: ShortcutCategory[] = [ 'navigation', 'session', 'tabs', @@ -32,7 +32,7 @@ export const CATEGORY_LABELS = { tools: 'Add Tool', shortcuts: 'Shortcuts', debug: 'Debug', -} satisfies Record; +} satisfies Record; export function formatKeyDisplay(keys: string): string { const isMacPlatform = isMac(); diff --git a/frontend/src/utils/terminalKeyHandling.test.ts b/frontend/src/utils/terminalKeyHandling.test.ts index fc69de0c7..4e7e6b763 100644 --- a/frontend/src/utils/terminalKeyHandling.test.ts +++ b/frontend/src/utils/terminalKeyHandling.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it } from 'vitest'; +import { KEYBOARD_SHORTCUT_CATALOG } from '../../../shared/constants/keyboardShortcuts'; +import { + buildInterceptionSets, + collectInterceptionBindings, +} from '../../../shared/utils/keyboardBindings'; import { isFineSurfaceScrollKey, + isTerminalReservedChord, isPageSurfaceScrollKey, resolveTerminalKeyHandling, + shouldReleaseToApplication, shouldOpenTerminalSearch, terminalClaimsFineSurfaceScroll, TERMINAL_MULTILINE_NEWLINE_SEQUENCE, @@ -49,19 +56,98 @@ describe('focused surface terminal key boundary', () => { }); }); +const legacyTuiReleasable = (event: TerminalKeyLike): boolean => { + if (event.altKey && !event.shiftKey) { + return /^Digit[1-9]$/.test(event.code) || event.key.startsWith('Arrow'); + } + if (!event.altKey && event.key === 'Tab') return true; + if (!event.altKey && event.shiftKey && /^Digit[1-9]$/.test(event.code)) return true; + if (!event.altKey && event.shiftKey && event.key.toLowerCase() === 'z') return true; + return !event.altKey && (event.code === 'Backslash' || event.code === 'IntlBackslash'); +}; + const tui = (overrides: Partial<{ isTuiActive: boolean; isCliPanel: boolean; isMac: boolean; keyboardShortcutsEnabled: boolean; + isTuiReleasableChord: (event: TerminalKeyLike) => boolean; }> = {}) => ({ isTuiActive: true, isCliPanel: true, isMac: false, keyboardShortcutsEnabled: true, + isTuiReleasableChord: legacyTuiReleasable, ...overrides, }); +describe('terminal application release', () => { + it('matches the legacy ordinary-terminal release inventory', () => { + const sets = buildInterceptionSets({}); + for (const entry of KEYBOARD_SHORTCUT_CATALOG) { + if (!entry.defaultChord) continue; + expect(sets.bound.has(entry.defaultChord), entry.id).toBe(entry.id !== 'usage-share'); + } + expect(sets.bound.has('mod+alt+t')).toBe(false); + expect(sets.bound.has('mod+shift+t')).toBe(false); + }); + + it('matches the frozen legacy TUI command inventory from the catalog', () => { + const expectedIds = [...Object.freeze([ + 'focus-group-left', 'focus-group-right', 'focus-group-up', 'focus-group-down', + 'add-tool-terminal', 'add-tool-explorer', 'add-tool-terminal-claude', + 'add-tool-terminal-codex', 'add-tool-terminal-cursor', + 'add-tool-custom-0', 'add-tool-custom-1', 'add-tool-custom-2', 'add-tool-custom-3', + 'cycle-session-next-0', 'cycle-session-prev-0', + 'panel-tab-1', 'panel-tab-2', 'panel-tab-3', 'panel-tab-4', 'panel-tab-5', + 'panel-tab-6', 'panel-tab-7', 'panel-tab-8', 'panel-tab-9', + 'zoom-toggle', 'split-right', 'split-down', + ])].sort(); + const actualIds = collectInterceptionBindings({}) + .filter(binding => binding.releaseInTui) + .map(binding => binding.id) + .sort(); + expect(actualIds).toEqual(expectedIds); + }); + + it('releases bound-but-disabled chords and preserves the Windows backslash guard', () => { + const sets = buildInterceptionSets({}); + expect(shouldReleaseToApplication( + key({ key: 'U', code: 'KeyU', ctrlKey: true, shiftKey: true }), + { isMac: false, isBound: sets.bound.has('mod+shift+u') }, + )).toBe(true); + expect(shouldReleaseToApplication( + key({ key: '\\', code: 'Backslash', metaKey: true }), + { isMac: false, isBound: sets.bound.has('mod+\\') }, + )).toBe(false); + }); + + it.each(['f', 'v', 'q', 'p'])('keeps mod+%s terminal-reserved with extra modifiers', (letter) => { + expect(isTerminalReservedChord(key({ + key: letter, code: `Key${letter.toUpperCase()}`, ctrlKey: true, altKey: true, shiftKey: true, + }))).toBe(true); + }); + + it('releases only bound non-AltGr chords while preserving backslash platform guards', () => { + expect(shouldReleaseToApplication(key({ ctrlKey: true }), { isMac: false, isBound: true })).toBe(true); + expect(shouldReleaseToApplication(key({ ctrlKey: true, getModifierState: name => name === 'AltGraph' }), { isMac: false, isBound: true })).toBe(false); + expect(shouldReleaseToApplication( + key({ key: '\\', code: 'Backslash', ctrlKey: true }), { isMac: true, isBound: true }, + )).toBe(false); + expect(shouldReleaseToApplication( + key({ key: '\\', code: 'Backslash', metaKey: true }), { isMac: true, isBound: true }, + )).toBe(true); + }); + + it('lets a remapped TUI chord release while a non-TUI chord stays terminal-owned', () => { + const remapped = key({ key: 'j', code: 'KeyJ', ctrlKey: true, altKey: true }); + expect(resolveTerminalKeyHandling(remapped, tui({ isTuiReleasableChord: () => true }))) + .toEqual({ action: 'release-to-app' }); + expect(resolveTerminalKeyHandling(remapped, tui({ isTuiReleasableChord: () => false }))) + .toEqual({ action: 'pass-through' }); + }); +}); + describe('resolveTerminalKeyHandling', () => { it('passes keys through when Pane keyboard shortcuts are disabled', () => { expect(resolveTerminalKeyHandling( diff --git a/frontend/src/utils/terminalKeyHandling.ts b/frontend/src/utils/terminalKeyHandling.ts index fe616ecbe..f3995690d 100644 --- a/frontend/src/utils/terminalKeyHandling.ts +++ b/frontend/src/utils/terminalKeyHandling.ts @@ -12,6 +12,7 @@ export interface TerminalKeyHandlingState { isCliPanel: boolean; isMac: boolean; keyboardShortcutsEnabled: boolean; + isTuiReleasableChord?: (event: TerminalKeyLike) => boolean; } export interface TerminalKeyLike { @@ -75,25 +76,24 @@ function isPaneNavigationShortcut( && event.altKey && digitMatch && event.key !== digitMatch[1]; - if ( - event.altKey - && !isAltGr - && !isUnreportedAltGrDigit - && !event.shiftKey - && ( - ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key) - || digitMatch - ) - ) { - return true; - } + if (isAltGr || isUnreportedAltGrDigit) return false; + return state.isTuiReleasableChord?.(event) ?? false; +} - if (!event.altKey && event.key === 'Tab') return true; - if (!event.altKey && event.shiftKey && /^Digit[1-9]$/.test(event.code)) return true; - if (!event.altKey && event.shiftKey && event.key.toLowerCase() === 'z') return true; +export function isTerminalReservedChord(event: TerminalKeyLike): boolean { + if (event.code === 'AltRight') return true; + return (event.ctrlKey || event.metaKey) + && ['f', 'v', 'q', 'p'].includes(event.key.toLowerCase()); +} +export function shouldReleaseToApplication( + event: TerminalKeyLike, + state: { isMac: boolean; isBound: boolean }, +): boolean { + if (event.getModifierState('AltGraph')) return false; const isBackslash = event.code === 'Backslash' || event.code === 'IntlBackslash'; - return !event.altKey && isBackslash; + if (isBackslash && (state.isMac ? !event.metaKey : !event.ctrlKey)) return false; + return state.isBound; } export function resolveTerminalKeyHandling( diff --git a/main/src/index.ts b/main/src/index.ts index 73d95757e..9d8ea1a55 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -66,7 +66,11 @@ import * as os from 'os'; import type { SessionManager } from './services/sessionManager'; import { isCliAgentType, resolveAgentTypeFromCommand } from './services/agents/agentIdentity'; import type { ConfigManager } from './services/configManager'; -import { areKeyboardShortcutsEnabled, shouldForwardCommandPaletteShortcut } from './utils/keyboardShortcuts'; +import { + areKeyboardShortcutsEnabled, + buildWebviewForwardSet, + shouldForwardWebviewInput, +} from './utils/keyboardShortcuts'; import { parseStoredOverlayColors, shouldEnableWindowControlsOverlay, @@ -115,6 +119,8 @@ export const webviewContextMap = new Map(); let devToolsHandlersRegistered = false; +let webviewForwardSet = new Set(); +let webviewForwardConfigListenerRegistered = false; // Track partitions that already have the localhost header-stripping hook registered, // so we don't add duplicate listeners when multiple webviews share the same partition. @@ -298,6 +304,13 @@ function readStoredOverlayColors(): WindowControlsOverlayColors | null { } async function createWindow() { + webviewForwardSet = buildWebviewForwardSet(configManager.getConfig()); + if (!webviewForwardConfigListenerRegistered) { + webviewForwardConfigListenerRegistered = true; + configManager.on('config-updated', (config) => { + webviewForwardSet = buildWebviewForwardSet(config); + }); + } // Strip iframe-blocking headers for localhost URLs (enables embedded browser panel) session.defaultSession.webRequest.onHeadersReceived( { urls: [ @@ -449,54 +462,9 @@ async function createWindow() { // the specific Ctrl/Cmd+key combos that Pane actually handles, so that normal // browser shortcuts (Ctrl+F, Ctrl+R, Ctrl+A in inputs, etc.) still work // inside embedded browser panels. - // Whitelist of Pane hotkeys that should be forwarded from webviews. - // mod+key (no extra modifiers): - const paneHotkeys: ReadonlySet = new Set([ - 'b', ',', 'n', 'a', 'd', 'w', 't', '`', - // mod+1..9 switch session, mod+Tab/ArrowDown cycle next - '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'tab', 'arrowdown', 'arrowup', - ]); - // mod+shift+key — matched by physical key code (Digit/Key) because - // input.key reports the shifted symbol (e.g. '!' for Shift+1) which - // varies by keyboard layout. - const paneShiftCodes: ReadonlySet = new Set([ - 'KeyE', 'KeyN', 'KeyK', 'KeyP', 'KeyZ', 'KeyL', 'KeyR', 'KeyM', 'KeyU', - 'KeyB', 'KeyW', 'KeyD', - 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', - 'Digit6', 'Digit7', 'Digit8', 'Digit9', - 'Tab', // mod+shift+Tab cycles prev session - ]); wvContents.on('before-input-event', (event, input) => { - if (input.type !== 'keyDown') return; - const mod = input.control || input.meta; - if (!mod) return; - - const key = input.key.toLowerCase(); - const code = input.code; const config = configManager.getConfig(); - if ( - !areKeyboardShortcutsEnabled(config) - && !shouldForwardCommandPaletteShortcut(config, input) - ) return; - - // Skip AltGr: on Windows/Linux international layouts, AltGr reports - // control+alt simultaneously. We detect this as control+alt without - // meta, and only forward when the physical key is a letter, digit, - // or slash (the patterns used by Pane's mod+alt shortcuts). This - // prevents blocking character input like @, €, or \ on those layouts. - const isAltGr = input.control && input.alt && !input.meta - && !/^(Key[A-Z]|Digit[0-9]|Slash)$/.test(code); - - // Determine if this combo matches a registered Pane hotkey. - // mod+alt combos are forwarded (user-configurable terminal shortcuts - // use mod+alt+), but AltGr character input is excluded above. - const isPaneHotkey = - (input.alt && !isAltGr) || - (input.shift && !input.alt && paneShiftCodes.has(code)) || - (!input.shift && !input.alt && paneHotkeys.has(key)); - - if (!isPaneHotkey) return; + if (!shouldForwardWebviewInput(input, webviewForwardSet, config)) return; event.preventDefault(); mainWindow?.webContents.send('synthetic-keydown', { diff --git a/main/src/ipc/config.test.ts b/main/src/ipc/config.test.ts index 92e33a60a..84add2700 100644 --- a/main/src/ipc/config.test.ts +++ b/main/src/ipc/config.test.ts @@ -2,7 +2,7 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import type { IpcMain } from 'electron'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Project } from '../database/models'; import type { AppServices } from './types'; import type { AppConfig, UpdateConfigRequest } from '../types/config'; @@ -50,6 +50,8 @@ function createServicesStub(projects: Project[]): AppServices { // SAFETY: This test fixture intentionally supplies the minimal structural substitute exercised by the unit. let config = { agentContext: { managedAgentsMd: true } } as AppConfig; + const listeners = new Map void>(); + const send = vi.fn(); // SAFETY: This test fixture intentionally supplies the minimal structural substitute exercised by the unit. return { app: {}, @@ -58,6 +60,9 @@ function createServicesStub(projects: Project[]): AppServices { }, gitStatusManager: {}, configManager: { + on: (event: string, listener: (config: AppConfig) => void) => { + listeners.set(event, listener); + }, getConfig: () => config, reloadFromDisk: async () => config, updateConfig: async (updates: UpdateConfigRequest) => { @@ -68,6 +73,7 @@ function createServicesStub(projects: Project[]): AppServices { ? { ...config.agentContext, ...updates.agentContext } : config.agentContext, }; + listeners.get('config-updated')?.(config); return config; }, getSessionCreationPreferences: () => config.sessionCreationPreferences, @@ -87,7 +93,7 @@ function createServicesStub(projects: Project[]): AppServices { archiveProgressManager: {}, spotlightManager: {}, runCommandManager: {}, - getMainWindow: () => null, + getMainWindow: () => ({ webContents: { send } }), } as AppServices; } @@ -133,4 +139,19 @@ describe('config IPC handlers', () => { expect(inactiveContent).toBe(''); await expect(fs.access(inactiveAgentsPath)).resolves.toBeUndefined(); }); + + it('relays each config update to the renderer once', async () => { + const ipcMain = createIpcMainStub(); + const services = createServicesStub([]); + // SAFETY: The stub implements the handler surface exercised by registerConfigHandlers. + registerConfigHandlers(ipcMain as IpcMain, services); + + await ipcMain.handlers.get('config:update')?.({}, { keyboardShortcutsEnabled: false }); + + const send = services.getMainWindow?.()?.webContents.send; + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('config:updated', expect.objectContaining({ + keyboardShortcutsEnabled: false, + })); + }); }); diff --git a/main/src/ipc/config.ts b/main/src/ipc/config.ts index 09de23ab3..c5217964b 100644 --- a/main/src/ipc/config.ts +++ b/main/src/ipc/config.ts @@ -15,6 +15,10 @@ export function registerConfigHandlers( { app, configManager, claudeCodeManager, databaseService, getMainWindow, sessionManager }: AppServices, commandRegistry?: PaneCommandRegistry, ): void { + configManager.on('config-updated', (config: AppConfig) => { + getMainWindow()?.webContents.send('config:updated', config); + }); + if (commandRegistry) { commandRegistry.register('remote:pwa-affordances', (): RemotePwaAffordances => { const config = configManager.getConfig(); diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 843717aaa..c540caf2b 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -244,7 +244,7 @@ function createServicesStub(overrides: Partial = {}): AppServices { return { sessionManager: {}, gitStatusManager: {}, - configManager: {}, + configManager: { on: () => undefined }, databaseService: {}, worktreeManager: {}, gitDiffManager: {}, @@ -278,6 +278,7 @@ describe('daemon registry IPC bindings', () => { // SAFETY: This test fixture intentionally supplies the minimal structural substitute exercised by the unit. registerConfigHandlers(ipcMain, createServicesStub({ configManager: { + on: () => undefined, getConfig: () => ({ anthropicApiKey: 'secret-api-key', terminalShortcuts: [{ @@ -342,6 +343,7 @@ describe('daemon registry IPC bindings', () => { // SAFETY: This test fixture intentionally supplies the minimal structural substitute exercised by the unit. registerVoiceHandlers(ipcMain, createServicesStub({ configManager: { + on: () => undefined, getConfig: () => ({}), }, } as Partial), registry); diff --git a/main/src/preload.ts b/main/src/preload.ts index cc0762707..295e858bb 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -1035,6 +1035,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('config:terminal-font-updated', wrappedCallback); return () => ipcRenderer.removeListener('config:terminal-font-updated', wrappedCallback); }, + onConfigUpdated: (callback: (config: AppConfig) => void) => { + const wrappedCallback = (_event: Electron.IpcRendererEvent, config: AppConfig) => callback(config); + ipcRenderer.on('config:updated', wrappedCallback); + return () => ipcRenderer.removeListener('config:updated', wrappedCallback); + }, // Process management events onZombieProcessesDetected: (callback: (data: { count: number; processes: string[] }) => void) => { diff --git a/main/src/services/agents/agentLaunchPresets.test.ts b/main/src/services/agents/agentLaunchPresets.test.ts index f7982a4ff..f7d5886de 100644 --- a/main/src/services/agents/agentLaunchPresets.test.ts +++ b/main/src/services/agents/agentLaunchPresets.test.ts @@ -5,6 +5,7 @@ import { isAgentSupportedOnPlatform, } from '../../../../shared/constants/agentLaunchPresets'; import { RUNPANE_CONTRACT } from '../../../../shared/types/generatedRunpaneContract'; +import { getCatalogEntry } from '../../../../shared/constants/keyboardShortcuts'; describe('AGENT_LAUNCH_PRESETS', () => { it('mirrors the RunPane contract agent templates exactly', () => { @@ -16,9 +17,9 @@ describe('AGENT_LAUNCH_PRESETS', () => { } }); - it('assigns unique, contiguous hotkey slots starting at mod+alt+3', () => { - const slots = AGENT_LAUNCH_PRESETS.map(p => Number(p.hotkey.replace('mod+alt+', ''))); - expect(slots).toEqual(slots.map((_, i) => 3 + i)); + it('maps every preset to its catalog default', () => { + expect(AGENT_LAUNCH_PRESETS.map(p => getCatalogEntry(p.hotkeyId)?.defaultChord)) + .toEqual(['mod+alt+3', 'mod+alt+4', 'mod+alt+5']); expect(new Set(AGENT_LAUNCH_PRESETS.map(p => p.hotkeyId)).size).toBe(AGENT_LAUNCH_PRESETS.length); }); diff --git a/main/src/services/configManager.test.ts b/main/src/services/configManager.test.ts new file mode 100644 index 000000000..5d28452e7 --- /dev/null +++ b/main/src/services/configManager.test.ts @@ -0,0 +1,96 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigManager } from './configManager'; + +describe('ConfigManager keyboard shortcut overrides', () => { + let directory = ''; + let previousPaneDir: string | undefined; + + beforeEach(async () => { + previousPaneDir = process.env.PANE_DIR; + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'pane-keybindings-')); + process.env.PANE_DIR = directory; + }); + + afterEach(async () => { + if (previousPaneDir === undefined) delete process.env.PANE_DIR; + else process.env.PANE_DIR = previousPaneDir; + await fs.rm(directory, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('keeps absence sparse and deletes an empty reset map', async () => { + const manager = new ConfigManager(); + await manager.initialize(); + expect(manager.getConfig()).not.toHaveProperty('keyboardShortcutOverrides'); + await manager.updateConfig({ keyboardShortcutOverrides: { 'open-settings': 'mod+alt+7' } }); + await manager.updateConfig({ keyboardShortcutOverrides: {} }); + expect(manager.getConfig()).not.toHaveProperty('keyboardShortcutOverrides'); + expect(JSON.parse(await fs.readFile(path.join(directory, 'config.json'), 'utf8'))) + .not.toHaveProperty('keyboardShortcutOverrides'); + + await manager.updateConfig({ keyboardShortcutOverrides: { 'open-settings': 'mod+alt+7' } }); + const persisted = JSON.parse(await fs.readFile(path.join(directory, 'config.json'), 'utf8')); + delete persisted.keyboardShortcutOverrides; + await fs.writeFile(path.join(directory, 'config.json'), JSON.stringify(persisted)); + await manager.reloadFromDisk(); + expect(manager.getConfig()).not.toHaveProperty('keyboardShortcutOverrides'); + }); + + it('round-trips null, unknown ids, and invalid chords verbatim', async () => { + const raw = { + 'open-settings': null, + 'future-command': 'mod+alt+8', + 'new-session': 'not-a-chord', + }; + await fs.writeFile(path.join(directory, 'config.json'), JSON.stringify({ keyboardShortcutOverrides: raw })); + const manager = new ConfigManager(); + await manager.initialize(); + await manager.updateConfig({ verbose: true }); + expect(manager.getConfig().keyboardShortcutOverrides).toEqual(raw); + expect(JSON.parse(await fs.readFile(path.join(directory, 'config.json'), 'utf8')).keyboardShortcutOverrides) + .toEqual(raw); + }); + + it('drops and diagnoses a non-object override map loaded from disk', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await fs.writeFile(path.join(directory, 'config.json'), JSON.stringify({ + keyboardShortcutOverrides: 'abc', + })); + const manager = new ConfigManager(); + await manager.initialize(); + + expect(manager.getConfig()).not.toHaveProperty('keyboardShortcutOverrides'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be an object')); + }); + + it('drops and diagnoses a non-object override map received in an update', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const manager = new ConfigManager(); + await manager.initialize(); + + const malformedUpdate = JSON.parse('{"keyboardShortcutOverrides":["mod+x"]}'); + await manager.updateConfig(malformedUpdate); + + expect(manager.getConfig()).not.toHaveProperty('keyboardShortcutOverrides'); + expect(JSON.parse(await fs.readFile(path.join(directory, 'config.json'), 'utf8'))) + .not.toHaveProperty('keyboardShortcutOverrides'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be an object')); + }); + + it('logs a snippet/agent conflict with both owners once', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await fs.writeFile(path.join(directory, 'config.json'), JSON.stringify({ + terminalShortcuts: [{ id: 'duplicate', label: 'Duplicate', key: '3', text: '', enabled: true }], + })); + const manager = new ConfigManager(); + await manager.initialize(); + await manager.reloadFromDisk(); + const messages = warn.mock.calls.map(call => call.join(' ')).filter(message => message.includes('conflict')); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('add-tool-terminal-claude'); + expect(messages[0]).toContain('terminal-shortcut-duplicate'); + }); +}); diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index bba3f5a11..25b656876 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -12,7 +12,17 @@ import os from 'os'; import { randomUUID } from 'crypto'; import { getAppDirectory } from '../utils/appDirectory'; import { clearShellPathCache } from '../utils/shellPath'; -import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; +import { + boundary, + decodeBoundary, + decodeOptionalBoundary, + type JsonValue, +} from '../../../shared/validation/boundaryDecoder'; +import { + collectActiveBindings, + findChordConflicts, + normalizeKeyboardShortcutOverrides, +} from '../../../shared/utils/keyboardBindings'; const DEFAULT_POSTHOG_API_KEY = 'phc_wir25CCsjr2NsZGEdlWNdvwcNG1XDjhxc9RyL5KDCf1'; const LEGACY_POSTHOG_HOST = 'https://us.i.posthog.com'; @@ -32,6 +42,7 @@ export class ConfigManager extends EventEmitter { private configDir: string; private fileWatcher: FSWatcher | null = null; private lastConfigJson: string = ''; + private lastLoggedShortcutDiagnostics: string = ''; private saveConfigQueue: Promise = Promise.resolve(); constructor(defaultGitPath?: string) { @@ -184,6 +195,15 @@ export class ConfigManager extends EventEmitter { : DEFAULT_WORKTREE_FILE_SYNC_ENTRIES }; + const rawShortcutOverrides: JsonValue | undefined = loadedConfig.keyboardShortcutOverrides; + const decodedShortcutOverrides = rawShortcutOverrides === undefined + ? undefined + : decodeOptionalBoundary(rawShortcutOverrides, boundary.jsonObject); + if (decodedShortcutOverrides === undefined) { + delete this.config.keyboardShortcutOverrides; + } + this.logKeyboardShortcutDiagnostics(rawShortcutOverrides); + if (this.config.analytics?.posthogHost === LEGACY_POSTHOG_HOST) { this.config.analytics.posthogHost = DEFAULT_POSTHOG_HOST; await this.saveConfig(); @@ -313,6 +333,10 @@ export class ConfigManager extends EventEmitter { } async updateConfig(updates: Partial): Promise { + const rawShortcutOverrides: JsonValue | undefined = updates.keyboardShortcutOverrides; + const decodedShortcutOverrides = rawShortcutOverrides === undefined + ? undefined + : decodeOptionalBoundary(rawShortcutOverrides, boundary.jsonObject); const analytics = updates.analytics !== undefined ? { @@ -344,6 +368,16 @@ export class ConfigManager extends EventEmitter { ? normalizeRemoteDaemonConfig(updates.remoteDaemon) : this.config.remoteDaemon, }; + if ('keyboardShortcutOverrides' in updates) { + if (!decodedShortcutOverrides || Object.keys(decodedShortcutOverrides).length === 0) { + delete this.config.keyboardShortcutOverrides; + } + } + this.logKeyboardShortcutDiagnostics( + 'keyboardShortcutOverrides' in updates + ? rawShortcutOverrides + : this.config.keyboardShortcutOverrides, + ); await this.saveConfig(); // Clear PATH cache if additional paths were updated @@ -356,6 +390,28 @@ export class ConfigManager extends EventEmitter { return this.getConfig(); } + private logKeyboardShortcutDiagnostics(rawOverrides: JsonValue | undefined): void { + const normalized = normalizeKeyboardShortcutOverrides(rawOverrides); + const messages = normalized.diagnostics.map(message => + `[ConfigManager] keyboardShortcutOverrides: ${message}` + ); + const conflicts = findChordConflicts(collectActiveBindings({ + overrides: rawOverrides, + terminalShortcuts: this.config.terminalShortcuts, + customCommands: this.config.customCommands, + platform: process.platform, + })); + for (const conflict of conflicts) { + messages.push( + `[ConfigManager] keyboardShortcutOverrides conflict: ${conflict.chord} is bound to ${conflict.ids.join(' and ')}` + ); + } + const diagnosticKey = messages.join('\n'); + if (diagnosticKey === this.lastLoggedShortcutDiagnostics) return; + this.lastLoggedShortcutDiagnostics = diagnosticKey; + for (const message of messages) console.warn(message); + } + getGitRepoPath(): string { return this.config.gitRepoPath || ''; } diff --git a/main/src/types/config.ts b/main/src/types/config.ts index 9293a5466..85e6d044f 100644 --- a/main/src/types/config.ts +++ b/main/src/types/config.ts @@ -4,6 +4,7 @@ import type { RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; import type { PaneChatAgent } from '../../../shared/types/paneChat'; import type { VoiceTranscriptionMode } from '../../../shared/types/voiceTranscription'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; +import type { KeyboardShortcutOverrides } from '../../../shared/utils/keyboardBindings'; interface TerminalShortcut { id: string; @@ -132,6 +133,8 @@ export interface AppConfig { customCommands?: CustomCommand[]; // Terminal shortcuts — hotkey-triggered clipboard paste snippets terminalShortcuts?: TerminalShortcut[]; + // Missing follows defaults; null unassigns; an empty update resets all. + keyboardShortcutOverrides?: KeyboardShortcutOverrides; // Whether Pane intercepts application keyboard shortcuts keyboardShortcutsEnabled?: boolean; // Whether the Command Palette shortcut remains active when other shortcuts are disabled @@ -210,6 +213,8 @@ export interface UpdateConfigRequest { customCommands?: CustomCommand[]; // Terminal shortcuts — hotkey-triggered clipboard paste snippets terminalShortcuts?: TerminalShortcut[]; + // Replaces the sparse override map wholesale; {} deletes it. + keyboardShortcutOverrides?: KeyboardShortcutOverrides; // Whether Pane intercepts application keyboard shortcuts keyboardShortcutsEnabled?: boolean; // Whether the Command Palette shortcut remains active when other shortcuts are disabled diff --git a/main/src/utils/keyboardBindings.test.ts b/main/src/utils/keyboardBindings.test.ts new file mode 100644 index 000000000..a84e78489 --- /dev/null +++ b/main/src/utils/keyboardBindings.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + buildInterceptionSets, + collectActiveBindings, + findChordConflicts, + normalizeKeyboardShortcutOverrides, + resolveEffectiveChord, +} from '../../../shared/utils/keyboardBindings'; + +describe('keyboard bindings', () => { + it('normalizes valid overrides and diagnoses invalid data', () => { + expect(normalizeKeyboardShortcutOverrides({ + 'open-settings': 'SHIFT+MOD+P', unknown: 'mod+x', 'new-session': 'x', 'new-project': null, + })).toEqual({ + overrides: { 'open-settings': 'mod+shift+p', 'new-project': null }, + diagnostics: expect.arrayContaining([ + expect.stringContaining('unknown'), expect.stringContaining('new-session'), + ]), + }); + expect(normalizeKeyboardShortcutOverrides('bad').diagnostics).toHaveLength(1); + }); + + it('resolves explicit, null, and default bindings', () => { + expect(resolveEffectiveChord('x', { x: 'mod+z' }, 'mod+a')).toBe('mod+z'); + expect(resolveEffectiveChord('x', { x: null }, 'mod+a')).toBeNull(); + expect(resolveEffectiveChord('x', {}, 'mod+a')).toBe('mod+a'); + }); + + it('finds overlapping conflicts but permits the exclusive usage duplicate', () => { + const defaults = collectActiveBindings({}); + expect(findChordConflicts(defaults)).toEqual([]); + const conflicts = findChordConflicts(collectActiveBindings({ + overrides: { 'add-tool-terminal-codex': 'mod+alt+3' }, + })); + expect(conflicts).toContainEqual({ + chord: 'mod+alt+3', ids: ['add-tool-terminal-claude', 'add-tool-terminal-codex'], + }); + }); + + it('instantiates custom conflicts only for configured slots and handles malformed arrays', () => { + const override = { 'add-tool-terminal-claude': 'mod+alt+6' }; + expect(findChordConflicts(collectActiveBindings({ overrides: override, customCommands: [] }))).toEqual([]); + expect(findChordConflicts(collectActiveBindings({ overrides: override, customCommands: [{}] }))[0]?.ids) + .toContain('add-tool-custom-0'); + expect(() => collectActiveBindings({ terminalShortcuts: 'bad', customCommands: {} })).not.toThrow(); + }); + + it('keeps interception mount- and platform-independent', () => { + const sets = buildInterceptionSets({ platform: 'win32', customCommands: [] }); + expect(sets.bound.has('mod+alt+5')).toBe(true); + expect(sets.bound.has('mod+alt+9')).toBe(true); + }); +}); diff --git a/main/src/utils/keyboardChords.test.ts b/main/src/utils/keyboardChords.test.ts new file mode 100644 index 000000000..aa89ee052 --- /dev/null +++ b/main/src/utils/keyboardChords.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + chordFromElectronInput, + chordFromKeyboardEvent, + parseChord, +} from '../../../shared/utils/keyboardChords'; + +const domEvent = (overrides: Partial[0]> = {}) => ({ + key: 'a', code: 'KeyA', ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, + getModifierState: () => false, + ...overrides, +}); + +describe('keyboard chord grammar', () => { + it.each([ + ['MOD+SHIFT+p', 'mod+shift+p'], + ['mod+shift+a', 'mod+shift+a'], + ['shift+arrowup', 'shift+ArrowUp'], + ['alt+mod+\\', 'mod+alt+\\'], + ])('canonicalizes %s', (input, expected) => { + expect(parseChord(input)).toEqual({ ok: true, chord: expected }); + }); + + it.each([ + ['', 'empty'], + ['mod', 'modifier-only'], + ['a', 'bare-printable'], + ['shift+a', 'bare-printable'], + ['alt+a', 'bare-printable'], + ['mod+Hyper+a', 'unknown-modifier'], + ])('rejects %s', (input, reason) => { + expect(parseChord(input)).toEqual({ ok: false, reason }); + }); + + it('normalizes shifted digits, backslash, Option letters, and AltGr safely', () => { + expect(chordFromKeyboardEvent(domEvent({ key: '@', code: 'Digit2', shiftKey: true }))).toBe('mod+shift+2'); + expect(chordFromKeyboardEvent(domEvent({ key: '|', code: 'IntlBackslash', shiftKey: true }))).toBe('mod+shift+\\'); + expect(chordFromKeyboardEvent(domEvent({ key: 'å', code: 'KeyA', altKey: true }))).toBe('mod+alt+a'); + expect(chordFromKeyboardEvent(domEvent({ altKey: true, getModifierState: key => key === 'AltGraph' }))).toBe(''); + }); + + it('keeps DOM and Electron adapters aligned for ordinary physical presses', () => { + const dom = domEvent({ key: '!', code: 'Digit1', altKey: true, shiftKey: true }); + expect(chordFromKeyboardEvent(dom)).toBe(chordFromElectronInput({ + key: dom.key, code: dom.code, control: dom.ctrlKey, meta: dom.metaKey, + alt: dom.altKey, shift: dom.shiftKey, + })); + }); + + it('normalizes a physical Space key to the grammar named key', () => { + const parsed = parseChord('mod+space'); + expect(parsed).toEqual({ ok: true, chord: 'mod+Space' }); + expect(chordFromKeyboardEvent(domEvent({ key: ' ', code: 'Space' }))).toBe('mod+Space'); + expect(chordFromElectronInput({ + key: ' ', code: 'Space', control: true, meta: false, alt: false, shift: false, + })).toBe('mod+Space'); + }); +}); diff --git a/main/src/utils/keyboardShortcutCatalog.test.ts b/main/src/utils/keyboardShortcutCatalog.test.ts new file mode 100644 index 000000000..ccff556bf --- /dev/null +++ b/main/src/utils/keyboardShortcutCatalog.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { AGENT_LAUNCH_PRESETS } from '../../../shared/constants/agentLaunchPresets'; +import { + getCatalogEntry, + KEYBOARD_SHORTCUT_CATALOG, + scopesOverlap, +} from '../../../shared/constants/keyboardShortcuts'; + +describe('keyboard shortcut catalog', () => { + it('matches the complete audited registration inventory', () => { + const expected = [ + 'open-command-palette', 'toggle-sidebar', 'open-settings', 'focus-sidebar', + 'open-shortcut-settings', 'new-session', 'new-project', 'cycle-tab-prev-a', + 'cycle-tab-next-d', 'add-tool-terminal', 'add-tool-explorer', + 'add-tool-terminal-claude', 'add-tool-terminal-codex', 'add-tool-terminal-cursor', + 'close-active-tab', 'archive-active-session', 'split-right', 'split-down', + 'focus-group-left', 'focus-group-right', 'focus-group-up', 'focus-group-down', + 'zoom-toggle', 'git-commit', 'git-push', 'git-soft-reset', 'git-pull', + 'git-rebase-from-main', 'git-merge-to-main', 'cycle-session-next-0', + 'cycle-session-prev-0', 'cycle-sidebar-session-next', 'cycle-sidebar-session-prev', + 'toggle-terminal', 'toggle-detail-panel', 'open-add-tool', 'run-dev-server', + 'usage-download', 'usage-share', 'scroll-focused-surface-up', + 'scroll-focused-surface-down', 'page-focused-surface-up', 'page-focused-surface-down', + ...Array.from({ length: 9 }, (_, index) => `panel-tab-${index + 1}`), + ...Array.from({ length: 9 }, (_, index) => `switch-session-${index + 1}`), + ...Array.from({ length: 4 }, (_, index) => `add-tool-custom-${index}`), + ].sort(); + expect(KEYBOARD_SHORTCUT_CATALOG.map(entry => entry.id).sort()).toEqual(expected); + }); + + it('has unique ids and only the intentional exclusive default duplicate', () => { + expect(new Set(KEYBOARD_SHORTCUT_CATALOG.map(entry => entry.id)).size).toBe(KEYBOARD_SHORTCUT_CATALOG.length); + for (let left = 0; left < KEYBOARD_SHORTCUT_CATALOG.length; left += 1) { + for (let right = left + 1; right < KEYBOARD_SHORTCUT_CATALOG.length; right += 1) { + const a = KEYBOARD_SHORTCUT_CATALOG[left]; + const b = KEYBOARD_SHORTCUT_CATALOG[right]; + if (a.defaultChord && a.defaultChord === b.defaultChord) { + expect(scopesOverlap(a.scope, b.scope), `${a.id}/${b.id}`).toBe(false); + } + } + } + }); + + it('matches agent defaults and platform gates', () => { + expect(AGENT_LAUNCH_PRESETS.map(preset => getCatalogEntry(preset.hotkeyId)?.defaultChord)) + .toEqual(['mod+alt+3', 'mod+alt+4', 'mod+alt+5']); + expect(getCatalogEntry('add-tool-terminal-cursor')?.platforms).toEqual(['darwin', 'linux', 'wsl']); + }); + + it('pins interception flags', () => { + const notReleasedFromTerminal = KEYBOARD_SHORTCUT_CATALOG + .filter(entry => !entry.releaseFromTerminal) + .map(entry => entry.id); + expect(notReleasedFromTerminal).toEqual(['usage-share']); + const notForwarded = KEYBOARD_SHORTCUT_CATALOG.filter(entry => !entry.forwardFromWebview).map(entry => entry.id).sort(); + expect(notForwarded).toEqual(['split-down', 'split-right', 'usage-download', 'usage-share']); + const releasedInTui = KEYBOARD_SHORTCUT_CATALOG + .filter(entry => entry.releaseInTui) + .map(entry => entry.id) + .sort(); + expect(releasedInTui).toEqual([ + 'add-tool-custom-0', 'add-tool-custom-1', 'add-tool-custom-2', 'add-tool-custom-3', + 'add-tool-explorer', 'add-tool-terminal', 'add-tool-terminal-claude', + 'add-tool-terminal-codex', 'add-tool-terminal-cursor', 'cycle-session-next-0', + 'cycle-session-prev-0', 'focus-group-down', 'focus-group-left', 'focus-group-right', + 'focus-group-up', 'panel-tab-1', 'panel-tab-2', 'panel-tab-3', 'panel-tab-4', + 'panel-tab-5', 'panel-tab-6', 'panel-tab-7', 'panel-tab-8', 'panel-tab-9', + 'split-down', 'split-right', 'zoom-toggle', + ]); + }); +}); diff --git a/main/src/utils/keyboardShortcuts.test.ts b/main/src/utils/keyboardShortcuts.test.ts index 14da489a3..09b723842 100644 --- a/main/src/utils/keyboardShortcuts.test.ts +++ b/main/src/utils/keyboardShortcuts.test.ts @@ -1,41 +1,127 @@ import { describe, expect, it } from 'vitest'; import { areKeyboardShortcutsEnabled, + buildWebviewForwardSet, isCommandPaletteShortcutEnabled, shouldForwardCommandPaletteShortcut, + shouldForwardWebviewInput, } from './keyboardShortcuts'; -describe('areKeyboardShortcutsEnabled', () => { - it('defaults to enabled for existing configurations', () => { - expect(areKeyboardShortcutsEnabled({})).toBe(true); - }); +const input = (overrides: Partial<{ + type: string; key: string; code: string; control: boolean; meta: boolean; shift: boolean; alt: boolean; +}> = {}) => ({ + type: 'keyDown', key: 'p', code: 'KeyP', control: true, meta: false, shift: false, alt: false, + ...overrides, +}); - it('honors explicit enabled and disabled values', () => { - expect(areKeyboardShortcutsEnabled({ keyboardShortcutsEnabled: true })).toBe(true); - expect(areKeyboardShortcutsEnabled({ keyboardShortcutsEnabled: false })).toBe(false); - }); +const LEGACY_PANE_HOTKEYS = Object.freeze([ + 'b', ',', 'n', 'a', 'd', 'w', 't', '`', + '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'Tab', 'ArrowDown', 'ArrowUp', +]); + +const LEGACY_PANE_SHIFT_CODES = Object.freeze([ + 'KeyE', 'KeyN', 'KeyK', 'KeyP', 'KeyZ', 'KeyL', 'KeyR', 'KeyM', 'KeyU', + 'KeyB', 'KeyW', 'KeyD', + 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', + 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Tab', +]); + +function codeForKey(key: string): string { + if (/^[a-z]$/.test(key)) return `Key${key.toUpperCase()}`; + if (/^[1-9]$/.test(key)) return `Digit${key}`; + if (key === ',') return 'Comma'; + if (key === '`') return 'Backquote'; + return key; +} + +function keyForShiftCode(code: string): string { + if (code.startsWith('Key')) return code.slice(3); + if (code.startsWith('Digit')) return '!'; + return code; +} - it('keeps the Command Palette shortcut as an optional exception', () => { +describe('keyboard shortcut forwarding', () => { + it('preserves global enable defaults and the palette exception', () => { + expect(areKeyboardShortcutsEnabled({})).toBe(true); expect(isCommandPaletteShortcutEnabled({ keyboardShortcutsEnabled: false })).toBe(true); expect(isCommandPaletteShortcutEnabled({ - keyboardShortcutsEnabled: false, - commandPaletteShortcutEnabled: false, + keyboardShortcutsEnabled: false, commandPaletteShortcutEnabled: false, })).toBe(false); - expect(isCommandPaletteShortcutEnabled({ - keyboardShortcutsEnabled: true, - commandPaletteShortcutEnabled: false, - })).toBe(true); - }); - - it('forwards only the exact Command Palette chord from embedded panels', () => { - const config = { keyboardShortcutsEnabled: false, commandPaletteShortcutEnabled: true }; - expect(shouldForwardCommandPaletteShortcut(config, { shift: true, alt: false, code: 'KeyP' })).toBe(true); - expect(shouldForwardCommandPaletteShortcut(config, { shift: false, alt: false, code: 'KeyP' })).toBe(false); - expect(shouldForwardCommandPaletteShortcut(config, { shift: true, alt: true, code: 'KeyP' })).toBe(false); - expect(shouldForwardCommandPaletteShortcut(config, { shift: true, alt: false, code: 'KeyW' })).toBe(false); - expect(shouldForwardCommandPaletteShortcut( - { keyboardShortcutsEnabled: false, commandPaletteShortcutEnabled: false }, - { shift: true, alt: false, code: 'KeyP' }, + }); + + it('resolves the palette exception from its effective chord', () => { + const config = { + keyboardShortcutsEnabled: false, + commandPaletteShortcutEnabled: true, + keyboardShortcutOverrides: { 'open-command-palette': 'mod+alt+p' }, + }; + expect(shouldForwardCommandPaletteShortcut(config, input({ alt: true }))).toBe(true); + expect(shouldForwardCommandPaletteShortcut(config, input({ shift: true }))).toBe(false); + }); + + it('forwards every legacy unshifted Pane hotkey with default config', () => { + const config = {}; + const forwardSet = buildWebviewForwardSet(config); + for (const key of LEGACY_PANE_HOTKEYS) { + expect( + shouldForwardWebviewInput(input({ key, code: codeForKey(key) }), forwardSet, config), + `mod+${key}`, + ).toBe(true); + } + }); + + it('forwards every legacy shifted Pane physical key with default config', () => { + const config = {}; + const forwardSet = buildWebviewForwardSet(config); + for (const code of LEGACY_PANE_SHIFT_CODES) { + expect( + shouldForwardWebviewInput( + input({ key: keyForShiftCode(code), code, shift: true }), + forwardSet, + config, + ), + `mod+shift+${code}`, + ).toBe(true); + } + }); + + it('forwards configured catalog chords and removes old/null bindings', () => { + const config = { keyboardShortcutOverrides: { 'add-tool-terminal-claude': 'mod+alt+7' } }; + const set = buildWebviewForwardSet(config); + expect(shouldForwardWebviewInput(input({ key: '7', code: 'Digit7', alt: true }), set, config)).toBe(true); + expect(shouldForwardWebviewInput(input({ key: '3', code: 'Digit3', alt: true }), set, config)).toBe(false); + const unassigned = { keyboardShortcutOverrides: { 'add-tool-terminal-claude': null } }; + expect(shouldForwardWebviewInput( + input({ key: '3', code: 'Digit3', alt: true }), buildWebviewForwardSet(unassigned), unassigned, )).toBe(false); }); + + it.each([ + ['split right', input({ key: '\\', code: 'Backslash' })], + ['split down', input({ key: '|', code: 'Backslash', shift: true })], + ['usage share', input({ key: 'S', code: 'KeyS', shift: true })], + ['browser find', input({ key: 'f', code: 'KeyF' })], + ['browser reload', input({ key: 'r', code: 'KeyR' })], + ['browser location', input({ key: 'l', code: 'KeyL' })], + ['unowned alt', input({ key: 'w', code: 'KeyW', alt: true })], + ])('does not forward %s', (_label, event) => { + const config = {}; + expect(shouldForwardWebviewInput(event, buildWebviewForwardSet(config), config)).toBe(false); + }); + + it('forwards custom slots and enabled snippets mount-independently', () => { + const config = { terminalShortcuts: [{ id: 'q', key: 'q', enabled: true }] }; + const set = buildWebviewForwardSet(config); + expect(shouldForwardWebviewInput(input({ key: '6', code: 'Digit6', alt: true }), set, config)).toBe(true); + expect(shouldForwardWebviewInput(input({ key: 'q', code: 'KeyQ', alt: true }), set, config)).toBe(true); + }); + + it('preserves the AltGr heuristic and disabled-shortcuts gate', () => { + const config = { keyboardShortcutsEnabled: false }; + const set = buildWebviewForwardSet(config); + expect(shouldForwardWebviewInput(input({ key: '@', code: 'BracketLeft', alt: true }), set, config)).toBe(false); + expect(shouldForwardWebviewInput(input({ key: 'P', code: 'KeyP', shift: true }), set, config)).toBe(true); + expect(shouldForwardWebviewInput(input({ key: 'b', code: 'KeyB' }), set, config)).toBe(false); + }); }); diff --git a/main/src/utils/keyboardShortcuts.ts b/main/src/utils/keyboardShortcuts.ts index 8eeb8b27c..efd58dc92 100644 --- a/main/src/utils/keyboardShortcuts.ts +++ b/main/src/utils/keyboardShortcuts.ts @@ -1,4 +1,7 @@ import type { AppConfig } from '../types/config'; +import { buildInterceptionSets, normalizeKeyboardShortcutOverrides, resolveEffectiveChord } from '../../../shared/utils/keyboardBindings'; +import { getCatalogEntry } from '../../../shared/constants/keyboardShortcuts'; +import { chordFromElectronInput, type ElectronKeyboardInputLike } from '../../../shared/utils/keyboardChords'; export function areKeyboardShortcutsEnabled( config: Pick, @@ -13,11 +16,38 @@ export function isCommandPaletteShortcutEnabled( } export function shouldForwardCommandPaletteShortcut( - config: Pick, - input: { shift: boolean; alt: boolean; code: string }, + config: Pick, + input: ElectronKeyboardInputLike, ): boolean { return isCommandPaletteShortcutEnabled(config) - && input.shift - && !input.alt - && input.code === 'KeyP'; + && chordFromElectronInput(input) === effectiveChordFor(config, 'open-command-palette'); +} + +function effectiveChordFor(config: Pick, id: string): string | null { + const { overrides } = normalizeKeyboardShortcutOverrides(config.keyboardShortcutOverrides); + return resolveEffectiveChord(id, overrides, getCatalogEntry(id)?.defaultChord ?? null); +} + +export function buildWebviewForwardSet( + config: Pick, +): Set { + return buildInterceptionSets({ + overrides: config.keyboardShortcutOverrides, + terminalShortcuts: config.terminalShortcuts, + customCommands: config.customCommands, + }).webviewForward; +} + +export function shouldForwardWebviewInput( + input: ElectronKeyboardInputLike & { type: string }, + forwardSet: ReadonlySet, + config: Pick, +): boolean { + if (input.type !== 'keyDown' || (!input.control && !input.meta)) return false; + if (!areKeyboardShortcutsEnabled(config)) { + return shouldForwardCommandPaletteShortcut(config, input); + } + const isAltGr = input.control && input.alt && !input.meta + && !/^(Key[A-Z]|Digit[0-9]|Slash)$/.test(input.code); + return !isAltGr && forwardSet.has(chordFromElectronInput(input)); } diff --git a/shared/constants/agentLaunchPresets.ts b/shared/constants/agentLaunchPresets.ts index 97822638a..22006ab8a 100644 --- a/shared/constants/agentLaunchPresets.ts +++ b/shared/constants/agentLaunchPresets.ts @@ -1,3 +1,5 @@ +import type { KeyboardShortcutId } from './keyboardShortcuts'; + export type AgentLaunchPresetId = 'claude' | 'codex' | 'cursor'; export interface AgentLaunchPreset { @@ -5,8 +7,7 @@ export interface AgentLaunchPreset { title: string; command: string; iconKey: string; - hotkeyId: string; - hotkey: string; + hotkeyId: KeyboardShortcutId; platforms?: readonly string[]; } @@ -22,7 +23,6 @@ export const AGENT_LAUNCH_PRESETS: readonly AgentLaunchPreset[] = [ command: 'claude --dangerously-skip-permissions', iconKey: 'claude', hotkeyId: 'add-tool-terminal-claude', - hotkey: 'mod+alt+3', }, { id: 'codex', @@ -30,7 +30,6 @@ export const AGENT_LAUNCH_PRESETS: readonly AgentLaunchPreset[] = [ command: 'codex --yolo', iconKey: 'codex', hotkeyId: 'add-tool-terminal-codex', - hotkey: 'mod+alt+4', }, { id: 'cursor', @@ -38,7 +37,6 @@ export const AGENT_LAUNCH_PRESETS: readonly AgentLaunchPreset[] = [ command: 'cursor-agent --force --trust', iconKey: 'cursor', hotkeyId: 'add-tool-terminal-cursor', - hotkey: 'mod+alt+5', platforms: ['darwin', 'linux', 'wsl'], }, ]; diff --git a/shared/constants/keyboardShortcuts.ts b/shared/constants/keyboardShortcuts.ts new file mode 100644 index 000000000..58d52f981 --- /dev/null +++ b/shared/constants/keyboardShortcuts.ts @@ -0,0 +1,174 @@ +export type ShortcutScope = 'app' | 'session' | 'session-panels' | 'usage'; +export type ShortcutCategory = + | 'navigation' + | 'session' + | 'tabs' + | 'view' + | 'tools' + | 'debug' + | 'shortcuts'; + +type Digit1to9 = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; +type CustomSlot = '0' | '1' | '2' | '3'; + +export type PanelTabId = `panel-tab-${Digit1to9}`; +export type SwitchSessionId = `switch-session-${Digit1to9}`; +export type CustomCommandId = `add-tool-custom-${CustomSlot}`; +export type ScrollSurfaceId = + | 'scroll-focused-surface-up' + | 'scroll-focused-surface-down' + | 'page-focused-surface-up' + | 'page-focused-surface-down'; + +const STATIC_SHORTCUT_IDS = [ + 'open-command-palette', 'toggle-sidebar', 'open-settings', 'focus-sidebar', + 'open-shortcut-settings', 'new-session', 'new-project', 'cycle-tab-prev-a', + 'cycle-tab-next-d', 'add-tool-terminal', 'add-tool-explorer', + 'add-tool-terminal-claude', 'add-tool-terminal-codex', 'add-tool-terminal-cursor', + 'close-active-tab', 'archive-active-session', 'split-right', 'split-down', + 'focus-group-left', 'focus-group-right', 'focus-group-up', 'focus-group-down', + 'zoom-toggle', 'git-commit', 'git-push', 'git-soft-reset', 'git-pull', + 'git-rebase-from-main', 'git-merge-to-main', 'cycle-session-next-0', + 'cycle-session-prev-0', 'cycle-sidebar-session-next', 'cycle-sidebar-session-prev', + 'toggle-terminal', 'toggle-detail-panel', 'open-add-tool', 'run-dev-server', + 'usage-download', 'usage-share', 'scroll-focused-surface-up', + 'scroll-focused-surface-down', 'page-focused-surface-up', + 'page-focused-surface-down', +] as const; + +export type StaticKeyboardShortcutId = typeof STATIC_SHORTCUT_IDS[number]; +export type KeyboardShortcutId = StaticKeyboardShortcutId | PanelTabId | SwitchSessionId | CustomCommandId; +export type HotkeyId = KeyboardShortcutId | `terminal-shortcut-${string}`; + +export interface ShortcutCatalogEntry { + id: KeyboardShortcutId; + label: string; + category: ShortcutCategory; + scope: ShortcutScope; + defaultChord: string | null; + platforms?: readonly string[]; + dynamicSlot?: 'custom-command'; + releaseFromTerminal: boolean; + releaseInTui: boolean; + forwardFromWebview: boolean; +} + +type InterceptionFlag = 'releaseFromTerminal' | 'releaseInTui' | 'forwardFromWebview'; +type EntryInput = Omit + & Partial>; + +function entry(input: EntryInput): ShortcutCatalogEntry { + return { + ...input, + releaseFromTerminal: input.releaseFromTerminal ?? true, + releaseInTui: input.releaseInTui ?? false, + forwardFromWebview: input.forwardFromWebview ?? true, + }; +} + +const APP_ENTRIES: readonly ShortcutCatalogEntry[] = [ + entry({ id: 'open-command-palette', label: 'Open Command Palette', category: 'navigation', scope: 'app', defaultChord: 'mod+shift+p' }), + entry({ id: 'toggle-sidebar', label: 'Toggle Sidebar', category: 'view', scope: 'app', defaultChord: 'mod+b' }), + entry({ id: 'open-settings', label: 'Open Settings', category: 'navigation', scope: 'app', defaultChord: 'mod+,' }), + entry({ id: 'focus-sidebar', label: 'Focus Sidebar', category: 'navigation', scope: 'app', defaultChord: 'mod+shift+e' }), + entry({ id: 'open-shortcut-settings', label: 'Open Shortcut Settings', category: 'shortcuts', scope: 'app', defaultChord: 'mod+alt+/' }), + entry({ id: 'new-session', label: 'New Pane', category: 'session', scope: 'app', defaultChord: 'mod+n' }), + entry({ id: 'new-project', label: 'New Project', category: 'navigation', scope: 'app', defaultChord: 'mod+shift+n' }), +]; + +const SESSION_ENTRIES: readonly ShortcutCatalogEntry[] = [ + entry({ id: 'cycle-tab-prev-a', label: 'Previous Tab', category: 'tabs', scope: 'session', defaultChord: 'mod+a' }), + entry({ id: 'cycle-tab-next-d', label: 'Next Tab', category: 'tabs', scope: 'session', defaultChord: 'mod+d' }), + entry({ id: 'add-tool-terminal', label: 'Add Terminal', category: 'tools', scope: 'session', defaultChord: 'mod+alt+1', releaseInTui: true }), + entry({ id: 'add-tool-explorer', label: 'Show Files', category: 'tools', scope: 'session', defaultChord: 'mod+alt+2', releaseInTui: true }), + entry({ id: 'add-tool-terminal-claude', label: 'Add Claude Code', category: 'tools', scope: 'session', defaultChord: 'mod+alt+3', releaseInTui: true }), + entry({ id: 'add-tool-terminal-codex', label: 'Add Codex', category: 'tools', scope: 'session', defaultChord: 'mod+alt+4', releaseInTui: true }), + entry({ id: 'add-tool-terminal-cursor', label: 'Add Cursor', category: 'tools', scope: 'session', defaultChord: 'mod+alt+5', platforms: ['darwin', 'linux', 'wsl'], releaseInTui: true }), + entry({ id: 'close-active-tab', label: 'Close active tab', category: 'tabs', scope: 'session', defaultChord: 'mod+w' }), + entry({ id: 'archive-active-session', label: 'Archive Pane', category: 'session', scope: 'session', defaultChord: 'mod+shift+w' }), + entry({ id: 'split-right', label: 'Split Right', category: 'tabs', scope: 'session', defaultChord: 'mod+\\', releaseInTui: true, forwardFromWebview: false }), + entry({ id: 'split-down', label: 'Split Down', category: 'tabs', scope: 'session', defaultChord: 'mod+shift+\\', releaseInTui: true, forwardFromWebview: false }), + entry({ id: 'focus-group-left', label: 'Focus Group Left', category: 'tabs', scope: 'session', defaultChord: 'mod+alt+ArrowLeft', releaseInTui: true }), + entry({ id: 'focus-group-right', label: 'Focus Group Right', category: 'tabs', scope: 'session', defaultChord: 'mod+alt+ArrowRight', releaseInTui: true }), + entry({ id: 'focus-group-up', label: 'Focus Group Up', category: 'tabs', scope: 'session', defaultChord: 'mod+alt+ArrowUp', releaseInTui: true }), + entry({ id: 'focus-group-down', label: 'Focus Group Down', category: 'tabs', scope: 'session', defaultChord: 'mod+alt+ArrowDown', releaseInTui: true }), + entry({ id: 'zoom-toggle', label: 'Toggle Zoom', category: 'tabs', scope: 'session', defaultChord: 'mod+shift+z', releaseInTui: true }), + entry({ id: 'git-commit', label: 'Git: Commit', category: 'session', scope: 'session', defaultChord: 'mod+shift+k' }), + entry({ id: 'git-push', label: 'Git: Push', category: 'session', scope: 'session', defaultChord: 'mod+shift+u' }), + entry({ id: 'git-soft-reset', label: 'Git: Undo Last Commit', category: 'session', scope: 'session', defaultChord: 'mod+alt+z' }), + entry({ id: 'git-pull', label: 'Git: Pull', category: 'session', scope: 'session', defaultChord: 'mod+shift+l' }), + entry({ id: 'git-rebase-from-main', label: 'Git: Rebase from Main', category: 'session', scope: 'session', defaultChord: 'mod+shift+r' }), + entry({ id: 'git-merge-to-main', label: 'Git: Merge to Main', category: 'session', scope: 'session', defaultChord: 'mod+shift+m' }), + entry({ id: 'cycle-session-next-0', label: 'Next Pane', category: 'session', scope: 'app', defaultChord: 'mod+Tab', releaseInTui: true }), + entry({ id: 'cycle-session-prev-0', label: 'Previous Pane', category: 'session', scope: 'app', defaultChord: 'mod+shift+Tab', releaseInTui: true }), + entry({ id: 'cycle-sidebar-session-next', label: 'Next Pane in Sidebar', category: 'session', scope: 'app', defaultChord: 'mod+ArrowDown' }), + entry({ id: 'cycle-sidebar-session-prev', label: 'Previous Pane in Sidebar', category: 'session', scope: 'app', defaultChord: 'mod+ArrowUp' }), + entry({ id: 'toggle-terminal', label: 'Toggle Terminal', category: 'view', scope: 'session', defaultChord: 'mod+`' }), + entry({ id: 'toggle-detail-panel', label: 'Toggle Detail Panel', category: 'view', scope: 'session', defaultChord: 'mod+shift+b' }), +]; + +const SCROLL_ENTRIES: readonly ShortcutCatalogEntry[] = [ + entry({ id: 'scroll-focused-surface-up', label: 'Scroll focused surface up', category: 'view', scope: 'app', defaultChord: 'shift+ArrowUp' }), + entry({ id: 'scroll-focused-surface-down', label: 'Scroll focused surface down', category: 'view', scope: 'app', defaultChord: 'shift+ArrowDown' }), + entry({ id: 'page-focused-surface-up', label: 'Page focused surface up', category: 'view', scope: 'app', defaultChord: 'shift+PageUp' }), + entry({ id: 'page-focused-surface-down', label: 'Page focused surface down', category: 'view', scope: 'app', defaultChord: 'shift+PageDown' }), +]; + +const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as const; +const panelEntries = digits.map(digit => entry({ + id: `panel-tab-${digit}`, + label: `Switch to tab ${digit}`, + category: 'tabs', + scope: 'session', + defaultChord: `mod+shift+${digit}`, + releaseInTui: true, +})); +const switchEntries = digits.map(digit => entry({ + id: `switch-session-${digit}`, + label: `Switch to pane ${digit}`, + category: 'session', + scope: 'app', + defaultChord: `mod+${digit}`, +})); +const customSlots: readonly CustomSlot[] = ['0', '1', '2', '3']; +const customEntries = customSlots.map((slot, index) => entry({ + id: `add-tool-custom-${slot}`, + label: `Add custom tool ${index + 1}`, + category: 'tools', + scope: 'session', + defaultChord: `mod+alt+${index + 6}`, + dynamicSlot: 'custom-command', + releaseInTui: true, +})); + +export const KEYBOARD_SHORTCUT_CATALOG = [ + ...APP_ENTRIES, + ...SESSION_ENTRIES, + ...SCROLL_ENTRIES, + ...panelEntries, + ...switchEntries, + ...customEntries, + entry({ id: 'open-add-tool', label: 'Open Add Tool menu', category: 'tabs', scope: 'session-panels', defaultChord: 'mod+t' }), + entry({ id: 'run-dev-server', label: 'Run Dev Server', category: 'tools', scope: 'session-panels', defaultChord: 'mod+shift+d' }), + entry({ id: 'usage-download', label: 'Download usage image', category: 'tools', scope: 'usage', defaultChord: 'mod+shift+d', forwardFromWebview: false }), + entry({ id: 'usage-share', label: 'Share usage image', category: 'tools', scope: 'usage', defaultChord: 'mod+shift+s', releaseFromTerminal: false, forwardFromWebview: false }), +] satisfies readonly ShortcutCatalogEntry[]; + +const catalogById = new Map( + KEYBOARD_SHORTCUT_CATALOG.map(catalogEntry => [catalogEntry.id, catalogEntry]), +); + +export function getCatalogEntry(id: string): ShortcutCatalogEntry | undefined { + return catalogById.get(id); +} + +export function isDynamicShortcutId(id: string): id is `terminal-shortcut-${string}` { + return id.startsWith('terminal-shortcut-'); +} + +export function scopesOverlap(left: ShortcutScope, right: ShortcutScope): boolean { + return !( + (left === 'session-panels' && right === 'usage') + || (left === 'usage' && right === 'session-panels') + ); +} diff --git a/shared/utils/keyboardBindings.ts b/shared/utils/keyboardBindings.ts new file mode 100644 index 000000000..e98aa9b90 --- /dev/null +++ b/shared/utils/keyboardBindings.ts @@ -0,0 +1,220 @@ +import { + getCatalogEntry, + KEYBOARD_SHORTCUT_CATALOG, + scopesOverlap, + type ShortcutScope, +} from '../constants/keyboardShortcuts'; +import { parseChord } from './keyboardChords'; +import { + BoundaryDecodeError, + boundary, + decodeBoundary, + type JsonValue, +} from '../validation/boundaryDecoder'; + +export type KeyboardShortcutOverrides = Record; + +interface TerminalShortcutBindingInput { + id: string; + key: string; + enabled: boolean; +} + +export interface NormalizedKeyboardShortcutOverrides { + overrides: KeyboardShortcutOverrides; + diagnostics: string[]; +} + +export interface InterceptionSets { + bound: Set; + tuiReleasable: Set; + webviewForward: Set; +} + +export interface BindingInput { + overrides?: KeyboardShortcutOverrides | JsonValue; + terminalShortcuts?: readonly TerminalShortcutBindingInput[] | JsonValue; + customCommands?: readonly object[] | JsonValue; + platform?: string; +} + +export interface ActiveBinding { + id: string; + chord: string; + scope: ShortcutScope; +} + +export interface InterceptionBinding { + id: string; + chord: string; + releaseFromTerminal: boolean; + releaseInTui: boolean; + forwardFromWebview: boolean; +} + +export function normalizeKeyboardShortcutOverrides( + raw: KeyboardShortcutOverrides | JsonValue | undefined, +): NormalizedKeyboardShortcutOverrides { + const overrides: KeyboardShortcutOverrides = {}; + const diagnostics: string[] = []; + if (raw === undefined) return { overrides, diagnostics }; + let source; + try { + source = decodeBoundary(raw, boundary.jsonObject); + } catch (error) { + if (!(error instanceof BoundaryDecodeError)) throw error; + return { overrides, diagnostics: ['keyboardShortcutOverrides must be an object'] }; + } + + for (const [id, value] of Object.entries(source)) { + if (!getCatalogEntry(id)) { + diagnostics.push(`unknown keyboard shortcut id: ${id}`); + continue; + } + if (value === null) { + overrides[id] = null; + continue; + } + let chordValue: string; + try { + chordValue = decodeBoundary(value, boundary.string); + } catch (error) { + if (!(error instanceof BoundaryDecodeError)) throw error; + diagnostics.push(`keyboard shortcut ${id} must be a string or null`); + continue; + } + const parsed = parseChord(chordValue); + if (!parsed.ok) { + diagnostics.push(`invalid keyboard shortcut ${id}: ${parsed.reason}`); + continue; + } + overrides[id] = parsed.chord; + } + return { overrides, diagnostics }; +} + +export function resolveEffectiveChord( + id: string, + overrides: KeyboardShortcutOverrides, + defaultChord: string | null, +): string | null { + if (Object.prototype.hasOwnProperty.call(overrides, id)) return overrides[id]; + if (defaultChord === null) return null; + const parsed = parseChord(defaultChord); + return parsed.ok ? parsed.chord : null; +} + +function enabledTerminalShortcuts( + raw: BindingInput['terminalShortcuts'], +): Array<{ id: string; chord: string }> { + let values: JsonValue[]; + try { + values = decodeBoundary(raw, boundary.array(boundary.json)); + } catch (error) { + if (!(error instanceof BoundaryDecodeError)) throw error; + return []; + } + const result: Array<{ id: string; chord: string }> = []; + for (const item of values) { + try { + const shortcut = decodeBoundary(item, boundary.object({ + enabled: boundary.boolean, + id: boundary.string, + key: boundary.string, + })); + if (!shortcut.enabled) continue; + const parsed = parseChord(`mod+alt+${shortcut.key}`); + if (parsed.ok) result.push({ id: `terminal-shortcut-${shortcut.id}`, chord: parsed.chord }); + } catch (error) { + if (!(error instanceof BoundaryDecodeError)) throw error; + } + } + return result; +} + +function customCommandCount(raw: BindingInput['customCommands']): number { + try { + return Math.min(decodeBoundary(raw, boundary.array(boundary.json)).length, 4); + } catch (error) { + if (!(error instanceof BoundaryDecodeError)) throw error; + return 0; + } +} + +export function collectActiveBindings(input: BindingInput): ActiveBinding[] { + const { overrides } = normalizeKeyboardShortcutOverrides(input.overrides); + const customCount = customCommandCount(input.customCommands); + const bindings: ActiveBinding[] = []; + + for (const catalogEntry of KEYBOARD_SHORTCUT_CATALOG) { + if (catalogEntry.dynamicSlot && Number(catalogEntry.id.slice(-1)) >= customCount) continue; + if (catalogEntry.platforms && input.platform && !catalogEntry.platforms.includes(input.platform)) continue; + const chord = resolveEffectiveChord(catalogEntry.id, overrides, catalogEntry.defaultChord); + if (chord) bindings.push({ id: catalogEntry.id, chord, scope: catalogEntry.scope }); + } + for (const shortcut of enabledTerminalShortcuts(input.terminalShortcuts)) { + bindings.push({ ...shortcut, scope: 'app' }); + } + return bindings; +} + +export function collectInterceptionBindings(input: BindingInput): InterceptionBinding[] { + const { overrides } = normalizeKeyboardShortcutOverrides(input.overrides); + const bindings: InterceptionBinding[] = []; + for (const catalogEntry of KEYBOARD_SHORTCUT_CATALOG) { + const chord = resolveEffectiveChord(catalogEntry.id, overrides, catalogEntry.defaultChord); + if (!chord) continue; + bindings.push({ + id: catalogEntry.id, + chord, + releaseFromTerminal: catalogEntry.releaseFromTerminal, + releaseInTui: catalogEntry.releaseInTui, + forwardFromWebview: catalogEntry.forwardFromWebview, + }); + } + for (const shortcut of enabledTerminalShortcuts(input.terminalShortcuts)) { + bindings.push({ + ...shortcut, + releaseFromTerminal: true, + releaseInTui: false, + forwardFromWebview: true, + }); + } + return bindings; +} + +export function findChordConflicts(bindings: readonly ActiveBinding[]): Array<{ chord: string; ids: string[] }> { + const byChord = new Map(); + for (const binding of bindings) { + const group = byChord.get(binding.chord) ?? []; + group.push(binding); + byChord.set(binding.chord, group); + } + + const conflicts: Array<{ chord: string; ids: string[] }> = []; + for (const [chord, group] of byChord) { + const ids = new Set(); + for (let left = 0; left < group.length; left += 1) { + for (let right = left + 1; right < group.length; right += 1) { + if (scopesOverlap(group[left].scope, group[right].scope)) { + ids.add(group[left].id); + ids.add(group[right].id); + } + } + } + if (ids.size > 1) conflicts.push({ chord, ids: [...ids].sort() }); + } + return conflicts; +} + +export function buildInterceptionSets(input: BindingInput): InterceptionSets { + const bound = new Set(); + const tuiReleasable = new Set(); + const webviewForward = new Set(); + for (const binding of collectInterceptionBindings(input)) { + if (binding.releaseFromTerminal) bound.add(binding.chord); + if (binding.releaseInTui) tuiReleasable.add(binding.chord); + if (binding.forwardFromWebview) webviewForward.add(binding.chord); + } + return { bound, tuiReleasable, webviewForward }; +} diff --git a/shared/utils/keyboardChords.ts b/shared/utils/keyboardChords.ts new file mode 100644 index 000000000..aba4144ae --- /dev/null +++ b/shared/utils/keyboardChords.ts @@ -0,0 +1,158 @@ +export type ChordParseFailureReason = + | 'empty' + | 'unknown-modifier' + | 'modifier-only' + | 'bare-printable' + | 'unsupported-key' + | 'malformed'; + +export type ChordParseResult = + | { ok: true; chord: string } + | { ok: false; reason: ChordParseFailureReason }; + +export interface KeyboardEventLike { + key: string; + code: string; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; + getModifierState: (key: string) => boolean; +} + +export interface ElectronKeyboardInputLike { + key: string; + code: string; + control: boolean; + meta: boolean; + alt: boolean; + shift: boolean; +} + +const MODIFIER_ORDER = ['mod', 'alt', 'shift'] as const; +const MODIFIERS = new Set(MODIFIER_ORDER); +const NAMED_KEYS = new Map([ + ['arrowleft', 'ArrowLeft'], + ['arrowright', 'ArrowRight'], + ['arrowup', 'ArrowUp'], + ['arrowdown', 'ArrowDown'], + ['tab', 'Tab'], + ['enter', 'Enter'], + ['escape', 'Escape'], + ['backspace', 'Backspace'], + ['delete', 'Delete'], + ['home', 'Home'], + ['end', 'End'], + ['pageup', 'PageUp'], + ['pagedown', 'PageDown'], + ['space', 'Space'], + ...Array.from({ length: 12 }, (_, index) => [`f${index + 1}`, `F${index + 1}`] as const), +]); + +const PUNCTUATION_BY_CODE = new Map([ + ['Slash', '/'], + ['Comma', ','], + ['Period', '.'], + ['Semicolon', ';'], + ['Quote', "'"], + ['BracketLeft', '['], + ['BracketRight', ']'], + ['Backquote', '`'], + ['Minus', '-'], + ['Equal', '='], + ['Backslash', '\\'], + ['IntlBackslash', '\\'], +]); + +function canonicalKey(key: string): string | null { + if (key === ' ') return 'Space'; + if (key.length === 1) return key.toLowerCase(); + return NAMED_KEYS.get(key.toLowerCase()) ?? null; +} + +export function canonicalChord(parts: readonly string[]): string { + const modifiers = MODIFIER_ORDER.filter(modifier => + parts.some(part => part.toLowerCase() === modifier) + ); + const key = parts.find(part => !MODIFIERS.has(part.toLowerCase())); + return key ? [...modifiers, canonicalKey(key) ?? key].join('+') : modifiers.join('+'); +} + +export function parseChord(input: string): ChordParseResult { + const trimmed = input.trim(); + if (!trimmed) return { ok: false, reason: 'empty' }; + + const parts = trimmed.split('+').map(part => part.trim()); + if (parts.some(part => !part)) return { ok: false, reason: 'malformed' }; + + const modifiers: string[] = []; + const keys: string[] = []; + for (const part of parts) { + const lower = part.toLowerCase(); + if (MODIFIERS.has(lower)) { + if (modifiers.includes(lower)) return { ok: false, reason: 'malformed' }; + modifiers.push(lower); + } else { + keys.push(part); + } + } + + if (keys.length === 0) return { ok: false, reason: 'modifier-only' }; + if (keys.length > 1) { + const modifierLike = keys.some(key => /^[a-z]+$/i.test(key) && key.length > 1); + return { ok: false, reason: modifierLike ? 'unknown-modifier' : 'malformed' }; + } + + const key = canonicalKey(keys[0]); + if (!key) return { ok: false, reason: 'unsupported-key' }; + if (key.length === 1 && !modifiers.includes('mod')) { + return { ok: false, reason: 'bare-printable' }; + } + + return { ok: true, chord: canonicalChord([...modifiers, key]) }; +} + +export function keyFromCode(code: string): string | null { + const letter = /^Key([A-Z])$/.exec(code); + if (letter) return letter[1].toLowerCase(); + const digit = /^Digit([0-9])$/.exec(code); + if (digit) return digit[1]; + return PUNCTUATION_BY_CODE.get(code) ?? null; +} + +function chordFromParts( + key: string, + code: string, + modifiers: { ctrlOrMeta: boolean; alt: boolean; shift: boolean }, +): string { + const parts: string[] = []; + if (modifiers.ctrlOrMeta) parts.push('mod'); + if (modifiers.alt) parts.push('alt'); + if (modifiers.shift) parts.push('shift'); + + const codeKey = modifiers.alt ? keyFromCode(code) : null; + let normalizedKey = codeKey ?? (key.length === 1 ? key.toLowerCase() : key); + const shiftedDigit = modifiers.shift ? /^Digit([0-9])$/.exec(code) : null; + if (shiftedDigit) normalizedKey = shiftedDigit[1]; + if (code === 'Backslash' || code === 'IntlBackslash') normalizedKey = '\\'; + const namedKey = canonicalKey(normalizedKey); + parts.push(namedKey ?? normalizedKey); + return parts.join('+'); +} + +export function chordFromKeyboardEvent(event: KeyboardEventLike): string { + if (event.getModifierState('AltGraph')) return ''; + return chordFromParts(event.key, event.code, { + ctrlOrMeta: event.ctrlKey || event.metaKey, + alt: event.altKey, + shift: event.shiftKey, + }); +} + +export function chordFromElectronInput(input: ElectronKeyboardInputLike): string { + return chordFromParts(input.key, input.code, { + ctrlOrMeta: input.control || input.meta, + alt: input.alt, + shift: input.shift, + }); +} diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts index bdaed992b..1a8a1c4cc 100644 --- a/tests/electronApiMock.ts +++ b/tests/electronApiMock.ts @@ -303,6 +303,9 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc if (prop === 'onSessionUpdated') { return (callback: MockEventCallback) => subscribe('session:updated', callback); } + if (prop === 'onConfigUpdated') { + return (callback: MockEventCallback) => subscribe('config:updated', callback); + } return () => unsubscribe; }, }); From 92865cf6bba5cda33e1e7a83cf1f770c64e08247 Mon Sep 17 00:00:00 2001 From: Tyler Brown Date: Sat, 29 Aug 2026 15:39:47 -0700 Subject: [PATCH 2/7] feat: complete Shortcuts settings map with recorder, conflicts, and reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of configurable keybindings. Settings → Shortcuts gains a searchable, category-grouped key-binding table sourced from the shared catalog plus the current snippet and custom-command bindings: effective and default chords, activation scope, customized/unassigned/invalid state, and per-environment availability. An accessible recorder (window-capture while armed, Escape cancels without closing Settings, Backspace unassigns, terminal-reserved chords refused, recording a row's default removes the override) writes a sparse draft; conflicts are validated globally (no platform gate) and block Apply naming both owners; per-row Reset deletes the override and Reset all sends {}. Help renders the same map. The raw override map round-trips untouched (unknown ids and malformed values are preserved on disk and shown as "invalid — using default"). ConfigManager validates conflicts globally. Adds Playwright coverage for the settings map, agent-launch remaps in worktree and main-repo views (mock now records panel creates/updates/activations and broadcasts panel:created), plus unit coverage for the map builder and glyphs. Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9 --- ...keybindings-and-external-link-modifiers.md | 179 +++++++++++++ docs/ADDING_NEW_CLI_TOOLS.md | 4 +- frontend/src/components/Help.tsx | 90 ++++--- frontend/src/components/Settings.tsx | 2 +- .../src/components/settings/KeyRecorder.tsx | 142 ++++++++++ .../settings/KeyboardShortcutMap.tsx | 214 +++++++++++++++ frontend/src/components/settings/catalog.tsx | 4 +- .../settings/categories/ShortcutsSettings.tsx | 63 ++++- .../src/hooks/useActiveProjectEnvironment.ts | 39 +++ frontend/src/stores/hotkeyStore.ts | 21 +- frontend/src/types/config.ts | 2 +- frontend/src/types/settings.ts | 1 + frontend/src/utils/agentPresets.ts | 4 +- frontend/src/utils/hotkeyUtils.test.ts | 31 +++ frontend/src/utils/hotkeyUtils.ts | 9 + frontend/src/utils/platformUtils.ts | 8 + frontend/src/utils/shortcutMap.test.ts | 120 +++++++++ frontend/src/utils/shortcutMap.ts | 231 ++++++++++++++++ frontend/src/utils/terminalKeyHandling.ts | 15 +- main/src/services/configManager.test.ts | 14 + main/src/services/configManager.ts | 44 ++- shared/constants/agentLaunchPresets.ts | 6 +- shared/constants/keyboardShortcuts.ts | 10 + tests/electronApiMock.ts | 48 +++- tests/launch-shortcuts.spec.ts | 95 +++++++ tests/settings.spec.ts | 52 ++++ tests/shortcuts-settings.spec.ts | 252 ++++++++++++++++++ 27 files changed, 1598 insertions(+), 102 deletions(-) create mode 100644 briefs/configurable-keybindings-and-external-link-modifiers.md create mode 100644 frontend/src/components/settings/KeyRecorder.tsx create mode 100644 frontend/src/components/settings/KeyboardShortcutMap.tsx create mode 100644 frontend/src/hooks/useActiveProjectEnvironment.ts create mode 100644 frontend/src/utils/hotkeyUtils.test.ts create mode 100644 frontend/src/utils/shortcutMap.test.ts create mode 100644 frontend/src/utils/shortcutMap.ts create mode 100644 tests/launch-shortcuts.spec.ts create mode 100644 tests/shortcuts-settings.spec.ts diff --git a/briefs/configurable-keybindings-and-external-link-modifiers.md b/briefs/configurable-keybindings-and-external-link-modifiers.md new file mode 100644 index 000000000..eb8022cfa --- /dev/null +++ b/briefs/configurable-keybindings-and-external-link-modifiers.md @@ -0,0 +1,179 @@ +# Implementation brief: one configurable shortcut model and consistent link modifiers + +**Baseline:** `origin/main` at `169f8aa3` (`release: v2.4.87`, fetched 2026-08-29)
+**Workstream:** global Pane shortcut mapping, configurable agent-launch bindings, and consistent modified-click link opening + +## Outcome at a glance + +Implement one global, persistent Pane keybinding model that: + +- Defines application-command defaults in a shared catalog and stores only explicit overrides or unassignments. +- Lets users remap every cataloged application command, including supported agent-launch commands, while including dynamic snippet and custom-command bindings in conflict detection. +- Uses the same effective binding for Settings and Help displays, renderer dispatch, terminal release, and embedded-browser forwarding. +- Rejects ambiguous conflicts between commands that can be active together instead of resolving them by registration order. +- Centralizes terminal-link routing so the platform primary modifier opens HTTP(S) externally and primary-modifier+Shift opens it in Pane's Browser when that surface is available. + +### Terms used throughout + +- **Cataloged command:** a registry-backed Pane action whose identity, metadata, and default chord live in the shared catalog. +- **Effective binding:** a command's explicit override, explicit unassignment, or catalog default, in that order. +- **Activation scope:** the contexts in which a command can run. A duplicate chord is a conflict only when the commands' scopes overlap. + +## Approved product decisions + +- “Global” means one setting per Pane data directory, shared by all saved repositories and worktrees, not a system-global accelerator. +- All registry-backed Pane commands are editable. Terminal-, native-, and context-only shortcuts appear as a complete read-only reference unless separately moved into the registry. +- A command may be explicitly unassigned; reset is distinct from unassign. Existing chord reuse is valid only for provably non-overlapping activation scopes. +- Remote PWA keyboard remapping is outside this desktop workstream. +- Command is the primary modifier on macOS; Control is primary on Windows/Linux. Primary-click opens a terminal HTTP(S) link externally, while primary+Shift opens it in Pane's Browser when that surface is available. On macOS, an unconsumed Control-click may remain an external-browser alias, but must not suppress the native context-click gesture. +- Existing provider-specific plain-click behavior remains unchanged, including OSC-8's plain-click external activation. The more-specific primary+Shift route always wins over external routing. + +## Problem + +Users cannot remap the shortcuts that start supported agent terminals. Pane also has no complete settings view of its application shortcuts: bindings are declared where actions mount, Help reflects only the current runtime registry, conflicts are resolved implicitly, and there is no reset-to-default path. Terminal links also lack a single platform-aware router that can distinguish external opening from opening inside Pane, preserve provider-specific plain clicks, and avoid duplicate activation. + +This is a root-cause workstream, not three isolated UI patches. The underlying problems are: + +1. Shortcut identity, defaults, effective user bindings, active actions, conflict policy, terminal interception, webview forwarding, and presentation are separate sources of truth. +2. Modified-click policy is repeated in multiple terminal link implementations, and Pane Chat inherits those inconsistencies through its terminal panel. + +A patch that only makes `AGENT_LAUNCH_PRESETS[*].hotkey` editable or adds `event.ctrlKey` to one callback would fix symptoms but leave remapped shortcuts unreliable whenever focus is in xterm or an embedded browser. + +## Current-state evidence + +| Area | Evidence on `origin/main` | Consequence | +| --- | --- | --- | +| Supported agents/defaults | `shared/constants/agentLaunchPresets.ts:1-55` defines Claude, Codex, and Cursor plus hard-coded `mod+alt+3/4/5`; Cursor is unavailable only for native Windows. `main/src/services/agents/agentLaunchPresets.test.ts:9-33` pins the agents and commands to the RunPane contract. | Agent launch metadata and default keys are coupled. | +| Agent launch/action | `frontend/src/components/SessionView.tsx:1232-1255` registers each visible preset directly from `preset.hotkey`; `:1248-1251` creates the terminal with the preset command/title. `frontend/src/components/ProjectView.tsx:218-270` has its own option-aware panel creator but exposes only `addTerminal` through the current bridge. | No override is resolved, and agent-launch behavior in the separately rendered main-repo view needs an explicit shared action/verification path. | +| Shortcut displays | `frontend/src/components/SessionView.tsx:1156-1198` and `frontend/src/components/panels/PanelTabBar.tsx:654-676` read the mounted registry to show keys. `frontend/src/components/Help.tsx:8-64` does the same and contains one separately hard-coded contextual shortcut. | Settings/Help cannot show a stable, complete map independent of the current view. | +| Runtime collision policy | `frontend/src/stores/hotkeyStore.ts:224-236` rebuilds a single `chord -> id` map; a later registration overwrites an earlier one, with a warning only in development. | A conflict can silently run the wrong command in production. | +| Existing settings/persistence | `frontend/src/components/settings/categories/ShortcutsSettings.tsx:18-177` can toggle all Pane shortcuts and edit terminal snippet letters, detecting duplicates only among those snippets. `frontend/src/types/config.ts:54-155`, `frontend/src/stores/configStore.ts:24-67`, and `main/src/services/configManager.ts:41-118,148-185,315-356` provide global JSON config loading/merging/saving. | There is an appropriate global persistence owner, but no application-keybinding model or reset semantics. | +| Dynamic conflict participants | `frontend/src/hooks/useTerminalShortcuts.ts:18-40` registers snippets as `mod+alt+`. `frontend/src/components/SessionView.tsx:1257-1270` assigns custom commands `mod+alt+6..9`. | Agent remaps must be checked against bindings outside the static agent list. | +| Intentional scoped reuse | Run Dev Server uses `mod+shift+d` in `frontend/src/components/panels/PanelTabBar.tsx:348-401`; Usage Download uses the same chord in `frontend/src/components/usage/UsageView.tsx:260-274`. | Conflict detection needs explicit activation scopes; a global duplicate-string ban would reject an existing valid default. | +| Terminal interception | `frontend/src/components/panels/TerminalPanel.tsx:1016-1157` contains a long hard-coded release list, while `frontend/src/utils/terminalKeyHandling.ts:64-124` separately recognizes selected Pane chords in TUIs. | An arbitrary remap can be consumed by xterm/the CLI instead of reaching the hotkey registry. | +| Embedded-browser interception | `main/src/index.ts:446-510` forwards a hard-coded whitelist plus broad `mod+alt` patterns from webviews; the renderer receives them through `main/src/preload.ts:333-352`. The disabled-shortcut Command Palette exception is itself hard-coded to Shift+P in `main/src/utils/keyboardShortcuts.ts:15-22`. | A remap outside those patterns stops being global while a browser panel has focus, or forces Pane to intercept unrelated browser shortcuts. | +| Terminal and chat links | `frontend/src/components/panels/TerminalPanel.tsx:991-997,1185-1198` owns OSC-8 and auto-detected HTTP(S) activation. OSC-8 currently opens on every activation; auto-detected URLs require `metaKey` on macOS or `ctrlKey` elsewhere. Git SHA/issue providers build HTTPS URLs and repeat the modifier gate (`frontend/src/components/terminal/linkProviders/gitLinkProvider.ts:35-115`; `main/src/ipc/git.ts:2056-2084`). File links are a separate provider whose modified click opens a file-action popover rather than a URL (`frontend/src/components/terminal/linkProviders/fileLinkProvider.ts:96-137`). Pane Chat renders the same `TerminalPanel` path (`frontend/src/components/PaneChatView.tsx:164-168`; `frontend/src/components/panels/PanelContainer.tsx:48-52`). | Routing is fragmented, and URL versus file-link behavior must stay distinct. | +| Existing in-Pane browser path | Terminal text selection already reuses or creates a Browser panel, activates it, and emits `browser-panel:navigate` (`frontend/src/components/terminal/hooks/useTerminalLinks.ts:194-234`). `BrowserPanel` also observes panel state and the custom event (`frontend/src/components/panels/browser/BrowserPanel.tsx:94-116,279-301`), so the current path can update and then reload/navigate the same URL twice. | Modified-click should use one session-scoped orchestration path with one terminal activation and one Browser navigation. | +| Browser availability and schemes | Browser panels are declared worktree-only (`shared/types/panels.ts:358-366`) and filtered from Project view (`frontend/src/components/panels/PanelTabBar.tsx:403-426`); Pane Chat renders only its one terminal (`frontend/src/components/PaneChatView.tsx:164-168`). `BrowserPanel` accepts `http:`, `https:`, and trusted `file:` flows (`frontend/src/components/panels/browser/BrowserPanel.tsx:94-110`), while xterm's OSC-8 provider and the Web Links addon admit HTTP(S) by default. | The gesture can safely target HTTP(S) in worktree Browser panels; Project and Pane Chat need an explicit fallback, and terminal output must not gain access to trusted local-file preview behavior. | +| Browser security boundary | Every attached webview has preload removed, Node disabled, context isolation enabled, and sandboxing enforced in `main/src/index.ts:382-388`. Browser cookies/storage are partitioned by project in `frontend/src/components/panels/browser/BrowserPanel.tsx:421-428`; popup routing is registered by session/panel in `main/src/index.ts:390-428` and `main/src/ipc/panels.ts:890-895`. | Reuse the existing Browser panel and its isolation; do not create a new webview path or weaken its session routing. | +| External browser boundary | Renderer calls converge on `openExternal`; `main/src/ipc/app.ts:70-90` uses native `open` on macOS and `shell.openExternal` elsewhere. | External routing remains behind the existing main-process boundary; the terminal gesture router accepts only validated HTTP(S) sources before invoking it. | + +## Design decisions + +### 1. One catalog and one effective-binding resolver + +- Add a shared, typed catalog for every registry-backed Pane command: stable id, label, category, activation scope, default chord, platform/environment availability, and whether the row is user-editable. Generate numbered families (pane switching and tab switching) rather than duplicating literals. +- Keep actions in renderer components, but register them by catalog id. The registry resolves `effective chord = explicit override | unassigned | catalog default`; registrations no longer own defaults. +- Keep agent command/title/platform data in `AGENT_LAUNCH_PRESETS`, but make each preset reference its catalog shortcut id. Remove the duplicate hotkey value from the preset. +- Persist sparse global overrides in `AppConfig` as `keyboardShortcutOverrides` keyed by stable command id. Missing key means “follow the current default”; `null` means explicitly unassigned. Per-row Reset deletes one override; Reset all deletes the override map. +- Preserve Pane's portable `mod` abstraction (Command on macOS, Control on Windows/Linux). Literal Control-vs-Command bindings on macOS, multi-step chords, and OS-global accelerators are not introduced by this workstream. + +### 2. Complete settings/reference surface + +- Replace the current “View all” runtime snapshot with a searchable, category-grouped map sourced from the catalog plus current dynamic bindings. Show command, effective chord, default chord, availability/scope, and customized/conflict state. +- Make all cataloged application commands rebindable or unassignable, including Claude, Codex, and Cursor launch commands. Keep terminal snippet content/key editing in its existing section, but include snippets and generated custom-command bindings in the map and conflict engine. +- List contextual/native shortcuts that are not global-registry commands (for example terminal copy/search/clear and submit/continue) as read-only reference rows rather than pretending they are remappable. +- Use an accessible key recorder. Canonicalize modifier order/case and physical-key handling with the same pure normalizer used at runtime. Reject modifier-only input, bare printable keys that would break typing, unsupported sequences, and malformed external config values. +- Block Apply while two commands with overlapping activation scopes share a normalized chord. Identify both commands inline. Scoped duplicates that provably cannot coexist remain valid. Backend/config validation repeats the check; if hand-edited config creates an active ambiguity, execute neither command and surface/log the conflict instead of choosing by registration order. +- Help, Command Palette, tool menus, empty states, and shortcut hints consume the same effective binding. Unassigned commands remain available from click/palette surfaces without a key label. + +### 3. Make remaps work in every existing focus path + +- Replace the hotkey store's last-registration-wins index with `chord -> candidate ids`; select exactly one currently enabled candidate or no-op on ambiguity. +- Replace TerminalPanel's command-by-command release list with a query against the active effective registry after terminal-owned editing/clipboard/search behavior has had its documented precedence. Retain AltGr, international-layout, SIGQUIT, TUI, and modal protections. +- Extract webview matching/forwarding into a pure main-process helper built from the effective shared catalog and current config. Forward only exact configured Pane chords, update immediately after config changes, and resolve the Command Palette exception from its effective binding. Do not broaden interception to all modified keys. +- “Global” means one Pane configuration across repositories/worktrees and consistent handling in renderer, xterm, and embedded-browser focus. It does not mean an OS-level shortcut while Pane is unfocused. Agent creation remains available only in a view that can create a terminal, matching current behavior. + +### 4. One ordered router for terminal HTTP(S) links + +- Add one pure event classifier and one session-aware URL router used by xterm's auto-detected HTTP(S) callback, OSC-8 handler, and git SHA/issue links. Classify a single activation in this order, with no fallthrough: + 1. **Primary+Shift:** Command+Shift on macOS; Control+Shift on Windows/Linux. Open in Pane's Browser when the current view has an eligible worktree Browser surface. + 2. **Primary:** Command on macOS; Control on Windows/Linux. Open through the existing `openExternal` IPC. + 3. **macOS Control alias:** open externally only when xterm/Chromium delivers it as an unconsumed primary-button activation. Never prevent or replace a native `contextmenu`/secondary-click event. + 4. **No qualifying gesture:** preserve the provider's current behavior—OSC-8 opens externally; auto-detected and git-reference links do nothing. +- Treat a URL gesture as a single consumed routing decision. Primary+Shift must never also reach the external branch, and overlapping xterm providers must not each act on the same click. The router owns one terminal activation id/outcome and invokes exactly one destination. +- For in-Pane opening, validate an absolute URL with the platform `URL` parser and allow only `http:` or `https:` before any panel mutation. Supported inputs are auto-detected HTTP(S), HTTP(S) OSC-8, and HTTPS git commit/issue links. Reject `file:`, `javascript:`, `data:`, `blob:`, custom schemes, credentials-bearing URLs, and malformed input from this gesture path. File-path links retain their current modified-click file popover and never route to either browser. +- Reuse the current session's first Browser panel or create and activate one through `panelApi`; preserve its existing project partition and hardened webview. Replace the current state-update-plus-`browser-panel:navigate` combination with one authoritative create-or-navigate operation so an existing URL is not also reloaded by a duplicate event. +- A Browser surface is available only in an ordinary worktree Session that permits Browser panels. In Project/main-repo terminals, Pane Chat, Remote PWA, or any context that cannot visibly host and activate a Browser panel, Primary+Shift falls back to `openExternal` exactly once; it must not create a hidden/disallowed panel. If an eligible internal open has an indeterminate partial failure, report/log the failure without also opening externally; fallback is only for known-unavailable surfaces or a failure confirmed before mutation. +- Add consistent provider hover text using platform glyphs/names. When Browser is available, gated URL links show “⌘+Click: external · ⇧⌘+Click: Pane Browser” on macOS and “Ctrl+Click: external · Ctrl+Shift+Click: Pane Browser” elsewhere; OSC-8 prefixes the existing “Click: external” behavior. When unavailable, say that Pane Browser is unavailable here and advertise external opening only. Do not advertise the macOS Control-click compatibility alias because native context-click takes precedence. + +## Scope + +- Shared command catalog, binding/event normalization, effective binding resolution, activation scopes, and conflict detection. +- Global config types, normalization/validation, persistence, change propagation, and reset semantics. +- Complete Shortcuts settings/reference UI and all current effective-key display consumers. +- Configurable launch bindings for every contract-backed supported agent, with current environment availability preserved. +- Renderer hotkey dispatch, terminal/TUI release, and embedded-webview forwarding driven by effective bindings. +- Ordered modified-click routing for terminal HTTP(S)/git links, Browser-panel reuse/creation where supported, safe external fallback elsewhere, and the Pane Chat terminal path. +- Unit, component, Playwright/Electron, and manual cross-platform coverage; update `docs/ADDING_NEW_CLI_TOOLS.md` so new agents must add catalog metadata and tests. + +## Non-goals + +- System-wide/global shortcuts when Pane is not focused. +- Per-project, per-pane, per-agent-profile, or cloud-synced keybinding profiles. +- Multi-key sequences, multiple alternate bindings per command, arbitrary macros, or importing VS Code keymaps. +- Redesigning terminal-native editing keys, Monaco/browser-native shortcuts, or Remote PWA touch controls. +- Changing agent commands, agent availability, Pane Chat's default-agent selector, RunPane's public contract, or custom-command execution. +- Broadening Pane Browser's scheme support, exposing trusted `file:` preview to terminal output, or changing the main-process external-browser launcher. +- Making ordinary React `` elements require a modifier; this work targets terminal/chat link activation paths. +- Changing file-path link activation/popovers or the user-visible semantics of selection-popover buttons; they are not part of the modified-click URL gesture, though “Open in Browser” should reuse the same single-navigation helper. + +## Acceptance criteria + +1. Shortcuts Settings shows the same complete catalog regardless of the current Pane view, with effective/default bindings and clear unavailable/read-only/customized states. +2. Claude and Codex can be remapped globally on every supported desktop environment; Cursor can be remapped and launched on macOS, Linux, and WSL, and remains unavailable for native Windows projects. +3. Saving an agent remap changes dispatch and every displayed hint without restart. The previous chord stops launching it; the new chord creates exactly one terminal with the existing preset title and command in worktree and main-repo Pane views. +4. A remapped command works while focus is in ordinary renderer UI, a CLI/TUI terminal, and an embedded browser webview, subject to its declared activation scope. +5. Conflicts are detected against overlapping built-in commands, other agent commands, terminal snippets, and generated custom-command bindings. Apply is blocked with both owners named; production dispatch never silently picks the last registration. +6. Per-command Reset and Reset all restore catalog defaults immediately and remove sparse overrides rather than copying default strings into config. An unassigned command has no active chord but remains clickable/searchable. +7. The all-shortcuts enable toggle and the Command Palette exception retain their behavior with remapped bindings; disabled shortcuts are not swallowed by terminal/webview interception. +8. Existing installs with no override field retain byte-for-byte effective shortcut behavior, including scoped duplicate defaults, AltGr safeguards, international keyboard handling, and platform display glyphs. +9. For validated HTTP(S) auto-detected, OSC-8, and git-reference links, Command-click on macOS and Control-click on Windows/Linux open the external browser exactly once; Meta-click alone does nothing on Windows/Linux. An unconsumed macOS Control-click may do the same, but native context-click remains intact. Auto-detected/git plain clicks remain inactive, OSC-8 plain click still opens externally, and file-path links retain their existing popover behavior. +10. Primary+Shift takes precedence and opens each supported HTTP(S) link exactly once in the visible Browser panel of an eligible worktree Session, reusing the first Browser panel or creating/activating one without a duplicate state update, custom event, reload, or external open. The gesture rejects malformed, credential-bearing, local-file, and non-HTTP(S) targets before panel mutation. In Project/main-repo terminals, Pane Chat, Remote PWA, and any known Browser-ineligible context, it opens externally exactly once and creates no hidden Browser panel; hover text accurately describes the available platform gestures and fallback. +11. Help, Command Palette, Add Tool, and empty-state agent entries all display the effective remapped or reset chord and never show stale preset constants. + +## Test and QA plan + +### Automated + +- Shared/unit: catalog id/default uniqueness; RunPane agent parity; platform availability; event/string normalization for letters, shifted digits, punctuation, arrows, Backslash/IntlBackslash, Command/Control, and AltGr; override fallback/unassign/reset; activation-scope conflict matrix. +- Store/unit: zero/one/multiple enabled candidates; conflicts no-op rather than last-wins; disabled-shortcut and remapped Command Palette exception behavior. +- Main/unit: extract and test exact webview forwarding from effective bindings, including config updates, disabled shortcuts, native browser shortcuts, shifted physical codes, and AltGr. +- Terminal/unit: effective remaps are released from xterm only when active; existing TUI, SIGQUIT, clipboard, search, paste, and international-layout cases remain green. +- Settings/Playwright: complete view-independent inventory; accessible key capture; dirty/save/error states; conflict owners; unassign; row reset; reset all; persistence after close/reopen; platform availability; snippet/custom-command conflicts; effective hints in Help and Add Tool. +- Launch integration: remap each supported agent, invoke from normal UI/terminal/webview focus, and assert one panel with the preset title/initial command in worktree and main-repo contexts. +- Link unit: classifier truth table for macOS Command/Command+Shift/Control/context-click and Windows/Linux Control/Control+Shift/Meta, including Shift precedence, primary-button filtering, provider-specific plain clicks, and file-link exclusion. URL fixtures cover HTTP/HTTPS, credentials, malformed strings, `file:`, `javascript:`, `data:`, `blob:`, and a custom scheme. +- Link integration: auto-detected, OSC-8, and git-reference providers each route one activation to one sink. Existing worktree Browser reuse/create/activate performs one navigation without `openExternal`; Project and Pane Chat Primary+Shift use one external fallback and create no Browser panel. Assert platform-specific hover copy and unchanged file popovers. + +### Manual desktop QA + +- macOS: US and one Option-sensitive layout; verify Command-click externally and Command+Shift-click in Pane Browser from a worktree terminal for auto-detected, OSC-8, and GitHub issue/commit links. Verify Pane Chat/Project fallback, OSC-8 plain click, file popovers, and that Control-click still opens the native context menu (or externally only when delivered as an ordinary unconsumed click). +- Windows native and WSL project; Linux: verify Control-click externally, Control+Shift-click in Pane Browser where eligible, external fallback elsewhere, Meta-click no-op, Cursor availability rules, AltGr entry, and remapped launch from xterm and an embedded browser. +- Restart Pane after saving, edit `config.json` externally once, and verify valid changes reload while malformed/conflicting values fail safe and remain diagnosable in Settings/logs. +- Keyboard-only and screen-reader pass for recording, error announcement, reset confirmation, focus restoration, 200% zoom, and narrow Settings layout. +- Required gates: targeted unit/Playwright suites, `pnpm lint`, and `pnpm typecheck`. + +## Migration and backward compatibility + +- No database migration. `keyboardShortcutOverrides` is optional in the existing global `config.json`; absence resolves to current defaults. +- Existing terminal snippets, custom commands, keyboard enablement, and Command Palette exception fields remain intact. They become inputs to the shared conflict resolver, not rewritten records. +- Invalid or unknown override ids from hand edits/newer versions are preserved in config for forward/downgrade tolerance but ignored by the current runtime with a diagnostic. Invalid chord syntax falls back safely; an active duplicate invokes neither action. +- Sparse overrides deliberately follow future default changes only when the user has not customized that command. Reset removes an override so future defaults apply; explicit custom/unassigned values survive upgrades. +- Stable ids are the compatibility boundary. Renaming labels or changing agent titles must not rename ids; removing an agent must not reuse its id. + +## Risks and mitigations + +- **Catalog drift:** a command could register without settings metadata. Make catalog membership type-checked for static commands and test dynamic id families; fail tests on unmatched registrations. +- **Scope mistakes:** incorrectly declaring two commands mutually exclusive could hide a real conflict. Keep a small explicit scope model, default unknown/dynamic commands to the broadest scope, and test every intentional duplicate. +- **Terminal regressions:** generalized matching could swallow CLI keys. Keep terminal-native precedence and the current AltGr/TUI/layout regression suite; only release an exact, enabled effective Pane chord. +- **Webview regressions:** broad interception breaks browser editing/navigation. Match the exact effective set and add negative tests for Ctrl/Cmd+A/F/R and disabled commands. +- **Cross-process drift:** DOM and Electron keyboard events differ. Share pure canonicalization primitives and run the same fixture matrix against both adapters. +- **Recorder accessibility/layout:** key capture can trap focus or hide validation. Provide explicit Record/Clear/Reset controls, live conflict announcements, Escape cancel, and non-color status text. +- **Link double activation:** OSC-8, WebLinks, provider callbacks, panel-state effects, and `browser-panel:navigate` can overlap. Use one ordered classifier and one result-bearing navigation entry point; test sink call counts and remove the redundant update/event path. +- **Untrusted terminal URLs:** Pane Browser also supports trusted local HTML previews, but terminal output is untrusted. Admit only parsed HTTP(S) without embedded credentials to the in-Pane gesture and retain the existing sandbox, context isolation, project partition, and popup routing. + +## Dependencies + +- Existing Zustand hotkey registry, config store/IPC/`ConfigManager`, Settings persistence and dirty-form guard, xterm link APIs, Electron `before-input-event`, `panelApi`/Browser panel lifecycle, and `openExternal` IPC. +- `AGENT_LAUNCH_PRESETS` and the generated RunPane agent contract remain the source of truth for supported agent commands/platforms. +- No new runtime dependency is expected. diff --git a/docs/ADDING_NEW_CLI_TOOLS.md b/docs/ADDING_NEW_CLI_TOOLS.md index e1f016dd7..43948a502 100644 --- a/docs/ADDING_NEW_CLI_TOOLS.md +++ b/docs/ADDING_NEW_CLI_TOOLS.md @@ -89,7 +89,9 @@ default chord to `shared/constants/keyboardShortcuts.ts`. `agentLaunchPresets.test.ts` and `keyboardShortcutCatalog.test.ts` pin preset, catalog, platform, and default-chord parity. Add the brand icon to `frontend/src/components/ui/BrandIcons.tsx` (`CLI_BRAND_ICONS`) and a search alias in -`frontend/src/components/settings/catalog.tsx`. +`frontend/src/components/settings/catalog.tsx`. Settings → Shortcuts, Help, and the +conflict engine read the catalog directly, so a new agent's launch command becomes +remappable (and conflict-checked against every other binding) with no further UI work. ## 8. Worktree file sync diff --git a/frontend/src/components/Help.tsx b/frontend/src/components/Help.tsx index 67534640c..0efa6ff9e 100644 --- a/frontend/src/components/Help.tsx +++ b/frontend/src/components/Help.tsx @@ -1,26 +1,34 @@ import { useMemo } from 'react'; import { GitBranch, Terminal, Folder, Zap, MessageSquare, Settings, Bell, History } from 'lucide-react'; import { Modal, ModalHeader, ModalBody } from './ui/Modal'; -import { useHotkeyStore, type HotkeyDefinition } from '../stores/hotkeyStore'; -import { formatKeyDisplay, CATEGORY_LABELS } from '../utils/hotkeyUtils'; +import type { ShortcutCategory } from '../../../shared/constants/keyboardShortcuts'; +import { useConfigStore } from '../stores/configStore'; +import { CATEGORY_LABELS, CATEGORY_ORDER, formatKeyDisplay } from '../utils/hotkeyUtils'; +import { rendererPlatform } from '../utils/platformUtils'; +import { buildShortcutMap, REFERENCE_ROWS, type ShortcutMapRow } from '../utils/shortcutMap'; import { Kbd } from './ui/Kbd'; function KeyboardShortcutsSection() { - const hotkeys = useHotkeyStore((s) => s.hotkeys); - const allHotkeys = useMemo( - () => - Array.from(hotkeys.values()) - .filter((def) => !def.devOnly || process.env.NODE_ENV === 'development') - .filter((def) => def.showInPalette !== false) - .filter((h) => !h.enabled || h.enabled()), - [hotkeys] - ); + const config = useConfigStore((s) => s.config); + const { rows } = useMemo(() => buildShortcutMap({ + overridesRaw: config?.keyboardShortcutOverrides, + terminalShortcuts: config?.terminalShortcuts, + customCommands: config?.customCommands, + environment: rendererPlatform(), + }), [config?.keyboardShortcutOverrides, config?.terminalShortcuts, config?.customCommands]); - const grouped = allHotkeys.reduce>((acc, def) => { - if (!acc[def.category]) acc[def.category] = []; - acc[def.category].push(def); - return acc; - }, {}); + const grouped = useMemo(() => { + const byCategory = new Map(); + for (const row of rows) { + const group = byCategory.get(row.category) ?? []; + group.push(row); + byCategory.set(row.category, group); + } + return CATEGORY_ORDER.flatMap((category) => { + const group = byCategory.get(category); + return group ? [{ category, rows: group }] : []; + }); + }, [rows]); return (
@@ -28,37 +36,41 @@ function KeyboardShortcutsSection() { Keyboard Shortcuts
- {/* Static shortcut not in registry (scoped input handler) */} -
-
- Send Input / Continue Conversation - {formatKeyDisplay('mod+enter')} -
-
- {/* Dynamic shortcuts from registry */} - {Object.entries(grouped).map(([category, hotkeys]) => { - // SAFETY: grouped is keyed by HotkeyDefinition category values. - const hotkeyCategory = category as HotkeyDefinition['category']; - return
+ {grouped.map(({ category, rows: groupRows }) => ( +

- {CATEGORY_LABELS[hotkeyCategory] ?? category} + {CATEGORY_LABELS[category]}

- {hotkeys.map((hotkey) => ( -
- {hotkey.label} - {hotkey.keys ? ( - - {formatKeyDisplay(hotkey.keys)} - + {groupRows.map((row) => ( +
+ + {row.label} + {row.availability === 'unavailable-platform' && ( + unavailable on this platform + )} + + {row.effectiveChord ? ( + {formatKeyDisplay(row.effectiveChord)} ) : ( - palette only + unassigned )}
))}
-
; - })} +
+ ))} +
+

Terminal / native — not remappable

+
+ {REFERENCE_ROWS.map((reference) => ( +
+ {reference.label} + {formatKeyDisplay(reference.chord)} +
+ ))} +
+
); diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 42d018532..1b43d5ac9 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -198,7 +198,7 @@ export function Settings({ isOpen, onClose, category, onCategoryChange, openRequ case 'integrations': return ; case 'shortcuts': - return ; + return ; case 'privacy': return ; case 'advanced': diff --git a/frontend/src/components/settings/KeyRecorder.tsx b/frontend/src/components/settings/KeyRecorder.tsx new file mode 100644 index 000000000..4ff68f5b2 --- /dev/null +++ b/frontend/src/components/settings/KeyRecorder.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from '../ui/Button'; +import { Kbd } from '../ui/Kbd'; +import { chordFromKeyboardEvent, parseChord } from '../../../../shared/utils/keyboardChords'; +import { formatKeyDisplay } from '../../utils/hotkeyUtils'; +import { isRecordableChord } from '../../utils/shortcutMap'; + +interface KeyRecorderProps { + label: string; + chord: string | null; + /** The row's catalog default; recording it removes the override instead of storing a duplicate. */ + defaultChord: string | null; + /** Row has an explicit override or unassignment that Reset would remove. */ + customized: boolean; + disabled?: boolean; + /** Ids of elements describing the row (inline status/conflict text). */ + describedBy?: string; + onRecord: (chord: string) => void; + onUnassign: () => void; + onReset: () => void; +} + +const RECORDER_COPY = { + modifierOnly: 'Press a key with the modifier', + altgr: 'Not a usable shortcut', + cancelled: 'Recording cancelled', + unassigned: 'Shortcut cleared', + 'empty': 'No key was pressed', + 'unknown-modifier': 'Unsupported modifier', + 'modifier-only': 'Press a key with the modifier', + 'bare-printable': 'Shortcuts with a letter, digit, or punctuation key must include Ctrl/⌘', + 'unsupported-key': "That key can't be used", + 'malformed': "That combination can't be used", + 'reserved-by-terminal': 'Reserved by the terminal (search/paste/clear/flow control)', +} as const; + +const MODIFIER_KEYS = new Set(['Control', 'Meta', 'Alt', 'Shift', 'AltGraph', 'CapsLock']); + +export function KeyRecorder({ + label, chord, defaultChord, customized, disabled, describedBy, onRecord, onUnassign, onReset, +}: KeyRecorderProps) { + const [recording, setRecording] = useState(false); + const [status, setStatus] = useState(''); + const buttonRef = useRef(null); + + // Latest callbacks for the native listener registered below. + const callbacksRef = useRef({ defaultChord, onRecord, onUnassign, onReset }); + callbacksRef.current = { defaultChord, onRecord, onUnassign, onReset }; + + useEffect(() => { + if (!recording) return; + const finish = (message: string) => { + setRecording(false); + setStatus(message); + buttonRef.current?.focus(); + }; + // Capture on window so the chord never reaches the Settings modal's + // document-level Escape handler or the global hotkey listener. + const handleKeyDown = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopImmediatePropagation(); + const { defaultChord: ownDefault, onRecord: record, onUnassign: unassign, onReset: reset } = callbacksRef.current; + if (event.key === 'Escape') { + finish(RECORDER_COPY.cancelled); + return; + } + if (event.repeat) return; + if (MODIFIER_KEYS.has(event.key)) { + setStatus(RECORDER_COPY.modifierOnly); + return; + } + const noModifiers = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; + if ((event.key === 'Backspace' || event.key === 'Delete') && noModifiers) { + finish(RECORDER_COPY.unassigned); + unassign(); + return; + } + const pressed = chordFromKeyboardEvent(event); + if (pressed === '') { + setStatus(RECORDER_COPY.altgr); + return; + } + const parsed = parseChord(pressed); + if (!parsed.ok) { + setStatus(RECORDER_COPY[parsed.reason]); + return; + } + const recordable = isRecordableChord(parsed.chord, { ownDefault }); + if (!recordable.ok) { + setStatus(RECORDER_COPY[recordable.reason]); + return; + } + finish(`Set to ${formatKeyDisplay(parsed.chord)}`); + if (parsed.chord === ownDefault) reset(); + else record(parsed.chord); + }; + // Swallow the matching keyup/keypress too so nothing downstream reacts to a half chord. + const swallow = (event: KeyboardEvent) => { event.preventDefault(); event.stopImmediatePropagation(); }; + const cancel = () => setRecording(false); + window.addEventListener('keydown', handleKeyDown, true); + window.addEventListener('keyup', swallow, true); + window.addEventListener('keypress', swallow, true); + window.addEventListener('blur', cancel); + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + window.removeEventListener('keyup', swallow, true); + window.removeEventListener('keypress', swallow, true); + window.removeEventListener('blur', cancel); + }; + }, [recording]); + + return ( +
+ + + + + {status} + +
+ ); +} diff --git a/frontend/src/components/settings/KeyboardShortcutMap.tsx b/frontend/src/components/settings/KeyboardShortcutMap.tsx new file mode 100644 index 000000000..00ffd734b --- /dev/null +++ b/frontend/src/components/settings/KeyboardShortcutMap.tsx @@ -0,0 +1,214 @@ +import { useMemo, useState } from 'react'; +import { Search } from 'lucide-react'; +import { Button } from '../ui/Button'; +import { Input } from '../ui/Input'; +import { Kbd } from '../ui/Kbd'; +import { ConfirmDialog } from '../ConfirmDialog'; +import { KeyRecorder } from './KeyRecorder'; +import type { KeyboardShortcutOverrides } from '../../../../shared/utils/keyboardBindings'; +import type { CustomCommand, TerminalShortcut } from '../../types/config'; +import { CATEGORY_LABELS, CATEGORY_ORDER, formatKeyDisplay } from '../../utils/hotkeyUtils'; +import { + filterShortcutRows, + labelForId, + REFERENCE_ROWS, + SCOPE_LABELS, + type ShortcutMap, + type ShortcutMapRow, +} from '../../utils/shortcutMap'; + +interface KeyboardShortcutMapProps { + map: ShortcutMap; + draft: KeyboardShortcutOverrides; + dirty: boolean; + terminalShortcuts: readonly TerminalShortcut[]; + customCommands: readonly CustomCommand[]; + onDraftChange: (next: KeyboardShortcutOverrides) => void; + onApply: () => void; +} + +const STATE_LABELS = { + 'default': null, + 'customized': 'Customized', + 'unassigned': 'Unassigned', + 'invalid': 'Invalid — using default', +} satisfies Record; + +export function KeyboardShortcutMap({ + map, draft, dirty, terminalShortcuts, customCommands, onDraftChange, onApply, +}: KeyboardShortcutMapProps) { + const [query, setQuery] = useState(''); + const [confirmResetAll, setConfirmResetAll] = useState(false); + const visible = useMemo(() => filterShortcutRows(map.rows, query), [map.rows, query]); + const grouped = useMemo(() => { + const byCategory = new Map(); + for (const row of visible) { + const group = byCategory.get(row.category) ?? []; + group.push(row); + byCategory.set(row.category, group); + } + return CATEGORY_ORDER.flatMap((category) => { + const group = byCategory.get(category); + return group ? [{ category, rows: group }] : []; + }); + }, [visible]); + const conflicted = map.conflicts.length > 0; + const sources = { terminalShortcuts, customCommands }; + const whereToEdit = (id: string) => ( + id.startsWith('terminal-shortcut-') ? ' (edit in Terminal snippets below)' + : id.startsWith('add-tool-custom-') ? ' (custom command; remap it in its own row or in Add Tool › Custom commands)' + : '' + ); + + const setOverride = (id: string, value: string | null) => onDraftChange({ ...draft, [id]: value }); + const removeOverride = (id: string) => { + const next = { ...draft }; + delete next[id]; + onDraftChange(next); + }; + + return ( +
+ } + aria-label="Search shortcuts" + placeholder="Search commands or keys" + value={query} + onChange={(event) => setQuery(event.target.value)} + fullWidth + /> +
+
+
+
+ Command + Shortcut + Default + Scope + State +
+
+ {grouped.length === 0 && ( +

No shortcuts match “{query}”.

+ )} + {grouped.map(({ category, rows: groupRows }) => ( +
+ + {groupRows.map((row) => { + const statusId = `shortcut-status-${row.id}`; + const conflictText = row.conflicts.length > 0 + ? `${row.effectiveChord ? formatKeyDisplay(row.effectiveChord) : 'This key'} is also bound to ${row.conflicts.map((id) => labelForId(id, sources) + whereToEdit(id)).join(', ')}` + : ''; + return ( +
+
+ {row.label} + {row.origin === 'snippet' && ( + Snippet — edit in Terminal snippets below + )} + {conflictText && ( + + )} +
+
+ {row.editable ? ( + setOverride(row.id, chord)} + onUnassign={() => setOverride(row.id, null)} + onReset={() => removeOverride(row.id)} + /> + ) : row.effectiveChord ? ( + {formatKeyDisplay(row.effectiveChord)} + ) : ( + No key + )} +
+
+ Default: + {row.defaultChord ? formatKeyDisplay(row.defaultChord) : '—'} +
+
+ Scope: {SCOPE_LABELS[row.scope]} +
+
+ {STATE_LABELS[row.state] && {STATE_LABELS[row.state]}} + {row.availability === 'unavailable-platform' && Unavailable on this platform} +
+
+ ); + })} +
+ ))} + {!query && ( +
+ + {REFERENCE_ROWS.map((reference) => ( +
+ {reference.label} + {formatKeyDisplay(reference.chord)} + Owned by the terminal or the view +
+ ))} +
+ )} +
+
+
+ +
+ {conflicted && ( +
    + {map.conflicts.map((conflict) => ( +
  • + {formatKeyDisplay(conflict.chord)} is bound to {conflict.ids.map((id) => labelForId(id, sources)).join(' and ')} +
  • + ))} +
  • Resolve conflicts to apply.
  • +
+ )} + +
+
+ setConfirmResetAll(false)} + onConfirm={() => { setConfirmResetAll(false); onDraftChange({}); }} + title="Reset all key bindings?" + message="Every command returns to its default shortcut. Apply afterwards to save." + confirmText="Reset all" + cancelText="Keep bindings" + variant="warning" + /> +
+ ); +} + +function StateTag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/settings/catalog.tsx b/frontend/src/components/settings/catalog.tsx index 2aff0f969..2eb76f343 100644 --- a/frontend/src/components/settings/catalog.tsx +++ b/frontend/src/components/settings/catalog.tsx @@ -103,8 +103,8 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategoryDefinition[] = [ label: 'Shortcuts', description: 'Application and terminal snippet hotkeys.', icon: Keyboard, - settingIds: ['keyboard-shortcuts', 'command-palette-shortcut', 'kitty-keyboard', 'terminal-shortcuts'], - aliases: ['hotkeys', 'keyboard', 'snippets', 'kitty', 'key reporting'], + settingIds: ['keyboard-shortcuts', 'command-palette-shortcut', 'keyboard-shortcut-map', 'kitty-keyboard', 'terminal-shortcuts'], + aliases: ['hotkeys', 'keyboard', 'keybindings', 'keybinding', 'rebind', 'remap', 'agent shortcut', 'snippets', 'kitty', 'key reporting'], }, { id: 'privacy', diff --git a/frontend/src/components/settings/categories/ShortcutsSettings.tsx b/frontend/src/components/settings/categories/ShortcutsSettings.tsx index 7b8c5a9e9..62012a34c 100644 --- a/frontend/src/components/settings/categories/ShortcutsSettings.tsx +++ b/frontend/src/components/settings/categories/ShortcutsSettings.tsx @@ -7,23 +7,36 @@ import { SettingRow, SettingsPage } from '../SettingRow'; import { ImmediateToggle } from '../SettingsControls'; import type { SettingsPersistence } from '../useSettingsPersistence'; import type { TerminalShortcut } from '../../../types/config'; +import type { KeyboardShortcutOverrides } from '../../../../../shared/utils/keyboardBindings'; import { formatKeyDisplay } from '../../../utils/hotkeyUtils'; +import { buildShortcutMap, resolveShortcutEnvironment } from '../../../utils/shortcutMap'; +import { useActiveProjectEnvironment } from '../../../hooks/useActiveProjectEnvironment'; +import { KeyboardShortcutMap } from '../KeyboardShortcutMap'; interface ShortcutsSettingsProps { persistence: SettingsPersistence; + /** Host platform from the main process; the active project's environment takes precedence for availability. */ + platform: string; onDirtyChange: (dirty: boolean) => void; onShowKeyboardShortcuts: () => void; } -export function ShortcutsSettings({ persistence, onDirtyChange, onShowKeyboardShortcuts }: ShortcutsSettingsProps) { +export function ShortcutsSettings({ persistence, platform, onDirtyChange, onShowKeyboardShortcuts }: ShortcutsSettingsProps) { const config = persistence.config!; const persistedShortcuts = config.terminalShortcuts ?? []; const persistedKey = JSON.stringify(persistedShortcuts); const [shortcuts, setShortcuts] = useState(persistedShortcuts); - const dirty = JSON.stringify(shortcuts) !== persistedKey; + const snippetsDirty = JSON.stringify(shortcuts) !== persistedKey; + const persistedOverrides = config.keyboardShortcutOverrides ?? {}; + const persistedOverridesKey = JSON.stringify(persistedOverrides); + const [overridesDraft, setOverridesDraft] = useState(persistedOverrides); + const overridesDirty = JSON.stringify(overridesDraft) !== persistedOverridesKey; + const dirty = snippetsDirty || overridesDirty; // SAFETY: App-owned storage writes this value through the matching typed serializer. useEffect(() => setShortcuts(JSON.parse(persistedKey) as TerminalShortcut[]), [persistedKey]); + // SAFETY: Same app-owned storage; the main process normalizes the override map before saving. + useEffect(() => setOverridesDraft(JSON.parse(persistedOverridesKey) as KeyboardShortcutOverrides), [persistedOverridesKey]); useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]); useEffect(() => () => onDirtyChange(false), [onDirtyChange]); @@ -34,7 +47,21 @@ export function ShortcutsSettings({ persistence, onDirtyChange, onShowKeyboardSh } return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([key]) => key)); }, [shortcuts]); - const invalid = shortcuts.some((shortcut) => !shortcut.label.trim() || !shortcut.key || !shortcut.text.trim()) || duplicateKeys.size > 0; + const projectEnvironment = useActiveProjectEnvironment(); + const customCommands = useMemo(() => config.customCommands ?? [], [config.customCommands]); + const shortcutMap = useMemo(() => buildShortcutMap({ + overridesRaw: overridesDraft, + terminalShortcuts: shortcuts, + customCommands, + environment: resolveShortcutEnvironment(projectEnvironment, platform), + }), [overridesDraft, shortcuts, customCommands, projectEnvironment, platform]); + const conflicted = shortcutMap.conflicts.length > 0; + const snippetConflicts = new Set( + shortcutMap.rows.filter((row) => row.origin === 'snippet' && row.conflicts.length > 0).map((row) => row.id), + ); + const invalid = shortcuts.some((shortcut) => !shortcut.label.trim() || !shortcut.key || !shortcut.text.trim()) + || duplicateKeys.size > 0 + || snippetConflicts.size > 0; const update = (index: number, patch: Partial) => { setShortcuts((current) => current.map((shortcut, shortcutIndex) => ( @@ -44,8 +71,12 @@ export function ShortcutsSettings({ persistence, onDirtyChange, onShowKeyboardSh const apply = async () => { if (invalid) return; - const saved = await persistence.saveConfig('terminal-shortcuts', { terminalShortcuts: shortcuts }); - if (saved) onDirtyChange(false); + await persistence.saveConfig('terminal-shortcuts', { terminalShortcuts: shortcuts }); + }; + + const applyOverrides = async () => { + if (conflicted) return; + await persistence.saveConfig('keyboard-shortcut-map', { keyboardShortcutOverrides: overridesDraft }); }; return ( @@ -75,6 +106,24 @@ export function ShortcutsSettings({ persistence, onDirtyChange, onShowKeyboardSh onSave={(value) => persistence.saveConfig('command-palette-shortcut', { commandPaletteShortcutEnabled: value })} /> + + + - + diff --git a/frontend/src/hooks/useActiveProjectEnvironment.ts b/frontend/src/hooks/useActiveProjectEnvironment.ts new file mode 100644 index 000000000..5204900e8 --- /dev/null +++ b/frontend/src/hooks/useActiveProjectEnvironment.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; +import { API } from '../utils/api'; +import { useSessionStore } from '../stores/sessionStore'; +import type { ProjectEnvironment } from '../../../shared/types/panels'; +import type { Project } from '../types/project'; + +/** + * Environment of the active session's project (`macos` | `windows` | `linux` + * | `wsl`), or undefined when no session is active or the project is unknown. + * Mirrors how SessionView resolves the environment for Add Tool presets. + */ +export function useActiveProjectEnvironment(): ProjectEnvironment | undefined { + const projectId = useSessionStore((state) => { + if (!state.activeSessionId) return undefined; + if (state.activeMainRepoSession?.id === state.activeSessionId) return state.activeMainRepoSession.projectId; + return state.sessions.find((session) => session.id === state.activeSessionId)?.projectId; + }); + const [environment, setEnvironment] = useState(undefined); + + useEffect(() => { + let cancelled = false; + if (projectId === undefined) { + setEnvironment(undefined); + return undefined; + } + API.projects.getAll() + .then((response) => { + if (cancelled || !response.success || !response.data) return; + const project = response.data.find((candidate: Project) => candidate.id === projectId); + setEnvironment(project?.environment); + }) + .catch(() => { + if (!cancelled) setEnvironment(undefined); + }); + return () => { cancelled = true; }; + }, [projectId]); + + return environment; +} diff --git a/frontend/src/stores/hotkeyStore.ts b/frontend/src/stores/hotkeyStore.ts index c814d8ca3..d7569b8bd 100644 --- a/frontend/src/stores/hotkeyStore.ts +++ b/frontend/src/stores/hotkeyStore.ts @@ -242,10 +242,6 @@ export const useHotkeyStore = create((set, get) => ({ }, })); -let previousOverrides = initialConfig?.keyboardShortcutOverrides; -let previousTerminalShortcuts = initialConfig?.terminalShortcuts; -let previousCustomCommands = initialConfig?.customCommands; - function rebuildForConfig(): void { const config = useConfigStore.getState().config; interceptionSets = buildInterceptionSets({ @@ -258,17 +254,8 @@ function rebuildForConfig(): void { useHotkeyStore.setState({ hotkeys: rebuilt.next }); } -useConfigStore.subscribe(state => { - const overrides = state.config?.keyboardShortcutOverrides; - const terminalShortcuts = state.config?.terminalShortcuts; - const customCommands = state.config?.customCommands; - if ( - overrides === previousOverrides - && terminalShortcuts === previousTerminalShortcuts - && customCommands === previousCustomCommands - ) return; - previousOverrides = overrides; - previousTerminalShortcuts = terminalShortcuts; - previousCustomCommands = customCommands; - rebuildForConfig(); +// Rebuilding is a cheap pass over ~70 catalog rows, so any config change rebuilds +// rather than diffing the three inputs by hand. +useConfigStore.subscribe((state, previous) => { + if (state.config !== previous.config) rebuildForConfig(); }); diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index c04538a8e..b7b73ba34 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -13,7 +13,7 @@ export interface TerminalShortcut { enabled: boolean; } -interface CustomCommand { +export interface CustomCommand { name: string; command: string; } diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 688792b79..f6d878b3e 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -54,6 +54,7 @@ export type SettingsSettingId = | 'command-palette-shortcut' | 'kitty-keyboard' | 'terminal-shortcuts' + | 'keyboard-shortcut-map' | 'analytics' | 'verbose-logging' | 'developer-mode' diff --git a/frontend/src/utils/agentPresets.ts b/frontend/src/utils/agentPresets.ts index 1ff428404..f11c58625 100644 --- a/frontend/src/utils/agentPresets.ts +++ b/frontend/src/utils/agentPresets.ts @@ -1,10 +1,10 @@ import { AgentLaunchPreset, agentPresetsForPlatform } from '../../../shared/constants/agentLaunchPresets'; import type { ProjectEnvironment } from '../../../shared/types/panels'; -import { isMac, isWindows } from './platformUtils'; +import { rendererPlatform } from './platformUtils'; export type { AgentLaunchPreset }; export function visibleAgentPresets(projectEnvironment?: ProjectEnvironment): readonly AgentLaunchPreset[] { - const platform = projectEnvironment ?? (isWindows() ? 'win32' : isMac() ? 'darwin' : 'linux'); + const platform = projectEnvironment ?? rendererPlatform(); return agentPresetsForPlatform(platform); } diff --git a/frontend/src/utils/hotkeyUtils.test.ts b/frontend/src/utils/hotkeyUtils.test.ts new file mode 100644 index 000000000..f404deb51 --- /dev/null +++ b/frontend/src/utils/hotkeyUtils.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { formatKeyDisplay } from './hotkeyUtils'; + +describe('formatKeyDisplay', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('keeps existing default chords unchanged on macOS', () => { + vi.stubGlobal('navigator', { platform: 'MacIntel' }); + expect(formatKeyDisplay('mod+shift+d')).toBe('⌘ + ⇧ + D'); + expect(formatKeyDisplay('mod+alt+ArrowLeft')).toBe('⌘ + ⌥ + ←'); + expect(formatKeyDisplay('mod+Tab')).toBe('⌘ + Tab'); + expect(formatKeyDisplay('mod+`')).toBe('⌘ + `'); + }); + + it('keeps existing default chords unchanged elsewhere', () => { + vi.stubGlobal('navigator', { platform: 'Win32' }); + expect(formatKeyDisplay('mod+shift+d')).toBe('Ctrl + Shift + D'); + expect(formatKeyDisplay('mod+alt+/')).toBe('Ctrl + Alt + /'); + }); + + it('renders newly recordable named keys', () => { + vi.stubGlobal('navigator', { platform: 'Win32' }); + expect(formatKeyDisplay('mod+Enter')).toBe('Ctrl + Enter'); + expect(formatKeyDisplay('shift+PageUp')).toBe('Shift + PgUp'); + expect(formatKeyDisplay('mod+Backspace')).toBe('Ctrl + Backspace'); + expect(formatKeyDisplay('mod+Space')).toBe('Ctrl + Space'); + vi.stubGlobal('navigator', { platform: 'MacIntel' }); + expect(formatKeyDisplay('mod+Enter')).toBe('⌘ + ↩'); + expect(formatKeyDisplay('mod+Delete')).toBe('⌘ + ⌦'); + }); +}); diff --git a/frontend/src/utils/hotkeyUtils.ts b/frontend/src/utils/hotkeyUtils.ts index 76d042e5b..c54f85ef8 100644 --- a/frontend/src/utils/hotkeyUtils.ts +++ b/frontend/src/utils/hotkeyUtils.ts @@ -47,6 +47,15 @@ export function formatKeyDisplay(keys: string): string { case 'arrowup': return '↑'; case 'arrowdown': return '↓'; case 'tab': return 'Tab'; + case 'enter': return isMacPlatform ? '↩' : 'Enter'; + case 'escape': return 'Esc'; + case 'backspace': return isMacPlatform ? '⌫' : 'Backspace'; + case 'delete': return isMacPlatform ? '⌦' : 'Del'; + case 'space': return 'Space'; + case 'pageup': return 'PgUp'; + case 'pagedown': return 'PgDn'; + case 'home': return 'Home'; + case 'end': return 'End'; default: return part.length === 1 ? part.toUpperCase() : part; } }); diff --git a/frontend/src/utils/platformUtils.ts b/frontend/src/utils/platformUtils.ts index 57a8cc8e1..4acbcd961 100644 --- a/frontend/src/utils/platformUtils.ts +++ b/frontend/src/utils/platformUtils.ts @@ -24,3 +24,11 @@ export function isWindows(): boolean { export function getModifierKeyName(): string { return isMac() ? 'Cmd' : 'Ctrl'; } + +/** + * Platform id in the form the shared shortcut catalog and agent presets use. + * Project environments (e.g. WSL) can override this per project. + */ +export function rendererPlatform(): 'darwin' | 'win32' | 'linux' { + return isWindows() ? 'win32' : isMac() ? 'darwin' : 'linux'; +} diff --git a/frontend/src/utils/shortcutMap.test.ts b/frontend/src/utils/shortcutMap.test.ts new file mode 100644 index 000000000..96b092f5f --- /dev/null +++ b/frontend/src/utils/shortcutMap.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { + buildShortcutMap, + filterShortcutRows, + isRecordableChord, + labelForId, + resolveShortcutEnvironment, +} from './shortcutMap'; + +const base = { environment: 'darwin' }; +const row = (map: ReturnType, id: string) => map.rows.find(candidate => candidate.id === id); + +describe('buildShortcutMap', () => { + it('lists every catalog command with its default as the effective chord when nothing is overridden', () => { + const map = buildShortcutMap(base); + expect(row(map, 'add-tool-terminal-claude')).toMatchObject({ + defaultChord: 'mod+alt+3', effectiveChord: 'mod+alt+3', state: 'default', editable: true, origin: 'catalog', + }); + expect(map.rows.filter(candidate => candidate.id.startsWith('add-tool-custom-'))).toHaveLength(0); + expect(map.conflicts).toEqual([]); + expect(map.unknownIdDiagnostics).toEqual([]); + }); + + it('derives customized, unassigned, and invalid states from the raw map', () => { + const map = buildShortcutMap({ + ...base, + overridesRaw: { 'add-tool-terminal-claude': 'mod+alt+k', 'toggle-sidebar': null, 'open-settings': 'nope', 'ghost': 'mod+x' }, + }); + expect(row(map, 'add-tool-terminal-claude')).toMatchObject({ effectiveChord: 'mod+alt+k', state: 'customized' }); + expect(row(map, 'toggle-sidebar')).toMatchObject({ effectiveChord: null, state: 'unassigned' }); + expect(row(map, 'open-settings')).toMatchObject({ effectiveChord: 'mod+,', state: 'invalid' }); + expect(map.unknownIdDiagnostics).toEqual([ + 'invalid keyboard shortcut open-settings: unsupported-key', + 'unknown keyboard shortcut id: ghost', + ]); + }); + + it('reports conflicts between catalog commands, snippets, and custom commands in overlapping scopes', () => { + const map = buildShortcutMap({ + ...base, + overridesRaw: { 'add-tool-terminal-codex': 'mod+alt+q' }, + terminalShortcuts: [ + { id: 'snip', label: 'Snippet', key: 'q', text: 'x', enabled: true }, + { id: 'off', label: 'Disabled', key: 'q', text: 'x', enabled: false }, + ], + customCommands: [{ name: 'Lint', command: 'pnpm lint' }], + }); + expect(row(map, 'add-tool-terminal-codex')?.conflicts).toEqual(['terminal-shortcut-snip']); + expect(row(map, 'terminal-shortcut-snip')).toMatchObject({ editable: false, origin: 'snippet', conflicts: ['add-tool-terminal-codex'] }); + expect(row(map, 'terminal-shortcut-off')).toBeUndefined(); + expect(row(map, 'add-tool-custom-0')).toMatchObject({ label: 'Add Lint', effectiveChord: 'mod+alt+6' }); + }); + + it('validates conflicts globally: a Cursor remap on native Windows still conflicts', () => { + const map = buildShortcutMap({ + environment: 'win32', + overridesRaw: { 'add-tool-terminal-cursor': 'mod+alt+3' }, + }); + expect(row(map, 'add-tool-terminal-cursor')).toMatchObject({ + availability: 'unavailable-platform', + editable: true, + conflicts: ['add-tool-terminal-claude'], + }); + expect(row(map, 'add-tool-terminal-claude')?.conflicts).toEqual(['add-tool-terminal-cursor']); + }); + + it('keeps the intentional scoped duplicate between Run Dev Server and Usage Download conflict-free', () => { + const map = buildShortcutMap(base); + expect(row(map, 'run-dev-server')?.conflicts).toEqual([]); + expect(row(map, 'usage-download')?.conflicts).toEqual([]); + }); + + it('normalizes project environments for availability', () => { + expect(row(buildShortcutMap({ environment: 'windows' }), 'add-tool-terminal-cursor')?.availability).toBe('unavailable-platform'); + expect(row(buildShortcutMap({ environment: 'macos' }), 'add-tool-terminal-cursor')?.availability).toBe('available'); + expect(row(buildShortcutMap({ environment: 'wsl' }), 'add-tool-terminal-cursor')?.availability).toBe('available'); + }); + + it('lets a reserved snippet letter participate in conflicts without rejecting it', () => { + const map = buildShortcutMap({ + ...base, + overridesRaw: { 'toggle-sidebar': 'mod+alt+f' }, + terminalShortcuts: [{ id: 'find', label: 'Find snippet', key: 'f', text: 'x', enabled: true }], + }); + expect(row(map, 'terminal-shortcut-find')?.conflicts).toEqual(['toggle-sidebar']); + }); +}); + +describe('helpers', () => { + it('filters by label, id, or chord', () => { + const { rows } = buildShortcutMap(base); + expect(filterShortcutRows(rows, 'codex').map(candidate => candidate.id)).toEqual(['add-tool-terminal-codex']); + expect(filterShortcutRows(rows, 'mod+alt+3').map(candidate => candidate.id)).toEqual(['add-tool-terminal-claude']); + }); + + it('labels snippet, custom, catalog, and unknown ids', () => { + const sources = { + terminalShortcuts: [{ id: 'snip', label: 'Lint snippet', key: 'l', text: 'x', enabled: true }], + customCommands: [{ name: 'Lint', command: 'pnpm lint' }], + }; + expect(labelForId('terminal-shortcut-snip', sources)).toBe('Lint snippet'); + expect(labelForId('add-tool-custom-0', sources)).toBe('Add Lint'); + expect(labelForId('add-tool-custom-3', sources)).toBe('Add custom tool 4'); + expect(labelForId('toggle-sidebar', sources)).toBe('Toggle Sidebar'); + expect(labelForId('mystery', sources)).toBe('mystery'); + }); + + it('refuses reserved chords for recordings but grandfathers a row default', () => { + expect(isRecordableChord('mod+alt+f', { ownDefault: 'mod+b' })).toEqual({ ok: false, reason: 'reserved-by-terminal' }); + expect(isRecordableChord('mod+shift+k', { ownDefault: 'mod+shift+k' })).toEqual({ ok: true }); + expect(isRecordableChord('mod+shift+p', { ownDefault: 'mod+shift+p' })).toEqual({ ok: true }); + expect(isRecordableChord('mod+alt+x', { ownDefault: 'mod+b' })).toEqual({ ok: true }); + }); + + it('prefers the active project environment over the host platform', () => { + expect(resolveShortcutEnvironment('wsl', 'win32')).toBe('wsl'); + expect(resolveShortcutEnvironment('macos', 'linux')).toBe('darwin'); + expect(resolveShortcutEnvironment(undefined, 'win32')).toBe('win32'); + }); +}); diff --git a/frontend/src/utils/shortcutMap.ts b/frontend/src/utils/shortcutMap.ts new file mode 100644 index 000000000..fe90a3e75 --- /dev/null +++ b/frontend/src/utils/shortcutMap.ts @@ -0,0 +1,231 @@ +/** + * Builds the complete, view-independent shortcut map shown by Shortcuts + * Settings and Help. Rows come from the shared catalog plus the current + * dynamic bindings (terminal snippets, custom commands); effective chords + * and conflicts are resolved with the same primitives the runtime uses. + * + * Conflicts are validated globally (no platform gate — a native-Windows + * install can open a WSL project where Cursor is active), while platform + * availability is only a badge derived from the supplied environment. + */ +import { + KEYBOARD_SHORTCUT_CATALOG, + getCatalogEntry, + normalizeEnvironmentPlatform, + type ShortcutCategory, + type ShortcutScope, +} from '../../../shared/constants/keyboardShortcuts'; +import { + collectActiveBindings, + findChordConflicts, + normalizeKeyboardShortcutOverrides, + resolveEffectiveChord, + type KeyboardShortcutOverrides, +} from '../../../shared/utils/keyboardBindings'; +import { parseChord } from '../../../shared/utils/keyboardChords'; +import { boundary, decodeOptionalBoundary, BoundaryDecodeError, type JsonValue } from '../../../shared/validation/boundaryDecoder'; +import type { ProjectEnvironment } from '../../../shared/types/panels'; +import type { CustomCommand, TerminalShortcut } from '../types/config'; +import { isTerminalReservedChordString } from './terminalKeyHandling'; + +export type ShortcutRowOrigin = 'catalog' | 'snippet'; +export type ShortcutRowState = 'default' | 'customized' | 'unassigned' | 'invalid'; +export type ShortcutAvailability = 'available' | 'unavailable-platform'; + +export interface ShortcutMapRow { + id: string; + origin: ShortcutRowOrigin; + /** Rebindable through keyboardShortcutOverrides (catalog rows only). */ + editable: boolean; + label: string; + category: ShortcutCategory; + scope: ShortcutScope; + /** Catalog default; null for snippet rows. */ + defaultChord: string | null; + /** Override | unassigned (null) | default; invalid overrides fall back to the default. */ + effectiveChord: string | null; + state: ShortcutRowState; + availability: ShortcutAvailability; + /** Ids of other rows that share this chord within an overlapping scope. */ + conflicts: string[]; +} + +export interface ShortcutMapInput { + /** The raw persisted map, verbatim (unknown ids and malformed values included). */ + overridesRaw?: KeyboardShortcutOverrides | JsonValue; + terminalShortcuts?: readonly TerminalShortcut[]; + customCommands?: readonly CustomCommand[]; + /** Environment used only for the availability badge (`darwin` | `win32` | `linux` | `wsl`). */ + environment: string; +} + +export interface ShortcutMap { + rows: ShortcutMapRow[]; + conflicts: Array<{ chord: string; ids: string[] }>; + /** Raw override entries whose id is unknown or whose value cannot be parsed. */ + unknownIdDiagnostics: string[]; +} + +export interface ReferenceRow { + id: string; + label: string; + chord: string; +} + +/** + * Terminal- and context-native shortcuts that are not registry commands. + * They are listed for reference only; the terminal owns them before Pane's + * hotkey registry sees the key. + */ +export const REFERENCE_ROWS: readonly ReferenceRow[] = [ + { id: 'reference-send-input', label: 'Send Input / Continue Conversation', chord: 'mod+Enter' }, + { id: 'reference-newline', label: 'Insert newline in agent input', chord: 'shift+Enter' }, + { id: 'reference-terminal-copy', label: 'Terminal: Copy selection', chord: 'mod+c' }, + { id: 'reference-terminal-paste', label: 'Terminal: Paste', chord: 'mod+v' }, + { id: 'reference-terminal-search', label: 'Terminal: Find', chord: 'mod+f' }, + { id: 'reference-terminal-clear', label: 'Terminal: Clear scrollback', chord: 'mod+k' }, + { id: 'reference-prompt-history', label: 'Terminal: Prompt history', chord: 'mod+p' }, +]; + +export const SCOPE_LABELS = { + 'app': 'Everywhere', + 'session': 'Pane view', + 'session-panels': 'Pane tabs', + 'usage': 'Usage & Limits', +} satisfies Record; + +/** + * Environment for the availability badge: the active project's environment + * when one is known, otherwise the host platform. + */ +export function resolveShortcutEnvironment( + projectEnvironment: ProjectEnvironment | undefined, + hostPlatform: string, +): string { + return normalizeEnvironmentPlatform(projectEnvironment ?? hostPlatform); +} + +function rawEntries(raw: ShortcutMapInput['overridesRaw']): Map { + const entries = new Map(); + try { + const parsed = decodeOptionalBoundary(raw, boundary.jsonObject); + if (parsed) for (const [id, value] of Object.entries(parsed)) entries.set(id, value); + } catch (error) { + if (!(error instanceof BoundaryDecodeError)) throw error; + } + return entries; +} + +export function buildShortcutMap(input: ShortcutMapInput): ShortcutMap { + const normalized = normalizeKeyboardShortcutOverrides(input.overridesRaw); + const overrides = normalized.overrides; + const raw = rawEntries(input.overridesRaw); + const customCommands = input.customCommands ?? []; + const terminalShortcuts = input.terminalShortcuts ?? []; + const environment = normalizeEnvironmentPlatform(input.environment); + + // Global validation: no platform gate, so platform-limited commands stay in the set. + const conflicts = findChordConflicts(collectActiveBindings({ + overrides, + terminalShortcuts, + customCommands, + })); + const conflictIdsByRow = new Map(); + for (const conflict of conflicts) { + for (const id of conflict.ids) { + conflictIdsByRow.set(id, conflict.ids.filter(other => other !== id)); + } + } + + const rows: ShortcutMapRow[] = []; + for (const entry of KEYBOARD_SHORTCUT_CATALOG) { + let label = entry.label; + if (entry.dynamicSlot === 'custom-command') { + const command = customCommands[Number(entry.id.slice(-1))]; + if (!command) continue; + label = `Add ${command.name}`; + } + let state: ShortcutRowState = 'default'; + if (raw.has(entry.id)) { + if (raw.get(entry.id) === null) state = 'unassigned'; + else if (Object.prototype.hasOwnProperty.call(overrides, entry.id)) state = 'customized'; + else state = 'invalid'; + } + rows.push({ + id: entry.id, + origin: 'catalog', + editable: true, + label, + category: entry.category, + scope: entry.scope, + defaultChord: entry.defaultChord, + effectiveChord: resolveEffectiveChord(entry.id, overrides, entry.defaultChord), + state, + availability: !entry.platforms || entry.platforms.includes(environment) + ? 'available' + : 'unavailable-platform', + conflicts: conflictIdsByRow.get(entry.id) ?? [], + }); + } + + for (const shortcut of terminalShortcuts) { + if (!shortcut.enabled) continue; + const id = `terminal-shortcut-${shortcut.id}`; + const parsed = shortcut.key ? parseChord(`mod+alt+${shortcut.key}`) : null; + rows.push({ + id, + origin: 'snippet', + editable: false, + label: shortcut.label || 'Untitled snippet', + category: 'shortcuts', + scope: 'app', + defaultChord: null, + effectiveChord: parsed?.ok ? parsed.chord : null, + state: 'default', + availability: 'available', + conflicts: conflictIdsByRow.get(id) ?? [], + }); + } + + return { rows, conflicts, unknownIdDiagnostics: normalized.diagnostics }; +} + +export function filterShortcutRows(rows: readonly ShortcutMapRow[], query: string): ShortcutMapRow[] { + const lower = query.trim().toLowerCase(); + if (!lower) return [...rows]; + return rows.filter(row => + row.label.toLowerCase().includes(lower) + || row.id.toLowerCase().includes(lower) + || (row.effectiveChord ?? '').toLowerCase().includes(lower) + ); +} + +/** Human label for any binding id, including snippet and custom-command ids. */ +export function labelForId( + id: string, + sources: { terminalShortcuts?: readonly TerminalShortcut[]; customCommands?: readonly CustomCommand[] }, +): string { + if (id.startsWith('terminal-shortcut-')) { + const snippet = sources.terminalShortcuts?.find(shortcut => `terminal-shortcut-${shortcut.id}` === id); + return snippet?.label || 'Untitled snippet'; + } + if (id.startsWith('add-tool-custom-')) { + const command = sources.customCommands?.[Number(id.slice(-1))]; + if (command) return `Add ${command.name}`; + } + return getCatalogEntry(id)?.label ?? id; +} + +export type RecordableChordResult = { ok: true } | { ok: false; reason: 'reserved-by-terminal' }; + +/** + * Whether a user may record this chord. Terminal-reserved chords are refused + * unless the row's own default is that chord (grandfathered defaults such as + * `open-command-palette` = mod+shift+p and `git-commit` = mod+shift+k). + */ +export function isRecordableChord(chord: string, options: { ownDefault: string | null }): RecordableChordResult { + if (chord !== options.ownDefault && isTerminalReservedChordString(chord)) { + return { ok: false, reason: 'reserved-by-terminal' }; + } + return { ok: true }; +} diff --git a/frontend/src/utils/terminalKeyHandling.ts b/frontend/src/utils/terminalKeyHandling.ts index f3995690d..2ef40365e 100644 --- a/frontend/src/utils/terminalKeyHandling.ts +++ b/frontend/src/utils/terminalKeyHandling.ts @@ -80,10 +80,23 @@ function isPaneNavigationShortcut( return state.isTuiReleasableChord?.(event) ?? false; } +const TERMINAL_RESERVED_EVENT_KEYS = ['f', 'v', 'q', 'p']; +// mod+k (any Shift/Alt) is the terminal's clear-scrollback branch in TerminalPanel. +const TERMINAL_RESERVED_CHORD_KEYS = new Set([...TERMINAL_RESERVED_EVENT_KEYS, 'k']); + export function isTerminalReservedChord(event: TerminalKeyLike): boolean { if (event.code === 'AltRight') return true; return (event.ctrlKey || event.metaKey) - && ['f', 'v', 'q', 'p'].includes(event.key.toLowerCase()); + && TERMINAL_RESERVED_EVENT_KEYS.includes(event.key.toLowerCase()); +} + +/** + * String twin of `isTerminalReservedChord` for chords a user records: the + * terminal owns Ctrl/Cmd + f/v/q/p/k with any Shift/Alt combination. + */ +export function isTerminalReservedChordString(chord: string): boolean { + const parts = chord.split('+'); + return parts.includes('mod') && TERMINAL_RESERVED_CHORD_KEYS.has(parts[parts.length - 1]); } export function shouldReleaseToApplication( diff --git a/main/src/services/configManager.test.ts b/main/src/services/configManager.test.ts index 5d28452e7..7fd79646d 100644 --- a/main/src/services/configManager.test.ts +++ b/main/src/services/configManager.test.ts @@ -54,6 +54,20 @@ describe('ConfigManager keyboard shortcut overrides', () => { .toEqual(raw); }); + it('preserves a map whose entries are all unknown or invalid', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const raw = { 'future-command': 'mod+alt+8', 'new-session': 'not-a-chord' }; + await fs.writeFile(path.join(directory, 'config.json'), JSON.stringify({ keyboardShortcutOverrides: raw })); + const manager = new ConfigManager(); + await manager.initialize(); + await manager.updateConfig({ verbose: true }); + expect(manager.getConfig().keyboardShortcutOverrides).toEqual(raw); + expect(JSON.parse(await fs.readFile(path.join(directory, 'config.json'), 'utf8')).keyboardShortcutOverrides) + .toEqual(raw); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown keyboard shortcut id: future-command')); + warn.mockRestore(); + }); + it('drops and diagnoses a non-object override map loaded from disk', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); await fs.writeFile(path.join(directory, 'config.json'), JSON.stringify({ diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index 25b656876..b55c282aa 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -195,14 +195,9 @@ export class ConfigManager extends EventEmitter { : DEFAULT_WORKTREE_FILE_SYNC_ENTRIES }; - const rawShortcutOverrides: JsonValue | undefined = loadedConfig.keyboardShortcutOverrides; - const decodedShortcutOverrides = rawShortcutOverrides === undefined - ? undefined - : decodeOptionalBoundary(rawShortcutOverrides, boundary.jsonObject); - if (decodedShortcutOverrides === undefined) { + if (!this.applyKeyboardShortcutOverrides(loadedConfig.keyboardShortcutOverrides)) { delete this.config.keyboardShortcutOverrides; } - this.logKeyboardShortcutDiagnostics(rawShortcutOverrides); if (this.config.analytics?.posthogHost === LEGACY_POSTHOG_HOST) { this.config.analytics.posthogHost = DEFAULT_POSTHOG_HOST; @@ -333,10 +328,6 @@ export class ConfigManager extends EventEmitter { } async updateConfig(updates: Partial): Promise { - const rawShortcutOverrides: JsonValue | undefined = updates.keyboardShortcutOverrides; - const decodedShortcutOverrides = rawShortcutOverrides === undefined - ? undefined - : decodeOptionalBoundary(rawShortcutOverrides, boundary.jsonObject); const analytics = updates.analytics !== undefined ? { @@ -368,16 +359,9 @@ export class ConfigManager extends EventEmitter { ? normalizeRemoteDaemonConfig(updates.remoteDaemon) : this.config.remoteDaemon, }; - if ('keyboardShortcutOverrides' in updates) { - if (!decodedShortcutOverrides || Object.keys(decodedShortcutOverrides).length === 0) { - delete this.config.keyboardShortcutOverrides; - } + if (!this.applyKeyboardShortcutOverrides(this.config.keyboardShortcutOverrides)) { + delete this.config.keyboardShortcutOverrides; } - this.logKeyboardShortcutDiagnostics( - 'keyboardShortcutOverrides' in updates - ? rawShortcutOverrides - : this.config.keyboardShortcutOverrides, - ); await this.saveConfig(); // Clear PATH cache if additional paths were updated @@ -390,7 +374,15 @@ export class ConfigManager extends EventEmitter { return this.getConfig(); } - private logKeyboardShortcutDiagnostics(rawOverrides: JsonValue | undefined): void { + /** + * Normalizes the raw override map, logs anything malformed or conflicting + * (once per distinct message set), and reports whether the map should stay + * in config: any non-empty object is preserved verbatim (unknown ids and + * malformed values from hand edits or newer versions are kept for + * forward/downgrade tolerance and only ignored at runtime); `{}` and + * non-objects are dropped. + */ + private applyKeyboardShortcutOverrides(rawOverrides: JsonValue | undefined): boolean { const normalized = normalizeKeyboardShortcutOverrides(rawOverrides); const messages = normalized.diagnostics.map(message => `[ConfigManager] keyboardShortcutOverrides: ${message}` @@ -399,7 +391,8 @@ export class ConfigManager extends EventEmitter { overrides: rawOverrides, terminalShortcuts: this.config.terminalShortcuts, customCommands: this.config.customCommands, - platform: process.platform, + // No platform gate: overrides are global and a Windows host can open a + // WSL project where platform-limited commands (Cursor) are active. })); for (const conflict of conflicts) { messages.push( @@ -407,9 +400,12 @@ export class ConfigManager extends EventEmitter { ); } const diagnosticKey = messages.join('\n'); - if (diagnosticKey === this.lastLoggedShortcutDiagnostics) return; - this.lastLoggedShortcutDiagnostics = diagnosticKey; - for (const message of messages) console.warn(message); + if (diagnosticKey !== this.lastLoggedShortcutDiagnostics) { + this.lastLoggedShortcutDiagnostics = diagnosticKey; + for (const message of messages) console.warn(message); + } + const parsed = decodeOptionalBoundary(rawOverrides, boundary.jsonObject); + return parsed !== undefined && Object.keys(parsed).length > 0; } getGitRepoPath(): string { diff --git a/shared/constants/agentLaunchPresets.ts b/shared/constants/agentLaunchPresets.ts index 22006ab8a..6d483ec40 100644 --- a/shared/constants/agentLaunchPresets.ts +++ b/shared/constants/agentLaunchPresets.ts @@ -1,4 +1,4 @@ -import type { KeyboardShortcutId } from './keyboardShortcuts'; +import { normalizeEnvironmentPlatform, type KeyboardShortcutId } from './keyboardShortcuts'; export type AgentLaunchPresetId = 'claude' | 'codex' | 'cursor'; @@ -46,9 +46,7 @@ export function agentPresetsForPlatform(platform: string): readonly AgentLaunchP } export function isAgentSupportedOnPlatform(agent: AgentLaunchPresetId, platform: string): boolean { - const normalizedPlatform = platform === 'macos' - ? 'darwin' - : platform === 'windows' ? 'win32' : platform; + const normalizedPlatform = normalizeEnvironmentPlatform(platform); const preset = AGENT_LAUNCH_PRESETS.find(candidate => candidate.id === agent); return Boolean(preset && (!preset.platforms || preset.platforms.includes(normalizedPlatform))); } diff --git a/shared/constants/keyboardShortcuts.ts b/shared/constants/keyboardShortcuts.ts index 58d52f981..7664b822e 100644 --- a/shared/constants/keyboardShortcuts.ts +++ b/shared/constants/keyboardShortcuts.ts @@ -162,6 +162,16 @@ export function getCatalogEntry(id: string): ShortcutCatalogEntry | undefined { return catalogById.get(id); } +/** + * Maps a project environment (`macos` | `windows` | `linux` | `wsl`) or a + * Node `process.platform` value onto the platform ids used by `platforms`. + */ +export function normalizeEnvironmentPlatform(platform: string): string { + if (platform === 'macos') return 'darwin'; + if (platform === 'windows') return 'win32'; + return platform; +} + export function isDynamicShortcutId(id: string): id is `terminal-shortcut-${string}` { return id.startsWith('terminal-shortcut-'); } diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts index 1a8a1c4cc..86a33ab24 100644 --- a/tests/electronApiMock.ts +++ b/tests/electronApiMock.ts @@ -48,6 +48,8 @@ type ElectronApiMockOptions = { /** Seeded split layout for the session under test (panels:get-layout). */ initialLayout?: JsonObject | null; initialTerminalStates?: Record; + /** Value returned by `git:get-github-remote` (enables git SHA/issue links). */ + githubRemoteUrl?: string | null; initialAgentUsage?: JsonObject; initialUsageReport?: JsonObject; initialLeaderboardStatus?: JsonObject; @@ -77,6 +79,9 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc const pendingPermissions: PanePermissionRequest[] = []; const feedbackSubmissions: SubmitFeedbackRequest[] = []; const openedExternalUrls: string[] = []; + const panelCreates: JsonObject[] = []; + const panelUpdates: Array<{ panelId: string; updates: JsonObject }> = []; + const panelActivations: Array<{ sessionId: string; panelId: string }> = []; const clone = (value: T): T => structuredClone(value); interface MockPreferences { [key: string]: string; @@ -306,14 +311,30 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc if (prop === 'onConfigUpdated') { return (callback: MockEventCallback) => subscribe('config:updated', callback); } + if (prop === 'onPanelCreated') { + return (callback: MockEventCallback) => subscribe('panel:created', callback); + } return () => unsubscribe; }, }); - const invoke = (channel: string, key?: string, value?: string) => { + const invoke = (channel: string, key?: string, value?: string | JsonObject) => { if (channel === 'panels:get-layout') { return success(clone(mockOptions.initialLayout ?? null)); } + if (channel === 'panels:update') { + // This body runs inside the page (addInitScript), so no imported helpers are available. + const updates = value instanceof Object && !Array.isArray(value) ? value : undefined; + if (key && updates) { + panelUpdates.push({ panelId: key, updates: clone(updates) }); + const panel = mockPanels.find((candidate) => candidate.id === key); + if (panel) Object.assign(panel, clone(updates)); + } + return success(); + } + if (channel === 'git:get-github-remote') { + return success(mockOptions.githubRemoteUrl ?? null); + } if (channel === 'panels:shouldAutoCreate') { // Fixtures seed their own panels; the app must not grow a terminal. // The caller reads the bare boolean, not an IPC envelope. @@ -335,8 +356,9 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc return Promise.resolve({ success: false, error }); } if (key) { - preferences[key] = value ?? ''; - preferenceWrites.push({ key, value: value ?? '' }); + const stored = value instanceof Object ? '' : (value ?? ''); + preferences[key] = stored; + preferenceWrites.push({ key, value: stored }); } return success(); } @@ -628,8 +650,16 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc metadata: { createdAt: now, lastActiveAt: now, position: mockPanels.length }, }; mockPanels.push(panel); + panelCreates.push(clone(panel)); + // The main process broadcasts every created panel; SessionView relies on it + // to place panels created outside its own create path into the layout. + setTimeout(() => emit('panel:created', clone(panel)), 0); return success(clone(panel)); }, + setActivePanel: (sessionId: string, panelId: string) => { + panelActivations.push({ sessionId, panelId }); + return success(); + }, shouldAutoCreate: () => success(false), }), permissions: namespace({ @@ -951,6 +981,18 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc getOpenedExternalUrls() { return clone(openedExternalUrls); }, + getPanelCreates() { + return clone(panelCreates); + }, + getPanelUpdates() { + return clone(panelUpdates); + }, + getPanelActivations() { + return clone(panelActivations); + }, + getPanels() { + return clone(mockPanels); + }, getPreferenceWrites() { return clone(preferenceWrites); }, diff --git a/tests/launch-shortcuts.spec.ts b/tests/launch-shortcuts.spec.ts new file mode 100644 index 000000000..a4fb1bdc8 --- /dev/null +++ b/tests/launch-shortcuts.spec.ts @@ -0,0 +1,95 @@ +import { expect, test, type Page } from '@playwright/test'; +import type { JsonObject } from '../shared/validation/boundaryDecoder'; +import { AGENT_LAUNCH_PRESETS } from '../shared/constants/agentLaunchPresets'; +import { installElectronApiMock } from './electronApiMock'; + +type LaunchMock = { + getPanelCreates: () => JsonObject[]; +}; + +const project = { + id: 610, + name: 'Launch shortcut fixture', + path: '/tmp/launch-shortcut-fixture', + active: true, + environment: 'linux', + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), +}; + +const baseSession = { + prompt: 'Verify agent launch shortcuts', + status: 'stopped', + createdAt: new Date(0).toISOString(), + lastActivity: new Date(0).toISOString(), + output: [], + jsonMessages: [], + isRunning: false, + permissionMode: 'ignore', + projectId: project.id, + isFavorite: false, + toolType: 'none', + archived: false, +}; + +const worktreeSession = { ...baseSession, id: 'launch-worktree', name: 'Launch worktree pane', worktreePath: `${project.path}/wt`, displayOrder: 0 }; +const mainSession = { ...baseSession, id: 'launch-main', name: 'Launch main repo', worktreePath: project.path, isMainRepo: true, displayOrder: 1 }; + +// A pinned (permanent) bottom terminal plus one tab terminal, as the popover spec seeds. +const terminalPanels = (sessionId: string, prefix: string) => ['Bottom Terminal', 'Tab Terminal'].map((title, index) => ({ + id: `${prefix}-${index}`, + sessionId, + type: 'terminal', + title, + state: { isActive: index === 1, hasBeenViewed: true, customState: { isInitialized: true } }, + metadata: { createdAt: new Date(index).toISOString(), lastActiveAt: new Date(index).toISOString(), position: index, permanent: index === 0 }, +})); +const allPanels = [...terminalPanels(worktreeSession.id, 'wt'), ...terminalPanels(mainSession.id, 'main')]; + +async function panelCreates(page: Page): Promise { + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + return page.evaluate(() => (window as typeof window & { __paneTestElectronMock: LaunchMock }).__paneTestElectronMock.getPanelCreates()); +} + +async function openSession(page: Page, sessionName: string) { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.getByRole('button', { name: /^Expand repository Launch shortcut fixture$/ }).click(); + await page.getByRole('button', { name: sessionName, exact: true }).click(); + await expect(page.locator('.xterm-screen').first()).toBeVisible({ timeout: 15_000 }); + await page.mouse.click(20, 20); +} + +for (const preset of AGENT_LAUNCH_PRESETS) { + for (const view of [worktreeSession, mainSession]) { + test(`remapped ${preset.title} launch creates exactly one terminal in ${view.isMainRepo ? 'the main-repo view' : 'a worktree pane'}`, async ({ page }) => { + await installElectronApiMock(page, { + platform: 'linux', + initialConfig: { keyboardShortcutOverrides: { [preset.hotkeyId]: 'mod+alt+7' } }, + initialProjects: [project], + initialSessions: [worktreeSession, mainSession], + initialPanels: allPanels, + initialTerminalStates: Object.fromEntries(allPanels.map((panel) => [panel.id, { scrollbackBuffer: 'ready\r\n' }])), + activeProjectId: project.id, + }); + await openSession(page, view.name); + const before = (await panelCreates(page)).length; + + // The default chord no longer launches the agent. + await page.keyboard.press(`Control+Alt+${preset.hotkeyId === 'add-tool-terminal-claude' ? '3' : preset.hotkeyId === 'add-tool-terminal-codex' ? '4' : '5'}`); + await page.waitForTimeout(300); + expect((await panelCreates(page)).length).toBe(before); + + await page.keyboard.press('Control+Alt+7'); + await expect.poll(async () => (await panelCreates(page)).length).toBe(before + 1); + const created = (await panelCreates(page)).at(-1); + expect(created).toMatchObject({ + sessionId: view.id, + type: 'terminal', + title: preset.title, + state: { customState: { initialCommand: preset.command } }, + }); + await page.waitForTimeout(300); + expect((await panelCreates(page)).length).toBe(before + 1); + }); + } +} diff --git a/tests/settings.spec.ts b/tests/settings.spec.ts index 9ed8c48ec..0f30383df 100644 --- a/tests/settings.spec.ts +++ b/tests/settings.spec.ts @@ -144,6 +144,58 @@ test.describe('Settings', () => { await expect(page.getByText('Open Command Palette')).toBeVisible(); }); + test('records, blocks conflicting, and resets global key bindings', async ({ page }) => { + await bootSettings(page, { + initialConfig: { terminalShortcuts: [{ id: 'snip', label: 'Lint snippet', key: 'l', text: 'pnpm lint', enabled: true }] }, + }); + await page.getByRole('button', { name: 'Shortcuts', exact: true }).click(); + const map = page.locator('[data-setting-id="keyboard-shortcut-map"]'); + const claudeRow = map.locator('[data-shortcut-id="add-tool-terminal-claude"]'); + const apply = map.getByRole('button', { name: 'Apply' }); + await expect(apply).toBeDisabled(); + + // A chord already used by an enabled snippet is an active conflict. + await claudeRow.getByRole('button', { name: 'Record shortcut for Add Claude Code' }).click(); + await page.keyboard.press('Control+Alt+L'); + await expect(claudeRow.getByRole('alert')).toContainText('is also bound to Lint snippet'); + await expect(apply).toBeDisabled(); + await expect(map.getByText('Resolve conflicts to apply.')).toBeVisible(); + + // A terminal-reserved chord is refused with live text and recording stays armed. + await claudeRow.getByRole('button', { name: 'Record shortcut for Add Claude Code' }).click(); + await page.keyboard.press('Control+Alt+F'); + await expect(claudeRow.getByRole('status')).toContainText('Reserved by the terminal'); + await page.keyboard.press('Control+Alt+Y'); + await expect(claudeRow.getByRole('alert')).toHaveCount(0); + await expect(claudeRow.getByText('Customized')).toBeVisible(); + await expect(apply).toBeEnabled(); + await apply.click(); + + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + const updates = await page.evaluate(() => ( + window as typeof window & { __paneTestElectronMock: SettingsMock } + ).__paneTestElectronMock.getConfigUpdates()); + expect(updates).toContainEqual({ keyboardShortcutOverrides: { 'add-tool-terminal-claude': 'mod+alt+y' } }); + + await map.getByRole('button', { name: 'Reset all to defaults' }).click(); + await page.getByRole('dialog', { name: 'Reset all key bindings?' }).getByRole('button', { name: 'Reset all' }).click(); + await expect(claudeRow.getByText('Customized')).toHaveCount(0); + }); + + test('shows the effective remapped chord in the shortcut reference', async ({ page }) => { + await bootSettings(page, { + initialConfig: { keyboardShortcutOverrides: { 'add-tool-terminal-codex': 'mod+alt+y', 'toggle-sidebar': null } }, + }); + await page.getByRole('button', { name: 'Shortcuts', exact: true }).click(); + await page.getByRole('button', { name: 'View all Pane keyboard shortcuts' }).click(); + const dialog = page.getByRole('dialog', { name: 'Keyboard Shortcuts' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Add Codex')).toBeVisible(); + await expect(dialog.locator('kbd', { hasText: 'Y' })).toHaveCount(1); + await expect(dialog.getByText('unassigned')).toHaveCount(1); + await expect(dialog.getByText('Send Input / Continue Conversation')).toBeVisible(); + }); + test('guards the shortcut reference when snippet edits are dirty', async ({ page }) => { await bootSettings(page); await page.getByRole('button', { name: 'Shortcuts', exact: true }).click(); diff --git a/tests/shortcuts-settings.spec.ts b/tests/shortcuts-settings.spec.ts new file mode 100644 index 000000000..b9ea38110 --- /dev/null +++ b/tests/shortcuts-settings.spec.ts @@ -0,0 +1,252 @@ +import { expect, test, type Page } from '@playwright/test'; +import type { JsonObject } from '../shared/validation/boundaryDecoder'; +import { KEYBOARD_SHORTCUT_CATALOG } from '../shared/constants/keyboardShortcuts'; +import { installElectronApiMock } from './electronApiMock'; +import { expectNoAxeViolations } from './axeTest'; + +type ShortcutsMock = { + getConfigUpdates: () => JsonObject[]; + failNextConfigUpdate: (error: string) => void; +}; + +const project = { + id: 620, + name: 'Shortcut settings fixture', + path: '/tmp/shortcut-settings-fixture', + active: true, + environment: 'linux', + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), +}; + +const session = { + id: 'shortcut-settings-session', + name: 'Shortcut settings pane', + worktreePath: `${project.path}/wt`, + prompt: 'Verify shortcut settings', + status: 'stopped', + createdAt: new Date(0).toISOString(), + lastActivity: new Date(0).toISOString(), + output: [], + jsonMessages: [], + isRunning: false, + permissionMode: 'ignore', + projectId: project.id, + displayOrder: 0, + isFavorite: false, + toolType: 'none', + archived: false, +}; + +const panels = ['Bottom Terminal', 'Tab Terminal'].map((title, index) => ({ + id: `shortcut-terminal-${index}`, + sessionId: session.id, + type: 'terminal', + title, + state: { isActive: index === 1, hasBeenViewed: true, customState: { isInitialized: true } }, + metadata: { createdAt: new Date(index).toISOString(), lastActiveAt: new Date(index).toISOString(), position: index, permanent: index === 0 }, +})); +const terminalStates = Object.fromEntries(panels.map((panel) => [panel.id, { scrollbackBuffer: 'ready\r\n' }])); + +const REFERENCE_ROW_COUNT = 7; +const SNIPPET = { id: 'snip', label: 'Lint snippet', key: 'l', text: 'pnpm lint', enabled: true }; +const CUSTOM = { name: 'Deploy', command: 'pnpm deploy' }; + +async function mock(page: Page, initialConfig: JsonObject = {}, options: Parameters[1] = {}) { + await installElectronApiMock(page, { + initialConfig: { terminalShortcuts: [SNIPPET], customCommands: [CUSTOM], ...initialConfig }, + ...options, + }); +} + +async function openShortcuts(page: Page) { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await expect(page.locator('[data-testid="sidebar"]').first()).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Settings' }).first().click(); + await expect(page.getByRole('dialog', { name: 'Pane Settings' })).toBeVisible(); + await page.getByRole('button', { name: 'Shortcuts', exact: true }).click(); + const map = page.locator('[data-setting-id="keyboard-shortcut-map"]'); + await expect(map).toBeVisible(); + return map; +} + +async function configUpdates(page: Page): Promise { + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + return page.evaluate(() => (window as typeof window & { __paneTestElectronMock: ShortcutsMock }).__paneTestElectronMock.getConfigUpdates()); +} + +const expectedRowCount = KEYBOARD_SHORTCUT_CATALOG.filter((entry) => !entry.dynamicSlot).length + 1 /* configured custom slot */ + 1 /* snippet */; + +test('shows the complete inventory regardless of the current view, with an axe-clean page', async ({ page }) => { + await mock(page, { keyboardShortcutOverrides: { 'open-settings': 'nope', 'toggle-sidebar': null } }); + const map = await openShortcuts(page); + + await expect(map.locator('[data-shortcut-id]')).toHaveCount(expectedRowCount); + await expect(map.getByRole('rowgroup', { name: 'Terminal and native shortcuts' }).getByRole('row')).toHaveCount(REFERENCE_ROW_COUNT); + await expect(map.locator('[data-shortcut-id="open-settings"]').getByText('Invalid — using default')).toBeVisible(); + // Recorder button and state tag both read "Unassigned". + await expect(map.locator('[data-shortcut-id="toggle-sidebar"]').getByText('Unassigned', { exact: true })).toHaveCount(2); + await expect(map.locator('[data-shortcut-id="add-tool-custom-0"]')).toContainText('Add Deploy'); + await expect(map.locator('[data-shortcut-id="terminal-shortcut-snip"]')).toContainText('Lint snippet'); + await expectNoAxeViolations(page); + + await map.getByRole('textbox', { name: 'Search shortcuts' }).fill('codex'); + await expect(map.locator('[data-shortcut-id]')).toHaveCount(1); + await expect(map.locator('[data-shortcut-id="add-tool-terminal-codex"]')).toBeVisible(); +}); + +test('shows the same inventory from a Project view with a WSL project on a Windows host', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'platform', { configurable: true, get: () => 'Win32' }); + }); + await mock(page, {}, { + platform: 'win32', + initialProjects: [{ ...project, environment: 'wsl' }], + initialSessions: [session], + initialPanels: panels, + initialTerminalStates: terminalStates, + activeProjectId: project.id, + }); + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.getByRole('button', { name: /^Expand repository Shortcut settings fixture$/ }).click(); + await page.getByRole('button', { name: session.name, exact: true }).click(); + await expect(page.getByRole('tabpanel').locator('.xterm-screen').first()).toBeVisible({ timeout: 15_000 }); + await page.keyboard.press('Control+Alt+/'); + await expect(page.getByRole('dialog', { name: 'Pane Settings' })).toBeVisible(); + const map = page.locator('[data-setting-id="keyboard-shortcut-map"]'); + + await expect(map.locator('[data-shortcut-id]')).toHaveCount(expectedRowCount); + // The active project is WSL, so Cursor is available even though the host is Windows. + await expect(map.locator('[data-shortcut-id="add-tool-terminal-cursor"]').getByText('Unavailable on this platform')).toHaveCount(0); +}); + +test('marks Cursor unavailable but still editable on a native Windows host', async ({ page }) => { + await mock(page, {}, { platform: 'win32' }); + const map = await openShortcuts(page); + const cursor = map.locator('[data-shortcut-id="add-tool-terminal-cursor"]'); + await expect(cursor.getByText('Unavailable on this platform')).toBeVisible(); + await expect(cursor.getByRole('button', { name: 'Record shortcut for Add Cursor' })).toBeEnabled(); + + // A remap onto Cursor's chord still conflicts: bindings are validated globally. + const push = map.locator('[data-shortcut-id="git-push"]'); + await push.getByRole('button', { name: 'Record shortcut for Git: Push' }).click(); + await page.keyboard.press('Control+Alt+5'); + await expect(push.getByRole('alert')).toContainText('is also bound to Add Cursor'); + await expect(map.getByRole('button', { name: 'Apply' })).toBeDisabled(); +}); + +test('records with the keyboard only, cancels with Escape without closing Settings, and unassigns', async ({ page }) => { + await mock(page); + const map = await openShortcuts(page); + const row = map.locator('[data-shortcut-id="add-tool-terminal-claude"]'); + const record = row.getByRole('button', { name: 'Record shortcut for Add Claude Code' }); + + await record.focus(); + await page.keyboard.press('Enter'); + await expect(row.getByRole('button', { name: /Recording shortcut for Add Claude Code/ })).toBeVisible(); + await page.keyboard.press('Shift'); + await expect(row.getByText('Press a key with the modifier')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(row.getByText('Recording cancelled')).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'Pane Settings' })).toBeVisible(); + await expect(record).toBeFocused(); + + await page.keyboard.press('Enter'); + await page.keyboard.press('x'); + await expect(row.getByText(/must include Ctrl/)).toBeVisible(); + await page.keyboard.press('Backspace'); + await expect(row.getByText('Unassigned', { exact: true })).toHaveCount(2); + await expect(map.getByRole('button', { name: 'Apply' })).toBeEnabled(); + await map.getByRole('button', { name: 'Apply' }).click(); + expect(await configUpdates(page)).toContainEqual({ keyboardShortcutOverrides: { 'add-tool-terminal-claude': null } }); +}); + +test('names both owners for a snippet conflict and a custom-command conflict, then resets', async ({ page }) => { + await mock(page); + const map = await openShortcuts(page); + const apply = map.getByRole('button', { name: 'Apply' }); + const codex = map.locator('[data-shortcut-id="add-tool-terminal-codex"]'); + + await codex.getByRole('button', { name: 'Record shortcut for Add Codex' }).click(); + await page.keyboard.press('Control+Alt+L'); + await expect(codex.getByRole('alert')).toContainText('is also bound to Lint snippet (edit in Terminal snippets below)'); + await expect(map.locator('[data-shortcut-id="terminal-shortcut-snip"]').getByRole('alert')).toContainText('is also bound to Add Codex'); + await expect(apply).toBeDisabled(); + + await codex.getByRole('button', { name: 'Record shortcut for Add Codex' }).click(); + await page.keyboard.press('Control+Alt+6'); + await expect(codex.getByRole('alert')).toContainText('is also bound to Add Deploy'); + await expect(apply).toBeDisabled(); + + await codex.getByRole('button', { name: 'Reset Add Codex to default' }).click(); + await expect(codex.getByRole('alert')).toHaveCount(0); + await expect(apply).toBeDisabled(); +}); + +test('recording a row default removes the override instead of storing it', async ({ page }) => { + await mock(page, { keyboardShortcutOverrides: { 'add-tool-terminal-claude': 'mod+alt+y' } }); + const map = await openShortcuts(page); + const row = map.locator('[data-shortcut-id="add-tool-terminal-claude"]'); + await expect(row.getByText('Customized')).toBeVisible(); + await row.getByRole('button', { name: 'Record shortcut for Add Claude Code' }).click(); + await page.keyboard.press('Control+Alt+3'); + await expect(row.getByText('Customized')).toHaveCount(0); + await map.getByRole('button', { name: 'Apply' }).click(); + expect(await configUpdates(page)).toContainEqual({ keyboardShortcutOverrides: {} }); +}); + +test('a failed Apply keeps the draft, reports the error, and stays retryable', async ({ page }) => { + await mock(page); + const map = await openShortcuts(page); + const row = map.locator('[data-shortcut-id="git-pull"]'); + await row.getByRole('button', { name: 'Record shortcut for Git: Pull' }).click(); + await page.keyboard.press('Control+Alt+J'); + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + await page.evaluate(() => (window as typeof window & { __paneTestElectronMock: ShortcutsMock }).__paneTestElectronMock.failNextConfigUpdate('disk full')); + await map.getByRole('button', { name: 'Apply' }).click(); + await expect(map.getByText('disk full')).toBeVisible(); + await expect(row.getByText('Customized')).toBeVisible(); + await expect(map.getByRole('button', { name: 'Apply' })).toBeEnabled(); + await map.getByRole('button', { name: 'Apply' }).click(); + await expect(map.getByText('Saved', { exact: true })).toBeVisible(); + expect(await configUpdates(page)).toContainEqual({ keyboardShortcutOverrides: { 'git-pull': 'mod+alt+j' } }); +}); + +test('Help and the Add Tool menu show the effective chord after a remap and after reset', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window.navigator, 'platform', { configurable: true, get: () => 'Linux x86_64' }); + }); + await mock(page, { keyboardShortcutOverrides: { 'add-tool-terminal-codex': 'mod+alt+y' } }, { + initialProjects: [project], + initialSessions: [session], + initialPanels: panels, + initialTerminalStates: terminalStates, + activeProjectId: project.id, + }); + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.getByRole('button', { name: /^Expand repository Shortcut settings fixture$/ }).click(); + await page.getByRole('button', { name: session.name, exact: true }).click(); + await expect(page.getByRole('tabpanel').locator('.xterm-screen').first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('tabpanel').getByRole('status', { name: 'Loading terminal' })).toHaveCount(0, { timeout: 15_000 }); + await page.waitForTimeout(1_500); + await page.getByRole('button', { name: 'Add tool' }).first().click(); + await expect(page.getByRole('menu')).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /Codex/ })).toContainText('Ctrl+Alt+Y'); + await page.keyboard.press('Escape'); + + await page.keyboard.press('Control+Alt+/'); + await expect(page.getByRole('dialog', { name: 'Pane Settings' })).toBeVisible(); + const map = page.locator('[data-setting-id="keyboard-shortcut-map"]'); + await map.getByRole('button', { name: 'Reset all to defaults' }).click(); + await page.getByRole('dialog', { name: 'Reset all key bindings?' }).getByRole('button', { name: 'Reset all' }).click(); + await map.getByRole('button', { name: 'Apply' }).click(); + await expect(map.getByText('Saved', { exact: true })).toBeVisible(); + expect(await configUpdates(page)).toContainEqual({ keyboardShortcutOverrides: {} }); + + await page.getByRole('button', { name: 'View all Pane keyboard shortcuts' }).click(); + const help = page.getByRole('dialog', { name: 'Keyboard Shortcuts' }); + await expect(help).toBeVisible(); + await expect(help.getByText('Add Codex')).toBeVisible(); + await expect(help.locator('kbd', { hasText: /^Y$/ })).toHaveCount(0); + await expect(help.getByText('Send Input / Continue Conversation')).toBeVisible(); +}); From 5ce8ff4b84c5897914b0860ca52b84f7fb8cd927 Mon Sep 17 00:00:00 2001 From: Tyler Brown Date: Sat, 29 Aug 2026 15:39:47 -0700 Subject: [PATCH 3/7] feat: route terminal HTTP(S) links through one gesture classifier and router Phase 3 of configurable keybindings. Auto-detected URLs, OSC-8 hyperlinks, and git SHA/issue links share one pure classifier and router: Primary+Shift opens a validated, credential-free HTTP(S) URL in the session's Browser panel (reusing the first one or creating one through a single create-or-navigate helper) and falls back to the external browser exactly once where no Browser surface exists (Project/main-repo terminals, Pane Chat, no session context); Primary opens externally; the macOS Control-click alias applies only to unshifted primary button activations; Alt and non-primary buttons never qualify; rejected URLs on the Browser branch open nothing. Hover text names the available gestures per provider and platform. The browser-panel:navigate event path is removed; BrowserPanel navigates from panel state alone, with a monotonic navigationNonce so a repeated same-URL request reloads. The selection popover shows "Open in Browser" only where a Browser surface exists; HTML previews reuse the same helper. Also fixes a pre-existing off-by-one in the file and git link providers: xterm passes a 1-based buffer line to provideLinks, so they read the row below and emitted ranges one row off, which meant their links never matched the pointer. Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9 --- .../src/components/panels/TerminalPanel.tsx | 27 +- .../panels/browser/BrowserPanel.tsx | 44 ++-- .../panels/editor/previewHtmlFile.ts | 33 +-- .../components/terminal/SelectionPopover.tsx | 17 +- .../terminal/hooks/useTerminalLinks.ts | 119 ++++----- .../linkProviders/fileLinkProvider.ts | 8 +- .../linkProviders/gitLinkProvider.test.ts | 48 ++++ .../terminal/linkProviders/gitLinkProvider.ts | 41 ++-- .../terminal/linkProviders/types.ts | 5 +- .../components/terminal/linkRouting.test.ts | 128 ++++++++++ .../src/components/terminal/linkRouting.ts | 123 ++++++++++ .../services/browserPanelNavigation.test.ts | 120 +++++++++ .../src/services/browserPanelNavigation.ts | 108 ++++++++ shared/types/panels.ts | 6 + tests/terminal-links.spec.ts | 231 ++++++++++++++++++ 15 files changed, 890 insertions(+), 168 deletions(-) create mode 100644 frontend/src/components/terminal/linkProviders/gitLinkProvider.test.ts create mode 100644 frontend/src/components/terminal/linkRouting.test.ts create mode 100644 frontend/src/components/terminal/linkRouting.ts create mode 100644 frontend/src/services/browserPanelNavigation.test.ts create mode 100644 frontend/src/services/browserPanelNavigation.ts create mode 100644 tests/terminal-links.spec.ts diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index e291bfd7d..036e68d72 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -642,6 +642,10 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv handleShowInExplorer, closeFilePopover, closeSelectionPopover, + routeUrlRef, + showLinkTooltipRef, + closeTooltipRef, + browserAvailable, } = useTerminalLinks(terminalInstance, { workingDirectory: workingDirectory || '', sessionId: sessionId || panel.sessionId, @@ -995,11 +999,13 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv minimumContrastRatio: getMinimumContrastRatio(terminalRuntimeRef.current.highContrast), macOptionIsMeta: false, linkHandler: { - activate: (_event, uri) => { - void window.electronAPI.openExternal(uri).catch((error) => { - console.error('[TerminalPanel] Failed to open terminal link:', error); - }); + // OSC-8 hyperlinks: plain click keeps opening externally; modified + // clicks go through the shared router like every other URL source. + activate: (event, uri) => { + void routeUrlRef.current(uri, event, 'osc8'); }, + hover: (event, uri) => showLinkTooltipRef.current(event, uri, 'osc8'), + leave: () => closeTooltipRef.current(), }, }); devLog.debug('[TerminalPanel] XTerm instance created:', !!terminal); @@ -1120,12 +1126,12 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv try { const { WebLinksAddon: WebLinksAddonImpl } = await import('@xterm/addon-web-links'); if (!disposed) { - const isMac = navigator.platform.toUpperCase().includes('MAC'); const webLinksAddon = new WebLinksAddonImpl((event, uri) => { - // Only open link if Ctrl (Windows/Linux) or Cmd (Mac) is held - if (isMac ? event.metaKey : event.ctrlKey) { - window.electronAPI.openExternal(uri); - } + // Auto-detected URLs: no plain-click behavior; the router gates on modifiers. + void routeUrlRef.current(uri, event, 'web-links'); + }, { + hover: (event, uri) => showLinkTooltipRef.current(event, uri, 'web-links'), + leave: () => closeTooltipRef.current(), }); terminal.loadAddon(webLinksAddon); webLinksAddonRef.current = webLinksAddon; @@ -1837,7 +1843,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv setIsInitialized(false); }; - }, [panel.id]); // Only depend on panel.id to prevent re-initialization on session switch + }, [panel.id, routeUrlRef, showLinkTooltipRef, closeTooltipRef]); // Only depend on panel.id (the refs are stable) to prevent re-initialization on session switch // Shared activation refresh for both power modes: fires on tab activation and // window refocus (activationVisible = panelVisible && windowFocused). Initial, @@ -2118,6 +2124,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv workingDirectory={workingDirectory} sessionId={panel.sessionId} isRemoteMode={isRemoteMode} + browserAvailable={browserAvailable} onOpenInBrowser={handleOpenInBrowser} onClose={closeSelectionPopover} /> diff --git a/frontend/src/components/panels/browser/BrowserPanel.tsx b/frontend/src/components/panels/browser/BrowserPanel.tsx index 29fba3b65..eafaf2da0 100644 --- a/frontend/src/components/panels/browser/BrowserPanel.tsx +++ b/frontend/src/components/panels/browser/BrowserPanel.tsx @@ -4,6 +4,7 @@ import type { ToolPanel, BrowserPanelState } from '../../../../../shared/types/p import { cn } from '../../../utils/cn'; import { panelApi } from '../../../services/panelApi'; import { usePanelStore } from '../../../stores/panelStore'; +import { resolveBrowserNavigation } from '../../../services/browserPanelNavigation'; import { useSessionStore } from '../../../stores/sessionStore'; import { useResizable } from '../../../hooks/useResizable'; import { normalizeUrl } from './browserUrl'; @@ -23,7 +24,10 @@ const BrowserPanel: React.FC = ({ panel, isActive }) => { const [canGoForward, setCanGoForward] = useState(false); const [devToolsOpen, setDevToolsOpen] = useState(false); // SAFETY: The panel type discriminator determines the corresponding custom-state shape. - const currentUrlFromPanelState = (panel.state.customState as BrowserPanelState | undefined)?.currentUrl; + const browserStateFromPanel = panel.state.customState as BrowserPanelState | undefined; + const currentUrlFromPanelState = browserStateFromPanel?.currentUrl; + const navigationNonceFromPanelState = browserStateFromPanel?.navigationNonce; + const lastNavigationNonceRef = useRef(navigationNonceFromPanelState); const webviewRef = useRef(null); const devToolsPlaceholderRef = useRef(null); @@ -110,10 +114,18 @@ const BrowserPanel: React.FC = ({ panel, isActive }) => { persistState(normalized); }, [persistState]); + // Single navigation trigger for requests from other surfaces (terminal links, + // selection popover, HTML preview): they write panel state through + // openUrlInSessionBrowser, and a fresh nonce for the same URL means reload. useEffect(() => { - if (!currentUrlFromPanelState || currentUrlFromPanelState === url) return; - navigateTo(currentUrlFromPanelState); - }, [currentUrlFromPanelState, navigateTo, url]); + const decision = resolveBrowserNavigation( + { url, nonce: lastNavigationNonceRef.current }, + { currentUrl: currentUrlFromPanelState, nonce: navigationNonceFromPanelState }, + ); + lastNavigationNonceRef.current = navigationNonceFromPanelState; + if (decision === 'navigate' && currentUrlFromPanelState) navigateTo(currentUrlFromPanelState); + else if (decision === 'reload') webviewRef.current?.reload(); + }, [currentUrlFromPanelState, navigationNonceFromPanelState, navigateTo, url]); const handleBack = () => { webviewRef.current?.goBack(); @@ -276,30 +288,6 @@ const BrowserPanel: React.FC = ({ panel, isActive }) => { return () => window.removeEventListener('browser-panel:popup-requested', handler); }, [panel.id, panel.sessionId, addPanel, setActivePanelInStore]); - // Listen for browser-panel:navigate CustomEvents (e.g., from SelectionPopover "Open in Browser") - // Uses stopImmediatePropagation so only the first browser panel for a session handles the event, - // preventing duplicate navigation when multiple browser panels exist. - // Also auto-focuses this browser panel so the user sees the navigated page immediately. - useEffect(() => { - const handler = (e: Event) => { - // SAFETY: The registered DOM/custom-event source establishes this target and detail shape. - const customEvent = e as CustomEvent<{ url: string; sessionId: string }>; - if (customEvent.detail.sessionId === panel.sessionId) { - e.stopImmediatePropagation(); - if (customEvent.detail.url === url) { - webviewRef.current?.reload(); - } else { - navigateTo(customEvent.detail.url); - } - // Auto-focus this browser panel - setActivePanelInStore(panel.sessionId, panel.id); - panelApi.setActivePanel(panel.sessionId, panel.id).catch(() => {}); - } - }; - window.addEventListener('browser-panel:navigate', handler); - return () => window.removeEventListener('browser-panel:navigate', handler); - }, [panel.sessionId, panel.id, setActivePanelInStore, navigateTo, url]); - // Hide/show DevTools overlay when switching between panel tabs. // Close the WebContentsView when inactive so it doesn't cover other panels, // and re-open it when this panel becomes active again. diff --git a/frontend/src/components/panels/editor/previewHtmlFile.ts b/frontend/src/components/panels/editor/previewHtmlFile.ts index 4058bfaf4..b07cf80b9 100644 --- a/frontend/src/components/panels/editor/previewHtmlFile.ts +++ b/frontend/src/components/panels/editor/previewHtmlFile.ts @@ -1,10 +1,8 @@ /** * Opens (or re-targets) the session's Browser panel at an HTML file. */ -import type { BrowserPanelState, ToolPanel } from '../../../../../shared/types/panels'; import { boundary, decodeBoundary } from '../../../../../shared/validation/boundaryDecoder'; -import { panelApi } from '../../../services/panelApi'; -import { usePanelStore } from '../../../stores/panelStore'; +import { openUrlInSessionBrowser } from '../../../services/browserPanelNavigation'; const filePathResponseSchema = boundary.object({ success: boundary.boolean, @@ -21,32 +19,5 @@ export async function previewHtmlFileInBrowser(sessionId: string, filePath: stri throw new Error(result.error || 'Failed to resolve HTML preview URL'); } - const store = usePanelStore.getState(); - const existingPanel = store.getSessionPanels(sessionId).find((candidate) => candidate.type === 'browser'); - const title = filePath.split('/').pop() || 'Browser'; - let browserPanel: ToolPanel; - - if (existingPanel) { - // SAFETY: The browser panel type discriminator establishes BrowserPanelState. - const existingCustomState = (existingPanel.state.customState ?? {}) as BrowserPanelState; - browserPanel = { - ...existingPanel, - title, - state: { ...existingPanel.state, customState: { ...existingCustomState, currentUrl: result.url } }, - }; - await panelApi.updatePanel(browserPanel.id, { title, state: browserPanel.state }); - store.updatePanelState(browserPanel); - } else { - browserPanel = await panelApi.createPanel({ - sessionId, - type: 'browser', - title, - initialState: { customState: { currentUrl: result.url } }, - }); - store.addPanel(browserPanel); - } - - store.setActivePanel(sessionId, browserPanel.id); - await panelApi.setActivePanel(sessionId, browserPanel.id); - window.dispatchEvent(new CustomEvent('browser-panel:navigate', { detail: { url: result.url, sessionId } })); + await openUrlInSessionBrowser(sessionId, result.url, { title: filePath.split('/').pop() || 'Browser' }); } diff --git a/frontend/src/components/terminal/SelectionPopover.tsx b/frontend/src/components/terminal/SelectionPopover.tsx index 0aae8a062..c1859b90c 100644 --- a/frontend/src/components/terminal/SelectionPopover.tsx +++ b/frontend/src/components/terminal/SelectionPopover.tsx @@ -14,7 +14,9 @@ export interface SelectionPopoverProps { workingDirectory?: string; sessionId?: string; isRemoteMode?: boolean; - onOpenInBrowser?: (url: string) => void | Promise; + /** Whether this session can host a Pane Browser panel; the button is hidden otherwise. */ + browserAvailable: boolean; + onOpenInBrowser: (url: string) => void | Promise; onClose: () => void; } @@ -62,6 +64,7 @@ export const SelectionPopover: React.FC = ({ workingDirectory, sessionId, isRemoteMode = false, + browserAvailable, onOpenInBrowser, onClose, }) => { @@ -93,15 +96,9 @@ export const SelectionPopover: React.FC = ({ }; const handleOpenInBrowser = async () => { - if (urlMatch && sessionId) { + if (urlMatch && sessionId && browserAvailable) { try { - if (onOpenInBrowser) { - await onOpenInBrowser(urlMatch[0]); - } else { - window.dispatchEvent(new CustomEvent('browser-panel:navigate', { - detail: { url: urlMatch[0], sessionId } - })); - } + await onOpenInBrowser(urlMatch[0]); } catch (error) { console.error('Failed to open URL in browser panel:', error); } finally { @@ -145,7 +142,7 @@ export const SelectionPopover: React.FC = ({ Copy - {isUrl && sessionId && ( + {isUrl && sessionId && browserAvailable && ( diff --git a/frontend/src/components/terminal/hooks/useTerminalLinks.ts b/frontend/src/components/terminal/hooks/useTerminalLinks.ts index 97773d4f2..992ffdebe 100644 --- a/frontend/src/components/terminal/hooks/useTerminalLinks.ts +++ b/frontend/src/components/terminal/hooks/useTerminalLinks.ts @@ -2,11 +2,17 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { Terminal } from '@xterm/xterm'; import type { LinkProviderConfig } from '../linkProviders/types'; import { registerAllLinkProviders } from '../linkProviders'; -import { panelApi } from '../../../services/panelApi'; import { openFileInEditor } from '../../../services/openFileInEditor'; -import { usePanelStore } from '../../../stores/panelStore'; +import { canHostSessionBrowser, openUrlInSessionBrowser } from '../../../services/browserPanelNavigation'; import { useConfigStore } from '../../../stores/configStore'; -import type { BrowserPanelState, ToolPanel } from '../../../../../shared/types/panels'; +import { useSession } from '../../../contexts/useSession'; +import { isMac } from '../../../utils/platformUtils'; +import { + describeUrlGestures, + routeUrlActivation, + type LinkActivationEventLike, + type LinkProvider, +} from '../linkRouting'; export interface UseTerminalLinksConfig { workingDirectory: string; @@ -36,14 +42,6 @@ interface SelectionPopoverState { text: string; } -function getBrowserPanelTitle(url: string): string { - try { - return new URL(url).host || 'Browser'; - } catch { - return 'Browser'; - } -} - export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalLinksConfig) { const [tooltip, setTooltip] = useState({ visible: false, @@ -71,6 +69,38 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL const [githubRemoteUrl, setGithubRemoteUrl] = useState(null); const isRemoteMode = useConfigStore((state) => state.config?.remoteDaemon?.client.mode === 'remote'); const mousePositionRef = useRef({ x: 0, y: 0 }); + const sessionContext = useSession(); + + // A Browser panel can only be hosted by an ordinary worktree Session. Project + // (main-repo) terminals, Pane Chat, and terminals without a session context + // cannot show one, so Primary+Shift falls back to the external browser there + // instead of creating a hidden panel. + const browserAvailable = canHostSessionBrowser(sessionContext?.session); + const urlHoverHint = describeUrlGestures(isMac(), browserAvailable, 'git'); + const hoverHintFor = useCallback( + (provider: LinkProvider) => describeUrlGestures(isMac(), browserAvailable, provider), + [browserAvailable], + ); + + const routeUrl = useCallback( + (url: string, event: LinkActivationEventLike, provider: LinkProvider) => routeUrlActivation(url, event, provider, { + isMac: isMac(), + browserAvailable, + openExternal: async (target) => { + try { + await window.electronAPI.openExternal(target); + } catch (error) { + console.error('[useTerminalLinks] Failed to open link externally:', error); + } + }, + openInPaneBrowser: async (target) => { await openUrlInSessionBrowser(config.sessionId, target); }, + }), + [browserAvailable, config.sessionId], + ); + // xterm's linkHandler and WebLinksAddon are created once per terminal, so + // they read the latest router through a ref rather than re-creating xterm. + const routeUrlRef = useRef(routeUrl); + routeUrlRef.current = routeUrl; // Track mouse position for selection popover const onMouseMove = useCallback((e: React.MouseEvent) => { @@ -108,9 +138,10 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL onShowFilePopover: (event, path, line) => { setFilePopover({ visible: true, x: event.clientX, y: event.clientY, path, line: line ?? 0 }); }, - onOpenUrl: (url) => { - window.electronAPI.openExternal(url); + onActivateUrl: (url, event) => { + void routeUrlRef.current(url, event, 'git'); }, + urlHoverHint, }; const disposables = registerAllLinkProviders(providerConfig); @@ -118,7 +149,7 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL return () => { disposables.forEach((d) => d.dispose()); }; - }, [terminal, config.workingDirectory, githubRemoteUrl]); + }, [terminal, config.workingDirectory, githubRemoteUrl, urlHoverHint]); // Listen for selection changes useEffect(() => { @@ -139,11 +170,6 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL }; }, [terminal]); - // Get panel store methods - const addPanel = usePanelStore((state) => state.addPanel); - const setActivePanelInStore = usePanelStore((state) => state.setActivePanel); - const updatePanelState = usePanelStore((state) => state.updatePanelState); - // File popover action handlers const handleOpenInEditor = useCallback(async () => { const { path, line } = filePopover; @@ -192,51 +218,22 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL }, [filePopover, isRemoteMode, config.sessionId]); const handleOpenInBrowser = useCallback(async (url: string) => { - const panels = usePanelStore.getState().getSessionPanels(config.sessionId); - const existingPanel = panels.find((candidate) => candidate.type === 'browser'); - - let browserPanel: ToolPanel; - if (existingPanel) { - // SAFETY: The panel type discriminator determines the corresponding custom-state shape. - const existingCustomState = (existingPanel.state.customState ?? {}) as BrowserPanelState; - browserPanel = { - ...existingPanel, - state: { - ...existingPanel.state, - customState: { - ...existingCustomState, - currentUrl: url, - }, - }, - }; - await panelApi.updatePanel(browserPanel.id, { state: browserPanel.state }); - updatePanelState(browserPanel); - } else { - browserPanel = await panelApi.createPanel({ - sessionId: config.sessionId, - type: 'browser', - title: getBrowserPanelTitle(url), - initialState: { - customState: { - currentUrl: url, - }, - }, - }); - addPanel(browserPanel); - } - - setActivePanelInStore(config.sessionId, browserPanel.id); - await panelApi.setActivePanel(config.sessionId, browserPanel.id); - - window.dispatchEvent(new CustomEvent('browser-panel:navigate', { - detail: { url, sessionId: config.sessionId }, - })); - }, [config.sessionId, addPanel, setActivePanelInStore, updatePanelState]); + await openUrlInSessionBrowser(config.sessionId, url); + }, [config.sessionId]); const closeTooltip = useCallback(() => { setTooltip((prev) => ({ ...prev, visible: false })); }, []); + const showLinkTooltip = useCallback((event: MouseEvent, text: string, provider: LinkProvider) => { + setTooltip({ visible: true, x: event.clientX, y: event.clientY, text, hint: hoverHintFor(provider) }); + }, [hoverHintFor]); + // Consumed by xterm handlers created once per terminal. + const showLinkTooltipRef = useRef(showLinkTooltip); + showLinkTooltipRef.current = showLinkTooltip; + const closeTooltipRef = useRef(closeTooltip); + closeTooltipRef.current = closeTooltip; + const closeFilePopover = useCallback(() => { setFilePopover((prev) => ({ ...prev, visible: false })); }, []); @@ -247,6 +244,10 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL return { onMouseMove, + routeUrlRef, + showLinkTooltipRef, + closeTooltipRef, + browserAvailable, tooltip, filePopover, isRemoteMode, diff --git a/frontend/src/components/terminal/linkProviders/fileLinkProvider.ts b/frontend/src/components/terminal/linkProviders/fileLinkProvider.ts index 4e1e3ed12..86277db0c 100644 --- a/frontend/src/components/terminal/linkProviders/fileLinkProvider.ts +++ b/frontend/src/components/terminal/linkProviders/fileLinkProvider.ts @@ -94,8 +94,10 @@ export function createFileLinkProvider(config: LinkProviderConfig): ILinkProvide } return { + // xterm passes a 1-based buffer line; buffer.getLine is 0-based and link + // ranges are 1-based, so the range y is the line number as given. provideLinks(lineNumber: number, callback: (links: ILink[] | undefined) => void) { - const line = config.terminal.buffer.active.getLine(lineNumber); + const line = config.terminal.buffer.active.getLine(lineNumber - 1); if (!line) { callback(undefined); return; @@ -118,8 +120,8 @@ export function createFileLinkProvider(config: LinkProviderConfig): ILinkProvide links.push({ range: { - start: { x: match.index + 1, y: lineNumber + 1 }, - end: { x: match.index + match[0].length + 1, y: lineNumber + 1 }, + start: { x: match.index + 1, y: lineNumber }, + end: { x: match.index + match[0].length + 1, y: lineNumber }, }, text: rawPath, activate: (event: MouseEvent) => { diff --git a/frontend/src/components/terminal/linkProviders/gitLinkProvider.test.ts b/frontend/src/components/terminal/linkProviders/gitLinkProvider.test.ts new file mode 100644 index 000000000..e33734a85 --- /dev/null +++ b/frontend/src/components/terminal/linkProviders/gitLinkProvider.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ILink, Terminal } from '@xterm/xterm'; +import { createGitLinkProvider } from './gitLinkProvider'; +import type { LinkProviderConfig } from './types'; + +function providerFor(text: string) { + // SAFETY: The provider only reads buffer.active.getLine(n).translateToString(); the stub covers that surface. + const requestedLines: number[] = []; + // SAFETY: The provider only reads buffer.active.getLine(n).translateToString(); the stub covers that surface. + const terminal = Object.assign({} as Terminal, { + buffer: { active: { getLine: (index: number) => { requestedLines.push(index); return { translateToString: () => text }; } } }, + }); + const config: LinkProviderConfig = { + terminal, + workingDirectory: '/repo', + githubRemoteUrl: 'https://github.com/dcouple/pane', + onShowTooltip: vi.fn(), + onHideTooltip: vi.fn(), + onShowFilePopover: vi.fn(), + onActivateUrl: vi.fn(), + urlHoverHint: 'HINT', + }; + const provider = createGitLinkProvider(config); + let links: ILink[] | undefined; + // xterm passes 1-based buffer lines. + provider.provideLinks(3, (result) => { links = result; }); + return { config, links: links ?? [], requestedLines }; +} + +describe('gitLinkProvider', () => { + it('hands every activation and the shared hover hint to the router instead of gating on modifiers', () => { + const { config, links, requestedLines } = providerFor('fix a1b2c3d closes #42 and dcouple/skills#7'); + expect(links.map((link) => link.text)).toEqual(['a1b2c3d', '#42', 'dcouple/skills#7']); + // Reads the 0-based buffer line for the 1-based request and reports 1-based ranges on that row. + expect(requestedLines).toEqual([2]); + expect(links.map((link) => link.range.start.y)).toEqual([3, 3, 3]); + + // SAFETY: activate/hover only forward the event; the router reads modifier flags from it. + const plain = { metaKey: false, ctrlKey: false, shiftKey: false } as MouseEvent; + links[0].activate(plain, links[0].text); + expect(config.onActivateUrl).toHaveBeenCalledWith('https://github.com/dcouple/pane/commit/a1b2c3d', plain); + links[1].activate(plain, links[1].text); + expect(config.onActivateUrl).toHaveBeenLastCalledWith('https://github.com/dcouple/pane/issues/42', plain); + + links[2].hover?.(plain, links[2].text); + expect(config.onShowTooltip).toHaveBeenCalledWith(plain, 'https://github.com/dcouple/skills/issues/7', 'HINT'); + }); +}); diff --git a/frontend/src/components/terminal/linkProviders/gitLinkProvider.ts b/frontend/src/components/terminal/linkProviders/gitLinkProvider.ts index 646a05b38..a51458e0f 100644 --- a/frontend/src/components/terminal/linkProviders/gitLinkProvider.ts +++ b/frontend/src/components/terminal/linkProviders/gitLinkProvider.ts @@ -1,6 +1,5 @@ import type { ILink, ILinkProvider } from '@xterm/xterm'; import type { LinkProviderConfig } from './types'; -import { isMac, getModifierKeyName } from '../../../utils/platformUtils'; /** * Creates a git link provider that detects git SHAs and issue references. @@ -22,8 +21,10 @@ export function createGitLinkProvider(config: LinkProviderConfig): ILinkProvider const CROSS_REPO_ISSUE = /([a-z0-9_-]+\/[a-z0-9_-]+)#(\d+)/gi; return { + // xterm passes a 1-based buffer line; buffer.getLine is 0-based and link + // ranges are 1-based, so the range y is the line number as given. provideLinks(lineNumber: number, callback: (links: ILink[] | undefined) => void) { - const line = config.terminal.buffer.active.getLine(lineNumber); + const line = config.terminal.buffer.active.getLine(lineNumber - 1); if (!line) { callback(undefined); return; @@ -32,9 +33,6 @@ export function createGitLinkProvider(config: LinkProviderConfig): ILinkProvider const text = line.translateToString(); const links: ILink[] = []; - const isMacPlatform = isMac(); - const modifierKey = getModifierKeyName(); - // Match Git SHAs GIT_SHA.lastIndex = 0; let shaMatch; @@ -44,18 +42,15 @@ export function createGitLinkProvider(config: LinkProviderConfig): ILinkProvider links.push({ range: { - start: { x: shaMatch.index + 1, y: lineNumber + 1 }, - end: { x: shaMatch.index + shaMatch[0].length + 1, y: lineNumber + 1 }, + start: { x: shaMatch.index + 1, y: lineNumber }, + end: { x: shaMatch.index + shaMatch[0].length + 1, y: lineNumber }, }, text: sha, activate: (event: MouseEvent) => { - // Only activate on Ctrl/Cmd+Click - if (isMacPlatform ? event.metaKey : event.ctrlKey) { - config.onOpenUrl(commitUrl); - } + config.onActivateUrl(commitUrl, event); }, hover: (event: MouseEvent) => { - config.onShowTooltip(event, commitUrl, `${modifierKey}+Click to open`); + config.onShowTooltip(event, commitUrl, config.urlHoverHint); }, leave: () => { config.onHideTooltip(); @@ -72,18 +67,15 @@ export function createGitLinkProvider(config: LinkProviderConfig): ILinkProvider links.push({ range: { - start: { x: issueMatch.index + 1, y: lineNumber + 1 }, - end: { x: issueMatch.index + issueMatch[0].length + 1, y: lineNumber + 1 }, + start: { x: issueMatch.index + 1, y: lineNumber }, + end: { x: issueMatch.index + issueMatch[0].length + 1, y: lineNumber }, }, text: `#${issueNumber}`, activate: (event: MouseEvent) => { - // Only activate on Ctrl/Cmd+Click - if (isMacPlatform ? event.metaKey : event.ctrlKey) { - config.onOpenUrl(issueUrl); - } + config.onActivateUrl(issueUrl, event); }, hover: (event: MouseEvent) => { - config.onShowTooltip(event, issueUrl, `${modifierKey}+Click to open`); + config.onShowTooltip(event, issueUrl, config.urlHoverHint); }, leave: () => { config.onHideTooltip(); @@ -101,18 +93,15 @@ export function createGitLinkProvider(config: LinkProviderConfig): ILinkProvider links.push({ range: { - start: { x: crossRepoMatch.index + 1, y: lineNumber + 1 }, - end: { x: crossRepoMatch.index + crossRepoMatch[0].length + 1, y: lineNumber + 1 }, + start: { x: crossRepoMatch.index + 1, y: lineNumber }, + end: { x: crossRepoMatch.index + crossRepoMatch[0].length + 1, y: lineNumber }, }, text: `${repo}#${issueNumber}`, activate: (event: MouseEvent) => { - // Only activate on Ctrl/Cmd+Click - if (isMacPlatform ? event.metaKey : event.ctrlKey) { - config.onOpenUrl(crossRepoUrl); - } + config.onActivateUrl(crossRepoUrl, event); }, hover: (event: MouseEvent) => { - config.onShowTooltip(event, crossRepoUrl, `${modifierKey}+Click to open`); + config.onShowTooltip(event, crossRepoUrl, config.urlHoverHint); }, leave: () => { config.onHideTooltip(); diff --git a/frontend/src/components/terminal/linkProviders/types.ts b/frontend/src/components/terminal/linkProviders/types.ts index 87e3d81ca..e1c046d4b 100644 --- a/frontend/src/components/terminal/linkProviders/types.ts +++ b/frontend/src/components/terminal/linkProviders/types.ts @@ -7,5 +7,8 @@ export interface LinkProviderConfig { onShowTooltip: (event: MouseEvent, text: string, hint: string) => void; onHideTooltip: () => void; onShowFilePopover: (event: MouseEvent, filePath: string, line?: number) => void; - onOpenUrl: (url: string) => void; + /** Routes one URL activation through the shared link router (gesture classification included). */ + onActivateUrl: (url: string, event: MouseEvent) => void; + /** Hover hint advertising the available URL gestures for this session. */ + urlHoverHint: string; } diff --git a/frontend/src/components/terminal/linkRouting.test.ts b/frontend/src/components/terminal/linkRouting.test.ts new file mode 100644 index 000000000..5e2a95936 --- /dev/null +++ b/frontend/src/components/terminal/linkRouting.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + classifyLinkGesture, + describeUrlGestures, + routeUrlActivation, + validateBrowserUrl, + type LinkProvider, + type LinkRouterDeps, +} from './linkRouting'; + +const click = (overrides: Partial<{ metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; button: number }> = {}) => ({ + metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, button: 0, ...overrides, +}); + +function deps(overrides: Partial = {}): LinkRouterDeps { + return { + isMac: true, + browserAvailable: true, + openExternal: vi.fn(() => Promise.resolve()), + openInPaneBrowser: vi.fn(() => Promise.resolve()), + ...overrides, + }; +} + +const PROVIDERS: LinkProvider[] = ['osc8', 'web-links', 'git']; + +describe('classifyLinkGesture', () => { + it('uses Command on macOS and Control elsewhere as the primary modifier', () => { + expect(classifyLinkGesture(click({ metaKey: true }), true)).toBe('external'); + expect(classifyLinkGesture(click({ ctrlKey: true }), false)).toBe('external'); + expect(classifyLinkGesture(click({ metaKey: true }), false)).toBe('none'); + expect(classifyLinkGesture(click({ metaKey: true, shiftKey: true }), false)).toBe('none'); + }); + + it('gives Primary+Shift precedence', () => { + expect(classifyLinkGesture(click({ metaKey: true, shiftKey: true }), true)).toBe('pane-browser'); + expect(classifyLinkGesture(click({ ctrlKey: true, shiftKey: true }), false)).toBe('pane-browser'); + }); + + it('treats an unconsumed macOS Control primary click as external but never a shifted or secondary one', () => { + expect(classifyLinkGesture(click({ ctrlKey: true }), true)).toBe('external'); + expect(classifyLinkGesture(click({ ctrlKey: true, button: 2 }), true)).toBe('none'); + expect(classifyLinkGesture(click({ ctrlKey: true, shiftKey: true }), true)).toBe('none'); + }); + + it('excludes Alt and non-primary buttons from every gesture', () => { + expect(classifyLinkGesture(click({ metaKey: true, altKey: true }), true)).toBe('none'); + expect(classifyLinkGesture(click({ ctrlKey: true, shiftKey: true, altKey: true }), false)).toBe('none'); + expect(classifyLinkGesture(click({ metaKey: true, shiftKey: true, button: 1 }), true)).toBe('none'); + }); +}); + +describe('validateBrowserUrl', () => { + it('admits only absolute credential-free HTTP(S) URLs and returns the canonical href', () => { + expect(validateBrowserUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); + expect(validateBrowserUrl('http://localhost:3000')).toBe('http://localhost:3000/'); + expect(validateBrowserUrl('https://bücher.example')).toBe('https://xn--bcher-kva.example/'); + for (const rejected of [ + 'file:///etc/passwd', 'javascript:alert(1)', 'data:text/html,hi', 'blob:https://x/y', + 'vscode://open', 'https://user:pw@example.com', 'https://', 'not a url', 'example.com', + ]) { + expect(validateBrowserUrl(rejected), rejected).toBeNull(); + } + }); +}); + +describe('routeUrlActivation', () => { + it('opens Primary+Shift in the Pane Browser exactly once and never externally, for every provider', async () => { + for (const provider of PROVIDERS) { + const d = deps(); + await expect(routeUrlActivation('https://example.com', click({ metaKey: true, shiftKey: true }), provider, d)).resolves.toBe('pane-browser'); + expect(d.openInPaneBrowser).toHaveBeenCalledTimes(1); + expect(d.openInPaneBrowser).toHaveBeenCalledWith('https://example.com/'); + expect(d.openExternal).not.toHaveBeenCalled(); + } + }); + + it('falls back externally exactly once when no Browser surface is available', async () => { + for (const provider of PROVIDERS) { + const d = deps({ browserAvailable: false }); + await expect(routeUrlActivation('https://example.com', click({ metaKey: true, shiftKey: true }), provider, d)).resolves.toBe('external'); + expect(d.openInPaneBrowser).not.toHaveBeenCalled(); + expect(d.openExternal).toHaveBeenCalledTimes(1); + } + }); + + it('rejects URLs the Browser must not load with no sink call', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + for (const url of ['file:///tmp/x.html', 'javascript:alert(1)', 'https://user:pw@example.com', 'nope']) { + const d = deps(); + await expect(routeUrlActivation(url, click({ metaKey: true, shiftKey: true }), 'web-links', d)).resolves.toBe('none'); + expect(d.openInPaneBrowser).not.toHaveBeenCalled(); + expect(d.openExternal).not.toHaveBeenCalled(); + } + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('does not open externally after an in-Pane failure', async () => { + const d = deps({ openInPaneBrowser: vi.fn(() => Promise.reject(new Error('boom'))) }); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await expect(routeUrlActivation('https://example.com', click({ metaKey: true, shiftKey: true }), 'git', d)).resolves.toBe('pane-browser'); + expect(d.openExternal).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it('opens Primary externally once and applies the provider plain-click policy', async () => { + const d = deps({ isMac: false }); + await expect(routeUrlActivation('https://a', click({ ctrlKey: true }), 'web-links', d)).resolves.toBe('external'); + await expect(routeUrlActivation('https://a', click(), 'web-links', d)).resolves.toBe('none'); + await expect(routeUrlActivation('https://a', click(), 'git', d)).resolves.toBe('none'); + await expect(routeUrlActivation('https://a', click(), 'osc8', d)).resolves.toBe('external'); + // Meta alone on Windows/Linux is inert for gated providers; OSC-8 keeps its plain-click policy. + await expect(routeUrlActivation('https://a', click({ metaKey: true }), 'web-links', d)).resolves.toBe('none'); + await expect(routeUrlActivation('https://a', click({ metaKey: true }), 'osc8', d)).resolves.toBe('external'); + expect(d.openExternal).toHaveBeenCalledTimes(3); + }); +}); + +describe('describeUrlGestures', () => { + it('uses platform glyphs, provider plain-click wording, and an unavailable note', () => { + expect(describeUrlGestures(true, true, 'web-links')).toBe('⌘+Click: external · ⇧⌘+Click: Pane Browser'); + expect(describeUrlGestures(false, true, 'git')).toBe('Ctrl+Click: external · Ctrl+Shift+Click: Pane Browser'); + expect(describeUrlGestures(true, true, 'osc8')).toBe('Click: external · ⇧⌘+Click: Pane Browser'); + expect(describeUrlGestures(false, false, 'web-links')).toBe('Ctrl+Click: external (Pane Browser unavailable here)'); + expect(describeUrlGestures(true, false, 'osc8')).toBe('Click: external (Pane Browser unavailable here)'); + }); +}); diff --git a/frontend/src/components/terminal/linkRouting.ts b/frontend/src/components/terminal/linkRouting.ts new file mode 100644 index 000000000..9fa36b666 --- /dev/null +++ b/frontend/src/components/terminal/linkRouting.ts @@ -0,0 +1,123 @@ +/** + * One ordered classifier and one router for terminal HTTP(S) link activation. + * + * Every terminal URL source (xterm auto-detected links, OSC-8 hyperlinks, git + * SHA/issue links) funnels a single activation through `routeUrlActivation`, + * which invokes exactly one destination: + * + * 1. Primary+Shift -> Pane's Browser panel when the session can host one, + * otherwise the external browser exactly once. + * 2. Primary -> external browser. + * 3. macOS Control -> external browser only when Chromium delivered it as an + * unconsumed primary-button click (native context-click + * takes precedence and never reaches here). + * 4. No gesture -> the provider's plain-click policy (OSC-8 opens + * externally; auto-detected and git links do nothing). + * + * Alt is never part of a qualifying gesture (Alt+click moves the cursor), and + * only primary-button activations qualify. + */ + +export type LinkGesture = 'pane-browser' | 'external' | 'none'; +export type LinkDestination = 'pane-browser' | 'external' | 'none'; +export type LinkProvider = 'osc8' | 'web-links' | 'git'; + +export interface LinkActivationEventLike { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey?: boolean; + /** MouseEvent.button; undefined is treated as the primary button. */ + button?: number; +} + +export interface LinkRouterDeps { + isMac: boolean; + /** The current session can visibly host and activate a Browser panel. */ + browserAvailable: boolean; + openExternal: (url: string) => Promise; + openInPaneBrowser: (url: string) => Promise; +} + +/** Providers whose plain (unmodified) click opens externally today. */ +const PLAIN_CLICK_OPENS_EXTERNALLY = { + 'osc8': true, + 'web-links': false, + 'git': false, +} satisfies Record; + +export function classifyLinkGesture(event: LinkActivationEventLike, isMac: boolean): LinkGesture { + if (event.altKey || (event.button ?? 0) !== 0) return 'none'; + const primary = isMac ? event.metaKey : event.ctrlKey; + if (primary && event.shiftKey) return 'pane-browser'; + if (primary) return 'external'; + // macOS Control-click alias: only when it arrives as a primary-button activation. + if (isMac && event.ctrlKey && !event.shiftKey) return 'external'; + return 'none'; +} + +/** + * Returns the canonical href when the input is an absolute, credential-free + * HTTP(S) URL that the in-Pane Browser may load; null otherwise. Terminal + * output is untrusted, so `file:`, `javascript:`, `data:`, `blob:`, and custom + * schemes never reach the Browser panel through this gesture. + */ +export function validateBrowserUrl(raw: string): string | null { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + if (parsed.username || parsed.password) return null; + if (!parsed.hostname) return null; + return parsed.href; +} + +export async function routeUrlActivation( + url: string, + event: LinkActivationEventLike, + provider: LinkProvider, + deps: LinkRouterDeps, +): Promise { + const gesture = classifyLinkGesture(event, deps.isMac); + + if (gesture === 'pane-browser') { + if (!deps.browserAvailable) { + // Known-unavailable surface: external fallback, exactly once. + await deps.openExternal(url); + return 'external'; + } + const validated = validateBrowserUrl(url); + if (!validated) { + console.warn('[linkRouting] Rejected URL for Pane Browser:', url); + return 'none'; + } + // A failure after this point may have partially mutated panel state; + // report it rather than also opening externally. + try { + await deps.openInPaneBrowser(validated); + } catch (error) { + console.error('[linkRouting] Failed to open link in Pane Browser:', error); + } + return 'pane-browser'; + } + + if (gesture === 'external' || PLAIN_CLICK_OPENS_EXTERNALLY[provider]) { + await deps.openExternal(url); + return 'external'; + } + + return 'none'; +} + +/** Hover text advertising the gestures available for a URL link from the given provider. */ +export function describeUrlGestures(isMac: boolean, browserAvailable: boolean, provider: LinkProvider): string { + const primary = isMac ? '⌘+Click' : 'Ctrl+Click'; + const inPane = isMac ? '⇧⌘+Click' : 'Ctrl+Shift+Click'; + const externalLabel = PLAIN_CLICK_OPENS_EXTERNALLY[provider] ? 'Click' : primary; + return browserAvailable + ? `${externalLabel}: external · ${inPane}: Pane Browser` + : `${externalLabel}: external (Pane Browser unavailable here)`; +} diff --git a/frontend/src/services/browserPanelNavigation.test.ts b/frontend/src/services/browserPanelNavigation.test.ts new file mode 100644 index 000000000..733b446d9 --- /dev/null +++ b/frontend/src/services/browserPanelNavigation.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ToolPanel } from '../../../shared/types/panels'; + +import { usePanelStore } from '../stores/panelStore'; +import { + canHostSessionBrowser, + openUrlInSessionBrowser, + resolveBrowserNavigation, + type BrowserPanelApi, +} from './browserPanelNavigation'; + +const browserPanel = (id: string, sessionId: string, currentUrl: string): ToolPanel => ({ + id, + sessionId, + type: 'browser', + title: 'Browser', + state: { isActive: false, customState: { currentUrl } }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, +}); + +interface FakePanelApi extends BrowserPanelApi { + createPanel: ReturnType>; + updatePanel: ReturnType>; + setActivePanel: ReturnType>; +} + +let panelApi: FakePanelApi; + +const reset = () => { + usePanelStore.setState({ panels: {}, activePanels: {}, activityStatus: {}, agentStatus: {}, agentStatusSession: {} }); + panelApi = { + createPanel: vi.fn(async (request) => browserPanel('created', request.sessionId, '')), + updatePanel: vi.fn(async () => undefined), + setActivePanel: vi.fn(async () => undefined), + }; +}; + +describe('openUrlInSessionBrowser', () => { + beforeEach(reset); + + it('reuses the first Browser panel with one update and one activation, no event', async () => { + usePanelStore.setState({ panels: { s1: [browserPanel('b1', 's1', 'https://old'), browserPanel('b2', 's1', 'https://other')] } }); + + const result = await openUrlInSessionBrowser('s1', 'https://new/', {}, panelApi); + + expect(result).toEqual({ panelId: 'b1', created: false }); + expect(panelApi.createPanel).not.toHaveBeenCalled(); + expect(panelApi.updatePanel).toHaveBeenCalledTimes(1); + expect(panelApi.setActivePanel).toHaveBeenCalledWith('s1', 'b1'); + expect(panelApi.setActivePanel).toHaveBeenCalledTimes(1); + const stored = usePanelStore.getState().panels.s1.find((panel) => panel.id === 'b1'); + expect(stored?.state.customState).toMatchObject({ currentUrl: 'https://new/' }); + expect(stored?.title).toBe('Browser'); + expect(usePanelStore.getState().activePanels.s1).toBe('b1'); + }); + + it('gives two concurrent same-URL requests distinct nonces', async () => { + usePanelStore.setState({ panels: { s1: [browserPanel('b1', 's1', 'https://same')] } }); + const releases: Array<() => void> = []; + panelApi.updatePanel.mockImplementation(() => new Promise((resolve) => { releases.push(() => resolve()); })); + + const first = openUrlInSessionBrowser('s1', 'https://same', {}, panelApi); + const second = openUrlInSessionBrowser('s1', 'https://same', {}, panelApi); + const nonces = panelApi.updatePanel.mock.calls.map(([, updates]) => { + // SAFETY: the helper always writes BrowserPanelState into customState. + const customState = updates.state?.customState as { navigationNonce?: number } | undefined; + return customState?.navigationNonce; + }); + expect(nonces).toHaveLength(2); + expect(nonces[0]).not.toBe(nonces[1]); + for (const release of releases) release(); + await Promise.all([first, second]); + }); + + it('creates a Browser panel titled by host when none exists and activates it once', async () => { + const result = await openUrlInSessionBrowser('s2', 'https://example.com/path', {}, panelApi); + + expect(result).toEqual({ panelId: 'created', created: true }); + expect(panelApi.createPanel).toHaveBeenCalledWith({ + sessionId: 's2', + type: 'browser', + title: 'example.com', + initialState: { customState: { currentUrl: 'https://example.com/path' } }, + }); + expect(panelApi.updatePanel).not.toHaveBeenCalled(); + expect(panelApi.setActivePanel).toHaveBeenCalledTimes(1); + // The panel:created broadcast (handled by SessionView) adds it to the store and layout. + expect(usePanelStore.getState().panels.s2).toBeUndefined(); + expect(usePanelStore.getState().activePanels.s2).toBe('created'); + }); + + it('retitles an existing panel only when asked (HTML previews)', async () => { + usePanelStore.setState({ panels: { s1: [browserPanel('b1', 's1', 'https://old')] } }); + await openUrlInSessionBrowser('s1', 'file:///tmp/index.html', { title: 'index.html', retitleExisting: true }, panelApi); + expect(panelApi.updatePanel.mock.calls[0][1]).toMatchObject({ title: 'index.html' }); + expect(usePanelStore.getState().panels.s1[0].title).toBe('index.html'); + }); +}); + +describe('canHostSessionBrowser', () => { + it('allows ordinary worktree sessions only', () => { + expect(canHostSessionBrowser({ id: 'w1' })).toBe(true); + expect(canHostSessionBrowser({ id: 'w1', isMainRepo: false })).toBe(true); + expect(canHostSessionBrowser({ id: 'main', isMainRepo: true })).toBe(false); + expect(canHostSessionBrowser({ id: '__pane_chat_session__' })).toBe(false); + expect(canHostSessionBrowser(null)).toBe(false); + expect(canHostSessionBrowser(undefined)).toBe(false); + }); +}); + +describe('resolveBrowserNavigation', () => { + it('navigates on a new URL, reloads on a new nonce for the same URL, otherwise no-ops', () => { + expect(resolveBrowserNavigation({ url: '', nonce: undefined }, { currentUrl: 'https://a' })).toBe('navigate'); + expect(resolveBrowserNavigation({ url: 'https://a', nonce: undefined }, { currentUrl: 'https://b' })).toBe('navigate'); + expect(resolveBrowserNavigation({ url: 'https://a', nonce: 1 }, { currentUrl: 'https://a', nonce: 2 })).toBe('reload'); + expect(resolveBrowserNavigation({ url: 'https://a', nonce: 2 }, { currentUrl: 'https://a', nonce: 2 })).toBe('none'); + expect(resolveBrowserNavigation({ url: 'https://a', nonce: 2 }, { currentUrl: 'https://a' })).toBe('none'); + expect(resolveBrowserNavigation({ url: 'https://a', nonce: undefined }, {})).toBe('none'); + }); +}); diff --git a/frontend/src/services/browserPanelNavigation.ts b/frontend/src/services/browserPanelNavigation.ts new file mode 100644 index 000000000..16a155536 --- /dev/null +++ b/frontend/src/services/browserPanelNavigation.ts @@ -0,0 +1,108 @@ +import { panelApi } from './panelApi'; +import { usePanelStore } from '../stores/panelStore'; +import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; +import { PANEL_CAPABILITIES, type BrowserPanelState, type ToolPanel } from '../../../shared/types/panels'; +import type { Session } from '../types/session'; + +function browserPanelTitle(url: string): string { + try { + return new URL(url).host || 'Browser'; + } catch { + return 'Browser'; + } +} + +/** + * Whether this session can visibly host and activate a Browser panel. + * Project (main-repo) sessions and Pane Chat cannot; a missing session + * context is treated as ineligible so no hidden panel is ever created. + */ +export function canHostSessionBrowser(session: Pick | null | undefined): boolean { + if (!session) return false; + if (!PANEL_CAPABILITIES.browser.canAppearInWorktrees) return false; + if (session.id === PANE_CHAT_SESSION_ID) return false; + return session.isMainRepo !== true; +} + +export type BrowserNavigationDecision = 'none' | 'navigate' | 'reload'; + +/** + * Pure decision for BrowserPanel's state effect: navigate when the URL + * differs, reload when the same URL arrives with a new navigation nonce, + * otherwise do nothing. + */ +export function resolveBrowserNavigation( + previous: { url: string; nonce: number | undefined }, + next: { currentUrl?: string; nonce?: number }, +): BrowserNavigationDecision { + if (!next.currentUrl) return 'none'; + if (next.currentUrl !== previous.url) return 'navigate'; + if (next.nonce !== undefined && next.nonce !== previous.nonce) return 'reload'; + return 'none'; +} + +// Module-scoped and monotonic so two concurrent navigations can never share a +// nonce (a read-modify-write on the store snapshot could). +let navigationNonceCounter = 0; +function nextNavigationNonce(): number { + navigationNonceCounter += 1; + return navigationNonceCounter; +} + +export interface SessionBrowserNavigationOptions { + title?: string; + /** Apply `title` to an existing Browser panel as well (HTML previews do; terminal links keep the panel title). */ + retitleExisting?: boolean; +} + +/** The backend calls the helper makes; injectable for tests. */ +export type BrowserPanelApi = Pick; + +/** + * Single authoritative "create or navigate" for a session's Browser panel. + * + * Reuses the session's first Browser panel (updating its state so the panel + * navigates through its own state effect — a fresh nonce makes a same-URL + * request reload) or creates one, then activates it once. This is the only + * navigation path; it deliberately does not dispatch any custom event. + * Callers validate the URL before calling. + */ +export async function openUrlInSessionBrowser( + sessionId: string, + url: string, + options: SessionBrowserNavigationOptions = {}, + api: BrowserPanelApi = panelApi, +): Promise<{ panelId: string; created: boolean }> { + const store = usePanelStore.getState(); + const existing = store.getSessionPanels(sessionId).find((panel) => panel.type === 'browser'); + + let browserPanel: ToolPanel; + let created = false; + if (existing) { + // SAFETY: The panel type discriminator determines the corresponding custom-state shape. + const existingState = (existing.state.customState ?? {}) as BrowserPanelState; + const nextState: BrowserPanelState = { ...existingState, currentUrl: url, navigationNonce: nextNavigationNonce() }; + const updates: Partial = { state: { ...existing.state, customState: nextState } }; + if (options.retitleExisting && options.title) updates.title = options.title; + browserPanel = { ...existing, ...updates }; + // Commit locally first so the panel navigates immediately and concurrent + // callers observe the newest state; then persist. + store.updatePanelState(browserPanel); + await api.updatePanel(browserPanel.id, updates); + } else { + browserPanel = await api.createPanel({ + sessionId, + type: 'browser', + title: options.title ?? browserPanelTitle(url), + initialState: { customState: { currentUrl: url } }, + }); + // Deliberately no local addPanel: the main process broadcasts panel:created + // and SessionView's listener both adds the panel and inserts it into the + // split layout — but only when the panel is not already in the store. + created = true; + } + + store.setActivePanel(sessionId, browserPanel.id); + await api.setActivePanel(sessionId, browserPanel.id); + return { panelId: browserPanel.id, created }; +} diff --git a/shared/types/panels.ts b/shared/types/panels.ts index a6e853a11..5cff8443f 100644 --- a/shared/types/panels.ts +++ b/shared/types/panels.ts @@ -185,6 +185,12 @@ export interface SetupTasksPanelState { export interface BrowserPanelState { currentUrl?: string; + /** + * Transient, monotonically increasing token written by the renderer's + * navigation helper so a repeated request for the current URL reloads it. + * Not persisted (BrowserPanel writes only currentUrl back). + */ + navigationNonce?: number; isPopup?: boolean; } diff --git a/tests/terminal-links.spec.ts b/tests/terminal-links.spec.ts new file mode 100644 index 000000000..4eb44ba62 --- /dev/null +++ b/tests/terminal-links.spec.ts @@ -0,0 +1,231 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import type { JsonObject } from '../shared/validation/boundaryDecoder'; +import { installElectronApiMock } from './electronApiMock'; + +type LinksMock = { + getOpenedExternalUrls: () => string[]; + getPanelCreates: () => JsonObject[]; + getPanelUpdates: () => Array<{ panelId: string; updates: JsonObject }>; + getPanelActivations: () => Array<{ sessionId: string; panelId: string }>; +}; + +const project = { + id: 630, + name: 'Terminal links fixture', + path: '/tmp/terminal-links-fixture', + active: true, + environment: 'linux', + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), +}; + +const baseSession = { + prompt: 'Verify terminal link routing', + status: 'stopped', + createdAt: new Date(0).toISOString(), + lastActivity: new Date(0).toISOString(), + output: [], + jsonMessages: [], + isRunning: false, + permissionMode: 'ignore', + projectId: project.id, + isFavorite: false, + toolType: 'none', + archived: false, +}; +const worktreeSession = { ...baseSession, id: 'links-worktree', name: 'Links worktree pane', worktreePath: `${project.path}/wt`, displayOrder: 0 }; +const mainSession = { ...baseSession, id: 'links-main', name: 'Links main repo', worktreePath: project.path, isMainRepo: true, displayOrder: 1 }; + +const PLAIN_URL = 'https://plain.example.com/path'; +const OSC_URL = 'https://osc.example.com/doc'; +const GITHUB_REMOTE = 'https://github.com/dcouple/pane'; +// Line 1: auto-detected URL · line 2: OSC-8 hyperlink · line 3: issue reference. +const SCROLLBACK = `${PLAIN_URL}\r\n\x1b]8;;${OSC_URL}\x1b\\OSCLINK\x1b]8;;\x1b\\\r\nfix #123 now\r\n`; + +// A pinned (permanent) bottom terminal plus one tab terminal, as the popover spec seeds. +const terminalPanels = (sessionId: string, prefix: string) => ['Bottom Terminal', 'Tab Terminal'].map((title, index) => ({ + id: `${prefix}-${index}`, + sessionId, + type: 'terminal', + title, + state: { isActive: index === 1, hasBeenViewed: true, customState: { isInitialized: true } }, + metadata: { createdAt: new Date(index).toISOString(), lastActiveAt: new Date(index).toISOString(), position: index, permanent: index === 0 }, +})); +const browserPanel = (sessionId: string) => ({ + id: 'existing-browser', + sessionId, + type: 'browser', + title: 'Browser', + state: { isActive: false, hasBeenViewed: true, customState: { currentUrl: 'https://before.example.com/' } }, + metadata: { createdAt: new Date(0).toISOString(), lastActiveAt: new Date(0).toISOString(), position: 1 }, +}); + +type MockWindow = typeof window & { __paneTestElectronMock: LinksMock }; + +async function counts(page: Page) { + return page.evaluate(() => { + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + const mock = (window as MockWindow).__paneTestElectronMock; + return { + external: mock.getOpenedExternalUrls().length, + creates: mock.getPanelCreates().length, + updates: mock.getPanelUpdates().length, + activations: mock.getPanelActivations().length, + }; + }); +} + +async function openedUrls(page: Page): Promise { + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + return page.evaluate(() => (window as MockWindow).__paneTestElectronMock.getOpenedExternalUrls()); +} + +async function panelCreates(page: Page): Promise { + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + return page.evaluate(() => (window as MockWindow).__paneTestElectronMock.getPanelCreates()); +} + +async function panelUpdates(page: Page): Promise> { + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + return page.evaluate(() => (window as MockWindow).__paneTestElectronMock.getPanelUpdates()); +} + +/** + * Hovers the terminal until the link tooltip names the expected link. WebGL + * rendering leaves no DOM rows to measure, so the scan probes a column a few + * cells into the text, row by row, and confirms linkification via the tooltip. + */ +async function hoverLink(page: Page, terminal: Locator, expectedLinkText: string) { + const box = await terminal.locator('.xterm-screen').boundingBox(); + if (!box) throw new Error('Terminal screen has no bounding box'); + const x = box.x + 40; + for (let offset = 4; offset < 120; offset += 3) { + const y = box.y + offset; + await page.mouse.move(x - 1, y); + await page.mouse.move(x, y); + const tooltip = page.getByRole('tooltip'); + try { + await expect(tooltip).toContainText(expectedLinkText, { timeout: 250 }); + return { x, y }; + } catch { + // keep scanning + } + } + throw new Error(`No link tooltip for ${expectedLinkText}`); +} + +async function activate(page: Page, at: { x: number; y: number }, modifiers: string[]) { + for (const modifier of modifiers) await page.keyboard.down(modifier); + await page.mouse.move(at.x, at.y); + await page.mouse.down(); + await page.mouse.up(); + for (const modifier of [...modifiers].reverse()) await page.keyboard.up(modifier); +} + +async function boot(page: Page, options: { + platform: 'darwin' | 'linux'; + sessionName: string; + extraPanels?: JsonObject[]; +}) { + await page.addInitScript((navigatorPlatform) => { + Object.defineProperty(window.navigator, 'platform', { configurable: true, get: () => navigatorPlatform }); + }, options.platform === 'darwin' ? 'MacIntel' : 'Linux x86_64'); + await installElectronApiMock(page, { + platform: options.platform, + githubRemoteUrl: GITHUB_REMOTE, + initialProjects: [project], + initialSessions: [worktreeSession, mainSession], + initialPanels: [...terminalPanels(worktreeSession.id, 'wt'), ...terminalPanels(mainSession.id, 'main'), ...(options.extraPanels ?? [])], + initialTerminalStates: { + 'wt-0': { scrollbackBuffer: SCROLLBACK }, 'wt-1': { scrollbackBuffer: SCROLLBACK }, + 'main-0': { scrollbackBuffer: SCROLLBACK }, 'main-1': { scrollbackBuffer: SCROLLBACK }, + }, + activeProjectId: project.id, + }); + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.getByRole('button', { name: /^Expand repository Terminal links fixture$/ }).click(); + await page.getByRole('button', { name: options.sessionName, exact: true }).click(); + const terminal = page.getByRole('tabpanel').locator('.xterm').first(); + await expect(terminal.locator('.xterm-screen')).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(1_000); + return terminal; +} + +const primary = (platform: 'darwin' | 'linux') => (platform === 'darwin' ? 'Meta' : 'Control'); + +for (const platform of ['darwin', 'linux'] as const) { + test(`worktree: primary opens externally once and primary+shift opens the Pane Browser once (${platform})`, async ({ page }) => { + const terminal = await boot(page, { platform, sessionName: worktreeSession.name }); + const mod = primary(platform); + const hint = platform === 'darwin' ? '⌘+Click: external · ⇧⌘+Click: Pane Browser' : 'Ctrl+Click: external · Ctrl+Shift+Click: Pane Browser'; + + // Auto-detected URL: plain click is inert, primary opens externally. + let at = await hoverLink(page, terminal, PLAIN_URL); + await expect(page.getByRole('tooltip')).toContainText(hint); + await activate(page, at, []); + await page.waitForTimeout(200); + expect(await counts(page)).toMatchObject({ external: 0, creates: 0 }); + await activate(page, at, [mod]); + await expect.poll(async () => (await counts(page)).external).toBe(1); + expect(await openedUrls(page)).toEqual([PLAIN_URL]); + expect(await counts(page)).toMatchObject({ creates: 0, updates: 0 }); + + // Primary+Shift creates one Browser panel, activates it once, opens nothing externally. + at = await hoverLink(page, terminal, PLAIN_URL); + await activate(page, at, [mod, 'Shift']); + await expect.poll(async () => (await counts(page)).creates).toBe(1); + const created = (await panelCreates(page))[0]; + expect(created).toMatchObject({ sessionId: worktreeSession.id, type: 'browser', title: 'plain.example.com', state: { customState: { currentUrl: PLAIN_URL } } }); + await expect(page.getByRole('tab', { name: /plain\.example\.com|Browser/ })).toBeVisible(); + await page.waitForTimeout(200); + // Two activations: the router's own, plus SessionView's layout sync when the + // panel:created broadcast inserts the new tab (SessionView applyLayout) — the + // same as any panel creation. No second navigation or external open occurs. + expect(await counts(page)).toMatchObject({ external: 1, creates: 1, updates: 0, activations: 2 }); + }); +} + +test('worktree: OSC-8 plain click opens externally and git references route through the same policy', async ({ page }) => { + const terminal = await boot(page, { platform: 'linux', sessionName: worktreeSession.name }); + + let at = await hoverLink(page, terminal, OSC_URL); + await expect(page.getByRole('tooltip')).toContainText('Click: external · Ctrl+Shift+Click: Pane Browser'); + await activate(page, at, []); + await expect.poll(async () => (await counts(page)).external).toBe(1); + expect(await openedUrls(page)).toEqual([OSC_URL]); + + at = await hoverLink(page, terminal, `${GITHUB_REMOTE}/issues/123`); + await activate(page, at, []); + await page.waitForTimeout(200); + expect((await counts(page)).external).toBe(1); + await activate(page, at, ['Control']); + await expect.poll(async () => (await counts(page)).external).toBe(2); + expect((await openedUrls(page)).at(-1)).toBe(`${GITHUB_REMOTE}/issues/123`); + expect(await counts(page)).toMatchObject({ creates: 0, updates: 0 }); +}); + +test('worktree: primary+shift reuses the existing Browser panel with one update and no external open', async ({ page }) => { + const terminal = await boot(page, { platform: 'linux', sessionName: worktreeSession.name, extraPanels: [browserPanel(worktreeSession.id)] }); + const at = await hoverLink(page, terminal, PLAIN_URL); + await activate(page, at, ['Control', 'Shift']); + await expect.poll(async () => (await counts(page)).updates).toBe(1); + const update = (await panelUpdates(page))[0]; + expect(update.panelId).toBe('existing-browser'); + expect(update.updates).toMatchObject({ state: { customState: { currentUrl: PLAIN_URL } } }); + await page.waitForTimeout(200); + expect(await counts(page)).toMatchObject({ external: 0, creates: 0, updates: 1, activations: 1 }); +}); + +// Pane Chat's terminal never leaves its CLI loading overlay under the mock bridge, so its +// external-only routing is pinned by canHostSessionBrowser's unit test and the manual QA drive. +for (const context of ['main-repo'] as const) { + test(`${context}: primary+shift falls back to the external browser exactly once and creates no panel`, async ({ page }) => { + const terminal = await boot(page, { platform: 'linux', sessionName: mainSession.name }); + const at = await hoverLink(page, terminal, PLAIN_URL); + await expect(page.getByRole('tooltip')).toContainText('Ctrl+Click: external (Pane Browser unavailable here)'); + await activate(page, at, ['Control', 'Shift']); + await expect.poll(async () => (await counts(page)).external).toBe(1); + await page.waitForTimeout(200); + expect(await counts(page)).toMatchObject({ external: 1, creates: 0, updates: 0, activations: 0 }); + }); +} From 322f733cc0ae72ce0065d25c2e420d0c06ebb7a8 Mon Sep 17 00:00:00 2001 From: Tyler Brown Date: Sat, 29 Aug 2026 15:48:00 -0700 Subject: [PATCH 4/7] fix: harden link routing and settings apply after review - Every gesture-driven link sink (Pane Browser, external, and the unavailable-surface fallback) now takes only a validated, credential-free HTTP(S) URL; rejected targets open nothing. OSC-8 plain click keeps its pre-existing pass-through. - Alt and non-primary-button activations are classified as rejected so OSC-8 cannot treat them as plain clicks. - Shortcuts settings: the key-binding Apply and the snippet Apply are each blocked while the other draft is unsaved, since conflicts are validated across both drafts but persisted separately. - HTML previews retitle a reused Browser panel again. - Drop three unused type exports flagged by Knip. Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9 --- .../panels/editor/previewHtmlFile.ts | 2 +- .../settings/KeyboardShortcutMap.tsx | 9 +++- .../settings/categories/ShortcutsSettings.tsx | 16 ++++-- .../components/terminal/linkRouting.test.ts | 53 +++++++++++++++---- .../src/components/terminal/linkRouting.ts | 44 ++++++++------- frontend/src/utils/shortcutMap.ts | 6 +-- 6 files changed, 92 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/panels/editor/previewHtmlFile.ts b/frontend/src/components/panels/editor/previewHtmlFile.ts index b07cf80b9..ee37c7168 100644 --- a/frontend/src/components/panels/editor/previewHtmlFile.ts +++ b/frontend/src/components/panels/editor/previewHtmlFile.ts @@ -19,5 +19,5 @@ export async function previewHtmlFileInBrowser(sessionId: string, filePath: stri throw new Error(result.error || 'Failed to resolve HTML preview URL'); } - await openUrlInSessionBrowser(sessionId, result.url, { title: filePath.split('/').pop() || 'Browser' }); + await openUrlInSessionBrowser(sessionId, result.url, { title: filePath.split('/').pop() || 'Browser', retitleExisting: true }); } diff --git a/frontend/src/components/settings/KeyboardShortcutMap.tsx b/frontend/src/components/settings/KeyboardShortcutMap.tsx index 00ffd734b..317b6fdb9 100644 --- a/frontend/src/components/settings/KeyboardShortcutMap.tsx +++ b/frontend/src/components/settings/KeyboardShortcutMap.tsx @@ -25,6 +25,8 @@ interface KeyboardShortcutMapProps { customCommands: readonly CustomCommand[]; onDraftChange: (next: KeyboardShortcutOverrides) => void; onApply: () => void; + /** When set, Apply is disabled and this text explains why. */ + applyBlockedReason?: string | null; } const STATE_LABELS = { @@ -35,7 +37,7 @@ const STATE_LABELS = { } satisfies Record; export function KeyboardShortcutMap({ - map, draft, dirty, terminalShortcuts, customCommands, onDraftChange, onApply, + map, draft, dirty, terminalShortcuts, customCommands, onDraftChange, onApply, applyBlockedReason = null, }: KeyboardShortcutMapProps) { const [query, setQuery] = useState(''); const [confirmResetAll, setConfirmResetAll] = useState(false); @@ -188,7 +190,10 @@ export function KeyboardShortcutMap({
  • Resolve conflicts to apply.
  • )} - + {applyBlockedReason && dirty && ( + {applyBlockedReason} + )} + !shortcut.label.trim() || !shortcut.key || !shortcut.text.trim()) || duplicateKeys.size > 0 || snippetConflicts.size > 0; + // Conflicts are validated across both drafts, but each Apply persists only its + // own section, so neither may save while the other draft is unsaved. + const applyOverridesBlockedBySnippets = snippetsDirty; + const applySnippetsBlockedByOverrides = overridesDirty; const update = (index: number, patch: Partial) => { setShortcuts((current) => current.map((shortcut, shortcutIndex) => ( @@ -70,12 +74,12 @@ export function ShortcutsSettings({ persistence, platform, onDirtyChange, onShow }; const apply = async () => { - if (invalid) return; + if (invalid || applySnippetsBlockedByOverrides) return; await persistence.saveConfig('terminal-shortcuts', { terminalShortcuts: shortcuts }); }; const applyOverrides = async () => { - if (conflicted) return; + if (conflicted || applyOverridesBlockedBySnippets) return; await persistence.saveConfig('keyboard-shortcut-map', { keyboardShortcutOverrides: overridesDraft }); }; @@ -122,6 +126,7 @@ export function ShortcutsSettings({ persistence, platform, onDirtyChange, onShow customCommands={customCommands} onDraftChange={setOverridesDraft} onApply={applyOverrides} + applyBlockedReason={applyOverridesBlockedBySnippets ? 'Apply or discard the Terminal snippet changes below first.' : null} /> - +
    + {applySnippetsBlockedByOverrides && snippetsDirty && ( + Apply or discard the Key bindings changes above first. + )} + +
    diff --git a/frontend/src/components/terminal/linkRouting.test.ts b/frontend/src/components/terminal/linkRouting.test.ts index 5e2a95936..8c6391729 100644 --- a/frontend/src/components/terminal/linkRouting.test.ts +++ b/frontend/src/components/terminal/linkRouting.test.ts @@ -39,14 +39,15 @@ describe('classifyLinkGesture', () => { it('treats an unconsumed macOS Control primary click as external but never a shifted or secondary one', () => { expect(classifyLinkGesture(click({ ctrlKey: true }), true)).toBe('external'); - expect(classifyLinkGesture(click({ ctrlKey: true, button: 2 }), true)).toBe('none'); + expect(classifyLinkGesture(click({ ctrlKey: true, button: 2 }), true)).toBe('rejected'); expect(classifyLinkGesture(click({ ctrlKey: true, shiftKey: true }), true)).toBe('none'); }); - it('excludes Alt and non-primary buttons from every gesture', () => { - expect(classifyLinkGesture(click({ metaKey: true, altKey: true }), true)).toBe('none'); - expect(classifyLinkGesture(click({ ctrlKey: true, shiftKey: true, altKey: true }), false)).toBe('none'); - expect(classifyLinkGesture(click({ metaKey: true, shiftKey: true, button: 1 }), true)).toBe('none'); + it('rejects Alt and non-primary buttons outright', () => { + expect(classifyLinkGesture(click({ metaKey: true, altKey: true }), true)).toBe('rejected'); + expect(classifyLinkGesture(click({ ctrlKey: true, shiftKey: true, altKey: true }), false)).toBe('rejected'); + expect(classifyLinkGesture(click({ metaKey: true, shiftKey: true, button: 1 }), true)).toBe('rejected'); + expect(classifyLinkGesture(click({ button: 2 }), false)).toBe('rejected'); }); }); @@ -84,18 +85,50 @@ describe('routeUrlActivation', () => { } }); - it('rejects URLs the Browser must not load with no sink call', async () => { + it('rejects non-HTTP(S) URLs on every gesture-driven sink, including unavailable-surface fallback', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); for (const url of ['file:///tmp/x.html', 'javascript:alert(1)', 'https://user:pw@example.com', 'nope']) { - const d = deps(); - await expect(routeUrlActivation(url, click({ metaKey: true, shiftKey: true }), 'web-links', d)).resolves.toBe('none'); - expect(d.openInPaneBrowser).not.toHaveBeenCalled(); - expect(d.openExternal).not.toHaveBeenCalled(); + for (const provider of PROVIDERS) { + for (const [event, available] of [ + [click({ metaKey: true, shiftKey: true }), true], + [click({ metaKey: true, shiftKey: true }), false], + [click({ metaKey: true }), true], + [click({ ctrlKey: true }), true], + ] as const) { + const d = deps({ browserAvailable: available }); + await expect(routeUrlActivation(url, event, provider, d)).resolves.toBe('none'); + expect(d.openInPaneBrowser).not.toHaveBeenCalled(); + expect(d.openExternal).not.toHaveBeenCalled(); + } + } } expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); + it('opens nothing for Alt or non-primary activations, even for OSC-8', async () => { + for (const provider of PROVIDERS) { + const d = deps(); + await expect(routeUrlActivation('https://a', click({ altKey: true }), provider, d)).resolves.toBe('none'); + await expect(routeUrlActivation('https://a', click({ button: 1 }), provider, d)).resolves.toBe('none'); + await expect(routeUrlActivation('https://a', click({ button: 2, metaKey: true }), provider, d)).resolves.toBe('none'); + expect(d.openExternal).not.toHaveBeenCalled(); + expect(d.openInPaneBrowser).not.toHaveBeenCalled(); + } + }); + + it('keeps OSC-8 plain click opening its target as-is (pre-existing behavior)', async () => { + const d = deps(); + await expect(routeUrlActivation('mailto:dev@example.com', click(), 'osc8', d)).resolves.toBe('external'); + expect(d.openExternal).toHaveBeenCalledWith('mailto:dev@example.com'); + }); + + it('passes the canonical href to the external sink', async () => { + const d = deps({ isMac: false }); + await routeUrlActivation('https://example.com', click({ ctrlKey: true }), 'web-links', d); + expect(d.openExternal).toHaveBeenCalledWith('https://example.com/'); + }); + it('does not open externally after an in-Pane failure', async () => { const d = deps({ openInPaneBrowser: vi.fn(() => Promise.reject(new Error('boom'))) }); const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/frontend/src/components/terminal/linkRouting.ts b/frontend/src/components/terminal/linkRouting.ts index 9fa36b666..bdba81076 100644 --- a/frontend/src/components/terminal/linkRouting.ts +++ b/frontend/src/components/terminal/linkRouting.ts @@ -18,7 +18,8 @@ * only primary-button activations qualify. */ -export type LinkGesture = 'pane-browser' | 'external' | 'none'; +/** `rejected` = Alt or a non-primary button: never an activation, not even a plain click. */ +export type LinkGesture = 'pane-browser' | 'external' | 'none' | 'rejected'; export type LinkDestination = 'pane-browser' | 'external' | 'none'; export type LinkProvider = 'osc8' | 'web-links' | 'git'; @@ -47,7 +48,7 @@ const PLAIN_CLICK_OPENS_EXTERNALLY = { } satisfies Record; export function classifyLinkGesture(event: LinkActivationEventLike, isMac: boolean): LinkGesture { - if (event.altKey || (event.button ?? 0) !== 0) return 'none'; + if (event.altKey || (event.button ?? 0) !== 0) return 'rejected'; const primary = isMac ? event.metaKey : event.ctrlKey; if (primary && event.shiftKey) return 'pane-browser'; if (primary) return 'external'; @@ -82,18 +83,25 @@ export async function routeUrlActivation( deps: LinkRouterDeps, ): Promise { const gesture = classifyLinkGesture(event, deps.isMac); + if (gesture === 'rejected') return 'none'; - if (gesture === 'pane-browser') { - if (!deps.browserAvailable) { - // Known-unavailable surface: external fallback, exactly once. - await deps.openExternal(url); - return 'external'; - } - const validated = validateBrowserUrl(url); - if (!validated) { - console.warn('[linkRouting] Rejected URL for Pane Browser:', url); - return 'none'; - } + if (gesture === 'none') { + // No qualifying gesture: the provider's plain-click policy. OSC-8 keeps + // its pre-existing behavior of opening the hyperlink target as-is. + if (!PLAIN_CLICK_OPENS_EXTERNALLY[provider]) return 'none'; + await deps.openExternal(url); + return 'external'; + } + + // Every gesture-driven sink — internal or external — takes only a + // validated, credential-free HTTP(S) URL; anything else opens nothing. + const validated = validateBrowserUrl(url); + if (!validated) { + console.warn('[linkRouting] Rejected URL for modified-click routing:', url); + return 'none'; + } + + if (gesture === 'pane-browser' && deps.browserAvailable) { // A failure after this point may have partially mutated panel state; // report it rather than also opening externally. try { @@ -104,12 +112,10 @@ export async function routeUrlActivation( return 'pane-browser'; } - if (gesture === 'external' || PLAIN_CLICK_OPENS_EXTERNALLY[provider]) { - await deps.openExternal(url); - return 'external'; - } - - return 'none'; + // Primary, the macOS Control alias, or Primary+Shift on a known-unavailable + // surface: external, exactly once. + await deps.openExternal(validated); + return 'external'; } /** Hover text advertising the gestures available for a URL link from the given provider. */ diff --git a/frontend/src/utils/shortcutMap.ts b/frontend/src/utils/shortcutMap.ts index fe90a3e75..cda4627bb 100644 --- a/frontend/src/utils/shortcutMap.ts +++ b/frontend/src/utils/shortcutMap.ts @@ -28,9 +28,9 @@ import type { ProjectEnvironment } from '../../../shared/types/panels'; import type { CustomCommand, TerminalShortcut } from '../types/config'; import { isTerminalReservedChordString } from './terminalKeyHandling'; -export type ShortcutRowOrigin = 'catalog' | 'snippet'; -export type ShortcutRowState = 'default' | 'customized' | 'unassigned' | 'invalid'; -export type ShortcutAvailability = 'available' | 'unavailable-platform'; +type ShortcutRowOrigin = 'catalog' | 'snippet'; +type ShortcutRowState = 'default' | 'customized' | 'unassigned' | 'invalid'; +type ShortcutAvailability = 'available' | 'unavailable-platform'; export interface ShortcutMapRow { id: string; From 95a30aa64bf5b89839b2bf2d7a69e359a6aec4c0 Mon Sep 17 00:00:00 2001 From: Tyler Brown Date: Sat, 29 Aug 2026 15:51:34 -0700 Subject: [PATCH 5/7] fix: refuse bare navigation keys and terminal copy in the key recorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording an unmodified named key (Tab, Enter, Space, arrows, paging…) would hijack focus navigation and typing app-wide, and mod+c / mod+shift+c is the terminal's copy shortcut; both are now refused with live status text. Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9 --- frontend/src/components/settings/KeyRecorder.tsx | 3 ++- frontend/src/utils/shortcutMap.test.ts | 6 ++++++ frontend/src/utils/shortcutMap.ts | 7 +++++-- frontend/src/utils/terminalKeyHandling.ts | 7 ++++--- shared/types/panels.ts | 7 ++++--- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/settings/KeyRecorder.tsx b/frontend/src/components/settings/KeyRecorder.tsx index 4ff68f5b2..9122180dc 100644 --- a/frontend/src/components/settings/KeyRecorder.tsx +++ b/frontend/src/components/settings/KeyRecorder.tsx @@ -31,7 +31,8 @@ const RECORDER_COPY = { 'bare-printable': 'Shortcuts with a letter, digit, or punctuation key must include Ctrl/⌘', 'unsupported-key': "That key can't be used", 'malformed': "That combination can't be used", - 'reserved-by-terminal': 'Reserved by the terminal (search/paste/clear/flow control)', + 'reserved-by-terminal': 'Reserved by the terminal (search/paste/copy/clear/flow control)', + 'bare-navigation-key': 'Add a modifier — that key alone is used for navigation and typing', } as const; const MODIFIER_KEYS = new Set(['Control', 'Meta', 'Alt', 'Shift', 'AltGraph', 'CapsLock']); diff --git a/frontend/src/utils/shortcutMap.test.ts b/frontend/src/utils/shortcutMap.test.ts index 96b092f5f..0049cba59 100644 --- a/frontend/src/utils/shortcutMap.test.ts +++ b/frontend/src/utils/shortcutMap.test.ts @@ -110,6 +110,12 @@ describe('helpers', () => { expect(isRecordableChord('mod+shift+k', { ownDefault: 'mod+shift+k' })).toEqual({ ok: true }); expect(isRecordableChord('mod+shift+p', { ownDefault: 'mod+shift+p' })).toEqual({ ok: true }); expect(isRecordableChord('mod+alt+x', { ownDefault: 'mod+b' })).toEqual({ ok: true }); + expect(isRecordableChord('mod+c', { ownDefault: 'mod+b' })).toEqual({ ok: false, reason: 'reserved-by-terminal' }); + expect(isRecordableChord('mod+shift+c', { ownDefault: 'mod+b' })).toEqual({ ok: false, reason: 'reserved-by-terminal' }); + for (const bare of ['Tab', 'Enter', 'Space', 'ArrowUp', 'PageDown', 'Home', 'F5']) { + expect(isRecordableChord(bare, { ownDefault: null })).toEqual({ ok: false, reason: 'bare-navigation-key' }); + } + expect(isRecordableChord('shift+ArrowUp', { ownDefault: 'shift+ArrowUp' })).toEqual({ ok: true }); }); it('prefers the active project environment over the host platform', () => { diff --git a/frontend/src/utils/shortcutMap.ts b/frontend/src/utils/shortcutMap.ts index cda4627bb..951d1efc0 100644 --- a/frontend/src/utils/shortcutMap.ts +++ b/frontend/src/utils/shortcutMap.ts @@ -216,14 +216,17 @@ export function labelForId( return getCatalogEntry(id)?.label ?? id; } -export type RecordableChordResult = { ok: true } | { ok: false; reason: 'reserved-by-terminal' }; +export type RecordableChordResult = { ok: true } | { ok: false; reason: 'reserved-by-terminal' | 'bare-navigation-key' }; /** * Whether a user may record this chord. Terminal-reserved chords are refused - * unless the row's own default is that chord (grandfathered defaults such as + * (f/v/q/p/k/c) unless the row's own default is that chord (grandfathered defaults such as * `open-command-palette` = mod+shift+p and `git-commit` = mod+shift+k). */ export function isRecordableChord(chord: string, options: { ownDefault: string | null }): RecordableChordResult { + // A named key without any modifier (Tab, Enter, Space, arrows, paging, Home/End…) + // would hijack focus navigation and text entry app-wide. + if (!chord.includes('+')) return { ok: false, reason: 'bare-navigation-key' }; if (chord !== options.ownDefault && isTerminalReservedChordString(chord)) { return { ok: false, reason: 'reserved-by-terminal' }; } diff --git a/frontend/src/utils/terminalKeyHandling.ts b/frontend/src/utils/terminalKeyHandling.ts index 2ef40365e..dbec962b3 100644 --- a/frontend/src/utils/terminalKeyHandling.ts +++ b/frontend/src/utils/terminalKeyHandling.ts @@ -81,8 +81,9 @@ function isPaneNavigationShortcut( } const TERMINAL_RESERVED_EVENT_KEYS = ['f', 'v', 'q', 'p']; -// mod+k (any Shift/Alt) is the terminal's clear-scrollback branch in TerminalPanel. -const TERMINAL_RESERVED_CHORD_KEYS = new Set([...TERMINAL_RESERVED_EVENT_KEYS, 'k']); +// mod+k (any Shift/Alt) is the terminal's clear-scrollback branch in TerminalPanel; +// mod+c / mod+shift+c is terminal copy (terminalClipboard.isTerminalCopyShortcut). +const TERMINAL_RESERVED_CHORD_KEYS = new Set([...TERMINAL_RESERVED_EVENT_KEYS, 'k', 'c']); export function isTerminalReservedChord(event: TerminalKeyLike): boolean { if (event.code === 'AltRight') return true; @@ -92,7 +93,7 @@ export function isTerminalReservedChord(event: TerminalKeyLike): boolean { /** * String twin of `isTerminalReservedChord` for chords a user records: the - * terminal owns Ctrl/Cmd + f/v/q/p/k with any Shift/Alt combination. + * terminal owns Ctrl/Cmd + f/v/q/p/k/c with any Shift/Alt combination. */ export function isTerminalReservedChordString(chord: string): boolean { const parts = chord.split('+'); diff --git a/shared/types/panels.ts b/shared/types/panels.ts index 5cff8443f..70cf3b4bb 100644 --- a/shared/types/panels.ts +++ b/shared/types/panels.ts @@ -186,9 +186,10 @@ export interface SetupTasksPanelState { export interface BrowserPanelState { currentUrl?: string; /** - * Transient, monotonically increasing token written by the renderer's - * navigation helper so a repeated request for the current URL reloads it. - * Not persisted (BrowserPanel writes only currentUrl back). + * Monotonically increasing token written by the renderer's navigation + * helper so a repeated request for the current URL reloads it. It rides + * along in the panel state update but carries no meaning across restarts: + * BrowserPanel seeds its last-seen nonce from the mounted state. */ navigationNonce?: number; isPopup?: boolean; From 2bcb074041c1d1d3a5930ed5262b709658afca65 Mon Sep 17 00:00:00 2001 From: Tyler Brown Date: Sat, 29 Aug 2026 15:53:32 -0700 Subject: [PATCH 6/7] fix: make terminal/native reference rows searchable in the key-binding map Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9 --- frontend/src/components/settings/KeyboardShortcutMap.tsx | 9 ++++++--- tests/shortcuts-settings.spec.ts | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/settings/KeyboardShortcutMap.tsx b/frontend/src/components/settings/KeyboardShortcutMap.tsx index 317b6fdb9..01223a64b 100644 --- a/frontend/src/components/settings/KeyboardShortcutMap.tsx +++ b/frontend/src/components/settings/KeyboardShortcutMap.tsx @@ -54,6 +54,9 @@ export function KeyboardShortcutMap({ return group ? [{ category, rows: group }] : []; }); }, [visible]); + const lowerQuery = query.trim().toLowerCase(); + const visibleReference = REFERENCE_ROWS.filter((reference) => + !lowerQuery || reference.label.toLowerCase().includes(lowerQuery) || reference.chord.toLowerCase().includes(lowerQuery)); const conflicted = map.conflicts.length > 0; const sources = { terminalShortcuts, customCommands }; const whereToEdit = (id: string) => ( @@ -90,7 +93,7 @@ export function KeyboardShortcutMap({ State - {grouped.length === 0 && ( + {grouped.length === 0 && visibleReference.length === 0 && (

    No shortcuts match “{query}”.

    )} {grouped.map(({ category, rows: groupRows }) => ( @@ -153,12 +156,12 @@ export function KeyboardShortcutMap({ })} ))} - {!query && ( + {visibleReference.length > 0 && (
    - {REFERENCE_ROWS.map((reference) => ( + {visibleReference.map((reference) => (
    {reference.label} {formatKeyDisplay(reference.chord)} diff --git a/tests/shortcuts-settings.spec.ts b/tests/shortcuts-settings.spec.ts index b9ea38110..0104a9865 100644 --- a/tests/shortcuts-settings.spec.ts +++ b/tests/shortcuts-settings.spec.ts @@ -93,6 +93,10 @@ test('shows the complete inventory regardless of the current view, with an axe-c await map.getByRole('textbox', { name: 'Search shortcuts' }).fill('codex'); await expect(map.locator('[data-shortcut-id]')).toHaveCount(1); await expect(map.locator('[data-shortcut-id="add-tool-terminal-codex"]')).toBeVisible(); + await expect(map.getByRole('rowgroup', { name: 'Terminal and native shortcuts' })).toHaveCount(0); + await map.getByRole('textbox', { name: 'Search shortcuts' }).fill('copy selection'); + await expect(map.locator('[data-shortcut-id]')).toHaveCount(0); + await expect(map.getByRole('rowgroup', { name: 'Terminal and native shortcuts' }).getByRole('row')).toHaveCount(1); }); test('shows the same inventory from a Project view with a WSL project on a Windows host', async ({ page }) => { From d63046970c1763441ff749671ea46642681fd9fd Mon Sep 17 00:00:00 2001 From: Tyler Brown Date: Sat, 29 Aug 2026 15:58:07 -0700 Subject: [PATCH 7/7] refactor: sync latest-callback refs in effects instead of during render Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9 --- frontend/src/components/settings/KeyRecorder.tsx | 4 +++- .../src/components/settings/KeyboardShortcutMap.tsx | 11 ++++++----- .../src/components/terminal/hooks/useTerminalLinks.ts | 8 +++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/settings/KeyRecorder.tsx b/frontend/src/components/settings/KeyRecorder.tsx index 9122180dc..903ae7835 100644 --- a/frontend/src/components/settings/KeyRecorder.tsx +++ b/frontend/src/components/settings/KeyRecorder.tsx @@ -46,7 +46,9 @@ export function KeyRecorder({ // Latest callbacks for the native listener registered below. const callbacksRef = useRef({ defaultChord, onRecord, onUnassign, onReset }); - callbacksRef.current = { defaultChord, onRecord, onUnassign, onReset }; + useEffect(() => { + callbacksRef.current = { defaultChord, onRecord, onUnassign, onReset }; + }, [defaultChord, onRecord, onUnassign, onReset]); useEffect(() => { if (!recording) return; diff --git a/frontend/src/components/settings/KeyboardShortcutMap.tsx b/frontend/src/components/settings/KeyboardShortcutMap.tsx index 01223a64b..3b4ddc0e3 100644 --- a/frontend/src/components/settings/KeyboardShortcutMap.tsx +++ b/frontend/src/components/settings/KeyboardShortcutMap.tsx @@ -29,6 +29,12 @@ interface KeyboardShortcutMapProps { applyBlockedReason?: string | null; } +function whereToEdit(id: string): string { + if (id.startsWith('terminal-shortcut-')) return ' (edit in Terminal snippets below)'; + if (id.startsWith('add-tool-custom-')) return ' (custom command; remap it in its own row or in Add Tool › Custom commands)'; + return ''; +} + const STATE_LABELS = { 'default': null, 'customized': 'Customized', @@ -59,11 +65,6 @@ export function KeyboardShortcutMap({ !lowerQuery || reference.label.toLowerCase().includes(lowerQuery) || reference.chord.toLowerCase().includes(lowerQuery)); const conflicted = map.conflicts.length > 0; const sources = { terminalShortcuts, customCommands }; - const whereToEdit = (id: string) => ( - id.startsWith('terminal-shortcut-') ? ' (edit in Terminal snippets below)' - : id.startsWith('add-tool-custom-') ? ' (custom command; remap it in its own row or in Add Tool › Custom commands)' - : '' - ); const setOverride = (id: string, value: string | null) => onDraftChange({ ...draft, [id]: value }); const removeOverride = (id: string) => { diff --git a/frontend/src/components/terminal/hooks/useTerminalLinks.ts b/frontend/src/components/terminal/hooks/useTerminalLinks.ts index 992ffdebe..b415e89be 100644 --- a/frontend/src/components/terminal/hooks/useTerminalLinks.ts +++ b/frontend/src/components/terminal/hooks/useTerminalLinks.ts @@ -100,7 +100,7 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL // xterm's linkHandler and WebLinksAddon are created once per terminal, so // they read the latest router through a ref rather than re-creating xterm. const routeUrlRef = useRef(routeUrl); - routeUrlRef.current = routeUrl; + useEffect(() => { routeUrlRef.current = routeUrl; }, [routeUrl]); // Track mouse position for selection popover const onMouseMove = useCallback((e: React.MouseEvent) => { @@ -230,9 +230,11 @@ export function useTerminalLinks(terminal: Terminal | null, config: UseTerminalL }, [hoverHintFor]); // Consumed by xterm handlers created once per terminal. const showLinkTooltipRef = useRef(showLinkTooltip); - showLinkTooltipRef.current = showLinkTooltip; const closeTooltipRef = useRef(closeTooltip); - closeTooltipRef.current = closeTooltip; + useEffect(() => { + showLinkTooltipRef.current = showLinkTooltip; + closeTooltipRef.current = closeTooltip; + }, [showLinkTooltip, closeTooltip]); const closeFilePopover = useCallback(() => { setFilePopover((prev) => ({ ...prev, visible: false }));