macOS: enable mode 5522 paste events

Advertise Kitty clipboard protocol mode 5522 on macOS and route
clipboard paste requests through the protocol when it is enabled.
This commit is contained in:
Mitchell Hashimoto
2026-08-24 09:37:22 -07:00
parent 75d657788a
commit c2c0db68aa
8 changed files with 133 additions and 26 deletions

View File

@@ -113,6 +113,7 @@ typedef enum {
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ,
GHOSTTY_CLIPBOARD_REQUEST_LIST,
} ghostty_clipboard_request_e;
// apprt.ClipboardReadResult

View File

@@ -91,14 +91,25 @@ extension NSPasteboard {
/// The MIME types available on the pasteboard, best-effort mapped
/// from the pasteboard types. Types without a MIME mapping are not
/// reported.
/// reported. This only inspects declared types and never reads data,
/// since mode 5522 paste events must remain metadata-only.
func ghosttyAvailableMimes() -> [String] {
var result: [String] = []
var seen = Set<String>()
let availableTypes = types ?? []
let mimeType: (NSPasteboard.PasteboardType) -> String? = { type in
guard let mime = UTType(type.rawValue)?.preferredMIMEType else { return nil }
return mime == "text/plain;charset=utf-8" ? "text/plain" : mime
}
// Any text-like contents are reported under the canonical type,
// matching what ghosttyData(forMime:) serves.
if getOpinionatedStringContents() != nil {
// Plain text and copied files can both be served as the canonical
// text representation. Infer this from declared types so lazy
// pasteboard providers are not asked for their contents.
let hasFileURL = availableTypes.contains(.fileURL)
let hasPlainText = hasFileURL || availableTypes.contains { type in
mimeType(type) == "text/plain"
}
if hasPlainText {
result.append("text/plain")
seen.insert("text/plain")
}
@@ -106,14 +117,13 @@ extension NSPasteboard {
// Copied files are additionally served as a URI list. The
// generic mapping below never reports this since file URL
// pasteboard types have no MIME type.
if !ghosttyFileURLs.isEmpty {
if hasFileURL {
result.append("text/uri-list")
seen.insert("text/uri-list")
}
for type in types ?? [] {
guard let utType = UTType(type.rawValue),
let mime = utType.preferredMIMEType,
for type in availableTypes {
guard let mime = mimeType(type),
!seen.contains(mime) else { continue }
seen.insert(mime)
result.append(mime)

View File

@@ -4077,7 +4077,7 @@ pub fn mouseButtonCallback(
else
.standard,
};
_ = try self.startClipboardRequest(clipboard, .{ .paste = {} });
_ = try self.startClipboardRequest(clipboard, .{ .paste = clipboard });
},
};
@@ -4161,7 +4161,7 @@ pub fn mouseButtonCallback(
// request so we need to unlock.
self.renderer_state.mutex.unlock(global.io());
defer self.renderer_state.mutex.lockUncancelable(global.io());
_ = try self.startClipboardRequest(.standard, .paste);
_ = try self.startClipboardRequest(.standard, .{ .paste = .standard });
// We don't need to clear selection because we didn't have
// one to begin with.
@@ -4176,7 +4176,7 @@ pub fn mouseButtonCallback(
// request so we need to unlock.
self.renderer_state.mutex.unlock(global.io());
defer self.renderer_state.mutex.lockUncancelable(global.io());
_ = try self.startClipboardRequest(.standard, .paste);
_ = try self.startClipboardRequest(.standard, .{ .paste = .standard });
},
}
@@ -5111,12 +5111,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.paste_from_clipboard => return (try self.startClipboardRequest(
.standard,
.{ .paste = {} },
.{ .paste = .standard },
)) == .started,
.paste_from_selection => return (try self.startClipboardRequest(
.selection,
.{ .paste = {} },
.{ .paste = .selection },
)) == .started,
.increase_font_size => |delta| {
@@ -5897,6 +5897,13 @@ pub fn completeClipboardRequest(
complete.confirmed,
),
.list => |clipboard| if (!try self.completeClipboardPasteEvent(
clipboard,
complete.available,
)) {
log.debug("mode 5522 paste event was not written", .{});
},
.osc_52_read => |clipboard| try self.completeClipboardReadOSC52(
clipboardTextContent(complete.contents) orelse "",
clipboard,
@@ -5969,7 +5976,7 @@ fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u
pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void {
switch (req) {
// A denied paste simply doesn't happen.
.paste => {},
.paste, .list => {},
// OSC 52 has no error responses, but the client is waiting on
// a reply, so a denied read is answered with empty contents.
@@ -6006,8 +6013,25 @@ fn startClipboardRequest(
loc: apprt.Clipboard,
req: apprt.ClipboardRequest,
) !apprt.ClipboardReadResult {
switch (req) {
.paste => {}, // always allowed
const effective_req: apprt.ClipboardRequest = switch (req) {
.paste => |clipboard| effective: {
// Snapshot the mode before asking the apprt for clipboard data.
// Event pastes request only a MIME listing, while ordinary
// pastes request the text representation as before.
self.renderer_state.mutex.lockUncancelable(global.io());
const event = self.io.terminal.modes.get(.kitty_paste_events);
self.renderer_state.mutex.unlock(global.io());
break :effective if (event)
.{ .list = clipboard }
else
req;
},
else => req,
};
switch (effective_req) {
.paste, .list => {}, // always allowed
.osc_52_read => if (self.config.clipboard_read == .deny) {
log.info(
"application attempted to read clipboard, but 'clipboard-read' is set to deny",
@@ -6024,7 +6048,7 @@ fn startClipboardRequest(
.osc_52_write => unreachable,
}
return try self.rt_surface.clipboardRequest(loc, req);
return try self.rt_surface.clipboardRequest(loc, effective_req);
}
fn completeClipboardPaste(
@@ -6106,6 +6130,63 @@ fn completeClipboardPaste(
};
}
/// Send a Kitty clipboard-protocol paste event when mode 5522 is enabled.
/// The event only lists the available MIME types; it does not read any of
/// their data. The shared terminal paste implementation generates and records
/// the one-time password used by the program's follow-up OSC 5522 read.
fn completeClipboardPasteEvent(
self: *Surface,
clipboard: apprt.Clipboard,
available: []const []const u8,
) !bool {
if (self.readonly) return false;
const kitty_clipboard = terminal.kitty.clipboard;
const location: terminal.clipboard.Location = switch (clipboard) {
.standard => .standard,
.selection => .selection,
.primary => .primary,
};
// The protocol implementation caps listings at this size too. Cap here
// so the temporary Content array stays on the stack.
var contents_buf: [kitty_clipboard.max_listing_mimes]terminal.clipboard.Content = undefined;
const contents_len = @min(available.len, contents_buf.len);
for (available[0..contents_len], contents_buf[0..contents_len]) |mime, *content| {
content.* = .{ .mime = mime, .data = "" };
}
var aw: std.Io.Writer.Allocating = .init(self.alloc);
defer aw.deinit();
self.renderer_state.mutex.lockUncancelable(global.io());
defer self.renderer_state.mutex.unlock(global.io());
const pasted = try terminal.paste.paste(.{
.terminal = &self.io.terminal,
.alloc = self.alloc,
.writer = &aw.writer,
.kitty_clipboard = .{
.grants = &self.io.terminal_stream.handler.kitty_clipboard_grants,
.io = global.io(),
},
}, .{
.source = .{ .clipboard = location },
.contents = .{ .memory = contents_buf[0..contents_len] },
// A paste event discloses no clipboard data, so unsafe-text
// confirmation does not apply. If mode 5522 is reset, the empty
// stand-in representations cause the shared helper to write nothing.
.allow_unsafe = true,
});
if (!pasted) return false;
self.queueIo(.{ .write_alloc = .{
.alloc = self.alloc,
.data = try aw.toOwnedSlice(),
} }, .locked);
return true;
}
fn completeClipboardReadOSC52(
self: *Surface,
data: []const u8,

View File

@@ -720,6 +720,10 @@ pub const Surface = struct {
const mimes: []const [*:0]const u8 = switch (state) {
.paste, .osc_52_read => &.{"text/plain"},
// A mode 5522 paste event only lists types and must not read
// any clipboard data.
.list => &.{},
.kitty_read => |kitty| mimes: {
assert(kitty.mimes.len <= mimes_buf.len);
for (kitty.mimes, mimes_buf[0..kitty.mimes.len]) |mime, *dst| {
@@ -735,6 +739,9 @@ pub const Surface = struct {
.osc_52_write => unreachable,
};
const list = switch (state) {
// Paste events need the full MIME listing without reading any
// representation. Kitty reads only ask for it when requested.
.list => true,
.kitty_read => |kitty| kitty.list,
else => false,
};

View File

@@ -205,6 +205,7 @@ pub const ClipboardConfirmationDialog = extern struct {
self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste"));
self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."));
},
.list => unreachable,
}
}

View File

@@ -4121,7 +4121,7 @@ const Clipboard = struct {
) Allocator.Error!apprt.ClipboardReadResult {
// The GTK apprt doesn't support Kitty clipboard protocol reads
// yet.
if (state == .kitty_read) return .unsupported;
if (state == .kitty_read or state == .list) return .unsupported;
// Get our requested clipboard
const clipboard = get(
@@ -4172,7 +4172,7 @@ const Clipboard = struct {
const surface = self.private().core_surface orelse return;
surface.completeClipboardRequest(
.paste,
.{ .paste = .standard },
.{ .contents = &.{.{ .mime = "text/plain", .data = text }} },
) catch |err| switch (err) {
error.UnsafePaste,
@@ -4180,7 +4180,7 @@ const Clipboard = struct {
=> {
showClipboardConfirmation(
self,
.paste,
.{ .paste = .standard },
text,
);
return;
@@ -4224,7 +4224,7 @@ const Clipboard = struct {
.request = &req,
.@"can-remember" = switch (req) {
.osc_52_read, .osc_52_write => true,
.paste, .kitty_read => false,
.paste, .list, .kitty_read => false,
},
.@"clipboard-contents" = contents_buf,
},
@@ -4261,7 +4261,7 @@ const Clipboard = struct {
if (remember) switch (req.*) {
.osc_52_read => surface.config.clipboard_read = .allow,
.osc_52_write => surface.config.clipboard_write = .allow,
.paste, .kitty_read => {},
.paste, .list, .kitty_read => {},
};
// Get our text
@@ -4299,7 +4299,7 @@ const Clipboard = struct {
if (remember) switch (req.*) {
.osc_52_read => surface.config.clipboard_read = .deny,
.osc_52_write => surface.config.clipboard_write = .deny,
.paste, .kitty_read => @panic("request should not be able to be remembered"),
.paste, .list, .kitty_read => @panic("request should not be able to be remembered"),
};
}

View File

@@ -70,6 +70,7 @@ pub const ClipboardRequestType = enum(u8) {
osc_52_read,
osc_52_write,
kitty_read,
list,
};
/// The result of starting a clipboard read request. This only reports
@@ -95,7 +96,7 @@ pub const ClipboardReadResult = enum(c_int) {
/// be sent as a response to a ClipboardRequest event.
pub const ClipboardRequest = union(ClipboardRequestType) {
/// A direct paste of clipboard contents.
paste: void,
paste: Clipboard,
/// A request to read clipboard contents via OSC 52.
osc_52_read: Clipboard,
@@ -107,6 +108,10 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
/// protocol (OSC 5522).
kitty_read: *KittyRead,
/// A request to list the available clipboard MIME types without
/// reading any of their data.
list: Clipboard,
/// State for one in-flight Kitty clipboard protocol read. This is
/// created on the IO thread and completed on the app thread, so it
/// owns all of its memory: everything, including the struct itself,

View File

@@ -8,6 +8,7 @@
//! to ensure all our various types and logic remain in sync.
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("terminal_options");
const testing = std.testing;
@@ -335,8 +336,9 @@ const entries: []const ModeEntry = &.{
.{
.name = "kitty_paste_events",
.value = 5522,
// Only libghostty-vt supports this currently
.disabled = build_options.artifact != .lib,
// The macOS app and libghostty-vt can both serve the follow-up
// Kitty clipboard read that a paste event grants.
.disabled = build_options.artifact != .lib and builtin.os.tag != .macos,
},
};