Commit 20c0d5e

Mohamad Yahia  ·  2026-09-06 02:40:50 -0400 EDT
parent 85353f8
feat(history): capture the active screen with bounded scrollback
6 files changed,  +158, -12
+11, -0
......@@ -121,6 +121,17 @@ Commands:
121121 [h]elp Show this help
122122 ```
123123
124+### screen and scrollback capture
125+
126+`zmx history --screen <name>` prints only the active screen, and
127+`zmx history --scrollback N <name>` adds up to `N` rows of scrollback above it.
128+Both work with `--vt` and `--html`. The daemon selects the rows before
129+formatting them, so a tool that polls a session's current state no longer
130+receives and discards the whole history. Alternate screens have no scrollback,
131+so they always return just the screen. Sessions whose daemon predates this
132+feature keep serving plain `history`; the scoped flags report that the daemon
133+is too old.
134+
124135 ## nested sessions
125136
126137 Nested sessions are not supported. Inside a session `ZMX_SESSION` is set, and `attach` reads it: instead of creating another client it switches the calling terminal to the session you named.
+13, -3
......@@ -43,10 +43,14 @@ const bash_completions =
4343 \\ fi
4444 \\
4545 \\ case "$prev" in
46- \\ attach|run|send|print|write|kill|history|get|set|clear|print-env|wait|tail)
46+ \\ attach|run|send|print|write|kill|get|set|clear|print-env|wait|tail)
4747 \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
4848 \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur"))
4949 \\ ;;
50+ \\ history)
51+ \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
52+ \\ COMPREPLY=($(compgen -W "--screen --scrollback --vt --html $sessions" -- "$cur"))
53+ \\ ;;
5054 \\ completions)
5155 \\ COMPREPLY=($(compgen -W "bash zsh fish nu yash" -- "$cur"))
5256 \\ ;;
......@@ -100,8 +104,12 @@ const zsh_completions =
100104 \\ ;;
101105 \\ args)
102106 \\ case $words[2] in
103- \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|print-env|hi|wait|w|tail|t)
107+ \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|get|g|set|clear|print-env|wait|w|tail|t)
108+ \\ _zmx_sessions
109+ \\ ;;
110+ \\ history|hi)
104111 \\ _zmx_sessions
112+ \\ _values 'options' '--screen' '--scrollback' '--vt' '--html'
105113 \\ ;;
106114 \\ completions|c)
107115 \\ _values 'shell' 'bash' 'zsh' 'fish' 'nu' 'yash'
......@@ -172,6 +180,8 @@ const fish_completions =
172180 \\complete -c zmx -n "__fish_seen_subcommand_from k kill" -l force -d 'Force kill'
173181 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l vt -d 'History format for escape sequences'
174182 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l html -d 'History format for escape sequences'
183+ \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l screen -d 'Output only the active screen'
184+ \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l scrollback -d 'Rows of scrollback to add above the screen' -r
175185 \\complete -c zmx -n "__fish_seen_subcommand_from print-env" -s s -l shell -d 'Output POSIX export/unset commands for eval'
176186 ;
177187
......@@ -219,7 +229,7 @@ const nu_completions =
219229 \\
220230 \\export extern "zmx detach" []
221231 \\export extern "zmx list" [--short]
222- \\export extern "zmx history" [name: string@"nu-complete zmx sessions", --vt, --html]
232+ \\export extern "zmx history" [name: string@"nu-complete zmx sessions", --vt, --html, --screen, --scrollback: int]
223233 \\export extern "zmx wait" [...sessions: string@"nu-complete zmx sessions"]
224234 \\export extern "zmx tail" [...sessions: string@"nu-complete zmx sessions"]
225235 \\export extern "zmx version" []
+13, -1
......@@ -26,6 +26,9 @@ pub const Tag = enum(u8) {
2626 EnvGet = 19,
2727 EnvSet = 20,
2828 EnvData = 21,
29+ /// Scoped history: the active screen plus a bounded number of
30+ /// preceding scrollback rows. Request payload is `Capture`.
31+ Capture = 22,
2932 // Non-exhaustive: this enum comes off the wire via bytesToValue and
3033 // @enumFromInt, so out-of-range values are representable
3134 // rather than UB. Switches must handle `_` (unknown tag).
......@@ -54,6 +57,14 @@ pub const Resize = packed struct {
5457 }
5558 };
5659
60+/// Request payload for `Tag.Capture`. `format` is a `util.HistoryFormat`
61+/// and `rows` is how many scrollback rows to include above the active
62+/// screen; zero returns only the screen.
63+pub const Capture = packed struct {
64+ format: u8,
65+ rows: u32,
66+};
67+
5768 pub fn getTerminalSize(fd: i32) Resize {
5869 var ws: cross.c.struct_winsize = undefined;
5970 if (cross.c.ioctl(fd, cross.c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) {
......@@ -344,7 +355,8 @@ test "Tag wire values are frozen" {
344355 .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 },
345356 .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 },
346357 .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 }, .{ Tag.LabelData, 17 },
347- .{ Tag.Send, 18 },
358+ .{ Tag.Send, 18 }, .{ Tag.EnvGet, 19 }, .{ Tag.EnvSet, 20 },
359+ .{ Tag.EnvData, 21 }, .{ Tag.Capture, 22 },
348360 }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0]));
349361 }
350362
+18, -0
......@@ -490,6 +490,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
490490 .EnvGet => try daemon.handleEnvGet(gpa, client),
491491 .EnvSet => try daemon.handleEnvSet(gpa, client, msg.payload),
492492 .History => try daemon.handleHistory(gpa, client, &term, msg.payload),
493+ .Capture => try daemon.handleCapture(gpa, client, &term, msg.payload),
493494 .Run => try daemon.handleRun(gpa, io, client, msg.payload),
494495 .Ack, .TaskComplete, .LabelData, .EnvData => {},
495496 .Write => try daemon.handleWrite(gpa, client, msg.payload),
......@@ -1147,6 +1148,23 @@ pub const Daemon = struct {
11471148 }
11481149 }
11491150
1151+ pub fn handleCapture(
1152+ self: *Daemon,
1153+ gpa: std.mem.Allocator,
1154+ client: *Client,
1155+ term: *ghostty_vt.Terminal,
1156+ payload: []const u8,
1157+ ) !void {
1158+ if (payload.len != @sizeOf(ipc.Capture)) return;
1159+ const request = std.mem.bytesToValue(ipc.Capture, payload);
1160+ const format = std.enums.fromInt(util.HistoryFormat, request.format) orelse return;
1161+ self.setPwd(term);
1162+ const output = util.serializeTerminalRange(gpa, term, format, request.rows);
1163+ defer if (output) |text| gpa.free(text);
1164+ try ipc.appendMessage(gpa, &client.write_buf, .Capture, output orelse "");
1165+ client.has_pending_output = true;
1166+ }
1167+
11501168 pub fn handleRun(self: *Daemon, gpa: std.mem.Allocator, io: std.Io, client: *Client, payload: []const u8) !void {
11511169 // Reset task tracking so the new command's exit marker is detected.
11521170 // Without this, a second `zmx run` on the same session is ignored
+43, -8
......@@ -128,6 +128,7 @@ pub fn main(init: std.process.Init) !void {
128128 } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
129129 var session_name: ?[]const u8 = null;
130130 var format: util.HistoryFormat = .plain;
131+ var scrollback_rows: ?u32 = null;
131132 while (args.next()) |arg| {
132133 if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
133134 return help(io);
......@@ -135,6 +136,13 @@ pub fn main(init: std.process.Init) !void {
135136 format = .vt;
136137 } else if (std.mem.eql(u8, arg, "--html")) {
137138 format = .html;
139+ } else if (std.mem.eql(u8, arg, "--screen")) {
140+ if (scrollback_rows != null) return printError(io, "--screen and --scrollback may only be specified once", .{});
141+ scrollback_rows = 0;
142+ } else if (std.mem.eql(u8, arg, "--scrollback")) {
143+ if (scrollback_rows != null) return printError(io, "--screen and --scrollback may only be specified once", .{});
144+ const count = args.next() orelse return printError(io, "--scrollback requires a row count", .{});
145+ scrollback_rows = std.fmt.parseInt(u32, count, 10) catch return printError(io, "invalid scrollback row count: {s}", .{count});
138146 } else if (session_name == null) {
139147 session_name = arg;
140148 }
......@@ -145,7 +153,7 @@ pub fn main(init: std.process.Init) !void {
145153 });
146154 const sesh = try socket.getSeshName(gpa, raw_name);
147155 defer gpa.free(sesh);
148- return history(gpa, io, &cfg, sesh, format);
156+ return history(gpa, io, &cfg, sesh, format, scrollback_rows);
149157 } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
150158 var attach_args: std.ArrayList([]const u8) = .empty;
151159 defer attach_args.deinit(gpa);
......@@ -545,8 +553,14 @@ fn help(io: std.Io) !void {
545553 \\ This should generally be used with `tail` to print the last lines
546554 \\ of the session's scrollback history.
547555 \\
556+ \\ --screen outputs only the active screen, and --scrollback N adds up
557+ \\ to N rows of scrollback above it, so pollers can skip formatting
558+ \\ history they would discard. Both work with --vt and --html.
559+ \\
548560 \\ Examples:
549561 \\ zmx history <session> | tail -100
562+ \\ zmx history --screen <session>
563+ \\ zmx history --scrollback 512 <session>
550564 \\
551565 \\Run:
552566 \\ Commands run inside a PTY using bash
......@@ -1437,7 +1451,7 @@ fn fetchHistory(
14371451 return error.NoHistoryResponse;
14381452 }
14391453
1440-fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void {
1454+fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat, scrollback_rows: ?u32) !void {
14411455 std.log.info("history session={s}", .{session_name});
14421456
14431457 const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
......@@ -1460,11 +1474,29 @@ fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []cons
14601474 };
14611475 defer lib_posix.close(fd);
14621476
1463- const format_byte = [_]u8{@intFromEnum(format)};
1464- ipc.send(fd, .History, &format_byte) catch |err| switch (err) {
1465- error.BrokenPipe, error.ConnectionResetByPeer => return,
1466- else => return err,
1467- };
1477+ const reply_tag: ipc.Tag = if (scrollback_rows != null) .Capture else .History;
1478+ if (scrollback_rows) |rows| {
1479+ // zeroes() so asBytes() doesn't ship packed struct padding.
1480+ var request = std.mem.zeroes(ipc.Capture);
1481+ request.format = @intFromEnum(format);
1482+ request.rows = rows;
1483+ ipc.send(fd, .Capture, std.mem.asBytes(&request)) catch |err| switch (err) {
1484+ error.BrokenPipe, error.ConnectionResetByPeer => return,
1485+ else => return err,
1486+ };
1487+ // Requests are handled in connection order. Info is a compatibility
1488+ // barrier: old daemons ignore Capture but still answer Info.
1489+ ipc.send(fd, .Info, "") catch |err| switch (err) {
1490+ error.BrokenPipe, error.ConnectionResetByPeer => return,
1491+ else => return err,
1492+ };
1493+ } else {
1494+ const format_byte = [_]u8{@intFromEnum(format)};
1495+ ipc.send(fd, .History, &format_byte) catch |err| switch (err) {
1496+ error.BrokenPipe, error.ConnectionResetByPeer => return,
1497+ else => return err,
1498+ };
1499+ }
14681500
14691501 var sb = try ipc.SocketBuffer.init(alloc);
14701502 defer sb.deinit();
......@@ -1481,10 +1513,13 @@ fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []cons
14811513 if (n == 0) return;
14821514
14831515 while (sb.next()) |msg| {
1484- if (msg.header.tag == .History) {
1516+ if (msg.header.tag == reply_tag) {
14851517 _ = lib_posix.write(lib_posix.STDOUT_FILENO, msg.payload) catch return;
14861518 return;
14871519 }
1520+ if (scrollback_rows != null and msg.header.tag == .Info) {
1521+ return printError(io, "session \"{s}\" does not support --screen/--scrollback (daemon too old?)", .{session_name});
1522+ }
14881523 }
14891524 }
14901525 }
+60, -0
......@@ -921,6 +921,18 @@ pub fn serializeTerminal(
921921 alloc: std.mem.Allocator,
922922 term: *ghostty_vt.Terminal,
923923 format: HistoryFormat,
924+) ?[]const u8 {
925+ return serializeTerminalRange(alloc, term, format, null);
926+}
927+
928+/// Formats the active screen and at most `scrollback_rows` preceding physical
929+/// rows. Null preserves whole-history formatting. Alternate screens have no
930+/// scrollback, so their selection always stays inside the current grid.
931+pub fn serializeTerminalRange(
932+ alloc: std.mem.Allocator,
933+ term: *ghostty_vt.Terminal,
934+ format: HistoryFormat,
935+ scrollback_rows: ?u32,
924936 ) ?[]const u8 {
925937 var builder: std.Io.Writer.Allocating = .init(alloc);
926938 defer builder.deinit();
......@@ -932,6 +944,18 @@ pub fn serializeTerminal(
932944 };
933945 var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
934946 term_formatter.content = .{ .selection = null };
947+ if (scrollback_rows) |rows| {
948+ const pages = &term.screens.active.pages;
949+ const active_top = pages.getTopLeft(.active);
950+ const top = active_top.up(rows) orelse pages.getTopLeft(.screen);
951+ const bottom = pages.pin(.{ .active = .{
952+ .x = @intCast(pages.cols - 1),
953+ .y = @intCast(pages.rows - 1),
954+ } }) orelse return null;
955+ term_formatter.content = .{
956+ .selection = ghostty_vt.Selection.init(top, bottom, false),
957+ };
958+ }
935959 term_formatter.extra = switch (format) {
936960 .plain => .none,
937961 .vt => .{
......@@ -1709,6 +1733,42 @@ test "serializeTerminal vt replays the pwd without a NUL sentinel" {
17091733 try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
17101734 }
17111735
1736+test "serializeTerminalRange screen only returns the active screen" {
1737+ const alloc = testing.allocator;
1738+ var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
1739+ defer term.deinit(alloc);
1740+
1741+ const output = serializeTerminalRange(alloc, &term, .plain, 0) orelse return error.TestUnexpectedNull;
1742+ defer alloc.free(output);
1743+ try testing.expectEqualStrings("3\n4\n5", output);
1744+}
1745+
1746+test "serializeTerminalRange adds the requested scrollback rows" {
1747+ const alloc = testing.allocator;
1748+ var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
1749+ defer term.deinit(alloc);
1750+
1751+ const output = serializeTerminalRange(alloc, &term, .plain, 1) orelse return error.TestUnexpectedNull;
1752+ defer alloc.free(output);
1753+ try testing.expectEqualStrings("2\n3\n4\n5", output);
1754+
1755+ const all = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
1756+ defer alloc.free(all);
1757+ const full = serializeTerminal(alloc, &term, .plain) orelse return error.TestUnexpectedNull;
1758+ defer alloc.free(full);
1759+ try testing.expectEqualStrings(full, all);
1760+}
1761+
1762+test "serializeTerminalRange alternate screen returns only its grid" {
1763+ const alloc = testing.allocator;
1764+ var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5\x1b[?1049h\x1b[Halt");
1765+ defer term.deinit(alloc);
1766+
1767+ const output = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
1768+ defer alloc.free(output);
1769+ try testing.expectEqualStrings("alt", output);
1770+}
1771+
17121772 fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
17131773 var term = try ghostty_vt.Terminal.init(io, alloc, .{
17141774 .cols = cols,