feat: paste clipboard images into terminals with ⌘V (Claude Code integration)#81
feat: paste clipboard images into terminals with ⌘V (Claude Code integration)#81iOSDevSK wants to merge 1 commit into
Conversation
…ntegration) When a terminal app (iTerm2, Terminal, Warp, kitty, Alacritty, WezTerm, Ghostty) is frontmost and the clipboard holds a pure image (no text), plain Cmd+V pastes the image's shell-escaped file path instead — Claude Code picks it up as [Image KartikLabhshetwar#1], exactly like drag & drop. - Reuses the existing global CGEvent tap in ShortcutService; no second tap and no new permissions (Accessibility is already required) - Screenshots captured by BetterShot reuse the already-saved file (no duplicate PNG); images copied from other apps are saved to ~/Pictures/BetterShot and cleaned up after 7 days - The original clipboard is restored 0.6s after the paste, so the image can still be pasted into GUI apps - Synthetic Cmd+V events are tagged with an event-source marker so the tap lets them through (no interception loop) - Toggle in Settings -> General -> Claude Code, enabled by default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Someone is attempting to deploy a commit to the knox projects Team on Vercel. A member of the Team first needs to authorize it. |
| if let urls = pb.readObjects( | ||
| forClasses: [NSURL.self], | ||
| options: [.urlReadingFileURLsOnly: true] | ||
| ) as? [URL], !urls.isEmpty { |
There was a problem hiding this comment.
readObjects(forClasses:) here often comes back as [NSURL] (bridged), so casting directly to [URL] can be flaky.
| ) as? [URL], !urls.isEmpty { | |
| if let nsUrls = pb.readObjects( | |
| forClasses: [NSURL.self], | |
| options: [.urlReadingFileURLsOnly: true] | |
| ) as? [NSURL], !nsUrls.isEmpty { | |
| let urls = nsUrls.map { $0 as URL } | |
| let images = urls.filter { imageExtensions.contains($0.pathExtension.lowercased()) } | |
| guard images.count == urls.count else { return nil } | |
| let text = images.map { shellEscape($0.path) }.joined(separator: " ") + " " | |
| return (text, images.count == 1 ? images[0] : nil) | |
| } |
| pb.clearContents() | ||
| pb.setString(text, forType: .string) | ||
|
|
||
| guard let source = CGEventSource(stateID: .combinedSessionState) else { return } | ||
| source.userData = Self.syntheticEventUserData | ||
| let vKey = CGKeyCode(kVK_ANSI_V) | ||
| if let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), | ||
| let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) { | ||
| down.flags = .maskCommand | ||
| up.flags = .maskCommand | ||
| down.post(tap: .cghidEventTap) | ||
| up.post(tap: .cghidEventTap) | ||
| } | ||
|
|
||
| // Restore the original clipboard (the image) after the terminal has pasted. | ||
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in | ||
| pb.clearContents() | ||
| pb.writeObjects(saved) | ||
| if let self, let fileURL { | ||
| // Restoring bumps changeCount — re-note the backing file so a | ||
| // repeated ⌘V reuses it instead of saving a duplicate PNG. | ||
| self.noteCopiedToClipboard(fileURL) | ||
| } | ||
| } |
There was a problem hiding this comment.
If CGEventSource(...) fails, this returns before the restore timer is scheduled, leaving the clipboard overwritten with the path. I’d schedule the restore before the guard (or use defer) so the original clipboard always comes back.
| pb.clearContents() | |
| pb.setString(text, forType: .string) | |
| guard let source = CGEventSource(stateID: .combinedSessionState) else { return } | |
| source.userData = Self.syntheticEventUserData | |
| let vKey = CGKeyCode(kVK_ANSI_V) | |
| if let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), | |
| let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) { | |
| down.flags = .maskCommand | |
| up.flags = .maskCommand | |
| down.post(tap: .cghidEventTap) | |
| up.post(tap: .cghidEventTap) | |
| } | |
| // Restore the original clipboard (the image) after the terminal has pasted. | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in | |
| pb.clearContents() | |
| pb.writeObjects(saved) | |
| if let self, let fileURL { | |
| // Restoring bumps changeCount — re-note the backing file so a | |
| // repeated ⌘V reuses it instead of saving a duplicate PNG. | |
| self.noteCopiedToClipboard(fileURL) | |
| } | |
| } | |
| pb.clearContents() | |
| pb.setString(text, forType: .string) | |
| // Restore the original clipboard (the image) after the terminal has pasted. | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in | |
| pb.clearContents() | |
| pb.writeObjects(saved) | |
| if let self, let fileURL { | |
| // Restoring bumps changeCount — re-note the backing file so a | |
| // repeated ⌘V reuses it instead of saving a duplicate PNG. | |
| self.noteCopiedToClipboard(fileURL) | |
| } | |
| } | |
| guard let source = CGEventSource(stateID: .combinedSessionState) else { return } | |
| source.userData = Self.syntheticEventUserData | |
| let vKey = CGKeyCode(kVK_ANSI_V) | |
| if let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), | |
| let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) { | |
| down.flags = .maskCommand | |
| up.flags = .maskCommand | |
| down.post(tap: .cghidEventTap) | |
| up.post(tap: .cghidEventTap) | |
| } |
| if keyCode == UInt32(kVK_ANSI_V), carbonMods == UInt32(cmdKey) { | ||
| let handled = MainActor.assumeIsolated { | ||
| TerminalPasteService.shared.interceptCommandV() | ||
| } | ||
| if handled { return nil } | ||
| } |
There was a problem hiding this comment.
MainActor.assumeIsolated is easy to accidentally make unsafe later (if the tap ever moves off the main run loop). Adding a cheap debug-time check makes this a lot easier to reason about.
| if keyCode == UInt32(kVK_ANSI_V), carbonMods == UInt32(cmdKey) { | |
| let handled = MainActor.assumeIsolated { | |
| TerminalPasteService.shared.interceptCommandV() | |
| } | |
| if handled { return nil } | |
| } | |
| if keyCode == UInt32(kVK_ANSI_V), carbonMods == UInt32(cmdKey) { | |
| assert(Thread.isMainThread, "Event tap callback must run on main thread") | |
| let handled = MainActor.assumeIsolated { | |
| TerminalPasteService.shared.interceptCommandV() | |
| } | |
| if handled { return nil } | |
| } |
What
When a terminal app (iTerm2, Terminal, Warp, kitty, Alacritty, WezTerm, Ghostty) is frontmost and the clipboard holds a pure image (no text), plain ⌘V now pastes the image's shell-escaped file path instead — Claude Code picks it up as
[Image #1], exactly like drag & drop.BetterShot already copies every screenshot to the clipboard (Copy to clipboard after saving), but pasting an image into a terminal running Claude Code does nothing — you have to drag the file into the window. This closes that gap: screenshot → ⌘V → done.
How it works
~/Pictures/BetterShot(paste-*.png, cleaned up after 7 days)Scope
Sources/Services/TerminalPasteService.swiftShortcutServiceevent tap callback, one-line hooks inCaptureOrchestrator(copy-after-save) andBetterShotDelegate(launch cleanup)CHANGELOG.mdentry under UnreleasedTesting
make releasebuilds clean[Image #1]; image copied from a browser gets saved and pasted; copied text pastes normally; toggle off restores stock ⌘V behavior; clipboard still holds the image after the pasteNote: on the newest Xcode toolchains
mainitself doesn't compile — see the companion PR #80 with minimal Swift 6 concurrency fixes. This PR is independent of it.🤖 Generated with Claude Code