diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e3adb12..7dc216d 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -2033,9 +2033,20 @@ pub fn run() !void { }; } else if (key == c.SDLK_V and has_gui and !has_blocking_mod) { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘V", now); - terminal_actions.pasteClipboardIntoSession(focused, allocator, &ui, now, session_interaction_component) catch |err| { - std.debug.print("Paste failed: {}\n", .{err}); - }; + // Opt-in: if the clipboard holds an image, forward Ctrl+V so a + // CLI in the terminal inlines it; otherwise paste text as usual. + var handled_paste = false; + if (config.paste.image_passthrough) { + handled_paste = terminal_actions.tryPasteImagePassthrough(focused, allocator, &ui, now, session_interaction_component) catch |err| blk: { + std.debug.print("Image paste passthrough failed: {}\n", .{err}); + break :blk false; + }; + } + if (!handled_paste) { + terminal_actions.pasteClipboardIntoSession(focused, allocator, &ui, now, session_interaction_component) catch |err| { + std.debug.print("Paste failed: {}\n", .{err}); + }; + } } else if (input.fontSizeShortcut(key, mod)) |direction| { if (config.ui.show_hotkey_feedback) ui.showHotkey(if (direction == .increase) "⌘+" else "⌘-", now); const delta: c_int = if (direction == .increase) font_step else -font_step; diff --git a/src/app/terminal_actions.zig b/src/app/terminal_actions.zig index 40e2e06..303ae1a 100644 --- a/src/app/terminal_actions.zig +++ b/src/app/terminal_actions.zig @@ -3,6 +3,7 @@ const ghostty_vt = @import("ghostty-vt"); const session_state = @import("../session/state.zig"); const ui_mod = @import("../ui/mod.zig"); const c = @import("../c.zig"); +const macos_clipboard = @import("../platform/macos_clipboard.zig"); const SessionState = session_state.SessionState; const log = std.log.scoped(.terminal_actions); @@ -36,6 +37,60 @@ pub fn pasteText( } } +/// Cmd+V image paste (opt-in via `[paste] image_passthrough`). +/// +/// When the system clipboard holds an image, Architect reads it, writes it to a +/// temp PNG file, and pastes that file path as text into the focused terminal. +/// CLIs like Claude Code recognize an image file path and attach the image. This +/// is far more reliable than forwarding Ctrl+V and relying on the program's own +/// clipboard read, which can't materialize promised (lazily transferred) +/// Universal Clipboard data and fails on large images. +/// +/// Returns true if it handled the event (an image was read and its path pasted); +/// false means there was no readable image and the caller should fall back to +/// the normal text paste. macOS only — `readClipboardImagePng` is null elsewhere. +pub fn tryPasteImagePassthrough( + session: *SessionState, + allocator: std.mem.Allocator, + ui: *ui_mod.UiRoot, + now: i64, + session_interaction: *ui_mod.SessionInteractionComponent, +) !bool { + if (!macos_clipboard.hasClipboardImage()) return false; + + // Detected an image but couldn't read its bytes -> let the caller fall back + // to the normal text paste rather than swallowing the event. + const png = macos_clipboard.readClipboardImagePng(allocator) orelse return false; + defer allocator.free(png); + + const path = writeClipboardImageTempFile(allocator, png, session.id, now) catch |err| { + log.warn("session {d}: failed to write clipboard image temp file: {}", .{ session.id, err }); + return false; + }; + defer allocator.free(path); + + try pasteText(session, allocator, path, session_interaction); + ui.showToast("Pasted image as file path", now); + return true; +} + +/// Writes clipboard image PNG bytes to a uniquely-named temp file and returns +/// the absolute path (caller owns it). The file is intentionally left on disk so +/// the CLI can read it when the message is sent; the OS reclaims the temp dir. +fn writeClipboardImageTempFile(allocator: std.mem.Allocator, png: []const u8, session_id: usize, now: i64) ![]u8 { + const tmp_dir = std.posix.getenv("TMPDIR") orelse "/tmp"; + const name = try std.fmt.allocPrint(allocator, "architect-paste-{d}-{d}.png", .{ now, session_id }); + defer allocator.free(name); + const path = try std.fs.path.join(allocator, &.{ tmp_dir, name }); + errdefer allocator.free(path); + + const file = try std.fs.createFileAbsolute(path, .{}); + defer file.close(); + try file.writeAll(png); + + return path; +} + pub fn clearTerminal(session: *SessionState) void { const terminal_ptr = session.terminal orelse return; var terminal = terminal_ptr; diff --git a/src/config.zig b/src/config.zig index 2acdf28..46718eb 100644 --- a/src/config.zig +++ b/src/config.zig @@ -73,6 +73,14 @@ pub const UiConfig = struct { enable_animations: bool = true, }; +pub const PasteConfig = struct { + /// When true, pressing Cmd+V while the system clipboard holds an image + /// writes the image to a temp PNG and pastes that file path into the focused + /// terminal, so a CLI like Claude Code attaches the image. Plain text paste + /// is unaffected. macOS only. Default false. + image_passthrough: bool = false, +}; + pub const PaletteConfig = struct { black: ?[]const u8 = null, red: ?[]const u8 = null, @@ -779,6 +787,7 @@ pub const Config = struct { grid: GridConfig = .{}, theme: ThemeConfig = .{}, ui: UiConfig = .{}, + paste: PasteConfig = .{}, rendering: Rendering = .{}, metrics: MetricsConfig = .{}, logging: LoggingConfig = .{}, @@ -831,6 +840,13 @@ pub const Config = struct { \\# show_hotkey_feedback = true \\# enable_animations = true \\ + \\# Paste options + \\# [paste] + \\# image_passthrough = false # When true and the clipboard holds an + \\# # image, Cmd+V writes a temp PNG and pastes + \\# # its path so a CLI (e.g. Claude Code) + \\# # attaches the image. + \\ \\# Theme colors (hex format) \\# [theme] \\# background = "#0E1116" @@ -1050,6 +1066,26 @@ test "Config - decode sectioned toml" { try std.testing.expectEqual(std.log.Level.warn, config.logging.getMinLevel()); try std.testing.expectEqual(false, config.ui.show_hotkey_feedback); try std.testing.expectEqual(false, config.ui.enable_animations); + // [paste] omitted above -> defaults to disabled. + try std.testing.expectEqual(false, config.paste.image_passthrough); +} + +test "Config - parses [paste] image_passthrough" { + const allocator = std.testing.allocator; + + const content = + \\[paste] + \\image_passthrough = true + \\ + ; + + var parser = toml.Parser(Config).init(allocator); + defer parser.deinit(); + + var result = try parser.parseString(content); + defer result.deinit(); + + try std.testing.expectEqual(true, result.value.paste.image_passthrough); } test "LoggingConfig.getMinLevel falls back to info for unknown values" { diff --git a/src/platform/macos_clipboard.zig b/src/platform/macos_clipboard.zig new file mode 100644 index 0000000..244ea04 --- /dev/null +++ b/src/platform/macos_clipboard.zig @@ -0,0 +1,140 @@ +// macOS clipboard image detection. +// +// Used by the Cmd+V image-passthrough feature: when the general pasteboard +// holds image data (a screenshot, a copied PNG/TIFF, etc.), Architect forwards +// Ctrl+V to the focused terminal so a CLI like Claude Code performs its own +// inline image paste, instead of pasting clipboard text. +// +// Implemented with the same objc_msgSend interop pattern as +// platform/macos_input_source.zig. The AppKit classes (NSPasteboard, NSImage) +// are looked up by name at runtime via objc_getClass, so no AppKit headers are +// needed here — AppKit is already linked by build.zig. + +const std = @import("std"); +const builtin = @import("builtin"); + +const is_macos = builtin.os.tag == .macos; + +const Impl = if (is_macos) struct { + const c = @cImport({ + @cInclude("objc/runtime.h"); + @cInclude("objc/message.h"); + }); + + // objc_msgSend has no single C prototype; cast it per call signature. + const MsgSend = *const fn (?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque; + const MsgSendIdArg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) ?*anyopaque; + const MsgSendBool2Arg = *const fn (?*anyopaque, ?*anyopaque, ?*anyopaque, ?*anyopaque) callconv(.c) u8; + const MsgSendUInt = *const fn (?*anyopaque, ?*anyopaque) callconv(.c) usize; + const MsgSendRepType = *const fn (?*anyopaque, ?*anyopaque, usize, ?*anyopaque) callconv(.c) ?*anyopaque; + + fn hasClipboardImage() bool { + const ns_pasteboard = c.objc_getClass("NSPasteboard") orelse return false; + const ns_image = c.objc_getClass("NSImage") orelse return false; + const ns_array = c.objc_getClass("NSArray") orelse return false; + const sel_general = c.sel_registerName("generalPasteboard") orelse return false; + const sel_array_with = c.sel_registerName("arrayWithObject:") orelse return false; + const sel_can_read = c.sel_registerName("canReadObjectForClasses:options:") orelse return false; + + const msg_send = @as(MsgSend, @ptrCast(&c.objc_msgSend)); + const msg_send_id = @as(MsgSendIdArg, @ptrCast(&c.objc_msgSend)); + const msg_send_bool2 = @as(MsgSendBool2Arg, @ptrCast(&c.objc_msgSend)); + + // [NSPasteboard generalPasteboard] -> shared pasteboard (not owned by us). + const pasteboard = msg_send(ns_pasteboard, sel_general) orelse return false; + + // classes = [NSArray arrayWithObject:[NSImage class]]. A Class doubles as + // the `id` element; canReadObjectForClasses: wants NSPasteboardReading + // classes, and NSImage conforms. + const classes = msg_send_id(ns_array, sel_array_with, ns_image) orelse return false; + + // [pasteboard canReadObjectForClasses:classes options:nil] -> BOOL. + // Unlike +[NSImage canInitWithPasteboard:] (which only matches the legacy + // NeXT pasteboard type set), this is UTI-aware: it matches any + // public.image-conforming type, including Universal Clipboard / iPhone + // screenshots that declare modern UTIs, and image file URLs, while + // rejecting non-image file URLs. It inspects declared types only, so it + // does not pull promised (lazily transferred) Universal Clipboard bytes. + return msg_send_bool2(pasteboard, sel_can_read, classes, null) != 0; + } + + fn cstrArg(s: [*:0]const u8) ?*anyopaque { + return @ptrCast(@constCast(s)); + } + + // data = [pasteboard dataForType:[NSString stringWithUTF8String:type_name]] + fn pasteboardData( + pasteboard: ?*anyopaque, + ns_string: ?*anyopaque, + sel_string_utf8: ?*anyopaque, + sel_data_for_type: ?*anyopaque, + type_name: [*:0]const u8, + ) ?*anyopaque { + const msg_send_id = @as(MsgSendIdArg, @ptrCast(&c.objc_msgSend)); + const type_str = msg_send_id(ns_string, sel_string_utf8, cstrArg(type_name)) orelse return null; + return msg_send_id(pasteboard, sel_data_for_type, type_str); + } + + fn readClipboardImagePng(allocator: std.mem.Allocator) ?[]u8 { + const ns_pasteboard = c.objc_getClass("NSPasteboard") orelse return null; + const ns_string = c.objc_getClass("NSString") orelse return null; + const ns_bitmap = c.objc_getClass("NSBitmapImageRep") orelse return null; + const sel_general = c.sel_registerName("generalPasteboard") orelse return null; + const sel_string_utf8 = c.sel_registerName("stringWithUTF8String:") orelse return null; + const sel_data_for_type = c.sel_registerName("dataForType:") orelse return null; + const sel_imagerep_data = c.sel_registerName("imageRepWithData:") orelse return null; + const sel_rep_using = c.sel_registerName("representationUsingType:properties:") orelse return null; + const sel_bytes = c.sel_registerName("bytes") orelse return null; + const sel_length = c.sel_registerName("length") orelse return null; + + const msg_send = @as(MsgSend, @ptrCast(&c.objc_msgSend)); + const msg_send_id = @as(MsgSendIdArg, @ptrCast(&c.objc_msgSend)); + const msg_send_uint = @as(MsgSendUInt, @ptrCast(&c.objc_msgSend)); + const msg_send_rep = @as(MsgSendRepType, @ptrCast(&c.objc_msgSend)); + + const pasteboard = msg_send(ns_pasteboard, sel_general) orelse return null; + + // Prefer the ready-made PNG representation — screenshots, browser copies, + // and Universal Clipboard transfers all provide public.png directly. + var png_data = pasteboardData(pasteboard, ns_string, sel_string_utf8, sel_data_for_type, "public.png"); + + // Fallback: convert the TIFF representation to PNG via NSBitmapImageRep. + if (png_data == null) { + const tiff = pasteboardData(pasteboard, ns_string, sel_string_utf8, sel_data_for_type, "public.tiff") orelse return null; + const rep = msg_send_id(ns_bitmap, sel_imagerep_data, tiff) orelse return null; + png_data = msg_send_rep(rep, sel_rep_using, 4, null); // 4 = NSBitmapImageFileTypePNG + } + const png = png_data orelse return null; + + const len = msg_send_uint(png, sel_length); + if (len == 0) return null; + const bytes_ptr = msg_send(png, sel_bytes) orelse return null; + const src = @as([*]const u8, @ptrCast(bytes_ptr))[0..len]; + return allocator.dupe(u8, src) catch null; + } +} else struct { + fn hasClipboardImage() bool { + return false; + } + + fn readClipboardImagePng(_: std.mem.Allocator) ?[]u8 { + return null; + } +}; + +/// Returns true if the macOS general pasteboard currently holds image data +/// NSImage can read — PNG/TIFF/JPEG/HEIC/PDF, including Universal Clipboard / +/// iPhone screenshots (modern UTIs) and image file URLs, while rejecting +/// non-image file URLs. Always false on non-macOS targets. +pub fn hasClipboardImage() bool { + return Impl.hasClipboardImage(); +} + +/// Reads the macOS general pasteboard's image as PNG bytes (caller owns the +/// returned slice). Returns null when there is no image, the bytes can't be +/// materialized, or on non-macOS targets. This pulls promised (lazily +/// transferred) Universal Clipboard data, so it may briefly block on the +/// cross-device fetch — unlike hasClipboardImage(), which is metadata-only. +pub fn readClipboardImagePng(allocator: std.mem.Allocator) ?[]u8 { + return Impl.readClipboardImagePng(allocator); +}