Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions src/app/terminal_actions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -779,6 +787,7 @@ pub const Config = struct {
grid: GridConfig = .{},
theme: ThemeConfig = .{},
ui: UiConfig = .{},
paste: PasteConfig = .{},
rendering: Rendering = .{},
metrics: MetricsConfig = .{},
logging: LoggingConfig = .{},
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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" {
Expand Down
140 changes: 140 additions & 0 deletions src/platform/macos_clipboard.zig
Original file line number Diff line number Diff line change
@@ -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);
}
Loading