diff --git a/docs/components/terminal.md b/docs/components/terminal.md index c32f00aa4..386d2e05b 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -150,6 +150,10 @@ Scrollback, wide/CJK character rendering, and copy/paste are handled by Ghostty with its defaults — Prowl doesn't override them. Customize terminal behavior in your Ghostty config at `~/.config/ghostty/config`. +When the clipboard contains only an image, Prowl leaves performable paste +shortcuts to the terminal program so agents that support clipboard image import +can handle the image directly. + ## Color scheme The terminal theme automatically follows the macOS light/dark appearance. There's diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift index 5503c1ec6..a457e4207 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView+Keyboard.swift @@ -429,8 +429,9 @@ extension GhosttySurfaceView { if bridge.state.keyTableDepth > 0 { return false } let raw = flags.rawValue let isAll = (raw & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 + let isPerformable = (raw & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 let isConsumed = (raw & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 - return !isAll && isConsumed + return !isAll && !isPerformable && isConsumed } @IBAction func copy(_ sender: Any?) { diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift index 50469f1f4..e3090d1d2 100644 --- a/supacodeTests/GhosttySurfaceViewTests.swift +++ b/supacodeTests/GhosttySurfaceViewTests.swift @@ -97,6 +97,47 @@ struct GhosttySurfaceViewTests { #expect(!GhosttySurfaceView.hasKeyEquivalentFocusOwnership(cachedFocused: false, isActualFirstResponder: true)) } + @Test func imageClipboardLeavesPerformablePasteForPTY() { + let pasteboard = NSPasteboard(name: .init("prowl-tests.image-clipboard.\(UUID().uuidString)")) + pasteboard.clearContents() + defer { pasteboard.clearContents() } + + #expect(pasteboard.setData(Data([0x89, 0x50, 0x4E, 0x47]), forType: .png)) + #expect(pasteboard.getOpinionatedStringContents() == nil) + + let surfaceView = makeSurfaceView() + let flags = ghostty_binding_flags_e( + GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue | GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue + ) + + #expect(!surfaceView.shouldAttemptMenu(for: flags)) + } + + @Test func menuAttemptRequiresConsumedNonAllBinding() { + let surfaceView = makeSurfaceView() + let consumedAndAll = ghostty_binding_flags_e( + GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue | GHOSTTY_BINDING_FLAGS_ALL.rawValue + ) + + #expect(surfaceView.shouldAttemptMenu(for: GHOSTTY_BINDING_FLAGS_CONSUMED)) + #expect(!surfaceView.shouldAttemptMenu(for: ghostty_binding_flags_e(0))) + #expect(!surfaceView.shouldAttemptMenu(for: consumedAndAll)) + } + + @Test func activeBindingScopesBypassMenuUntilTheyExit() { + let surfaceView = makeSurfaceView() + + surfaceView.bridge.state.keySequenceActive = true + #expect(!surfaceView.shouldAttemptMenu(for: GHOSTTY_BINDING_FLAGS_CONSUMED)) + + surfaceView.bridge.state.keySequenceActive = false + surfaceView.bridge.state.keyTableDepth = 1 + #expect(!surfaceView.shouldAttemptMenu(for: GHOSTTY_BINDING_FLAGS_CONSUMED)) + + surfaceView.bridge.state.keyTableDepth = 0 + #expect(surfaceView.shouldAttemptMenu(for: GHOSTTY_BINDING_FLAGS_CONSUMED)) + } + @Test func occlusionStateResendsDesiredValueAfterAttachmentChange() { var state = GhosttySurfaceView.OcclusionState() @@ -494,4 +535,13 @@ struct GhosttySurfaceViewTests { ) ) } + + private func makeSurfaceView() -> GhosttySurfaceView { + GhosttySurfaceView( + runtime: GhosttyRuntime(), + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true + ) + } }