From 5984d6f7326845312bf5c00f4d4ae181cd733c41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 10:16:25 -0700 Subject: [PATCH 01/11] terminal: add isTextMime helper for plain text MIME type names --- src/terminal/clipboard.zig | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/terminal/clipboard.zig b/src/terminal/clipboard.zig index 554ca984b..170aa0725 100644 --- a/src/terminal/clipboard.zig +++ b/src/terminal/clipboard.zig @@ -1,3 +1,5 @@ +const std = @import("std"); + /// The clipboard destination for a write. pub const Location = enum(c_int) { standard = 0, @@ -6,6 +8,21 @@ pub const Location = enum(c_int) { _, }; +/// MIME types that name plain text across the platforms terminals run +/// on. We accept the union everywhere since serving text under any of these +/// names is harmless. +pub fn isTextMime(mime: []const u8) bool { + const names: []const []const u8 = &.{ + "text/plain", + "text/plain;charset=utf-8", + "UTF8_STRING", + "TEXT", + "STRING", + }; + for (names) |n| if (std.mem.eql(u8, mime, n)) return true; + return false; +} + /// A single representation of clipboard data. /// /// The MIME type and data are borrowed and only valid for the duration of a @@ -34,3 +51,11 @@ pub const WriteResult = enum(c_int) { io_error = 5, _, }; + +test isTextMime { + const testing = std.testing; + try testing.expect(isTextMime("text/plain")); + try testing.expect(isTextMime("UTF8_STRING")); + try testing.expect(!isTextMime("image/png")); + try testing.expect(!isTextMime(".")); +} From 25b1170d422f06146661eb1531c1b574d20f1771 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 10:43:06 -0700 Subject: [PATCH 02/11] terminal: add kitty clipboard protocol (OSC 5522) command parsing --- src/terminal/kitty/clipboard.zig | 55 ++++ src/terminal/kitty/clipboard_command.zig | 386 +++++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 src/terminal/kitty/clipboard.zig create mode 100644 src/terminal/kitty/clipboard_command.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig new file mode 100644 index 000000000..ddc768c34 --- /dev/null +++ b/src/terminal/kitty/clipboard.zig @@ -0,0 +1,55 @@ +//! Kitty clipboard protocol (OSC 5522). +//! +//! This implements the protocol semantics on top of the raw OSC capture: +//! src/terminal/osc/parsers/kitty_clipboard_protocol.zig: +//! +//! The behavior here is modeled on the kitty reference implementation +//! (kitty/clipboard.py) rather than only the prose spec, since the two +//! disagree in places. Notable reference behaviors we reproduce: +//! +//! * Malformed metadata (any record without '=', including an empty +//! metadata section), an unknown or missing `type`, and invalid +//! base64 in `mime`, `name`, or `pw` all silently drop the request +//! with no response. +//! * `mime`, `name`, and `pw` metadata values are base64-encoded UTF-8; +//! everything else is verbatim. Unknown keys are ignored. +//! * `id` is sanitized by stripping characters outside [a-zA-Z0-9-_+.] +//! and truncating to 512 bytes, then echoed verbatim in every +//! response packet (omitted when empty). +//! * Write data chunks are decoded with a streaming base64 decoder that +//! persists across packets of the same MIME type; a '=' padding +//! mid-stream finishes the current group and resets the decoder, so +//! both per-chunk-padded and continuous unpadded streams work. A +//! chunk with invalid base64 is dropped (decoder reset) and the +//! transaction continues; it is not a protocol error. +//! * A `type=write` silently replaces any in-flight transaction. A +//! commit (`type=wdata` without a MIME type) with no in-flight +//! transaction is silently ignored. +//! * Oversized writes are truncated and still complete with DONE. +//! * Responses never send a payload section for an empty payload, +//! except the targets ('.') listing DATA packet which is always sent. +//! +//! I plan to open an upstream issue asking for clarification on these +//! once I implement this. +//! +//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/ + +const oscpkg = @import("../osc.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); +const command = @import("clipboard_command.zig"); + +pub const OSC = protocol.OSC; +pub const Operation = protocol.Operation; +pub const Status = protocol.Status; +pub const Terminator = oscpkg.Terminator; + +pub const Metadata = command.Metadata; +pub const Payload = command.Payload; +pub const max_id_len = command.max_id_len; +pub const max_pw_len = command.max_pw_len; +pub const max_mime_len = command.max_mime_len; +pub const max_name_len = command.max_name_len; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/kitty/clipboard_command.zig b/src/terminal/kitty/clipboard_command.zig new file mode 100644 index 000000000..5f7115d83 --- /dev/null +++ b/src/terminal/kitty/clipboard_command.zig @@ -0,0 +1,386 @@ +//! Kitty clipboard protocol (OSC 5522) request decoding. +//! +//! OSC 5522 format: `5522;metadata;payload` +//! +//! Per the spec: "metadata is a colon separated list of key-value +//! pairs and payload is base64 encoded data." +//! +//! This contains the logic for parsing this. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const simd = @import("../../simd/main.zig"); +const clipboard = @import("../clipboard.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); + +const Operation = protocol.Operation; + +/// Maximum id length, nothing specified but this is the limit used +/// in Kitty's source so we'll match it. +pub const max_id_len = 512; + +/// Maximum decoded password length. Kitty has no limit. Passwords are +/// UUID-sized in practice so anything longer simply never matches a +/// stored grant anyway. +pub const max_pw_len = 128; + +/// Maximum decoded MIME type length. Kitty has no limit but real MIME +/// 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. +pub const max_name_len = 256; + +/// The decoded, validated metadata of one OSC 5522 sequence. +/// +/// All slice values are allocated from the allocator given to parse and +/// are sized to their contents. Callers are expected to pass an arena +/// scoped to handling the packet. There is no deinit. +pub const Metadata = struct { + op: Operation, + + /// The clipboard this operation targets. Per the spec: "To read + /// from the primary selection instead of the clipboard, add the + /// key `loc=primary` to the metadata section." Any other value + /// means the clipboard, so only standard and primary are possible + /// here. + loc: clipboard.Location = .standard, + + /// Sanitized id: invalid characters stripped, truncated to + /// max_id_len. Empty means no id. + id: []const u8 = "", + + /// Decoded mime metadata value. Empty means absent; kitty treats an + /// empty mime the same as a missing one everywhere it matters (a + /// wdata packet with either commits the transaction). + mime: []const u8 = "", + + /// Decoded password. Empty means absent. Per the spec: + /// "Specifying a password without a human friendly name is + /// equivalent to not specifying a password and the terminal must + /// 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, + + /// Parse the metadata field. The raw value is expected to be exactly + /// the metadata (prefix and payload and separators stripped out). + /// + /// A null result means it was invalid but without any response. + /// Silently drop the OSC. + pub fn parse( + alloc: Allocator, + raw: []const u8, + ) Allocator.Error!?Metadata { + var op_raw: ?[]const u8 = null; + var result: Metadata = .{ .op = undefined }; + + // Note this loop visits every record even though an empty raw + // string yields a single empty record: that record has no '=' + // and correctly drops the packet, matching kitty which requires + // at least a valid `type` record. + var it = std.mem.splitScalar(u8, raw, ':'); + while (it.next()) |record| { + // Every record must be key=value. Any single invalid record + // is dropped, matching Kitty's behavior. + const eql_idx = std.mem.indexOfScalar(u8, record, '=') orelse return null; + const key = record[0..eql_idx]; + const value = record[eql_idx + 1 ..]; + + if (std.mem.eql(u8, key, "type")) { + // Validated after the loop: a duplicate key's last + // occurrence wins. This isn't specified but its how Kitty + // works. + op_raw = value; + } else if (std.mem.eql(u8, key, "loc")) { + result.loc = if (std.mem.eql(u8, value, "primary")) + .primary + else + .standard; + } else if (std.mem.eql(u8, key, "id")) { + result.id = try sanitizeId(alloc, value); + } else if (std.mem.eql(u8, key, "mime")) { + result.mime = decodeValue( + alloc, + value, + max_mime_len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Overflow, error.Invalid => return null, + }; + } else if (std.mem.eql(u8, key, "pw")) { + result.pw = decodeValue( + alloc, + value, + max_pw_len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + // An over-long password behaves as if none was + // given: it can never match a stored grant. + error.Overflow => "", + 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; + }; + } + // Unknown keys are ignored. + } + + // A missing or unknown operation drops the request. + result.op = Operation.init(op_raw orelse return null) orelse return null; + return result; + } + + /// Sanitize the ID according to the spec: + /// + /// Valid ids must include only characters from the set: [a-zA-Z0-9-_+.]. + /// Any other characters must be stripped out from the id by the terminal + /// emulator before retransmitting it. + fn sanitizeId(alloc: Allocator, value: []const u8) Allocator.Error![]const u8 { + var list: std.ArrayListUnmanaged(u8) = .empty; + defer list.deinit(alloc); + for (value) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '-', '_', '+', '.' => {}, + else => continue, + } + if (list.items.len >= max_id_len) break; + try list.append(alloc, c); + } + return try list.toOwnedSlice(alloc); + } + + /// Base64-decode a metadata value. Per the spec these values "are + /// UTF-8 strings that are base64 encoded", so the decoded result + /// must be valid UTF-8. + fn decodeValue(alloc: Allocator, value: []const u8, max_len: usize) error{ + OutOfMemory, + Overflow, + Invalid, + }![]const u8 { + const Encoder = std.base64.standard.Encoder; + + // Avoid hostile large payloads. + if (value.len > Encoder.calcSize(max_len)) return error.Overflow; + + // Decode + const buf = try alloc.alloc(u8, simd.base64.maxLen(value)); + errdefer alloc.free(buf); + const decoded = simd.base64.decode( + value, + buf, + ) catch return error.Invalid; + + // Must be valid UTF-8 + if (!std.unicode.utf8ValidateSlice(decoded)) return error.Invalid; + if (decoded.len > max_len) return error.Overflow; + return decoded; + } +}; + +/// A decoded base64 payload of one OSC 5522 sequence, e.g. the MIME +/// type list of a read request or the alias list of a walias packet. +/// The data slice aliases the allocated buf. +pub const Payload = struct { + buf: []u8, + data: []const u8, + + /// Decode a base64 payload into freshly allocated memory. An + /// invalid payload means the sequence is dropped. + pub fn init( + alloc: Allocator, + payload: []const u8, + ) error{ OutOfMemory, Invalid }!Payload { + const buf = try alloc.alloc(u8, simd.base64.maxLen(payload)); + errdefer alloc.free(buf); + const data = simd.base64.decode( + payload, + buf, + ) catch return error.Invalid; + return .{ .buf = buf, .data = data }; + } + + pub fn deinit(self: *const Payload, alloc: Allocator) void { + alloc.free(self.buf); + } + + /// Iterate the whitespace-separated MIME types of the payload. + /// Matches Python str.split() used by kitty. + pub fn mimeIterator(self: *const Payload) std.mem.TokenIterator(u8, .any) { + return std.mem.tokenizeAny( + u8, + self.data, + &std.ascii.whitespace, + ); + } +}; + +test "metadata: empty is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "")) == null); +} + +test "metadata: record without = is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:bare")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "bare:type=read")) == null); +} + +test "metadata: missing or unknown type is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "loc=primary")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "type=bobr")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "type=")) == null); +} + +test "metadata: duplicate keys keep the last occurrence" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expectEqual( + Operation.read, + (try Metadata.parse(arena.allocator(), "type=bobr:type=read")).?.op, + ); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:type=bobr")) == null); +} + +test "metadata: basic read" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const meta = (try Metadata.parse(arena.allocator(), "type=read")).?; + try testing.expectEqual(Operation.read, meta.op); + try testing.expect(meta.loc == .standard); + try testing.expectEqual(@as(usize, 0), meta.id.len); +} + +test "metadata: unknown keys ignored" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const meta = (try Metadata.parse(arena.allocator(), "type=read:bobr=kurwa")).?; + try testing.expectEqual(Operation.read, meta.op); +} + +test "metadata: loc" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=primary")).?.loc == .primary); + // Anything other than "primary" means the clipboard; it is not an + // error. + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=bobr")).?.loc == .standard); +} + +test "metadata: id sanitized" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + { + const meta = (try Metadata.parse(arena.allocator(), "type=read:id=abc-123_x.Y+z")).?; + try testing.expectEqualStrings("abc-123_x.Y+z", meta.id); + } + { + // Invalid characters are stripped, not rejected. + const meta = (try Metadata.parse(arena.allocator(), "type=read:id=*4 2*")).?; + try testing.expectEqualStrings("42", meta.id); + } +} + +test "metadata: id truncated to max" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const raw = "type=read:id=" ++ "a" ** (max_id_len + 100); + const meta = (try Metadata.parse(arena.allocator(), raw)).?; + try testing.expectEqual(@as(usize, max_id_len), meta.id.len); +} + +test "metadata: mime decoded" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // "text/plain" + const meta = (try Metadata.parse(arena.allocator(), "type=wdata:mime=dGV4dC9wbGFpbg==")).?; + try testing.expectEqualStrings("text/plain", meta.mime); +} + +test "metadata: invalid mime base64 dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=wdata:mime=!!!")) == null); +} + +test "metadata: invalid mime utf8 dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // base64 of 0xff 0xfe + try testing.expect((try Metadata.parse(arena.allocator(), "type=wdata:mime=//4=")) == null); +} + +test "metadata: pw and name" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // 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); +} + +test "metadata: empty name" { + const testing = std.testing; + 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); +} + +test "payload: mime iterator" { + const testing = std.testing; + // base64 of "text/plain text/html\ntext/uri-list" + const payload = try Payload.init( + testing.allocator, + "dGV4dC9wbGFpbiAgdGV4dC9odG1sCnRleHQvdXJpLWxpc3Q=", + ); + defer payload.deinit(testing.allocator); + var it = payload.mimeIterator(); + try testing.expectEqualStrings("text/plain", it.next().?); + try testing.expectEqualStrings("text/html", it.next().?); + try testing.expectEqualStrings("text/uri-list", it.next().?); + try testing.expect(it.next() == null); +} + +test "payload: invalid base64" { + const testing = std.testing; + try testing.expectError( + error.Invalid, + Payload.init(testing.allocator, "!!!"), + ); +} From e28acd928c4c02e8a7d8999e55b4d118098ec423 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 11:34:30 -0700 Subject: [PATCH 03/11] terminal: add kitty clipboard protocol (OSC 5522) response encoding --- src/terminal/kitty/clipboard.zig | 7 + src/terminal/kitty/clipboard_response.zig | 385 ++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 src/terminal/kitty/clipboard_response.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index ddc768c34..7ef850bfe 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -37,6 +37,7 @@ const oscpkg = @import("../osc.zig"); const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); const command = @import("clipboard_command.zig"); +const response = @import("clipboard_response.zig"); pub const OSC = protocol.OSC; pub const Operation = protocol.Operation; @@ -50,6 +51,12 @@ pub const max_pw_len = command.max_pw_len; pub const max_mime_len = command.max_mime_len; pub const max_name_len = command.max_name_len; +pub const Response = response.Response; +pub const ReadSuccess = response.ReadSuccess; +pub const read_chunk_size = response.read_chunk_size; +pub const max_read_mimes = response.max_read_mimes; +pub const targets_mime = response.targets_mime; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/kitty/clipboard_response.zig b/src/terminal/kitty/clipboard_response.zig new file mode 100644 index 000000000..c7f01c165 --- /dev/null +++ b/src/terminal/kitty/clipboard_response.zig @@ -0,0 +1,385 @@ +//! Kitty clipboard protocol (OSC 5522) response encoding. + +const std = @import("std"); +const clipboard = @import("../clipboard.zig"); +const oscpkg = @import("../osc.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); + +const b64 = std.base64.standard.Encoder; +const Content = clipboard.Content; +const Operation = protocol.Operation; +const Status = protocol.Status; +const Terminator = oscpkg.Terminator; + +/// Maximum raw (pre-base64) bytes per DATA packet in read responses. +/// This is specified by the protocol. +pub const read_chunk_size = 4096; + +/// Maximum requested MIME types served by a single read request. +/// Requests beyond this simply see no DATA packets for the extras, +/// which is how the protocol communicates an unavailable type anyway. +pub const max_read_mimes = 4; + +/// The special MIME type that requests the list of available types. +pub const targets_mime = "."; + +/// A single response packet. +pub const Response = struct { + op: Operation, + status: Status, + primary: bool = false, + id: []const u8 = "", + mime: ?[]const u8 = null, + pw: ?[]const u8 = null, + /// The raw payload; the encoder base64-encodes it. An empty payload + /// emits no payload section at all (no ';'). + payload: []const u8 = "", + terminator: Terminator = .st, + + /// Encode the response. Errors may result in partially written data + /// so it is up to callers to buffer it if they need to. + pub fn encode( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try self.encodeMetadata(writer); + if (self.payload.len > 0) { + try writer.writeAll(";"); + try b64.encodeWriter(writer, self.payload); + } + try writer.writeAll(self.terminator.string()); + } + + /// Encode the escape prefix and metadata section only: everything + /// up to (and not including) the payload section and terminator. + fn encodeMetadata( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // The exact order of fields here matches what Kitty does. + try writer.print("\x1b]5522;type={t}:status={t}", .{ self.op, self.status }); + if (self.primary) try writer.writeAll(":loc=primary"); + if (self.id.len > 0) try writer.print(":id={s}", .{self.id}); + if (self.mime) |mime| { + try writer.writeAll(":mime="); + try b64.encodeWriter(writer, mime); + } + if (self.pw) |pw| { + try writer.writeAll(":pw="); + try b64.encodeWriter(writer, pw); + } + } +}; + +/// Encode a full successful read response: the OK packet, the targets +/// listing if requested, DATA chunks for each served representation, +/// and the final DONE packet. This is also the shape of an unsolicited +/// paste event (list=true, pw set to the one-time password). +pub const ReadSuccess = struct { + primary: bool = false, + id: []const u8 = "", + + /// One-time password echoed in every packet. Only used for + /// terminal-initiated paste events. + pw: ?[]const u8 = null, + + /// True when the targets ('.') listing was requested. + list: bool = false, + + /// The MIME types available on the clipboard, reported by the + /// targets listing. Kitty reports these space-separated in one + /// packet with a trailing newline when non-empty. + available: []const []const u8 = &.{}, + + /// The representations to serve, in request order. Each entry's + /// data is chunked into DATA packets under its own MIME type. An + /// entry with empty data produces no packets, which is how the + /// protocol communicates an unavailable type. + contents: []const Content = &.{}, + + terminator: Terminator = .st, + + pub fn encode( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // Initial read response + try (Response{ + .op = .read, + .status = .OK, + .primary = self.primary, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + + // Listing of mimes if requested + if (self.list) try self.encodeListing(writer); + + // Encoding of each mime-type + content + for (self.contents) |content| { + var i: usize = 0; + while (i < content.data.len) { + const n = @min(content.data.len - i, read_chunk_size); + try (Response{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = content.mime, + .pw = self.pw, + .payload = content.data[i..][0..n], + .terminator = self.terminator, + }).encode(writer); + i += n; + } + } + + // Trailing done. + try (Response{ + .op = .read, + .status = .DONE, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + } + + /// Encode the targets ('.') listing packet. The listing gets a + /// trailing newline when non-empty. + fn encodeListing( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const listing: Response = .{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = targets_mime, + .pw = self.pw, + .terminator = self.terminator, + }; + try listing.encodeMetadata(writer); + if (self.available.len > 0) { + try writer.writeAll(";"); + + // Join the types into one chunk before encoding. The + // listing is a DATA packet, so it shares the pre-encoding + // chunk size bound of any other data packet; types that + // don't fit are dropped (a listing that large doesn't + // happen in practice). + var raw: [read_chunk_size]u8 = undefined; + var fixed: std.Io.Writer = .fixed(&raw); + for (self.available, 0..) |mime, i| { + const sep: usize = if (i > 0) 1 else 0; + if (fixed.end + sep + mime.len + "\n".len > raw.len) break; + if (i > 0) fixed.writeAll(" ") catch unreachable; + fixed.writeAll(mime) catch unreachable; + } + fixed.writeAll("\n") catch unreachable; + try b64.encodeWriter(writer, fixed.buffered()); + } + try writer.writeAll(self.terminator.string()); + } +}; + +test "response: basic status packet" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ .op = .write, .status = .DONE }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "response: id echo and terminator" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .write, + .status = .EPERM, + .id = "42", + .terminator = .bel, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=EPERM:id=42\x07", + writer.buffered(), + ); +} + +test "response: key order type,status,loc,id,mime,pw and payload" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .read, + .status = .DATA, + .primary = true, + .id = "x", + .mime = "text/plain", + .pw = "otp", + .payload = "Ghostty", + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=DATA:loc=primary:id=x" ++ + ":mime=dGV4dC9wbGFpbg==:pw=b3Rw;R2hvc3R0eQ==\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty request is OK then DONE" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .id = "7" }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:id=7\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:id=7\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing with text" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true, .available = &.{"text/plain"} }).encode(&writer); + // "." => "Lg==", "text/plain\n" => "dGV4dC9wbGFpbgo=" + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing joins multiple types" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .available = &.{ "text/plain", "image/png" }, + }).encode(&writer); + // Payload is base64 of "text/plain image/png\n". + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty targets listing packet still sent" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: data chunks under requested mime" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "Ghostty" }}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: each representation carries its own data" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{ + .{ .mime = "text/plain", .data = "hello" }, + .{ .mime = "image/png", .data = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" }, + }, + }).encode(&writer); + 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=DATA:mime=aW1hZ2UvcG5n;iVBORw0KGgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: chunking at read_chunk_size" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = "z" ** (read_chunk_size + 1); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = data }}, + }).encode(&aw.writer); + + // OK + 2 DATA packets + DONE = 4 packets. + const count = std.mem.count(u8, aw.written(), "\x1b]5522;"); + try testing.expectEqual(@as(usize, 4), count); + + // The first chunk is exactly read_chunk_size bytes, base64 encoded + // with padding. + const Encoder = std.base64.standard.Encoder; + var chunk_buf: [Encoder.calcSize(read_chunk_size)]u8 = undefined; + const first = Encoder.encode(&chunk_buf, data[0..read_chunk_size]); + try testing.expect(std.mem.indexOf(u8, aw.written(), first) != null); +} + +test "read success: no data packets for empty clipboard" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "" }}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: paste event carries pw in every packet" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .pw = "otp", + .available = &.{"text/plain"}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +} From 33cda4dc5dbfd0478f6891fb4b53844a4fbee17c Mon Sep 17 00:00:00 2001 From: Arnesh Date: Sat, 22 Aug 2026 00:19:58 +0530 Subject: [PATCH 04/11] terminal: reload cell pointers when print grows a page Terminal.print's grapheme path holds a raw pointer to the previous cell while it writes other cells. Writing the wide spacer tail can grow the page to fit the cursor hyperlink, and growing replaces the page, so the pointer is left dangling and the following appendGrapheme writes into freed memory. Record the cursor page identity (node pointer plus serial, since pooled nodes can reuse an address) before the spacer write and reload the cell only when the page actually changed, so the common path costs nothing. The same function had three more pointers held across an operation that can replace a page: the grapheme move after a wrap, the grapheme append loop, and printCell's assert on a failed hyperlink write. Those now read through the cursor or a freshly resolved pin. Fixes #11261 --- src/terminal/Terminal.zig | 143 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 5 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 21c087446..b7176bba2 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1323,14 +1323,21 @@ pub fn print(self: *Terminal, c: u21) !void { const old_rac = old_pin.rowAndCell(); if (new_pin.node == old_pin.node) { - new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell); - prev.cell.content_tag = .codepoint; + new_pin.node.page().moveGrapheme(old_rac.cell, new_rac.cell); + old_rac.cell.content_tag = .codepoint; new_rac.cell.content_tag = .codepoint_grapheme; new_rac.row.grapheme = true; } else { const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?; for (cps) |cp| { - try self.screens.active.appendGrapheme(new_rac.cell, cp); + // appendGrapheme can grow the cursor + // page, so read the destination from + // the cursor each time rather than + // holding a pointer across the call. + try self.screens.active.appendGrapheme( + self.screens.active.cursor.page_cell, + cp, + ); } old_pin.node.page().clearGrapheme(old_rac.cell); } @@ -1340,7 +1347,7 @@ pub fn print(self: *Terminal, c: u21) !void { // Point prev.cell to our new previous cell that // we'll be appending graphemes to - prev.cell = new_rac.cell; + prev.cell = self.screens.active.cursor.page_cell; } else { self.printCell( 0, @@ -1359,8 +1366,30 @@ pub fn print(self: *Terminal, c: u21) !void { // Write our spacer, since prev.cell is now wide self.screens.active.cursorRight(1); + + // Writing the spacer can grow the page to make room for + // the cursor hyperlink. Growing replaces the page, which + // invalidates `prev.cell`. Record the page identity first + // so the common case where nothing grows stays free. + // + // A pointer comparison alone isn't enough: pages are + // pooled, so a replacement can reuse the same address. + // The serial makes the pair a unique identity. + const spacer_node = self.screens.active.cursor.page_pin.node; + const spacer_serial = spacer_node.serial; + self.printCell(0, .spacer_tail); + if (self.screens.active.cursor.page_pin.node != spacer_node or + self.screens.active.cursor.page_pin.node.serial != spacer_serial) + { + @branchHint(.unlikely); + + // The cursor is on the spacer tail we just wrote, so + // the wide cell we append to is the one to its left. + prev.cell = self.screens.active.cursorCellLeft(1); + } + // Move the cursor again so we're beyond our spacer if (self.screens.active.cursor.x == right_limit - 1) { self.screens.active.cursor.pending_wrap = true; @@ -1704,7 +1733,11 @@ fn printCell( self.screens.active.cursorSetHyperlink() catch |err| { @branchHint(.unlikely); log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); - assert(!cell.hyperlink); + + // A partially successful grow can replace the page even when the + // call fails, so `cell` may be stale here. The cursor pointers are + // always reloaded, so read the cell through the cursor. + assert(!self.screens.active.cursor.page_cell.hyperlink); }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. @@ -6132,6 +6165,106 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { } } +test "Terminal: VS16 widening when the spacer tail grows the page" { + // Regression test for a stale cell pointer in print's grapheme `.wide` + // path: writing the spacer tail can grow the page to fit the hyperlink, + // which replaces the page and invalidates the pointer to the wide cell. + var t = try init(testing.io, testing.allocator, .{ .rows = 10, .cols = 20 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + try t.screens.active.startHyperlink("http://example.com", null); + + // Fill the page hyperlink map until a single slot is left. The '#' below + // takes that slot so the spacer tail is what forces the page to grow. + while (true) { + const page = t.screens.active.cursor.page_pin.node.page(); + const map = page.hyperlink_map.map(page.memory); + if (map.maxLoad() - map.count() == 1) break; + try t.print('x'); + } + + const x = t.screens.active.cursor.x; + const y = t.screens.active.cursor.y; + try t.print('#'); + + // Without the fix this crashed appending to a freed page. + try t.print(0xFE0F); + + { + // '#' is wide and carries the VS16 grapheme. + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = x, + .y = y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices( + u21, + &.{0xFE0F}, + list_cell.node.page().lookupGrapheme(cell).?, + ); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = x + 1, + .y = y, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + } +} + +test "Terminal: grapheme transfer when widening wraps to the next line" { + // Covers print's grapheme `.wide` path where the previous cell already + // holds grapheme data and has to be moved to the wrapped row. + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.cursorRight(2); + + // A narrow emoji, then ZWJ, then a second emoji. The ZWJ attaches + // without changing the width, so the cell has grapheme data by the time + // the second emoji widens it. + try t.print(0x263A); + try t.print(0x200D); + try t.print(0x2764); + + { + // The old cell becomes a spacer head on the wrapped row. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide); + try testing.expect(list_cell.row.wrap); + } + { + // The grapheme moved with the base codepoint. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 0, + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x263A), cell.content.codepoint.data); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqualSlices( + u21, + &.{ 0x200D, 0x2764 }, + list_cell.node.page().lookupGrapheme(cell).?, + ); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + } +} + test "Terminal: VS16 to make wide character with pending wrap" { var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); From 7a940ec02830fcd39f94ea9a28974c2a82d96486 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:09:53 -0700 Subject: [PATCH 05/11] terminal: add kitty clipboard protocol (OSC 5522) write transactions --- src/terminal/kitty/clipboard.zig | 13 +- src/terminal/kitty/clipboard_write.zig | 443 +++++++++++++++++++++++++ 2 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 src/terminal/kitty/clipboard_write.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 7ef850bfe..4c9472e13 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -16,11 +16,7 @@ //! * `id` is sanitized by stripping characters outside [a-zA-Z0-9-_+.] //! and truncating to 512 bytes, then echoed verbatim in every //! response packet (omitted when empty). -//! * Write data chunks are decoded with a streaming base64 decoder that -//! persists across packets of the same MIME type; a '=' padding -//! mid-stream finishes the current group and resets the decoder, so -//! both per-chunk-padded and continuous unpadded streams work. A -//! chunk with invalid base64 is dropped (decoder reset) and the +//! * A write data chunk with invalid base64 is dropped and the //! transaction continues; it is not a protocol error. //! * A `type=write` silently replaces any in-flight transaction. A //! commit (`type=wdata` without a MIME type) with no in-flight @@ -37,6 +33,7 @@ const oscpkg = @import("../osc.zig"); const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); const command = @import("clipboard_command.zig"); +const write = @import("clipboard_write.zig"); const response = @import("clipboard_response.zig"); pub const OSC = protocol.OSC; @@ -51,6 +48,12 @@ pub const max_pw_len = command.max_pw_len; pub const max_mime_len = command.max_mime_len; pub const max_name_len = command.max_name_len; +pub const Content = write.Content; +pub const WriteState = write.WriteState; +pub const max_write_size = write.max_write_size; +pub const max_write_mimes = write.max_write_mimes; +pub const max_write_aliases = write.max_write_aliases; + pub const Response = response.Response; pub const ReadSuccess = response.ReadSuccess; pub const read_chunk_size = response.read_chunk_size; diff --git a/src/terminal/kitty/clipboard_write.zig b/src/terminal/kitty/clipboard_write.zig new file mode 100644 index 000000000..c4e33a4f3 --- /dev/null +++ b/src/terminal/kitty/clipboard_write.zig @@ -0,0 +1,443 @@ +//! Kitty clipboard protocol (OSC 5522) write transactions: the +//! stateful accumulation of wdata chunks and walias aliases until the +//! commit packet arrives. See clipboard.zig for the protocol overview. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const clipboard = @import("../clipboard.zig"); +const clipboard_command = @import("clipboard_command.zig"); + +const Metadata = clipboard_command.Metadata; +const Payload = clipboard_command.Payload; +const max_mime_len = clipboard_command.max_mime_len; + +const log = std.log.scoped(.kitty_clipboard); + +/// Maximum total decoded bytes accumulated by one write transaction. +/// We hardcode this for now but probably will make this configurable +/// later. +pub const max_write_size = 32 * 1024 * 1024; + +/// Maximum MIME types and aliases per write transaction. +pub const max_write_mimes = 64; +pub const max_write_aliases = 64; + +/// One MIME representation of committed clipboard data. This is the +/// same type the clipboard write effect consumes so committed contents +/// can be passed through directly. +pub const Content = clipboard.Content; + +/// The state of one in-flight write transaction: a single `type=write` +/// plus all the `wdata` chunks and `walias` aliases until completion +/// or error. +pub const WriteState = struct { + arena: std.heap.ArenaAllocator, + loc: clipboard.Location, + id: []const u8, + pw: []const u8, + has_name: bool, + spool: std.ArrayListUnmanaged(u8) = .empty, + entries: std.ArrayListUnmanaged(Entry) = .empty, + aliases: std.ArrayListUnmanaged(Alias) = .empty, + + /// Index into entries currently receiving data. + current: ?usize = null, + + /// Set when max_write_size was exceeded; excess data is dropped + /// but the write still completes. + truncated: bool = false, + + const Entry = struct { + /// Owned by the transaction arena. + mime: []const u8, + start: usize = 0, + len: usize = 0, + }; + + const Alias = struct { + /// Owned by the transaction arena. + alias: []const u8, + target: []const u8, + }; + + /// Begin a transaction from a type=write packet. + pub fn init(alloc: Allocator, meta: *const Metadata) Allocator.Error!WriteState { + assert(meta.op == .write); + var arena: std.heap.ArenaAllocator = .init(alloc); + errdefer arena.deinit(); + const id = try arena.allocator().dupe(u8, meta.id); + const pw = try arena.allocator().dupe(u8, meta.pw); + return .{ + .arena = arena, + .loc = meta.loc, + .id = id, + .pw = pw, + .has_name = meta.has_name, + }; + } + + pub fn deinit(self: *WriteState, alloc: Allocator) void { + self.spool.deinit(alloc); + self.entries.deinit(alloc); + self.aliases.deinit(alloc); + self.arena.deinit(); + } + + /// Accumulate one wdata chunk carrying data for meta.mime (which + /// must be non-empty; an empty mime is a commit, not data). + pub fn data( + self: *WriteState, + alloc: Allocator, + meta: *const Metadata, + payload: []const u8, + ) error{OutOfMemory}!void { + assert(meta.op == .wdata); + assert(meta.mime.len > 0); + // Switch the receiving entry if this chunk is for a different + // MIME type than the last one. + entry: { + if (self.current) |idx| { + const entry = &self.entries.items[idx]; + if (std.mem.eql(u8, entry.mime, meta.mime)) { + break :entry; + } + + // Finalize the previous region. + entry.len = self.spool.items.len - entry.start; + } + + // Re-using an earlier MIME type starts a fresh region, + // overwriting the previous mapping. + for (self.entries.items, 0..) |*entry, idx| { + if (std.mem.eql(u8, entry.mime, meta.mime)) { + entry.start = self.spool.items.len; + entry.len = 0; + self.current = idx; + break :entry; + } + } + + if (self.entries.items.len >= max_write_mimes) { + log.warn( + "clipboard write has too many MIME types, ignoring mime={s}", + .{meta.mime}, + ); + self.current = null; + return; + } + + try self.entries.append(alloc, .{ + .mime = try self.arena.allocator().dupe(u8, meta.mime), + .start = self.spool.items.len, + }); + self.current = self.entries.items.len - 1; + } + + // Each packet's payload is independently base64-encoded: per + // the spec, "payload is base64 encoded data" and clients chunk + // the data before encoding. An invalid chunk is dropped and + // the transaction continues. + const decoded = Payload.init( + alloc, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Invalid => { + log.warn("clipboard write chunk has invalid base64, ignoring chunk", .{}); + return; + }, + }; + defer decoded.deinit(alloc); + + const remaining = max_write_size -| self.spool.items.len; + const n = @min(decoded.data.len, remaining); + try self.spool.appendSlice(alloc, decoded.data[0..n]); + if (n < decoded.data.len) { + self.truncated = true; + logTruncatedOnce(); + } + } + + /// Register aliases from a walias packet: meta.mime is the target + /// (the type that carries data) and the payload is a base64-encoded, + /// whitespace-separated list of aliases. Returns error.Invalid for + /// an undecodable payload, which aborts the transaction with EINVAL. + pub fn alias( + self: *WriteState, + alloc: Allocator, + meta: *const Metadata, + payload: []const u8, + ) error{ OutOfMemory, Invalid }!void { + assert(meta.op == .walias); + assert(meta.mime.len > 0); + const decoded = try Payload.init(alloc, payload); + defer decoded.deinit(alloc); + var it = decoded.mimeIterator(); + + // Copy the target only if at least one valid alias exists. + const target: []const u8 = target: { + while (it.next()) |name| { + if (name.len > max_mime_len) continue; + break :target try self.arena.allocator().dupe(u8, meta.mime); + } + + // If we didn't find a target then ignore it. + return; + }; + + // Rewind so the alias that satisfied the check above is + // associated too. + it.reset(); + + // Associate the aliases + while (it.next()) |name| { + if (name.len > max_mime_len) continue; + + // A repeated alias overwrites its previous target. + for (self.aliases.items) |*a| { + if (std.mem.eql(u8, a.alias, name)) { + a.target = target; + break; + } + } else { + if (self.aliases.items.len >= max_write_aliases) { + log.warn("clipboard write has too many aliases, ignoring", .{}); + return; + } + + try self.aliases.append(alloc, .{ + .alias = try self.arena.allocator().dupe(u8, name), + .target = target, + }); + } + } + } + + /// The result of a committed transaction. All slices borrow the + /// WriteState's memory and are valid until it is deinited. + pub const Committed = struct { + loc: clipboard.Location, + id: []const u8, + pw: []const u8, + has_name: bool, + truncated: bool, + contents: []const Content, + + pub fn deinit(self: *const Committed, alloc: Allocator) void { + alloc.free(self.contents); + } + }; + + /// Commit the transaction (a wdata packet without a MIME type). + /// The caller must use the result, call Committed.deinit, and then + /// deinit this state. + pub fn commit( + self: *WriteState, + alloc: Allocator, + ) error{OutOfMemory}!Committed { + // Finalize the region receiving data. + if (self.current) |idx| { + const entry = &self.entries.items[idx]; + entry.len = self.spool.items.len - entry.start; + self.current = null; + } + + // Resolve the final MIME map: entries in arrival order, then + // aliases applied sequentially against the evolving map so + // chained aliases work like kitty's dict iteration. An alias + // whose target has no mapping is dropped; an alias colliding + // with an existing name overwrites it. + var contents: std.ArrayListUnmanaged(Content) = .empty; + defer contents.deinit(alloc); + try contents.ensureTotalCapacity( + alloc, + self.entries.items.len + self.aliases.items.len, + ); + + for (self.entries.items) |*entry| { + contents.appendAssumeCapacity(.{ + .mime = entry.mime, + .data = self.spool.items[entry.start..][0..entry.len], + }); + } + + for (self.aliases.items) |*a| { + const target: Content = target: { + for (contents.items) |c| { + if (std.mem.eql(u8, c.mime, a.target)) { + break :target c; + } + } + continue; + }; + + for (contents.items) |*c| { + if (std.mem.eql(u8, c.mime, a.alias)) { + c.data = target.data; + break; + } + } else { + contents.appendAssumeCapacity(.{ + .mime = a.alias, + .data = target.data, + }); + } + } + + return .{ + .loc = self.loc, + .id = self.id, + .pw = self.pw, + .has_name = self.has_name, + .truncated = self.truncated, + .contents = try contents.toOwnedSlice(alloc), + }; + } + + fn logTruncatedOnce() void { + // Log spam protection: this can be hit for every chunk of an + // oversized write. + const S = struct { + var logged: bool = false; + }; + if (!S.logged) { + S.logged = true; + log.warn( + "clipboard write exceeds {} bytes, truncating", + .{max_write_size}, + ); + } + } +}; + +test "write: basic transaction" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write, .id = "42" }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("42", committed.id); + try testing.expect(committed.loc == .standard); + try testing.expectEqual(@as(usize, 1), committed.contents.len); + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[0].data); +} + +test "write: chunked data accumulates" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "V29ybGQ="); // "World" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("HelloWorld", committed.contents[0].data); +} + +test "write: multiple mimes in order" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 2), committed.contents.len); + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("a", committed.contents[0].data); + try testing.expectEqualStrings("text/html", committed.contents[1].mime); + try testing.expectEqualStrings("b", committed.contents[1].data); +} + +test "write: reused mime overwrites" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "Yw=="); // "c" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 2), committed.contents.len); + // Position preserved, data replaced. + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("c", committed.contents[0].data); +} + +test "write: invalid base64 chunk is dropped, transaction continues" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "!!!bad!!!"); + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "V29ybGQ="); // "World" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("HelloWorld", committed.contents[0].data); +} + +test "write: aliases resolve at commit" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty" + + // Alias "TEXT UTF8_STRING" -> text/plain. + const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" }; + try state.alias(alloc, &alias_meta, "VEVYVCBVVEY4X1NUUklORw=="); + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 3), committed.contents.len); + try testing.expectEqualStrings("TEXT", committed.contents[1].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[1].data); + try testing.expectEqualStrings("UTF8_STRING", committed.contents[2].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[2].data); +} + +test "write: alias without data target is dropped" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta); + defer state.deinit(alloc); + + const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" }; + try state.alias(alloc, &alias_meta, "VEVYVA=="); // "TEXT" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 0), committed.contents.len); +} From 6f007e7678a4d893a5c73df2f0305b1e00609b5a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:13:56 -0700 Subject: [PATCH 06/11] terminal: add kitty clipboard protocol (OSC 5522) session password grants --- src/terminal/kitty/clipboard.zig | 5 + src/terminal/kitty/clipboard_grants.zig | 184 ++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/terminal/kitty/clipboard_grants.zig diff --git a/src/terminal/kitty/clipboard.zig b/src/terminal/kitty/clipboard.zig index 4c9472e13..18271c704 100644 --- a/src/terminal/kitty/clipboard.zig +++ b/src/terminal/kitty/clipboard.zig @@ -35,6 +35,7 @@ const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); const command = @import("clipboard_command.zig"); const write = @import("clipboard_write.zig"); const response = @import("clipboard_response.zig"); +const grants = @import("clipboard_grants.zig"); pub const OSC = protocol.OSC; pub const Operation = protocol.Operation; @@ -60,6 +61,10 @@ pub const read_chunk_size = response.read_chunk_size; pub const max_read_mimes = response.max_read_mimes; pub const targets_mime = response.targets_mime; +pub const Grants = grants.Grants; +pub const otp_len = grants.otp_len; +pub const generateOtp = grants.generateOtp; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/kitty/clipboard_grants.zig b/src/terminal/kitty/clipboard_grants.zig new file mode 100644 index 000000000..599e27e98 --- /dev/null +++ b/src/terminal/kitty/clipboard_grants.zig @@ -0,0 +1,184 @@ +//! Kitty clipboard protocol (OSC 5522) session password grants: +//! requests carrying a granted password skip the permission prompt, and +//! paste events mint one-time passwords. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const clipboard_command = @import("clipboard_command.zig"); + +const max_pw_len = clipboard_command.max_pw_len; + +/// Session password grants, used to skip permission prompts for +/// requests carrying a known pw. Callers can choose to scope these +/// however they want, e.g. Kitty does it per window and the spec +/// doesn't demand anything. +pub const Grants = struct { + entries: std.ArrayListUnmanaged(Entry) = .empty, + + const max_entries = 32; + + pub const Direction = enum { read, write }; + + const Entry = struct { + /// Owned by the allocator passed to grant. + pw: []const u8, + read: bool = false, + write: bool = false, + one_time: bool = false, + }; + + pub fn deinit(self: *Grants, alloc: Allocator) void { + for (self.entries.items) |entry| alloc.free(entry.pw); + self.entries.deinit(alloc); + } + + /// Record a grant for pw. An existing grant for the same password + /// gains the new direction. + pub fn grant( + self: *Grants, + alloc: Allocator, + pw: []const u8, + dir: Direction, + one_time: bool, + ) Allocator.Error!void { + if (pw.len == 0 or pw.len > max_pw_len) return; + + const entry: *Entry = entry: { + if (self.findIndex(pw)) |idx| { + const entry = &self.entries.items[idx]; + entry.one_time = entry.one_time and one_time; + break :entry entry; + } + + // Evict the oldest grant once full. + if (self.entries.items.len >= max_entries) { + const oldest = self.entries.orderedRemove(0); + alloc.free(oldest.pw); + } + + const owned = try alloc.dupe(u8, pw); + errdefer alloc.free(owned); + try self.entries.append(alloc, .{ + .pw = owned, + .one_time = one_time, + }); + break :entry &self.entries.items[self.entries.items.len - 1]; + }; + + switch (dir) { + .read => entry.read = true, + .write => entry.write = true, + } + } + + /// Check whether pw grants the given direction. A one-time grant is + /// consumed by this check even when the direction doesn't match, + /// matching kitty's pop-on-check behavior. + pub fn use( + self: *Grants, + alloc: Allocator, + pw: []const u8, + dir: Direction, + ) bool { + if (pw.len == 0) return false; + const idx = self.findIndex(pw) orelse return false; + const entry = &self.entries.items[idx]; + const allowed = switch (dir) { + .read => entry.read, + .write => entry.write, + }; + if (entry.one_time) { + const removed = self.entries.swapRemove(idx); + alloc.free(removed.pw); + } + return allowed; + } + + fn findIndex(self: *const Grants, pw: []const u8) ?usize { + for (self.entries.items, 0..) |*entry, idx| { + if (std.mem.eql(u8, entry.pw, pw)) return idx; + } + return null; + } +}; + +/// The length of a one-time password generated for paste events. +pub const otp_len = 22; + +/// Generate a one-time password for a paste event. The alphabet matches +/// kitty (alphanumeric without easily-confused characters), but the spec +/// doesn't demand this. +pub fn generateOtp(random: std.Random) [otp_len]u8 { + const alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; + var result: [otp_len]u8 = undefined; + for (&result) |*c| c.* = alphabet[random.uintLessThan(usize, alphabet.len)]; + return result; +} + +test "grants: basic grant and use" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try testing.expect(!grants.use(alloc, "pw1", .read)); + + try grants.grant(alloc, "pw1", .read, false); + try testing.expect(grants.use(alloc, "pw1", .read)); + // Persistent grants survive use. + try testing.expect(grants.use(alloc, "pw1", .read)); + try testing.expect(!grants.use(alloc, "pw1", .write)); +} + +test "grants: one-time consumed on check" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "otp", .read, true); + try testing.expect(grants.use(alloc, "otp", .read)); + try testing.expect(!grants.use(alloc, "otp", .read)); +} + +test "grants: one-time consumed even on direction mismatch" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "otp", .read, true); + try testing.expect(!grants.use(alloc, "otp", .write)); + try testing.expect(!grants.use(alloc, "otp", .read)); +} + +test "grants: directions are independent" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "pw", .read, false); + try grants.grant(alloc, "pw", .write, false); + try testing.expect(grants.use(alloc, "pw", .read)); + try testing.expect(grants.use(alloc, "pw", .write)); +} + +test "grants: capacity evicts the oldest" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + + var buf: [8]u8 = undefined; + for (0..Grants.max_entries + 1) |i| { + const pw = try std.fmt.bufPrint(&buf, "pw{}", .{i}); + try grants.grant(alloc, pw, .read, false); + } + + // The oldest grant was evicted; the newest survives. + try testing.expect(!grants.use(alloc, "pw0", .read)); + const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries}); + try testing.expect(grants.use(alloc, newest, .read)); +} From f2d4b32be3eb8c17f1ca943dda82a6506e7260b3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:15:07 -0700 Subject: [PATCH 07/11] terminal: expose kitty clipboard protocol (OSC 5522) for stream dispatch --- src/terminal/kitty.zig | 1 + src/terminal/osc/parsers/kitty_clipboard_protocol.zig | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/terminal/kitty.zig b/src/terminal/kitty.zig index 0868a7710..471fb91e7 100644 --- a/src/terminal/kitty.zig +++ b/src/terminal/kitty.zig @@ -3,6 +3,7 @@ const build_options = @import("terminal_options"); const key = @import("kitty/key.zig"); +pub const clipboard = @import("kitty/clipboard.zig"); pub const color = @import("kitty/color.zig"); pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {}; diff --git a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig index 5bd0e2547..c257edd10 100644 --- a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig +++ b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig @@ -22,6 +22,12 @@ pub const OSC = struct { /// The terminator that was used in case we need to send a response. terminator: Terminator, + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; + } + /// Decode an option from the metadata. pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { return key.read(self.metadata); From 07c6fc21ba79f2d3d320f3300029269ebf84030b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:16:38 -0700 Subject: [PATCH 08/11] terminal: add kitty clipboard paste events mode (5522), disabled for now --- include/ghostty/vt/modes.h | 1 + src/terminal/modes.zig | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h index fbef4e52b..6786f004b 100644 --- a/include/ghostty/vt/modes.h +++ b/include/ghostty/vt/modes.h @@ -94,6 +94,7 @@ extern "C" { #define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */ #define GHOSTTY_MODE_VISIBILITY_REPORT (ghostty_mode_new(2033, false)) /**< Report terminal visibility */ #define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */ +#define GHOSTTY_MODE_PASTE_EVENTS (ghostty_mode_new(5522, false)) /**< Kitty clipboard protocol paste events */ /** @} */ /** diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index c545b3304..9b95e85ce 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -327,6 +327,15 @@ const entries: []const ModeEntry = &.{ .{ .name = "report_color_scheme", .value = 2031 }, .{ .name = "report_visibility", .value = 2033, .default_configurable = false }, .{ .name = "in_band_size_reports", .value = 2048 }, + // Kitty clipboard protocol paste events. When set, a user-initiated + // paste sends an unsolicited OSC 5522 targets listing with a + // one-time password instead of pasting the text. + // See https://sw.kovidgoyal.net/kitty/clipboard/ + // + // Disabled until the apprt paste integration lands; until then the + // mode reports as unrecognized so applications correctly detect + // paste events as unsupported. + .{ .name = "paste_events", .value = 5522, .disabled = true }, }; test { From bcf44b40e69ffc4f32216016bb8e6ee68560cae2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:19:14 -0700 Subject: [PATCH 09/11] terminal: dispatch OSC 5522 as a kitty_clipboard stream action, unhandled --- src/terminal/stream.zig | 9 ++++++++- src/terminal/stream_terminal.zig | 2 ++ src/termio/stream_handler.zig | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 105cd1e4f..4308adf9f 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -128,6 +128,7 @@ pub const Action = union(Key) { kitty_color_report: kitty.color.OSC, color_operation: ColorOperation, semantic_prompt: SemanticPrompt, + kitty_clipboard: KittyClipboard, pub const Key = lib.Enum( lib.target, @@ -227,6 +228,7 @@ pub const Action = union(Key) { "kitty_color_report", "color_operation", "semantic_prompt", + "kitty_clipboard", }, ); @@ -444,6 +446,8 @@ pub const Action = union(Key) { }; pub const SemanticPrompt = osc.Command.SemanticPrompt; + + pub const KittyClipboard = osc.Command.KittyClipboardProtocol; }; /// Returns a type that can process a stream of tty control characters. @@ -2551,6 +2555,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.progress_report, v); }, + .kitty_clipboard_protocol => |v| { + self.handler.vt(.kitty_clipboard, v); + }, + .conemu_sleep, .conemu_show_message_box, .conemu_change_tab_title, @@ -2561,7 +2569,6 @@ pub fn Stream(comptime H: type) type { .conemu_output_environment_variable, .conemu_run_process, .kitty_text_sizing, - .kitty_clipboard_protocol, .kitty_dnd_protocol, .context_signal, => { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 1b1ad6c65..d19324886 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -398,6 +398,8 @@ pub const Handler = struct { // Have no terminal-modifying effect .title_push, .title_pop, + // Unimplemented; the sequence is consumed and ignored. + .kitty_clipboard, => {}, } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index cf6bca909..192e51644 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -356,6 +356,7 @@ pub const StreamHandler = struct { // Unimplemented .title_push, .title_pop, + .kitty_clipboard, => {}, } } From 128ec7cd047ab0d9ceb4f0b5c2c449984702266d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:26:56 -0700 Subject: [PATCH 10/11] terminal: rename paste_events mode to kitty_paste_events --- src/terminal/modes.zig | 7 +++---- src/terminal/snapshot/snapshot.ksy | 6 ++++-- src/terminal/snapshot/terminal.zig | 30 ++++++++++++++++++++---------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 9b95e85ce..baacd0089 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -332,10 +332,9 @@ const entries: []const ModeEntry = &.{ // one-time password instead of pasting the text. // See https://sw.kovidgoyal.net/kitty/clipboard/ // - // Disabled until the apprt paste integration lands; until then the - // mode reports as unrecognized so applications correctly detect - // paste events as unsupported. - .{ .name = "paste_events", .value = 5522, .disabled = true }, + // Forcibly disabled for now since the functionality isn't exposed + // yet to libghostty or any GUI apps. + .{ .name = "kitty_paste_events", .value = 5522, .disabled = true }, }; test { diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy index 80a1d182f..2da917ea3 100644 --- a/src/terminal/snapshot/snapshot.ksy +++ b/src/terminal/snapshot/snapshot.ksy @@ -390,13 +390,13 @@ types: Each named instance exposes one bit from the little-endian integer. Arithmetic division is used instead of bitwise operations because the JavaScript target implements those operations with signed 32-bit values. - All values remain exact because the registry occupies only 42 bits, + All values remain exact because the registry occupies only 43 bits, within JavaScript's 53-bit safe integer range. seq: - id: raw type: u8 valid: - max: 4398046511103 + max: 8796093022207 instances: disable_keyboard: value: (raw / 1) % 2 != 0 @@ -482,6 +482,8 @@ types: value: (raw / 1099511627776) % 2 != 0 in_band_size_reports: value: (raw / 2199023255552) % 2 != 0 + kitty_paste_events: + value: (raw / 4398046511104) % 2 != 0 tab_stops: params: diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index f20b97707..11c9004bb 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -206,7 +206,8 @@ //! bit 39 report_color_scheme //! bit 40 report_visibility //! bit 41 in_band_size_reports -//! bits 42-63 reserved, zero +//! bit 42 kitty_paste_events +//! bits 43-63 reserved, zero //! ``` //! //! This is the packed field order of native `ModePacked`. Its layout is @@ -470,7 +471,7 @@ pub const Header = struct { try writer.writeByte(@intCast(@intFromEnum(self.mouse_shape))); try writer.writeByte(@intFromBool(self.password_input)); - // Runtime, saved, and reset mode sets. ModePacked occupies 41 bits; + // Runtime, saved, and reset mode sets. ModePacked occupies 43 bits; // its eight-byte wire slots zero-extend the native packed value. const mode_values = [_]terminal_modes.ModePacked{ self.current_modes, @@ -1203,7 +1204,7 @@ const test_header_fixture = test_fixture.parse( test "TERMINAL mode bit layout" { try std.testing.expectEqual( - @as(usize, 42), + @as(usize, 43), @bitSizeOf(terminal_modes.ModePacked), ); @@ -1212,8 +1213,8 @@ test "TERMINAL mode bit layout" { ); first.disable_keyboard = true; try std.testing.expectEqual( - @as(u42, 1) << 0, - @as(u42, @bitCast(first)), + @as(u43, 1) << 0, + @as(u43, @bitCast(first)), ); var visibility: terminal_modes.ModePacked = std.mem.zeroes( @@ -1221,17 +1222,26 @@ test "TERMINAL mode bit layout" { ); visibility.report_visibility = true; try std.testing.expectEqual( - @as(u42, 1) << 40, - @as(u42, @bitCast(visibility)), + @as(u43, 1) << 40, + @as(u43, @bitCast(visibility)), + ); + + var size_reports: terminal_modes.ModePacked = std.mem.zeroes( + terminal_modes.ModePacked, + ); + size_reports.in_band_size_reports = true; + try std.testing.expectEqual( + @as(u43, 1) << 41, + @as(u43, @bitCast(size_reports)), ); var last: terminal_modes.ModePacked = std.mem.zeroes( terminal_modes.ModePacked, ); - last.in_band_size_reports = true; + last.kitty_paste_events = true; try std.testing.expectEqual( - @as(u42, 1) << 41, - @as(u42, @bitCast(last)), + @as(u43, 1) << 42, + @as(u43, @bitCast(last)), ); } From a8c3ab1915c9dc9cecf4ae93b5337d65f1bfffbf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 12:45:35 -0700 Subject: [PATCH 11/11] simd: fix scalar base64 empty input handling causing a crash --- src/simd/base64.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/simd/base64.zig b/src/simd/base64.zig index 81feeb723..a0ba45ea9 100644 --- a/src/simd/base64.zig +++ b/src/simd/base64.zig @@ -53,9 +53,9 @@ fn decodeScalar( /// For non-SIMD enabled builds, we trim the padding from the end of the /// base64 input in order to get identical output with the SIMD version. fn scalarInput(input: []const u8) []const u8 { - var i: usize = 0; - while (input[input.len - i - 1] == '=') i += 1; - return input[0 .. input.len - i]; + var end = input.len; + while (end > 0 and input[end - 1] == '=') end -= 1; + return input[0..end]; } // base64.cpp @@ -75,6 +75,14 @@ test "base64 maxLen" { try testing.expectEqual(11, len); } +test "base64 empty input" { + const testing = std.testing; + var output: [0]u8 = .{}; + + try testing.expectEqual(0, maxLen("")); + try testing.expectEqualStrings("", try decode("", &output)); +} + test "base64 decode" { const testing = std.testing; const alloc = testing.allocator;