From 9b4e447e7786b84e7129777efe234b6260f5e64b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 9 Apr 2026 17:28:27 -0700 Subject: [PATCH 1/4] Fix Raycast paste fallback regression --- Sources/GhosttyTerminalView.swift | 4 ++++ Sources/TerminalImageTransfer.swift | 5 +++++ cmuxTests/TerminalAndGhosttyTests.swift | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0c8f1a92c9..503f2af9ae 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -160,6 +160,10 @@ enum GhosttyPasteboardHelper { return hasPasteableContents(in: pasteboard) } + static func fallbackPlainTextContents(from pasteboard: NSPasteboard) -> String? { + plainTextContents(from: pasteboard) + } + static func writeString(_ string: String, to location: ghostty_clipboard_e) { guard let pasteboard = pasteboard(for: location) else { return } pasteboard.clearContents() diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index ce8473046e..0486bddb5f 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -265,6 +265,11 @@ enum TerminalImageTransferPlanner { return .fileURLs([imageURL]) } + // Clipboard managers can advertise unusable image types alongside valid text. + if let string = GhosttyPasteboardHelper.fallbackPlainTextContents(from: pasteboard), !string.isEmpty { + return .insertText(string) + } + if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { return .insertText(escapeForShell(rawURL)) } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index bc3836b7ff..de8841a638 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -430,6 +430,24 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertEqual(targetResolutionCount, 0) } + func testPastePlanFallsBackToAlternatePlainTextWhenImageTypeIsUnusable() { + let pasteboard = NSPasteboard(name: .init("cmux-test-raycast-fallback-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString( + "hello from Raycast", + forType: NSPasteboard.PasteboardType(UTType.plainText.identifier) + ) + pasteboard.setData(Data("not a real tiff".utf8), forType: .tiff) + + let plan = TerminalImageTransferPlanner.plan( + pasteboard: pasteboard, + mode: .paste, + target: .local + ) + + XCTAssertEqual(plan, .insertText("hello from Raycast")) + } + func testLazyPastePlanResolvesTargetForFileURLPaste() throws { let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-image-\(UUID().uuidString).png") try make1x1PNG(color: .systemTeal).write(to: fileURL) From eabe056a1a98bf93e4c224ff6aa27ce80e2e410e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 02:26:31 -0700 Subject: [PATCH 2/4] Finish Raycast paste regression fix --- Sources/AppDelegate.swift | 38 +++++++++++++--- Sources/GhosttyTerminalView.swift | 44 ++++++++++++++----- Sources/TerminalImageTransfer.swift | 7 ++- .../ShortcutAndCommandPaletteTests.swift | 42 ++++++++++++++++++ 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba3d12d82..25b6871f5c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1830,6 +1830,18 @@ func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow } +func shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: NSEvent.ModifierFlags, + responderIsWindow: Bool, + responderHasViableKeyRoutingOwner: Bool +) -> Bool { + let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) + guard normalizedFlags.contains(.command) else { return false } + // Command shortcuts should only repair focus when the responder has fallen + // back to the window itself or another non-routable owner. + return responderIsWindow || !responderHasViableKeyRoutingOwner +} + func shouldRouteTerminalFontZoomShortcutToGhostty( firstResponderIsGhostty: Bool, flags: NSEvent.ModifierFlags, @@ -5705,7 +5717,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { guard event.type == .keyDown else { return } let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !normalizedFlags.contains(.command) else { return } guard isMainTerminalWindow(window) else { return } guard window.attachedSheet == nil else { return } guard !isCommandPaletteEffectivelyVisible(in: window) else { return } @@ -5715,19 +5726,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let terminalPanel = workspace.terminalPanel(for: panelId) else { return } - guard responderNeedsFocusedTerminalKeyRepair( - window.firstResponder, - in: window, - hostedView: terminalPanel.hostedView - ) else { return } + let firstResponder = window.firstResponder + let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false + let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: normalizedFlags, + responderIsWindow: firstResponder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableOwner + ) + if normalizedFlags.contains(.command) { + guard commandEquivalentNeedsRepair else { return } + } else { + guard responderNeedsFocusedTerminalKeyRepair( + firstResponder, + in: window, + hostedView: terminalPanel.hostedView + ) else { return } + } #if DEBUG - let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let before = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() + let mode = normalizedFlags.contains(.command) ? "command" : "plain" dlog( "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + "panel=\(String(panelId.uuidString.prefix(5))) " + + "mode=\(mode) " + "target=\(target == .findField ? "searchField" : "surface") " + "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" ) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 503f2af9ae..ee8311278c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -101,6 +101,12 @@ private func cmuxScalarHex(_ value: String?) -> String { #endif enum GhosttyPasteboardHelper { + enum ImageFileMaterializationResult { + case saved(URL) + case noDecodableImagePayload + case rejectedImagePayload + } + private static let selectionPasteboard = NSPasteboard( name: NSPasteboard.Name("com.mitchellh.ghostty.selection") ) @@ -368,15 +374,12 @@ enum GhosttyPasteboardHelper { return normalized.isEmpty } - /// When the clipboard contains only image data (or rich text that resolves to - /// an attachment-only image), saves it as a temporary image file and returns the - /// file URL. Returns nil if the clipboard contains text or no image. - static func saveImageFileURLIfNeeded( - from pasteboard: NSPasteboard = .general, - assumeNoText: Bool = false - ) -> URL? { - if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } - + /// Attempts to materialize a decodable pasteboard image into a temporary file. + /// `rejectedImagePayload` means a real image was found but could not be used, + /// so callers should not fall back to auxiliary plain text or URLs. + static func materializeImageFileURLIfNeeded( + from pasteboard: NSPasteboard = .general + ) -> ImageFileMaterializationResult { let imageData: Data let fileExtension: String if let directImage = directImageRepresentation(in: pasteboard) { @@ -390,7 +393,9 @@ enum GhosttyPasteboardHelper { let image = NSImage(pasteboard: pasteboard), let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + let pngData = bitmap.representation(using: .png, properties: [:]) else { + return .noDecodableImagePayload + } imageData = pngData fileExtension = "png" } @@ -400,7 +405,7 @@ enum GhosttyPasteboardHelper { #if DEBUG dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") #endif - return nil + return .rejectedImagePayload } let formatter = DateFormatter() @@ -416,10 +421,25 @@ enum GhosttyPasteboardHelper { #if DEBUG dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") #endif - return nil + return .rejectedImagePayload } registerOwnedTemporaryImageFile(fileURL) + return .saved(fileURL) + } + + /// When the clipboard contains only image data (or rich text that resolves to + /// an attachment-only image), saves it as a temporary image file and returns the + /// file URL. Returns nil if the clipboard contains text or no image. + static func saveImageFileURLIfNeeded( + from pasteboard: NSPasteboard = .general, + assumeNoText: Bool = false + ) -> URL? { + if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } + + guard case .saved(let fileURL) = materializeImageFileURLIfNeeded(from: pasteboard) else { + return nil + } return fileURL } diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index 0486bddb5f..0fb28e9f91 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -261,8 +261,13 @@ enum TerminalImageTransferPlanner { return .insertText(string) } - if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) { + switch GhosttyPasteboardHelper.materializeImageFileURLIfNeeded(from: pasteboard) { + case .saved(let imageURL): return .fileURLs([imageURL]) + case .rejectedImagePayload: + return .reject + case .noDecodableImagePayload: + break } // Clipboard managers can advertise unusable image types alongside valid text. diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 567d0c87e6..4224ac5f35 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -59,6 +59,48 @@ final class SplitShortcutTransientFocusGuardTests: XCTestCase { } } +final class CommandEquivalentTransientFocusRepairTests: XCTestCase { + func testRepairsCommandEquivalentWhenFirstResponderFallsBackToWindow() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: true, + responderHasViableKeyRoutingOwner: false + ) + ) + } + + func testRepairsCommandEquivalentWhenResponderHasNoViableOwner() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: false + ) + ) + } + + func testDoesNotRepairCommandEquivalentWhenResponderHasViableOwner() { + XCTAssertFalse( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: true + ) + ) + } + + func testIgnoresNonCommandEvents() { + XCTAssertFalse( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [], + responderIsWindow: true, + responderHasViableKeyRoutingOwner: false + ) + ) + } +} + final class ReactGrabShortcutRouteTests: XCTestCase { func testFocusedBrowserRoutesDirectlyWithoutPasteback() { let browserId = UUID() From 14a5b1dc98fa3385092b128b04936a0f9e43e9a2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 02:38:17 -0700 Subject: [PATCH 3/4] Repair command-key focus drift in same window --- Sources/AppDelegate.swift | 41 +++++++++++++------ .../ShortcutAndCommandPaletteTests.swift | 23 +++++++++-- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 25b6871f5c..6b735e515c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1830,16 +1830,30 @@ func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow } +func focusedTerminalKeyRepairNeeded( + responderIsWindow: Bool, + responderHasViableKeyRoutingOwner: Bool, + responderMatchesPreferredKeyboardFocus: Bool +) -> Bool { + responderIsWindow || !responderHasViableKeyRoutingOwner || !responderMatchesPreferredKeyboardFocus +} + func shouldRepairFocusedTerminalCommandEquivalentInputs( flags: NSEvent.ModifierFlags, responderIsWindow: Bool, - responderHasViableKeyRoutingOwner: Bool + responderHasViableKeyRoutingOwner: Bool, + responderMatchesPreferredKeyboardFocus: Bool ) -> Bool { let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) guard normalizedFlags.contains(.command) else { return false } - // Command shortcuts should only repair focus when the responder has fallen - // back to the window itself or another non-routable owner. - return responderIsWindow || !responderHasViableKeyRoutingOwner + // Command shortcuts should repair for the same failure states as plain + // keyDown routing, including same-window SwiftUI responder drift where a + // visible owner still exists but no longer matches the focused terminal. + return focusedTerminalKeyRepairNeeded( + responderIsWindow: responderIsWindow, + responderHasViableKeyRoutingOwner: responderHasViableKeyRoutingOwner, + responderMatchesPreferredKeyboardFocus: responderMatchesPreferredKeyboardFocus + ) } func shouldRouteTerminalFontZoomShortcutToGhostty( @@ -5701,14 +5715,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hostedView: GhosttySurfaceScrollView ) -> Bool { guard let responder else { return true } - if responder is NSWindow { return true } - guard responderHasViableKeyRoutingOwner(responder, in: window) else { - return true - } - if hostedView.responderMatchesPreferredKeyboardFocus(responder) { - return false - } - return true + return focusedTerminalKeyRepairNeeded( + responderIsWindow: responder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableKeyRoutingOwner(responder, in: window), + responderMatchesPreferredKeyboardFocus: hostedView.responderMatchesPreferredKeyboardFocus(responder) + ) } func repairFocusedTerminalKeyboardRoutingIfNeeded( @@ -5728,10 +5739,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let firstResponder = window.firstResponder let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false + let responderMatchesPreferredFocus = firstResponder.map { + terminalPanel.hostedView.responderMatchesPreferredKeyboardFocus($0) + } ?? false let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( flags: normalizedFlags, responderIsWindow: firstResponder is NSWindow, - responderHasViableKeyRoutingOwner: responderHasViableOwner + responderHasViableKeyRoutingOwner: responderHasViableOwner, + responderMatchesPreferredKeyboardFocus: responderMatchesPreferredFocus ) if normalizedFlags.contains(.command) { guard commandEquivalentNeedsRepair else { return } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 4224ac5f35..90c9fcd2c3 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -65,7 +65,8 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false + responderHasViableKeyRoutingOwner: false, + responderMatchesPreferredKeyboardFocus: false ) ) } @@ -75,7 +76,19 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: false + responderHasViableKeyRoutingOwner: false, + responderMatchesPreferredKeyboardFocus: false + ) + ) + } + + func testRepairsCommandEquivalentWhenResponderDriftsWithinSameWindow() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: true, + responderMatchesPreferredKeyboardFocus: false ) ) } @@ -85,7 +98,8 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: true + responderHasViableKeyRoutingOwner: true, + responderMatchesPreferredKeyboardFocus: true ) ) } @@ -95,7 +109,8 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false + responderHasViableKeyRoutingOwner: false, + responderMatchesPreferredKeyboardFocus: false ) ) } From 9b258884a991ce8fd0512c15c54ebdf4b595bc26 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 02:51:42 -0700 Subject: [PATCH 4/4] Narrow command focus repair for menu shortcuts --- Sources/AppDelegate.swift | 31 +++++++------------ .../ShortcutAndCommandPaletteTests.swift | 19 +++++------- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6b735e515c..9a04b06744 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1841,19 +1841,14 @@ func focusedTerminalKeyRepairNeeded( func shouldRepairFocusedTerminalCommandEquivalentInputs( flags: NSEvent.ModifierFlags, responderIsWindow: Bool, - responderHasViableKeyRoutingOwner: Bool, - responderMatchesPreferredKeyboardFocus: Bool + responderHasViableKeyRoutingOwner: Bool ) -> Bool { let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) guard normalizedFlags.contains(.command) else { return false } - // Command shortcuts should repair for the same failure states as plain - // keyDown routing, including same-window SwiftUI responder drift where a - // visible owner still exists but no longer matches the focused terminal. - return focusedTerminalKeyRepairNeeded( - responderIsWindow: responderIsWindow, - responderHasViableKeyRoutingOwner: responderHasViableKeyRoutingOwner, - responderMatchesPreferredKeyboardFocus: responderMatchesPreferredKeyboardFocus - ) + // Command shortcuts should only repair genuinely broken responder states. + // If another live view already owns first responder, let menu routing use + // that responder rather than retargeting to the selected terminal pane. + return responderIsWindow || !responderHasViableKeyRoutingOwner } func shouldRouteTerminalFontZoomShortcutToGhostty( @@ -5738,17 +5733,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } let firstResponder = window.firstResponder - let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false - let responderMatchesPreferredFocus = firstResponder.map { - terminalPanel.hostedView.responderMatchesPreferredKeyboardFocus($0) - } ?? false - let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( - flags: normalizedFlags, - responderIsWindow: firstResponder is NSWindow, - responderHasViableKeyRoutingOwner: responderHasViableOwner, - responderMatchesPreferredKeyboardFocus: responderMatchesPreferredFocus - ) if normalizedFlags.contains(.command) { + let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false + let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: normalizedFlags, + responderIsWindow: firstResponder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableOwner + ) guard commandEquivalentNeedsRepair else { return } } else { guard responderNeedsFocusedTerminalKeyRepair( diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 90c9fcd2c3..3869639f65 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -65,8 +65,7 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: false ) ) } @@ -76,19 +75,17 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: false, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: false ) ) } - func testRepairsCommandEquivalentWhenResponderDriftsWithinSameWindow() { - XCTAssertTrue( + func testDoesNotRepairCommandEquivalentWhenLiveResponderDiffersFromSelectedPane() { + XCTAssertFalse( shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: true, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: true ) ) } @@ -98,8 +95,7 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: true, - responderMatchesPreferredKeyboardFocus: true + responderHasViableKeyRoutingOwner: true ) ) } @@ -109,8 +105,7 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: false ) ) }