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.
This commit is contained in:
Mitchell Hashimoto
2026-08-21 14:41:07 -07:00
parent 36676c5728
commit 6959fd46c6
4 changed files with 812 additions and 25 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
@@ -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

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,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\\", // "<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],
);
// 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" {

View File

@@ -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\\"); // "<b>hi</b>"
// 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("<b>hi</b>", 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);