diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h
index 89ae241d6..80fdad8c3 100644
--- a/include/ghostty/vt/terminal.h
+++ b/include/ghostty/vt/terminal.h
@@ -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
@@ -1243,9 +1255,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
diff --git a/src/lib_vt.zig b/src/lib_vt.zig
index 1cc5c9ed3..414f6995b 100644
--- a/src/lib_vt.zig
+++ b/src/lib_vt.zig
@@ -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;
diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig
index 947504582..ab45fdfff 100644
--- a/src/terminal/c/terminal.zig
+++ b/src/terminal/c/terminal.zig
@@ -240,8 +240,11 @@ 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-write 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).
const Effects = struct {
userdata: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
@@ -660,7 +663,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 +1249,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 +4728,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 +4751,119 @@ 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\\", // "hi"
+ "\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("hi", 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],
+ );
+
+ // Reads are always 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],
+ );
+
+ // 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" {
diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig
index 10f239831..f0db5f773 100644
--- a/src/terminal/stream_terminal.zig
+++ b/src/terminal/stream_terminal.zig
@@ -14,6 +14,7 @@ const color = @import("color.zig");
const modes = @import("modes.zig");
const osc = @import("osc.zig");
const osc_color = @import("osc/parsers/color.zig");
+const kitty_clipboard = @import("kitty/clipboard.zig");
const kitty_color = @import("kitty/color.zig");
const size_report = @import("size_report.zig");
const simd = @import("../simd/main.zig");
@@ -71,6 +72,11 @@ pub const Handler = struct {
/// The DCS command handler maintains state for DCS queries.
dcs_handler: dcs.Handler = .{},
+ /// The in-flight Kitty clipboard protocol (OSC 5522) write
+ /// transaction, if any. Null means no transaction is active.
+ /// Heap-allocated since transactions are rare and short-lived.
+ kitty_clipboard_write: ?*kitty_clipboard.WriteState = null,
+
/// Called for sequence identifiers not supported by this library.
/// Currently, only APC is reported. Content is borrowed and only valid
/// for the duration of the callback. Set `apc_handler.unknown_max_bytes`
@@ -145,8 +151,17 @@ pub const Handler = struct {
/// A write with no contents clears the destination. A content entry
/// with empty data is a distinct empty representation.
///
- /// Clipboard read requests (OSC 52 with a "?" payload) are
- /// delivered to clipboard_read instead.
+ /// OSC 52, OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes all
+ /// share this callback. Every call is one complete write whose
+ /// contents replace whatever the destination previously held; there
+ /// is never a partial update. A Kitty clipboard write transaction
+ /// results in exactly one call, at commit, carrying all of the
+ /// transaction's representations, and the returned result is
+ /// reported back to the running program as the commit status (see
+ /// kittyClipboard).
+ ///
+ /// Clipboard read requests (OSC 52 with a "?" payload and OSC 5522
+ /// reads) are delivered to clipboard_read instead.
clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
/// Called when the running program requests clipboard contents
@@ -207,6 +222,7 @@ pub const Handler = struct {
}
pub fn deinit(self: *Handler) void {
+ self.kittyClipboardAbort();
self.apc_handler.deinit();
self.dcs_handler.deinit();
}
@@ -374,6 +390,11 @@ pub const Handler = struct {
.kitty_color_report => self.kittyColorOperation(value) catch |err| {
log.warn("error reporting Kitty colors err={}", .{err});
},
+ .kitty_clipboard => self.kittyClipboard(value) catch |err| {
+ // Clipboard writes are external effects, not terminal
+ // state; a failed transaction was already answered.
+ log.warn("error handling kitty clipboard err={}", .{err});
+ },
// APC
.apc_start => self.apc_handler.start(),
@@ -411,8 +432,6 @@ pub const Handler = struct {
// Have no terminal-modifying effect
.title_push,
.title_pop,
- // Unimplemented; the sequence is consumed and ignored.
- .kitty_clipboard,
=> {},
}
}
@@ -655,6 +674,237 @@ pub const Handler = struct {
}
};
+ /// Handle one Kitty clipboard protocol (OSC 5522) packet.
+ fn kittyClipboard(
+ self: *Handler,
+ v: Action.Value(.kitty_clipboard),
+ ) error{OutOfMemory}!void {
+ // Decode and validate the metadata.
+ var arena: std.heap.ArenaAllocator = .init(self.terminal.gpa());
+ defer arena.deinit();
+ const meta = (try kitty_clipboard.Metadata.parse(
+ arena.allocator(),
+ v.metadata,
+ )) orelse return;
+
+ const payload = v.payload orelse "";
+ switch (meta.op) {
+ .read => try self.kittyClipboardRead(&meta, payload, v.terminator),
+ .write => try self.kittyClipboardWriteBegin(&meta, v.terminator),
+ .wdata => try self.kittyClipboardData(&meta, payload, v.terminator),
+ .walias => try self.kittyClipboardAlias(&meta, payload, v.terminator),
+ }
+ }
+
+ fn kittyClipboardRead(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ payload: []const u8,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // The payload (the requested MIME list) must still decode even
+ // though we never serve it: kitty drops a read request with an
+ // undecodable payload without any response.
+ const alloc = self.terminal.gpa();
+ const decoded = kitty_clipboard.Payload.init(
+ alloc,
+ payload,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.Invalid => return,
+ };
+ decoded.deinit(alloc);
+
+ // For now, EPERM always
+ self.kittyClipboardRespond(&.{
+ .op = .read,
+ .status = .EPERM,
+ .id = meta.id,
+ .terminator = terminator,
+ });
+ }
+
+ fn kittyClipboardWriteBegin(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // A new write silently replaces any in-flight transaction.
+ self.kittyClipboardAbort();
+
+ // Without a clipboard_write effect a commit can never succeed,
+ // so fail the transaction up front instead of spooling data
+ // we'd only throw away. Later wdata packets are ignored.
+ if (self.effects.clipboard_write == null) {
+ self.kittyClipboardRespond(&.{
+ .op = .write,
+ .status = .ENOSYS,
+ .id = meta.id,
+ .terminator = terminator,
+ });
+ return;
+ }
+
+ // Setup our write state
+ const alloc = self.terminal.gpa();
+ const state = try alloc.create(kitty_clipboard.WriteState);
+ errdefer alloc.destroy(state);
+ state.* = try .init(alloc, meta);
+ self.kitty_clipboard_write = state;
+ }
+
+ fn kittyClipboardData(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ payload: []const u8,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // Data without a transaction is silently ignored.
+ const state = self.kitty_clipboard_write orelse return;
+
+ // A wdata packet without a MIME type commits the transaction.
+ if (meta.mime.len == 0) return self.kittyClipboardCommit(
+ state,
+ terminator,
+ );
+
+ state.data(
+ self.terminal.gpa(),
+ meta,
+ payload,
+ ) catch |err| switch (err) {
+ // Failing to spool matches kitty's EIO for a failed buffer
+ // write.
+ error.OutOfMemory => {
+ self.kittyClipboardFinish(
+ state,
+ .EIO,
+ terminator,
+ );
+ return error.OutOfMemory;
+ },
+ };
+ }
+
+ fn kittyClipboardAlias(
+ self: *Handler,
+ meta: *const kitty_clipboard.Metadata,
+ payload: []const u8,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ // Aliases without a transaction or without a target MIME type
+ // are silently ignored.
+ const state = self.kitty_clipboard_write orelse return;
+ if (meta.mime.len == 0) return;
+
+ state.alias(
+ self.terminal.gpa(),
+ meta,
+ payload,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => {
+ self.kittyClipboardFinish(
+ state,
+ .EIO,
+ terminator,
+ );
+ return error.OutOfMemory;
+ },
+
+ // An undecodable alias payload aborts the transaction.
+ error.Invalid => self.kittyClipboardFinish(
+ state,
+ .EINVAL,
+ terminator,
+ ),
+ };
+ }
+
+ fn kittyClipboardCommit(
+ self: *Handler,
+ state: *kitty_clipboard.WriteState,
+ terminator: osc.Terminator,
+ ) error{OutOfMemory}!void {
+ const alloc = self.terminal.gpa();
+ const committed = state.commit(alloc) catch |err| switch (err) {
+ error.OutOfMemory => {
+ self.kittyClipboardFinish(state, .EIO, terminator);
+ return error.OutOfMemory;
+ },
+ };
+ defer committed.deinit(alloc);
+
+ // The effect result maps 1:1 onto the protocol's commit
+ // statuses. The effect can't be null here (checked when the
+ // transaction began) but if an embedder cleared it
+ // mid-transaction that's ENOSYS.
+ const result: clipboard.WriteResult = if (self.effects.clipboard_write) |func|
+ func(self, .{
+ .location = committed.loc,
+ .contents = committed.contents,
+ })
+ else
+ .unsupported;
+
+ self.kittyClipboardFinish(state, switch (result) {
+ .success => .DONE,
+ .denied => .EPERM,
+ .unsupported => .ENOSYS,
+ .busy => .EBUSY,
+ .invalid_data => .EINVAL,
+ .io_error, _ => .EIO,
+ }, terminator);
+ }
+
+ /// Answer a write transaction with its final status and drop it.
+ /// The id echoed is the one from the transaction's opening write
+ /// packet, matching kitty.
+ fn kittyClipboardFinish(
+ self: *Handler,
+ state: *const kitty_clipboard.WriteState,
+ status: kitty_clipboard.Status,
+ terminator: osc.Terminator,
+ ) void {
+ self.kittyClipboardRespond(&.{
+ .op = .write,
+ .status = status,
+ .id = state.id,
+ .terminator = terminator,
+ });
+ self.kittyClipboardAbort();
+ }
+
+ /// Drop any in-flight write transaction without responding.
+ fn kittyClipboardAbort(self: *Handler) void {
+ if (self.kitty_clipboard_write) |state| {
+ const alloc = self.terminal.gpa();
+ state.deinit(alloc);
+ alloc.destroy(state);
+ self.kitty_clipboard_write = null;
+ }
+ }
+
+ /// Encode and write a single response packet. Unlike kitty, which
+ /// always terminates responses with ST, we echo the terminator of
+ /// the request being answered, matching our other OSC responses.
+ fn kittyClipboardRespond(
+ self: *Handler,
+ response: *const kitty_clipboard.Response,
+ ) void {
+ if (self.effects.write_pty == null) return;
+
+ // Our responses carry at most a status and the echoed id so
+ // they virtually always fit on the stack.
+ var stack = std.heap.stackFallback(1024, self.terminal.gpa());
+ const alloc = stack.get();
+ var aw: std.Io.Writer.Allocating = .init(alloc);
+ defer aw.deinit();
+ response.encode(&aw.writer) catch return;
+ const resp = aw.toOwnedSliceSentinel(0) catch return;
+ defer alloc.free(resp);
+ self.writePty(resp);
+ }
+
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
@@ -2895,6 +3145,410 @@ test "clipboard_write allocation failure is ignored" {
try testing.expect(!s.handler.semantic_failure);
}
+/// Shared capture state for the Kitty clipboard (OSC 5522) tests below:
+/// records every pty response and the most recent clipboard write.
+const KittyClipboardCapture = struct {
+ var responses: [1024]u8 = undefined;
+ var responses_len: usize = 0;
+ var write_count: usize = 0;
+ var result: clipboard.WriteResult = .success;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_len: usize = 0;
+ var last_mimes: [8][64]u8 = undefined;
+ var last_mime_lens: [8]usize = @splat(0);
+ var last_data: [8][256]u8 = undefined;
+ var last_data_lens: [8]usize = @splat(0);
+
+ fn reset() void {
+ responses_len = 0;
+ write_count = 0;
+ result = .success;
+ last_location = .standard;
+ last_contents_len = 0;
+ last_mime_lens = @splat(0);
+ last_data_lens = @splat(0);
+ }
+
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ @memcpy(responses[responses_len..][0..data.len], data);
+ responses_len += data.len;
+ }
+
+ fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult {
+ write_count += 1;
+ last_location = write.location;
+ last_contents_len = write.contents.len;
+ for (write.contents[0..@min(write.contents.len, last_mimes.len)], 0..) |content, i| {
+ last_mime_lens[i] = content.mime.len;
+ @memcpy(last_mimes[i][0..content.mime.len], content.mime);
+ last_data_lens[i] = content.data.len;
+ @memcpy(last_data[i][0..content.data.len], content.data);
+ }
+ return result;
+ }
+
+ fn responseSlice() []const u8 {
+ return responses[0..responses_len];
+ }
+
+ fn mimeAt(i: usize) []const u8 {
+ return last_mimes[i][0..last_mime_lens[i]];
+ }
+
+ fn dataAt(i: usize) []const u8 {
+ return last_data[i][0..last_data_lens[i]];
+ }
+};
+
+test "kitty clipboard write transaction round trip" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Begin a write, stream two MIME types (one chunked), alias the
+ // plain text, and commit. Only the commit produces a response.
+ s.nextSlice("\x1B]5522;type=write:id=42\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); // "Ghost"
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\"); // "ty"
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\"); // "hi"
+ // Alias "TEXT UTF8_STRING" -> text/plain.
+ s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVCBVVEY4X1NUUklORw==\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expectEqual(@as(usize, 4), S.last_contents_len);
+ try testing.expectEqualStrings("text/plain", S.mimeAt(0));
+ try testing.expectEqualStrings("Ghostty", S.dataAt(0));
+ try testing.expectEqualStrings("text/html", S.mimeAt(1));
+ try testing.expectEqualStrings("hi", S.dataAt(1));
+ try testing.expectEqualStrings("TEXT", S.mimeAt(2));
+ try testing.expectEqualStrings("Ghostty", S.dataAt(2));
+ try testing.expectEqualStrings("UTF8_STRING", S.mimeAt(3));
+ try testing.expectEqualStrings("Ghostty", S.dataAt(3));
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE:id=42\x1B\\",
+ S.responseSlice(),
+ );
+
+ // A commit with no transaction in flight is silently ignored.
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+}
+
+test "kitty clipboard write result maps to response status" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ const cases = [_]struct {
+ result: clipboard.WriteResult,
+ response: []const u8,
+ }{
+ .{ .result = .success, .response = "\x1B]5522;type=write:status=DONE\x1B\\" },
+ .{ .result = .denied, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" },
+ .{ .result = .unsupported, .response = "\x1B]5522;type=write:status=ENOSYS\x1B\\" },
+ .{ .result = .busy, .response = "\x1B]5522;type=write:status=EBUSY\x1B\\" },
+ .{ .result = .invalid_data, .response = "\x1B]5522;type=write:status=EINVAL\x1B\\" },
+ .{ .result = .io_error, .response = "\x1B]5522;type=write:status=EIO\x1B\\" },
+ };
+
+ for (cases) |case| {
+ S.reset();
+ S.result = case.result;
+
+ // An immediately-committed write with no data is a clear.
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.last_contents_len);
+ try testing.expectEqualStrings(case.response, S.responseSlice());
+ }
+
+ // The response echoes the request terminator, unlike kitty which
+ // always uses ST.
+ S.reset();
+ s.nextSlice("\x1B]5522;type=write:loc=primary\x07");
+ s.nextSlice("\x1B]5522;type=wdata\x07");
+ try testing.expectEqual(clipboard.Location.primary, S.last_location);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE\x07",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard write without clipboard effect responds ENOSYS" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // The transaction fails as soon as it begins; the rest of it is
+ // ignored without further responses.
+ s.nextSlice("\x1B]5522;type=write:id=x\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\",
+ S.responseSlice(),
+ );
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard read is denied with EPERM" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // The denial never includes loc (only OK responses do) and echoes
+ // the sanitized id.
+ s.nextSlice("\x1B]5522;type=read:loc=primary:id=*4 2*;dGV4dC9wbGFpbg==\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM:id=42\x1B\\",
+ S.responseSlice(),
+ );
+
+ // A missing payload is an empty MIME list, still answered.
+ S.reset();
+ s.nextSlice("\x1B]5522;type=read\x07");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM\x07",
+ S.responseSlice(),
+ );
+
+ // An undecodable payload is dropped without a response.
+ S.reset();
+ s.nextSlice("\x1B]5522;type=read;!!!\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+}
+
+test "kitty clipboard malformed packets are silently dropped" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Missing type, unknown type, bare metadata record, invalid mime
+ // base64, and orphaned transaction packets all drop silently.
+ s.nextSlice("\x1B]5522;loc=primary\x1B\\");
+ s.nextSlice("\x1B]5522;type=bobr\x1B\\");
+ s.nextSlice("\x1B]5522;type=read:bare\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=!!!;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVA==\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+ try testing.expect(!s.handler.semantic_failure);
+
+ // The terminal is still functional afterwards.
+ s.nextSlice("ok");
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("ok", str);
+}
+
+test "kitty clipboard new write replaces in-flight transaction" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write:id=old\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;b2xk\x1B\\"); // "old"
+ s.nextSlice("\x1B]5522;type=write:id=new\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bmV3\x1B\\"); // "new"
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqual(@as(usize, 1), S.last_contents_len);
+ try testing.expectEqualStrings("new", S.dataAt(0));
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE:id=new\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard invalid walias payload aborts with EINVAL" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write:id=w\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;!!!\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
+ S.responseSlice(),
+ );
+ try testing.expect(!s.handler.semantic_failure);
+
+ // The transaction is gone: a commit does nothing further.
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard invalid wdata chunk is skipped" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbG8=\x1B\\"); // "Hello"
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;!!!bad!!!\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;V29ybGQ=\x1B\\"); // "World"
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqualStrings("HelloWorld", S.dataAt(0));
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=DONE\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard in-flight transaction is freed on deinit" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Never committed: stream deinit must free the transaction (the
+ // testing allocator catches the leak otherwise).
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+}
+
+test "kitty clipboard allocation failure is ignored" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // Only transaction state uses the terminal allocator here. Swap in
+ // an allocator that always fails, then restore it before teardown.
+ {
+ const alloc = t.screens.active.alloc;
+ t.screens.active.alloc = testing.failing_allocator;
+ defer t.screens.active.alloc = alloc;
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ }
+
+ // Clipboard writes are external effects, best-effort like OSC 52;
+ // the failed transaction never started and is not a semantic
+ // failure.
+ try testing.expect(!s.handler.semantic_failure);
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.write_count);
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+}
+
+test "kitty clipboard without write_pty still commits writes" {
+ var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = KittyClipboardCapture;
+ S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.clipboard_write = &S.clipboardWrite;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ s.nextSlice("\x1B]5522;type=write\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\");
+ s.nextSlice("\x1B]5522;type=wdata\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqualStrings("Ghost", S.dataAt(0));
+
+ // Reads are dropped without a way to respond.
+ s.nextSlice("\x1B]5522;type=read\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.responses_len);
+}
+
test "request mode DECRQM with write_pty callback" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);