- commit
- b471f1a
- parent
- 31205d2
- author
- Eric Bower
- date
- 2025-11-27 11:33:54 -0500 EST
feat: render terminal snapshot to client on re-attach This change uses libghostty-vt to restore the previous state of the terminal when a client re-attaches to a session. How it works: - user creates session `zmx attach term` - user interacts with terminal stdin - stdin gets sent to pty via daemon - daemon sends pty output to client *and* ghostty-vt - ghostty-vt holds terminal state and scrollback - user disconnects - user re-attaches to session - ghostty-vt sends terminal snapshot to client stdout In this way, ghostty-vt doesn't sit in the middle of an active terminal session, it simply receives all the same data the client receives so it can re-hydrate clients that connect to the session.
3 files changed,
+67,
-34
+2,
-2
1@@ -33,8 +33,8 @@
2 // internet connectivity.
3 .dependencies = .{
4 .ghostty = .{
5- .url = "git+https://github.com/ghostty-org/ghostty.git?ref=HEAD#42a38ff672fe0cbbb8588380058c91ac16ed9069",
6- .hash = "ghostty-1.2.1-5UdBC-7EVAMgwHLhtsdeH4rgcZ7WlagXZ1QXN9bRKJ5s",
7+ .url = "git+https://github.com/ghostty-org/ghostty.git?ref=HEAD#4ff0e0c9d251ddd0687ead578a90d58d63f9840b",
8+ .hash = "ghostty-1.3.0-dev-5UdBC1jbPQQiKIuCh-CDmtqsuAVn7HUDNjkzKjt9H_aX",
9 },
10 },
11 .paths = .{
+1,
-0
1@@ -9,6 +9,7 @@ pub const Tag = enum(u8) {
2 DetachAll = 4,
3 Kill = 5,
4 Info = 6,
5+ Init = 7,
6 };
7
8 pub const Header = packed struct {
+64,
-32
1@@ -1,6 +1,7 @@
2 const std = @import("std");
3 const posix = std.posix;
4 const builtin = @import("builtin");
5+const ghostty_vt = @import("ghostty-vt");
6 const ipc = @import("ipc.zig");
7 const log = @import("log.zig");
8
9@@ -158,6 +159,10 @@ pub fn main() !void {
10 }
11
12 const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
13+ var command: ?[][]const u8 = null;
14+ if (command_args.items.len > 0) {
15+ command = command_args.items;
16+ }
17 var daemon = Daemon{
18 .running = true,
19 .cfg = &cfg,
20@@ -166,7 +171,7 @@ pub fn main() !void {
21 .session_name = session_name,
22 .socket_path = undefined,
23 .pid = undefined,
24- .command = if (command_args.items.len > 0) command_args.items else null,
25+ .command = command,
26 };
27 daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
28 std.log.info("socket path={s}", .{daemon.socket_path});
29@@ -420,10 +425,9 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
30
31 setupSigwinchHandler();
32
33- // Send initial terminal size
34- if (getTerminalSize()) |size| {
35- ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&size)) catch {};
36- }
37+ // Send init message with terminal size
38+ const size = getTerminalSize(posix.STDOUT_FILENO);
39+ ipc.send(client_sock_fd, .Init, std.mem.asBytes(&size)) catch {};
40
41 var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 2);
42 defer poll_fds.deinit(alloc);
43@@ -443,12 +447,11 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
44 while (true) {
45 // Check for pending SIGWINCH
46 if (sigwinch_received.swap(false, .acq_rel)) {
47- if (getTerminalSize()) |size| {
48- ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&size)) catch |err| switch (err) {
49- error.BrokenPipe, error.ConnectionResetByPeer => return,
50- else => return err,
51- };
52- }
53+ const next_size = getTerminalSize(posix.STDOUT_FILENO);
54+ ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&next_size)) catch |err| switch (err) {
55+ error.BrokenPipe, error.ConnectionResetByPeer => return,
56+ else => return err,
57+ };
58 }
59
60 poll_fds.clearRetainingCapacity();
61@@ -569,6 +572,15 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
62 var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
63 defer poll_fds.deinit(daemon.alloc);
64
65+ const init_size = getTerminalSize(pty_fd);
66+ var term = try ghostty_vt.Terminal.init(daemon.alloc, .{
67+ .cols = init_size.cols,
68+ .rows = init_size.rows,
69+ });
70+ defer term.deinit(daemon.alloc);
71+ var vt_stream = term.vtStream();
72+ defer vt_stream.deinit();
73+
74 while (!should_exit and daemon.running) {
75 poll_fds.clearRetainingCapacity();
76
77@@ -615,17 +627,6 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
78 client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 4096);
79 try daemon.clients.append(daemon.alloc, client);
80 std.log.info("client connected fd={d} total={d}", .{ client_fd, daemon.clients.items.len });
81-
82- // TODO: Replace this SIGWINCH hack with proper terminal state restoration
83- // using ghostty-vt to maintain a virtual terminal buffer and replay it
84- // to new clients on re-attach.
85- // For now, trigger a SIGWINCH and in-band resize to force applications to redraw.
86- var ws: c.struct_winsize = undefined;
87- if (c.ioctl(pty_fd, c.TIOCGWINSZ, &ws) == 0) {
88- _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
89- }
90- // Send in-band resize notification
91- _ = posix.write(pty_fd, "\x1b[?2048h") catch {};
92 }
93
94 if (poll_fds.items[1].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
95@@ -642,6 +643,9 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
96 std.log.info("shell exited pty_fd={d}", .{pty_fd});
97 should_exit = true;
98 } else {
99+ // Feed PTY output to terminal emulator for state tracking
100+ try vt_stream.nextSlice(buf[0..n]);
101+
102 // Broadcast data to all clients
103 for (daemon.clients.items) |client| {
104 ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| {
105@@ -692,6 +696,37 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
106 _ = try posix.write(pty_fd, msg.payload);
107 }
108 },
109+ .Init => {
110+ if (msg.payload.len == @sizeOf(ipc.Resize)) {
111+ const resize = std.mem.bytesToValue(ipc.Resize, msg.payload);
112+ var ws: c.struct_winsize = .{
113+ .ws_row = resize.rows,
114+ .ws_col = resize.cols,
115+ .ws_xpixel = 0,
116+ .ws_ypixel = 0,
117+ };
118+ _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
119+ try term.resize(daemon.alloc, resize.cols, resize.rows);
120+ std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
121+
122+ // Send terminal state after resize
123+ var builder: std.Io.Writer.Allocating = .init(daemon.alloc);
124+ defer builder.deinit();
125+ var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(&term, .vt);
126+ term_formatter.content = .{ .selection = null };
127+ term_formatter.extra = .all;
128+ term_formatter.format(&builder.writer) catch |err| {
129+ std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
130+ };
131+ const term_output = builder.writer.buffered();
132+ if (term_output.len > 0) {
133+ ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, term_output) catch |err| {
134+ std.log.warn("failed to buffer terminal state for client err={s}", .{@errorName(err)});
135+ };
136+ client.has_pending_output = true;
137+ }
138+ }
139+ },
140 .Resize => {
141 if (msg.payload.len == @sizeOf(ipc.Resize)) {
142 const resize = std.mem.bytesToValue(ipc.Resize, msg.payload);
143@@ -702,6 +737,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
144 .ws_ypixel = 0,
145 };
146 _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
147+ try term.resize(daemon.alloc, resize.cols, resize.rows);
148 std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
149 }
150 },
151@@ -768,14 +804,10 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
152 }
153
154 fn spawnPty(daemon: *Daemon) !c_int {
155- // Get terminal size
156- var orig_ws: c.struct_winsize = undefined;
157- const result = c.ioctl(posix.STDOUT_FILENO, c.TIOCGWINSZ, &orig_ws);
158- const rows: u16 = if (result == 0) orig_ws.ws_row else 24;
159- const cols: u16 = if (result == 0) orig_ws.ws_col else 80;
160+ const size = getTerminalSize(posix.STDOUT_FILENO);
161 var ws: c.struct_winsize = .{
162- .ws_row = rows,
163- .ws_col = cols,
164+ .ws_row = size.rows,
165+ .ws_col = size.cols,
166 .ws_xpixel = 0,
167 .ws_ypixel = 0,
168 };
169@@ -875,10 +907,10 @@ fn setupSigwinchHandler() void {
170 posix.sigaction(posix.SIG.WINCH, &act, null);
171 }
172
173-fn getTerminalSize() ?ipc.Resize {
174+fn getTerminalSize(fd: i32) ipc.Resize {
175 var ws: c.struct_winsize = undefined;
176- if (c.ioctl(posix.STDOUT_FILENO, c.TIOCGWINSZ, &ws) == 0) {
177+ if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0) {
178 return .{ .rows = ws.ws_row, .cols = ws.ws_col };
179 }
180- return null;
181+ return .{ .rows = 24, .cols = 80 };
182 }