zmx

created pr with 138.1 on 2026-08-19T16:06:35Z · by c8ef7d19
cmds
checkout latest patchset:
ssh pr.pico.sh print 138 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print 138.[rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 138

Patchset 138.1 on 2026-08-19T16:06:35Z · commit 78560bc

This 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)
+4 -1 build.zig #
......@@ -20,7 +20,10 @@ pub fn build(b: *std.Build) void {
2020
2121 const options = b.addOptions();
2222 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";
2427 options.addOption([]const u8, "ghostty_version", ghostty_ver);
2528
2629 const exe_mod = b.createModule(.{
+2 -2 build.zig.zon #
......@@ -5,8 +5,8 @@
55 .minimum_zig_version = "0.16.0",
66 .dependencies = .{
77 .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",
1010 },
1111 },
1212 .paths = .{
+3 -0 src/cross.zig #
......@@ -7,18 +7,21 @@ pub const c = switch (builtin.os.tag) {
77 @cInclude("termios.h");
88 @cInclude("stdlib.h");
99 @cInclude("unistd.h");
10+ @cInclude("time.h");
1011 }),
1112 .freebsd => @cImport({
1213 @cInclude("termios.h"); // ioctl and constants
1314 @cInclude("libutil.h"); // openpty()
1415 @cInclude("stdlib.h");
1516 @cInclude("unistd.h");
17+ @cInclude("time.h");
1618 }),
1719 else => @cImport({
1820 @cInclude("sys/ioctl.h"); // ioctl and constants
1921 @cInclude("pty.h");
2022 @cInclude("stdlib.h");
2123 @cInclude("unistd.h");
24+ @cInclude("time.h");
2225 }),
2326 };
2427
+1 -0 src/ipc.zig #
......@@ -25,6 +25,7 @@ pub const Tag = enum(u8) {
2525 Send = 18,
2626 TermGet = 19,
2727 TermData = 20,
28+ Snapshot = 21,
2829 // Non-exhaustive: this enum comes off the wire via bytesToValue and
2930 // @enumFromInt, so out-of-range values are representable
3031 // rather than UB. Switches must handle `_` (unknown tag).
+121 -27 src/loop.zig #
......@@ -13,10 +13,12 @@ const assert = std.debug.assert;
1313 const daemonize = @import("daemonize.zig");
1414 const term_mod = @import("term.zig");
1515 const builtin = @import("builtin");
16+const snapshot = @import("snapshot.zig");
17+const probe = @import("probe.zig");
1618
1719 /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking
1820 /// 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 {
2022 std.log.info("client loop fd={d}", .{client_sock_fd});
2123 const gpa: std.mem.Allocator = blk: {
2224 if (builtin.mode == .Debug) {
......@@ -42,9 +44,27 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
4244 var sock_write_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
4345 defer sock_write_buf.deinit(gpa);
4446
45- // Send init message with terminal size (buffered)
47+ // Send init message with terminal snapshot or size (buffered)
4648 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+ }
4868
4969 var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 4);
5070 defer poll_fds.deinit(gpa);
......@@ -115,12 +135,24 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
115135
116136 if (n_opt) |n| {
117137 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)) {
120152 std.log.info("detach key detected", .{});
121153 try ipc.appendMessage(gpa, &sock_write_buf, .Detach, "");
122154 } else {
123- try ipc.appendMessage(gpa, &sock_write_buf, .Input, buf[0..n]);
155+ try ipc.appendMessage(gpa, &sock_write_buf, .Input, input_slice);
124156 }
125157 } else {
126158 std.log.info("eof stdin", .{});
......@@ -153,6 +185,30 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
153185 try stdout_buf.appendSlice(gpa, msg.payload);
154186 }
155187 },
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+ },
156212 .Resize => {
157213 // daemon is asking for the client's window size usually in response
158214 // 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
456512 .Input => try daemon.handleInput(gpa, client, msg.payload),
457513 .Send => daemon.handleSend(gpa, msg.payload),
458514 .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),
460516 .Switch => try daemon.handleSwitch(gpa, msg.payload),
461517 .Resize => try daemon.handleResize(gpa, client, pty_fd, &term, msg.payload),
462518 .Detach => {
......@@ -477,7 +533,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
477533 .TermGet => try daemon.handleTermGet(gpa, client, &term),
478534 .History => try daemon.handleHistory(gpa, client, &term, msg.payload),
479535 .Run => try daemon.handleRun(gpa, io, client, msg.payload),
480- .Ack, .TaskComplete, .LabelData, .TermData => {},
536+ .Ack, .TaskComplete, .LabelData, .TermData, .Snapshot => {},
481537 .Write => try daemon.handleWrite(gpa, client, msg.payload),
482538 _ => std.log.warn(
483539 "ignoring unknown IPC tag={d}",
......@@ -901,14 +957,53 @@ pub const Daemon = struct {
901957 pub fn handleInit(
902958 self: *Daemon,
903959 gpa: std.mem.Allocator,
960+ io: std.Io,
904961 client: *Client,
905962 pty_fd: i32,
906963 term: *ghostty_vt.Terminal,
907964 payload: []const u8,
908965 ) !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+ }
9101005
911- // Serialize terminal state BEFORE resize to capture correct cursor position.
1006+ // Export terminal snapshot BEFORE resize to capture correct cursor position.
9121007 // Resizing triggers reflow which can move the cursor, and the shell's
9131008 // SIGWINCH-triggered redraw will run after our snapshot is sent.
9141009 // Only serialize on re-attach (has_had_client), not first attach, to avoid
......@@ -919,16 +1014,16 @@ pub const Daemon = struct {
9191014 "cursor before serialize: x={d} y={d} pending_wrap={}",
9201015 .{ cursor.x, cursor.y, cursor.pending_wrap },
9211016 );
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| {
9301025 std.log.warn(
931- "failed to buffer terminal state for client err={s}",
1026+ "failed to buffer snapshot for client err={s}",
9321027 .{@errorName(err)},
9331028 );
9341029 };
......@@ -943,12 +1038,11 @@ pub const Daemon = struct {
9431038
9441039 // only resize if leader
9451040 if (self.leader_client_fd == client.socket_fd) {
946- const resize = std.mem.bytesToValue(ipc.Resize, payload);
9471041 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,
9521046 };
9531047 _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
9541048 // Disable prompt_redraw before resize. The daemon's internal terminal
......@@ -959,8 +1053,8 @@ pub const Daemon = struct {
9591053 term.flags.shell_redraws_prompt = .false;
9601054 defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
9611055 const opts = ghostty_vt.Terminal.Resize{
962- .cols = resize.cols,
963- .rows = resize.rows,
1056+ .cols = cols,
1057+ .rows = rows,
9641058 };
9651059 try term.resize(gpa, opts);
9661060
......@@ -968,7 +1062,7 @@ pub const Daemon = struct {
9681062 self.has_had_client = true;
9691063 self.has_terminal_client = true;
9701064
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 });
9721066 }
9731067 }
9741068
+34 -20 src/main.zig #
......@@ -125,15 +125,22 @@ pub fn main(init: std.process.Init) !void {
125125 defer gpa.free(sesh);
126126 return history(gpa, io, &cfg, sesh, format);
127127 } 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 = "";
133130 var command_args: std.ArrayList([]const u8) = .empty;
134131 defer command_args.deinit(gpa);
132+
135133 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+ }
137144 }
138145
139146 var command: ?[][]const u8 = null;
......@@ -156,7 +163,7 @@ pub fn main(init: std.process.Init) !void {
156163 daemon.setCwd(cwd);
157164 daemon.shell = shell_env;
158165 std.log.info("socket path={s}", .{daemon.socket_path});
159- return attach(gpa, io, &daemon);
166+ return attach(gpa, io, &daemon, snapshot_mode);
160167 } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
161168 const session_name = args.next() orelse "";
162169 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 {
417424 \\Usage: zmx <command> [args...]
418425 \\
419426 \\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
421428 \\ [r]un <name> [-d] [command...] Send command without attaching
422429 \\ [s]end <name> <text...> Send raw input to session PTY
423430 \\ [p]rint <name> <text...> Inject text into session display
......@@ -442,6 +449,7 @@ fn help(io: std.Io) !void {
442449 \\
443450 \\ Examples:
444451 \\ zmx attach dev
452+ \\ zmx attach --snapshot dev
445453 \\ zmx attach dev vim
446454 \\
447455 \\History:
......@@ -1335,7 +1343,7 @@ fn switchSesh(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, current_sesh:
13351343 };
13361344 }
13371345
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 {
13391347 const sesh = socket.getSeshNameFromEnv();
13401348 if (sesh.len > 0) {
13411349 return switchSesh(gpa, io, daemon, sesh);
......@@ -1358,7 +1366,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void {
13581366 // skip terminal setup entirely rather than applying undefined stack bytes
13591367 // via tcsetattr.
13601368 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;
13621370
13631371 // RIS, OSC 10/11/12
13641372 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 {
13671375 if (stdin_is_tty) {
13681376 _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
13691377 }
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+ }
13721382 }
13731383
13741384 if (stdin_is_tty) {
......@@ -1389,18 +1399,22 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void {
13891399 _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios);
13901400 }
13911401
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+ }
13961408
1397- const looper = try loop.clientLoop(client_sock);
1409+ const looper = try loop.clientLoop(io, client_sock, snapshot_mode);
13981410 switch (looper.kind) {
13991411 .detach => return,
14001412 .switch_session => {
14011413 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+ }
14041418
14051419 const target_path = socket.getSocketPath(
14061420 gpa,
......@@ -1422,7 +1436,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void {
14221436 std.log.info("switching to new session cwd={s}", .{switch_cwd});
14231437 target_daemon.setCwd(switch_cwd);
14241438 target_daemon.shell = daemon.shell;
1425- return attach(gpa, io, &target_daemon);
1439+ return attach(gpa, io, &target_daemon, snapshot_mode);
14261440 }
14271441 },
14281442 }
+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+}
+2 -0 src/test.zig #
......@@ -8,4 +8,6 @@ comptime {
88 _ = @import("loop.zig");
99 _ = @import("cfg.zig");
1010 _ = @import("daemonize.zig");
11+ _ = @import("snapshot.zig");
12+ _ = @import("probe.zig");
1113 }
Back to top