repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

commit
3c26522
parent
ceb43cc
author
Eric Bower
date
2025-12-27 16:28:05 -0500 EST
feat: `zmx [hi]story` command which prints session scrollback as text
4 files changed,  +94, -0
M CHANGELOG.md
+1, -0
1@@ -6,6 +6,7 @@ Use spec: https://common-changelog.org/
2 
3 ### Added
4 
5+- `zmx [hi]story` command which prints the session scrollback as plain text
6 - Use `XDG_RUNTIME_DIR` environment variable for socket directory (takes precedence over `TMPDIR` and `/tmp`)
7 
8 ### Changed
M README.md
+1, -0
1@@ -56,6 +56,7 @@ Commands:
2   [d]etach                      Detach all clients from current session  (ctrl+b + d for current client)
3   [l]ist                        List active sessions
4   [k]ill <name>                 Kill a session and all attached clients
5+  [hi]story <name>              Output session scrollback as plain text
6   [v]ersion                     Show version information
7   [h]elp                        Show this help message
8 ```
M src/ipc.zig
+1, -0
1@@ -10,6 +10,7 @@ pub const Tag = enum(u8) {
2     Kill = 5,
3     Info = 6,
4     Init = 7,
5+    History = 8,
6 };
7 
8 pub const Header = packed struct {
M src/main.zig
+91, -0
  1@@ -256,6 +256,17 @@ const Daemon = struct {
  2         try ipc.appendMessage(self.alloc, &client.write_buf, .Info, std.mem.asBytes(&info));
  3         client.has_pending_output = true;
  4     }
  5+
  6+    pub fn handleHistory(self: *Daemon, client: *Client, term: *ghostty_vt.Terminal) !void {
  7+        if (serializeTerminalPlainText(self.alloc, term)) |output| {
  8+            defer self.alloc.free(output);
  9+            try ipc.appendMessage(self.alloc, &client.write_buf, .History, output);
 10+            client.has_pending_output = true;
 11+        } else {
 12+            try ipc.appendMessage(self.alloc, &client.write_buf, .History, "");
 13+            client.has_pending_output = true;
 14+        }
 15+    }
 16 };
 17 
 18 pub fn main() !void {
 19@@ -291,6 +302,11 @@ pub fn main() !void {
 20             return error.SessionNameRequired;
 21         };
 22         return kill(&cfg, session_name);
 23+    } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
 24+        const session_name = args.next() orelse {
 25+            return error.SessionNameRequired;
 26+        };
 27+        return history(&cfg, session_name);
 28     } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 29         const session_name = args.next() orelse {
 30             return error.SessionNameRequired;
 31@@ -343,6 +359,7 @@ fn help() !void {
 32         \\  [d]etach                      Detach all clients from current session (ctrl+b + d for current client)
 33         \\  [l]ist                        List active sessions
 34         \\  [k]ill <name>                 Kill a session and all attached clients
 35+        \\  [hi]story <name>              Output session scrollback as plain text
 36         \\  [v]ersion                     Show version information
 37         \\  [h]elp                        Show this help message
 38         \\
 39@@ -501,6 +518,57 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
 40     try w.interface.flush();
 41 }
 42 
 43+fn history(cfg: *Cfg, session_name: []const u8) !void {
 44+    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 45+    defer _ = gpa.deinit();
 46+    const alloc = gpa.allocator();
 47+
 48+    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 49+    defer dir.close();
 50+
 51+    const exists = try sessionExists(dir, session_name);
 52+    if (!exists) {
 53+        std.log.err("session does not exist session_name={s}", .{session_name});
 54+        return;
 55+    }
 56+
 57+    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 58+    defer alloc.free(socket_path);
 59+    const result = probeSession(alloc, socket_path) catch |err| {
 60+        std.log.err("session unresponsive: {s}", .{@errorName(err)});
 61+        cleanupStaleSocket(dir, session_name);
 62+        return;
 63+    };
 64+    defer posix.close(result.fd);
 65+
 66+    ipc.send(result.fd, .History, "") catch |err| switch (err) {
 67+        error.BrokenPipe, error.ConnectionResetByPeer => return,
 68+        else => return err,
 69+    };
 70+
 71+    var sb = try ipc.SocketBuffer.init(alloc);
 72+    defer sb.deinit();
 73+
 74+    while (true) {
 75+        var poll_fds = [_]posix.pollfd{.{ .fd = result.fd, .events = posix.POLL.IN, .revents = 0 }};
 76+        const poll_result = posix.poll(&poll_fds, 5000) catch return;
 77+        if (poll_result == 0) {
 78+            std.log.err("timeout waiting for history response", .{});
 79+            return;
 80+        }
 81+
 82+        const n = sb.read(result.fd) catch return;
 83+        if (n == 0) return;
 84+
 85+        while (sb.next()) |msg| {
 86+            if (msg.header.tag == .History) {
 87+                _ = posix.write(posix.STDOUT_FILENO, msg.payload) catch return;
 88+                return;
 89+            }
 90+        }
 91+    }
 92+}
 93+
 94 fn attach(daemon: *Daemon) !void {
 95     if (std.posix.getenv("ZMX_SESSION")) |_| {
 96         return error.CannotAttachToSessionInSession;
 97@@ -946,6 +1014,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
 98                             break :clients_loop;
 99                         },
100                         .Info => try daemon.handleInfo(client),
101+                        .History => try daemon.handleHistory(client, &term),
102                         .Output => {},
103                     }
104                 }
105@@ -1235,6 +1304,28 @@ fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal)
106     };
107 }
108 
109+fn serializeTerminalPlainText(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
110+    var builder: std.Io.Writer.Allocating = .init(alloc);
111+    defer builder.deinit();
112+
113+    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .plain);
114+    term_formatter.content = .{ .selection = null };
115+    term_formatter.extra = .none;
116+
117+    term_formatter.format(&builder.writer) catch |err| {
118+        std.log.warn("failed to format terminal plain text err={s}", .{@errorName(err)});
119+        return null;
120+    };
121+
122+    const output = builder.writer.buffered();
123+    if (output.len == 0) return null;
124+
125+    return alloc.dupe(u8, output) catch |err| {
126+        std.log.warn("failed to allocate terminal plain text err={s}", .{@errorName(err)});
127+        return null;
128+    };
129+}
130+
131 fn isKittyCtrlB(buf: []const u8) bool {
132     return std.mem.indexOf(u8, buf, "\x1b[98;5u") != null or
133         std.mem.indexOf(u8, buf, "\x1b[98;133u") != null;