Skip to content

feat: paste clipboard images into terminals with ⌘V (Claude Code integration)#81

Open
iOSDevSK wants to merge 1 commit into
KartikLabhshetwar:mainfrom
iOSDevSK:feat/claude-code-terminal-paste
Open

feat: paste clipboard images into terminals with ⌘V (Claude Code integration)#81
iOSDevSK wants to merge 1 commit into
KartikLabhshetwar:mainfrom
iOSDevSK:feat/claude-code-terminal-paste

Conversation

@iOSDevSK

Copy link
Copy Markdown

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

⌘V in a terminal
  → existing CGEvent tap in ShortcutService (no second tap, no new permissions)
  → TerminalPasteService: is the frontmost app a terminal? is the clipboard a pure image?
  → swallow ⌘V, put the file path on the clipboard, post a synthetic ⌘V
  → restore the original clipboard 0.6 s later
  • Screenshots captured by BetterShot reuse the already-saved file — the pasteboard changeCount is tracked when the app copies after save, so no duplicate PNG is written
  • Images copied from other apps are saved to ~/Pictures/BetterShot (paste-*.png, cleaned up after 7 days)
  • Copied text is never affected; copied image files paste as their existing paths
  • Synthetic ⌘V events carry an event-source marker so the app's own tap lets them through (no interception loop)
  • The original clipboard is restored right after the paste, so the image can still be pasted into GUI apps
  • Settings → General → Claude Code toggle, enabled by default

Scope

  • New: Sources/Services/TerminalPasteService.swift
  • Hook in ShortcutService event tap callback, one-line hooks in CaptureOrchestrator (copy-after-save) and BetterShotDelegate (launch cleanup)
  • Preference + settings UI; CHANGELOG.md entry under Unreleased
  • No version bump (left to the maintainer), no new permissions (Accessibility is already required for shortcuts)

Testing

  • make release builds clean
  • Manually verified: BetterShot screenshot → ⌘V in iTerm2 running Claude Code inserts the saved file's path and Claude Code attaches it as [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 paste

Note: on the newest Xcode toolchains main itself doesn't compile — see the companion PR #80 with minimal Swift 6 concurrency fixes. This PR is independent of it.

🤖 Generated with Claude Code

…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>
@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readObjects(forClasses:) here often comes back as [NSURL] (bridged), so casting directly to [URL] can be flaky.

Suggested change
) 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)
}

Comment on lines +159 to +182
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)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Comment on lines +183 to +188
if keyCode == UInt32(kVK_ANSI_V), carbonMods == UInt32(cmdKey) {
let handled = MainActor.assumeIsolated {
TerminalPasteService.shared.interceptCommandV()
}
if handled { return nil }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant