repos / zmx

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

commit
c597d8d
parent
370157c
author
Eric Bower
date
2026-01-09 19:40:48 -0500 EST
feat(history): add `--vt` and `--html` flag

The `--vt` flag will send the raw ansi escape sequences to recreate the terminal
session.  The idea is that this could be useful for debugging purposes.

The `--html` flag was added because, why not? It's there, might as well
use it.
1 files changed,  +55, -14
M src/main.zig
+55, -14
  1@@ -257,8 +257,12 @@ const Daemon = struct {
  2         client.has_pending_output = true;
  3     }
  4 
  5-    pub fn handleHistory(self: *Daemon, client: *Client, term: *ghostty_vt.Terminal) !void {
  6-        if (serializeTerminalPlainText(self.alloc, term)) |output| {
  7+    pub fn handleHistory(self: *Daemon, client: *Client, term: *ghostty_vt.Terminal, payload: []const u8) !void {
  8+        const format: HistoryFormat = if (payload.len > 0)
  9+            @enumFromInt(payload[0])
 10+        else
 11+            .plain;
 12+        if (serializeTerminal(self.alloc, term, format)) |output| {
 13             defer self.alloc.free(output);
 14             try ipc.appendMessage(self.alloc, &client.write_buf, .History, output);
 15             client.has_pending_output = true;
 16@@ -313,10 +317,21 @@ pub fn main() !void {
 17         };
 18         return kill(&cfg, session_name);
 19     } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
 20-        const session_name = args.next() orelse {
 21+        var session_name: ?[]const u8 = null;
 22+        var format: HistoryFormat = .plain;
 23+        while (args.next()) |arg| {
 24+            if (std.mem.eql(u8, arg, "--vt")) {
 25+                format = .vt;
 26+            } else if (std.mem.eql(u8, arg, "--html")) {
 27+                format = .html;
 28+            } else if (session_name == null) {
 29+                session_name = arg;
 30+            }
 31+        }
 32+        if (session_name == null) {
 33             return error.SessionNameRequired;
 34-        };
 35-        return history(&cfg, session_name);
 36+        }
 37+        return history(&cfg, session_name.?, format);
 38     } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 39         const session_name = args.next() orelse {
 40             return error.SessionNameRequired;
 41@@ -395,7 +410,7 @@ fn help() !void {
 42         \\  [d]etach                      Detach all clients from current session (ctrl+\ for current client)
 43         \\  [l]ist                        List active sessions
 44         \\  [k]ill <name>                 Kill a session and all attached clients
 45-        \\  [hi]story <name>              Output session scrollback as plain text
 46+        \\  [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
 47         \\  [v]ersion                     Show version information
 48         \\  [h]elp                        Show this help message
 49         \\
 50@@ -554,7 +569,13 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
 51     try w.interface.flush();
 52 }
 53 
 54-fn history(cfg: *Cfg, session_name: []const u8) !void {
 55+const HistoryFormat = enum(u8) {
 56+    plain = 0,
 57+    vt = 1,
 58+    html = 2,
 59+};
 60+
 61+fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void {
 62     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 63     defer _ = gpa.deinit();
 64     const alloc = gpa.allocator();
 65@@ -577,7 +598,8 @@ fn history(cfg: *Cfg, session_name: []const u8) !void {
 66     };
 67     defer posix.close(result.fd);
 68 
 69-    ipc.send(result.fd, .History, "") catch |err| switch (err) {
 70+    const format_byte = [_]u8{@intFromEnum(format)};
 71+    ipc.send(result.fd, .History, &format_byte) catch |err| switch (err) {
 72         error.BrokenPipe, error.ConnectionResetByPeer => return,
 73         else => return err,
 74     };
 75@@ -1127,7 +1149,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
 76                             break :clients_loop;
 77                         },
 78                         .Info => try daemon.handleInfo(client),
 79-                        .History => try daemon.handleHistory(client, &term),
 80+                        .History => try daemon.handleHistory(client, &term, msg.payload),
 81                         .Run => try daemon.handleRun(client, pty_fd, msg.payload),
 82                         .Output, .Ack => {},
 83                     }
 84@@ -1374,16 +1396,33 @@ fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal)
 85     };
 86 }
 87 
 88-fn serializeTerminalPlainText(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
 89+fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, format: HistoryFormat) ?[]const u8 {
 90     var builder: std.Io.Writer.Allocating = .init(alloc);
 91     defer builder.deinit();
 92 
 93-    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .plain);
 94+    const opts: ghostty_vt.formatter.Options = switch (format) {
 95+        .plain => .plain,
 96+        .vt => .vt,
 97+        .html => .html,
 98+    };
 99+    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
100     term_formatter.content = .{ .selection = null };
101-    term_formatter.extra = .none;
102+    term_formatter.extra = switch (format) {
103+        .plain => .none,
104+        .vt => .{
105+            .palette = false,
106+            .modes = true,
107+            .scrolling_region = true,
108+            .tabstops = false,
109+            .pwd = true,
110+            .keyboard = true,
111+            .screen = .all,
112+        },
113+        .html => .styles,
114+    };
115 
116     term_formatter.format(&builder.writer) catch |err| {
117-        std.log.warn("failed to format terminal plain text err={s}", .{@errorName(err)});
118+        std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
119         return null;
120     };
121 
122@@ -1391,7 +1430,9 @@ fn serializeTerminalPlainText(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
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+        std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
128         return null;
129     };
130 }
131+
132+