aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/terminal/dcs.zig
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
commit2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch)
tree7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/terminal/dcs.zig
downloadslosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty main@bedb6f2 (zig 0.16), built to a 14MB static lib. M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer, a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a ~120Hz frame cap that coalesces pty reads. Also the seed of the headless harness: --headless dumps the composited screen without a tty, so composition is testable in milliseconds. tests/live_m0.py covers what headless cannot: raw mode, the diff emitter, resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/dcs.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/dcs.zig571
1 files changed, 571 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/dcs.zig b/vendor/libghostty-vt/src/terminal/dcs.zig
new file mode 100644
index 0000000..a76ab31
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/dcs.zig
@@ -0,0 +1,571 @@
+const std = @import("std");
+const build_options = @import("terminal_options");
+const assert = @import("../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const terminal = @import("main.zig");
+const DCS = terminal.DCS;
+
+const log = std.log.scoped(.terminal_dcs);
+
+/// DCS command handler. This should be hooked into a terminal.Stream handler.
+/// The hook/put/unhook functions are meant to be called from the
+/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively.
+pub const Handler = struct {
+ state: State = .{ .inactive = {} },
+
+ /// Maximum bytes any DCS command can take. This is to prevent
+ /// malicious input from causing us to allocate too much memory.
+ /// This is arbitrarily set to 1MB today, increase if needed.
+ max_bytes: usize = 1024 * 1024,
+
+ pub fn deinit(self: *Handler) void {
+ self.discard();
+ }
+
+ pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) ?Command {
+ assert(self.state == .inactive);
+
+ // Initialize our state to ignore in case of error
+ self.state = .ignore;
+
+ // Try to parse the hook.
+ const hk_ = self.tryHook(alloc, dcs) catch |err| {
+ log.info("error initializing DCS hook, will ignore hook err={}", .{err});
+ return null;
+ };
+ const hk = hk_ orelse {
+ log.info("unknown DCS hook: {}", .{dcs});
+ return null;
+ };
+
+ self.state = hk.state;
+ return hk.command;
+ }
+
+ const Hook = struct {
+ state: State,
+ command: ?Command = null,
+ };
+
+ fn tryHook(self: Handler, alloc: Allocator, dcs: DCS) !?Hook {
+ return switch (dcs.intermediates.len) {
+ 0 => switch (dcs.final) {
+ // Tmux control mode
+ 'p' => tmux: {
+ if (comptime !build_options.tmux_control_mode) {
+ log.debug("tmux control mode not enabled in build, ignoring", .{});
+ break :tmux null;
+ }
+
+ // Tmux control mode must start with ESC P 1000 p
+ if (dcs.params.len != 1 or dcs.params[0] != 1000) break :tmux null;
+
+ break :tmux .{
+ .state = .{
+ .tmux = .{
+ .max_bytes = self.max_bytes,
+ .buffer = try .initCapacity(
+ alloc,
+ 128, // Arbitrary choice to limit initial reallocs
+ ),
+ },
+ },
+ .command = .{ .tmux = .enter },
+ };
+ },
+
+ else => null,
+ },
+
+ 1 => switch (dcs.intermediates[0]) {
+ '+' => switch (dcs.final) {
+ // XTGETTCAP
+ // https://github.com/mitchellh/ghostty/issues/517
+ 'q' => .{
+ .state = .{
+ .xtgettcap = try .initCapacity(
+ alloc,
+ 128, // Arbitrary choice
+ ),
+ },
+ },
+
+ else => null,
+ },
+
+ '$' => switch (dcs.final) {
+ // DECRQSS
+ 'q' => .{ .state = .{
+ .decrqss = .{},
+ } },
+
+ else => null,
+ },
+
+ else => null,
+ },
+
+ else => null,
+ };
+ }
+
+ /// Put a byte into the DCS handler. This will return a command
+ /// if a command needs to be executed.
+ pub fn put(self: *Handler, byte: u8) ?Command {
+ return self.tryPut(byte) catch |err| {
+ // On error we just discard our state and ignore the rest
+ log.info("error putting byte into DCS handler err={}", .{err});
+ self.discard();
+ self.state = .ignore;
+ return null;
+ };
+ }
+
+ fn tryPut(self: *Handler, byte: u8) !?Command {
+ switch (self.state) {
+ .inactive,
+ .ignore,
+ => {},
+
+ .tmux => |*tmux| if (comptime build_options.tmux_control_mode) {
+ return .{
+ .tmux = (try tmux.put(byte)) orelse return null,
+ };
+ } else unreachable,
+
+ .xtgettcap => |*list| {
+ if (list.written().len >= self.max_bytes) {
+ return error.OutOfMemory;
+ }
+
+ try list.writer.writeByte(byte);
+ },
+
+ .decrqss => |*buffer| {
+ if (buffer.len >= buffer.data.len) {
+ return error.OutOfMemory;
+ }
+
+ buffer.data[buffer.len] = byte;
+ buffer.len += 1;
+ },
+ }
+
+ return null;
+ }
+
+ pub fn unhook(self: *Handler) ?Command {
+ // Note: we do NOT call deinit here on purpose because some commands
+ // transfer memory ownership. If state needs cleanup, the switch
+ // prong below should handle it.
+ defer self.state = .inactive;
+
+ return switch (self.state) {
+ .inactive,
+ .ignore,
+ => null,
+
+ .tmux => if (comptime build_options.tmux_control_mode) tmux: {
+ self.state.deinit();
+ break :tmux .{ .tmux = .exit };
+ } else unreachable,
+
+ .xtgettcap => |*list| xtgettcap: {
+ // Note: purposely do not deinit our state here because
+ // we copy it into the resulting command.
+ const items = list.written();
+ for (items, 0..) |b, i| items[i] = std.ascii.toUpper(b);
+ break :xtgettcap .{ .xtgettcap = .{ .data = list.* } };
+ },
+
+ .decrqss => |buffer| .{ .decrqss = switch (buffer.len) {
+ 0 => .none,
+ 1 => switch (buffer.data[0]) {
+ 'm' => .sgr,
+ 'r' => .decstbm,
+ 's' => .decslrm,
+ else => .none,
+ },
+ 2 => switch (buffer.data[0]) {
+ ' ' => switch (buffer.data[1]) {
+ 'q' => .decscusr,
+ else => .none,
+ },
+ else => .none,
+ },
+ else => unreachable,
+ } },
+ };
+ }
+
+ fn discard(self: *Handler) void {
+ self.state.deinit();
+ self.state = .inactive;
+ }
+};
+
+pub const Command = union(enum) {
+ /// XTGETTCAP
+ xtgettcap: XTGETTCAP,
+
+ /// DECRQSS
+ decrqss: DECRQSS,
+
+ /// Tmux control mode
+ tmux: if (build_options.tmux_control_mode)
+ terminal.tmux.ControlNotification
+ else
+ void,
+
+ pub fn deinit(self: *Command) void {
+ switch (self.*) {
+ .xtgettcap => |*v| v.data.deinit(),
+ .decrqss => {},
+ .tmux => {},
+ }
+ }
+
+ pub const XTGETTCAP = struct {
+ data: std.Io.Writer.Allocating,
+ i: usize = 0,
+
+ /// Returns the next terminfo key being requested and null
+ /// when there are no more keys. The returned value is NOT hex-decoded
+ /// because we expect to use a comptime lookup table.
+ pub fn next(self: *XTGETTCAP) ?[]const u8 {
+ const items = self.data.written();
+ if (self.i >= items.len) return null;
+ var rem = items[self.i..];
+ const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len;
+
+ // Note that if we're at the end, idx + 1 is len + 1 so we're over
+ // the end but that's okay because our check above is >= so we'll
+ // never read.
+ self.i += idx + 1;
+
+ return rem[0..idx];
+ }
+ };
+
+ /// Supported DECRQSS settings
+ pub const DECRQSS = enum {
+ none,
+ sgr,
+ decscusr,
+ decstbm,
+ decslrm,
+
+ /// Fixed upper bound for an encoded DECRPSS response. The comptime
+ /// calculated max at the time of writing this was around 63 so this
+ /// leaves a ton of space for future stuff. We don't do the comptime
+ /// calculation cause it complicated the implementation a bit too
+ /// much.
+ pub const max_response_bytes = 256;
+
+ /// Encode the response for this request.
+ pub fn encode(
+ self: DECRQSS,
+ t: *terminal.Terminal,
+ response: []u8,
+ ) ![]const u8 {
+ var writer: std.Io.Writer = .fixed(response);
+
+ const prefix_fmt = "\x1bP{d}$r";
+ const prefix_len = std.fmt.comptimePrint(prefix_fmt, .{0}).len;
+ writer.end = prefix_len;
+
+ switch (self) {
+ .none => {},
+ .sgr => {
+ const buf = try t.printAttributes(writer.buffer[writer.end..]);
+ writer.end += buf.len;
+ try writer.writeByte('m');
+ },
+ .decscusr => {
+ const blink = t.modes.get(.cursor_blinking);
+ const style: u8 = switch (t.screens.active.cursor.cursor_style) {
+ .block, .block_hollow => if (blink) 1 else 2,
+ .underline => if (blink) 3 else 4,
+ .bar => if (blink) 5 else 6,
+ };
+ try writer.print("{d} q", .{style});
+ },
+ .decstbm => try writer.print("{d};{d}r", .{
+ t.scrolling_region.top + 1,
+ t.scrolling_region.bottom + 1,
+ }),
+ .decslrm => if (t.modes.get(.enable_left_and_right_margin)) {
+ try writer.print("{d};{d}s", .{
+ t.scrolling_region.left + 1,
+ t.scrolling_region.right + 1,
+ });
+ },
+ }
+
+ const valid = writer.end > prefix_len;
+ try writer.writeAll("\x1b\\");
+ _ = try std.fmt.bufPrint(
+ response[0..prefix_len],
+ prefix_fmt,
+ .{@intFromBool(valid)},
+ );
+ return writer.buffered();
+ }
+ };
+};
+
+const State = union(enum) {
+ /// We're not in a DCS state at the moment.
+ inactive,
+
+ /// We're hooked, but its an unknown DCS command or one that went
+ /// invalid due to some bad input, so we're ignoring the rest.
+ ignore,
+
+ /// XTGETTCAP
+ xtgettcap: std.Io.Writer.Allocating,
+
+ /// DECRQSS
+ decrqss: struct {
+ data: [2]u8 = undefined,
+ len: u2 = 0,
+ },
+
+ /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode
+ tmux: if (build_options.tmux_control_mode)
+ terminal.tmux.ControlParser
+ else
+ void,
+
+ pub fn deinit(self: *State) void {
+ switch (self.*) {
+ .inactive,
+ .ignore,
+ => {},
+
+ .xtgettcap => |*v| v.deinit(),
+ .decrqss => {},
+ .tmux => |*v| if (comptime build_options.tmux_control_mode) {
+ v.deinit();
+ } else unreachable,
+ }
+ }
+};
+
+test "unknown DCS command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+ try testing.expect(h.hook(alloc, .{ .final = 'A' }) == null);
+ try testing.expect(h.state == .ignore);
+ try testing.expect(h.unhook() == null);
+ try testing.expect(h.state == .inactive);
+}
+
+test "XTGETTCAP command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+ try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
+ for ("536D756C78") |byte| _ = h.put(byte);
+ var cmd = h.unhook().?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .xtgettcap);
+ try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
+ try testing.expect(cmd.xtgettcap.next() == null);
+}
+
+test "XTGETTCAP mixed case" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+ try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
+ for ("536d756C78") |byte| _ = h.put(byte);
+ var cmd = h.unhook().?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .xtgettcap);
+ try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
+ try testing.expect(cmd.xtgettcap.next() == null);
+}
+
+test "XTGETTCAP command multiple keys" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+ try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
+ for ("536D756C78;536D756C78") |byte| _ = h.put(byte);
+ var cmd = h.unhook().?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .xtgettcap);
+ try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
+ try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
+ try testing.expect(cmd.xtgettcap.next() == null);
+}
+
+test "XTGETTCAP command invalid data" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+ try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
+ for ("who;536D756C78") |byte| _ = h.put(byte);
+ var cmd = h.unhook().?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .xtgettcap);
+ try testing.expectEqualStrings("WHO", cmd.xtgettcap.next().?);
+ try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
+ try testing.expect(cmd.xtgettcap.next() == null);
+}
+
+test "DECRQSS command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+ try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
+ _ = h.put('m');
+ var cmd = h.unhook().?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .decrqss);
+ try testing.expect(cmd.decrqss == .sgr);
+}
+
+test "DECRQSS invalid command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+ try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
+ _ = h.put('z');
+ var cmd = h.unhook().?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .decrqss);
+ try testing.expect(cmd.decrqss == .none);
+
+ h.discard();
+
+ try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
+ _ = h.put('"');
+ _ = h.put(' ');
+ _ = h.put('q');
+ try testing.expect(h.unhook() == null);
+}
+
+test "DECRQSS response encoding" {
+ const testing = std.testing;
+
+ var t: terminal.Terminal = try .init(
+ testing.io,
+ testing.allocator,
+ .{ .cols = 80, .rows = 24 },
+ );
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ fn expectResponse(
+ term: *terminal.Terminal,
+ request: Command.DECRQSS,
+ expected: []const u8,
+ ) !void {
+ var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
+ const encoded = try request.encode(term, &buf);
+ try testing.expectEqualStrings(expected, encoded);
+ }
+ };
+
+ try S.expectResponse(&t, .none, "\x1BP0$r\x1B\\");
+ try S.expectResponse(&t, .sgr, "\x1BP1$r0m\x1B\\");
+
+ try t.setAttribute(.bold);
+ try t.setAttribute(.{ .underline = .curly });
+ try S.expectResponse(&t, .sgr, "\x1BP1$r0;1;4:3m\x1B\\");
+
+ t.setCursorStyle(.steady_underline);
+ try S.expectResponse(&t, .decscusr, "\x1BP1$r4 q\x1B\\");
+
+ t.scrolling_region.top = 4;
+ t.scrolling_region.bottom = 19;
+ try S.expectResponse(&t, .decstbm, "\x1BP1$r5;20r\x1B\\");
+
+ try S.expectResponse(&t, .decslrm, "\x1BP0$r\x1B\\");
+ t.modes.set(.enable_left_and_right_margin, true);
+ t.scrolling_region.left = 2;
+ t.scrolling_region.right = 69;
+ try S.expectResponse(&t, .decslrm, "\x1BP1$r3;70s\x1B\\");
+}
+
+test "DECRQSS largest response fits fixed buffer" {
+ const testing = std.testing;
+
+ var t: terminal.Terminal = try .init(
+ testing.io,
+ testing.allocator,
+ .{ .cols = 80, .rows = 24 },
+ );
+ defer t.deinit(testing.allocator);
+
+ try t.setAttribute(.bold);
+ try t.setAttribute(.faint);
+ try t.setAttribute(.italic);
+ try t.setAttribute(.{ .underline = .dashed });
+ try t.setAttribute(.blink);
+ try t.setAttribute(.inverse);
+ try t.setAttribute(.invisible);
+ try t.setAttribute(.strikethrough);
+ try t.setAttribute(.overline);
+ try t.setAttribute(.{ .direct_color_fg = .{
+ .r = 255,
+ .g = 255,
+ .b = 255,
+ } });
+ try t.setAttribute(.{ .direct_color_bg = .{
+ .r = 255,
+ .g = 255,
+ .b = 255,
+ } });
+
+ var buf: [Command.DECRQSS.max_response_bytes]u8 = undefined;
+ const encoded = try Command.DECRQSS.sgr.encode(&t, &buf);
+
+ const expected =
+ "\x1BP1$r0;1;2;3;4:5;53;5;7;8;9" ++
+ ";38:2::255:255:255" ++
+ ";48:2::255:255:255m\x1B\\";
+ try testing.expectEqualStrings(expected, encoded);
+ try testing.expect(encoded.len <= Command.DECRQSS.max_response_bytes);
+}
+
+test "tmux enter and implicit exit" {
+ if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ defer h.deinit();
+
+ {
+ var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .tmux);
+ try testing.expect(cmd.tmux == .enter);
+ }
+
+ {
+ var cmd = h.unhook().?;
+ defer cmd.deinit();
+ try testing.expect(cmd == .tmux);
+ try testing.expect(cmd.tmux == .exit);
+ }
+}