Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -63003,6 +63003,23 @@
}
}
},
"settings.section.globalHotkey": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Global Hotkey"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "グローバルホットキー"
}
}
}
},
"settings.section.keyboardShortcuts": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -63479,6 +63496,91 @@
}
}
},
"settings.globalHotkey.enable": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enable System-Wide Hotkey"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "システム全体のホットキーを有効にする"
}
}
}
},
"settings.globalHotkey.enable.subtitleOff": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Turn this on to show or hide all cmux windows from any app."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "どのアプリからでも cmux のすべてのウィンドウを表示または非表示にするにはオンにします。"
}
}
}
},
"settings.globalHotkey.enable.subtitleOn": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Press the shortcut from any app to show or hide all cmux windows."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "どのアプリからでもこのショートカットで cmux のすべてのウィンドウを表示または非表示にできます。"
}
}
}
},
"settings.globalHotkey.note": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Use Command, Option, or Control with another key. No extra macOS permission is required."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Command、Option、または Control と別のキーを組み合わせてください。追加の macOS 権限は不要です。"
}
}
}
},
"settings.globalHotkey.shortcut": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show/Hide All Windows"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "すべてのウィンドウを表示/非表示"
}
}
}
},
"settings.shortcuts.managedByFile": {
"extractionState": "manual",
"localizations": {
Expand Down
1 change: 1 addition & 0 deletions Resources/shell-integration/.zshenv
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ fi
# interactive command executes.
builtin export CMUX_ZSH_RESTORE_TERM="$TERM"
builtin export TERM="xterm-ghostty"
builtin typeset -g _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=1
fi
} always {
if [[ -o interactive ]]; then
Expand Down
9 changes: 8 additions & 1 deletion Resources/shell-integration/cmux-zsh-integration.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0
typeset -g _CMUX_WINCH_GUARD_INSTALLED=0
typeset -g _CMUX_TMUX_PUSH_SIGNATURE=""
typeset -g _CMUX_TMUX_PULL_SIGNATURE=""
typeset -g _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=${_CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT:-0}
typeset -ga _CMUX_TMUX_SYNC_KEYS=(
CMUX_BUNDLED_CLI_PATH
CMUX_BUNDLE_ID
Expand Down Expand Up @@ -1064,7 +1065,9 @@ _cmux_command_starts_nested_shell() {
}

_cmux_preexec() {
_cmux_restore_terminal_identity_after_startup
if (( ! _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT )); then
_cmux_restore_terminal_identity_after_startup
fi
_cmux_tmux_sync_cmux_environment
local cmd="${1## }"

Expand Down Expand Up @@ -1099,6 +1102,9 @@ _cmux_preexec() {

_cmux_precmd() {
local last_status=$?
if (( _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT )); then
_CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=0
fi
_cmux_stop_git_head_watch
_cmux_tmux_sync_cmux_environment

Expand Down Expand Up @@ -1277,6 +1283,7 @@ _cmux_restore_terminal_identity_after_startup() {
builtin export TERM="$CMUX_ZSH_RESTORE_TERM"
builtin unset CMUX_ZSH_RESTORE_TERM
fi
_CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=0
}

_cmux_zshexit() {
Expand Down
181 changes: 13 additions & 168 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2503,6 +2503,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// UI tests run on a shared VM user profile, so persisted shortcuts can drift and make
// key-equivalent routing flaky. Force defaults for deterministic tests.
if isRunningUnderXCTest {
SystemWideHotkeySettings.reset()
KeyboardShortcutSettings.resetAll()
}
#endif
Expand Down Expand Up @@ -2590,6 +2591,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
installBrowserAddressBarFocusObservers()
installShortcutMonitor()
installShortcutDefaultsObserver()
SystemWideHotkeyController.shared.start()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the global hotkey inside KeyboardShortcutSettings.

Starting a separate SystemWideHotkeyController/SystemWideHotkeySettings path gives this cmux-owned shortcut its own persistence source of truth instead of KeyboardShortcutSettings, so settings.json management and the existing file-managed no-op semantics won’t apply. Please model the global hotkey as a KeyboardShortcutSettings.Action and have the controller consume that value.

Based on learnings: Every new cmux-owned keyboard shortcut must be added to KeyboardShortcutSettings and supported in ~/.config/cmux/settings.json; file-managed shortcuts must remain a no-op in KeyboardShortcutSettings.setShortcut(...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` at line 2594, The code starts
SystemWideHotkeyController directly (SystemWideHotkeyController.shared.start()),
which creates a separate persistence path; instead model the global hotkey as a
KeyboardShortcutSettings.Action and have the hotkey controller consume that
value. Change the flow so the global shortcut is declared/registered in
KeyboardShortcutSettings (add a new Action enum case for the cmux-owned global
hotkey), ensure KeyboardShortcutSettings.setShortcut(...) treats file-managed
shortcuts as no-ops as before, and update SystemWideHotkeyController to read the
shortcut from KeyboardShortcutSettings (e.g., observe the
KeyboardShortcutSettings.Action value) rather than using
SystemWideHotkeySettings; remove direct calls to
SystemWideHotkeyController.shared.start() and wire controller lifecycle to the
KeyboardShortcutSettings-backed value.

NSApp.servicesProvider = self
#if DEBUG
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
Expand Down Expand Up @@ -10271,8 +10273,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}

private func refreshConfiguredShortcutChordActions() {
configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter {
KeyboardShortcutSettings.shortcut(for: $0).hasChord
configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { action in
// showHideAllWindows is dispatched via Carbon RegisterEventHotKey
// (SystemWideHotkeyController) and never routed through AppKit's
// local key handler. If a managed settings.json entry happened to
// store it as a chord, arming the prefix here would swallow the
// first stroke and leave the second one orphaned, breaking that
// keystroke for the focused terminal/browser input.
guard action != .showHideAllWindows else { return false }
return KeyboardShortcutSettings.shortcut(for: action).hasChord
}
}

Expand Down Expand Up @@ -12271,90 +12280,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent

/// Match a shortcut stroke against an event, handling normal keys.
private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool {
// Some keys can include extra flags (e.g. .function) depending on the responder chain.
// Strip those for consistent matching across first responders (terminal, WebKit, etc).
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard flags == stroke.modifierFlags else { return false }

let shortcutKey = stroke.key.lowercased()
if shortcutKey == "\r" {
return event.keyCode == 36 || event.keyCode == 76
}

let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers
if shortcutCharacterMatches(
eventCharacter: eventCharsIgnoringModifiers,
shortcutKey: shortcutKey,
applyShiftSymbolNormalization: flags.contains(.shift),
eventKeyCode: event.keyCode
) {
return true
}

// For command-based shortcuts, trust AppKit's layout-aware characters when present.
// Keep this strict for letter shortcuts to avoid physical-key collisions across layouts,
// while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts.
// When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.),
// charactersIgnoringModifiers returns non-ASCII characters that can never match
// a Latin shortcut key — skip this guard and fall through to layout-based matching.
let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true)
let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true
let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true
if shortcutKeyIsDigit,
hasEventChars,
eventCharsAreASCII,
digitForNumberKeyCode(event.keyCode) == nil {
return false
}
if hasEventChars,
eventCharsAreASCII,
flags.contains(.command),
!flags.contains(.control),
shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) {
return false
}

// Match using the current keyboard layout so Command shortcuts stay character-based
// across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys.
let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags)
if shortcutCharacterMatches(
eventCharacter: layoutCharacter,
shortcutKey: shortcutKey,
applyShiftSymbolNormalization: false,
eventKeyCode: event.keyCode
) {
return true
}

// Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace),
// so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for
// command punctuation shortcuts, since some non-US layouts report different characters
// for the same physical key even when menu-equivalent semantics should still apply.
// When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.),
// event chars carry no usable Latin key identity. Always allow keyCode fallback as a
// safety net — even when the layout-based translation resolved a character, the
// physical key code is the definitive identifier for the intended shortcut.
// For empty-character events (synthetic/browser key equivalents), preserve the original
// behavior: only fall back when the layout translation also failed.
let hasUsableEventChars = hasEventChars && eventCharsAreASCII
let allowANSIKeyCodeFallback = flags.contains(.control)
Comment thread
cursor[bot] marked this conversation as resolved.
|| (flags.contains(.command)
&& !flags.contains(.control)
&& (
!shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey)
|| (hasEventChars && !eventCharsAreASCII)
|| (!hasEventChars && (layoutCharacter?.isEmpty ?? true))
))
if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) {
return event.keyCode == expectedKeyCode
}
return false
stroke.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider)
}

private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool {
guard !shortcut.hasChord else { return false }
return matchShortcutStroke(event: event, stroke: shortcut.firstStroke)
shortcut.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider)
}

private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? {
Expand Down Expand Up @@ -12408,30 +12338,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return digit
}

private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool {
guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else {
return false
}
return CharacterSet.letters.contains(scalar)
}

private func shortcutCharacterMatches(
eventCharacter: String?,
shortcutKey: String,
applyShiftSymbolNormalization: Bool,
eventKeyCode: UInt16
) -> Bool {
guard let eventCharacter, !eventCharacter.isEmpty else { return false }
if normalizedShortcutEventCharacter(
eventCharacter,
applyShiftSymbolNormalization: applyShiftSymbolNormalization,
eventKeyCode: eventKeyCode
) == shortcutKey {
return true
}
return false
}

private func normalizedShortcutEventCharacter(
_ eventCharacter: String,
applyShiftSymbolNormalization: Bool,
Expand Down Expand Up @@ -12466,67 +12372,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}

private func keyCodeForShortcutKey(_ key: String) -> UInt16? {
// Matches macOS ANSI key codes. This is intentionally limited to keys we
// support in StoredShortcut/ghostty trigger translation.
switch key {
case "a": return 0 // kVK_ANSI_A
case "s": return 1 // kVK_ANSI_S
case "d": return 2 // kVK_ANSI_D
case "f": return 3 // kVK_ANSI_F
case "h": return 4 // kVK_ANSI_H
case "g": return 5 // kVK_ANSI_G
case "z": return 6 // kVK_ANSI_Z
case "x": return 7 // kVK_ANSI_X
case "c": return 8 // kVK_ANSI_C
case "v": return 9 // kVK_ANSI_V
case "b": return 11 // kVK_ANSI_B
case "q": return 12 // kVK_ANSI_Q
case "w": return 13 // kVK_ANSI_W
case "e": return 14 // kVK_ANSI_E
case "r": return 15 // kVK_ANSI_R
case "y": return 16 // kVK_ANSI_Y
case "t": return 17 // kVK_ANSI_T
case "1": return 18 // kVK_ANSI_1
case "2": return 19 // kVK_ANSI_2
case "3": return 20 // kVK_ANSI_3
case "4": return 21 // kVK_ANSI_4
case "6": return 22 // kVK_ANSI_6
case "5": return 23 // kVK_ANSI_5
case "=": return 24 // kVK_ANSI_Equal
case "9": return 25 // kVK_ANSI_9
case "7": return 26 // kVK_ANSI_7
case "-": return 27 // kVK_ANSI_Minus
case "8": return 28 // kVK_ANSI_8
case "0": return 29 // kVK_ANSI_0
case "]": return 30 // kVK_ANSI_RightBracket
case "o": return 31 // kVK_ANSI_O
case "u": return 32 // kVK_ANSI_U
case "[": return 33 // kVK_ANSI_LeftBracket
case "i": return 34 // kVK_ANSI_I
case "p": return 35 // kVK_ANSI_P
case "l": return 37 // kVK_ANSI_L
case "j": return 38 // kVK_ANSI_J
case "'": return 39 // kVK_ANSI_Quote
case "k": return 40 // kVK_ANSI_K
case ";": return 41 // kVK_ANSI_Semicolon
case "\\": return 42 // kVK_ANSI_Backslash
case ",": return 43 // kVK_ANSI_Comma
case "/": return 44 // kVK_ANSI_Slash
case "n": return 45 // kVK_ANSI_N
case "m": return 46 // kVK_ANSI_M
case ".": return 47 // kVK_ANSI_Period
case "`": return 50 // kVK_ANSI_Grave
case "\r": return 36 // kVK_Return
case "←": return 123 // kVK_LeftArrow
case "→": return 124 // kVK_RightArrow
case "↓": return 125 // kVK_DownArrow
case "↑": return 126 // kVK_UpArrow
default:
return nil
}
}

private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? {
switch keyCode {
case 18: return 1 // kVK_ANSI_1
Expand Down
Loading
Loading