From 6959fd46c6ea7e6a2e5c2f9c680158db2f6f82f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 14:41:07 -0700 Subject: [PATCH] libghostty: implement Kitty clipboard protocol write only This implements only the clipboard _write_ side of the Kitty clipboard protocol for libghostty-vt. libghostty users don't need to do anything, this all automatically works since it just piggy-backs on the previous clipboard write effect. Clipboard reading is far more complicated because we don't have anything designed yet for libghostty-vt that does async requests (e.g. to ask the user for permission). I need to think about that more. --- include/ghostty/vt/terminal.h | 31 +- src/lib_vt.zig | 2 +- src/terminal/c/terminal.zig | 142 ++++++- src/terminal/stream_terminal.zig | 662 ++++++++++++++++++++++++++++++- 4 files changed, 812 insertions(+), 25 deletions(-) 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);