- commit
- 89393db
- parent
- 50d7e24
- author
- Eric Bower
- date
- 2025-10-10 20:55:54 -0400 EDT
fix: ghostty impl
5 files changed,
+568,
-55
+1,
-0
1@@ -8,3 +8,4 @@ commit_msg
2 *.sw?
3 libxev_src/
4 zig_std_src/
5+ghostty_src/
+4,
-0
1@@ -26,6 +26,10 @@ To inspect the source code for libxev, look inside the `libxev_src` folder.
2
3 To inspect the source code for zig's standard library, look inside the `zig_std_src` folder.
4
5+## finding ghostty library source code
6+
7+To inspect the source code for zig's standard library, look inside the `ghostty_src` folder.
8+
9 ### prior art - shpool
10
11 The project that most closely resembles `shpool`.
+292,
-0
1@@ -0,0 +1,292 @@
2+# Session Restore Implementation Plan
3+
4+This document outlines the plan for implementing session restore functionality in `daemon.zig` using `libghostty-vt` to preserve and restore terminal state when clients reattach to sessions.
5+
6+## Overview
7+
8+When a client detaches and later reattaches to a session, we need to restore the terminal to its exact visual state without replaying all historical output. We achieve this by:
9+1. Parsing all PTY output through libghostty-vt to maintain an up-to-date terminal grid
10+2. Proxying raw bytes to attached clients (no latency impact)
11+3. Rendering the terminal grid to ANSI on reattach
12+
13+## 1. Add libghostty-vt Dependency
14+
15+- Add libghostty-vt to `build.zig` and `build.zig.zon`
16+- Import the C bindings in `daemon.zig`
17+- Document the library's memory model and API surface
18+
19+## 2. Extend the Session Struct
20+
21+Add to `Session` struct in `daemon.zig`:
22+```zig
23+const Session = struct {
24+ name: []const u8,
25+ pty_master_fd: std.posix.fd_t,
26+ buffer: std.ArrayList(u8), // Keep for backwards compat (may remove later)
27+ child_pid: std.posix.pid_t,
28+ allocator: std.mem.Allocator,
29+ pty_read_buffer: [4096]u8,
30+ created_at: i64,
31+
32+ // NEW: Terminal emulator state
33+ vt: *c.ghostty_vt_t, // libghostty-vt terminal instance
34+ vt_grid: *c.ghostty_grid_t, // Current terminal grid snapshot
35+ attached_clients: std.AutoHashMap(std.posix.fd_t, void), // Track who's attached
36+};
37+```
38+
39+## 3. Initialize Terminal Emulator on Session Creation
40+
41+In `createSession()`:
42+- After forking PTY, initialize libghostty-vt instance
43+- Configure terminal size (rows, cols) - query from PTY or use defaults (e.g., 24x80)
44+- Configure scrollback buffer size (make this configurable, default 10,000 lines)
45+- Store the vt instance in the Session struct
46+
47+```zig
48+fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Session {
49+ // ... existing PTY creation code ...
50+
51+ // Initialize libghostty-vt
52+ const vt = c.ghostty_vt_new(80, 24, 10000) orelse return error.VtInitFailed;
53+
54+ session.* = .{
55+ // ... existing fields ...
56+ .vt = vt,
57+ .vt_grid = null, // Will be obtained from vt as needed
58+ .attached_clients = std.AutoHashMap(std.posix.fd_t, void).init(allocator),
59+ };
60+
61+ return session;
62+}
63+```
64+
65+## 4. Parse PTY Output Through Terminal Emulator
66+
67+Modify `readPtyCallback()`:
68+- Feed all PTY output bytes to libghostty-vt first
69+- Check if there are attached clients
70+- If clients attached: proxy raw bytes directly to them (existing behavior)
71+- If no clients attached: still feed to vt but don't send anywhere
72+
73+```zig
74+fn readPtyCallback(...) xev.CallbackAction {
75+ const client = client_opt.?;
76+ const session = getSessionForClient(client) orelse return .disarm;
77+
78+ if (read_result) |bytes_read| {
79+ const data = read_buffer.slice[0..bytes_read];
80+
81+ // ALWAYS parse through libghostty-vt to maintain state
82+ c.ghostty_vt_write(session.vt, data.ptr, data.len);
83+
84+ // Only proxy to clients if someone is attached
85+ if (session.attached_clients.count() > 0) {
86+ // Send raw bytes to all attached clients
87+ var it = session.attached_clients.keyIterator();
88+ while (it.next()) |client_fd| {
89+ const attached_client = ctx.clients.get(client_fd.*) orelse continue;
90+ sendPtyOutput(attached_client, data) catch |err| {
91+ std.debug.print("Error sending to client {d}: {s}\n", .{client_fd.*, @errorName(err)});
92+ };
93+ }
94+ }
95+
96+ return .rearm;
97+ }
98+ // ... error handling ...
99+}
100+```
101+
102+## 5. Render Terminal State on Reattach
103+
104+Create new function `renderTerminalSnapshot()`:
105+- Get current grid from libghostty-vt
106+- Serialize grid to ANSI escape sequences
107+- Send rendered output to reattaching client
108+
109+```zig
110+fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8 {
111+ // Get current terminal snapshot from libghostty-vt
112+ const grid = c.ghostty_vt_get_grid(session.vt);
113+
114+ var output = std.ArrayList(u8).init(allocator);
115+ errdefer output.deinit();
116+
117+ // Clear screen and move to home
118+ try output.appendSlice("\x1b[2J\x1b[H");
119+
120+ // Render each line of the grid
121+ const rows = c.ghostty_grid_rows(grid);
122+ const cols = c.ghostty_grid_cols(grid);
123+
124+ var row: usize = 0;
125+ while (row < rows) : (row += 1) {
126+ // Get line data from libghostty-vt
127+ const line = c.ghostty_grid_get_line(grid, row);
128+
129+ // Render cells with proper attributes (colors, bold, etc.)
130+ var col: usize = 0;
131+ while (col < cols) : (col += 1) {
132+ const cell = c.ghostty_line_get_cell(line, col);
133+
134+ // Emit SGR codes for cell attributes
135+ try renderCellAttributes(&output, cell);
136+
137+ // Emit the character
138+ const codepoint = c.ghostty_cell_get_codepoint(cell);
139+ try appendUtf8(&output, codepoint);
140+ }
141+
142+ try output.append('\n');
143+ }
144+
145+ // Reset attributes
146+ try output.appendSlice("\x1b[0m");
147+
148+ return output.toOwnedSlice();
149+}
150+```
151+
152+## 6. Modify handleAttachSession()
153+
154+Update attach logic to:
155+1. Check if session exists, create if not
156+2. If reattaching (session already exists):
157+ - Render current terminal state using libghostty-vt
158+ - Send rendered snapshot to client
159+3. Add client to session's attached_clients set
160+4. Start proxying raw PTY output
161+
162+```zig
163+fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []const u8) !void {
164+ const session = ctx.sessions.get(session_name) orelse {
165+ // New session - create it
166+ const new_session = try createSession(ctx.allocator, session_name);
167+ try ctx.sessions.put(session_name, new_session);
168+ session = new_session;
169+ };
170+
171+ // Mark client as attached
172+ client.attached_session = try ctx.allocator.dupe(u8, session_name);
173+ try session.attached_clients.put(client.fd, {});
174+
175+ // Check if this is a reattach (session already running)
176+ const is_reattach = session.attached_clients.count() > 1 or session.buffer.items.len > 0;
177+
178+ if (is_reattach) {
179+ // Render current terminal state and send it
180+ const snapshot = try renderTerminalSnapshot(session, ctx.allocator);
181+ defer ctx.allocator.free(snapshot);
182+
183+ const response = try std.fmt.allocPrint(
184+ ctx.allocator,
185+ "{{\"type\":\"pty_out\",\"payload\":{{\"text\":\"{s}\"}}}}\n",
186+ .{snapshot},
187+ );
188+ defer ctx.allocator.free(response);
189+
190+ _ = try posix.write(client.fd, response);
191+ }
192+
193+ // Start reading from PTY if not already started
194+ if (!session.pty_reading) {
195+ try startPtyReading(ctx, session);
196+ session.pty_reading = true;
197+ }
198+
199+ // Send attach success response
200+ // ... existing response code ...
201+}
202+```
203+
204+## 7. Handle Window Resize Events
205+
206+Add support for window size changes:
207+- When client sends window resize event, update libghostty-vt
208+- Update PTY window size with ioctl TIOCSWINSZ
209+- libghostty-vt will handle reflow automatically
210+
211+```zig
212+// New message type in protocol: "window_resize"
213+fn handleWindowResize(client: *Client, rows: u16, cols: u16) !void {
214+ const session = getSessionForClient(client) orelse return error.NotAttached;
215+
216+ // Update libghostty-vt
217+ c.ghostty_vt_resize(session.vt, cols, rows);
218+
219+ // Update PTY
220+ var ws: c.winsize = .{
221+ .ws_row = rows,
222+ .ws_col = cols,
223+ .ws_xpixel = 0,
224+ .ws_ypixel = 0,
225+ };
226+ _ = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
227+}
228+```
229+
230+## 8. Track Attached Clients Per Session
231+
232+Modify session management:
233+- Remove client from session.attached_clients on detach
234+- On disconnect, automatically detach client
235+- Keep session alive even when no clients attached
236+
237+```zig
238+fn handleDetachSession(client: *Client, session_name: []const u8, target_client_fd: ?i64) !void {
239+ const session = ctx.sessions.get(session_name) orelse return error.SessionNotFound;
240+
241+ const fd_to_remove = if (target_client_fd) |fd| @intCast(fd) else client.fd;
242+ _ = session.attached_clients.remove(fd_to_remove);
243+
244+ // Note: DO NOT kill session when last client detaches
245+ // Session continues running in background
246+}
247+```
248+
249+## 9. Clean Up Terminal Emulator on Session Destroy
250+
251+In session deinit:
252+- Free libghostty-vt resources
253+- Clean up attached_clients map
254+
255+```zig
256+fn deinit(self: *Session) void {
257+ self.allocator.free(self.name);
258+ self.buffer.deinit();
259+ self.attached_clients.deinit();
260+
261+ // Free libghostty-vt
262+ c.ghostty_vt_free(self.vt);
263+}
264+```
265+
266+## 10. Configuration Options
267+
268+Add configurable options (future work):
269+- Scrollback buffer size
270+- Default terminal dimensions
271+- Maximum grid memory usage
272+
273+## Implementation Order
274+
275+1. ✅ Add libghostty-vt C bindings and build integration
276+2. ✅ Extend Session struct with vt fields
277+3. ✅ Initialize vt in createSession()
278+4. ✅ Feed PTY output to vt in readPtyCallback()
279+5. ✅ Implement renderTerminalSnapshot()
280+6. ✅ Modify handleAttachSession() to render on reattach
281+7. ✅ Track attached_clients per session
282+8. ✅ Handle window resize events
283+9. ✅ Clean up vt resources in session deinit
284+10. ✅ Test with multiple attach/detach cycles
285+
286+## Testing Strategy
287+
288+- Create session, run commands, detach
289+- Verify PTY continues running (ps aux | grep)
290+- Reattach and verify terminal state is restored
291+- Test with various shell outputs: ls, vim, htop, long scrollback
292+- Test multiple clients attaching to same session
293+- Test window resize during detached state
+1,
-0
1@@ -235,6 +235,7 @@ fn readCallback(
2 }
3
4 ctx.allocator.destroy(read_ctx);
5+ ctx.read_ctx = null;
6 return cleanup(ctx, completion);
7 }
8
+270,
-55
1@@ -4,10 +4,13 @@ const xevg = @import("xev");
2 const xev = xevg.Dynamic;
3 const socket_path = "/tmp/zmx.sock";
4
5+const ghostty = @import("ghostty-vt");
6+
7 const c = @cImport({
8 @cInclude("pty.h");
9 @cInclude("utmp.h");
10 @cInclude("stdlib.h");
11+ @cInclude("sys/ioctl.h");
12 });
13
14 // Generic JSON message structure used for parsing incoming protocol messages from clients
15@@ -21,6 +24,21 @@ const AttachRequest = struct {
16 session_name: []const u8,
17 };
18
19+// Handler for processing VT sequences
20+const VTHandler = struct {
21+ terminal: *ghostty.Terminal,
22+
23+ pub fn print(self: *VTHandler, cp: u21) !void {
24+ try self.terminal.print(cp);
25+ }
26+};
27+
28+// Context for PTY read callbacks
29+const PtyReadContext = struct {
30+ session: *Session,
31+ server_ctx: *ServerContext,
32+};
33+
34 // A PTY session that manages a persistent shell process
35 // Stores the PTY master file descriptor, shell process PID, scrollback buffer,
36 // and a read buffer for async I/O with libxev
37@@ -33,9 +51,18 @@ const Session = struct {
38 pty_read_buffer: [4096]u8,
39 created_at: i64,
40
41+ // Terminal emulator state for session restore
42+ vt: ghostty.Terminal,
43+ vt_stream: ghostty.Stream(*VTHandler),
44+ vt_handler: VTHandler,
45+ attached_clients: std.AutoHashMap(std.posix.fd_t, void),
46+
47 fn deinit(self: *Session) void {
48 self.allocator.free(self.name);
49 self.buffer.deinit(self.allocator);
50+ self.vt.deinit(self.allocator);
51+ self.vt_stream.deinit();
52+ self.attached_clients.deinit();
53 }
54 };
55
56@@ -232,6 +259,10 @@ fn handleMessage(client: *Client, data: []const u8) !void {
57 } else if (std.mem.eql(u8, msg_type, "pty_in")) {
58 const text = payload.get("text").?.string;
59 try handlePtyInput(client, text);
60+ } else if (std.mem.eql(u8, msg_type, "window_resize")) {
61+ const rows = @as(u16, @intCast(payload.get("rows").?.integer));
62+ const cols = @as(u16, @intCast(payload.get("cols").?.integer));
63+ try handleWindowResize(client, rows, cols);
64 } else {
65 std.debug.print("Unknown message type: {s}\n", .{msg_type});
66 }
67@@ -241,7 +272,7 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
68 const ctx = client.server_ctx;
69
70 // Check if the session exists
71- if (!ctx.sessions.contains(session_name)) {
72+ const session = ctx.sessions.get(session_name) orelse {
73 const error_response = try std.fmt.allocPrint(
74 client.allocator,
75 "{{\"type\":\"detach_session_response\",\"payload\":{{\"status\":\"error\",\"error_message\":\"Session not found: {s}\"}}}}\n",
76@@ -254,7 +285,7 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
77 return err;
78 };
79 return;
80- }
81+ };
82
83 // If target_client_fd is provided, find and detach that specific client
84 if (target_client_fd) |target_fd| {
85@@ -263,6 +294,7 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
86 if (target_client.attached_session) |attached| {
87 if (std.mem.eql(u8, attached, session_name)) {
88 target_client.attached_session = null;
89+ _ = session.attached_clients.remove(target_fd_cast);
90
91 // Send notification to the target client
92 const notification = "{\"type\":\"detach_notification\",\"payload\":{\"status\":\"ok\"}}\n";
93@@ -328,6 +360,8 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
94 }
95
96 client.attached_session = null;
97+ _ = session.attached_clients.remove(client.fd);
98+
99 const response = "{\"type\":\"detach_session_response\",\"payload\":{\"status\":\"ok\"}}\n";
100 std.debug.print("Sending detach response to client fd={d}: {s}", .{ client.fd, response });
101
102@@ -465,20 +499,69 @@ fn handleListSessions(ctx: *ServerContext, client: *Client) !void {
103
104 fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []const u8) !void {
105 // Check if session already exists
106- if (ctx.sessions.get(session_name)) |session| {
107+ const is_reattach = ctx.sessions.contains(session_name);
108+ const session = if (is_reattach) blk: {
109 std.debug.print("Attaching to existing session: {s}\n", .{session_name});
110- client.attached_session = session.name;
111- try readFromPty(ctx, client, session);
112- // TODO: Send scrollback buffer to client
113- return;
114- }
115+ break :blk ctx.sessions.get(session_name).?;
116+ } else blk: {
117+ // Create new session with forkpty
118+ std.debug.print("Creating new session: {s}\n", .{session_name});
119+ const new_session = try createSession(ctx.allocator, session_name);
120+ try ctx.sessions.put(new_session.name, new_session);
121+ break :blk new_session;
122+ };
123
124- // Create new session with forkpty
125- std.debug.print("Creating new session: {s}\n", .{session_name});
126- const session = try createSession(ctx.allocator, session_name);
127- try ctx.sessions.put(session.name, session);
128+ // Mark client as attached
129 client.attached_session = session.name;
130- try readFromPty(ctx, client, session);
131+ try session.attached_clients.put(client.fd, {});
132+
133+ // If reattaching, send the current terminal state
134+ if (is_reattach and session.attached_clients.count() > 1) {
135+ const snapshot = try renderTerminalSnapshot(session, ctx.allocator);
136+ defer ctx.allocator.free(snapshot);
137+
138+ // Build JSON response with escaped snapshot
139+ var response_buf = try std.ArrayList(u8).initCapacity(ctx.allocator, 4096);
140+ defer response_buf.deinit(ctx.allocator);
141+
142+ try response_buf.appendSlice(ctx.allocator, "{\"type\":\"pty_out\",\"payload\":{\"text\":\"");
143+
144+ // Escape the snapshot for JSON
145+ for (snapshot) |byte| {
146+ switch (byte) {
147+ '"' => try response_buf.appendSlice(ctx.allocator, "\\\""),
148+ '\\' => try response_buf.appendSlice(ctx.allocator, "\\\\"),
149+ '\n' => try response_buf.appendSlice(ctx.allocator, "\\n"),
150+ '\r' => try response_buf.appendSlice(ctx.allocator, "\\r"),
151+ '\t' => try response_buf.appendSlice(ctx.allocator, "\\t"),
152+ 0x08 => try response_buf.appendSlice(ctx.allocator, "\\b"),
153+ 0x0C => try response_buf.appendSlice(ctx.allocator, "\\f"),
154+ 0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F...0xFF => {
155+ const escaped = try std.fmt.allocPrint(ctx.allocator, "\\u{x:0>4}", .{byte});
156+ defer ctx.allocator.free(escaped);
157+ try response_buf.appendSlice(ctx.allocator, escaped);
158+ },
159+ else => try response_buf.append(ctx.allocator, byte),
160+ }
161+ }
162+
163+ try response_buf.appendSlice(ctx.allocator, "\"}}\n");
164+ _ = try posix.write(client.fd, response_buf.items);
165+ }
166+
167+ // Start reading from PTY if not already started (first client)
168+ if (session.attached_clients.count() == 1) {
169+ try readFromPty(ctx, client, session);
170+ } else {
171+ // Send attach success response for additional clients
172+ const response = try std.fmt.allocPrint(
173+ ctx.allocator,
174+ "{{\"type\":\"attach_session_response\",\"payload\":{{\"status\":\"ok\",\"client_fd\":{d}}}}}\n",
175+ .{client.fd},
176+ );
177+ defer ctx.allocator.free(response);
178+ _ = try posix.write(client.fd, response);
179+ }
180 }
181
182 fn handlePtyInput(client: *Client, text: []const u8) !void {
183@@ -502,16 +585,49 @@ fn handlePtyInput(client: *Client, text: []const u8) !void {
184 _ = written;
185 }
186
187+fn handleWindowResize(client: *Client, rows: u16, cols: u16) !void {
188+ const session_name = client.attached_session orelse {
189+ std.debug.print("Client fd={d} not attached to any session\n", .{client.fd});
190+ return error.NotAttached;
191+ };
192+
193+ const session = client.server_ctx.sessions.get(session_name) orelse {
194+ std.debug.print("Session {s} not found\n", .{session_name});
195+ return error.SessionNotFound;
196+ };
197+
198+ std.debug.print("Resizing session {s} to {d}x{d}\n", .{ session_name, cols, rows });
199+
200+ // Update libghostty-vt terminal size
201+ try session.vt.resize(session.allocator, cols, rows);
202+
203+ // Update PTY window size
204+ var ws = c.struct_winsize{
205+ .ws_row = rows,
206+ .ws_col = cols,
207+ .ws_xpixel = 0,
208+ .ws_ypixel = 0,
209+ };
210+ const result = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
211+ if (result < 0) {
212+ return error.IoctlFailed;
213+ }
214+}
215+
216 fn readFromPty(ctx: *ServerContext, client: *Client, session: *Session) !void {
217- _ = ctx;
218 const stream = xev.Stream.initFd(session.pty_master_fd);
219 const read_compl = client.allocator.create(xev.Completion) catch @panic("failed to create completion");
220+ const pty_ctx = client.allocator.create(PtyReadContext) catch @panic("failed to create PTY context");
221+ pty_ctx.* = .{
222+ .session = session,
223+ .server_ctx = ctx,
224+ };
225 stream.read(
226- client.server_ctx.loop,
227+ ctx.loop,
228 read_compl,
229 .{ .slice = &session.pty_read_buffer },
230- Client,
231- client,
232+ PtyReadContext,
233+ pty_ctx,
234 readPtyCallback,
235 );
236
237@@ -531,18 +647,75 @@ fn readFromPty(ctx: *ServerContext, client: *Client, session: *Session) !void {
238 _ = written;
239 }
240
241+fn getSessionForClient(ctx: *ServerContext, client: *Client) ?*Session {
242+ const session_name = client.attached_session orelse return null;
243+ return ctx.sessions.get(session_name);
244+}
245+
246+fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8 {
247+ var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
248+ errdefer output.deinit(allocator);
249+
250+ // Clear screen and move to home
251+ try output.appendSlice(allocator, "\x1b[2J\x1b[H");
252+
253+ // Get the active screen from the terminal
254+ const screen = &session.vt.screen;
255+ const rows = screen.pages.rows;
256+ const cols = screen.pages.cols;
257+
258+ var row: usize = 0;
259+ while (row < rows) : (row += 1) {
260+ var col: usize = 0;
261+ while (col < cols) : (col += 1) {
262+ // Build a point.Point referring to the active (visible) page
263+ const pt: ghostty.point.Point = .{ .active = .{
264+ .x = @as(u16, @intCast(col)),
265+ .y = @as(u16, @intCast(row)),
266+ } };
267+
268+ if (screen.pages.getCell(pt)) |cell_ref| {
269+ const cp = cell_ref.cell.content.codepoint;
270+ if (cp == 0) {
271+ try output.append(allocator, ' ');
272+ } else {
273+ var buf: [4]u8 = undefined;
274+ const len = std.unicode.utf8Encode(cp, &buf) catch 0;
275+ if (len == 0) {
276+ try output.append(allocator, ' ');
277+ } else {
278+ try output.appendSlice(allocator, buf[0..len]);
279+ }
280+ }
281+ } else {
282+ // Outside bounds or no cell => space to preserve width
283+ try output.append(allocator, ' ');
284+ }
285+ }
286+
287+ if (row < rows - 1) {
288+ try output.appendSlice(allocator, "\r\n");
289+ }
290+ }
291+
292+ // Position cursor at correct location (ANSI is 1-based)
293+ const cursor = screen.cursor;
294+ try output.writer(allocator).print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 });
295+
296+ return output.toOwnedSlice(allocator);
297+}
298+
299 fn readPtyCallback(
300- client_opt: ?*Client,
301+ pty_ctx_opt: ?*PtyReadContext,
302 loop: *xev.Loop,
303 completion: *xev.Completion,
304 stream: xev.Stream,
305 read_buffer: xev.ReadBuffer,
306 read_result: xev.ReadError!usize,
307 ) xev.CallbackAction {
308- _ = loop;
309- _ = completion;
310- _ = stream;
311- const client = client_opt.?;
312+ const pty_ctx = pty_ctx_opt.?;
313+ const session = pty_ctx.session;
314+ const ctx = pty_ctx.server_ctx;
315
316 if (read_result) |bytes_read| {
317 if (bytes_read == 0) {
318@@ -553,44 +726,62 @@ fn readPtyCallback(
319 const data = read_buffer.slice[0..bytes_read];
320 std.debug.print("PTY output ({d} bytes)\n", .{bytes_read});
321
322- // Build JSON response with properly escaped text
323- var response_buf = std.ArrayList(u8).initCapacity(client.allocator, 4096) catch return .disarm;
324- defer response_buf.deinit(client.allocator);
325-
326- response_buf.appendSlice(client.allocator, "{\"type\":\"pty_out\",\"payload\":{\"text\":\"") catch return .disarm;
327+ // ALWAYS parse through libghostty-vt to maintain state
328+ session.vt_stream.nextSlice(data) catch |err| {
329+ std.debug.print("VT parse error: {s}\n", .{@errorName(err)});
330+ };
331
332- // Manually escape JSON special characters
333- for (data) |byte| {
334- switch (byte) {
335- '"' => response_buf.appendSlice(client.allocator, "\\\"") catch return .disarm,
336- '\\' => response_buf.appendSlice(client.allocator, "\\\\") catch return .disarm,
337- '\n' => response_buf.appendSlice(client.allocator, "\\n") catch return .disarm,
338- '\r' => response_buf.appendSlice(client.allocator, "\\r") catch return .disarm,
339- '\t' => response_buf.appendSlice(client.allocator, "\\t") catch return .disarm,
340- 0x08 => response_buf.appendSlice(client.allocator, "\\b") catch return .disarm,
341- 0x0C => response_buf.appendSlice(client.allocator, "\\f") catch return .disarm,
342- 0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F...0xFF => {
343- const escaped = std.fmt.allocPrint(client.allocator, "\\u{x:0>4}", .{byte}) catch return .disarm;
344- defer client.allocator.free(escaped);
345- response_buf.appendSlice(client.allocator, escaped) catch return .disarm;
346- },
347- else => response_buf.append(client.allocator, byte) catch return .disarm,
348+ // Only proxy to clients if someone is attached
349+ if (session.attached_clients.count() > 0) {
350+ // Build JSON response with properly escaped text
351+ var response_buf = std.ArrayList(u8).initCapacity(session.allocator, 4096) catch return .disarm;
352+ defer response_buf.deinit(session.allocator);
353+
354+ response_buf.appendSlice(session.allocator, "{\"type\":\"pty_out\",\"payload\":{\"text\":\"") catch return .disarm;
355+
356+ // Manually escape JSON special characters
357+ for (data) |byte| {
358+ switch (byte) {
359+ '"' => response_buf.appendSlice(session.allocator, "\\\"") catch return .disarm,
360+ '\\' => response_buf.appendSlice(session.allocator, "\\\\") catch return .disarm,
361+ '\n' => response_buf.appendSlice(session.allocator, "\\n") catch return .disarm,
362+ '\r' => response_buf.appendSlice(session.allocator, "\\r") catch return .disarm,
363+ '\t' => response_buf.appendSlice(session.allocator, "\\t") catch return .disarm,
364+ 0x08 => response_buf.appendSlice(session.allocator, "\\b") catch return .disarm,
365+ 0x0C => response_buf.appendSlice(session.allocator, "\\f") catch return .disarm,
366+ 0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F...0xFF => {
367+ const escaped = std.fmt.allocPrint(session.allocator, "\\u{x:0>4}", .{byte}) catch return .disarm;
368+ defer session.allocator.free(escaped);
369+ response_buf.appendSlice(session.allocator, escaped) catch return .disarm;
370+ },
371+ else => response_buf.append(session.allocator, byte) catch return .disarm,
372+ }
373 }
374- }
375
376- response_buf.appendSlice(client.allocator, "\"}}\n") catch return .disarm;
377+ response_buf.appendSlice(session.allocator, "\"}}\n") catch return .disarm;
378+ const response = response_buf.items;
379
380- const response = response_buf.items;
381- std.debug.print("Sending response to client fd={d}\n", .{client.fd});
382-
383- // Send synchronously for now (blocking write)
384- const written = posix.write(client.fd, response) catch |err| {
385- std.debug.print("Error writing to fd={d}: {s}", .{ client.fd, @errorName(err) });
386- return .disarm;
387- };
388- _ = written;
389+ // Send to all attached clients
390+ var it = session.attached_clients.keyIterator();
391+ while (it.next()) |client_fd| {
392+ const attached_client = ctx.clients.get(client_fd.*) orelse continue;
393+ std.debug.print("Sending response to client fd={d}\n", .{client_fd.*});
394+ _ = posix.write(attached_client.fd, response) catch |err| {
395+ std.debug.print("Error writing to fd={d}: {s}\n", .{ client_fd.*, @errorName(err) });
396+ };
397+ }
398+ }
399
400- return .rearm;
401+ // Re-arm to continue reading
402+ stream.read(
403+ loop,
404+ completion,
405+ .{ .slice = &session.pty_read_buffer },
406+ PtyReadContext,
407+ pty_ctx,
408+ readPtyCallback,
409+ );
410+ return .disarm;
411 } else |err| {
412 std.debug.print("PTY read error: {s}\n", .{@errorName(err)});
413 return .disarm;
414@@ -681,6 +872,14 @@ fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Sessi
415 const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
416 _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
417
418+ // Initialize terminal emulator for session restore
419+ var vt = try ghostty.Terminal.init(allocator, .{
420+ .cols = 80,
421+ .rows = 24,
422+ .max_scrollback = 10000,
423+ });
424+ errdefer vt.deinit(allocator);
425+
426 const session = try allocator.create(Session);
427 session.* = .{
428 .name = try allocator.dupe(u8, session_name),
429@@ -690,14 +889,30 @@ fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Sessi
430 .allocator = allocator,
431 .pty_read_buffer = undefined,
432 .created_at = std.time.timestamp(),
433+ .vt = vt,
434+ .vt_handler = VTHandler{ .terminal = &session.vt },
435+ .vt_stream = undefined,
436+ .attached_clients = std.AutoHashMap(std.posix.fd_t, void).init(allocator),
437 };
438
439+ // Initialize the stream after session is created since handler needs terminal pointer
440+ session.vt_stream = ghostty.Stream(*VTHandler).init(&session.vt_handler);
441+ session.vt_stream.parser.osc_parser.alloc = allocator;
442+
443 return session;
444 }
445
446 fn closeClient(client: *Client, completion: *xev.Completion) xev.CallbackAction {
447 std.debug.print("Closing client fd={d}\n", .{client.fd});
448
449+ // Remove client from attached session if any
450+ if (client.attached_session) |session_name| {
451+ if (client.server_ctx.sessions.get(session_name)) |session| {
452+ _ = session.attached_clients.remove(client.fd);
453+ std.debug.print("Removed client fd={d} from session {s} attached_clients\n", .{ client.fd, session_name });
454+ }
455+ }
456+
457 // Remove client from the clients map
458 _ = client.server_ctx.clients.remove(client.fd);
459