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: | |
| 121 | 121 | [h]elp Show this help | |
| 122 | 122 | ``` | |
| 123 | 123 | ||
| 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 | + | ||
| 124 | 135 | ## nested sessions | |
| 125 | 136 | ||
| 126 | 137 | 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 = | |
| 43 | 43 | \\ fi | |
| 44 | 44 | \\ | |
| 45 | 45 | \\ 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) | |
| 47 | 47 | \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ') | |
| 48 | 48 | \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur")) | |
| 49 | 49 | \\ ;; | |
| 50 | + | \\ history) | |
| 51 | + | \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ') | |
| 52 | + | \\ COMPREPLY=($(compgen -W "--screen --scrollback --vt --html $sessions" -- "$cur")) | |
| 53 | + | \\ ;; | |
| 50 | 54 | \\ completions) | |
| 51 | 55 | \\ COMPREPLY=($(compgen -W "bash zsh fish nu yash" -- "$cur")) | |
| 52 | 56 | \\ ;; |
| ... | ... | @@ -100,8 +104,12 @@ const zsh_completions = | |
| 100 | 104 | \\ ;; | |
| 101 | 105 | \\ args) | |
| 102 | 106 | \\ 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) | |
| 104 | 111 | \\ _zmx_sessions | |
| 112 | + | \\ _values 'options' '--screen' '--scrollback' '--vt' '--html' | |
| 105 | 113 | \\ ;; | |
| 106 | 114 | \\ completions|c) | |
| 107 | 115 | \\ _values 'shell' 'bash' 'zsh' 'fish' 'nu' 'yash' |
| ... | ... | @@ -172,6 +180,8 @@ const fish_completions = | |
| 172 | 180 | \\complete -c zmx -n "__fish_seen_subcommand_from k kill" -l force -d 'Force kill' | |
| 173 | 181 | \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l vt -d 'History format for escape sequences' | |
| 174 | 182 | \\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 | |
| 175 | 185 | \\complete -c zmx -n "__fish_seen_subcommand_from print-env" -s s -l shell -d 'Output POSIX export/unset commands for eval' | |
| 176 | 186 | ; | |
| 177 | 187 |
| ... | ... | @@ -219,7 +229,7 @@ const nu_completions = | |
| 219 | 229 | \\ | |
| 220 | 230 | \\export extern "zmx detach" [] | |
| 221 | 231 | \\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] | |
| 223 | 233 | \\export extern "zmx wait" [...sessions: string@"nu-complete zmx sessions"] | |
| 224 | 234 | \\export extern "zmx tail" [...sessions: string@"nu-complete zmx sessions"] | |
| 225 | 235 | \\export extern "zmx version" [] |
+13,
-1
| ... | ... | @@ -26,6 +26,9 @@ pub const Tag = enum(u8) { | |
| 26 | 26 | EnvGet = 19, | |
| 27 | 27 | EnvSet = 20, | |
| 28 | 28 | EnvData = 21, | |
| 29 | + | /// Scoped history: the active screen plus a bounded number of | |
| 30 | + | /// preceding scrollback rows. Request payload is `Capture`. | |
| 31 | + | Capture = 22, | |
| 29 | 32 | // Non-exhaustive: this enum comes off the wire via bytesToValue and | |
| 30 | 33 | // @enumFromInt, so out-of-range values are representable | |
| 31 | 34 | // rather than UB. Switches must handle `_` (unknown tag). |
| ... | ... | @@ -54,6 +57,14 @@ pub const Resize = packed struct { | |
| 54 | 57 | } | |
| 55 | 58 | }; | |
| 56 | 59 | ||
| 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 | + | ||
| 57 | 68 | pub fn getTerminalSize(fd: i32) Resize { | |
| 58 | 69 | var ws: cross.c.struct_winsize = undefined; | |
| 59 | 70 | 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" { | |
| 344 | 355 | .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 }, | |
| 345 | 356 | .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 }, | |
| 346 | 357 | .{ 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 }, | |
| 348 | 360 | }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0])); | |
| 349 | 361 | } | |
| 350 | 362 |
+18,
-0
| ... | ... | @@ -490,6 +490,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 490 | 490 | .EnvGet => try daemon.handleEnvGet(gpa, client), | |
| 491 | 491 | .EnvSet => try daemon.handleEnvSet(gpa, client, msg.payload), | |
| 492 | 492 | .History => try daemon.handleHistory(gpa, client, &term, msg.payload), | |
| 493 | + | .Capture => try daemon.handleCapture(gpa, client, &term, msg.payload), | |
| 493 | 494 | .Run => try daemon.handleRun(gpa, io, client, msg.payload), | |
| 494 | 495 | .Ack, .TaskComplete, .LabelData, .EnvData => {}, | |
| 495 | 496 | .Write => try daemon.handleWrite(gpa, client, msg.payload), |
| ... | ... | @@ -1147,6 +1148,23 @@ pub const Daemon = struct { | |
| 1147 | 1148 | } | |
| 1148 | 1149 | } | |
| 1149 | 1150 | ||
| 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 | + | ||
| 1150 | 1168 | pub fn handleRun(self: *Daemon, gpa: std.mem.Allocator, io: std.Io, client: *Client, payload: []const u8) !void { | |
| 1151 | 1169 | // Reset task tracking so the new command's exit marker is detected. | |
| 1152 | 1170 | // 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 { | |
| 128 | 128 | } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) { | |
| 129 | 129 | var session_name: ?[]const u8 = null; | |
| 130 | 130 | var format: util.HistoryFormat = .plain; | |
| 131 | + | var scrollback_rows: ?u32 = null; | |
| 131 | 132 | while (args.next()) |arg| { | |
| 132 | 133 | if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { | |
| 133 | 134 | return help(io); |
| ... | ... | @@ -135,6 +136,13 @@ pub fn main(init: std.process.Init) !void { | |
| 135 | 136 | format = .vt; | |
| 136 | 137 | } else if (std.mem.eql(u8, arg, "--html")) { | |
| 137 | 138 | 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}); | |
| 138 | 146 | } else if (session_name == null) { | |
| 139 | 147 | session_name = arg; | |
| 140 | 148 | } |
| ... | ... | @@ -145,7 +153,7 @@ pub fn main(init: std.process.Init) !void { | |
| 145 | 153 | }); | |
| 146 | 154 | const sesh = try socket.getSeshName(gpa, raw_name); | |
| 147 | 155 | defer gpa.free(sesh); | |
| 148 | - | return history(gpa, io, &cfg, sesh, format); | |
| 156 | + | return history(gpa, io, &cfg, sesh, format, scrollback_rows); | |
| 149 | 157 | } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) { | |
| 150 | 158 | var attach_args: std.ArrayList([]const u8) = .empty; | |
| 151 | 159 | defer attach_args.deinit(gpa); |
| ... | ... | @@ -545,8 +553,14 @@ fn help(io: std.Io) !void { | |
| 545 | 553 | \\ This should generally be used with `tail` to print the last lines | |
| 546 | 554 | \\ of the session's scrollback history. | |
| 547 | 555 | \\ | |
| 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 | + | \\ | |
| 548 | 560 | \\ Examples: | |
| 549 | 561 | \\ zmx history <session> | tail -100 | |
| 562 | + | \\ zmx history --screen <session> | |
| 563 | + | \\ zmx history --scrollback 512 <session> | |
| 550 | 564 | \\ | |
| 551 | 565 | \\Run: | |
| 552 | 566 | \\ Commands run inside a PTY using bash |
| ... | ... | @@ -1437,7 +1451,7 @@ fn fetchHistory( | |
| 1437 | 1451 | return error.NoHistoryResponse; | |
| 1438 | 1452 | } | |
| 1439 | 1453 | ||
| 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 { | |
| 1441 | 1455 | std.log.info("history session={s}", .{session_name}); | |
| 1442 | 1456 | ||
| 1443 | 1457 | 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 | |
| 1460 | 1474 | }; | |
| 1461 | 1475 | defer lib_posix.close(fd); | |
| 1462 | 1476 | ||
| 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 | + | } | |
| 1468 | 1500 | ||
| 1469 | 1501 | var sb = try ipc.SocketBuffer.init(alloc); | |
| 1470 | 1502 | defer sb.deinit(); |
| ... | ... | @@ -1481,10 +1513,13 @@ fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []cons | |
| 1481 | 1513 | if (n == 0) return; | |
| 1482 | 1514 | ||
| 1483 | 1515 | while (sb.next()) |msg| { | |
| 1484 | - | if (msg.header.tag == .History) { | |
| 1516 | + | if (msg.header.tag == reply_tag) { | |
| 1485 | 1517 | _ = lib_posix.write(lib_posix.STDOUT_FILENO, msg.payload) catch return; | |
| 1486 | 1518 | return; | |
| 1487 | 1519 | } | |
| 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 | + | } | |
| 1488 | 1523 | } | |
| 1489 | 1524 | } | |
| 1490 | 1525 | } |
+60,
-0
| ... | ... | @@ -921,6 +921,18 @@ pub fn serializeTerminal( | |
| 921 | 921 | alloc: std.mem.Allocator, | |
| 922 | 922 | term: *ghostty_vt.Terminal, | |
| 923 | 923 | 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, | |
| 924 | 936 | ) ?[]const u8 { | |
| 925 | 937 | var builder: std.Io.Writer.Allocating = .init(alloc); | |
| 926 | 938 | defer builder.deinit(); |
| ... | ... | @@ -932,6 +944,18 @@ pub fn serializeTerminal( | |
| 932 | 944 | }; | |
| 933 | 945 | var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts); | |
| 934 | 946 | 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 | + | } | |
| 935 | 959 | term_formatter.extra = switch (format) { | |
| 936 | 960 | .plain => .none, | |
| 937 | 961 | .vt => .{ |
| ... | ... | @@ -1709,6 +1733,42 @@ test "serializeTerminal vt replays the pwd without a NUL sentinel" { | |
| 1709 | 1733 | try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0)); | |
| 1710 | 1734 | } | |
| 1711 | 1735 | ||
| 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 | + | ||
| 1712 | 1772 | fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal { | |
| 1713 | 1773 | var term = try ghostty_vt.Terminal.init(io, alloc, .{ | |
| 1714 | 1774 | .cols = cols, |