libghostty: implement Kitty clipboard protocol read/write (#13963)

This implements the full Kitty clipboard protocol for libghostty-vt.
libghostty users need to only have the clipboard read/write effect for
this to work. This doesn't yet do mode 5522.
This commit is contained in:
Mitchell Hashimoto
2026-08-22 08:50:38 -07:00
committed by GitHub
6 changed files with 1365 additions and 68 deletions

View File

@@ -95,8 +95,8 @@ extern "C" {
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS query (CSI 14/16/18 t) or mode 2048 enable |
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" |
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 / OSC 5522 |
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" / OSC 5522 |
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
* | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 |
* | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier |
@@ -494,7 +494,10 @@ typedef struct {
* Result of a clipboard write callback.
*
* Protocols without write acknowledgements, including OSC 52 and iTerm2
* OSC 1337 Copy, ignore this result.
* OSC 1337 Copy, ignore this result. The Kitty clipboard protocol
* (OSC 5522) acknowledges writes: each result maps to the corresponding
* protocol status (DONE, EPERM, ENOSYS, EBUSY, EINVAL, EIO) and is
* reported back to the running program through the write_pty callback.
*
* @ingroup terminal
*/
@@ -525,9 +528,18 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Called synchronously for a complete logical clipboard write. Protocol
* details such as OSC 52 selectors, base64 encoding, multipart chunks,
* aliases, and terminators are normalized before this callback is invoked.
* OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback
* shape. OSC 52 clipboard read requests ("?") are delivered to
* GhosttyTerminalClipboardReadFn instead.
* OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes
* therefore use the same callback shape.
*
* Every invocation is one complete write: the contents replace whatever
* the destination previously held, so there is never a partial update to
* detect or a reset to perform. A Kitty clipboard write transaction
* results in exactly one invocation, at commit, carrying all of the
* transaction's MIME representations together; its protocol response is
* generated automatically from the returned result.
*
* Clipboard read requests (OSC 52 "?" and OSC 5522 reads) are delivered
* to GhosttyTerminalClipboardReadFn instead.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
@@ -572,8 +584,9 @@ typedef enum {
* duration of the reply call and may be freed as soon as it returns.
*
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
* program with an empty clipboard; the other fields are ignored in that
* case. On success, `contents` should carry one representation per
* program with an empty clipboard (OSC 52) or the matching protocol status
* (OSC 5522: EPERM, ENOSYS, EBUSY, EIO); the other fields are ignored in
* that case. On success, `contents` should carry one representation per
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
* has; unrequested representations are ignored. Protocols that carry a
* single text value (OSC 52) use the first entry with a text MIME type
@@ -637,7 +650,7 @@ typedef void (*GhosttyClipboardReadReplyFn)(
* GhosttyClipboardReadReply. This must happen before the callback returns;
* the request is invalid afterwards. Calling `reply` more than once is
* ignored. Returning without replying answers the program with an empty
* clipboard.
* clipboard (OSC 52) or EPERM (OSC 5522).
*
* @ingroup terminal
*/
@@ -695,15 +708,22 @@ struct GhosttyClipboardRead {
* Callback function type for clipboard_read.
*
* Called synchronously when the running program requests clipboard contents
* via OSC 52 with a "?" payload. Answering lets the program read the user's
* clipboard, so the embedder is expected to mediate consent. Because the
* read is synchronous, an embedder that needs to ask the user must block
* (for example by running a modal prompt) until it has an answer; the VT
* stream waits until the callback returns.
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read.
* Answering lets the program read the user's clipboard, so the embedder is
* expected to mediate consent. Because the read is synchronous, an embedder
* that needs to ask the user must block (for example by running a modal
* prompt) until it has an answer; the VT stream waits until the callback
* returns.
*
* Answer by calling `read->reply(read, &reply)` before returning. See
* GhosttyClipboardRead for the full contract.
*
* OSC 5522 requests carry the program's MIME list, name, and password grant
* state; a reply that sets `remember` records a session grant so later
* requests with the same password arrive with `granted` set. Kitty itself
* serves a request for only the targets listing (`list` with no `mimes`)
* without prompting.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param read Borrowed clipboard read request
@@ -1243,9 +1263,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program performs a clipboard write.
* OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set
* of decoded MIME representations. Set to NULL to ignore clipboard writes.
* Clipboard read requests are delivered to
* OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes
* are normalized to an atomic set of decoded MIME representations. Set
* to NULL to ignore clipboard writes (Kitty clipboard writes are then
* refused with ENOSYS). Clipboard read requests are delivered to
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead.
*
* Input type: GhosttyTerminalClipboardWriteFn
@@ -1411,9 +1432,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program requests clipboard contents
* via OSC 52 with a "?" payload. The read is synchronous and must be
* answered before the callback returns. Set to NULL to ignore clipboard
* read requests (the default).
* via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. The
* read is synchronous and must be answered before the callback returns.
* Set to NULL (the default) to ignore OSC 52 read requests and refuse
* OSC 5522 reads with EPERM.
*
* Input type: GhosttyTerminalClipboardReadFn
*/

View File

@@ -50,10 +50,10 @@ pub const sys = terminal.sys;
pub const TinyIo = @import("lib/TinyIo.zig");
pub const apc = terminal.apc;
pub const clipboard = terminal.clipboard;
pub const dcs = terminal.dcs;
pub const osc = terminal.osc;
pub const point = terminal.point;
pub const clipboard = terminal.clipboard;
pub const color = terminal.color;
pub const device_status = terminal.device_status;
pub const formatter = terminal.formatter;

View File

@@ -240,8 +240,12 @@ pub const ModeConfig = extern struct {
/// C callback state for terminal effects. Most trampolines are always
/// installed on the stream handler; they check these fields and no-op when
/// the corresponding callback is null. The unknown-sequence trampoline is
/// installed dynamically to preserve its null fast path.
/// the corresponding callback is null. The unknown-sequence and
/// clipboard trampolines are installed dynamically to preserve their
/// null fast paths (for clipboard_write, a null Zig-level effect makes
/// Kitty clipboard writes fail up front instead of spooling a
/// transaction that can never commit; for clipboard_read it keeps
/// reads denied).
const Effects = struct {
userdata: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
@@ -660,7 +664,9 @@ fn wrap(
.pwd_changed = &Effects.pwdChangedTrampoline,
.progress_report = &Effects.progressReportTrampoline,
.size = &Effects.sizeTrampoline,
.clipboard_write = &Effects.clipboardWriteTrampoline,
// Installed dynamically when the callback is set; see Effects.
.clipboard_write = null,
.clipboard_read = null,
};
@@ -1244,7 +1250,13 @@ fn setTyped(
.pwd_changed => wrapper.effects.pwd_changed = value,
.progress_report => wrapper.effects.progress_report = value,
.size_cb => wrapper.effects.size_cb = value,
.clipboard_write => wrapper.effects.clipboard_write = value,
.clipboard_write => {
wrapper.effects.clipboard_write = value;
wrapper.stream.handler.effects.clipboard_write = if (value != null)
&Effects.clipboardWriteTrampoline
else
null;
},
.clipboard_read => {
wrapper.effects.clipboard_read = value;
wrapper.stream.handler.effects.clipboard_read = if (value != null)
@@ -4717,8 +4729,10 @@ test "set clipboard_write callback" {
try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]);
try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]);
// Removing the callback takes effect immediately.
// Removing the callback takes effect immediately and uninstalls
// the trampoline.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
try testing.expect(t.?.stream.handler.effects.clipboard_write == null);
const after_remove = "\x1B]52;c;eA==\x1B\\";
vt_write(t, after_remove, after_remove.len);
try testing.expectEqual(@as(usize, 7), S.count);
@@ -4738,12 +4752,183 @@ test "clipboard_write without callback is unsupported and silent" {
const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
vt_write(t, seq, seq.len);
const handler = &t.?.stream.handler;
const result = handler.effects.clipboard_write.?(handler, .{
.location = .standard,
.contents = &.{.{ .mime = "text/plain", .data = "hello" }},
});
try testing.expectEqual(clipboard.WriteResult.unsupported, result);
// No trampoline is installed until a callback is set, so the
// stream skips clipboard work (and never spools a Kitty clipboard
// transaction it can't deliver).
try testing.expect(t.?.stream.handler.effects.clipboard_write == null);
}
test "kitty clipboard write via C effects" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
80,
24,
));
defer free(t);
const S = struct {
var responses: [512]u8 = undefined;
var responses_len: usize = 0;
var write_count: usize = 0;
var last_location: clipboard.Location = .standard;
var last_contents_len: usize = 0;
var last_mimes: [4][64]u8 = undefined;
var last_mime_lens: [4]usize = @splat(0);
var last_data: [4][64]u8 = undefined;
var last_data_lens: [4]usize = @splat(0);
fn writePty(
_: Terminal,
_: ?*anyopaque,
ptr: [*]const u8,
len: usize,
) callconv(lib.calling_conv) void {
@memcpy(responses[responses_len..][0..len], ptr[0..len]);
responses_len += len;
}
fn clipboardWrite(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardWrite,
) callconv(lib.calling_conv) clipboard.WriteResult {
write_count += 1;
last_location = request.location;
last_contents_len = request.contents_len;
if (request.contents) |ptr| {
for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| {
last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len);
@memcpy(
last_mimes[i][0..last_mime_lens[i]],
content.mime.ptr[0..last_mime_lens[i]],
);
last_data_lens[i] = @min(content.data.len, last_data[i].len);
@memcpy(
last_data[i][0..last_data_lens[i]],
content.data.ptr[0..last_data_lens[i]],
);
}
}
return .success;
}
};
S.responses_len = 0;
S.write_count = 0;
S.last_mime_lens = @splat(0);
S.last_data_lens = @splat(0);
try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
// A full OSC 5522 write transaction: begin, chunked data for two
// representations, commit. Only the commit invokes the callback,
// and its result maps to the DONE response.
const seqs = [_][]const u8{
"\x1B]5522;type=write:id=c1\x1B\\",
"\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\", // "Ghost"
"\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\", // "ty"
"\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\", // "<b>hi</b>"
"\x1B]5522;type=wdata\x1B\\",
};
for (seqs) |seq| vt_write(t, seq.ptr, seq.len);
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqual(clipboard.Location.standard, S.last_location);
try testing.expectEqual(@as(usize, 2), S.last_contents_len);
try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
try testing.expectEqualStrings("Ghostty", S.last_data[0][0..S.last_data_lens[0]]);
try testing.expectEqualStrings("text/html", S.last_mimes[1][0..S.last_mime_lens[1]]);
try testing.expectEqualStrings("<b>hi</b>", S.last_data[1][0..S.last_data_lens[1]]);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=DONE:id=c1\x1B\\",
S.responses[0..S.responses_len],
);
// Without a read callback reads are denied.
S.responses_len = 0;
const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\";
vt_write(t, read, read.len);
try testing.expectEqual(@as(usize, 1), S.write_count);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=EPERM:id=r1\x1B\\",
S.responses[0..S.responses_len],
);
// With a read callback the request is served through it.
const R = struct {
var count: usize = 0;
var last_mimes_len: usize = 0;
var last_mime_is_text: bool = false;
var last_list: bool = true;
var last_name_len: usize = 0;
var last_granted: bool = true;
var last_can_remember: bool = true;
fn clipboardRead(
_: Terminal,
_: ?*anyopaque,
request: *const ClipboardRead,
) callconv(lib.calling_conv) void {
count += 1;
last_mimes_len = request.mimes_len;
last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
u8,
request.mimes.?[0].ptr[0..request.mimes.?[0].len],
"text/plain",
);
last_list = request.list;
last_name_len = request.name.len;
last_granted = request.granted;
last_can_remember = request.can_remember;
const mime: []const u8 = "text/plain";
const data: []const u8 = "hello";
const contents = [_]ClipboardContent{.{
.mime = .init(mime),
.data = .init(data),
}};
request.reply(request, &.{
.size = @sizeOf(ClipboardReadReply),
.result = .success,
.contents = &contents,
.contents_len = contents.len,
.available = null,
.available_len = 0,
.remember = false,
});
}
};
try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&R.clipboardRead)));
S.responses_len = 0;
// name="app" without a password: forwarded for prompts, not
// rememberable.
const read2 = "\x1B]5522;type=read:id=r2:name=YXBw;dGV4dC9wbGFpbg==\x1B\\";
vt_write(t, read2, read2.len);
try testing.expectEqual(@as(usize, 1), R.count);
try testing.expectEqual(@as(usize, 1), R.last_mimes_len);
try testing.expect(R.last_mime_is_text);
try testing.expect(!R.last_list);
try testing.expectEqual(@as(usize, 3), R.last_name_len);
try testing.expect(!R.last_granted);
try testing.expect(!R.last_can_remember);
try testing.expectEqualStrings(
"\x1B]5522;type=read:status=OK:id=r2\x1B\\" ++
"\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
"\x1B]5522;type=read:status=DONE:id=r2\x1B\\",
S.responses[0..S.responses_len],
);
// Without a clipboard callback the transaction fails up front.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
S.responses_len = 0;
const begin = "\x1B]5522;type=write:id=c2\x1B\\";
vt_write(t, begin, begin.len);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=ENOSYS:id=c2\x1B\\",
S.responses[0..S.responses_len],
);
}
test "set clipboard_read callback" {

View File

@@ -28,8 +28,8 @@ pub const max_pw_len = 128;
/// types are tiny; anything longer drops the packet.
pub const max_mime_len = 256;
/// Maximum decoded name length we bother validating. Longer names are
/// treated as present without validation; only their presence matters.
/// Maximum decoded name length. Kitty has no limit but names are shown
/// in permission prompts so anything longer drops the packet.
pub const max_name_len = 256;
/// The decoded, validated metadata of one OSC 5522 sequence.
@@ -62,9 +62,10 @@ pub const Metadata = struct {
/// treat the request as though it had no password."
pw: []const u8 = "",
/// True if a non-empty (valid) name was given. We don't retain the
/// name contents; it exists to opt into password grants.
has_name: bool = false,
/// Decoded human friendly name of the requesting program, shown in
/// permission prompts. Empty means absent. Its presence opts into
/// password grants.
name: []const u8 = "",
/// Parse the metadata field. The raw value is expected to be exactly
/// the metadata (prefix and payload and separators stripped out).
@@ -124,21 +125,13 @@ pub const Metadata = struct {
error.Invalid => return null,
};
} else if (std.mem.eql(u8, key, "name")) {
// We only need to know whether a (non-empty) name was
// given; the contents are decoded for validation only.
result.has_name = has_name: {
const name = decodeValue(
alloc,
value,
max_name_len,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
// Over-long names are accepted as present but
// not validated further.
error.Overflow => break :has_name true,
error.Invalid => return null,
};
break :has_name name.len > 0;
result.name = decodeValue(
alloc,
value,
max_name_len,
) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Overflow, error.Invalid => return null,
};
}
// Unknown keys are ignored.
@@ -351,7 +344,21 @@ test "metadata: pw and name" {
// pw="secret", name="app"
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=YXBw")).?;
try testing.expectEqualStrings("secret", meta.pw);
try testing.expect(meta.has_name);
try testing.expectEqualStrings("app", meta.name);
}
test "metadata: over-long name dropped" {
const testing = std.testing;
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const Encoder = std.base64.standard.Encoder;
const long = "n" ** (max_name_len + 1);
var buf: [Encoder.calcSize(long.len)]u8 = undefined;
const raw = try std.mem.concat(arena.allocator(), u8, &.{
"type=read:name=",
Encoder.encode(&buf, long),
});
try testing.expect((try Metadata.parse(arena.allocator(), raw)) == null);
}
test "metadata: empty name" {
@@ -359,7 +366,7 @@ test "metadata: empty name" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=")).?;
try testing.expect(!meta.has_name);
try testing.expectEqual(@as(usize, 0), meta.name.len);
}
test "payload: mime iterator" {

View File

@@ -36,7 +36,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
has_name: bool,
name: []const u8,
spool: std.ArrayListUnmanaged(u8) = .empty,
entries: std.ArrayListUnmanaged(Entry) = .empty,
aliases: std.ArrayListUnmanaged(Alias) = .empty,
@@ -68,12 +68,13 @@ pub const WriteState = struct {
errdefer arena.deinit();
const id = try arena.allocator().dupe(u8, meta.id);
const pw = try arena.allocator().dupe(u8, meta.pw);
const name = try arena.allocator().dupe(u8, meta.name);
return .{
.arena = arena,
.loc = meta.loc,
.id = id,
.pw = pw,
.has_name = meta.has_name,
.name = name,
};
}
@@ -220,7 +221,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
has_name: bool,
name: []const u8,
truncated: bool,
contents: []const Content,
@@ -289,7 +290,7 @@ pub const WriteState = struct {
.loc = self.loc,
.id = self.id,
.pw = self.pw,
.has_name = self.has_name,
.name = self.name,
.truncated = self.truncated,
.contents = try contents.toOwnedSlice(alloc),
};

File diff suppressed because it is too large Load Diff