From 24c323659fd2c9f4e6e9143d7d71df3b84779e37 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 29 Apr 2026 17:08:39 -0700 Subject: [PATCH 1/3] Prove cleared Cmd-D split shortcut should pass through The regression captures the desired shortcut contract before the implementation exists: clearing the split-right shortcut must make Cmd-D fall through instead of creating another pane. Constraint: Local direct xcodebuild is forbidden for this task; verification runs through allowed reload/CI paths. Confidence: medium Scope-risk: narrow Tested: Not run locally; this test-only commit is expected to fail before the implementation adds the unbound shortcut state. Not-tested: Local cmux-unit execution because the user explicitly forbade direct xcodebuild. --- .../AppDelegateShortcutRoutingTests.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index e55c8a5eb526..57434438e8b4 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -1275,6 +1275,56 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active") } + func testCmdDPropagatesWhenSplitRightShortcutIsCleared() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window, manager, and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialPanelCount = workspace.panels.count + + withTemporaryShortcut(action: .splitRight, shortcut: .unbound) { + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: event), + "Cleared Cmd+D split shortcut should not be consumed by cmux" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual( + workspace.panels.count, + initialPanelCount, + "Cleared Cmd+D split shortcut should propagate instead of creating a new pane" + ) + } + func testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") From ba39f7e64fec33a25a1e230d7a5030903e7a850d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 29 Apr 2026 18:49:37 -0700 Subject: [PATCH 2/3] Make cleared split shortcuts persist as pass-through Cmd-D was registered in multiple layers: KeyboardShortcutSettings covered the app-level matcher, while SwiftUI/AppKit menu equivalents and Ghostty's default super+d split binding could still create a split after the setting was cleared or remapped. Persist an explicit unbound shortcut, expose Clear in the keyboard shortcut recorder, source split menus from KeyboardShortcutSettings, suppress stale default menu equivalents, and remove Ghostty's default split fallbacks so KeyboardShortcutSettings is the single owner of split shortcuts. The reload helper also now selects a target-matching Zig binary for Ghostty helper builds, because the required reload command otherwise picked the x86 Zig from /usr/local on this arm64 host and failed before launch. Constraint: Direct xcodebuild is forbidden; build verification uses reload.sh only Rejected: Treat clear as reset | reset restores Cmd-D and keeps terminal apps from receiving it Rejected: Only suppress the Swift app shortcut matcher | stale AppKit menu equivalents and Ghostty super+d still split Rejected: Leave Ghostty defaults active | forwarded Cmd-D still triggers Ghostty new_split:right Confidence: high Scope-risk: moderate Tested: git diff --check; bash -n scripts/reload.sh; bash -n scripts/build-ghostty-cli-helper.sh; Localizable.xcstrings JSON parse; cmux settings schema JSON parse; ./scripts/reload.sh --tag issue-2524-cmd-d-rebindable --launch; debug socket remap Cmd+J kept Cmd+D pane count at 15 and Cmd+J increased it to 16; debug socket clear kept Cmd+D pane count at 16; debug socket default restored Cmd+D split to 17 panes Not-tested: Local cmux-unit via xcodebuild because explicitly forbidden; SwiftPM test path cannot build app target because CMUXDebugLog is not in Package.swift; XCUITests are CI-only; Settings UI automation blocked by macOS Apple Events permission -1743 Related: https://github.com/manaflow-ai/cmux/issues/2524 --- Resources/Localizable.xcstrings | 34 ++++ Sources/AppDelegate.swift | 73 ++++++- Sources/CmuxConfig.swift | 6 +- Sources/GhosttyTerminalView.swift | 36 +++- Sources/KeyboardShortcutSettings.swift | 69 ++++++- Sources/TerminalController.swift | 13 +- Sources/cmuxApp.swift | 5 +- .../AppDelegateShortcutRoutingTests.swift | 189 ++++++++++++++++++ scripts/build-ghostty-cli-helper.sh | 56 +++++- scripts/reload.sh | 11 +- .../[locale]/docs/keyboard-shortcuts/page.tsx | 3 +- web/data/cmux-settings.schema.json | 4 +- 12 files changed, 483 insertions(+), 16 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 777009f7f1b0..f08d1d31dfd3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -72114,6 +72114,23 @@ } } }, + "shortcut.recorder.clear": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリア" + } + } + } + }, "shortcut.recorder.pendingChord": { "extractionState": "manual", "localizations": { @@ -72273,6 +72290,23 @@ } } }, + "shortcut.unbound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + } + } + }, "shortcut.key.mediaBrightnessDown": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9450f31efa8b..ce0c4343d062 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10668,7 +10668,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Split actions: Cmd+D / Cmd+Shift+D + // Configured split actions. if matchConfiguredShortcut(event: event, action: .splitRight) { #if DEBUG cmuxDebugLog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") @@ -11685,6 +11685,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func matchConfiguredShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -11705,6 +11706,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: KeyboardShortcutSettings.Action ) -> Int? { let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard !shortcut.isUnbound else { return nil } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -11723,6 +11725,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowKeyCode: UInt16 ) -> Bool { let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, shortcut.firstStroke == prefix else { @@ -11865,6 +11868,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent shortcut.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider) } + private func matchesKeyboardShortcutEvent( + _ event: NSEvent, + action: KeyboardShortcutSettings.Action, + shortcut: StoredShortcut + ) -> Bool { + guard !shortcut.isUnbound else { return false } + if action.usesNumberedDigitMatching { + return numberedShortcutDigit(event: event, shortcut: shortcut) != nil + } + guard !shortcut.hasChord else { return false } + return matchShortcut(event: event, shortcut: shortcut) + } + + func shouldSuppressStaleCmuxMenuShortcut(event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } + if event.window is NSPanel || NSApp.keyWindow is NSPanel || NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + return false + } + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags.contains(.command) else { return false } + + for action in KeyboardShortcutSettings.Action.allCases where action != .showHideAllWindows { + let currentShortcut = KeyboardShortcutSettings.shortcut(for: action) + if matchesKeyboardShortcutEvent(event, action: action, shortcut: currentShortcut) { + return false + } + } + + for action in KeyboardShortcutSettings.Action.allCases where action != .showHideAllWindows { + if matchesKeyboardShortcutEvent(event, action: action, shortcut: action.defaultShortcut) { + return true + } + } + return false + } + private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) @@ -12980,6 +13021,22 @@ private extension NSApplication { } } #endif + if AppDelegate.shared?.shouldSuppressStaleCmuxMenuShortcut(event: event) == true { + let responder = event.window?.firstResponder + ?? keyWindow?.firstResponder + ?? mainWindow?.firstResponder + if let ghosttyView = cmuxOwningGhosttyView(for: responder) { + ghosttyView.keyDown(with: event) +#if DEBUG + cmuxDebugLog("app.sendEvent suppressed stale cmux menu shortcut and forwarded to terminal") +#endif + } else { +#if DEBUG + cmuxDebugLog("app.sendEvent suppressed stale cmux menu shortcut") +#endif + } + return + } cmux_applicationSendEvent(event) } } @@ -13304,6 +13361,20 @@ private extension NSWindow { Self.cmuxOwningWebView(for: $0, in: self, event: event) } let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) + if AppDelegate.shared?.shouldSuppressStaleCmuxMenuShortcut(event: event) == true { + if let firstResponderGhosttyView { + firstResponderGhosttyView.keyDown(with: event) +#if DEBUG + cmuxDebugLog(" → terminal received command equivalent bypassing stale cmux menu shortcut") +#endif + return true + } +#if DEBUG + cmuxDebugLog(" → suppressed stale cmux menu shortcut") +#endif + return false + } + if let ghosttyView = firstResponderGhosttyView { // If the IME is composing and the key has no Cmd modifier, don't intercept — // let it flow through normal AppKit event dispatch so the input method can diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index fec499f0a916..da8294261bbe 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -941,7 +941,7 @@ struct CmuxConfigActionDefinition: Codable, Sendable, Hashable { throw DecodingError.dataCorruptedError( forKey: key, in: container, - debugDescription: "shortcut must use modifier+key syntax like 'cmd+shift+t'" + debugDescription: "shortcut must use modifier+key syntax like 'cmd+shift+t' or be empty to unbind" ) } return shortcut @@ -969,6 +969,10 @@ struct CmuxConfigActionDefinition: Codable, Sendable, Hashable { in container: inout KeyedEncodingContainer ) throws { guard let shortcut else { return } + if shortcut.isUnbound { + try container.encode("", forKey: key) + return + } if let secondStroke = shortcut.secondStroke { try container.encode( [shortcut.firstStroke.configString(), secondStroke.configString()], diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 92c02e618395..ede02c0ca344 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1930,6 +1930,7 @@ class GhosttyApp { prefix: "cmux-shell-integration-override", logLabel: "shell integration override (fallback)" ) + loadCmuxOwnedGhosttyKeybindOverrides(fallbackConfig) let fallbackRenderingModeChanged = setUsesHostLayerBackground( true, source: "initialize.fallbackConfig" @@ -2098,11 +2099,27 @@ class GhosttyApp { prefix: "cmux-shell-integration-override", logLabel: "shell integration override" ) + loadCmuxOwnedGhosttyKeybindOverrides(config) ghostty_config_finalize(config) return renderingModeChanged } + private func loadCmuxOwnedGhosttyKeybindOverrides(_ config: ghostty_config_t) { + // cmux owns these split shortcuts through KeyboardShortcutSettings. + // Remove Ghostty's default fallbacks so remapped or cleared shortcuts + // can reach the focused terminal instead of creating a split. + loadInlineGhosttyConfig( + """ + keybind = super+d=unbind + keybind = super+shift+d=unbind + """, + into: config, + prefix: "cmux-owned-split-keybind-overrides", + logLabel: "cmux-owned split keybind overrides" + ) + } + /// When the user has not configured `font-codepoint-map` for CJK ranges /// and has not already provided an explicit multi-entry `font-family` /// fallback chain, Ghostty's `CTFontCollection` scoring may pick an @@ -6674,6 +6691,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = performBindingAction("paste_from_clipboard") } + private func applyConfiguredMenuShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + /// Validates whether edit menu items (copy, paste, split) should be enabled. func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { @@ -8669,10 +8697,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let splitHorizontallyItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.splitHorizontally", defaultValue: "Split Horizontally"), action: #selector(splitHorizontally(_:)), - keyEquivalent: "d" + keyEquivalent: "" ) splitHorizontallyItem.target = self - splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift] + applyConfiguredMenuShortcut(KeyboardShortcutSettings.shortcut(for: .splitDown), to: splitHorizontallyItem) splitHorizontallyItem.image = NSImage( systemSymbolName: "rectangle.bottomhalf.inset.filled", accessibilityDescription: nil @@ -8681,10 +8709,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let splitVerticallyItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.splitVertically", defaultValue: "Split Vertically"), action: #selector(splitVertically(_:)), - keyEquivalent: "d" + keyEquivalent: "" ) splitVerticallyItem.target = self - splitVerticallyItem.keyEquivalentModifierMask = [.command] + applyConfiguredMenuShortcut(KeyboardShortcutSettings.shortcut(for: .splitRight), to: splitVerticallyItem) splitVerticallyItem.image = NSImage( systemSymbolName: "rectangle.righthalf.inset.filled", accessibilityDescription: nil diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 994b60ce606b..505138a6a937 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -351,6 +351,9 @@ enum KeyboardShortcutSettings { proposedAction: Action, configuredShortcut: StoredShortcut ) -> Bool { + guard !proposedShortcut.isUnbound, !configuredShortcut.isUnbound else { + return false + } guard conflictScope == proposedAction.conflictScope else { return false } @@ -363,6 +366,10 @@ enum KeyboardShortcutSettings { } func normalizedRecordedShortcutResult(_ shortcut: StoredShortcut) -> RecordedShortcutResolution { + guard !shortcut.isUnbound else { + return .accepted(shortcut) + } + if let conflictingAction = KeyboardShortcutSettings.conflictingAction( for: shortcut, excluding: self @@ -376,6 +383,10 @@ enum KeyboardShortcutSettings { func resolvedRecordedShortcutIgnoringConflicts( _ shortcut: StoredShortcut ) -> RecordedShortcutResolution { + guard !shortcut.isUnbound else { + return .accepted(shortcut) + } + switch self { case .showHideAllWindows: return KeyboardShortcutSettings.normalizedSystemWideHotkeyShortcutResult(shortcut) @@ -474,7 +485,6 @@ enum KeyboardShortcutSettings { } private static let hardcodedSystemWideHotkeyConflicts: [StoredShortcut] = [ - StoredShortcut(key: "d", command: true, shift: false, option: false, control: false), StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true), StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true), StoredShortcut(key: "`", command: true, shift: false, option: false, control: false), @@ -513,6 +523,10 @@ enum KeyboardShortcutSettings { _ configuredShortcut: StoredShortcut, configuredUsesNumberedDigitMatching: Bool ) -> Bool { + guard !proposedShortcut.isUnbound, !configuredShortcut.isUnbound else { + return false + } + switch (proposedShortcut.hasChord, configuredShortcut.hasChord) { case (false, false): return shortcutStrokeMatchersConflict( @@ -703,6 +717,10 @@ enum KeyboardShortcutSettings { postDidChangeNotification(action: action) } + static func clearShortcut(for action: Action) { + setShortcut(.unbound, for: action) + } + static func resetAll() { for action in Action.allCases { UserDefaults.standard.removeObject(forKey: action.defaultsKey) @@ -1838,6 +1856,8 @@ struct StoredShortcut: Codable, Equatable, Hashable { ) } + static let unbound = StoredShortcut(key: "", command: false, shift: false, option: false, control: false) + private enum CodingKeys: String, CodingKey { case key case command @@ -1894,11 +1914,24 @@ struct StoredShortcut: Codable, Equatable, Hashable { ) } + var isUnbound: Bool { + key.isEmpty && + !command && + !shift && + !option && + !control && + keyCode == nil && + secondStroke == nil + } + var hasChord: Bool { secondStroke != nil } var displayString: String { + if isUnbound { + return String(localized: "shortcut.unbound", defaultValue: "None") + } if let secondStroke { return "\(firstStroke.displayString) \(secondStroke.displayString)" } @@ -1906,6 +1939,9 @@ struct StoredShortcut: Codable, Equatable, Hashable { } var numberedDisplayString: String { + if isUnbound { + return displayString + } if hasChord { return numberedDigitHintPrefix + "1…9" } @@ -1958,6 +1994,7 @@ struct StoredShortcut: Codable, Equatable, Hashable { event: NSEvent, layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) ) -> Bool { + guard !isUnbound else { return false } guard !hasChord else { return false } return firstStroke.matches(event: event, layoutCharacterProvider: layoutCharacterProvider) } @@ -1968,6 +2005,7 @@ struct StoredShortcut: Codable, Equatable, Hashable { eventCharacter: String?, layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) ) -> Bool { + guard !isUnbound else { return false } guard !hasChord else { return false } return firstStroke.matches( keyCode: keyCode, @@ -1978,6 +2016,7 @@ struct StoredShortcut: Codable, Equatable, Hashable { } var carbonHotKeyRegistration: CarbonHotKeyRegistration? { + guard !isUnbound else { return nil } guard !hasChord else { return nil } return firstStroke.carbonHotKeyRegistration } @@ -2113,11 +2152,21 @@ extension ShortcutStroke { extension StoredShortcut { static func parseConfig(_ rawValue: String) -> StoredShortcut? { - parseConfig(strokes: [rawValue]) + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || ["none", "clear", "unbound"].contains(trimmed.lowercased()) { + return .unbound + } + return parseConfig(strokes: [rawValue]) } static func parseConfig(strokes: [String]) -> StoredShortcut? { guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + if strokes.count == 1, let first = strokes.first { + let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || ["none", "clear", "unbound"].contains(trimmed.lowercased()) { + return .unbound + } + } let parsedStrokes = strokes.compactMap(ShortcutStroke.parseConfig(_:)) guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { return nil @@ -2128,6 +2177,9 @@ extension StoredShortcut { } var configIdentifier: String { + if isUnbound { + return "" + } if let secondStroke { return "\(firstStroke.configString()) \(secondStroke.configString())" } @@ -2285,6 +2337,7 @@ struct KeyboardShortcutRecorder: View { var validationMessage: String? = nil var validationButtonTitle: String? = nil var onValidationButtonPressed: (() -> Void)? = nil + var onClearButtonPressed: (() -> Void)? = nil var undoButtonTitle: String? = nil var onUndoButtonPressed: (() -> Void)? = nil var hasPendingRejection: Bool = false @@ -2318,6 +2371,18 @@ struct KeyboardShortcutRecorder: View { ) .frame(width: 160) .disabled(isDisabled) + + if let onClearButtonPressed { + Button(String(localized: "shortcut.recorder.clear", defaultValue: "Clear")) { + KeyboardShortcutRecorderActivity.stopAllRecording() + onClearButtonPressed() + onRecorderFeedbackChanged(nil) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isDisabled || shortcut.isUnbound) + .accessibilityIdentifier("ShortcutRecorderClearButton") + } } if let validationMessage { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 19e6c25d1de3..75be675a8d5c 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -12099,6 +12099,10 @@ class TerminalController { action = .focusUp case "focus_down", "focusdown": action = .focusDown + case "split_right", "splitright": + action = .splitRight + case "split_down", "splitdown": + action = .splitDown case "workspace_digits", "workspace_number", "select_workspace_by_number": action = .selectWorkspaceByNumber case "surface_digits", "surface_number", "select_surface_by_number": @@ -12108,10 +12112,15 @@ class TerminalController { } guard let action else { - return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down, workspace_digits, surface_digits" + return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down, split_right, split_down, workspace_digits, surface_digits" } - if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { + if combo.lowercased() == "clear" || combo.lowercased() == "unbound" || combo.lowercased() == "none" { + KeyboardShortcutSettings.clearShortcut(for: action) + return "OK" + } + + if combo.lowercased() == "default" || combo.lowercased() == "reset" { KeyboardShortcutSettings.resetShortcut(for: action) return "OK" } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a2109f48a53f..c4abf81f26c4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -8146,6 +8146,7 @@ private struct ShortcutRecorderSettingsControl: View { var subtitle: String? = nil var displayString: (StoredShortcut) -> String = { $0.displayString } var isDisabled: Bool = false + var allowsClearing: Bool = true @State private var rejectedAttempt: ShortcutRecorderRejectedAttempt? @@ -8161,6 +8162,7 @@ private struct ShortcutRecorderSettingsControl: View { onValidationButtonPressed: validationPresentation?.canSwap == true ? { swapConflictingShortcut() } : nil, + onClearButtonPressed: allowsClearing ? { shortcut = .unbound } : nil, undoButtonTitle: validationPresentation?.undoButtonTitle, onUndoButtonPressed: rejectedAttempt != nil ? { rejectedAttempt = nil } : nil, hasPendingRejection: rejectedAttempt != nil, @@ -8255,7 +8257,8 @@ private struct GlobalHotkeySection: View { action: SystemWideHotkeySettings.action, shortcut: $shortcut, subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: SystemWideHotkeySettings.action), - isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(SystemWideHotkeySettings.action) + isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(SystemWideHotkeySettings.action), + allowsClearing: false ) .padding(.horizontal, 14) .padding(.vertical, 9) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 57434438e8b4..b1383787bb19 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -11,8 +11,17 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } +private final class MenuActionProbe: NSObject { + var callCount = 0 + + @objc func perform(_ sender: Any?) { + callCount += 1 + } +} private final class GhosttyCommandEquivalentProbeView: GhosttyNSView { var afterMenuMissCallCount = 0 + var keyDownCallCount = 0 + var lastKeyDownCharactersIgnoringModifiers: String? var pasteCallCount = 0 var pasteAsPlainTextCallCount = 0 var performAfterMenuMissResult = true @@ -22,6 +31,11 @@ private final class GhosttyCommandEquivalentProbeView: GhosttyNSView { return performAfterMenuMissResult } + override func keyDown(with event: NSEvent) { + keyDownCallCount += 1 + lastKeyDownCharactersIgnoringModifiers = event.charactersIgnoringModifiers + } + override func paste(_ sender: Any?) { pasteCallCount += 1 } @@ -4641,6 +4655,181 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + func testWindowPerformKeyEquivalentForwardsClearedCmdDPastStaleMenuShortcut() { + let previousMainMenu = NSApp.mainMenu + let probeWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) + let probeView = GhosttyCommandEquivalentProbeView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let menuProbe = MenuActionProbe() + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let staleMenu = NSMenu(title: "Test") + let staleSplitItem = NSMenuItem( + title: "Split Right", + action: #selector(MenuActionProbe.perform(_:)), + keyEquivalent: "d" + ) + staleSplitItem.keyEquivalentModifierMask = [.command] + staleSplitItem.target = menuProbe + staleMenu.addItem(staleSplitItem) + NSApp.mainMenu = staleMenu + + probeWindow.contentView = contentView + contentView.addSubview(probeView) + probeWindow.makeKeyAndOrderFront(nil) + probeWindow.displayIfNeeded() + XCTAssertTrue(probeWindow.makeFirstResponder(probeView), "Expected probe Ghostty view to own first responder") + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + + withTemporaryShortcut(action: .splitRight, shortcut: .unbound) { + XCTAssertTrue( + probeWindow.performKeyEquivalent(with: event), + "Cleared Cmd+D should still be handled by forwarding it to the focused terminal" + ) + } + + XCTAssertEqual(menuProbe.callCount, 0, "A stale menu equivalent must not keep consuming cleared Cmd+D") + XCTAssertEqual(probeView.keyDownCallCount, 1, "Cleared Cmd+D should be forwarded into the terminal") + XCTAssertEqual(probeView.lastKeyDownCharactersIgnoringModifiers, "d") + } + + func testWindowPerformKeyEquivalentSuppressesRemappedCmdDStaleMenuShortcut() { + let previousMainMenu = NSApp.mainMenu + let probeWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) + let focusableView = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let menuProbe = MenuActionProbe() + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let staleMenu = NSMenu(title: "Test") + let staleSplitItem = NSMenuItem( + title: "Split Right", + action: #selector(MenuActionProbe.perform(_:)), + keyEquivalent: "d" + ) + staleSplitItem.keyEquivalentModifierMask = [.command] + staleSplitItem.target = menuProbe + staleMenu.addItem(staleSplitItem) + NSApp.mainMenu = staleMenu + + probeWindow.contentView = contentView + contentView.addSubview(focusableView) + probeWindow.makeKeyAndOrderFront(nil) + probeWindow.displayIfNeeded() + XCTAssertTrue(probeWindow.makeFirstResponder(focusableView), "Expected probe view to own first responder") + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + + let remappedSplitRight = StoredShortcut( + key: "j", + command: true, + shift: false, + option: false, + control: false + ) + withTemporaryShortcut(action: .splitRight, shortcut: remappedSplitRight) { + XCTAssertFalse( + probeWindow.performKeyEquivalent(with: event), + "Remapped Cmd+D should not be consumed by stale cmux menu equivalents" + ) + } + + XCTAssertEqual(menuProbe.callCount, 0, "Cmd+D must not keep splitting after splitRight is remapped") + } + + func testApplicationSendEventSuppressesRemappedCmdDStaleMenuShortcut() { + let previousMainMenu = NSApp.mainMenu + let probeWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: probeWindow.contentRect(forFrameRect: probeWindow.frame)) + let focusableView = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let menuProbe = MenuActionProbe() + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let staleMenu = NSMenu(title: "Test") + let staleSplitItem = NSMenuItem( + title: "Split Right", + action: #selector(MenuActionProbe.perform(_:)), + keyEquivalent: "d" + ) + staleSplitItem.keyEquivalentModifierMask = [.command] + staleSplitItem.target = menuProbe + staleMenu.addItem(staleSplitItem) + NSApp.mainMenu = staleMenu + + probeWindow.contentView = contentView + contentView.addSubview(focusableView) + probeWindow.makeKeyAndOrderFront(nil) + probeWindow.displayIfNeeded() + XCTAssertTrue(probeWindow.makeFirstResponder(focusableView), "Expected probe view to own first responder") + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + + let remappedSplitRight = StoredShortcut( + key: "j", + command: true, + shift: false, + option: false, + control: false + ) + withTemporaryShortcut(action: .splitRight, shortcut: remappedSplitRight) { + NSApp.sendEvent(event) + } + + XCTAssertEqual(menuProbe.callCount, 0, "App-level Cmd+D dispatch must not fire a stale split menu item after remap") + } + func testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index 043a1449a2ab..2f685dab54e6 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -95,6 +95,43 @@ if ! command -v zig >/dev/null 2>&1; then exit 1 fi +zig_binary_arch() { + local zig_bin="$1" + file "$zig_bin" 2>/dev/null | grep -oE '(arm64|x86_64)' | head -1 || true +} + +zig_binary_for_arch() { + local desired_arch="$1" + local default_zig + default_zig="$(command -v zig 2>/dev/null || true)" + + local candidates=( + /opt/homebrew/bin/zig + /usr/local/bin/zig + "$default_zig" + ) + local candidate="" + local seen=" " + for candidate in "${candidates[@]}"; do + [[ -n "$candidate" && -x "$candidate" ]] || continue + if [[ "$seen" == *" $candidate "* ]]; then + continue + fi + seen="${seen}${candidate} " + if [[ "$(zig_binary_arch "$candidate")" == "$desired_arch" ]]; then + echo "$candidate" + return 0 + fi + done + + if [[ -n "$default_zig" ]]; then + echo "$default_zig" + return 0 + fi + + return 1 +} + if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then echo "error: Ghostty submodule is missing at $GHOSTTY_DIR" >&2 exit 1 @@ -103,8 +140,25 @@ fi build_helper() { local prefix="$1" local target="${2:-}" + local zig_bin + local target_arch="" + + case "$target" in + aarch64-macos) target_arch="arm64" ;; + x86_64-macos) target_arch="x86_64" ;; + esac + + if [[ -n "$target_arch" ]]; then + zig_bin="$(zig_binary_for_arch "$target_arch")" + if [[ "$(zig_binary_arch "$zig_bin")" == "$target_arch" ]]; then + target="" + fi + else + zig_bin="$(command -v zig)" + fi + local args=( - zig build + "$zig_bin" build cli-helper -Dapp-runtime=none -Demit-macos-app=false diff --git a/scripts/reload.sh b/scripts/reload.sh index ba4c66d52de9..8fd20479dfb3 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -612,7 +612,16 @@ if [[ -d "$PWD/ghostty" ]]; then if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then echo "Skipping direct ghostty CLI helper zig build (CMUX_SKIP_ZIG_BUILD=1)" else - (cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast) + GHOSTTY_HELPER_TARGET="" + case "$(/usr/bin/arch)" in + arm64) GHOSTTY_HELPER_TARGET="aarch64-macos" ;; + i386|x86_64) GHOSTTY_HELPER_TARGET="x86_64-macos" ;; + esac + if [[ -n "$GHOSTTY_HELPER_TARGET" ]]; then + "$PWD/scripts/build-ghostty-cli-helper.sh" --target "$GHOSTTY_HELPER_TARGET" --output "$GHOSTTY_HELPER_SRC" + else + "$PWD/scripts/build-ghostty-cli-helper.sh" --output "$GHOSTTY_HELPER_SRC" + fi fi fi if [[ -x "$CMUXD_SRC" ]]; then diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index 005a4c517430..d0fd33d67f74 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -11,7 +11,8 @@ const shortcutChordExample = `{ "bindings": { "newSurface": ["ctrl+b", "c"], "showNotifications": ["ctrl+b", "i"], - "toggleSidebar": "cmd+b" + "toggleSidebar": "cmd+b", + "splitRight": "" } } }`; diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index cafbb769b811..2d3185129563 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -510,7 +510,7 @@ "bindings": { "type": "object", "default": {}, - "description": "Shortcut overrides keyed by cmux action id. Use a string for a single shortcut or an array for a chord.", + "description": "Shortcut overrides keyed by cmux action id. Use a string for a single shortcut, an array for a chord, or an empty string to unbind.", "propertyNames": { "enum": [ "openSettings", @@ -604,7 +604,7 @@ "oneOf": [ { "type": "string", - "description": "Single-stroke shortcut, for example cmd+n." + "description": "Single-stroke shortcut, for example cmd+n. Use an empty string, none, clear, or unbound to unbind." }, { "type": "array", From c20f6f2c6859de19fc994fb30e59873c55d83beb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 29 Apr 2026 19:00:43 -0700 Subject: [PATCH 3/3] Refresh Swift length budget for Cmd-D shortcut tests CI failed in workflow-guard-tests because the shortcut regression tests and pass-through fix intentionally grew several already-budgeted Swift files. Refresh the checked-in budget with scripts/swift_file_length_budget.py so the guard reflects the accepted debt from this PR and the current origin/main merge. Constraint: User requested making the PR pass CI/CD after merging origin/main Rejected: Remove the regression coverage | the added tests are the proof that clearing/remapping Cmd-D no longer splits Confidence: high Scope-risk: narrow Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv --write-budget; python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv; ./tests/test_ci_swift_file_length_budget.sh; git diff --check Not-tested: xcodebuild/unit tests locally remain forbidden by task instruction Related: https://github.com/manaflow-ai/cmux/pull/3338 --- .github/swift-file-length-budget.tsv | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5c63c546e601..06ed91bf8695 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,26 +2,26 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 19963 CLI/cmux.swift -16745 Sources/TerminalController.swift -15874 Sources/ContentView.swift -14157 Sources/AppDelegate.swift +16754 Sources/TerminalController.swift +15872 Sources/ContentView.swift +14228 Sources/AppDelegate.swift 13686 Sources/Workspace.swift -13354 Sources/GhosttyTerminalView.swift -10603 Sources/Panels/BrowserPanel.swift -8406 Sources/cmuxApp.swift -7495 Sources/TabManager.swift +13382 Sources/GhosttyTerminalView.swift +10600 Sources/Panels/BrowserPanel.swift +8409 Sources/cmuxApp.swift +7492 Sources/TabManager.swift 6811 Sources/Panels/BrowserPanelView.swift -5218 cmuxTests/AppDelegateShortcutRoutingTests.swift +5457 cmuxTests/AppDelegateShortcutRoutingTests.swift 4646 cmuxTests/WorkspaceUnitTests.swift 4639 cmuxTests/TerminalAndGhosttyTests.swift 4503 cmuxTests/GhosttyConfigTests.swift -4469 cmuxTests/WorkspaceRemoteConnectionTests.swift +4465 cmuxTests/WorkspaceRemoteConnectionTests.swift 4324 Sources/BrowserWindowPortal.swift 3840 Sources/Feed/FeedPanelView.swift -3660 cmuxTests/BrowserConfigTests.swift +3605 cmuxTests/BrowserConfigTests.swift 3229 cmuxTests/BrowserPanelTests.swift -2826 Sources/CmuxConfig.swift -2679 Sources/KeyboardShortcutSettings.swift +2830 Sources/CmuxConfig.swift +2744 Sources/KeyboardShortcutSettings.swift 2609 Sources/SessionIndexView.swift 2491 Sources/Panels/CmuxWebView.swift 2292 cmuxTests/TabManagerUnitTests.swift @@ -29,10 +29,10 @@ 2153 cmuxTests/SessionPersistenceTests.swift 2112 Sources/Update/UpdateTitlebarAccessory.swift 2026 cmuxTests/CJKIMEInputTests.swift +1951 cmuxTests/WindowAndDragTests.swift 1949 Sources/FileExplorerView.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1941 Sources/SessionIndexStore.swift -1951 cmuxTests/WindowAndDragTests.swift 1784 cmuxTests/ShortcutAndCommandPaletteTests.swift 1692 cmuxTests/CmuxConfigTests.swift 1676 Sources/KeyboardShortcutSettingsFileStore.swift @@ -54,8 +54,8 @@ 844 Sources/WorkspaceContentView.swift 834 Sources/RestorableAgentSession.swift 829 cmuxUITests/TerminalCmdClickUITests.swift -807 Sources/TerminalSSHSessionDetector.swift 809 cmuxUITests/BonsplitTabDragUITests.swift +807 Sources/TerminalSSHSessionDetector.swift 785 Sources/App/TerminalDirectoryOpenSupport.swift 761 Sources/GhosttyConfig.swift 754 Sources/MainWindowFocusController.swift