- 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
+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
+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 ```
+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 {
+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;