diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 607fdb56..e0cabdaa 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1176,6 +1176,33 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( ) != nil } +func shouldRouteTerminalCommandShortcutToGhostty( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + terminalHasSelection: Bool +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalizedFlags.contains(.command) else { return false } + + let normalizedChars = chars.lowercased() + if normalizedFlags == [.command] { + // Keep Preferences (Cmd+,) menu-routed even when a terminal is focused. + if normalizedChars == "," || keyCode == 43 { + return false + } + + // Preserve standard copy behavior when text is selected in the terminal. + if (normalizedChars == "c" || keyCode == 8), terminalHasSelection { + return false + } + } + + return true +} + func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { guard let responder else { return nil } if let ghosttyView = responder as? GhosttyNSView { @@ -8428,6 +8455,23 @@ private extension NSWindow { return true } + // Support custom tmux prefixes (for example Cmd+C): when the terminal is focused + // and no app-level shortcut matched, prefer forwarding Command-key input to the + // terminal rather than consuming it as a menu key equivalent. + if let ghosttyView = firstResponderGhosttyView, + shouldRouteTerminalCommandShortcutToGhostty( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + terminalHasSelection: ghosttyView.terminalSurface?.hasSelection() ?? false + ) { + ghosttyView.keyDown(with: event) +#if DEBUG + dlog(" → ghostty command passthrough") +#endif + return true + } + // When the terminal is focused, skip the full NSWindow.performKeyEquivalent // (which walks the SwiftUI content view hierarchy) and dispatch Command-key // events directly to the main menu. This avoids the broken SwiftUI focus path. diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3e0d1c72..dc2ecb6f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2228,6 +2228,71 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { } } +final class TerminalCommandShortcutRoutingPolicyTests: XCTestCase { + func testRoutesCommandCToTerminalWhenNoSelection() { + XCTAssertTrue( + shouldRouteTerminalCommandShortcutToGhostty( + flags: [.command], + chars: "c", + keyCode: 8, // kVK_ANSI_C + terminalHasSelection: false + ) + ) + } + + func testKeepsCommandCCopyMenuRoutedWhenSelectionExists() { + XCTAssertFalse( + shouldRouteTerminalCommandShortcutToGhostty( + flags: [.command], + chars: "c", + keyCode: 8, // kVK_ANSI_C + terminalHasSelection: true + ) + ) + } + + func testKeepsCommandCommaMenuRoutedForPreferences() { + XCTAssertFalse( + shouldRouteTerminalCommandShortcutToGhostty( + flags: [.command], + chars: ",", + keyCode: 43, // kVK_ANSI_Comma + terminalHasSelection: false + ) + ) + } + + func testRequiresCommandModifier() { + XCTAssertFalse( + shouldRouteTerminalCommandShortcutToGhostty( + flags: [.control], + chars: "c", + keyCode: 8, + terminalHasSelection: false + ) + ) + } + + func testRoutesOtherCommandShortcutsToTerminal() { + XCTAssertTrue( + shouldRouteTerminalCommandShortcutToGhostty( + flags: [.command, .option], + chars: "c", + keyCode: 8, + terminalHasSelection: false + ) + ) + XCTAssertTrue( + shouldRouteTerminalCommandShortcutToGhostty( + flags: [.command], + chars: "v", + keyCode: 9, // kVK_ANSI_V + terminalHasSelection: false + ) + ) + } +} + final class GhosttyResponderResolutionTests: XCTestCase { private final class FocusProbeView: NSView { override var acceptsFirstResponder: Bool { true }