repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

commit
280e122
parent
e0ea9a3
author
Eric Bower
date
2025-10-15 14:37:32 -0400 EDT
refactor: move render terminal snapshot to separate module
2 files changed,  +164, -58
M src/daemon.zig
+2, -58
 1@@ -9,6 +9,7 @@ const builtin = @import("builtin");
 2 
 3 const ghostty = @import("ghostty-vt");
 4 const sgr = @import("sgr.zig");
 5+const terminal_snapshot = @import("terminal_snapshot.zig");
 6 
 7 const c = switch (builtin.os.tag) {
 8     .macos => @cImport({
 9@@ -797,7 +798,7 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
10 
11     // If reattaching, send the scrollback buffer as binary frame
12     if (is_reattach) {
13-        const buffer_slice = try renderTerminalSnapshot(session, client.allocator);
14+        const buffer_slice = try terminal_snapshot.render(&session.vt, client.allocator);
15         defer client.allocator.free(buffer_slice);
16 
17         try protocol.writeBinaryFrame(client.fd, .pty_binary, buffer_slice);
18@@ -885,63 +886,6 @@ fn getSessionForClient(ctx: *ServerContext, client: *Client) ?*Session {
19     return ctx.sessions.get(session_name);
20 }
21 
22-fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8 {
23-    var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
24-    errdefer output.deinit(allocator);
25-
26-    // Get the terminal's page list
27-    const pages = &session.vt.screen.pages;
28-
29-    // Create row iterator for active viewport
30-    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
31-
32-    // Iterate through viewport rows
33-    var row_idx: usize = 0;
34-    while (row_it.next()) |pin| : (row_idx += 1) {
35-        // Get row and cell data from pin
36-        const rac = pin.rowAndCell();
37-        const row = rac.row;
38-        const page = &pin.node.data;
39-        const cells = page.getCells(row);
40-
41-        // TODO: Process cells for this row (row_idx available for positioning)
42-        _ = cells;
43-    }
44-
45-    // TODO: Properly restore scrollback
46-    try output.appendSlice(allocator, "\x1b[2J\x1b[H"); // Clear screen and home cursor
47-
48-    return output.toOwnedSlice(allocator);
49-}
50-
51-test "renderTerminalSnapshot: rowIterator viewport iteration" {
52-    const testing = std.testing;
53-    const allocator = testing.allocator;
54-
55-    // Create a simple terminal
56-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
57-    defer vt.deinit(allocator);
58-
59-    // Write some content
60-    try vt.print('H');
61-    try vt.print('e');
62-    try vt.print('l');
63-    try vt.print('l');
64-    try vt.print('o');
65-
66-    // Test that we can iterate through viewport using rowIterator
67-    const pages = &vt.screen.pages;
68-    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
69-
70-    var row_count: usize = 0;
71-    while (row_it.next()) |pin| : (row_count += 1) {
72-        const rac = pin.rowAndCell();
73-        _ = rac; // Just verify we can access row and cell
74-    }
75-
76-    try testing.expectEqual(pages.rows, row_count);
77-}
78-
79 fn notifyAttachedClientsAndCleanup(session: *Session, ctx: *ServerContext, reason: []const u8) void {
80     // Copy the session name FIRST before doing anything else, including printing
81     // This protects against any potential memory corruption
A src/terminal_snapshot.zig
+162, -0
  1@@ -0,0 +1,162 @@
  2+const std = @import("std");
  3+const ghostty = @import("ghostty-vt");
  4+
  5+/// Extract UTF-8 text content from a cell, including multi-codepoint graphemes
  6+fn extractCellText(pin: ghostty.Pin, cell: *const ghostty.Cell, buf: *std.ArrayList(u8), allocator: std.mem.Allocator) !void {
  7+    // Skip empty cells and spacer cells
  8+    if (cell.isEmpty()) return;
  9+    if (cell.wide == .spacer_tail or cell.wide == .spacer_head) return;
 10+
 11+    // Get the first codepoint
 12+    const cp = cell.codepoint();
 13+    if (cp == 0) return; // Empty cell
 14+
 15+    // Encode first codepoint to UTF-8
 16+    var utf8_buf: [4]u8 = undefined;
 17+    const len = std.unicode.utf8Encode(cp, &utf8_buf) catch return;
 18+    try buf.appendSlice(allocator, utf8_buf[0..len]);
 19+
 20+    // If this is a multi-codepoint grapheme, encode the rest
 21+    if (cell.hasGrapheme()) {
 22+        if (pin.grapheme(cell)) |codepoints| {
 23+            for (codepoints) |extra_cp| {
 24+                const extra_len = std.unicode.utf8Encode(extra_cp, &utf8_buf) catch continue;
 25+                try buf.appendSlice(allocator, utf8_buf[0..extra_len]);
 26+            }
 27+        }
 28+    }
 29+}
 30+
 31+/// Render the current terminal viewport state as text (with escape sequences to come)
 32+/// Returns owned slice that must be freed by caller
 33+pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
 34+    var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
 35+    errdefer output.deinit(allocator);
 36+
 37+    // Get the terminal's page list
 38+    const pages = &vt.screen.pages;
 39+
 40+    // Create row iterator for active viewport
 41+    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
 42+
 43+    // Iterate through viewport rows
 44+    var row_idx: usize = 0;
 45+    while (row_it.next()) |pin| : (row_idx += 1) {
 46+        // Get row and cell data from pin
 47+        const rac = pin.rowAndCell();
 48+        const row = rac.row;
 49+        const page = &pin.node.data;
 50+        const cells = page.getCells(row);
 51+
 52+        // Extract text from each cell in the row
 53+        for (cells, 0..) |*cell, col_idx| {
 54+            // Create a pin for this specific cell to access graphemes
 55+            const cell_pin = ghostty.Pin{
 56+                .node = pin.node,
 57+                .y = pin.y,
 58+                .x = @intCast(col_idx),
 59+            };
 60+
 61+            try extractCellText(cell_pin, cell, &output, allocator);
 62+        }
 63+
 64+        // Add newline after each row
 65+        try output.appendSlice(allocator, "\n");
 66+    }
 67+
 68+    // TODO: Properly restore scrollback with colors and cursor position
 69+    // For now, just return the text content
 70+    // try output.appendSlice(allocator, "\x1b[2J\x1b[H"); // Clear screen and home cursor
 71+
 72+    return output.toOwnedSlice(allocator);
 73+}
 74+
 75+test "render: rowIterator viewport iteration" {
 76+    const testing = std.testing;
 77+    const allocator = testing.allocator;
 78+
 79+    // Create a simple terminal
 80+    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
 81+    defer vt.deinit(allocator);
 82+
 83+    // Write some content
 84+    try vt.print('H');
 85+    try vt.print('e');
 86+    try vt.print('l');
 87+    try vt.print('l');
 88+    try vt.print('o');
 89+
 90+    // Test that we can iterate through viewport using rowIterator
 91+    const pages = &vt.screen.pages;
 92+    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
 93+
 94+    var row_count: usize = 0;
 95+    while (row_it.next()) |pin| : (row_count += 1) {
 96+        const rac = pin.rowAndCell();
 97+        _ = rac; // Just verify we can access row and cell
 98+    }
 99+
100+    try testing.expectEqual(pages.rows, row_count);
101+}
102+
103+test "extractCellText: single codepoint" {
104+    const testing = std.testing;
105+    const allocator = testing.allocator;
106+
107+    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
108+    defer vt.deinit(allocator);
109+
110+    // Write ASCII text
111+    try vt.print('A');
112+    try vt.print('B');
113+    try vt.print('C');
114+
115+    // Get the first cell
116+    const pages = &vt.screen.pages;
117+    const pin = pages.pin(.{ .active = .{} }).?;
118+    const rac = pin.rowAndCell();
119+    const page = &pin.node.data;
120+    const cells = page.getCells(rac.row);
121+
122+    // Extract text from first 3 cells
123+    var buf = try std.ArrayList(u8).initCapacity(allocator, 64);
124+    defer buf.deinit(allocator);
125+
126+    for (cells[0..3], 0..) |*cell, col_idx| {
127+        const cell_pin = ghostty.Pin{
128+            .node = pin.node,
129+            .y = pin.y,
130+            .x = @intCast(col_idx),
131+        };
132+        try extractCellText(cell_pin, cell, &buf, allocator);
133+    }
134+
135+    try testing.expectEqualStrings("ABC", buf.items);
136+}
137+
138+test "extractCellText: multi-codepoint grapheme (emoji)" {
139+    const testing = std.testing;
140+    const allocator = testing.allocator;
141+
142+    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
143+    defer vt.deinit(allocator);
144+
145+    // Write an emoji with skin tone modifier (multi-codepoint grapheme)
146+    // 👋 (waving hand) + skin tone modifier
147+    try vt.print(0x1F44B); // 👋
148+    try vt.print(0x1F3FB); // light skin tone
149+
150+    const pages = &vt.screen.pages;
151+    const pin = pages.pin(.{ .active = .{} }).?;
152+    const rac = pin.rowAndCell();
153+    const page = &pin.node.data;
154+    const cells = page.getCells(rac.row);
155+
156+    var buf = try std.ArrayList(u8).initCapacity(allocator, 64);
157+    defer buf.deinit(allocator);
158+
159+    try extractCellText(pin, &cells[0], &buf, allocator);
160+
161+    // Should have both codepoints encoded as UTF-8
162+    try testing.expect(buf.items.len > 4); // At least 2 multi-byte UTF-8 sequences
163+}