From e125fc301f3fb9628ca4e83c403294c1121a5e3c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Apr 2026 17:23:11 -0700 Subject: [PATCH 1/7] test: cover terminal Cmd+V fallback when menu misses --- cmuxTests/CJKIMEInputTests.swift | 153 +++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index b5adf57da377..3d4995fc79e3 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -10,6 +10,8 @@ import ObjectiveC.runtime private var cjkIMEInterpretKeyEventsSwizzled = false private var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)? +private var ghosttyPasteActionSwizzled = false +private var ghosttyPasteActionHook: ((GhosttyNSView, Any?) -> Void)? private extension GhosttyNSView { @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) { @@ -18,6 +20,11 @@ private extension GhosttyNSView { } cmuxUnitTest_interpretKeyEvents(eventArray) } + + @objc func cmuxUnitTest_paste(_ sender: Any?) { + ghosttyPasteActionHook?(self, sender) + cmuxUnitTest_paste(sender) + } } private func installCJKIMEInterpretKeyEventsSwizzle() { @@ -52,6 +59,38 @@ private func installCJKIMEInterpretKeyEventsSwizzle() { cjkIMEInterpretKeyEventsSwizzled = true } +private func installGhosttyPasteActionSwizzle() { + guard !ghosttyPasteActionSwizzled else { return } + + let originalSelector = #selector(GhosttyNSView.paste(_:)) + let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_paste(_:)) + + guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else { + fatalError("Unable to locate GhosttyNSView paste methods for swizzling") + } + + let didAddMethod = class_addMethod( + GhosttyNSView.self, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + GhosttyNSView.self, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + ghosttyPasteActionSwizzled = true +} + private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? { if let view = view as? GhosttyNSView { return view @@ -1470,6 +1509,10 @@ final class GhosttyBackquoteRegressionTests: XCTestCase { @MainActor final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { + private struct PasteboardItemSnapshot { + let representations: [(type: NSPasteboard.PasteboardType, data: Data)] + } + private struct HostedTerminalWindow { let surface: TerminalSurface let window: NSWindow @@ -1516,6 +1559,48 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { ) } + private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { + guard let items = pasteboard.pasteboardItems else { return [] } + return items.map { item in + let representations = item.types.compactMap { type -> (NSPasteboard.PasteboardType, Data)? in + guard let data = item.data(forType: type) else { return nil } + return (type, data) + } + return PasteboardItemSnapshot(representations: representations) + } + } + + private func restorePasteboardItems( + _ snapshots: [PasteboardItemSnapshot], + to pasteboard: NSPasteboard + ) { + pasteboard.clearContents() + guard !snapshots.isEmpty else { return } + let items = snapshots.compactMap { snapshot -> NSPasteboardItem? in + let item = NSPasteboardItem() + guard !snapshot.representations.isEmpty else { return nil } + for representation in snapshot.representations { + item.setData(representation.data, forType: representation.type) + } + return item + } + if !items.isEmpty { + _ = pasteboard.writeObjects(items) + } + } + + private func installUnrelatedMainMenu() -> NSMenu { + let mainMenu = NSMenu() + let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") + let fileMenu = NSMenu(title: "File") + let item = NSMenuItem(title: "New", action: nil, keyEquivalent: "n") + item.keyEquivalentModifierMask = [.command] + fileMenu.addItem(item) + mainMenu.addItem(fileItem) + mainMenu.setSubmenu(fileMenu, for: fileItem) + return mainMenu + } + func testShiftSlashPrintableKeyEquivalentBypassesShortcutPath() throws { let hostedTerminal = try makeHostedTerminalWindow() let window = hostedTerminal.window @@ -1581,6 +1666,74 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { ) } } + + func testCommandVPasteStillInvokesTerminalPasteWhenMainMenuMisses() throws { + installGhosttyPasteActionSwizzle() + + let hostedTerminal = try makeHostedTerminalWindow() + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + defer { window.orderOut(nil) } + + window.makeFirstResponder(surfaceView) + XCTAssertNotNil(surfaceView.terminalSurface) + + let previousMainMenu = NSApp.mainMenu + NSApp.mainMenu = installUnrelatedMainMenu() + defer { NSApp.mainMenu = previousMainMenu } + + let pasteboard = NSPasteboard.general + let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) + defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } + pasteboard.clearContents() + pasteboard.setString("opencode paste", forType: .string) + + var pasteInvocationCount = 0 + let previousPasteHook = ghosttyPasteActionHook + ghosttyPasteActionHook = { _, _ in + pasteInvocationCount += 1 + } + defer { ghosttyPasteActionHook = previousPasteHook } + + var forwardedCommandVCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } + forwardedCommandVCount += 1 + } + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + ) else { + XCTFail("Failed to construct Cmd+V event") + return + } + + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 1, + "Cmd+V should still invoke the terminal paste action even if the window main-menu fast path misses" + ) + XCTAssertEqual( + forwardedCommandVCount, + 0, + "Cmd+V should not fall back to Ghostty keyDown when the terminal paste action is available" + ) + } } @MainActor From 81052f42008b164d7b83e1e91448598fe57736b7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Apr 2026 17:24:42 -0700 Subject: [PATCH 2/7] fix: preserve terminal paste when menu shortcut misses --- Sources/AppDelegate.swift | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0a5cb6e40a4f..02740ec32ac2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1938,6 +1938,26 @@ func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { return true } +private enum TerminalPasteCommandEquivalent { + case paste + case pasteAsPlainText +} + +private func terminalPasteCommandEquivalent(for event: NSEvent) -> TerminalPasteCommandEquivalent? { + guard event.keyCode == 9 else { return nil } // V key (hardware position, layout-independent) + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + switch normalizedFlags { + case [.command]: + return .paste + case [.command, .shift]: + return .pasteAsPlainText + default: + return nil + } +} + private enum BrowserFindCommandEquivalent { case find case findNext @@ -14506,6 +14526,24 @@ private extension NSWindow { } } + if let ghosttyView = firstResponderGhosttyView, + let pasteCommand = terminalPasteCommandEquivalent(for: event), + GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) { + // If the window-level direct-to-menu path misses Cmd+V, do not let the + // event fall through to generic keyDown routing. Bracketed-paste TUIs + // such as OpenCode need the dedicated terminal paste action. + switch pasteCommand { + case .paste: + ghosttyView.paste(nil) + case .pasteAsPlainText: + ghosttyView.pasteAsPlainText(nil) + } +#if DEBUG + dlog(" → consumed by direct terminal paste fallback") +#endif + return true + } + let result = cmux_performKeyEquivalent(with: event) #if DEBUG if result { dlog(" → consumed by original performKeyEquivalent") } From 2796ae49807cb3ae9b6160218824dd6cddd2b5fc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Apr 2026 19:50:40 -0700 Subject: [PATCH 3/7] Add regression coverage for paste surface recovery --- cmuxTests/CJKIMEInputTests.swift | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 3d4995fc79e3..0d5aa7462c16 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -1734,6 +1734,69 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { "Cmd+V should not fall back to Ghostty keyDown when the terminal paste action is available" ) } + + func testCommandVPasteRecreatesReleasedSurfaceBeforeConsumption() throws { + installGhosttyPasteActionSwizzle() + + let hostedTerminal = try makeHostedTerminalWindow() + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + defer { window.orderOut(nil) } + + window.makeFirstResponder(surfaceView) + XCTAssertNotNil(surfaceView.terminalSurface) + XCTAssertNotNil(hostedTerminal.surface.surface) + + let previousMainMenu = NSApp.mainMenu + NSApp.mainMenu = installUnrelatedMainMenu() + defer { NSApp.mainMenu = previousMainMenu } + + let pasteboard = NSPasteboard.general + let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) + defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } + pasteboard.clearContents() + pasteboard.setString("surface recovery paste", forType: .string) + + var pasteInvocationCount = 0 + let previousPasteHook = ghosttyPasteActionHook + ghosttyPasteActionHook = { _, _ in + pasteInvocationCount += 1 + } + defer { ghosttyPasteActionHook = previousPasteHook } + + hostedTerminal.surface.releaseSurfaceForTesting() + XCTAssertNil( + hostedTerminal.surface.surface, + "Expected the runtime Ghostty surface to be released before simulating Cmd+V" + ) + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + ) else { + XCTFail("Failed to construct Cmd+V event") + return + } + + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 1, + "Cmd+V should still invoke the terminal paste action after a transient surface release" + ) + XCTAssertNotNil( + hostedTerminal.surface.surface, + "Cmd+V should recreate the Ghostty surface before the direct terminal paste fallback consumes the shortcut" + ) + } } @MainActor From 3bc13afbccc176370097ff9aecbae3743e97357c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Apr 2026 19:50:51 -0700 Subject: [PATCH 4/7] Recover Ghostty surface before consuming paste shortcuts --- Sources/AppDelegate.swift | 21 +++++++++++++++++---- Sources/GhosttyTerminalView.swift | 11 +++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 02740ec32ac2..fe96950832c1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14532,16 +14532,29 @@ private extension NSWindow { // If the window-level direct-to-menu path misses Cmd+V, do not let the // event fall through to generic keyDown routing. Bracketed-paste TUIs // such as OpenCode need the dedicated terminal paste action. + let handledDirectPaste: Bool switch pasteCommand { case .paste: - ghosttyView.paste(nil) + handledDirectPaste = ghosttyView.prepareSurfaceForPaste( + reason: "window.performKeyEquivalent.paste.missingSurface" + ) + if handledDirectPaste { + ghosttyView.paste(nil) + } case .pasteAsPlainText: - ghosttyView.pasteAsPlainText(nil) + handledDirectPaste = ghosttyView.prepareSurfaceForPaste( + reason: "window.performKeyEquivalent.pasteAsPlainText.missingSurface" + ) + if handledDirectPaste { + ghosttyView.pasteAsPlainText(nil) + } } + if handledDirectPaste { #if DEBUG - dlog(" → consumed by direct terminal paste fallback") + dlog(" → consumed by direct terminal paste fallback") #endif - return true + return true + } } let result = cmux_performKeyEquivalent(with: event) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4d9855bfde47..dfc677be288f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5927,6 +5927,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } + @discardableResult + func prepareSurfaceForPaste(reason: String) -> Bool { + guard ensureSurfaceReadyForInput() != nil else { + requestInputRecoveryAfterSurfaceMiss(reason: reason) + return false + } + return true + } + func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in @@ -6149,11 +6158,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // MARK: - Clipboard paste @IBAction func paste(_ sender: Any?) { + guard prepareSurfaceForPaste(reason: "paste.missingSurface") else { return } _ = performBindingAction("paste_from_clipboard") } /// Pastes clipboard text as plain text, stripping any rich formatting. @IBAction func pasteAsPlainText(_ sender: Any?) { + guard prepareSurfaceForPaste(reason: "pasteAsPlainText.missingSurface") else { return } _ = performBindingAction("paste_from_clipboard") } From 1194c3966d02d31ebf207d26751e50826e895bfa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Apr 2026 20:00:30 -0700 Subject: [PATCH 5/7] Tighten terminal paste regression tests --- cmuxTests/CJKIMEInputTests.swift | 202 +++++++++++++++++++++++++++---- 1 file changed, 176 insertions(+), 26 deletions(-) diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 0d5aa7462c16..57f51e49634d 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -12,6 +12,8 @@ private var cjkIMEInterpretKeyEventsSwizzled = false private var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)? private var ghosttyPasteActionSwizzled = false private var ghosttyPasteActionHook: ((GhosttyNSView, Any?) -> Void)? +private var ghosttyPasteAsPlainTextActionSwizzled = false +private var ghosttyPasteAsPlainTextActionHook: ((GhosttyNSView, Any?) -> Void)? private extension GhosttyNSView { @objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) { @@ -25,6 +27,11 @@ private extension GhosttyNSView { ghosttyPasteActionHook?(self, sender) cmuxUnitTest_paste(sender) } + + @objc func cmuxUnitTest_pasteAsPlainText(_ sender: Any?) { + ghosttyPasteAsPlainTextActionHook?(self, sender) + cmuxUnitTest_pasteAsPlainText(sender) + } } private func installCJKIMEInterpretKeyEventsSwizzle() { @@ -89,6 +96,36 @@ private func installGhosttyPasteActionSwizzle() { } ghosttyPasteActionSwizzled = true + + guard !ghosttyPasteAsPlainTextActionSwizzled else { return } + + let plainTextOriginalSelector = #selector(GhosttyNSView.pasteAsPlainText(_:)) + let plainTextSwizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_pasteAsPlainText(_:)) + + guard let plainTextOriginalMethod = class_getInstanceMethod(GhosttyNSView.self, plainTextOriginalSelector), + let plainTextSwizzledMethod = class_getInstanceMethod(GhosttyNSView.self, plainTextSwizzledSelector) else { + fatalError("Unable to locate GhosttyNSView pasteAsPlainText methods for swizzling") + } + + let didAddPlainTextMethod = class_addMethod( + GhosttyNSView.self, + plainTextOriginalSelector, + method_getImplementation(plainTextSwizzledMethod), + method_getTypeEncoding(plainTextSwizzledMethod) + ) + + if didAddPlainTextMethod { + class_replaceMethod( + GhosttyNSView.self, + plainTextSwizzledSelector, + method_getImplementation(plainTextOriginalMethod), + method_getTypeEncoding(plainTextOriginalMethod) + ) + } else { + method_exchangeImplementations(plainTextOriginalMethod, plainTextSwizzledMethod) + } + + ghosttyPasteAsPlainTextActionSwizzled = true } private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? { @@ -1671,6 +1708,7 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { installGhosttyPasteActionSwizzle() let hostedTerminal = try makeHostedTerminalWindow() + let terminalSurface = hostedTerminal.surface let window = hostedTerminal.window let surfaceView = hostedTerminal.surfaceView defer { window.orderOut(nil) } @@ -1690,7 +1728,9 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { var pasteInvocationCount = 0 let previousPasteHook = ghosttyPasteActionHook - ghosttyPasteActionHook = { _, _ in + ghosttyPasteActionHook = { candidateView, sender in + previousPasteHook?(candidateView, sender) + guard candidateView === surfaceView else { return } pasteInvocationCount += 1 } defer { ghosttyPasteActionHook = previousPasteHook } @@ -1722,30 +1762,120 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { return } - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual( - pasteInvocationCount, - 1, - "Cmd+V should still invoke the terminal paste action even if the window main-menu fast path misses" - ) - XCTAssertEqual( - forwardedCommandVCount, - 0, - "Cmd+V should not fall back to Ghostty keyDown when the terminal paste action is available" - ) + withExtendedLifetime(terminalSurface) { + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 1, + "Cmd+V should still invoke the terminal paste action even if the window main-menu fast path misses" + ) + XCTAssertEqual( + forwardedCommandVCount, + 0, + "Cmd+V should not fall back to Ghostty keyDown when the terminal paste action is available" + ) + } + } + + func testCommandShiftVPasteAsPlainTextStillInvokesTerminalFallbackWhenMainMenuMisses() throws { + installGhosttyPasteActionSwizzle() + + let hostedTerminal = try makeHostedTerminalWindow() + let terminalSurface = hostedTerminal.surface + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + defer { window.orderOut(nil) } + + window.makeFirstResponder(surfaceView) + XCTAssertNotNil(surfaceView.terminalSurface) + + let previousMainMenu = NSApp.mainMenu + NSApp.mainMenu = installUnrelatedMainMenu() + defer { NSApp.mainMenu = previousMainMenu } + + let pasteboard = NSPasteboard.general + let pasteboardSnapshot = snapshotPasteboardItems(pasteboard) + defer { restorePasteboardItems(pasteboardSnapshot, to: pasteboard) } + pasteboard.clearContents() + pasteboard.setString("opencode paste plain text", forType: .string) + + var pasteInvocationCount = 0 + let previousPasteHook = ghosttyPasteActionHook + ghosttyPasteActionHook = { candidateView, sender in + previousPasteHook?(candidateView, sender) + guard candidateView === surfaceView else { return } + pasteInvocationCount += 1 + } + defer { ghosttyPasteActionHook = previousPasteHook } + + var pasteAsPlainTextInvocationCount = 0 + let previousPasteAsPlainTextHook = ghosttyPasteAsPlainTextActionHook + ghosttyPasteAsPlainTextActionHook = { candidateView, sender in + previousPasteAsPlainTextHook?(candidateView, sender) + guard candidateView === surfaceView else { return } + pasteAsPlainTextInvocationCount += 1 + } + defer { ghosttyPasteAsPlainTextActionHook = previousPasteAsPlainTextHook } + + var forwardedCommandVCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } + forwardedCommandVCount += 1 + } + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "V", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + ) else { + XCTFail("Failed to construct Cmd+Shift+V event") + return + } + + withExtendedLifetime(terminalSurface) { + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 0, + "Cmd+Shift+V should route through pasteAsPlainText instead of the regular terminal paste action" + ) + XCTAssertEqual( + pasteAsPlainTextInvocationCount, + 1, + "Cmd+Shift+V should still invoke the terminal pasteAsPlainText action even if the window main-menu fast path misses" + ) + XCTAssertEqual( + forwardedCommandVCount, + 0, + "Cmd+Shift+V should not fall back to Ghostty keyDown when the terminal plain-text paste action is available" + ) + } } func testCommandVPasteRecreatesReleasedSurfaceBeforeConsumption() throws { installGhosttyPasteActionSwizzle() let hostedTerminal = try makeHostedTerminalWindow() + let terminalSurface = hostedTerminal.surface let window = hostedTerminal.window let surfaceView = hostedTerminal.surfaceView defer { window.orderOut(nil) } window.makeFirstResponder(surfaceView) XCTAssertNotNil(surfaceView.terminalSurface) - XCTAssertNotNil(hostedTerminal.surface.surface) + XCTAssertNotNil(terminalSurface.surface) let previousMainMenu = NSApp.mainMenu NSApp.mainMenu = installUnrelatedMainMenu() @@ -1759,14 +1889,27 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { var pasteInvocationCount = 0 let previousPasteHook = ghosttyPasteActionHook - ghosttyPasteActionHook = { _, _ in + ghosttyPasteActionHook = { candidateView, sender in + previousPasteHook?(candidateView, sender) + guard candidateView === surfaceView else { return } pasteInvocationCount += 1 } defer { ghosttyPasteActionHook = previousPasteHook } - hostedTerminal.surface.releaseSurfaceForTesting() + var forwardedCommandVCount = 0 + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 9 else { return } + forwardedCommandVCount += 1 + } + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + } + + terminalSurface.releaseSurfaceForTesting() XCTAssertNil( - hostedTerminal.surface.surface, + terminalSurface.surface, "Expected the runtime Ghostty surface to be released before simulating Cmd+V" ) @@ -1786,16 +1929,23 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { return } - XCTAssertTrue(window.performKeyEquivalent(with: event)) - XCTAssertEqual( - pasteInvocationCount, - 1, - "Cmd+V should still invoke the terminal paste action after a transient surface release" - ) - XCTAssertNotNil( - hostedTerminal.surface.surface, - "Cmd+V should recreate the Ghostty surface before the direct terminal paste fallback consumes the shortcut" - ) + withExtendedLifetime(terminalSurface) { + XCTAssertTrue(window.performKeyEquivalent(with: event)) + XCTAssertEqual( + pasteInvocationCount, + 1, + "Cmd+V should still invoke the terminal paste action after a transient surface release" + ) + XCTAssertEqual( + forwardedCommandVCount, + 0, + "Cmd+V should recover the Ghostty surface without falling back to keyDown" + ) + XCTAssertNotNil( + terminalSurface.surface, + "Cmd+V should recreate the Ghostty surface before the direct terminal paste fallback consumes the shortcut" + ) + } } } From 06c6d3499e38adb2125fe7eba1ea107a1ba75cc1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Apr 2026 20:02:12 -0700 Subject: [PATCH 6/7] Organize terminal paste fallback regression tests --- cmuxTests/CJKIMEInputTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 57f51e49634d..ee970dc8d429 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -1545,7 +1545,7 @@ final class GhosttyBackquoteRegressionTests: XCTestCase { } @MainActor -final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { +final class GhosttyKeyEquivalentRegressionTests: XCTestCase { private struct PasteboardItemSnapshot { let representations: [(type: NSPasteboard.PasteboardType, data: Data)] } @@ -1704,6 +1704,8 @@ final class GhosttyPrintableShiftKeyEquivalentRegressionTests: XCTestCase { } } + // MARK: - Terminal Paste Fallback + func testCommandVPasteStillInvokesTerminalPasteWhenMainMenuMisses() throws { installGhosttyPasteActionSwizzle() From a90bc5480ccc9f22e3efb7e50dbcab9e5e6a5c1e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Apr 2026 20:33:49 -0700 Subject: [PATCH 7/7] Honor Ghostty keybindings after paste menu miss --- Sources/AppDelegate.swift | 59 +++------------- Sources/GhosttyTerminalView.swift | 11 ++- .../AppDelegateShortcutRoutingTests.swift | 68 +++++++++++++++++++ 3 files changed, 85 insertions(+), 53 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fe96950832c1..e368e575a448 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1938,26 +1938,6 @@ func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { return true } -private enum TerminalPasteCommandEquivalent { - case paste - case pasteAsPlainText -} - -private func terminalPasteCommandEquivalent(for event: NSEvent) -> TerminalPasteCommandEquivalent? { - guard event.keyCode == 9 else { return nil } // V key (hardware position, layout-independent) - - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - switch normalizedFlags { - case [.command]: - return .paste - case [.command, .shift]: - return .pasteAsPlainText - default: - return nil - } -} - private enum BrowserFindCommandEquivalent { case find case findNext @@ -14517,41 +14497,18 @@ private extension NSWindow { } #endif if !consumedByMenu { - // Fall through to the original performKeyEquivalent path below. - } else { + // After a direct-to-menu miss, let Ghostty resolve the command key + // through its normal binding path so user key overrides still win. + let consumedByGhostty = firstResponderGhosttyView?.performKeyEquivalentAfterMenuMiss(with: event) == true #if DEBUG - dlog(" → consumed by mainMenu (bypassed SwiftUI)") + dlog(" → mainMenu miss; ghostty command path: \(consumedByGhostty)") #endif - return true - } - } - - if let ghosttyView = firstResponderGhosttyView, - let pasteCommand = terminalPasteCommandEquivalent(for: event), - GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) { - // If the window-level direct-to-menu path misses Cmd+V, do not let the - // event fall through to generic keyDown routing. Bracketed-paste TUIs - // such as OpenCode need the dedicated terminal paste action. - let handledDirectPaste: Bool - switch pasteCommand { - case .paste: - handledDirectPaste = ghosttyView.prepareSurfaceForPaste( - reason: "window.performKeyEquivalent.paste.missingSurface" - ) - if handledDirectPaste { - ghosttyView.paste(nil) - } - case .pasteAsPlainText: - handledDirectPaste = ghosttyView.prepareSurfaceForPaste( - reason: "window.performKeyEquivalent.pasteAsPlainText.missingSurface" - ) - if handledDirectPaste { - ghosttyView.pasteAsPlainText(nil) + if consumedByGhostty { + return true } - } - if handledDirectPaste { + } else { #if DEBUG - dlog(" → consumed by direct terminal paste fallback") + dlog(" → consumed by mainMenu (bypassed SwiftUI)") #endif return true } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index dfc677be288f..255e7c98a816 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6444,6 +6444,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func performKeyEquivalent(with event: NSEvent) -> Bool { + performKeyEquivalent(with: event, shouldRetryMainMenu: true) + } + + func performKeyEquivalentAfterMenuMiss(with event: NSEvent) -> Bool { + performKeyEquivalent(with: event, shouldRetryMainMenu: false) + } + + private func performKeyEquivalent(with event: NSEvent, shouldRetryMainMenu: Bool) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() defer { @@ -6515,14 +6523,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let bindingFlags { let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 let isAll = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 - let isPerformable = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 // If the binding is consumed and not meant for the menu, allow menu first. // Performable bindings (e.g. paste_from_clipboard) also need the menu // path so that Edit > Paste handles Cmd+V instead of keyDown double- // firing the clipboard request through both interpretKeyEvents and // ghostty_surface_key. - if isConsumed && !isAll && keySequence.isEmpty && keyTables.isEmpty { + if shouldRetryMainMenu && isConsumed && !isAll && keySequence.isEmpty && keyTables.isEmpty { if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index b6e796fb586c..cf617bc3083f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -11,6 +11,25 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } +private final class GhosttyCommandEquivalentProbeView: GhosttyNSView { + var afterMenuMissCallCount = 0 + var pasteCallCount = 0 + var pasteAsPlainTextCallCount = 0 + var performAfterMenuMissResult = true + + override func performKeyEquivalentAfterMenuMiss(with event: NSEvent) -> Bool { + afterMenuMissCallCount += 1 + return performAfterMenuMissResult + } + + override func paste(_ sender: Any?) { + pasteCallCount += 1 + } + + override func pasteAsPlainText(_ sender: Any?) { + pasteAsPlainTextCallCount += 1 + } +} @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { @@ -4254,6 +4273,55 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } + func testWindowPerformKeyEquivalentDefersTerminalPasteMenuMissToGhosttyBindingResolution() { + 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)) + + defer { + NSApp.mainMenu = previousMainMenu + probeWindow.orderOut(nil) + } + + let emptyMenu = NSMenu(title: "Test") + emptyMenu.addItem(withTitle: "Placeholder", action: nil, keyEquivalent: "") + NSApp.mainMenu = emptyMenu + + 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: "v", + modifiers: [.command], + keyCode: 9, + windowNumber: probeWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+V event") + return + } + + XCTAssertTrue( + probeWindow.performKeyEquivalent(with: event), + "Cmd+V menu miss should still route through Ghostty binding resolution" + ) + XCTAssertEqual(probeView.afterMenuMissCallCount, 1, "Ghostty binding resolution should run after the menu miss") + XCTAssertEqual(probeView.pasteCallCount, 0, "Window routing must not force paste before Ghostty inspects bindings") + XCTAssertEqual( + probeView.pasteAsPlainTextCallCount, + 0, + "Window routing must not force plain-text paste before Ghostty inspects bindings" + ) + } + func testWindowSendEventRepairsVisibleSameWindowResponderDriftForFocusedTerminalTyping() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared")