diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h
index 89ae241d6..b23303605 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
@@ -572,8 +584,9 @@ typedef enum {
* duration of the reply call and may be freed as soon as it returns.
*
* Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the
- * program with an empty clipboard; the other fields are ignored in that
- * case. On success, `contents` should carry one representation per
+ * program with an empty clipboard (OSC 52) or the matching protocol status
+ * (OSC 5522: EPERM, ENOSYS, EBUSY, EIO); the other fields are ignored in
+ * that case. On success, `contents` should carry one representation per
* requested MIME type (GhosttyClipboardRead::mimes) that the clipboard
* has; unrequested representations are ignored. Protocols that carry a
* single text value (OSC 52) use the first entry with a text MIME type
@@ -637,7 +650,7 @@ typedef void (*GhosttyClipboardReadReplyFn)(
* GhosttyClipboardReadReply. This must happen before the callback returns;
* the request is invalid afterwards. Calling `reply` more than once is
* ignored. Returning without replying answers the program with an empty
- * clipboard.
+ * clipboard (OSC 52) or EPERM (OSC 5522).
*
* @ingroup terminal
*/
@@ -695,15 +708,22 @@ struct GhosttyClipboardRead {
* Callback function type for clipboard_read.
*
* Called synchronously when the running program requests clipboard contents
- * via OSC 52 with a "?" payload. Answering lets the program read the user's
- * clipboard, so the embedder is expected to mediate consent. Because the
- * read is synchronous, an embedder that needs to ask the user must block
- * (for example by running a modal prompt) until it has an answer; the VT
- * stream waits until the callback returns.
+ * via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read.
+ * Answering lets the program read the user's clipboard, so the embedder is
+ * expected to mediate consent. Because the read is synchronous, an embedder
+ * that needs to ask the user must block (for example by running a modal
+ * prompt) until it has an answer; the VT stream waits until the callback
+ * returns.
*
* Answer by calling `read->reply(read, &reply)` before returning. See
* GhosttyClipboardRead for the full contract.
*
+ * OSC 5522 requests carry the program's MIME list, name, and password grant
+ * state; a reply that sets `remember` records a session grant so later
+ * requests with the same password arrive with `granted` set. Kitty itself
+ * serves a request for only the targets listing (`list` with no `mimes`)
+ * without prompting.
+ *
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param read Borrowed clipboard read request
@@ -1243,9 +1263,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program performs a clipboard write.
- * OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set
- * of decoded MIME representations. Set to NULL to ignore clipboard writes.
- * Clipboard read requests are delivered to
+ * OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes
+ * are normalized to an atomic set of decoded MIME representations. Set
+ * to NULL to ignore clipboard writes (Kitty clipboard writes are then
+ * refused with ENOSYS). Clipboard read requests are delivered to
* GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead.
*
* Input type: GhosttyTerminalClipboardWriteFn
@@ -1411,9 +1432,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Callback invoked when the running program requests clipboard contents
- * via OSC 52 with a "?" payload. The read is synchronous and must be
- * answered before the callback returns. Set to NULL to ignore clipboard
- * read requests (the default).
+ * via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. The
+ * read is synchronous and must be answered before the callback returns.
+ * Set to NULL (the default) to ignore OSC 52 read requests and refuse
+ * OSC 5522 reads with EPERM.
*
* Input type: GhosttyTerminalClipboardReadFn
*/
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..ba00f1385 100644
--- a/src/terminal/c/terminal.zig
+++ b/src/terminal/c/terminal.zig
@@ -240,8 +240,12 @@ pub const ModeConfig = extern struct {
/// C callback state for terminal effects. Most trampolines are always
/// installed on the stream handler; they check these fields and no-op when
-/// the corresponding callback is null. The unknown-sequence trampoline is
-/// installed dynamically to preserve its null fast path.
+/// the corresponding callback is null. The unknown-sequence and
+/// clipboard trampolines are installed dynamically to preserve their
+/// null fast paths (for clipboard_write, a null Zig-level effect makes
+/// Kitty clipboard writes fail up front instead of spooling a
+/// transaction that can never commit; for clipboard_read it keeps
+/// reads denied).
const Effects = struct {
userdata: ?*anyopaque = null,
write_pty: ?WritePtyFn = null,
@@ -660,7 +664,9 @@ fn wrap(
.pwd_changed = &Effects.pwdChangedTrampoline,
.progress_report = &Effects.progressReportTrampoline,
.size = &Effects.sizeTrampoline,
- .clipboard_write = &Effects.clipboardWriteTrampoline,
+
+ // Installed dynamically when the callback is set; see Effects.
+ .clipboard_write = null,
.clipboard_read = null,
};
@@ -1244,7 +1250,13 @@ fn setTyped(
.pwd_changed => wrapper.effects.pwd_changed = value,
.progress_report => wrapper.effects.progress_report = value,
.size_cb => wrapper.effects.size_cb = value,
- .clipboard_write => wrapper.effects.clipboard_write = value,
+ .clipboard_write => {
+ wrapper.effects.clipboard_write = value;
+ wrapper.stream.handler.effects.clipboard_write = if (value != null)
+ &Effects.clipboardWriteTrampoline
+ else
+ null;
+ },
.clipboard_read => {
wrapper.effects.clipboard_read = value;
wrapper.stream.handler.effects.clipboard_read = if (value != null)
@@ -4717,8 +4729,10 @@ test "set clipboard_write callback" {
try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]);
try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]);
- // Removing the callback takes effect immediately.
+ // Removing the callback takes effect immediately and uninstalls
+ // the trampoline.
try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
+ try testing.expect(t.?.stream.handler.effects.clipboard_write == null);
const after_remove = "\x1B]52;c;eA==\x1B\\";
vt_write(t, after_remove, after_remove.len);
try testing.expectEqual(@as(usize, 7), S.count);
@@ -4738,12 +4752,183 @@ test "clipboard_write without callback is unsupported and silent" {
const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
vt_write(t, seq, seq.len);
- const handler = &t.?.stream.handler;
- const result = handler.effects.clipboard_write.?(handler, .{
- .location = .standard,
- .contents = &.{.{ .mime = "text/plain", .data = "hello" }},
- });
- try testing.expectEqual(clipboard.WriteResult.unsupported, result);
+ // No trampoline is installed until a callback is set, so the
+ // stream skips clipboard work (and never spools a Kitty clipboard
+ // transaction it can't deliver).
+ try testing.expect(t.?.stream.handler.effects.clipboard_write == null);
+}
+
+test "kitty clipboard write via C effects" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ 80,
+ 24,
+ ));
+ defer free(t);
+
+ const S = struct {
+ var responses: [512]u8 = undefined;
+ var responses_len: usize = 0;
+ var write_count: usize = 0;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_len: usize = 0;
+ var last_mimes: [4][64]u8 = undefined;
+ var last_mime_lens: [4]usize = @splat(0);
+ var last_data: [4][64]u8 = undefined;
+ var last_data_lens: [4]usize = @splat(0);
+
+ fn writePty(
+ _: Terminal,
+ _: ?*anyopaque,
+ ptr: [*]const u8,
+ len: usize,
+ ) callconv(lib.calling_conv) void {
+ @memcpy(responses[responses_len..][0..len], ptr[0..len]);
+ responses_len += len;
+ }
+
+ fn clipboardWrite(
+ _: Terminal,
+ _: ?*anyopaque,
+ request: *const ClipboardWrite,
+ ) callconv(lib.calling_conv) clipboard.WriteResult {
+ write_count += 1;
+ last_location = request.location;
+ last_contents_len = request.contents_len;
+ if (request.contents) |ptr| {
+ for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| {
+ last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len);
+ @memcpy(
+ last_mimes[i][0..last_mime_lens[i]],
+ content.mime.ptr[0..last_mime_lens[i]],
+ );
+ last_data_lens[i] = @min(content.data.len, last_data[i].len);
+ @memcpy(
+ last_data[i][0..last_data_lens[i]],
+ content.data.ptr[0..last_data_lens[i]],
+ );
+ }
+ }
+ return .success;
+ }
+ };
+ S.responses_len = 0;
+ S.write_count = 0;
+ S.last_mime_lens = @splat(0);
+ S.last_data_lens = @splat(0);
+
+ try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
+
+ // A full OSC 5522 write transaction: begin, chunked data for two
+ // representations, commit. Only the commit invokes the callback,
+ // and its result maps to the DONE response.
+ const seqs = [_][]const u8{
+ "\x1B]5522;type=write:id=c1\x1B\\",
+ "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\", // "Ghost"
+ "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\", // "ty"
+ "\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\", // "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],
+ );
+
+ // Without a read callback reads are denied.
+ S.responses_len = 0;
+ const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\";
+ vt_write(t, read, read.len);
+ try testing.expectEqual(@as(usize, 1), S.write_count);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM:id=r1\x1B\\",
+ S.responses[0..S.responses_len],
+ );
+
+ // With a read callback the request is served through it.
+ const R = struct {
+ var count: usize = 0;
+ var last_mimes_len: usize = 0;
+ var last_mime_is_text: bool = false;
+ var last_list: bool = true;
+ var last_name_len: usize = 0;
+ var last_granted: bool = true;
+ var last_can_remember: bool = true;
+
+ fn clipboardRead(
+ _: Terminal,
+ _: ?*anyopaque,
+ request: *const ClipboardRead,
+ ) callconv(lib.calling_conv) void {
+ count += 1;
+ last_mimes_len = request.mimes_len;
+ last_mime_is_text = request.mimes_len > 0 and std.mem.eql(
+ u8,
+ request.mimes.?[0].ptr[0..request.mimes.?[0].len],
+ "text/plain",
+ );
+ last_list = request.list;
+ last_name_len = request.name.len;
+ last_granted = request.granted;
+ last_can_remember = request.can_remember;
+
+ const mime: []const u8 = "text/plain";
+ const data: []const u8 = "hello";
+ const contents = [_]ClipboardContent{.{
+ .mime = .init(mime),
+ .data = .init(data),
+ }};
+ request.reply(request, &.{
+ .size = @sizeOf(ClipboardReadReply),
+ .result = .success,
+ .contents = &contents,
+ .contents_len = contents.len,
+ .available = null,
+ .available_len = 0,
+ .remember = false,
+ });
+ }
+ };
+ try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&R.clipboardRead)));
+ S.responses_len = 0;
+ // name="app" without a password: forwarded for prompts, not
+ // rememberable.
+ const read2 = "\x1B]5522;type=read:id=r2:name=YXBw;dGV4dC9wbGFpbg==\x1B\\";
+ vt_write(t, read2, read2.len);
+ try testing.expectEqual(@as(usize, 1), R.count);
+ try testing.expectEqual(@as(usize, 1), R.last_mimes_len);
+ try testing.expect(R.last_mime_is_text);
+ try testing.expect(!R.last_list);
+ try testing.expectEqual(@as(usize, 3), R.last_name_len);
+ try testing.expect(!R.last_granted);
+ try testing.expect(!R.last_can_remember);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK:id=r2\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE:id=r2\x1B\\",
+ S.responses[0..S.responses_len],
+ );
+
+ // Without a clipboard callback the transaction fails up front.
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
+ S.responses_len = 0;
+ const begin = "\x1B]5522;type=write:id=c2\x1B\\";
+ vt_write(t, begin, begin.len);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=write:status=ENOSYS:id=c2\x1B\\",
+ S.responses[0..S.responses_len],
+ );
}
test "set clipboard_read callback" {
diff --git a/src/terminal/kitty/clipboard_command.zig b/src/terminal/kitty/clipboard_command.zig
index 5f7115d83..40718840a 100644
--- a/src/terminal/kitty/clipboard_command.zig
+++ b/src/terminal/kitty/clipboard_command.zig
@@ -28,8 +28,8 @@ pub const max_pw_len = 128;
/// types are tiny; anything longer drops the packet.
pub const max_mime_len = 256;
-/// Maximum decoded name length we bother validating. Longer names are
-/// treated as present without validation; only their presence matters.
+/// Maximum decoded name length. Kitty has no limit but names are shown
+/// in permission prompts so anything longer drops the packet.
pub const max_name_len = 256;
/// The decoded, validated metadata of one OSC 5522 sequence.
@@ -62,9 +62,10 @@ pub const Metadata = struct {
/// treat the request as though it had no password."
pw: []const u8 = "",
- /// True if a non-empty (valid) name was given. We don't retain the
- /// name contents; it exists to opt into password grants.
- has_name: bool = false,
+ /// Decoded human friendly name of the requesting program, shown in
+ /// permission prompts. Empty means absent. Its presence opts into
+ /// password grants.
+ name: []const u8 = "",
/// Parse the metadata field. The raw value is expected to be exactly
/// the metadata (prefix and payload and separators stripped out).
@@ -124,21 +125,13 @@ pub const Metadata = struct {
error.Invalid => return null,
};
} else if (std.mem.eql(u8, key, "name")) {
- // We only need to know whether a (non-empty) name was
- // given; the contents are decoded for validation only.
- result.has_name = has_name: {
- const name = decodeValue(
- alloc,
- value,
- max_name_len,
- ) catch |err| switch (err) {
- error.OutOfMemory => return error.OutOfMemory,
- // Over-long names are accepted as present but
- // not validated further.
- error.Overflow => break :has_name true,
- error.Invalid => return null,
- };
- break :has_name name.len > 0;
+ result.name = decodeValue(
+ alloc,
+ value,
+ max_name_len,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.Overflow, error.Invalid => return null,
};
}
// Unknown keys are ignored.
@@ -351,7 +344,21 @@ test "metadata: pw and name" {
// pw="secret", name="app"
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=YXBw")).?;
try testing.expectEqualStrings("secret", meta.pw);
- try testing.expect(meta.has_name);
+ try testing.expectEqualStrings("app", meta.name);
+}
+
+test "metadata: over-long name dropped" {
+ const testing = std.testing;
+ var arena: std.heap.ArenaAllocator = .init(testing.allocator);
+ defer arena.deinit();
+ const Encoder = std.base64.standard.Encoder;
+ const long = "n" ** (max_name_len + 1);
+ var buf: [Encoder.calcSize(long.len)]u8 = undefined;
+ const raw = try std.mem.concat(arena.allocator(), u8, &.{
+ "type=read:name=",
+ Encoder.encode(&buf, long),
+ });
+ try testing.expect((try Metadata.parse(arena.allocator(), raw)) == null);
}
test "metadata: empty name" {
@@ -359,7 +366,7 @@ test "metadata: empty name" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=")).?;
- try testing.expect(!meta.has_name);
+ try testing.expectEqual(@as(usize, 0), meta.name.len);
}
test "payload: mime iterator" {
diff --git a/src/terminal/kitty/clipboard_write.zig b/src/terminal/kitty/clipboard_write.zig
index c4e33a4f3..fbb631446 100644
--- a/src/terminal/kitty/clipboard_write.zig
+++ b/src/terminal/kitty/clipboard_write.zig
@@ -36,7 +36,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
- has_name: bool,
+ name: []const u8,
spool: std.ArrayListUnmanaged(u8) = .empty,
entries: std.ArrayListUnmanaged(Entry) = .empty,
aliases: std.ArrayListUnmanaged(Alias) = .empty,
@@ -68,12 +68,13 @@ pub const WriteState = struct {
errdefer arena.deinit();
const id = try arena.allocator().dupe(u8, meta.id);
const pw = try arena.allocator().dupe(u8, meta.pw);
+ const name = try arena.allocator().dupe(u8, meta.name);
return .{
.arena = arena,
.loc = meta.loc,
.id = id,
.pw = pw,
- .has_name = meta.has_name,
+ .name = name,
};
}
@@ -220,7 +221,7 @@ pub const WriteState = struct {
loc: clipboard.Location,
id: []const u8,
pw: []const u8,
- has_name: bool,
+ name: []const u8,
truncated: bool,
contents: []const Content,
@@ -289,7 +290,7 @@ pub const WriteState = struct {
.loc = self.loc,
.id = self.id,
.pw = self.pw,
- .has_name = self.has_name,
+ .name = self.name,
.truncated = self.truncated,
.contents = try contents.toOwnedSlice(alloc),
};
diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig
index 10f239831..03e6d1887 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,17 @@ 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,
+
+ /// Kitty clipboard protocol (OSC 5522) session password grants,
+ /// recorded when a clipboard_read reply asks to remember the user's
+ /// decision. Later requests carrying a granted password are forwarded
+ /// with `granted` set so the embedder can skip its prompt.
+ kitty_clipboard_grants: kitty_clipboard.Grants = .{},
+
/// 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,21 +157,38 @@ 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
- /// (OSC 52 with a "?" payload). Answering one lets the program
- /// read the user's clipboard, so the embedder is expected to
- /// mediate consent.
+ /// (OSC 52 with a "?" payload, or a Kitty clipboard (OSC 5522)
+ /// read). Answering one lets the program read the user's
+ /// clipboard, so the embedder is expected to mediate consent.
///
/// Reads are synchronous: the callback must answer through
/// `read.reply` before it returns, so an embedder that needs to
/// ask the user must block (e.g. run a modal prompt) while the
- /// stream waits. Returning without a reply, or replying denied or
- /// unsupported, answers the program with an empty clipboard so it
- /// doesn't hang. If this is null, read requests are ignored.
+ /// stream waits. Returning without a reply, or replying with any
+ /// failure, answers the program with an empty clipboard (OSC 52)
+ /// or the matching protocol status (OSC 5522) so it doesn't hang.
+ /// If this is null, OSC 52 reads are ignored and OSC 5522 reads
+ /// are refused with EPERM.
+ ///
+ /// OSC 5522 requests carry the program's MIME list, name, and
+ /// password grant state; a reply that sets `remember` records a
+ /// session grant so later requests with the same password arrive
+ /// with `granted` set. Kitty itself serves a request for only the
+ /// targets listing (`list` with no `mimes`) without prompting.
clipboard_read: ?*const fn (*Handler, clipboard.Read) void,
/// Called in response to an XTVERSION query. Returns the version
@@ -207,6 +236,8 @@ pub const Handler = struct {
}
pub fn deinit(self: *Handler) void {
+ self.kittyClipboardAbort();
+ self.kitty_clipboard_grants.deinit(self.terminal.gpa());
self.apc_handler.deinit();
self.dcs_handler.deinit();
}
@@ -374,6 +405,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 operations 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 +447,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 +689,397 @@ 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 is the requested MIME list. 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,
+ };
+ defer decoded.deinit(alloc);
+
+ // Without a clipboard_read effect nothing can serve the read.
+ // EPERM is the protocol's denial so clients degrade gracefully.
+ const func = self.effects.clipboard_read orelse {
+ self.kittyClipboardRespond(&.{
+ .op = .read,
+ .status = .EPERM,
+ .id = meta.id,
+ .terminator = terminator,
+ });
+ return;
+ };
+
+ // The targets type ('.') asks for the listing of available
+ // types rather than data. Requested types beyond the cap are
+ // dropped and simply never served, which is how the protocol
+ // reports an unavailable type anyway.
+ var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined;
+ const mimes, const list = mimes: {
+ var targets = false;
+ var len: usize = 0;
+ var it = decoded.mimeIterator();
+ while (it.next()) |mime| {
+ if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) {
+ targets = true;
+ continue;
+ }
+ if (len == mimes_buf.len) continue;
+ mimes_buf[len] = mime;
+ len += 1;
+ }
+ break :mimes .{ mimes_buf[0..len], targets };
+ };
+
+ // Per the spec a password without a name is no password. A
+ // stored grant for it lets the embedder skip its prompt.
+ const pw: []const u8 = if (meta.name.len > 0) meta.pw else "";
+ const granted = self.kitty_clipboard_grants.use(alloc, pw, .read);
+
+ var state: KittyClipboardReadState = .{
+ .handler = self,
+ .primary = meta.loc == .primary,
+ .id = meta.id,
+ .pw = pw,
+ .mimes = mimes,
+ .list = list,
+ .terminator = terminator,
+ };
+ func(self, .{
+ .location = meta.loc,
+ .mimes = mimes,
+ .list = list,
+ .name = meta.name,
+ .granted = granted,
+ .can_remember = pw.len > 0,
+ .reply_ctx = &state,
+ .reply_fn = &KittyClipboardReadState.reply,
+ });
+
+ // The program is waiting on us, so a callback that returned
+ // without a reply is answered as a denial rather than silence.
+ if (!state.replied) state.respondStatus(.EPERM);
+ }
+
+ /// Reply state for one synchronous Kitty clipboard read. This lives
+ /// on the kittyClipboardRead stack frame, so it is only valid during
+ /// the callback.
+ const KittyClipboardReadState = struct {
+ handler: *Handler,
+ primary: bool,
+ id: []const u8,
+
+ /// The effective password, empty when the request had none.
+ pw: []const u8,
+
+ /// The requested types; only these are served from a reply.
+ mimes: []const []const u8,
+ list: bool,
+ terminator: osc.Terminator,
+ replied: bool = false,
+
+ fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void {
+ const self: *KittyClipboardReadState = @ptrCast(@alignCast(ctx));
+ if (self.replied) {
+ log.warn("clipboard read replied more than once, ignoring", .{});
+ return;
+ }
+ self.replied = true;
+
+ const success = switch (result) {
+ .denied => return self.respondStatus(.EPERM),
+ .unsupported => return self.respondStatus(.ENOSYS),
+ .busy => return self.respondStatus(.EBUSY),
+ .io_error => return self.respondStatus(.EIO),
+ .success => |s| s,
+ };
+
+ // Remembering is only offered when the request carried a
+ // usable password.
+ if (success.remember and self.pw.len > 0) {
+ self.handler.kitty_clipboard_grants.grant(
+ self.handler.terminal.gpa(),
+ self.pw,
+ .read,
+ false,
+ ) catch |err| {
+ log.warn("error recording clipboard grant err={}", .{err});
+ };
+ }
+
+ self.respondSuccess(&success) catch |err| {
+ log.warn("error replying to clipboard read err={}", .{err});
+ self.respondStatus(.EIO);
+ };
+ }
+
+ /// Answer with a single status packet.
+ fn respondStatus(
+ self: *const KittyClipboardReadState,
+ status: kitty_clipboard.Status,
+ ) void {
+ self.handler.kittyClipboardRespond(&.{
+ .op = .read,
+ .status = status,
+ .id = self.id,
+ .terminator = self.terminator,
+ });
+ }
+
+ /// Answer with the full success sequence (OK, listing, DATA
+ /// chunks, DONE), serving only the requested representations
+ /// in request order.
+ fn respondSuccess(
+ self: *const KittyClipboardReadState,
+ success: *const clipboard.Read.Result.Success,
+ ) error{ OutOfMemory, WriteFailed }!void {
+ const handler = self.handler;
+ if (handler.effects.write_pty == null) return;
+
+ var served_buf: [kitty_clipboard.max_read_mimes]clipboard.Content = undefined;
+ var served_len: usize = 0;
+ for (self.mimes) |mime| {
+ for (success.contents) |content| {
+ if (!std.mem.eql(u8, content.mime, mime)) continue;
+ served_buf[served_len] = content;
+ served_len += 1;
+ break;
+ }
+ }
+
+ // Status packets fit on the stack; DATA packets carry the
+ // clipboard contents and fall back to the heap.
+ var stack = std.heap.stackFallback(1024, handler.terminal.gpa());
+ const alloc = stack.get();
+ var aw: std.Io.Writer.Allocating = .init(alloc);
+ defer aw.deinit();
+ try (kitty_clipboard.ReadSuccess{
+ .primary = self.primary,
+ .id = self.id,
+ .list = self.list,
+ .available = success.available,
+ .contents = served_buf[0..served_len],
+ .terminator = self.terminator,
+ }).encode(&aw.writer);
+
+ const written = try aw.toOwnedSliceSentinel(0);
+ defer alloc.free(written);
+ handler.writePty(written);
+ }
+ };
+
+ 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 +3320,663 @@ 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);
+
+ // Read capture. A null read_result returns without replying.
+ var read_count: usize = 0;
+ var read_result: ?clipboard.Read.Result = null;
+ var read_reply_twice: bool = false;
+ var last_read_location: clipboard.Location = .standard;
+ var last_read_mimes: [8][64]u8 = undefined;
+ var last_read_mime_lens: [8]usize = @splat(0);
+ var last_read_mimes_len: usize = 0;
+ var last_read_list: bool = false;
+ var last_read_name: [64]u8 = undefined;
+ var last_read_name_len: usize = 0;
+ var last_read_granted: bool = false;
+ var last_read_can_remember: bool = false;
+
+ 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);
+ read_count = 0;
+ read_result = null;
+ read_reply_twice = false;
+ last_read_location = .standard;
+ last_read_mime_lens = @splat(0);
+ last_read_mimes_len = 0;
+ last_read_list = false;
+ last_read_name_len = 0;
+ last_read_granted = false;
+ last_read_can_remember = false;
+ }
+
+ 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 clipboardRead(_: *Handler, read: clipboard.Read) void {
+ read_count += 1;
+ last_read_location = read.location;
+ last_read_mimes_len = read.mimes.len;
+ for (read.mimes[0..@min(read.mimes.len, last_read_mimes.len)], 0..) |mime, i| {
+ last_read_mime_lens[i] = mime.len;
+ @memcpy(last_read_mimes[i][0..mime.len], mime);
+ }
+ last_read_list = read.list;
+ last_read_name_len = read.name.len;
+ @memcpy(last_read_name[0..read.name.len], read.name);
+ last_read_granted = read.granted;
+ last_read_can_remember = read.can_remember;
+ if (read_result) |r| read.reply(r);
+ if (read_reply_twice) read.reply(.denied);
+ }
+
+ fn responseSlice() []const u8 {
+ return responses[0..responses_len];
+ }
+
+ fn readMimeAt(i: usize) []const u8 {
+ return last_read_mimes[i][0..last_read_mime_lens[i]];
+ }
+
+ fn readName() []const u8 {
+ return last_read_name[0..last_read_name_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 without effect 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 read 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_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ S.read_result = .{
+ .success = .{
+ .contents = &.{
+ // Unrequested representations are never served, and the
+ // served ones follow request order, not reply order.
+ .{ .mime = "image/png", .data = "\x89PNG" },
+ .{ .mime = "text/html", .data = "hi" },
+ .{ .mime = "text/plain", .data = "Ghostty" },
+ },
+ .available = &.{ "text/plain", "text/html" },
+ },
+ };
+
+ // Request the targets listing plus two types from the primary
+ // selection: ". text/plain text/html".
+ s.nextSlice("\x1B]5522;type=read:loc=primary:id=r1;LiB0ZXh0L3BsYWluIHRleHQvaHRtbA==\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqual(clipboard.Location.primary, S.last_read_location);
+ try testing.expectEqual(@as(usize, 2), S.last_read_mimes_len);
+ try testing.expectEqualStrings("text/plain", S.readMimeAt(0));
+ try testing.expectEqualStrings("text/html", S.readMimeAt(1));
+ try testing.expect(S.last_read_list);
+ try testing.expectEqualStrings("", S.readName());
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(!S.last_read_can_remember);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK:loc=primary:id=r1\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r1:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE:id=r1\x1B\\",
+ S.responseSlice(),
+ );
+
+ // Without the listing request `available` is ignored. The response
+ // echoes the request terminator.
+ S.responses_len = 0;
+ s.nextSlice("\x1B]5522;type=read:id=r2;dGV4dC9wbGFpbg==\x07");
+ try testing.expectEqual(clipboard.Location.standard, S.last_read_location);
+ try testing.expectEqual(@as(usize, 1), S.last_read_mimes_len);
+ try testing.expect(!S.last_read_list);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK:id=r2\x07" ++
+ "\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x07" ++
+ "\x1B]5522;type=read:status=DONE:id=r2\x07",
+ S.responseSlice(),
+ );
+
+ // A listing-only request carries no types.
+ S.responses_len = 0;
+ s.nextSlice("\x1B]5522;type=read;Lg==\x1B\\");
+ try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len);
+ try testing.expect(S.last_read_list);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard read 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_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ const cases = [_]struct {
+ result: ?clipboard.Read.Result,
+ response: []const u8,
+ }{
+ .{ .result = .denied, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
+ .{ .result = .unsupported, .response = "\x1B]5522;type=read:status=ENOSYS:id=x\x1B\\" },
+ .{ .result = .busy, .response = "\x1B]5522;type=read:status=EBUSY:id=x\x1B\\" },
+ .{ .result = .io_error, .response = "\x1B]5522;type=read:status=EIO:id=x\x1B\\" },
+ // No reply at all is a denial rather than silence.
+ .{ .result = null, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" },
+ // A success with nothing to serve is still OK then DONE.
+ .{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=read:status=OK:id=x\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE:id=x\x1B\\" },
+ };
+
+ for (cases) |case| {
+ S.reset();
+ S.read_result = case.result;
+ s.nextSlice("\x1B]5522;type=read:id=x;dGV4dC9wbGFpbg==\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqualStrings(case.response, S.responseSlice());
+ }
+
+ // A second reply is ignored.
+ S.reset();
+ S.read_result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } };
+ S.read_reply_twice = true;
+ s.nextSlice("\x1B]5522;type=read;dGV4dC9wbGFpbg==\x1B\\");
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=OK\x1B\\" ++
+ "\x1B]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++
+ "\x1B]5522;type=read:status=DONE\x1B\\",
+ S.responseSlice(),
+ );
+}
+
+test "kitty clipboard read caps requested types" {
+ 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_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // "a/0 a/1 a/2 a/3 a/4 a/5 .": extras are dropped but the listing
+ // request after them still counts.
+ S.read_result = .{ .success = .{} };
+ s.nextSlice("\x1B]5522;type=read;YS8wIGEvMSBhLzIgYS8zIGEvNCBhLzUgLg==\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqual(kitty_clipboard.max_read_mimes, S.last_read_mimes_len);
+ try testing.expectEqualStrings("a/0", S.readMimeAt(0));
+ try testing.expectEqualStrings("a/3", S.readMimeAt(kitty_clipboard.max_read_mimes - 1));
+ try testing.expect(S.last_read_list);
+}
+
+test "kitty clipboard read password grants" {
+ 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_read = &S.clipboardRead;
+ var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler });
+ defer s.deinit();
+
+ // pw="secret", name="app": the first request isn't granted but the
+ // reply may ask to remember it.
+ S.read_result = .{ .success = .{ .remember = true } };
+ s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
+ try testing.expectEqual(@as(usize, 1), S.read_count);
+ try testing.expectEqualStrings("app", S.readName());
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(S.last_read_can_remember);
+
+ // The same password is now granted; a different one is not.
+ S.read_result = .{ .success = .{} };
+ s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\");
+ try testing.expect(S.last_read_granted);
+ s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(S.last_read_can_remember);
+
+ // A password without a name doesn't count: it is neither granted
+ // nor rememberable, even if the reply asks.
+ S.read_result = .{ .success = .{ .remember = true } };
+ s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0\x1B\\");
+ try testing.expectEqualStrings("", S.readName());
+ try testing.expect(!S.last_read_granted);
+ try testing.expect(!S.last_read_can_remember);
+ s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=\x1B\\");
+ try testing.expect(!S.last_read_can_remember);
+ S.read_result = .{ .success = .{} };
+ s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw\x1B\\");
+ try testing.expect(!S.last_read_granted);
+
+ // A grant is advisory: the request is still forwarded and the
+ // embedder may deny it.
+ S.responses_len = 0;
+ S.read_result = .denied;
+ s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw\x1B\\");
+ try testing.expect(S.last_read_granted);
+ try testing.expectEqualStrings(
+ "\x1B]5522;type=read:status=EPERM:id=d\x1B\\",
+ S.responseSlice(),
+ );
+
+ // Grants are freed with the stream (the testing allocator catches
+ // the leak otherwise).
+}
+
+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);