From 8ebbbc94abfbe74e51f941f0de4e98ba56c74933 Mon Sep 17 00:00:00 2001
From: Jinwoo-H
Date: Tue, 19 May 2026 18:13:30 -0400
Subject: [PATCH] Add custom window shortcut recorder
Co-authored-by: Orca
---
src/main/browser/browser-guest-ui.ts | 110 ++++--
src/main/browser/browser-manager.test.ts | 65 ++++
src/main/browser/browser-manager.ts | 11 +-
src/main/persistence.ts | 6 +
src/main/window/createMainWindow.test.ts | 3 +-
src/main/window/createMainWindow.ts | 10 +-
.../src/components/settings/ShortcutsPane.tsx | 32 +-
.../WindowShortcutBindingsSection.tsx | 212 ++++++++++++
src/shared/constants.ts | 1 +
src/shared/types.ts | 4 +
src/shared/window-shortcut-bindings.test.ts | 32 ++
src/shared/window-shortcut-bindings.ts | 318 ++++++++++++++++++
src/shared/window-shortcut-policy.test.ts | 44 +++
src/shared/window-shortcut-policy.ts | 54 ++-
14 files changed, 863 insertions(+), 39 deletions(-)
create mode 100644 src/renderer/src/components/settings/WindowShortcutBindingsSection.tsx
create mode 100644 src/shared/window-shortcut-bindings.test.ts
create mode 100644 src/shared/window-shortcut-bindings.ts
diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts
index 2391e635a32..041516c7b1c 100644
--- a/src/main/browser/browser-guest-ui.ts
+++ b/src/main/browser/browser-guest-ui.ts
@@ -1,3 +1,6 @@
+/* eslint-disable max-lines -- Why: guest input forwarding keeps context-menu,
+grab-mode, app-shortcut, and renderer-forwarding behavior beside the single
+webContents boundary they all protect. */
import { screen, webContents } from 'electron'
import {
normalizeBrowserNavigationUrl,
@@ -6,12 +9,18 @@ import {
} from '../../shared/browser-url'
import {
isWindowShortcutModifierChord,
- resolveWindowShortcutAction
+ resolveWindowShortcutAction,
+ type WindowShortcutAction
} from '../../shared/window-shortcut-policy'
+import {
+ resolveCustomWindowShortcutAction,
+ type WindowShortcutBindings
+} from '../../shared/window-shortcut-bindings'
import { readGuestNavigationState } from './browser-guest-navigation-state'
type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null
type ShouldForwardDictationShortcut = () => boolean
+type GetWindowShortcutBindings = () => WindowShortcutBindings | undefined
function isTerminalTabSwitchChord(input: Electron.Input): boolean {
return (
@@ -31,6 +40,54 @@ function isControlKeyRelease(input: Electron.Input): boolean {
return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight')
}
+function sendResolvedWindowShortcutAction(
+ renderer: Electron.WebContents,
+ action: WindowShortcutAction,
+ shouldForwardDictationShortcut?: ShouldForwardDictationShortcut
+): boolean {
+ if (action.type === 'zoom') {
+ renderer.send('terminal:zoom', action.direction)
+ return true
+ }
+ if (action.type === 'toggleLeftSidebar') {
+ renderer.send('ui:toggleLeftSidebar')
+ return true
+ }
+ if (action.type === 'toggleRightSidebar') {
+ renderer.send('ui:toggleRightSidebar')
+ return true
+ }
+ if (action.type === 'toggleWorktreePalette') {
+ renderer.send('ui:toggleWorktreePalette')
+ return true
+ }
+ if (action.type === 'toggleFloatingTerminal') {
+ renderer.send('ui:toggleFloatingTerminal')
+ return true
+ }
+ if (action.type === 'openQuickOpen') {
+ renderer.send('ui:openQuickOpen')
+ return true
+ }
+ if (action.type === 'openNewWorkspace') {
+ renderer.send('ui:openNewWorkspace')
+ return true
+ }
+ if (action.type === 'jumpToWorktreeIndex') {
+ renderer.send('ui:jumpToWorktreeIndex', action.index)
+ return true
+ }
+ if (action.type === 'worktreeHistoryNavigate') {
+ renderer.send('ui:worktreeHistoryNavigate', action.direction)
+ return true
+ }
+ if (action.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) {
+ renderer.send('ui:dictationKeyDown')
+ return true
+ }
+ return false
+}
+
export function setupGuestContextMenu(args: {
browserTabId: string
guest: Electron.WebContents
@@ -228,8 +285,15 @@ export function setupGuestShortcutForwarding(args: {
guest: Electron.WebContents
resolveRenderer: ResolveRenderer
shouldForwardDictationShortcut?: ShouldForwardDictationShortcut
+ getWindowShortcutBindings?: GetWindowShortcutBindings
}): () => void {
- const { browserTabId, guest, resolveRenderer, shouldForwardDictationShortcut } = args
+ const {
+ browserTabId,
+ guest,
+ resolveRenderer,
+ shouldForwardDictationShortcut,
+ getWindowShortcutBindings
+ } = args
let ctrlTabSwitching = false
const handler = (event: Electron.Event, input: Electron.Input): void => {
if (isCtrlTabSwitchKey(input)) {
@@ -258,30 +322,35 @@ export function setupGuestShortcutForwarding(args: {
// Alt and must be handled before the generic modifier-chord gate below,
// which rejects Alt. Every other chord handled further down can reuse
// the same `action` rather than re-running the full predicate chain.
- const action = resolveWindowShortcutAction(input, process.platform)
+ const windowShortcutBindings = getWindowShortcutBindings?.()
+ const action = resolveWindowShortcutAction(input, process.platform, windowShortcutBindings)
+ const customAction = resolveCustomWindowShortcutAction(input, windowShortcutBindings)
if (input.isAutoRepeat) {
if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) {
event.preventDefault()
}
return
}
- if (action?.type === 'worktreeHistoryNavigate') {
- // Why: preventDefault unconditionally — if we cannot resolve the
- // renderer (torn-down tab or teardown race), dropping the keystroke
- // into the guest's webContents would let Chromium / the guest page
- // handle Cmd+Alt+Arrow as their own chord (e.g. guest-side text
- // navigation). Consistency with the main-window path is preserved
- // only by suppressing the event here too.
+ if (customAction) {
+ if (customAction.type === 'dictationKeyDown' && !shouldForwardDictationShortcut?.()) {
+ return
+ }
+ // Why: custom bindings may include Alt or chords that overlap guest
+ // browser shortcuts. Resolve the shared policy before the guest-specific
+ // fallbacks so recorded shortcuts behave the same from focused webviews.
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
- renderer?.send('ui:worktreeHistoryNavigate', action.direction)
+ if (renderer) {
+ sendResolvedWindowShortcutAction(renderer, customAction, shouldForwardDictationShortcut)
+ }
return
}
-
- if (action?.type === 'toggleFloatingTerminal') {
+ if (action?.type === 'worktreeHistoryNavigate' || action?.type === 'toggleFloatingTerminal') {
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
- renderer?.send('ui:toggleFloatingTerminal')
+ if (renderer) {
+ sendResolvedWindowShortcutAction(renderer, action, shouldForwardDictationShortcut)
+ }
return
}
@@ -358,19 +427,10 @@ export function setupGuestShortcutForwarding(args: {
renderer.send('ui:closeActiveTab')
} else if (input.shift && (input.code === 'BracketRight' || input.code === 'BracketLeft')) {
renderer.send('ui:switchTab', input.code === 'BracketRight' ? 1 : -1)
- } else if (action?.type === 'toggleWorktreePalette') {
- renderer.send('ui:toggleWorktreePalette')
- } else if (action?.type === 'openQuickOpen') {
- renderer.send('ui:openQuickOpen')
- } else if (action?.type === 'openNewWorkspace') {
- renderer.send('ui:openNewWorkspace')
- } else if (action?.type === 'jumpToWorktreeIndex') {
- renderer.send('ui:jumpToWorktreeIndex', action.index)
- } else if (action?.type === 'dictationKeyDown') {
- if (!shouldForwardDictationShortcut?.()) {
+ } else if (action) {
+ if (!sendResolvedWindowShortcutAction(renderer, action, shouldForwardDictationShortcut)) {
return
}
- renderer.send('ui:dictationKeyDown')
} else {
return
}
diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts
index 1a5ef7ae022..ca627e7d484 100644
--- a/src/main/browser/browser-manager.test.ts
+++ b/src/main/browser/browser-manager.test.ts
@@ -59,6 +59,7 @@ describe('browserManager', () => {
webContentsFromIdMock.mockReset()
browserManager.unregisterAll()
browserManager.setDictationShortcutForwardingPredicate(null)
+ browserManager.setWindowShortcutBindingsResolver(null)
})
afterEach(() => {
@@ -1110,6 +1111,70 @@ describe('browserManager', () => {
expect(rendererSendMock).toHaveBeenNthCalledWith(9, 'ui:hardReloadBrowserPage')
})
+ it('lets custom browser guest shortcuts override guest-specific defaults', () => {
+ const isDarwin = process.platform === 'darwin'
+ const rendererSendMock = vi.fn()
+ const guest = {
+ id: 409,
+ isDestroyed: vi.fn(() => false),
+ getType: vi.fn(() => 'webview'),
+ setBackgroundThrottling: guestSetBackgroundThrottlingMock,
+ setWindowOpenHandler: guestSetWindowOpenHandlerMock,
+ on: guestOnMock,
+ off: guestOffMock,
+ openDevTools: guestOpenDevToolsMock
+ }
+
+ webContentsFromIdMock.mockImplementation((id: number) => {
+ if (id === guest.id) {
+ return guest
+ }
+ if (id === rendererWebContentsId) {
+ return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
+ }
+ return null
+ })
+
+ browserManager.setWindowShortcutBindingsResolver(() => ({
+ openQuickOpen: {
+ key: 'l',
+ code: 'KeyL',
+ meta: isDarwin,
+ control: !isDarwin
+ }
+ }))
+ browserManager.attachGuestPolicies(guest as never)
+ browserManager.registerGuest({
+ browserPageId: 'browser-1',
+ webContentsId: guest.id,
+ rendererWebContentsId
+ })
+
+ const beforeInputHandler = guestOnMock.mock.calls
+ .filter(([event]) => event === 'before-input-event')
+ .at(-1)?.[1] as
+ | ((event: { preventDefault: () => void }, input: Record) => void)
+ | undefined
+
+ const preventDefault = vi.fn()
+ beforeInputHandler?.(
+ { preventDefault },
+ {
+ type: 'keyDown',
+ code: 'KeyL',
+ key: 'l',
+ meta: isDarwin,
+ control: !isDarwin,
+ alt: false,
+ shift: false
+ }
+ )
+
+ expect(preventDefault).toHaveBeenCalledTimes(1)
+ expect(rendererSendMock).toHaveBeenCalledTimes(1)
+ expect(rendererSendMock).toHaveBeenCalledWith('ui:openQuickOpen')
+ })
+
it('forwards browser guest Ctrl+Tab keydown and Ctrl release', () => {
const rendererSendMock = vi.fn()
const guest = {
diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts
index caab6977686..3e654d67cef 100644
--- a/src/main/browser/browser-manager.ts
+++ b/src/main/browser/browser-manager.ts
@@ -37,6 +37,7 @@ import {
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { cleanElectronUserAgent } from './browser-session-ua'
import type { BrowserViewportOverride } from '../../shared/types'
+import type { WindowShortcutBindings } from '../../shared/window-shortcut-bindings'
import {
type BrowserAnnotationViewportBridgeOptions,
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
@@ -119,6 +120,7 @@ export class BrowserManager {
private readonly policyAttachedGuestIds = new Set()
private readonly policyCleanupByGuestId = new Map void>()
private shouldForwardDictationShortcut: (() => boolean) | null = null
+ private getWindowShortcutBindings: (() => WindowShortcutBindings | undefined) | null = null
private readonly pendingLoadFailuresByGuestId = new Map<
number,
{ code: number; description: string; validatedUrl: string }
@@ -127,6 +129,12 @@ export class BrowserManager {
setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void {
this.shouldForwardDictationShortcut = predicate
}
+
+ setWindowShortcutBindingsResolver(
+ resolver: (() => WindowShortcutBindings | undefined) | null
+ ): void {
+ this.getWindowShortcutBindings = resolver
+ }
private readonly pendingPermissionEventsByGuestId = new Map()
private readonly pendingPopupEventsByGuestId = new Map()
private readonly pendingDownloadIdsByGuestId = new Map()
@@ -1282,7 +1290,8 @@ export class BrowserManager {
guest,
resolveRenderer: (tabId) =>
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId),
- shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false
+ shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false,
+ getWindowShortcutBindings: () => this.getWindowShortcutBindings?.()
})
)
}
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index f093f51514d..8275916f0e6 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -78,6 +78,7 @@ import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../share
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
import { normalizeVisibleTaskProviders } from '../shared/task-providers'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
+import { normalizeWindowShortcutBindings } from '../shared/window-shortcut-bindings'
import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
@@ -2124,6 +2125,11 @@ export class Store {
if ('openInApplications' in updates) {
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications)
}
+ if ('windowShortcutBindings' in updates) {
+ sanitizedUpdates.windowShortcutBindings = normalizeWindowShortcutBindings(
+ updates.windowShortcutBindings
+ )
+ }
// Why: `telemetry` is deep-merged for the same reason `notifications` is —
// partial updates from the Privacy pane / consent flow (e.g., flipping
// only `optedIn`) must not clobber sibling fields like `installId` or
diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts
index f13475c3acf..6262c58a3d9 100644
--- a/src/main/window/createMainWindow.test.ts
+++ b/src/main/window/createMainWindow.test.ts
@@ -47,7 +47,8 @@ vi.mock('../../../resources/icon-dev.png?asset', () => ({
vi.mock('../browser/browser-manager', () => ({
browserManager: {
attachGuestPolicies: attachGuestPoliciesMock,
- setDictationShortcutForwardingPredicate: vi.fn()
+ setDictationShortcutForwardingPredicate: vi.fn(),
+ setWindowShortcutBindingsResolver: vi.fn()
}
}))
diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts
index dc13e6ea4c2..b44e2a81a82 100644
--- a/src/main/window/createMainWindow.ts
+++ b/src/main/window/createMainWindow.ts
@@ -173,6 +173,9 @@ export function createMainWindow(
// dictation session whose final text would be dropped.
return false
})
+ browserManager.setWindowShortcutBindingsResolver(
+ () => store?.getSettings().windowShortcutBindings
+ )
const blur = settings?.windowBackgroundBlur ?? false
// Why: native blur requires platform-specific Electron APIs. macOS uses
// vibrancy (needs transparent: true), Windows uses backgroundMaterial.
@@ -609,7 +612,11 @@ export function createMainWindow(
// Why: keep the main-process interception surface as an explicit allowlist.
// Anything outside this helper must continue to the renderer/PTTY so
// readline control chords are not silently stolen above the terminal.
- const action = resolveWindowShortcutAction(input, process.platform)
+ const action = resolveWindowShortcutAction(
+ input,
+ process.platform,
+ store?.getSettings().windowShortcutBindings
+ )
if (!action) {
return
}
@@ -835,6 +842,7 @@ export function createMainWindow(
ipcMain.removeListener(minimizeChannel, onMinimize)
ipcMain.removeListener(maximizeChannel, onMaximize)
browserManager.setDictationShortcutForwardingPredicate(null)
+ browserManager.setWindowShortcutBindingsResolver(null)
ipcMain.removeListener(requestCloseChannel, onRequestClose)
ipcMain.removeListener(popupMenuChannel, onPopupMenu)
ipcMain.removeHandler(isMaximizedChannel)
diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx
index 404ad93f021..487fe31a7a8 100644
--- a/src/renderer/src/components/settings/ShortcutsPane.tsx
+++ b/src/renderer/src/components/settings/ShortcutsPane.tsx
@@ -6,6 +6,11 @@ import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import {
+ CUSTOMIZABLE_ACTION_TITLES,
+ WindowShortcutBindingsSection,
+ WINDOW_SHORTCUT_BINDING_SEARCH_ENTRIES
+} from './WindowShortcutBindingsSection'
type ShortcutItem = {
action: string
@@ -251,6 +256,7 @@ const CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
// Why: search is supposed to stay in lockstep with the rendered shortcuts. Deriving
// both from one definition prevents the registry drift regression this branch introduced.
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
+ ...WINDOW_SHORTCUT_BINDING_SEARCH_ENTRIES,
...SHORTCUT_GROUP_DEFINITIONS.flatMap((group) =>
group.items.map((item) => ({
title: item.action,
@@ -274,11 +280,13 @@ export function ShortcutsPane(): React.JSX.Element {
() =>
SHORTCUT_GROUP_DEFINITIONS.map((group) => ({
title: group.title,
- items: group.items.map((item) => ({
- action: item.action,
- keys: item.keys({ mod, shift, enter })
- }))
- })),
+ items: group.items
+ .filter((item) => !CUSTOMIZABLE_ACTION_TITLES.has(item.action))
+ .map((item) => ({
+ action: item.action,
+ keys: item.keys({ mod, shift, enter })
+ }))
+ })).filter((group) => group.items.length > 0),
[mod, shift, enter]
)
@@ -291,11 +299,13 @@ export function ShortcutsPane(): React.JSX.Element {
Object.fromEntries(
SHORTCUT_GROUP_DEFINITIONS.map((groupDef) => [
groupDef.title,
- groupDef.items.map((defItem) => ({
- title: defItem.action,
- description: `${groupDef.title} shortcut`,
- keywords: defItem.searchKeywords
- }))
+ groupDef.items
+ .filter((defItem) => !CUSTOMIZABLE_ACTION_TITLES.has(defItem.action))
+ .map((defItem) => ({
+ title: defItem.action,
+ description: `${groupDef.title} shortcut`,
+ keywords: defItem.searchKeywords
+ }))
])
),
[]
@@ -311,6 +321,8 @@ export function ShortcutsPane(): React.JSX.Element {
+
+
{matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES) ? (
({
+ title: definition.title,
+ description: `${definition.group} shortcut`,
+ keywords: definition.keywords
+ }))
+
+export const CUSTOMIZABLE_ACTION_TITLES = new Set(
+ WINDOW_SHORTCUT_BINDING_DEFINITIONS.map((definition) => definition.title)
+)
+
+type WindowShortcutBindingsSectionProps = {
+ searchQuery: string
+}
+
+export function WindowShortcutBindingsSection({
+ searchQuery
+}: WindowShortcutBindingsSectionProps): React.JSX.Element | null {
+ const windowShortcutBindings = useAppStore(
+ (state) => state.settings?.windowShortcutBindings ?? {}
+ )
+ const updateSettings = useAppStore((state) => state.updateSettings)
+ const isMac = navigator.userAgent.includes('Mac')
+ const shortcutPlatform = (isMac ? 'darwin' : 'linux') as NodeJS.Platform
+ const [recordingActionId, setRecordingActionId] = useState(
+ null
+ )
+ const [recordingError, setRecordingError] = useState(null)
+
+ const customizableRows = useMemo(
+ () =>
+ WINDOW_SHORTCUT_BINDING_DEFINITIONS.map((definition) => {
+ const binding = effectiveWindowShortcutBinding(
+ definition.id,
+ shortcutPlatform,
+ windowShortcutBindings
+ )
+ return {
+ ...definition,
+ binding,
+ keys: formatWindowShortcutBinding(binding, isMac),
+ customized: Boolean(windowShortcutBindings[definition.id])
+ }
+ }),
+ [isMac, shortcutPlatform, windowShortcutBindings]
+ )
+
+ const setShortcutBinding = (
+ actionId: WindowShortcutBindingActionId,
+ binding: WindowShortcutBinding
+ ): void => {
+ void updateSettings({
+ windowShortcutBindings: {
+ ...windowShortcutBindings,
+ [actionId]: binding
+ }
+ })
+ }
+
+ const resetShortcutBinding = (actionId: WindowShortcutBindingActionId): void => {
+ const next: WindowShortcutBindings = { ...windowShortcutBindings }
+ delete next[actionId]
+ void updateSettings({ windowShortcutBindings: next })
+ }
+
+ const handleRecorderKeyDown = (
+ event: React.KeyboardEvent,
+ actionId: WindowShortcutBindingActionId
+ ): void => {
+ if (recordingActionId !== actionId) {
+ return
+ }
+ event.preventDefault()
+ event.stopPropagation()
+ if (event.key === 'Escape' && !event.metaKey && !event.ctrlKey && !event.altKey) {
+ setRecordingActionId(null)
+ setRecordingError(null)
+ return
+ }
+ const binding = bindingFromKeyboardEvent(event.nativeEvent)
+ if (!binding) {
+ setRecordingError('Press a key with Ctrl, Cmd, or Alt.')
+ return
+ }
+ const conflict = customizableRows.find(
+ (row) => row.id !== actionId && bindingSignature(row.binding) === bindingSignature(binding)
+ )
+ if (conflict) {
+ setRecordingError(`Already used by ${conflict.title}.`)
+ return
+ }
+ setShortcutBinding(actionId, binding)
+ setRecordingActionId(null)
+ setRecordingError(null)
+ }
+
+ if (!matchesSettingsSearch(searchQuery, WINDOW_SHORTCUT_BINDING_SEARCH_ENTRIES)) {
+ return null
+ }
+
+ return (
+
+
+ Customizable
+
+
+ {customizableRows
+ .filter((row) =>
+ matchesSettingsSearch(searchQuery, [
+ {
+ title: row.title,
+ description: `${row.group} shortcut`,
+ keywords: row.keywords
+ }
+ ])
+ )
+ .map((row) => {
+ const isRecording = recordingActionId === row.id
+ return (
+
+
+
{row.title}
+ {isRecording && recordingError ? (
+
{recordingError}
+ ) : null}
+
+
+
+
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+function bindingFromKeyboardEvent(event: KeyboardEvent): WindowShortcutBinding | null {
+ if (
+ event.key === 'Meta' ||
+ event.key === 'Control' ||
+ event.key === 'Alt' ||
+ event.key === 'Shift'
+ ) {
+ return null
+ }
+ const binding: WindowShortcutBinding = {
+ key: event.key,
+ code: event.code,
+ meta: event.metaKey,
+ control: event.ctrlKey,
+ alt: event.altKey,
+ shift: event.shiftKey
+ }
+ return isRecordableWindowShortcutBinding(binding) ? binding : null
+}
+
+function bindingSignature(binding: WindowShortcutBinding): string {
+ return [
+ binding.meta === true ? 'meta' : '',
+ binding.control === true ? 'control' : '',
+ binding.alt === true ? 'alt' : '',
+ binding.shift === true ? 'shift' : '',
+ binding.key.toLowerCase(),
+ binding.code ?? ''
+ ].join('|')
+}
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index f7bc13c8a79..035c303c844 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -209,6 +209,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
showTitlebarAppName: true,
showTasksButton: true,
ctrlTabOrderMode: 'mru',
+ windowShortcutBindings: {},
floatingTerminalEnabled: true,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: '~',
diff --git a/src/shared/types.ts b/src/shared/types.ts
index d82f4794212..855abfdb9f8 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -14,6 +14,7 @@ import type { GitLabProjectSettings } from './gitlab-types'
import type { TaskProvider } from './task-providers'
import type { FeatureTipId } from './feature-tips'
import type { GitBranchChangeStatus } from './git-status-types'
+import type { WindowShortcutBindings } from './window-shortcut-bindings'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
@@ -1510,6 +1511,9 @@ export type GlobalSettings = {
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
* profiles saved before this setting existed; readers default to MRU. */
ctrlTabOrderMode?: CtrlTabOrderMode
+ /** User-recorded window shortcut bindings. Defaults continue to work when
+ * a key is not present, and malformed bindings are dropped on write. */
+ windowShortcutBindings?: WindowShortcutBindings
/** Why: Floating Terminal is the default global shell surface so users can
* reach a terminal outside repo/worktree context immediately. */
floatingTerminalEnabled: boolean
diff --git a/src/shared/window-shortcut-bindings.test.ts b/src/shared/window-shortcut-bindings.test.ts
new file mode 100644
index 00000000000..c032627b537
--- /dev/null
+++ b/src/shared/window-shortcut-bindings.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import {
+ formatWindowShortcutBinding,
+ isRecordableWindowShortcutBinding,
+ normalizeWindowShortcutBindings
+} from './window-shortcut-bindings'
+
+describe('window shortcut bindings', () => {
+ it('normalizes persisted custom bindings and drops unsafe bare keys', () => {
+ expect(
+ normalizeWindowShortcutBindings({
+ openQuickOpen: { key: 'k', code: 'KeyK', meta: true },
+ toggleLeftSidebar: { key: 'b', code: 'KeyB', shift: true },
+ unknown: { key: 'x', meta: true }
+ })
+ ).toEqual({
+ openQuickOpen: { key: 'k', code: 'KeyK', meta: true }
+ })
+ })
+
+ it('requires a non-shift modifier for recorded app shortcuts', () => {
+ expect(isRecordableWindowShortcutBinding({ key: 'k', shift: true })).toBe(false)
+ expect(isRecordableWindowShortcutBinding({ key: 'k', alt: true })).toBe(true)
+ })
+
+ it('formats modifier labels for each platform', () => {
+ const binding = { key: 'ArrowLeft', code: 'ArrowLeft', meta: true, alt: true, shift: true }
+
+ expect(formatWindowShortcutBinding(binding, true)).toEqual(['⌘', '⌥', '⇧', '←'])
+ expect(formatWindowShortcutBinding(binding, false)).toEqual(['Meta', 'Alt', 'Shift', '←'])
+ })
+})
diff --git a/src/shared/window-shortcut-bindings.ts b/src/shared/window-shortcut-bindings.ts
new file mode 100644
index 00000000000..f66b0c40472
--- /dev/null
+++ b/src/shared/window-shortcut-bindings.ts
@@ -0,0 +1,318 @@
+import type { WindowShortcutAction, WindowShortcutInput } from './window-shortcut-policy'
+
+export type WindowShortcutBindingActionId =
+ | 'openQuickOpen'
+ | 'toggleWorktreePalette'
+ | 'openNewWorkspace'
+ | 'toggleLeftSidebar'
+ | 'toggleRightSidebar'
+ | 'toggleFloatingTerminal'
+ | 'zoomIn'
+ | 'zoomOut'
+ | 'zoomReset'
+ | 'worktreeHistoryBack'
+ | 'worktreeHistoryForward'
+ | 'dictationKeyDown'
+
+export type WindowShortcutBinding = {
+ key: string
+ code?: string
+ meta?: boolean
+ control?: boolean
+ alt?: boolean
+ shift?: boolean
+}
+
+export type WindowShortcutBindings = Partial<
+ Record
+>
+
+export type WindowShortcutBindingDefinition = {
+ id: WindowShortcutBindingActionId
+ title: string
+ group: 'Global' | 'Navigation' | 'View'
+ keywords: string[]
+ toAction: () => WindowShortcutAction
+ defaultBinding: (platform: NodeJS.Platform) => WindowShortcutBinding
+}
+
+export const WINDOW_SHORTCUT_BINDING_DEFINITIONS: WindowShortcutBindingDefinition[] = [
+ {
+ id: 'openQuickOpen',
+ title: 'Go to File',
+ group: 'Global',
+ keywords: ['shortcut', 'global', 'file'],
+ toAction: () => ({ type: 'openQuickOpen' }),
+ defaultBinding: (platform) => primaryBinding(platform, 'p', 'KeyP')
+ },
+ {
+ id: 'toggleWorktreePalette',
+ title: 'Switch worktree',
+ group: 'Global',
+ keywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'],
+ toAction: () => ({ type: 'toggleWorktreePalette' }),
+ defaultBinding: (platform) => ({
+ ...primaryBinding(platform, 'j', 'KeyJ'),
+ shift: platform !== 'darwin'
+ })
+ },
+ {
+ id: 'openNewWorkspace',
+ title: 'Create worktree',
+ group: 'Global',
+ keywords: ['shortcut', 'global', 'worktree'],
+ toAction: () => ({ type: 'openNewWorkspace' }),
+ defaultBinding: (platform) => primaryBinding(platform, 'n', 'KeyN')
+ },
+ {
+ id: 'toggleLeftSidebar',
+ title: 'Toggle Sidebar',
+ group: 'Global',
+ keywords: ['shortcut', 'sidebar'],
+ toAction: () => ({ type: 'toggleLeftSidebar' }),
+ defaultBinding: (platform) => primaryBinding(platform, 'b', 'KeyB')
+ },
+ {
+ id: 'toggleRightSidebar',
+ title: 'Toggle Right Sidebar',
+ group: 'Global',
+ keywords: ['shortcut', 'sidebar', 'right'],
+ toAction: () => ({ type: 'toggleRightSidebar' }),
+ defaultBinding: (platform) => primaryBinding(platform, 'l', 'KeyL')
+ },
+ {
+ id: 'toggleFloatingTerminal',
+ title: 'Toggle Floating Terminal',
+ group: 'Global',
+ keywords: ['shortcut', 'terminal', 'floating'],
+ toAction: () => ({ type: 'toggleFloatingTerminal' }),
+ defaultBinding: (platform) => ({ ...primaryBinding(platform, 't', 'KeyT'), alt: true })
+ },
+ {
+ id: 'dictationKeyDown',
+ title: 'Dictation',
+ group: 'Global',
+ keywords: ['shortcut', 'dictation', 'voice', 'speech', 'microphone'],
+ toAction: () => ({ type: 'dictationKeyDown' }),
+ defaultBinding: (platform) => primaryBinding(platform, 'e', 'KeyE')
+ },
+ {
+ id: 'worktreeHistoryBack',
+ title: 'Previous worktree',
+ group: 'Navigation',
+ keywords: ['shortcut', 'global', 'worktree', 'history', 'back'],
+ toAction: () => ({ type: 'worktreeHistoryNavigate', direction: 'back' }),
+ defaultBinding: (platform) => ({
+ ...primaryBinding(platform, 'ArrowLeft', 'ArrowLeft'),
+ alt: true
+ })
+ },
+ {
+ id: 'worktreeHistoryForward',
+ title: 'Next worktree',
+ group: 'Navigation',
+ keywords: ['shortcut', 'global', 'worktree', 'history', 'forward'],
+ toAction: () => ({ type: 'worktreeHistoryNavigate', direction: 'forward' }),
+ defaultBinding: (platform) => ({
+ ...primaryBinding(platform, 'ArrowRight', 'ArrowRight'),
+ alt: true
+ })
+ },
+ {
+ id: 'zoomIn',
+ title: 'Zoom In',
+ group: 'View',
+ keywords: ['shortcut', 'zoom', 'in', 'scale'],
+ toAction: () => ({ type: 'zoom', direction: 'in' }),
+ defaultBinding: (platform) =>
+ platform === 'darwin'
+ ? { key: '=', code: 'Equal', meta: true }
+ : { key: '+', code: 'Equal', control: true, shift: true }
+ },
+ {
+ id: 'zoomOut',
+ title: 'Zoom Out',
+ group: 'View',
+ keywords: ['shortcut', 'zoom', 'out', 'scale'],
+ toAction: () => ({ type: 'zoom', direction: 'out' }),
+ defaultBinding: (platform) => primaryBinding(platform, '-', 'Minus')
+ },
+ {
+ id: 'zoomReset',
+ title: 'Reset Size',
+ group: 'View',
+ keywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'],
+ toAction: () => ({ type: 'zoom', direction: 'reset' }),
+ defaultBinding: (platform) => primaryBinding(platform, '0', 'Digit0')
+ }
+]
+
+function primaryBinding(
+ platform: NodeJS.Platform,
+ key: string,
+ code: string
+): WindowShortcutBinding {
+ return platform === 'darwin' ? { key, code, meta: true } : { key, code, control: true }
+}
+
+function modifierValue(value: boolean | undefined): boolean {
+ return value === true
+}
+
+export function isRecordableWindowShortcutBinding(binding: WindowShortcutBinding): boolean {
+ // Why: bare keys and Shift-only chords would steal normal typing from
+ // terminals, editors, and SSH sessions. Custom app shortcuts still need at
+ // least one non-Shift modifier.
+ return modifierValue(binding.meta) || modifierValue(binding.control) || modifierValue(binding.alt)
+}
+
+export function normalizeWindowShortcutBindings(input: unknown): WindowShortcutBindings {
+ if (!input || typeof input !== 'object') {
+ return {}
+ }
+ const validIds = new Set(WINDOW_SHORTCUT_BINDING_DEFINITIONS.map((definition) => definition.id))
+ const result: WindowShortcutBindings = {}
+ for (const [id, value] of Object.entries(input)) {
+ if (!validIds.has(id as WindowShortcutBindingActionId) || !value || typeof value !== 'object') {
+ continue
+ }
+ const binding = value as Partial
+ if (typeof binding.key !== 'string' || binding.key.length === 0) {
+ continue
+ }
+ const normalized: WindowShortcutBinding = {
+ key: binding.key,
+ ...(typeof binding.code === 'string' && binding.code.length > 0
+ ? { code: binding.code }
+ : {}),
+ ...(binding.meta === true ? { meta: true } : {}),
+ ...(binding.control === true ? { control: true } : {}),
+ ...(binding.alt === true ? { alt: true } : {}),
+ ...(binding.shift === true ? { shift: true } : {})
+ }
+ if (isRecordableWindowShortcutBinding(normalized)) {
+ result[id as WindowShortcutBindingActionId] = normalized
+ }
+ }
+ return result
+}
+
+export function effectiveWindowShortcutBinding(
+ actionId: WindowShortcutBindingActionId,
+ platform: NodeJS.Platform,
+ bindings?: WindowShortcutBindings
+): WindowShortcutBinding {
+ const custom = bindings?.[actionId]
+ if (custom && isRecordableWindowShortcutBinding(custom)) {
+ return custom
+ }
+ const definition = WINDOW_SHORTCUT_BINDING_DEFINITIONS.find((item) => item.id === actionId)
+ if (!definition) {
+ throw new Error(`Unknown window shortcut action: ${actionId}`)
+ }
+ return definition.defaultBinding(platform)
+}
+
+export function resolveCustomWindowShortcutAction(
+ input: WindowShortcutInput,
+ bindings?: WindowShortcutBindings
+): WindowShortcutAction | null {
+ const normalizedBindings = normalizeWindowShortcutBindings(bindings)
+ for (const definition of WINDOW_SHORTCUT_BINDING_DEFINITIONS) {
+ const custom = normalizedBindings[definition.id]
+ if (custom && matchesWindowShortcutBinding(input, custom)) {
+ return definition.toAction()
+ }
+ }
+ return null
+}
+
+export function matchesWindowShortcutBinding(
+ input: WindowShortcutInput,
+ binding: WindowShortcutBinding
+): boolean {
+ if (
+ modifierValue(input.meta) !== modifierValue(binding.meta) ||
+ modifierValue(input.control) !== modifierValue(binding.control) ||
+ modifierValue(input.alt) !== modifierValue(binding.alt) ||
+ modifierValue(input.shift) !== modifierValue(binding.shift)
+ ) {
+ return false
+ }
+
+ const bindingKey = normalizeShortcutKey(binding.key)
+ const inputKey = normalizeShortcutKey(input.key ?? '')
+ if (bindingKey && inputKey) {
+ if (isLetter(bindingKey) && isLetter(inputKey)) {
+ return bindingKey === inputKey
+ }
+ if (bindingKey === inputKey) {
+ return true
+ }
+ }
+
+ // Why: `key` is layout-aware and should win for printable characters, but
+ // Electron can emit empty/dead keys for some layouts. `code` keeps custom
+ // bindings reachable as a fallback without making QWERTY position primary.
+ return Boolean(binding.code && input.code && binding.code === input.code)
+}
+
+function isLetter(value: string): boolean {
+ return value.length === 1 && value >= 'a' && value <= 'z'
+}
+
+function normalizeShortcutKey(key: string): string {
+ if (key.length === 1) {
+ return key.toLowerCase()
+ }
+ return key
+}
+
+export function formatWindowShortcutBinding(
+ binding: WindowShortcutBinding,
+ isMac: boolean
+): string[] {
+ const keys: string[] = []
+ if (binding.control) {
+ keys.push(isMac ? '⌃' : 'Ctrl')
+ }
+ if (binding.meta) {
+ keys.push(isMac ? '⌘' : 'Meta')
+ }
+ if (binding.alt) {
+ keys.push(isMac ? '⌥' : 'Alt')
+ }
+ if (binding.shift) {
+ keys.push(isMac ? '⇧' : 'Shift')
+ }
+ keys.push(formatKeyLabel(binding.key, binding.code))
+ return keys
+}
+
+function formatKeyLabel(key: string, code?: string): string {
+ if (key === ' ') {
+ return 'Space'
+ }
+ if (key === 'ArrowUp') {
+ return '↑'
+ }
+ if (key === 'ArrowDown') {
+ return '↓'
+ }
+ if (key === 'ArrowLeft') {
+ return '←'
+ }
+ if (key === 'ArrowRight') {
+ return '→'
+ }
+ if (key === 'Escape') {
+ return 'Esc'
+ }
+ if (key.length === 1) {
+ return key.toUpperCase()
+ }
+ if (key === 'Control' || key === 'Meta' || key === 'Alt' || key === 'Shift') {
+ return code ?? key
+ }
+ return key
+}
diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts
index 177262eace1..891ee6a5e00 100644
--- a/src/shared/window-shortcut-policy.test.ts
+++ b/src/shared/window-shortcut-policy.test.ts
@@ -64,6 +64,50 @@ describe('resolveWindowShortcutAction', () => {
).toEqual({ type: 'jumpToWorktreeIndex', index: 2 })
})
+ it('resolves user-recorded custom window shortcut bindings before defaults', () => {
+ expect(
+ resolveWindowShortcutAction(
+ { code: 'KeyK', key: 'k', meta: true, control: false, alt: true, shift: false },
+ 'darwin',
+ {
+ openQuickOpen: { code: 'KeyK', key: 'k', meta: true, alt: true }
+ }
+ )
+ ).toEqual({ type: 'openQuickOpen' })
+ })
+
+ it('keeps default shortcuts available when custom bindings are absent', () => {
+ expect(
+ resolveWindowShortcutAction(
+ { code: 'KeyP', key: 'p', meta: true, control: false, alt: false, shift: false },
+ 'darwin',
+ {}
+ )
+ ).toEqual({ type: 'openQuickOpen' })
+ })
+
+ it('replaces a default shortcut when that action has a user-recorded binding', () => {
+ expect(
+ resolveWindowShortcutAction(
+ { code: 'KeyP', key: 'p', meta: true, control: false, alt: false, shift: false },
+ 'darwin',
+ {
+ openQuickOpen: { code: 'KeyK', key: 'k', meta: true, alt: true }
+ }
+ )
+ ).toBeNull()
+
+ expect(
+ resolveWindowShortcutAction(
+ { code: 'KeyK', key: 'k', meta: true, control: false, alt: true, shift: false },
+ 'darwin',
+ {
+ openQuickOpen: { code: 'KeyK', key: 'k', meta: true, alt: true }
+ }
+ )
+ ).toEqual({ type: 'openQuickOpen' })
+ })
+
it('requires shift for the non-mac worktree palette shortcut', () => {
expect(
resolveWindowShortcutAction(
diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts
index 4633a9428e0..04c9a06c769 100644
--- a/src/shared/window-shortcut-policy.ts
+++ b/src/shared/window-shortcut-policy.ts
@@ -1,3 +1,10 @@
+import {
+ normalizeWindowShortcutBindings,
+ resolveCustomWindowShortcutAction,
+ type WindowShortcutBindingActionId,
+ type WindowShortcutBindings
+} from './window-shortcut-bindings'
+
export type WindowShortcutInput = {
type?: string
key?: string
@@ -125,12 +132,27 @@ function matchesLetterShortcut(
export function resolveWindowShortcutAction(
input: WindowShortcutInput,
- platform: NodeJS.Platform
+ platform: NodeJS.Platform,
+ customBindings?: WindowShortcutBindings
): WindowShortcutAction | null {
+ const normalizedCustomBindings = normalizeWindowShortcutBindings(customBindings)
+ const isCustomized = (actionId: WindowShortcutBindingActionId): boolean =>
+ Boolean(normalizedCustomBindings[actionId])
+
+ const customAction = resolveCustomWindowShortcutAction(input, normalizedCustomBindings)
+ if (customAction) {
+ return customAction
+ }
+
// Why: evaluate the history-navigate chord BEFORE the standard modifier-chord
// gate because that gate rejects Alt. The predicate already narrows to
// ArrowLeft/ArrowRight so only those two codes reach here.
if (isHistoryNavigateChord(input, platform)) {
+ if (
+ isCustomized(input.code === 'ArrowLeft' ? 'worktreeHistoryBack' : 'worktreeHistoryForward')
+ ) {
+ return null
+ }
return {
type: 'worktreeHistoryNavigate',
direction: input.code === 'ArrowLeft' ? 'back' : 'forward'
@@ -138,6 +160,9 @@ export function resolveWindowShortcutAction(
}
if (isFloatingTerminalChord(input, platform)) {
+ if (isCustomized('toggleFloatingTerminal')) {
+ return null
+ }
return { type: 'toggleFloatingTerminal' }
}
@@ -146,14 +171,23 @@ export function resolveWindowShortcutAction(
}
if (isZoomInShortcut(input)) {
+ if (isCustomized('zoomIn')) {
+ return null
+ }
return { type: 'zoom', direction: 'in' }
}
if (isZoomOutShortcut(input)) {
+ if (isCustomized('zoomOut')) {
+ return null
+ }
return { type: 'zoom', direction: 'out' }
}
if (input.key === '0' && !input.shift) {
+ if (isCustomized('zoomReset')) {
+ return null
+ }
return { type: 'zoom', direction: 'reset' }
}
@@ -161,6 +195,9 @@ export function resolveWindowShortcutAction(
matchesLetterShortcut(input, 'j', 'KeyJ') &&
((platform === 'darwin' && !input.shift) || (platform !== 'darwin' && input.shift))
) {
+ if (isCustomized('toggleWorktreePalette')) {
+ return null
+ }
return { type: 'toggleWorktreePalette' }
}
@@ -169,14 +206,23 @@ export function resolveWindowShortcutAction(
// the renderer's window-capture handler can preventDefault, causing ^B / ^L
// to appear in the terminal alongside the sidebar toggle.
if (matchesLetterShortcut(input, 'b', 'KeyB') && !input.shift) {
+ if (isCustomized('toggleLeftSidebar')) {
+ return null
+ }
return { type: 'toggleLeftSidebar' }
}
if (matchesLetterShortcut(input, 'l', 'KeyL') && !input.shift) {
+ if (isCustomized('toggleRightSidebar')) {
+ return null
+ }
return { type: 'toggleRightSidebar' }
}
if (matchesLetterShortcut(input, 'p', 'KeyP') && !input.shift) {
+ if (isCustomized('openQuickOpen')) {
+ return null
+ }
return { type: 'openQuickOpen' }
}
@@ -188,6 +234,9 @@ export function resolveWindowShortcutAction(
// the unified composer now exposes source switching inside the name field.
if (matchesLetterShortcut(input, 'n', 'KeyN')) {
if (!input.alt) {
+ if (isCustomized('openNewWorkspace')) {
+ return null
+ }
return { type: 'openNewWorkspace' }
}
}
@@ -201,6 +250,9 @@ export function resolveWindowShortcutAction(
// dictation controller is responsible for forwarding the chord through to
// the PTY when dictation is intentionally disabled or the user is mid-input.
if (matchesLetterShortcut(input, 'e', 'KeyE') && !input.shift) {
+ if (isCustomized('dictationKeyDown')) {
+ return null
+ }
return { type: 'dictationKeyDown' }
}