aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/stream_terminal.zig
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/stream_terminal.zig
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/stream_terminal.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/stream_terminal.zig509
1 files changed, 493 insertions, 16 deletions
diff --git a/vendor/libghostty-vt/src/terminal/stream_terminal.zig b/vendor/libghostty-vt/src/terminal/stream_terminal.zig
index f68f088b..91cfb21c 100644
--- a/vendor/libghostty-vt/src/terminal/stream_terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/stream_terminal.zig
@@ -2,13 +2,16 @@ const std = @import("std");
const build_options = @import("terminal_options");
const testing = std.testing;
const apc = @import("apc.zig");
+const clipboard = @import("clipboard.zig");
const csi = @import("csi.zig");
const device_attributes = @import("device_attributes.zig");
const device_status = @import("device_status.zig");
const stream = @import("stream.zig");
const Action = stream.Action;
const Screen = @import("Screen.zig");
+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_color = @import("kitty/color.zig");
const size_report = @import("size_report.zig");
@@ -43,6 +46,11 @@ pub const Handler = struct {
/// the kitty graphics protocol.
apc_handler: apc.Handler = .{},
+ /// Default cursor style used by DECSCUSR reset (CSI 0 q).
+ default_cursor: bool = true,
+ default_cursor_style: Screen.CursorStyle = .block,
+ default_cursor_blink: bool = false,
+
pub const Effects = struct {
/// Called when the terminal needs to write data back to the pty,
/// e.g. in response to a DECRQM query. The data is only valid
@@ -78,6 +86,25 @@ pub const Handler = struct {
/// handler.terminal.getTitle().
title_changed: ?*const fn (*Handler) void,
+ /// Called when the terminal pwd changes via escape sequences
+ /// (e.g. OSC 7). The new pwd can be queried via
+ /// handler.terminal.getPwd().
+ pwd_changed: ?*const fn (*Handler) void,
+
+ /// Called when the running program writes to a clipboard. The write
+ /// has a normalized destination and one or more decoded MIME
+ /// representations. All request, MIME, and data memory is borrowed
+ /// and only valid for the duration of the callback.
+ ///
+ /// 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 never
+ /// forwarded: answering one would let any program running in the
+ /// terminal silently read the user's clipboard, and a VT state
+ /// library has no way to mediate that with user consent.
+ clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
+
/// Called in response to an XTVERSION query. Returns the version
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
/// must be valid for the lifetime of the call. The maximum length
@@ -89,11 +116,13 @@ pub const Handler = struct {
/// effects beyond that.
pub const readonly: Effects = .{
.bell = null,
+ .clipboard_write = null,
.color_scheme = null,
.device_attributes = null,
.enquiry = null,
.size = null,
.title_changed = null,
+ .pwd_changed = null,
.write_pty = null,
.xtversion = null,
};
@@ -126,6 +155,7 @@ pub const Handler = struct {
) !void {
switch (action) {
.print => try self.terminal.print(value.cp),
+ .print_slice => try self.terminal.printSlice(value.cps),
.print_repeat => try self.terminal.printRepeat(value),
.backspace => self.terminal.backspace(),
.carriage_return => self.terminal.carriageReturn(),
@@ -152,12 +182,19 @@ pub const Handler = struct {
self.terminal.screens.active.cursor.x + 1,
),
.cursor_style => {
+ self.default_cursor = false;
+
const blink = switch (value) {
- .default, .steady_block, .steady_bar, .steady_underline => false,
+ .default => self.default_cursor_blink,
+ .steady_block, .steady_bar, .steady_underline => false,
.blinking_block, .blinking_bar, .blinking_underline => true,
};
const style: Screen.CursorStyle = switch (value) {
- .default, .blinking_block, .steady_block => .block,
+ .default => style: {
+ self.default_cursor = true;
+ break :style self.default_cursor_style;
+ },
+ .blinking_block, .steady_block => .block,
.blinking_bar, .steady_bar => .bar,
.blinking_underline, .steady_underline => .underline,
};
@@ -228,17 +265,23 @@ pub const Handler = struct {
},
.active_status_display => self.terminal.status_display = value,
.decaln => try self.terminal.decaln(),
- .full_reset => self.terminal.fullReset(),
+ .full_reset => {
+ self.terminal.fullReset();
+ self.default_cursor = true;
+ self.terminal.modes.set(.cursor_blinking, self.default_cursor_blink);
+ self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style;
+ },
.start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id),
.end_hyperlink => self.terminal.screens.active.endHyperlink(),
.semantic_prompt => try self.terminal.semanticPrompt(value),
.mouse_shape => self.terminal.mouse_shape = value,
- .color_operation => try self.colorOperation(value.op, &value.requests),
+ .color_operation => try self.colorOperation(&value.requests, value.terminator),
.kitty_color_report => try self.kittyColorOperation(value),
// APC
.apc_start => self.apc_handler.start(),
.apc_put => self.apc_handler.feed(self.terminal.gpa(), value),
+ .apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes),
.apc_end => self.apcEnd(),
// Effect-based handlers
@@ -251,7 +294,9 @@ pub const Handler = struct {
.request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi),
.size_report => self.reportSize(value),
.window_title => self.windowTitle(value.title),
+ .report_pwd => self.reportPwd(value.url),
.xtversion => self.reportXtversion(),
+ .clipboard_contents => try self.clipboardContents(value.kind, value.data),
// No supported DCS commands have any terminal-modifying effects,
// but they may in the future. For now we just ignore it.
@@ -261,10 +306,8 @@ pub const Handler = struct {
=> {},
// Have no terminal-modifying effect
- .report_pwd,
.show_desktop_notification,
.progress_report,
- .clipboard_contents,
.title_push,
.title_pop,
=> {},
@@ -281,6 +324,44 @@ pub const Handler = struct {
func(self);
}
+ fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void {
+ const func = self.effects.clipboard_write orelse return;
+
+ // Read requests are deliberately not forwarded; see the effect docs.
+ if (data.len == 1 and data[0] == '?') return;
+
+ const location: clipboard.Location = switch (kind) {
+ 's' => .selection,
+ 'p' => .primary,
+ else => .standard,
+ };
+
+ // OSC 52 uses an empty payload to clear the selected clipboard.
+ if (data.len == 0) {
+ _ = func(self, .{
+ .location = location,
+ .contents = &.{},
+ });
+ return;
+ }
+
+ const decoder = std.base64.standard.Decoder;
+ const decoded_len = try decoder.calcSizeForSlice(data);
+ const alloc = self.terminal.gpa();
+ const decoded = try alloc.alloc(u8, decoded_len);
+ defer alloc.free(decoded);
+ try decoder.decode(decoded, data);
+
+ const contents = [_]clipboard.Content{.{
+ .mime = "text/plain",
+ .data = decoded,
+ }};
+ _ = func(self, .{
+ .location = location,
+ .contents = &contents,
+ });
+ }
+
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
@@ -325,10 +406,11 @@ pub const Handler = struct {
.color_scheme => {
const func = self.effects.color_scheme orelse return;
const scheme = func(self) orelse return;
- self.writePty(switch (scheme) {
- .dark => "\x1B[?997;1n",
- .light => "\x1B[?997;2n",
- });
+ var buf: [device_status.max_color_scheme_report_encode_size + 1]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(buf[0..device_status.max_color_scheme_report_encode_size]);
+ device_status.encodeColorSchemeReport(&writer, scheme) catch return;
+ buf[writer.end] = 0;
+ self.writePty(buf[0..writer.end :0]);
},
}
}
@@ -420,6 +502,29 @@ pub const Handler = struct {
func(self);
}
+ fn reportPwd(self: *Handler, url_raw: []const u8) void {
+ // Prevent DoS attacks by limiting url length. Headroom for
+ // Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding.
+ const max_url_len = 4096;
+ const url = if (url_raw.len > max_url_len) url: {
+ log.warn("pwd url length {d} exceeds max length {d}, truncating", .{
+ url_raw.len,
+ max_url_len,
+ });
+ break :url url_raw[0..max_url_len];
+ } else url_raw;
+
+ // We store the raw payload unparsed. Embedders read it via
+ // getPwd() and are responsible for decoding any URI scheme.
+ self.terminal.setPwd(url) catch |err| {
+ log.warn("error setting pwd err={}", .{err});
+ return;
+ };
+
+ const func = self.effects.pwd_changed orelse return;
+ func(self);
+ }
+
fn requestMode(self: *Handler, mode: modes.Mode) void {
const report = self.terminal.modes.getReport(.fromMode(mode));
self.sendModeReport(report);
@@ -550,12 +655,17 @@ pub const Handler = struct {
fn colorOperation(
self: *Handler,
- op: osc_color.Operation,
requests: *const osc_color.List,
+ terminator: osc.Terminator,
) !void {
- _ = op;
if (requests.count() == 0) return;
+ var stack = std.heap.stackFallback(1024, self.terminal.gpa());
+ const alloc = stack.get();
+ var response: std.Io.Writer.Allocating = .init(alloc);
+ defer response.deinit();
+ const writer = &response.writer;
+
var it = requests.constIterator(0);
while (it.next()) |req| {
switch (req.*) {
@@ -613,17 +723,67 @@ pub const Handler = struct {
mask.* = .initEmpty();
},
- .query,
- .reset_special,
- => {},
+ .query => |target| {
+ if (self.effects.write_pty == null) continue;
+ const c = self.terminal.colorForXterm(target) orelse continue;
+ try writeXtermColorReport(writer, target, c, terminator);
+ },
+
+ .reset_special => {},
}
}
+
+ if (response.written().len > 0) {
+ const resp = try response.toOwnedSliceSentinel(0);
+ defer alloc.free(resp);
+ self.writePty(resp);
+ }
+ }
+
+ fn writeXtermColorReport(
+ writer: *std.Io.Writer,
+ target: osc_color.Target,
+ c: color.RGB,
+ terminator: osc.Terminator,
+ ) !void {
+ switch (target) {
+ .palette => |i| {
+ try writer.print("\x1b]4;{d};", .{i});
+ try c.encodeRgb16(writer);
+ try writer.writeAll(terminator.string());
+ },
+ .dynamic => |dynamic| switch (dynamic) {
+ .foreground,
+ .background,
+ .cursor,
+ => {
+ try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)});
+ try c.encodeRgb16(writer);
+ try writer.writeAll(terminator.string());
+ },
+ .pointer_foreground,
+ .pointer_background,
+ .tektronix_foreground,
+ .tektronix_background,
+ .highlight_background,
+ .tektronix_cursor,
+ .highlight_foreground,
+ => {},
+ },
+ .special => {},
+ }
}
fn kittyColorOperation(
self: *Handler,
request: kitty_color.OSC,
) !void {
+ var stack = std.heap.stackFallback(1024, self.terminal.gpa());
+ const alloc = stack.get();
+ var response: std.Io.Writer.Allocating = .init(alloc);
+ defer response.deinit();
+ const writer = &response.writer;
+
for (request.list.items) |item| {
switch (item) {
.set => |v| switch (v.key) {
@@ -650,9 +810,28 @@ pub const Handler = struct {
else => {},
},
},
- .query => {},
+ .query => |key| {
+ if (self.effects.write_pty == null) continue;
+ const c = self.terminal.colorForKitty(key) orelse {
+ if (!key.hasTerminalQueryColor()) continue;
+ if (response.written().len == 0) try writer.writeAll("\x1b]21");
+ try writer.print(";{f}=", .{key});
+ continue;
+ };
+
+ if (response.written().len == 0) try writer.writeAll("\x1b]21");
+ try writer.print(";{f}=", .{key});
+ try c.encodeRgb8(writer);
+ },
}
}
+
+ if (response.written().len > 0) {
+ try writer.writeAll(request.terminator.string());
+ const resp = try response.toOwnedSliceSentinel(0);
+ defer alloc.free(resp);
+ self.writePty(resp);
+ }
}
fn apcEnd(self: *Handler) void {
@@ -679,6 +858,25 @@ pub const Handler = struct {
if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]);
}
},
+
+ .glyph => |*glyph_req| {
+ const resp = self.terminal.glyphProtocol(alloc, glyph_req);
+ if (resp) |r| resp_block: {
+ // Don't waste time encoding if we can't write responses
+ // anyways.
+ if (self.effects.write_pty == null) break :resp_block;
+
+ // Glyph responses are short and bounded by the protocol
+ // fields we emit, so this matches the Kitty response
+ // buffer size above with ample headroom.
+ var buf: [apc.glyph.Response.max_wire_bytes]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ r.formatWire(&writer) catch return;
+ writer.writeByte(0) catch return;
+ const final = writer.buffered();
+ self.writePty(final[0 .. final.len - 1 :0]);
+ }
+ },
}
}
};
@@ -900,6 +1098,8 @@ test "full reset" {
s.nextSlice("\x1B[10;20H");
s.nextSlice("\x1B[5;20r"); // Set scroll region
s.nextSlice("\x1B[?7l"); // Disable wraparound
+ s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
+ try testing.expect(t.glyph_glossary.contains(0xE0A0));
// Full reset
s.nextSlice("\x1Bc");
@@ -910,6 +1110,35 @@ test "full reset" {
try testing.expectEqual(@as(usize, 0), t.scrolling_region.top);
try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom);
try testing.expect(t.modes.get(.wraparound));
+ try testing.expect(!t.glyph_glossary.contains(0xE0A0));
+}
+
+test "glyph protocol APC with write_pty callback" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var last_response: ?[:0]const u8 = null;
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ if (last_response) |old| testing.allocator.free(old);
+ last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
+ }
+ };
+ S.last_response = null;
+ defer if (S.last_response) |old| testing.allocator.free(old);
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ s.nextSlice("\x1B_25a1;s\x1B\\");
+ try testing.expectEqualStrings("\x1B_25a1;s;fmt=glyf\x1B\\", S.last_response.?);
+
+ s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
+ try testing.expectEqualStrings("\x1B_25a1;r;cp=e0a0;status=0\x1B\\", S.last_response.?);
+ try testing.expect(t.glyph_glossary.contains(0xE0A0));
}
test "ignores query actions" {
@@ -923,6 +1152,8 @@ test "ignores query actions" {
s.nextSlice("\x1B[c"); // Device attributes
s.nextSlice("\x1B[5n"); // Device status report
s.nextSlice("\x1B[6n"); // Cursor position report
+ s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query
+ s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query
// Terminal should still be functional
s.nextSlice("Test");
@@ -1039,6 +1270,63 @@ test "OSC 12 set and reset cursor color" {
// After reset, cursor might be null (using default)
}
+test "OSC color query responses" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var last_response: ?[:0]const u8 = null;
+
+ fn reset() void {
+ if (last_response) |old| testing.allocator.free(old);
+ last_response = null;
+ }
+
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ reset();
+ last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
+ }
+ };
+ S.last_response = null;
+ defer S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ s.nextSlice("\x1b]10;?\x1b\\");
+ try testing.expect(S.last_response == null);
+
+ s.nextSlice("\x1b]11;?\x1b\\");
+ try testing.expect(S.last_response == null);
+
+ s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]4;2;rgb:1212/3434/5656\x1b\\",
+ S.last_response.?,
+ );
+
+ s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\");
+ s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\");
+ s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\");
+ s.nextSlice("\x1b]10;?;?;?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]10;rgb:0101/0202/0303\x1b\\" ++
+ "\x1b]11;rgb:0404/0505/0606\x1b\\" ++
+ "\x1b]12;rgb:0707/0808/0909\x1b\\",
+ S.last_response.?,
+ );
+
+ s.nextSlice("\x1b]112\x1b\\");
+ s.nextSlice("\x1b]12;?\x07");
+ try testing.expectEqualStrings(
+ "\x1b]12;rgb:0101/0202/0303\x07",
+ S.last_response.?,
+ );
+}
+
test "kitty color protocol set palette" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
@@ -1133,6 +1421,46 @@ test "kitty color protocol reset foreground" {
try testing.expect(t.colors.foreground.get() == null);
}
+test "kitty color protocol query responses" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var last_response: ?[:0]const u8 = null;
+
+ fn reset() void {
+ if (last_response) |old| testing.allocator.free(old);
+ last_response = null;
+ }
+
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ reset();
+ last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
+ }
+ };
+ S.last_response = null;
+ defer S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ s.nextSlice("\x1b]21;background=?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]21;background=\x1b\\",
+ S.last_response.?,
+ );
+
+ s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\");
+ s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\",
+ S.last_response.?,
+ );
+}
+
test "palette dirty flag set on color change" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
@@ -1321,6 +1649,155 @@ test "bell effect callback" {
}
}
+test "clipboard_write effect callback" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ // A null callback (the default readonly effects) silently ignores writes.
+ {
+ var s: Stream = .initAlloc(testing.allocator, .init(&t));
+ defer s.deinit();
+
+ s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
+
+ // Terminal should still be functional after the ignored sequence
+ s.nextSlice("AfterClipboard");
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("AfterClipboard", str);
+ }
+
+ t.fullReset();
+
+ const S = struct {
+ var count: usize = 0;
+ var result: clipboard.WriteResult = .success;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_len: usize = 0;
+ var last_mime: ?[]u8 = null;
+ var last_data: ?[]u8 = null;
+
+ fn clearCapture() void {
+ if (last_mime) |value| testing.allocator.free(value);
+ if (last_data) |value| testing.allocator.free(value);
+ last_mime = null;
+ last_data = null;
+ last_contents_len = 0;
+ }
+
+ fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult {
+ clearCapture();
+ count += 1;
+ last_location = write.location;
+ last_contents_len = write.contents.len;
+ if (write.contents.len > 0) {
+ last_mime = testing.allocator.dupe(u8, write.contents[0].mime) catch
+ @panic("failed to capture clipboard MIME type");
+ last_data = testing.allocator.dupe(u8, write.contents[0].data) catch
+ @panic("failed to capture clipboard data");
+ }
+ return result;
+ }
+ };
+ S.count = 0;
+ S.result = .denied;
+ S.clearCapture();
+ defer S.clearCapture();
+
+ var handler: Handler = .init(&t);
+ handler.effects.clipboard_write = &S.clipboardWrite;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ // Selectors are normalized and payloads are decoded before the callback.
+ const cases = [_]struct {
+ sequence: []const u8,
+ location: clipboard.Location,
+ data: []const u8,
+ }{
+ .{ .sequence = "\x1B]52;c;aGVsbG8=\x1B\\", .location = .standard, .data = "hello" },
+ .{ .sequence = "\x1B]52;s;d29ybGQ=\x07", .location = .selection, .data = "world" },
+ .{ .sequence = "\x1B]52;p;cHJpbWFyeQ==\x1B\\", .location = .primary, .data = "primary" },
+ .{ .sequence = "\x1B]52;0;Y3V0\x1B\\", .location = .standard, .data = "cut" },
+ .{ .sequence = "\x1B]52;x;ZmFsbGJhY2s=\x1B\\", .location = .standard, .data = "fallback" },
+ .{ .sequence = "\x1B]52;c;YQBi\x1B\\", .location = .standard, .data = "a\x00b" },
+ };
+
+ for (cases, 1..) |case, expected_count| {
+ s.nextSlice(case.sequence);
+ try testing.expectEqual(expected_count, S.count);
+ try testing.expectEqual(case.location, S.last_location);
+ try testing.expectEqual(@as(usize, 1), S.last_contents_len);
+ try testing.expectEqualStrings("text/plain", S.last_mime.?);
+ try testing.expectEqualSlices(u8, case.data, S.last_data.?);
+ }
+
+ // Empty data is a clear, represented by an empty contents slice.
+ s.nextSlice("\x1B]52;s;\x1B\\");
+ try testing.expectEqual(@as(usize, cases.len + 1), S.count);
+ try testing.expectEqual(clipboard.Location.selection, S.last_location);
+ try testing.expectEqual(@as(usize, 0), S.last_contents_len);
+ try testing.expect(S.last_mime == null);
+ try testing.expect(S.last_data == null);
+
+ // Reads and malformed base64 are ignored.
+ s.nextSlice("\x1B]52;c;?\x1B\\");
+ s.nextSlice("\x1B]52;c;***\x1B\\");
+ try testing.expectEqual(@as(usize, cases.len + 1), S.count);
+
+ // OSC 1337 Copy shares the normalized clipboard write path.
+ s.nextSlice("\x1B]1337;Copy=:aVRlcm0y\x1B\\");
+ try testing.expectEqual(@as(usize, cases.len + 2), S.count);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expectEqualStrings("text/plain", S.last_mime.?);
+ try testing.expectEqualStrings("iTerm2", S.last_data.?);
+
+ // Parsing across write boundaries still invokes exactly one atomic write.
+ s.nextSlice("\x1B]52;p;ZnJh");
+ s.nextSlice("Z21lbnRlZA==\x1B");
+ s.nextSlice("\\");
+ try testing.expectEqual(@as(usize, cases.len + 3), S.count);
+ try testing.expectEqual(clipboard.Location.primary, S.last_location);
+ try testing.expectEqualStrings("text/plain", S.last_mime.?);
+ try testing.expectEqualStrings("fragmented", S.last_data.?);
+
+ // Callback results are intentionally ignored for protocols without a
+ // write acknowledgement. The denied result above did not stop later writes.
+ try testing.expectEqual(clipboard.WriteResult.denied, S.result);
+}
+
+test "clipboard_write allocation failure is ignored" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var count: usize = 0;
+
+ fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult {
+ count += 1;
+ return .success;
+ }
+ };
+ S.count = 0;
+
+ var handler: Handler = .init(&t);
+ handler.effects.clipboard_write = &S.clipboardWrite;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ // Only the decoded scratch data uses the terminal allocator here. Swap in
+ // an allocator that always fails, then restore it before terminal teardown.
+ {
+ const alloc = t.screens.active.alloc;
+ t.screens.active.alloc = testing.failing_allocator;
+ defer t.screens.active.alloc = alloc;
+ s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
+ }
+ try testing.expectEqual(@as(usize, 0), S.count);
+}
+
test "request mode DECRQM with write_pty callback" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);