zmx
created pr with
138.1
cmds
checkout latest patchset:
ssh pr.pico.sh print 138 | git am -3checkout any patchset in a patch request:
ssh pr.pico.sh print 138.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 138
Patchset
138.1
feat(snapshot): daemon sends libghostty snapshots instead of raw bytestream
Eric Bower
2026-08-18T14:21:30ZThis allows zmx to directly inject terminal state into terminal emulators that speak libghostty's snapshot api. This only impacts terminal rehydration of clients when they first attach to a zmx session. What does this mean? Terminal emulators that support loading libghostty snapshots directly can receive a perfect recreation of zmx's session instead of relying on us re-printing the ansi bytestream into every client that connects to a zmx session. Right now only `monstar` has experimental support for the snapshot api, but this is how it would work: ```bash zmx attach snap.1 // do some terminal stuff // close terminal and then use monstar: monstar --attach="zmx attach -s snap.1" // zmx will directly load the underlying `libghostty.Terminal` into `monstar` ``` Link: https://github.com/rockorager/monstar/pull/35
Semantic diff summary
16 added,
12 modified,
0 signature changed,
0 removed
across 8 analyzed files
(1 file skipped: unsupported file type)
src/probe.zig
-
function_declarationapplyProbeChunkadded -
function_declarationmatchProbeResponseadded -
function_declarationstripProbeResponsesadded -
test_declaration"applyProbeChunk parses OSC colors, dec modes, and kitty protocols"added -
test_declaration"stripProbeResponses sanitizes late probe responses including dec modes"added -
function_declarationprobeOuterTerminaladded -
function_declarationprobeAndSnapshotadded -
test_declaration"probe snapshot roundtrip preserves probed state and modes"added -
function_declarationgetMonotonicNsadded
+4
-1
build.zig
#
| ... | ... | @@ -20,7 +20,10 @@ pub fn build(b: *std.Build) void { | |
| 20 | 20 | ||
| 21 | 21 | const options = b.addOptions(); | |
| 22 | 22 | options.addOption([]const u8, "version", version); | |
| 23 | - | const ghostty_ver = build_zig_zon.dependencies.ghostty.hash; | |
| 23 | + | const ghostty_ver = if (@hasField(@TypeOf(build_zig_zon.dependencies.ghostty), "hash")) | |
| 24 | + | build_zig_zon.dependencies.ghostty.hash | |
| 25 | + | else | |
| 26 | + | "local-dev"; | |
| 24 | 27 | options.addOption([]const u8, "ghostty_version", ghostty_ver); | |
| 25 | 28 | ||
| 26 | 29 | const exe_mod = b.createModule(.{ |
+2
-2
build.zig.zon
#
| ... | ... | @@ -5,8 +5,8 @@ | |
| 5 | 5 | .minimum_zig_version = "0.16.0", | |
| 6 | 6 | .dependencies = .{ | |
| 7 | 7 | .ghostty = .{ | |
| 8 | - | .url = "git+https://github.com/ghostty-org/ghostty/#aa21caeaa3a2feb6ef1251d20bd81b52f1da0940", | |
| 9 | - | .hash = "ghostty-1.3.2-dev-5UdBCw7jLQVbzMzfCu-BNb7P4o6P3L9WjORwuelawrt6", | |
| 8 | + | .url = "git+https://github.com/ghostty-org/ghostty#d9ffbbf17c11f570897a49d4c722130e8698d93b", | |
| 9 | + | .hash = "ghostty-1.3.2-dev-5UdBCzLsRgWyzo8uH7Un5zNFkoJLtI1END0jcr1MHJLI", | |
| 10 | 10 | }, | |
| 11 | 11 | }, | |
| 12 | 12 | .paths = .{ |
+3
-0
src/cross.zig
#
| ... | ... | @@ -7,18 +7,21 @@ pub const c = switch (builtin.os.tag) { | |
| 7 | 7 | @cInclude("termios.h"); | |
| 8 | 8 | @cInclude("stdlib.h"); | |
| 9 | 9 | @cInclude("unistd.h"); | |
| 10 | + | @cInclude("time.h"); | |
| 10 | 11 | }), | |
| 11 | 12 | .freebsd => @cImport({ | |
| 12 | 13 | @cInclude("termios.h"); // ioctl and constants | |
| 13 | 14 | @cInclude("libutil.h"); // openpty() | |
| 14 | 15 | @cInclude("stdlib.h"); | |
| 15 | 16 | @cInclude("unistd.h"); | |
| 17 | + | @cInclude("time.h"); | |
| 16 | 18 | }), | |
| 17 | 19 | else => @cImport({ | |
| 18 | 20 | @cInclude("sys/ioctl.h"); // ioctl and constants | |
| 19 | 21 | @cInclude("pty.h"); | |
| 20 | 22 | @cInclude("stdlib.h"); | |
| 21 | 23 | @cInclude("unistd.h"); | |
| 24 | + | @cInclude("time.h"); | |
| 22 | 25 | }), | |
| 23 | 26 | }; | |
| 24 | 27 |
+1
-0
src/ipc.zig
#
| ... | ... | @@ -25,6 +25,7 @@ pub const Tag = enum(u8) { | |
| 25 | 25 | Send = 18, | |
| 26 | 26 | TermGet = 19, | |
| 27 | 27 | TermData = 20, | |
| 28 | + | Snapshot = 21, | |
| 28 | 29 | // Non-exhaustive: this enum comes off the wire via bytesToValue and | |
| 29 | 30 | // @enumFromInt, so out-of-range values are representable | |
| 30 | 31 | // rather than UB. Switches must handle `_` (unknown tag). |
+121
-27
src/loop.zig
#
| ... | ... | @@ -13,10 +13,12 @@ const assert = std.debug.assert; | |
| 13 | 13 | const daemonize = @import("daemonize.zig"); | |
| 14 | 14 | const term_mod = @import("term.zig"); | |
| 15 | 15 | const builtin = @import("builtin"); | |
| 16 | + | const snapshot = @import("snapshot.zig"); | |
| 17 | + | const probe = @import("probe.zig"); | |
| 16 | 18 | ||
| 17 | 19 | /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking | |
| 18 | 20 | /// mechanism. It will send stdin to the daemon and receive stdout from the daemon. | |
| 19 | - | pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 21 | + | pub fn clientLoop(io: std.Io, client_sock_fd: i32, snapshot_mode: bool) !ClientResult { | |
| 20 | 22 | std.log.info("client loop fd={d}", .{client_sock_fd}); | |
| 21 | 23 | const gpa: std.mem.Allocator = blk: { | |
| 22 | 24 | if (builtin.mode == .Debug) { |
| ... | ... | @@ -42,9 +44,27 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 42 | 44 | var sock_write_buf = try std.ArrayList(u8).initCapacity(gpa, 4096); | |
| 43 | 45 | defer sock_write_buf.deinit(gpa); | |
| 44 | 46 | ||
| 45 | - | // Send init message with terminal size (buffered) | |
| 47 | + | // Send init message with terminal snapshot or size (buffered) | |
| 46 | 48 | const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO); | |
| 47 | - | try ipc.appendMessage(gpa, &sock_write_buf, .Init, std.mem.asBytes(&size)); | |
| 49 | + | var orig_termios: cross.c.termios = undefined; | |
| 50 | + | const is_tty = !snapshot_mode and cross.c.tcgetattr(lib_posix.STDIN_FILENO, &orig_termios) == 0; | |
| 51 | + | const init_snap = probe.probeAndSnapshot( | |
| 52 | + | io, | |
| 53 | + | gpa, | |
| 54 | + | size.rows, | |
| 55 | + | size.cols, | |
| 56 | + | size.xpixel, | |
| 57 | + | size.ypixel, | |
| 58 | + | is_tty, | |
| 59 | + | probe.DEFAULT_PROBE_TIMEOUT_MS, | |
| 60 | + | ) catch null; | |
| 61 | + | defer if (init_snap) |snap| gpa.free(snap); | |
| 62 | + | ||
| 63 | + | if (init_snap) |snap| { | |
| 64 | + | try ipc.appendMessage(gpa, &sock_write_buf, .Init, snap); | |
| 65 | + | } else { | |
| 66 | + | try ipc.appendMessage(gpa, &sock_write_buf, .Init, std.mem.asBytes(&size)); | |
| 67 | + | } | |
| 48 | 68 | ||
| 49 | 69 | var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 4); | |
| 50 | 70 | defer poll_fds.deinit(gpa); |
| ... | ... | @@ -115,12 +135,24 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 115 | 135 | ||
| 116 | 136 | if (n_opt) |n| { | |
| 117 | 137 | if (n > 0) { | |
| 118 | - | // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence) | |
| 119 | - | if (!detach_key_disabled and util.isCtrlBackslash(buf[0..n])) { | |
| 138 | + | const input_slice = buf[0..n]; | |
| 139 | + | if (std.mem.indexOf(u8, input_slice, "\x1b_Gsnap=req\x1b\\")) |_| { | |
| 140 | + | // Drop snapshot request marker from Monstar so it isn't forwarded as shell keystrokes | |
| 141 | + | } else if (try probe.stripProbeResponses(gpa, input_slice)) |cleaned| { | |
| 142 | + | defer gpa.free(cleaned); | |
| 143 | + | if (cleaned.len > 0) { | |
| 144 | + | if (!detach_key_disabled and util.isCtrlBackslash(cleaned)) { | |
| 145 | + | std.log.info("detach key detected", .{}); | |
| 146 | + | try ipc.appendMessage(gpa, &sock_write_buf, .Detach, ""); | |
| 147 | + | } else { | |
| 148 | + | try ipc.appendMessage(gpa, &sock_write_buf, .Input, cleaned); | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } else if (!detach_key_disabled and util.isCtrlBackslash(input_slice)) { | |
| 120 | 152 | std.log.info("detach key detected", .{}); | |
| 121 | 153 | try ipc.appendMessage(gpa, &sock_write_buf, .Detach, ""); | |
| 122 | 154 | } else { | |
| 123 | - | try ipc.appendMessage(gpa, &sock_write_buf, .Input, buf[0..n]); | |
| 155 | + | try ipc.appendMessage(gpa, &sock_write_buf, .Input, input_slice); | |
| 124 | 156 | } | |
| 125 | 157 | } else { | |
| 126 | 158 | std.log.info("eof stdin", .{}); |
| ... | ... | @@ -153,6 +185,30 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 153 | 185 | try stdout_buf.appendSlice(gpa, msg.payload); | |
| 154 | 186 | } | |
| 155 | 187 | }, | |
| 188 | + | .Snapshot => { | |
| 189 | + | if (msg.payload.len > 0) { | |
| 190 | + | if (snapshot_mode) { | |
| 191 | + | try stdout_buf.appendSlice(gpa, msg.payload); | |
| 192 | + | } else { | |
| 193 | + | var reader: std.Io.Reader = .fixed(msg.payload); | |
| 194 | + | if (snapshot.startImport(gpa, io, &reader, 1024 * 1024)) |import_res| { | |
| 195 | + | var mut_res = import_res; | |
| 196 | + | defer mut_res.deinit(gpa); | |
| 197 | + | var restored_term = mut_res.terminal; | |
| 198 | + | defer restored_term.deinit(gpa); | |
| 199 | + | while (snapshot.pumpHistory(gpa, &mut_res.decoder, &restored_term) catch false) {} | |
| 200 | + | if (util.serializeTerminalState(gpa, &restored_term)) |term_output| { | |
| 201 | + | defer gpa.free(term_output); | |
| 202 | + | const restore_data = util.rewritePromptRedraw(gpa, term_output) orelse term_output; | |
| 203 | + | defer if (restore_data.ptr != term_output.ptr) gpa.free(restore_data); | |
| 204 | + | try stdout_buf.appendSlice(gpa, restore_data); | |
| 205 | + | } | |
| 206 | + | } else |err| { | |
| 207 | + | std.log.warn("failed to import snapshot in client err={s}", .{@errorName(err)}); | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | }, | |
| 156 | 212 | .Resize => { | |
| 157 | 213 | // daemon is asking for the client's window size usually in response | |
| 158 | 214 | // to this client being set as leader. |
| ... | ... | @@ -456,7 +512,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 456 | 512 | .Input => try daemon.handleInput(gpa, client, msg.payload), | |
| 457 | 513 | .Send => daemon.handleSend(gpa, msg.payload), | |
| 458 | 514 | .Output => try daemon.handleOutput(gpa, msg.payload, &term, &vt_stream), | |
| 459 | - | .Init => try daemon.handleInit(gpa, client, pty_fd, &term, msg.payload), | |
| 515 | + | .Init => try daemon.handleInit(gpa, io, client, pty_fd, &term, msg.payload), | |
| 460 | 516 | .Switch => try daemon.handleSwitch(gpa, msg.payload), | |
| 461 | 517 | .Resize => try daemon.handleResize(gpa, client, pty_fd, &term, msg.payload), | |
| 462 | 518 | .Detach => { |
| ... | ... | @@ -477,7 +533,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 477 | 533 | .TermGet => try daemon.handleTermGet(gpa, client, &term), | |
| 478 | 534 | .History => try daemon.handleHistory(gpa, client, &term, msg.payload), | |
| 479 | 535 | .Run => try daemon.handleRun(gpa, io, client, msg.payload), | |
| 480 | - | .Ack, .TaskComplete, .LabelData, .TermData => {}, | |
| 536 | + | .Ack, .TaskComplete, .LabelData, .TermData, .Snapshot => {}, | |
| 481 | 537 | .Write => try daemon.handleWrite(gpa, client, msg.payload), | |
| 482 | 538 | _ => std.log.warn( | |
| 483 | 539 | "ignoring unknown IPC tag={d}", |
| ... | ... | @@ -901,14 +957,53 @@ pub const Daemon = struct { | |
| 901 | 957 | pub fn handleInit( | |
| 902 | 958 | self: *Daemon, | |
| 903 | 959 | gpa: std.mem.Allocator, | |
| 960 | + | io: std.Io, | |
| 904 | 961 | client: *Client, | |
| 905 | 962 | pty_fd: i32, | |
| 906 | 963 | term: *ghostty_vt.Terminal, | |
| 907 | 964 | payload: []const u8, | |
| 908 | 965 | ) !void { | |
| 909 | - | if (payload.len != @sizeOf(ipc.Resize)) return; | |
| 966 | + | var rows: u16 = term.rows; | |
| 967 | + | var cols: u16 = term.cols; | |
| 968 | + | var xpixel: u16 = @as(u16, @intCast(@min(std.math.maxInt(u16), term.width_px))); | |
| 969 | + | var ypixel: u16 = @as(u16, @intCast(@min(std.math.maxInt(u16), term.height_px))); | |
| 970 | + | ||
| 971 | + | if (payload.len >= 10 and std.mem.startsWith(u8, payload, "GHOSTSNP")) { | |
| 972 | + | var reader: std.Io.Reader = .fixed(payload); | |
| 973 | + | if (snapshot.startImport(gpa, io, &reader, 1024 * 1024)) |import_res| { | |
| 974 | + | var mut_res = import_res; | |
| 975 | + | defer mut_res.deinit(gpa); | |
| 976 | + | var imported_term = mut_res.terminal; | |
| 977 | + | defer imported_term.deinit(gpa); | |
| 978 | + | ||
| 979 | + | rows = imported_term.rows; | |
| 980 | + | cols = imported_term.cols; | |
| 981 | + | xpixel = @as(u16, @intCast(@min(std.math.maxInt(u16), imported_term.width_px))); | |
| 982 | + | ypixel = @as(u16, @intCast(@min(std.math.maxInt(u16), imported_term.height_px))); | |
| 983 | + | ||
| 984 | + | // First client attaching seeds the base session theme, cursor style/blink, modes, and pixel geometry | |
| 985 | + | if (!self.has_had_client) { | |
| 986 | + | term.colors = imported_term.colors; | |
| 987 | + | term.screens.active.cursor.cursor_style = imported_term.screens.active.cursor.cursor_style; | |
| 988 | + | term.screens.active.kitty_keyboard = imported_term.screens.active.kitty_keyboard; | |
| 989 | + | term.modes = imported_term.modes; | |
| 990 | + | term.width_px = imported_term.width_px; | |
| 991 | + | term.height_px = imported_term.height_px; | |
| 992 | + | } | |
| 993 | + | } else |err| { | |
| 994 | + | std.log.warn("failed to import client init snapshot err={s}", .{@errorName(err)}); | |
| 995 | + | } | |
| 996 | + | } else if (payload.len == @sizeOf(ipc.Resize)) { | |
| 997 | + | const resize = std.mem.bytesToValue(ipc.Resize, payload); | |
| 998 | + | rows = resize.rows; | |
| 999 | + | cols = resize.cols; | |
| 1000 | + | xpixel = resize.xpixel; | |
| 1001 | + | ypixel = resize.ypixel; | |
| 1002 | + | } else { | |
| 1003 | + | return; | |
| 1004 | + | } | |
| 910 | 1005 | ||
| 911 | - | // Serialize terminal state BEFORE resize to capture correct cursor position. | |
| 1006 | + | // Export terminal snapshot BEFORE resize to capture correct cursor position. | |
| 912 | 1007 | // Resizing triggers reflow which can move the cursor, and the shell's | |
| 913 | 1008 | // SIGWINCH-triggered redraw will run after our snapshot is sent. | |
| 914 | 1009 | // Only serialize on re-attach (has_had_client), not first attach, to avoid |
| ... | ... | @@ -919,16 +1014,16 @@ pub const Daemon = struct { | |
| 919 | 1014 | "cursor before serialize: x={d} y={d} pending_wrap={}", | |
| 920 | 1015 | .{ cursor.x, cursor.y, cursor.pending_wrap }, | |
| 921 | 1016 | ); | |
| 922 | - | if (util.serializeTerminalState(gpa, term)) |term_output| { | |
| 923 | - | std.log.debug("serialize terminal state", .{}); | |
| 924 | - | // Rewrite OSC 133;A to include redraw=0 so the outer terminal | |
| 925 | - | // does not clear prompt lines on resize (issue #111). | |
| 926 | - | const restore_data = util.rewritePromptRedraw(gpa, term_output) orelse term_output; | |
| 927 | - | defer gpa.free(term_output); | |
| 928 | - | defer if (restore_data.ptr != term_output.ptr) gpa.free(restore_data); | |
| 929 | - | ipc.appendMessage(gpa, &client.write_buf, .Output, restore_data) catch |err| { | |
| 1017 | + | var snap_buf: std.Io.Writer.Allocating = .init(gpa); | |
| 1018 | + | defer snap_buf.deinit(); | |
| 1019 | + | snapshot.exportSnapshot(gpa, &snap_buf.writer, term, .{}) catch |err| { | |
| 1020 | + | std.log.warn("failed to export snapshot err={s}", .{@errorName(err)}); | |
| 1021 | + | }; | |
| 1022 | + | if (snap_buf.written().len > 0) { | |
| 1023 | + | std.log.debug("serialize terminal snapshot bytes={d}", .{snap_buf.written().len}); | |
| 1024 | + | ipc.appendMessage(gpa, &client.write_buf, .Snapshot, snap_buf.written()) catch |err| { | |
| 930 | 1025 | std.log.warn( | |
| 931 | - | "failed to buffer terminal state for client err={s}", | |
| 1026 | + | "failed to buffer snapshot for client err={s}", | |
| 932 | 1027 | .{@errorName(err)}, | |
| 933 | 1028 | ); | |
| 934 | 1029 | }; |
| ... | ... | @@ -943,12 +1038,11 @@ pub const Daemon = struct { | |
| 943 | 1038 | ||
| 944 | 1039 | // only resize if leader | |
| 945 | 1040 | if (self.leader_client_fd == client.socket_fd) { | |
| 946 | - | const resize = std.mem.bytesToValue(ipc.Resize, payload); | |
| 947 | 1041 | var ws: cross.c.struct_winsize = .{ | |
| 948 | - | .ws_row = resize.rows, | |
| 949 | - | .ws_col = resize.cols, | |
| 950 | - | .ws_xpixel = resize.xpixel, | |
| 951 | - | .ws_ypixel = resize.ypixel, | |
| 1042 | + | .ws_row = rows, | |
| 1043 | + | .ws_col = cols, | |
| 1044 | + | .ws_xpixel = xpixel, | |
| 1045 | + | .ws_ypixel = ypixel, | |
| 952 | 1046 | }; | |
| 953 | 1047 | _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws); | |
| 954 | 1048 | // Disable prompt_redraw before resize. The daemon's internal terminal |
| ... | ... | @@ -959,8 +1053,8 @@ pub const Daemon = struct { | |
| 959 | 1053 | term.flags.shell_redraws_prompt = .false; | |
| 960 | 1054 | defer term.flags.shell_redraws_prompt = saved_prompt_redraw; | |
| 961 | 1055 | const opts = ghostty_vt.Terminal.Resize{ | |
| 962 | - | .cols = resize.cols, | |
| 963 | - | .rows = resize.rows, | |
| 1056 | + | .cols = cols, | |
| 1057 | + | .rows = rows, | |
| 964 | 1058 | }; | |
| 965 | 1059 | try term.resize(gpa, opts); | |
| 966 | 1060 |
| ... | ... | @@ -968,7 +1062,7 @@ pub const Daemon = struct { | |
| 968 | 1062 | self.has_had_client = true; | |
| 969 | 1063 | self.has_terminal_client = true; | |
| 970 | 1064 | ||
| 971 | - | std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols }); | |
| 1065 | + | std.log.debug("init resize rows={d} cols={d}", .{ rows, cols }); | |
| 972 | 1066 | } | |
| 973 | 1067 | } | |
| 974 | 1068 |
+34
-20
src/main.zig
#
| ... | ... | @@ -125,15 +125,22 @@ pub fn main(init: std.process.Init) !void { | |
| 125 | 125 | defer gpa.free(sesh); | |
| 126 | 126 | return history(gpa, io, &cfg, sesh, format); | |
| 127 | 127 | } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) { | |
| 128 | - | const session_name = args.next() orelse ""; | |
| 129 | - | if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) { | |
| 130 | - | return help(io); | |
| 131 | - | } | |
| 132 | - | ||
| 128 | + | var snapshot_mode = false; | |
| 129 | + | var session_name: []const u8 = ""; | |
| 133 | 130 | var command_args: std.ArrayList([]const u8) = .empty; | |
| 134 | 131 | defer command_args.deinit(gpa); | |
| 132 | + | ||
| 135 | 133 | while (args.next()) |arg| { | |
| 136 | - | try command_args.append(gpa, arg); | |
| 134 | + | if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { | |
| 135 | + | return help(io); | |
| 136 | + | } | |
| 137 | + | if (std.mem.eql(u8, arg, "--snapshot") or std.mem.eql(u8, arg, "-s")) { | |
| 138 | + | snapshot_mode = true; | |
| 139 | + | } else if (session_name.len == 0) { | |
| 140 | + | session_name = arg; | |
| 141 | + | } else { | |
| 142 | + | try command_args.append(gpa, arg); | |
| 143 | + | } | |
| 137 | 144 | } | |
| 138 | 145 | ||
| 139 | 146 | var command: ?[][]const u8 = null; |
| ... | ... | @@ -156,7 +163,7 @@ pub fn main(init: std.process.Init) !void { | |
| 156 | 163 | daemon.setCwd(cwd); | |
| 157 | 164 | daemon.shell = shell_env; | |
| 158 | 165 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 159 | - | return attach(gpa, io, &daemon); | |
| 166 | + | return attach(gpa, io, &daemon, snapshot_mode); | |
| 160 | 167 | } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) { | |
| 161 | 168 | const session_name = args.next() orelse ""; | |
| 162 | 169 | if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) { |
| ... | ... | @@ -417,7 +424,7 @@ fn help(io: std.Io) !void { | |
| 417 | 424 | \\Usage: zmx <command> [args...] | |
| 418 | 425 | \\ | |
| 419 | 426 | \\Commands: | |
| 420 | - | \\ [a]ttach <name> [command...] Attach to session, creating if needed | |
| 427 | + | \\ [a]ttach [-s|--snapshot] <name> [command...] Attach to session, creating if needed | |
| 421 | 428 | \\ [r]un <name> [-d] [command...] Send command without attaching | |
| 422 | 429 | \\ [s]end <name> <text...> Send raw input to session PTY | |
| 423 | 430 | \\ [p]rint <name> <text...> Inject text into session display |
| ... | ... | @@ -1335,7 +1343,7 @@ fn switchSesh(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, current_sesh: | |
| 1335 | 1343 | }; | |
| 1336 | 1344 | } | |
| 1337 | 1345 | ||
| 1338 | - | fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void { | |
| 1346 | + | fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, snapshot_mode: bool) !void { | |
| 1339 | 1347 | const sesh = socket.getSeshNameFromEnv(); | |
| 1340 | 1348 | if (sesh.len > 0) { | |
| 1341 | 1349 | return switchSesh(gpa, io, daemon, sesh); |
| ... | ... | @@ -1358,7 +1366,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void { | |
| 1358 | 1366 | // skip terminal setup entirely rather than applying undefined stack bytes | |
| 1359 | 1367 | // via tcsetattr. | |
| 1360 | 1368 | var orig_termios: cross.c.termios = undefined; | |
| 1361 | - | const stdin_is_tty = cross.c.tcgetattr(lib_posix.STDIN_FILENO, &orig_termios) == 0; | |
| 1369 | + | const stdin_is_tty = !snapshot_mode and cross.c.tcgetattr(lib_posix.STDIN_FILENO, &orig_termios) == 0; | |
| 1362 | 1370 | ||
| 1363 | 1371 | // RIS, OSC 10/11/12 | |
| 1364 | 1372 | const restore_seq = "\x1bc\x1b]110\x1b\\\x1b]111\x1b\\\x1b]112\x1b\\"; |
| ... | ... | @@ -1367,8 +1375,10 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void { | |
| 1367 | 1375 | if (stdin_is_tty) { | |
| 1368 | 1376 | _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios); | |
| 1369 | 1377 | } | |
| 1370 | - | // Reset terminal modes on detach | |
| 1371 | - | _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {}; | |
| 1378 | + | if (!snapshot_mode) { | |
| 1379 | + | // Reset terminal modes on detach | |
| 1380 | + | _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {}; | |
| 1381 | + | } | |
| 1372 | 1382 | } | |
| 1373 | 1383 | ||
| 1374 | 1384 | if (stdin_is_tty) { |
| ... | ... | @@ -1389,18 +1399,22 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void { | |
| 1389 | 1399 | _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios); | |
| 1390 | 1400 | } | |
| 1391 | 1401 | ||
| 1392 | - | // Clear screen before attaching. This provides a clean slate before | |
| 1393 | - | // the session restore. | |
| 1394 | - | const clear_seq = "\x1b[2J\x1b[H"; | |
| 1395 | - | _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq); | |
| 1402 | + | if (!snapshot_mode) { | |
| 1403 | + | // Clear screen before attaching. This provides a clean slate before | |
| 1404 | + | // the session restore. | |
| 1405 | + | const clear_seq = "\x1b[2J\x1b[H"; | |
| 1406 | + | _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq); | |
| 1407 | + | } | |
| 1396 | 1408 | ||
| 1397 | - | const looper = try loop.clientLoop(client_sock); | |
| 1409 | + | const looper = try loop.clientLoop(io, client_sock, snapshot_mode); | |
| 1398 | 1410 | switch (looper.kind) { | |
| 1399 | 1411 | .detach => return, | |
| 1400 | 1412 | .switch_session => { | |
| 1401 | 1413 | if (looper.session_name) |session_name| { | |
| 1402 | - | // Reset terminal modes when switching sessions | |
| 1403 | - | _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {}; | |
| 1414 | + | if (!snapshot_mode) { | |
| 1415 | + | // Reset terminal modes when switching sessions | |
| 1416 | + | _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {}; | |
| 1417 | + | } | |
| 1404 | 1418 | ||
| 1405 | 1419 | const target_path = socket.getSocketPath( | |
| 1406 | 1420 | gpa, |
| ... | ... | @@ -1422,7 +1436,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void { | |
| 1422 | 1436 | std.log.info("switching to new session cwd={s}", .{switch_cwd}); | |
| 1423 | 1437 | target_daemon.setCwd(switch_cwd); | |
| 1424 | 1438 | target_daemon.shell = daemon.shell; | |
| 1425 | - | return attach(gpa, io, &target_daemon); | |
| 1439 | + | return attach(gpa, io, &target_daemon, snapshot_mode); | |
| 1426 | 1440 | } | |
| 1427 | 1441 | }, | |
| 1428 | 1442 | } |
+461
-0
src/probe.zig
#
| ... | ... | @@ -0,0 +1,461 @@ | |
| 1 | + | //! Outer terminal probing and snapshot generation for zmx. | |
| 2 | + | //! | |
| 3 | + | //! Synchronizes the outer terminal emulator's configuration (colors, palette, | |
| 4 | + | //! cursor style, blinking, pixel geometry, protocol capabilities, and DEC modes | |
| 5 | + | //! such as in-band size reports) into a binary Snapshot buffer. | |
| 6 | + | ||
| 7 | + | const std = @import("std"); | |
| 8 | + | const ghostty_vt = @import("ghostty-vt"); | |
| 9 | + | const lib_posix = @import("posix.zig"); | |
| 10 | + | const cross = @import("cross.zig"); | |
| 11 | + | const ipc = @import("ipc.zig"); | |
| 12 | + | const snapshot = @import("snapshot.zig"); | |
| 13 | + | const Allocator = std.mem.Allocator; | |
| 14 | + | ||
| 15 | + | /// Escape sequences sent to probe the outer terminal. | |
| 16 | + | /// | |
| 17 | + | /// Combines: | |
| 18 | + | /// - Dynamic colors: OSC 10 (fg), OSC 11 (bg), OSC 12 (cursor) | |
| 19 | + | /// - 16-color ANSI palette: OSC 4 (0..15) | |
| 20 | + | /// - Cursor shape: DECSCUSR query (\x1b[? q) | |
| 21 | + | /// - Cursor blinking: DECRQM mode 12 (\x1b[?12$p) | |
| 22 | + | /// - In-band size reports: DECRQM mode 2048 (\x1b[?2048$p) | |
| 23 | + | /// - Color scheme / dark-light mode: DECRQM mode 2031 (\x1b[?2031$p) | |
| 24 | + | /// - Pixel geometry: XTWINOPS text area (\x1b[14t) and cell size (\x1b[16t) | |
| 25 | + | /// - Kitty keyboard protocol query (\x1b[?u) | |
| 26 | + | /// - Kitty graphics protocol query dummy pixel (\x1b_Gi=1,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\) | |
| 27 | + | pub const PROBE_QUERY = | |
| 28 | + | "\x1b]10;?\x07" ++ | |
| 29 | + | "\x1b]11;?\x07" ++ | |
| 30 | + | "\x1b]12;?\x07" ++ | |
| 31 | + | "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07" ++ | |
| 32 | + | "\x1b[? q" ++ | |
| 33 | + | "\x1b[?12$p" ++ | |
| 34 | + | "\x1b[?2048$p" ++ | |
| 35 | + | "\x1b[?2031$p" ++ | |
| 36 | + | "\x1b[14t" ++ | |
| 37 | + | "\x1b[16t" ++ | |
| 38 | + | "\x1b[?u" ++ | |
| 39 | + | "\x1b_Gi=1,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\"; | |
| 40 | + | ||
| 41 | + | /// Default probe timeout in milliseconds for local TTYs. | |
| 42 | + | pub const DEFAULT_PROBE_TIMEOUT_MS: u32 = 40; | |
| 43 | + | ||
| 44 | + | fn getMonotonicNs() i128 { | |
| 45 | + | var ts: cross.c.struct_timespec = undefined; | |
| 46 | + | _ = cross.c.clock_gettime(cross.c.CLOCK_MONOTONIC, &ts); | |
| 47 | + | return @as(i128, ts.tv_sec) * std.time.ns_per_s + ts.tv_nsec; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Drains probe responses from tty_in_fd into the terminal's vtStream. | |
| 51 | + | pub fn probeOuterTerminal( | |
| 52 | + | tty_out_fd: i32, | |
| 53 | + | tty_in_fd: i32, | |
| 54 | + | timeout_ms: u32, | |
| 55 | + | term: *ghostty_vt.Terminal, | |
| 56 | + | ) !void { | |
| 57 | + | _ = lib_posix.write(tty_out_fd, PROBE_QUERY) catch return; | |
| 58 | + | ||
| 59 | + | var vt_stream = term.vtStream(); | |
| 60 | + | defer vt_stream.deinit(); | |
| 61 | + | ||
| 62 | + | var poll_fds = [_]lib_posix.pollfd{.{ | |
| 63 | + | .fd = tty_in_fd, | |
| 64 | + | .events = lib_posix.POLL.IN, | |
| 65 | + | .revents = 0, | |
| 66 | + | }}; | |
| 67 | + | ||
| 68 | + | const start_ns = getMonotonicNs(); | |
| 69 | + | const timeout_ns = @as(i128, timeout_ms) * std.time.ns_per_ms; | |
| 70 | + | ||
| 71 | + | var buf: [4096]u8 = undefined; | |
| 72 | + | ||
| 73 | + | while (true) { | |
| 74 | + | const elapsed_ns = getMonotonicNs() - start_ns; | |
| 75 | + | if (elapsed_ns >= timeout_ns) break; | |
| 76 | + | const remaining_ms = @as(i32, @intCast(@min(50, @divTrunc(timeout_ns - elapsed_ns, std.time.ns_per_ms)))); | |
| 77 | + | if (remaining_ms <= 0) break; | |
| 78 | + | ||
| 79 | + | const poll_res = lib_posix.poll(&poll_fds, remaining_ms) catch break; | |
| 80 | + | if (poll_res == 0) break; // Timeout on slice | |
| 81 | + | ||
| 82 | + | if (poll_fds[0].revents & lib_posix.POLL.IN != 0) { | |
| 83 | + | const n = lib_posix.read(tty_in_fd, &buf) catch |err| { | |
| 84 | + | if (err == error.WouldBlock) continue; | |
| 85 | + | break; | |
| 86 | + | }; | |
| 87 | + | if (n == 0) break; | |
| 88 | + | ||
| 89 | + | const slice = buf[0..n]; | |
| 90 | + | applyProbeChunk(term, &vt_stream, slice); | |
| 91 | + | } else { | |
| 92 | + | break; | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Ingest a slice of probe response bytes into the terminal and handle any non-OSC | |
| 98 | + | /// response side-effects (DECRPM, XTWINOPS, Kitty keyboard). | |
| 99 | + | pub fn applyProbeChunk( | |
| 100 | + | term: *ghostty_vt.Terminal, | |
| 101 | + | vt_stream: *ghostty_vt.TerminalStream, | |
| 102 | + | slice: []const u8, | |
| 103 | + | ) void { | |
| 104 | + | // Feed through Ghostty VT stream to parse OSC 4/10/11/12, DECSCUSR, etc. | |
| 105 | + | vt_stream.nextSlice(slice); | |
| 106 | + | ||
| 107 | + | // Parse DECRPM responses: \x1b[?<mode>;<status>$y (DEC) or \x1b[<mode>;<status>$y (ANSI) | |
| 108 | + | var decrpm_idx: usize = 0; | |
| 109 | + | while (std.mem.indexOfPos(u8, slice, decrpm_idx, "\x1b[")) |start_idx| { | |
| 110 | + | const rest = slice[start_idx + 2 ..]; | |
| 111 | + | if (std.mem.indexOf(u8, rest, "$y")) |y_rel| { | |
| 112 | + | const report_str = rest[0..y_rel]; | |
| 113 | + | const is_dec = report_str.len > 0 and report_str[0] == '?'; | |
| 114 | + | const num_str = if (is_dec) report_str[1..] else report_str; | |
| 115 | + | if (std.mem.indexOfScalar(u8, num_str, ';')) |semi_rel| { | |
| 116 | + | const mode_raw = num_str[0..semi_rel]; | |
| 117 | + | const status_raw = num_str[semi_rel + 1 ..]; | |
| 118 | + | const mode_num = std.fmt.parseInt(u16, mode_raw, 10) catch 0; | |
| 119 | + | const status = std.fmt.parseInt(u8, status_raw, 10) catch 0; | |
| 120 | + | if (ghostty_vt.modes.modeFromInt(mode_num, !is_dec)) |mode| { | |
| 121 | + | if (status == 1) { | |
| 122 | + | term.modes.set(mode, true); | |
| 123 | + | } else if (status == 2) { | |
| 124 | + | term.modes.set(mode, false); | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | decrpm_idx = start_idx + 2 + y_rel + 2; | |
| 129 | + | } else { | |
| 130 | + | break; | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Parse XTWINOPS response: \x1b[4;<height>;<width>t | |
| 135 | + | var search_idx: usize = 0; | |
| 136 | + | while (std.mem.indexOfPos(u8, slice, search_idx, "\x1b[4;")) |start_idx| { | |
| 137 | + | const rest = slice[start_idx + 4 ..]; | |
| 138 | + | if (std.mem.indexOfScalar(u8, rest, 't')) |end_rel| { | |
| 139 | + | const param_str = rest[0..end_rel]; | |
| 140 | + | if (std.mem.indexOfScalar(u8, param_str, ';')) |semi_idx| { | |
| 141 | + | const h_str = param_str[0..semi_idx]; | |
| 142 | + | const w_str = param_str[semi_idx + 1 ..]; | |
| 143 | + | const h = std.fmt.parseInt(u32, h_str, 10) catch 0; | |
| 144 | + | const w = std.fmt.parseInt(u32, w_str, 10) catch 0; | |
| 145 | + | if (h > 0 and w > 0) { | |
| 146 | + | term.height_px = h; | |
| 147 | + | term.width_px = w; | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | search_idx = start_idx + 4 + end_rel + 1; | |
| 151 | + | } else { | |
| 152 | + | break; | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Parse Kitty keyboard query response: \x1b[?<flags>u | |
| 157 | + | var kb_search_idx: usize = 0; | |
| 158 | + | while (std.mem.indexOfPos(u8, slice, kb_search_idx, "\x1b[?")) |start_idx| { | |
| 159 | + | const rest = slice[start_idx + 3 ..]; | |
| 160 | + | if (std.mem.indexOfScalar(u8, rest, 'u')) |end_rel| { | |
| 161 | + | const flag_str = rest[0..end_rel]; | |
| 162 | + | if (std.fmt.parseInt(u5, flag_str, 10)) |flag_val| { | |
| 163 | + | term.screens.active.kitty_keyboard.set( | |
| 164 | + | .set, | |
| 165 | + | @as(ghostty_vt.kitty.KeyFlags, @bitCast(flag_val)), | |
| 166 | + | ); | |
| 167 | + | } else |_| {} | |
| 168 | + | kb_search_idx = start_idx + 3 + end_rel + 1; | |
| 169 | + | } else { | |
| 170 | + | break; | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Probes outer terminal if in TTY mode and encodes a binary Snapshot buffer. | |
| 176 | + | pub fn probeAndSnapshot( | |
| 177 | + | io: std.Io, | |
| 178 | + | alloc: Allocator, | |
| 179 | + | rows: u16, | |
| 180 | + | cols: u16, | |
| 181 | + | xpixel: u16, | |
| 182 | + | ypixel: u16, | |
| 183 | + | is_tty: bool, | |
| 184 | + | timeout_ms: u32, | |
| 185 | + | ) ![]u8 { | |
| 186 | + | var term = try ghostty_vt.Terminal.init(io, alloc, .{ | |
| 187 | + | .cols = cols, | |
| 188 | + | .rows = rows, | |
| 189 | + | }); | |
| 190 | + | defer term.deinit(alloc); | |
| 191 | + | ||
| 192 | + | term.width_px = xpixel; | |
| 193 | + | term.height_px = ypixel; | |
| 194 | + | ||
| 195 | + | if (is_tty) { | |
| 196 | + | probeOuterTerminal( | |
| 197 | + | lib_posix.STDOUT_FILENO, | |
| 198 | + | lib_posix.STDIN_FILENO, | |
| 199 | + | timeout_ms, | |
| 200 | + | &term, | |
| 201 | + | ) catch |err| { | |
| 202 | + | std.log.debug("probe outer terminal failed: {s}", .{@errorName(err)}); | |
| 203 | + | }; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | var snap_buf: std.Io.Writer.Allocating = .init(alloc); | |
| 207 | + | defer snap_buf.deinit(); | |
| 208 | + | ||
| 209 | + | try snapshot.exportSnapshot(alloc, &snap_buf.writer, &term, .{}); | |
| 210 | + | return alloc.dupe(u8, snap_buf.written()); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// Helper to detect if a sequence starts at `input[i]` matching a probe response. | |
| 214 | + | /// Returns the length of the matched response sequence or null if not a probe response. | |
| 215 | + | pub fn matchProbeResponse(input: []const u8) ?usize { | |
| 216 | + | if (input.len < 3) return null; | |
| 217 | + | if (input[0] != 0x1b) return null; | |
| 218 | + | ||
| 219 | + | // 1. OSC responses: \x1b]10;... / \x1b]11;... / \x1b]12;... / \x1b]4;... | |
| 220 | + | if (input[1] == ']') { | |
| 221 | + | const osc_body = input[2..]; | |
| 222 | + | const is_color_osc = std.mem.startsWith(u8, osc_body, "10;") or | |
| 223 | + | std.mem.startsWith(u8, osc_body, "11;") or | |
| 224 | + | std.mem.startsWith(u8, osc_body, "12;") or | |
| 225 | + | std.mem.startsWith(u8, osc_body, "4;"); | |
| 226 | + | ||
| 227 | + | if (is_color_osc) { | |
| 228 | + | // Find terminator: ST (\x1b\\) or BEL (\x07) | |
| 229 | + | var j: usize = 0; | |
| 230 | + | while (j < osc_body.len) : (j += 1) { | |
| 231 | + | if (osc_body[j] == 0x07) { | |
| 232 | + | return 2 + j + 1; | |
| 233 | + | } | |
| 234 | + | if (osc_body[j] == 0x1b and j + 1 < osc_body.len and osc_body[j + 1] == '\\') { | |
| 235 | + | return 2 + j + 2; | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // 2. APC responses: \x1b_G... (Kitty graphics response, etc.) | |
| 242 | + | if (input[1] == '_' and input.len >= 3 and input[2] == 'G') { | |
| 243 | + | const apc_body = input[3..]; | |
| 244 | + | var j: usize = 0; | |
| 245 | + | while (j < apc_body.len) : (j += 1) { | |
| 246 | + | if (apc_body[j] == 0x07) { | |
| 247 | + | return 3 + j + 1; | |
| 248 | + | } | |
| 249 | + | if (apc_body[j] == 0x1b and j + 1 < apc_body.len and apc_body[j + 1] == '\\') { | |
| 250 | + | return 3 + j + 2; | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // 3. CSI responses: \x1b[ | |
| 256 | + | if (input[1] == '[') { | |
| 257 | + | const csi_body = input[2..]; | |
| 258 | + | if (csi_body.len == 0) return null; | |
| 259 | + | ||
| 260 | + | // DECRPM: \x1b[?<mode>;<status>$y or \x1b[<mode>;<status>$y | |
| 261 | + | if (std.mem.indexOf(u8, csi_body, "$y")) |y_idx| { | |
| 262 | + | return 2 + y_idx + 2; | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // Kitty keyboard query response: \x1b[?<flags>u | |
| 266 | + | if (csi_body[0] == '?') { | |
| 267 | + | if (std.mem.indexOfScalar(u8, csi_body, 'u')) |u_idx| { | |
| 268 | + | return 2 + u_idx + 1; | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // XTWINOPS: \x1b[4;<h>;<w>t or \x1b[6;<h>;<w>t | |
| 273 | + | if (std.mem.startsWith(u8, csi_body, "4;") or std.mem.startsWith(u8, csi_body, "6;")) { | |
| 274 | + | if (std.mem.indexOfScalar(u8, csi_body, 't')) |t_idx| { | |
| 275 | + | return 2 + t_idx + 1; | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // DECSCUSR response: \x1b[<n> q (where n is 0..6) | |
| 280 | + | if (csi_body.len >= 3 and csi_body[0] >= '0' and csi_body[0] <= '6' and csi_body[1] == ' ' and csi_body[2] == 'q') { | |
| 281 | + | return 2 + 3; | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | return null; | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /// Strips late probe response sequences from input data. | |
| 289 | + | /// Returns null if no probe response sequences were found (caller can use `input` as-is). | |
| 290 | + | /// Returns an allocated slice if one or more probe response sequences were stripped. | |
| 291 | + | pub fn stripProbeResponses(alloc: Allocator, input: []const u8) !?[]u8 { | |
| 292 | + | var has_probe = false; | |
| 293 | + | var i: usize = 0; | |
| 294 | + | while (i < input.len) { | |
| 295 | + | if (input[i] == 0x1b) { | |
| 296 | + | if (matchProbeResponse(input[i..])) |len| { | |
| 297 | + | has_probe = true; | |
| 298 | + | i += len; | |
| 299 | + | continue; | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | i += 1; | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | if (!has_probe) return null; | |
| 306 | + | ||
| 307 | + | var cleaned = try std.ArrayList(u8).initCapacity(alloc, input.len); | |
| 308 | + | errdefer cleaned.deinit(alloc); | |
| 309 | + | ||
| 310 | + | i = 0; | |
| 311 | + | while (i < input.len) { | |
| 312 | + | if (input[i] == 0x1b) { | |
| 313 | + | if (matchProbeResponse(input[i..])) |len| { | |
| 314 | + | i += len; | |
| 315 | + | continue; | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | try cleaned.append(alloc, input[i]); | |
| 319 | + | i += 1; | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | return try cleaned.toOwnedSlice(alloc); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | test "applyProbeChunk parses OSC colors, dec modes, and kitty protocols" { | |
| 326 | + | const testing = std.testing; | |
| 327 | + | const alloc = testing.allocator; | |
| 328 | + | ||
| 329 | + | var term = try ghostty_vt.Terminal.init(testing.io, alloc, .{ .cols = 80, .rows = 24 }); | |
| 330 | + | defer term.deinit(alloc); | |
| 331 | + | ||
| 332 | + | var vt_stream = term.vtStream(); | |
| 333 | + | defer vt_stream.deinit(); | |
| 334 | + | ||
| 335 | + | // Responses from outer terminal | |
| 336 | + | const osc_fg = "\x1b]10;rgb:c0c0/caf5/f5f5\x1b\\"; | |
| 337 | + | const osc_bg = "\x1b]11;rgb:1a1a/1b1b/2626\x1b\\"; | |
| 338 | + | const osc_cursor = "\x1b]12;rgb:ffff/aaaa/5555\x07"; | |
| 339 | + | const osc_palette = "\x1b]4;0;rgb:1515/1616/1e1e;1;rgb:f7f7/7676/8e8e\x1b\\"; | |
| 340 | + | const decscusr = "\x1b[5 q"; // Blinking bar | |
| 341 | + | const dec_in_band = "\x1b[?2048;1$y"; // In-band size reports enabled | |
| 342 | + | const dec_color_scheme = "\x1b[?2031;1$y"; // Report color scheme enabled | |
| 343 | + | const xtwinops = "\x1b[4;900;1440t"; | |
| 344 | + | const kitty_kb = "\x1b[?1u"; | |
| 345 | + | const kitty_gfx = "\x1b_Gi=1;OK\x1b\\"; | |
| 346 | + | ||
| 347 | + | applyProbeChunk(&term, &vt_stream, osc_fg); | |
| 348 | + | applyProbeChunk(&term, &vt_stream, osc_bg); | |
| 349 | + | applyProbeChunk(&term, &vt_stream, osc_cursor); | |
| 350 | + | applyProbeChunk(&term, &vt_stream, osc_palette); | |
| 351 | + | applyProbeChunk(&term, &vt_stream, decscusr); | |
| 352 | + | applyProbeChunk(&term, &vt_stream, dec_in_band); | |
| 353 | + | applyProbeChunk(&term, &vt_stream, dec_color_scheme); | |
| 354 | + | applyProbeChunk(&term, &vt_stream, xtwinops); | |
| 355 | + | applyProbeChunk(&term, &vt_stream, kitty_kb); | |
| 356 | + | applyProbeChunk(&term, &vt_stream, kitty_gfx); | |
| 357 | + | ||
| 358 | + | const fg = term.colors.foreground.get().?; | |
| 359 | + | try testing.expectEqual(@as(u8, 0xc0), fg.r); | |
| 360 | + | try testing.expectEqual(@as(u8, 0xca), fg.g); | |
| 361 | + | try testing.expectEqual(@as(u8, 0xf5), fg.b); | |
| 362 | + | ||
| 363 | + | const bg = term.colors.background.get().?; | |
| 364 | + | try testing.expectEqual(@as(u8, 0x1a), bg.r); | |
| 365 | + | try testing.expectEqual(@as(u8, 0x1b), bg.g); | |
| 366 | + | try testing.expectEqual(@as(u8, 0x26), bg.b); | |
| 367 | + | ||
| 368 | + | const cursor = term.colors.cursor.get().?; | |
| 369 | + | try testing.expectEqual(@as(u8, 0xff), cursor.r); | |
| 370 | + | try testing.expectEqual(@as(u8, 0xaa), cursor.g); | |
| 371 | + | try testing.expectEqual(@as(u8, 0x55), cursor.b); | |
| 372 | + | ||
| 373 | + | const p0 = term.colors.palette.current[0]; | |
| 374 | + | try testing.expectEqual(@as(u8, 0x15), p0.r); | |
| 375 | + | try testing.expectEqual(@as(u8, 0x16), p0.g); | |
| 376 | + | try testing.expectEqual(@as(u8, 0x1e), p0.b); | |
| 377 | + | ||
| 378 | + | const p1 = term.colors.palette.current[1]; | |
| 379 | + | try testing.expectEqual(@as(u8, 0xf7), p1.r); | |
| 380 | + | try testing.expectEqual(@as(u8, 0x76), p1.g); | |
| 381 | + | try testing.expectEqual(@as(u8, 0x8e), p1.b); | |
| 382 | + | ||
| 383 | + | try testing.expectEqual(ghostty_vt.Screen.CursorStyle.bar, term.screens.active.cursor.cursor_style); | |
| 384 | + | try testing.expect(term.modes.get(.cursor_blinking)); | |
| 385 | + | try testing.expect(term.modes.get(.in_band_size_reports)); | |
| 386 | + | try testing.expect(term.modes.get(.report_color_scheme)); | |
| 387 | + | try testing.expectEqual(@as(u32, 1440), term.width_px); | |
| 388 | + | try testing.expectEqual(@as(u32, 900), term.height_px); | |
| 389 | + | try testing.expectEqual(@as(u8, 1), term.screens.active.kitty_keyboard.current().int()); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | test "probe snapshot roundtrip preserves probed state and modes" { | |
| 393 | + | const testing = std.testing; | |
| 394 | + | const alloc = testing.allocator; | |
| 395 | + | ||
| 396 | + | var term = try ghostty_vt.Terminal.init(testing.io, alloc, .{ .cols = 100, .rows = 30 }); | |
| 397 | + | defer term.deinit(alloc); | |
| 398 | + | ||
| 399 | + | var vt_stream = term.vtStream(); | |
| 400 | + | defer vt_stream.deinit(); | |
| 401 | + | ||
| 402 | + | applyProbeChunk(&term, &vt_stream, "\x1b]10;rgb:aabb/ccdd/eeff\x1b\\"); | |
| 403 | + | applyProbeChunk(&term, &vt_stream, "\x1b]11;rgb:1122/3344/5566\x1b\\"); | |
| 404 | + | applyProbeChunk(&term, &vt_stream, "\x1b[3 q"); // Blinking underline | |
| 405 | + | applyProbeChunk(&term, &vt_stream, "\x1b[?2048;1$y"); // In-band size reports | |
| 406 | + | applyProbeChunk(&term, &vt_stream, "\x1b[4;600;800t"); | |
| 407 | + | applyProbeChunk(&term, &vt_stream, "\x1b[?3u"); // Kitty keyboard flags = 3 | |
| 408 | + | ||
| 409 | + | var snap_buf: std.Io.Writer.Allocating = .init(alloc); | |
| 410 | + | defer snap_buf.deinit(); | |
| 411 | + | try snapshot.exportSnapshot(alloc, &snap_buf.writer, &term, .{}); | |
| 412 | + | ||
| 413 | + | var reader: std.Io.Reader = .fixed(snap_buf.written()); | |
| 414 | + | var import_res = try snapshot.startImport(alloc, testing.io, &reader, 1024 * 1024); | |
| 415 | + | defer import_res.deinit(alloc); | |
| 416 | + | ||
| 417 | + | var restored = import_res.terminal; | |
| 418 | + | defer restored.deinit(alloc); | |
| 419 | + | ||
| 420 | + | try testing.expectEqual(@as(u16, 100), restored.cols); | |
| 421 | + | try testing.expectEqual(@as(u16, 30), restored.rows); | |
| 422 | + | try testing.expectEqual(@as(u32, 800), restored.width_px); | |
| 423 | + | try testing.expectEqual(@as(u32, 600), restored.height_px); | |
| 424 | + | ||
| 425 | + | const fg = restored.colors.foreground.get().?; | |
| 426 | + | try testing.expectEqual(@as(u8, 0xaa), fg.r); | |
| 427 | + | try testing.expectEqual(@as(u8, 0xcc), fg.g); | |
| 428 | + | try testing.expectEqual(@as(u8, 0xee), fg.b); | |
| 429 | + | ||
| 430 | + | const bg = restored.colors.background.get().?; | |
| 431 | + | try testing.expectEqual(@as(u8, 0x11), bg.r); | |
| 432 | + | try testing.expectEqual(@as(u8, 0x33), bg.g); | |
| 433 | + | try testing.expectEqual(@as(u8, 0x55), bg.b); | |
| 434 | + | ||
| 435 | + | try testing.expectEqual(ghostty_vt.Screen.CursorStyle.underline, restored.screens.active.cursor.cursor_style); | |
| 436 | + | try testing.expect(restored.modes.get(.cursor_blinking)); | |
| 437 | + | try testing.expect(restored.modes.get(.in_band_size_reports)); | |
| 438 | + | try testing.expectEqual(@as(u8, 3), restored.screens.active.kitty_keyboard.current().int()); | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | test "stripProbeResponses sanitizes late probe responses including dec modes" { | |
| 442 | + | const testing = std.testing; | |
| 443 | + | const alloc = testing.allocator; | |
| 444 | + | ||
| 445 | + | // Normal input with no probe sequences | |
| 446 | + | const normal_input = "ls -la\r"; | |
| 447 | + | const res1 = try stripProbeResponses(alloc, normal_input); | |
| 448 | + | try testing.expect(res1 == null); | |
| 449 | + | ||
| 450 | + | // Input mixed with late probe response and in-band size report response | |
| 451 | + | const mixed = "echo \x1b]11;rgb:1a1a/1b1b/2626\x1b\\\x1b[?2048;1$yhello"; | |
| 452 | + | const res2 = (try stripProbeResponses(alloc, mixed)).?; | |
| 453 | + | defer alloc.free(res2); | |
| 454 | + | try testing.expectEqualStrings("echo hello", res2); | |
| 455 | + | ||
| 456 | + | // Input with DECRPM, DECSCUSR, and Kitty keyboard query response | |
| 457 | + | const mixed2 = "\x1b[?12;1$y\x1b[2 q\x1b[?1upwd\n"; | |
| 458 | + | const res3 = (try stripProbeResponses(alloc, mixed2)).?; | |
| 459 | + | defer alloc.free(res3); | |
| 460 | + | try testing.expectEqualStrings("pwd\n", res3); | |
| 461 | + | } |
+105
-0
src/snapshot.zig
#
| ... | ... | @@ -0,0 +1,105 @@ | |
| 1 | + | //! Terminal binary snapshot encode and decode helpers for zmx. | |
| 2 | + | ||
| 3 | + | const std = @import("std"); | |
| 4 | + | const ghostty_vt = @import("ghostty-vt"); | |
| 5 | + | const Allocator = std.mem.Allocator; | |
| 6 | + | ||
| 7 | + | pub const Continuation = ghostty_vt.snapshot.Continuation; | |
| 8 | + | pub const EncodeOptions = ghostty_vt.snapshot.EncodeOptions; | |
| 9 | + | pub const DecodeOptions = ghostty_vt.snapshot.DecodeOptions; | |
| 10 | + | pub const Decoded = ghostty_vt.snapshot.Decoded; | |
| 11 | + | pub const Decoder = ghostty_vt.snapshot.Decoder; | |
| 12 | + | ||
| 13 | + | pub const ExportOptions = struct { | |
| 14 | + | continuation: Continuation = .ground, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | pub const ImportResult = struct { | |
| 18 | + | terminal: ghostty_vt.Terminal, | |
| 19 | + | continuation: Continuation, | |
| 20 | + | history_rows: std.EnumMap(ghostty_vt.ScreenSet.Key, u64), | |
| 21 | + | decoder: Decoder, | |
| 22 | + | ||
| 23 | + | pub fn deinit(self: *ImportResult, alloc: Allocator) void { | |
| 24 | + | switch (self.continuation) { | |
| 25 | + | .ground => {}, | |
| 26 | + | .bytes => |bytes| alloc.free(bytes), | |
| 27 | + | } | |
| 28 | + | self.* = undefined; | |
| 29 | + | } | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | /// Encode a complete terminal snapshot into any writer. | |
| 33 | + | pub fn exportSnapshot( | |
| 34 | + | alloc: Allocator, | |
| 35 | + | destination: *std.Io.Writer, | |
| 36 | + | terminal: *const ghostty_vt.Terminal, | |
| 37 | + | options: ExportOptions, | |
| 38 | + | ) ghostty_vt.snapshot.EncodeError!void { | |
| 39 | + | try ghostty_vt.snapshot.encode(alloc, destination, terminal, .{ | |
| 40 | + | .continuation = options.continuation, | |
| 41 | + | }); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Begin decoding from a stream or pipe. | |
| 45 | + | pub fn startImport( | |
| 46 | + | alloc: Allocator, | |
| 47 | + | io: std.Io, | |
| 48 | + | reader: *std.Io.Reader, | |
| 49 | + | max_continuation_bytes: usize, | |
| 50 | + | ) Decoder.ReadyError!ImportResult { | |
| 51 | + | var decoder = Decoder.init(reader); | |
| 52 | + | var decoded = try decoder.ready(alloc, io, .{ | |
| 53 | + | .max_continuation_bytes = max_continuation_bytes, | |
| 54 | + | }); | |
| 55 | + | errdefer decoded.deinit(alloc); | |
| 56 | + | ||
| 57 | + | return .{ | |
| 58 | + | .terminal = decoded.toOwned(), | |
| 59 | + | .continuation = decoded.continuation, | |
| 60 | + | .history_rows = decoded.history_rows, | |
| 61 | + | .decoder = decoder, | |
| 62 | + | }; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Incrementally pump scrollback history pages until FINISH is reached. | |
| 66 | + | pub fn pumpHistory( | |
| 67 | + | alloc: Allocator, | |
| 68 | + | decoder: *Decoder, | |
| 69 | + | terminal: *ghostty_vt.Terminal, | |
| 70 | + | ) Decoder.NextError!bool { | |
| 71 | + | if (try decoder.next(alloc, terminal)) |_| { | |
| 72 | + | return true; | |
| 73 | + | } | |
| 74 | + | return false; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | test "snapshot export and import roundtrip" { | |
| 78 | + | const testing = std.testing; | |
| 79 | + | const alloc = testing.allocator; | |
| 80 | + | ||
| 81 | + | var term: ghostty_vt.Terminal = try .init(testing.io, alloc, .{ .cols = 40, .rows = 10 }); | |
| 82 | + | defer term.deinit(alloc); | |
| 83 | + | ||
| 84 | + | var stream = term.vtStream(); | |
| 85 | + | defer stream.deinit(); | |
| 86 | + | stream.nextSlice("Hello from ZMX Snapshot!\r\nLine 2"); | |
| 87 | + | ||
| 88 | + | var buffer: std.Io.Writer.Allocating = .init(alloc); | |
| 89 | + | defer buffer.deinit(); | |
| 90 | + | ||
| 91 | + | try exportSnapshot(alloc, &buffer.writer, &term, .{}); | |
| 92 | + | ||
| 93 | + | var reader: std.Io.Reader = .fixed(buffer.written()); | |
| 94 | + | var import_res = try startImport(alloc, testing.io, &reader, 64 * 1024); | |
| 95 | + | defer import_res.deinit(alloc); | |
| 96 | + | ||
| 97 | + | var restored = import_res.terminal; | |
| 98 | + | defer restored.deinit(alloc); | |
| 99 | + | ||
| 100 | + | try testing.expectEqual(@as(u16, 40), restored.cols); | |
| 101 | + | try testing.expectEqual(@as(u16, 10), restored.rows); | |
| 102 | + | ||
| 103 | + | const has_more = try pumpHistory(alloc, &import_res.decoder, &restored); | |
| 104 | + | try testing.expect(!has_more); | |
| 105 | + | } |