diff options
| author | user <user@clank> | 2026-09-02 15:45:32 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-02 15:45:32 +0200 |
| commit | 311979f10132697b990a670c6c99b19c1c33d623 (patch) | |
| tree | 4d96e1a3c74e1a95594e83d8a175ad47d5d51584 /vendor/libghostty-vt/src/terminal/stream.zig | |
| parent | contrib/vendor-check: watching upstream is one command now (diff) | |
| download | slosh-311979f10132697b990a670c6c99b19c1c33d623.tar.gz slosh-311979f10132697b990a670c6c99b19c1c33d623.tar.xz slosh-311979f10132697b990a670c6c99b19c1c33d623.zip | |
vendor: libghostty-vt bedb6f2 -> 3c1ef5b3 (2026-09-01)
Three weeks of upstream: 504 commits, 120 touching the terminal core
-- OSC 5522, kitty relative placements, C0 fixes, the wuffs pixel
path. Rebuilt from ghostty's own dist step (zig build dist
-Demit-lib-vt=true), zig floor unchanged at 0.16.0, and slosh's C
usage compiled unmodified.
Patch 0001 is re-implemented, not retired: upstream rewrote the area
into ImageStorage.clearScreen but kept the behaviour we patch out --
a terminal clear still reclaims transmitted-but-unplaced images,
which breaks the upload-once place-per-frame pattern the protocol's
two verbs exist for. The patch now removes just that sweep; the
placement clearing and orphan reaping stay upstream's. Their recent
'preserve data on unmatched delete' fixes the same class of bug, so
sending ours upstream remains the way to actually retire it.
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/stream.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/stream.zig | 523 |
1 files changed, 501 insertions, 22 deletions
diff --git a/vendor/libghostty-vt/src/terminal/stream.zig b/vendor/libghostty-vt/src/terminal/stream.zig index 729b6fc..29bdde7 100644 --- a/vendor/libghostty-vt/src/terminal/stream.zig +++ b/vendor/libghostty-vt/src/terminal/stream.zig @@ -128,6 +128,8 @@ pub const Action = union(Key) { kitty_color_report: kitty.color.OSC, color_operation: ColorOperation, semantic_prompt: SemanticPrompt, + kitty_clipboard: KittyClipboard, + kitty_dnd: KittyDnd, pub const Key = lib.Enum( lib.target, @@ -227,6 +229,8 @@ pub const Action = union(Key) { "kitty_color_report", "color_operation", "semantic_prompt", + "kitty_clipboard", + "kitty_dnd", }, ); @@ -236,7 +240,7 @@ pub const Action = union(Key) { @This(), // TODO: Before shipping an ABI-compatible libghostty, verify this. // This was just arbitrarily chosen for now. - [16]u64, + .{ .padding = [16]u64 }, ); pub const Tag = c_union.Tag; pub const Value = c_union.Value; @@ -412,6 +416,7 @@ pub const Action = union(Key) { pub const ClipboardContents = struct { kind: u8, data: []const u8, + terminator: osc.Terminator, pub const C = extern struct { kind: u8, @@ -444,6 +449,10 @@ pub const Action = union(Key) { }; pub const SemanticPrompt = osc.Command.SemanticPrompt; + + pub const KittyClipboard = osc.Command.KittyClipboardProtocol; + + pub const KittyDnd = osc.Command.KittyDndProtocol; }; /// Returns a type that can process a stream of tty control characters. @@ -646,9 +655,12 @@ pub fn Stream(comptime H: type) type { } inline fn nextSliceUntracked(self: *Self, input: []const u8) void { - // Disable SIMD optimizations if build requests it or if our - // manual debug mode is on. - if (comptime debug or !build_options.simd) { + // Byte-at-a-time parsing in debug mode only. Even without + // SIMD support (build_options.simd == false, e.g. wasm), the + // batched path below is much faster than per-byte dispatch + // because it decodes UTF-8 in bulk (via scalar fallbacks) and + // hands printable runs to the handler as print_slice actions. + if (comptime debug) { for (input) |c| self.nextUntracked(c); return; } @@ -706,9 +718,25 @@ pub fn Stream(comptime H: type) type { var i: usize = 0; while (i < cps.len) { const cp = cps[i]; - if (cp <= 0xF) { + // C0 and UTF-8-decoded C1 controls never enter + // printable runs. A codepoint is one of those + // exactly when it has no bit set outside 0x9F: + // bits 0-4 and bit 7 cover 0x00-0x1F and + // 0x80-0x9F and nothing else, so a single + // AND-test classifies both ranges. + if ((cp & ~@as(u32, 0x9F)) == 0) { @branchHint(.unlikely); - self.execute(@intCast(cp)); + if (cp <= 0x1F) { + // C0 controls execute rather than print. + self.execute(@intCast(cp)); + } else { + // C1 controls decoded from UTF-8 are ignored. + logUnsupportedOnce( + "ignoring UTF-8-decoded C1 controls, first: 0x{x}", + .{cp}, + 0x80, + ); + } i += 1; continue; } @@ -723,19 +751,23 @@ pub fn Stream(comptime H: type) type { scan: { if (simd.lanes(u32)) |lanes| { const V = @Vector(lanes, u32); - const threshold: V = @splat(0xF); + // Non-printable (C0 or decoded C1) is + // equivalent to "no bit outside 0x9F is + // set": one AND-test per lane. + const mask: V = @splat(~@as(u32, 0x9F)); + const zero: V = @splat(0); while (end + lanes <= cps.len) { const v: V = cps[end..][0..lanes].*; - const gt = v > threshold; - if (!@reduce(.And, gt)) { - const bits: std.meta.Int(.unsigned, lanes) = @bitCast(gt); - end += @ctz(~bits); + const stop = (v & mask) == zero; + if (@reduce(.Or, stop)) { + const bits: std.meta.Int(.unsigned, lanes) = @bitCast(stop); + end += @ctz(bits); break :scan; } end += lanes; } } - while (end < cps.len and cps[end] > 0xF) end += 1; + while (end < cps.len and (cps[end] & ~@as(u32, 0x9F)) != 0) end += 1; } self.handler.vt(.print_slice, .{ .cps = cps[i..end] }); i = end; @@ -845,6 +877,47 @@ pub fn Stream(comptime H: type) type { // Aborting transitions need the scalar path so the // handler can distinguish them from terminators. } + + // Bulk-consume OSC string bytes into the OSC parser. + // OSC payloads (e.g. OSC 52 clipboard operations and + // the kitty clipboard protocol) can be megabytes of + // base64 data, so per-byte dispatch is far too slow. + // This can't be used for handlers with a vtRaw hook + // because the bytes never produce osc_put actions. + if (self.parser.state == .osc_string) { + offset += self.consumeOscString(input[offset..]); + if (offset >= input.len) return input.len; + + // Fast-path normal string termination. This matches + // Parser.next's exit and entry actions while avoiding + // the generic action loop for every completed OSC. + switch (input[offset]) { + // The sequence should be terminated by a full + // ST ("ESC \"); the "\" that should follow is + // dispatched as a normal escape sequence. + std.ascii.control_code.esc => { + if (self.parser.osc_parser.end( + std.ascii.control_code.esc, + )) |cmd| self.oscDispatch(cmd.*); + self.parser.clear(); + self.parser.state = .escape; + offset += 1; + continue; + }, + // BEL terminates the string directly. + std.ascii.control_code.bel => { + if (self.parser.osc_parser.end( + std.ascii.control_code.bel, + )) |cmd| self.oscDispatch(cmd.*); + self.parser.state = .ground; + offset += 1; + continue; + }, + // CAN/SUB abort and ignored C0 bytes go + // through the state machine below. + else => {}, + } + } } self.nextNonUtf8(input[offset]); @@ -999,6 +1072,43 @@ pub fn Stream(comptime H: type) type { return end; } + /// Bulk-consume OSC string bytes into the OSC parser. Returns + /// the number of bytes consumed. Stops at the first byte that + /// is not an osc_put byte in the parse table, leaving it for + /// the caller to process through the state machine. Every byte + /// >= 0x20 is an osc_put byte; bytes below either terminate or + /// abort the string (BEL, CAN, SUB, ESC) or are ignored. + /// + /// Must not be used by handlers with a vtRaw hook because the + /// consumed bytes never produce osc_put actions. + fn consumeOscString(self: *Self, input: []const u8) usize { + comptime assert(!@hasDecl(T, "vtRaw")); + assert(self.parser.state == .osc_string); + + var end: usize = 0; + if (comptime std.simd.suggestVectorLength(u8)) |vector_len| { + const ByteVector = @Vector(vector_len, u8); + while (end + vector_len <= input.len) { + const bytes: ByteVector = input[end..][0..vector_len].*; + const stop = bytes < @as(ByteVector, @splat(0x20)); + if (@reduce(.Or, stop)) break; + end += vector_len; + } + } + while (end < input.len) { + switch (input[end]) { + // Not osc_put bytes: BEL/CAN/SUB/ESC terminate or + // abort the state; other C0 bytes are ignored by it. + 0x00...0x1F => break, + // Everything else is an osc_put byte. + else => end += 1, + } + } + + if (end > 0) self.parser.osc_parser.nextSlice(input[0..end]); + return end; + } + /// Like nextSlice but takes one byte and is necessarily a scalar /// operation that can't use SIMD. Prefer nextSlice if you can and /// try to get multiple bytes at once. @@ -1055,18 +1165,34 @@ pub fn Stream(comptime H: type) type { // a chain of inline functions. @setEvalBranchQuota(200_000); - // C0 control - if (c <= 0xF) { + // C0 control or a C1 control decoded from UTF-8: exactly + // the codepoints with no bit set outside 0x9F (bits 0-4 + // and bit 7 cover 0x00-0x1F and 0x80-0x9F and nothing + // else), so the printable fast path stays a single + // AND-test. + if ((c & ~@as(u21, 0x9F)) == 0) { @branchHint(.unlikely); + + // ESC + if (c == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + return; + } + + // Ignore C1 that came via UTF-8 decoding, matching xterm. + if (c > 0x1F) { + logUnsupportedOnce( + "ignoring UTF-8-decoded C1 controls, first: 0x{x}", + .{c}, + 0x80, + ); + return; + } + self.execute(@intCast(c)); return; } - // ESC - if (c == 0x1B) { - self.parser.state = .escape; - self.parser.clear(); - return; - } self.print(@intCast(c)); } @@ -2493,6 +2619,7 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.clipboard_contents, .{ .kind = clip.kind, .data = clip.data, + .terminator = clip.terminator, }); }, @@ -2548,6 +2675,14 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.progress_report, v); }, + .kitty_clipboard_protocol => |v| { + self.handler.vt(.kitty_clipboard, v); + }, + + .kitty_dnd_protocol => |v| { + self.handler.vt(.kitty_dnd, v); + }, + .conemu_sleep, .conemu_show_message_box, .conemu_change_tab_title, @@ -2558,8 +2693,7 @@ pub fn Stream(comptime H: type) type { .conemu_output_environment_variable, .conemu_run_process, .kitty_text_sizing, - .kitty_clipboard_protocol, - .kitty_dnd_protocol, + .kitty_desktop_notification, .context_signal, => { log.debug("unimplemented OSC callback: {}", .{cmd}); @@ -3017,6 +3151,166 @@ test "simd: complete incomplete utf-8" { try testing.expectEqual(@as(u21, 0x800), s.handler.c.?); } +test "stream: ground state C0 controls are executed, not printed" { + const H = struct { + buf: [128]u21 = undefined, + len: usize = 0, + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .print => { + self.buf[self.len] = value.cp; + self.len += 1; + }, + .print_slice => for (value.cps) |cp| { + self.buf[self.len] = @intCast(cp); + self.len += 1; + }, + else => {}, + } + } + }; + + // Every C0 control except ESC must never produce a print action + // in the ground state. ESC is excluded because it begins an + // escape sequence rather than executing. + for (0..0x20) |c| { + if (c == 0x1B) continue; + + // Scalar path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.next('A'); + s.next(@intCast(c)); + s.next('B'); + try testing.expectEqual(@as(usize, 2), s.handler.len); + try testing.expectEqual(@as(u21, 'A'), s.handler.buf[0]); + try testing.expectEqual(@as(u21, 'B'), s.handler.buf[1]); + } + + // Batched path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{ 'A', 'B', @intCast(c), 'C', 'D' }); + try testing.expectEqual(@as(usize, 4), s.handler.len); + for ("ABCD", 0..) |expected, i| { + try testing.expectEqual(@as(u21, expected), s.handler.buf[i]); + } + } + + // Batched path with a run long enough to exercise the + // vectorized printable-run scan on either side of the control. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + var input: [65]u8 = @splat('A'); + input[32] = @intCast(c); + s.nextSlice(&input); + try testing.expectEqual(@as(usize, 64), s.handler.len); + for (s.handler.buf[0..s.handler.len]) |cp| { + try testing.expectEqual(@as(u21, 'A'), cp); + } + } + } +} + +test "stream: ground state UTF-8-decoded C1 controls are ignored" { + const H = struct { + buf: [128]u21 = undefined, + len: usize = 0, + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .print => { + self.buf[self.len] = value.cp; + self.len += 1; + }, + .print_slice => for (value.cps) |cp| { + self.buf[self.len] = @intCast(cp); + self.len += 1; + }, + else => {}, + } + } + }; + + // Every C1 control that arrives as well-formed UTF-8 (a two byte + // 0xC2-lead sequence) must be dropped: not printed and not + // interpreted as a control (e.g. U+009B must not start a CSI). + for (0x80..0xA0) |c| { + const enc: [2]u8 = .{ 0xC2, @intCast(c) }; + + // Scalar path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.next('A'); + s.next(enc[0]); + s.next(enc[1]); + s.next('B'); + try testing.expectEqual(@as(usize, 2), s.handler.len); + try testing.expectEqual(@as(u21, 'A'), s.handler.buf[0]); + try testing.expectEqual(@as(u21, 'B'), s.handler.buf[1]); + } + + // Batched path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{ 'A', 'B', enc[0], enc[1], 'C', 'D' }); + try testing.expectEqual(@as(usize, 4), s.handler.len); + for ("ABCD", 0..) |expected, i| { + try testing.expectEqual(@as(u21, expected), s.handler.buf[i]); + } + } + + // Batched path with a run long enough to exercise the + // vectorized printable-run scan on either side of the control. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + var input: [66]u8 = @splat('A'); + input[32] = enc[0]; + input[33] = enc[1]; + s.nextSlice(&input); + try testing.expectEqual(@as(usize, 64), s.handler.len); + for (s.handler.buf[0..s.handler.len]) |cp| { + try testing.expectEqual(@as(u21, 'A'), cp); + } + } + } + + // U+00A0 (NBSP), just past the C1 range, must still print. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{ 0xC2, 0xA0 }); + try testing.expectEqual(@as(usize, 1), s.handler.len); + try testing.expectEqual(@as(u21, 0xA0), s.handler.buf[0]); + } + + // A codepoint whose continuation byte falls in the C1 range must + // still print: "Ü" is 0xC3 0x9C. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice("Ü"); + try testing.expectEqual(@as(usize, 1), s.handler.len); + try testing.expectEqual(@as(u21, 0xDC), s.handler.buf[0]); + } + + // A raw C1 byte is ill-formed UTF-8, not a decoded C1: it must + // still produce a U+FFFD replacement. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{0x9B}); + try testing.expectEqual(@as(usize, 1), s.handler.len); + try testing.expectEqual(@as(u21, 0xFFFD), s.handler.buf[0]); + } +} + test "stream: cursor right (CUF)" { const H = struct { amount: u16 = 0, @@ -3514,6 +3808,191 @@ test "stream: change window title with invalid utf-8" { } } +test "stream: osc 52 large payload in chunks" { + const alloc = testing.allocator; + + const H = struct { + const Self = @This(); + data: std.ArrayListUnmanaged(u8) = .empty, + kind: u8 = 0, + terminator: ?osc.Terminator = null, + count: usize = 0, + + pub fn vt( + self: *Self, + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .clipboard_contents => { + self.count += 1; + self.kind = value.kind; + self.terminator = value.terminator; + self.data.appendSlice( + testing.allocator, + value.data, + ) catch @panic("OOM"); + }, + else => {}, + } + } + }; + + // A payload that far exceeds the fixed buffer so the allocating + // capture is used, fed in chunk sizes that don't align with the + // sequence so the bulk path sees every kind of boundary. + const prefix = "\x1b]52;c;"; + const payload_len = 150_000; + var input: std.ArrayListUnmanaged(u8) = .empty; + defer input.deinit(alloc); + try input.appendSlice(alloc, prefix); + for (0..payload_len) |i| try input.append( + alloc, + 'A' + @as(u8, @intCast(i % 26)), + ); + try input.appendSlice(alloc, "\x1b\\"); + + var s: Stream(H) = .init(.{ .handler = .{}, .allocator = alloc }); + defer s.parser.deinit(); + defer s.handler.data.deinit(alloc); + + var i: usize = 0; + while (i < input.items.len) { + const end = @min(i + 4093, input.items.len); + s.nextSlice(input.items[i..end]); + i = end; + } + + try testing.expectEqual(@as(usize, 1), s.handler.count); + try testing.expectEqual(@as(u8, 'c'), s.handler.kind); + try testing.expectEqual(osc.Terminator.st, s.handler.terminator.?); + try testing.expectEqualSlices( + u8, + input.items[prefix.len .. prefix.len + payload_len], + s.handler.data.items, + ); +} + +test "stream: osc bulk path matches per-byte path" { + const alloc = testing.allocator; + + // Records every dispatch relevant to OSC processing in a + // normalized text form so streams fed different ways can be + // compared byte-for-byte. + const H = struct { + const Self = @This(); + alloc: Allocator, + journal: std.ArrayListUnmanaged(u8) = .empty, + + fn record(self: *Self, comptime fmt: []const u8, args: anytype) void { + const s = std.fmt.allocPrint( + self.alloc, + fmt, + args, + ) catch @panic("OOM"); + defer self.alloc.free(s); + self.journal.appendSlice(self.alloc, s) catch @panic("OOM"); + } + + pub fn vt( + self: *Self, + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .clipboard_contents => self.record( + "clip kind={c} term={s} data={s}\n", + .{ value.kind, @tagName(value.terminator), value.data }, + ), + .kitty_clipboard => self.record( + "kitty meta={s} payload={?s}\n", + .{ value.metadata, value.payload }, + ), + .window_title => self.record( + "title {s}\n", + .{value.title}, + ), + // Normalize prints to per-codepoint so the per-byte + // and slice paths journal identically. + .print => self.record("print {u}\n", .{value.cp}), + .print_slice => for (value.cps) |cp| self.record( + "print {u}\n", + .{@as(u21, @intCast(cp))}, + ), + else => {}, + } + } + }; + + const cases = [_][]const u8{ + "\x1b]52;c;aGVsbG8=\x1b\\", + "\x1b]52;c;aGVsbG8=\x07", + "\x1b]52;;aGVsbG8=\x07", + // Ignored C0 byte embedded in the payload. + "\x1b]52;c;aGVs\x01bG8=\x07", + // CAN and SUB aborts. + "\x1b]52;c;aGVsbG8=\x18", + "\x1b]52;c;aGVsbG8=\x1a", + // C1 byte embedded in the payload is data. + "\x1b]0;ab\x9ccd\x07", + // Terminated with trailing printable text. + "\x1b]0;a title\x07x", + // Back-to-back sequences. + "\x1b]2;another title\x1b\\\x1b]0;t2\x07", + "\x1b]5522;type=write;aGVsbG8=\x1b\\", + // Invalid OSC number. + "\x1b]999;junk\x07", + // Exceeds the fixed buffer: allocating capture. + "\x1b]52;c;" ++ "y" ** 3000 ++ "\x1b\\", + // Exceeds the fixed buffer: overflow, no dispatch. + "\x1b]0;" ++ "x" ** 3000 ++ "\x07", + }; + + for (cases) |case| { + // Reference: byte-at-a-time. + var ref: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer ref.parser.deinit(); + defer ref.handler.journal.deinit(alloc); + for (case) |c| ref.next(c); + + // The whole slice at once. + { + var s: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer s.parser.deinit(); + defer s.handler.journal.deinit(alloc); + s.nextSlice(case); + try testing.expectEqualSlices( + u8, + ref.handler.journal.items, + s.handler.journal.items, + ); + } + + // Split into two slices at every possible boundary. + for (0..case.len + 1) |split| { + var s: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer s.parser.deinit(); + defer s.handler.journal.deinit(alloc); + s.nextSlice(case[0..split]); + s.nextSlice(case[split..]); + try testing.expectEqualSlices( + u8, + ref.handler.journal.items, + s.handler.journal.items, + ); + } + } +} + test "stream: insert characters" { const H = struct { const Self = @This(); |
