Support pasting clipboard images in terminal (#562)

* Support pasting clipboard images as file paths in terminal

When the macOS clipboard contains only image data (e.g. from
Cmd+Ctrl+Shift+4 screenshot) and no text, Cmd+V now saves the image
as a temporary PNG file and pastes the file path into the terminal.
This allows CLI tools like Claude Code to receive pasted images.

The pasteboard heuristic only intercepts when there is image data
(TIFF/PNG) and no text/HTML/RTF, so normal text paste is unaffected.
Images over 10 MB are skipped and fall through to default behavior.

Closes #457

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix clipboard image paste: collision-free filenames and pasteAsPlainText validation

- Add UUID suffix to temp filenames to prevent overwrites when pasting
  images multiple times in the same second
- Only enable Paste menu (not Paste as Plain Text) for image-only clipboard,
  since pasteAsPlainText has no image-path handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Shell-escape pasted image path before sending to terminal

Use escapeDropForShell on the clipboard image temp path, consistent
with how drag/drop paths are escaped, to avoid issues with
shell-special characters in the path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add docstrings to paste and validation functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Jose Masri <ae_jmsalame@contractor.indeed.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
This commit is contained in:
Jose Masri 2026-03-02 20:00:00 -06:00 committed by GitHub
parent fdc38a3326
commit b6163ccfad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2708,20 +2708,96 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
_ = performBindingAction("copy_to_clipboard")
}
// MARK: - Clipboard image paste
private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
/// Quick check: does the clipboard have image data and no text?
private static func clipboardHasImageOnly() -> Bool {
let pb = NSPasteboard.general
let types = pb.types ?? []
let hasText = types.contains(.string) || types.contains(.html)
|| types.contains(.rtf) || types.contains(.rtfd)
if hasText { return false }
return types.contains(.tiff) || types.contains(.png)
}
/// When the clipboard contains only image data (no text/HTML), saves it as
/// a temporary PNG file and returns the file path. Returns nil if the
/// clipboard contains text or no image.
private static func saveClipboardImageIfNeeded() -> String? {
let pb = NSPasteboard.general
let types = pb.types ?? []
// If pasteboard has text/HTML, this is a normal copy let Ghostty handle it.
let hasText = types.contains(.string) || types.contains(.html)
|| types.contains(.rtf) || types.contains(.rtfd)
if hasText { return nil }
// Check for image types (TIFF from screenshots, PNG from some tools).
guard types.contains(.tiff) || types.contains(.png) else { return nil }
guard let image = NSImage(pasteboard: pb),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
guard pngData.count <= maxClipboardImageSize else {
#if DEBUG
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)")
#endif
return nil
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
let timestamp = formatter.string(from: Date())
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png"
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
do {
try pngData.write(to: URL(fileURLWithPath: path))
} catch {
#if DEBUG
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
#endif
return nil
}
return path
}
/// Pastes clipboard content into the terminal. If the clipboard contains only
/// image data, saves it as a temporary PNG and pastes the shell-escaped file path.
@IBAction func paste(_ sender: Any?) {
// When the clipboard contains only image data (e.g. from Cmd+Ctrl+Shift+4
// screenshot), save it as a temporary PNG and paste the file path so that
// CLI tools like Claude Code can accept the image.
if let path = Self.saveClipboardImageIfNeeded() {
#if DEBUG
dlog("terminal.paste.image path=\(path)")
#endif
terminalSurface?.sendText(Self.escapeDropForShell(path))
return
}
_ = performBindingAction("paste_from_clipboard")
}
/// Pastes clipboard text as plain text, stripping any rich formatting.
@IBAction func pasteAsPlainText(_ sender: Any?) {
_ = performBindingAction("paste_from_clipboard")
}
/// Validates whether edit menu items (copy, paste, split) should be enabled.
func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
switch item.action {
case #selector(copy(_:)):
guard let surface = surface else { return false }
return ghostty_surface_has_selection(surface)
case #selector(paste(_:)), #selector(pasteAsPlainText(_:)):
case #selector(paste(_:)):
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
|| Self.clipboardHasImageOnly()
case #selector(pasteAsPlainText(_:)):
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)):
return canSplitCurrentSurface()