repos / zmx

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

commit
e0ea9a3
parent
e2c3da5
author
Eric Bower
date
2025-10-15 14:21:21 -0400 EDT
chore(reattach): row iteration
2 files changed,  +281, -4
M src/daemon.zig
+48, -4
 1@@ -8,6 +8,7 @@ const protocol = @import("protocol.zig");
 2 const builtin = @import("builtin");
 3 
 4 const ghostty = @import("ghostty-vt");
 5+const sgr = @import("sgr.zig");
 6 
 7 const c = switch (builtin.os.tag) {
 8     .macos => @cImport({
 9@@ -888,16 +889,59 @@ fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8
10     var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
11     errdefer output.deinit(allocator);
12 
13-    _ = session;
14+    // Get the terminal's page list
15+    const pages = &session.vt.screen.pages;
16 
17-    // Clear screen when reattaching - let shell repaint naturally
18-    // TODO: Properly restore scrollback once we figure out why ghostty-vt
19-    // buffer contains corrupted escape sequence remnants
20+    // Create row iterator for active viewport
21+    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
22+
23+    // Iterate through viewport rows
24+    var row_idx: usize = 0;
25+    while (row_it.next()) |pin| : (row_idx += 1) {
26+        // Get row and cell data from pin
27+        const rac = pin.rowAndCell();
28+        const row = rac.row;
29+        const page = &pin.node.data;
30+        const cells = page.getCells(row);
31+
32+        // TODO: Process cells for this row (row_idx available for positioning)
33+        _ = cells;
34+    }
35+
36+    // TODO: Properly restore scrollback
37     try output.appendSlice(allocator, "\x1b[2J\x1b[H"); // Clear screen and home cursor
38 
39     return output.toOwnedSlice(allocator);
40 }
41 
42+test "renderTerminalSnapshot: rowIterator viewport iteration" {
43+    const testing = std.testing;
44+    const allocator = testing.allocator;
45+
46+    // Create a simple terminal
47+    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
48+    defer vt.deinit(allocator);
49+
50+    // Write some content
51+    try vt.print('H');
52+    try vt.print('e');
53+    try vt.print('l');
54+    try vt.print('l');
55+    try vt.print('o');
56+
57+    // Test that we can iterate through viewport using rowIterator
58+    const pages = &vt.screen.pages;
59+    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
60+
61+    var row_count: usize = 0;
62+    while (row_it.next()) |pin| : (row_count += 1) {
63+        const rac = pin.rowAndCell();
64+        _ = rac; // Just verify we can access row and cell
65+    }
66+
67+    try testing.expectEqual(pages.rows, row_count);
68+}
69+
70 fn notifyAttachedClientsAndCleanup(session: *Session, ctx: *ServerContext, reason: []const u8) void {
71     // Copy the session name FIRST before doing anything else, including printing
72     // This protects against any potential memory corruption
A src/sgr.zig
+233, -0
  1@@ -0,0 +1,233 @@
  2+const std = @import("std");
  3+const ghostty = @import("ghostty-vt");
  4+
  5+/// Helper functions for generating ANSI SGR (Select Graphic Rendition) escape sequences
  6+/// from ghostty-vt Style objects. Used to restore terminal styling when reattaching to sessions.
  7+/// Generate SGR sequence to change from old_style to new_style.
  8+/// Emits minimal SGR codes to reduce output size.
  9+/// Returns owned slice that caller must free.
 10+pub fn emitStyleChange(
 11+    allocator: std.mem.Allocator,
 12+    old_style: ghostty.Style,
 13+    new_style: ghostty.Style,
 14+) ![]u8 {
 15+    var buf = try std.ArrayList(u8).initCapacity(allocator, 64);
 16+
 17+    // If new style is default, emit reset
 18+    if (new_style.default()) {
 19+        try buf.appendSlice(allocator, "\x1b[0m");
 20+        return buf.toOwnedSlice(allocator);
 21+    }
 22+
 23+    // Start escape sequence
 24+    try buf.appendSlice(allocator, "\x1b[");
 25+    var first = true;
 26+
 27+    // Helper to add separator
 28+    const addSep = struct {
 29+        fn call(b: *std.ArrayList(u8), alloc: std.mem.Allocator, is_first: *bool) !void {
 30+            if (!is_first.*) {
 31+                try b.append(alloc, ';');
 32+            }
 33+            is_first.* = false;
 34+        }
 35+    }.call;
 36+
 37+    // Bold
 38+    if (new_style.flags.bold != old_style.flags.bold) {
 39+        try addSep(&buf, allocator, &first);
 40+        if (new_style.flags.bold) {
 41+            try buf.append(allocator, '1');
 42+        } else {
 43+            try buf.appendSlice(allocator, "22");
 44+        }
 45+    }
 46+
 47+    // Faint
 48+    if (new_style.flags.faint != old_style.flags.faint) {
 49+        try addSep(&buf, allocator, &first);
 50+        if (new_style.flags.faint) {
 51+            try buf.append('2');
 52+        } else {
 53+            try buf.appendSlice("22");
 54+        }
 55+    }
 56+
 57+    // Italic
 58+    if (new_style.flags.italic != old_style.flags.italic) {
 59+        try addSep(&buf, allocator, &first);
 60+        if (new_style.flags.italic) {
 61+            try buf.append('3');
 62+        } else {
 63+            try buf.appendSlice("23");
 64+        }
 65+    }
 66+
 67+    // Underline
 68+    if (!std.meta.eql(new_style.flags.underline, old_style.flags.underline)) {
 69+        try addSep(&buf, allocator, &first);
 70+        switch (new_style.flags.underline) {
 71+            .none => try buf.appendSlice("24"),
 72+            .single => try buf.append('4'),
 73+            .double => try buf.appendSlice("21"),
 74+            .curly => try buf.appendSlice("4:3"),
 75+            .dotted => try buf.appendSlice("4:4"),
 76+            .dashed => try buf.appendSlice("4:5"),
 77+        }
 78+    }
 79+
 80+    // Blink
 81+    if (new_style.flags.blink != old_style.flags.blink) {
 82+        try addSep(&buf, allocator, &first);
 83+        if (new_style.flags.blink) {
 84+            try buf.append('5');
 85+        } else {
 86+            try buf.appendSlice("25");
 87+        }
 88+    }
 89+
 90+    // Inverse
 91+    if (new_style.flags.inverse != old_style.flags.inverse) {
 92+        try addSep(&buf, allocator, &first);
 93+        if (new_style.flags.inverse) {
 94+            try buf.append('7');
 95+        } else {
 96+            try buf.appendSlice("27");
 97+        }
 98+    }
 99+
100+    // Invisible
101+    if (new_style.flags.invisible != old_style.flags.invisible) {
102+        try addSep(&buf, allocator, &first);
103+        if (new_style.flags.invisible) {
104+            try buf.append('8');
105+        } else {
106+            try buf.appendSlice("28");
107+        }
108+    }
109+
110+    // Strikethrough
111+    if (new_style.flags.strikethrough != old_style.flags.strikethrough) {
112+        try addSep(&buf, allocator, &first);
113+        if (new_style.flags.strikethrough) {
114+            try buf.append('9');
115+        } else {
116+            try buf.appendSlice("29");
117+        }
118+    }
119+
120+    // Foreground color
121+    if (!std.meta.eql(new_style.fg_color, old_style.fg_color)) {
122+        try addSep(&buf, allocator, &first);
123+        switch (new_style.fg_color) {
124+            .none => try buf.appendSlice("39"),
125+            .palette => |idx| {
126+                try buf.appendSlice("38;5;");
127+                try buf.writer().print("{d}", .{idx});
128+            },
129+            .rgb => |rgb| {
130+                try buf.appendSlice("38;2;");
131+                try buf.writer().print("{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
132+            },
133+        }
134+    }
135+
136+    // Background color
137+    if (!std.meta.eql(new_style.bg_color, old_style.bg_color)) {
138+        try addSep(&buf, allocator, &first);
139+        switch (new_style.bg_color) {
140+            .none => try buf.appendSlice("49"),
141+            .palette => |idx| {
142+                try buf.appendSlice("48;5;");
143+                try buf.writer().print("{d}", .{idx});
144+            },
145+            .rgb => |rgb| {
146+                try buf.appendSlice("48;2;");
147+                try buf.writer().print("{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
148+            },
149+        }
150+    }
151+
152+    // Underline color (not all terminals support this, but emit it anyway)
153+    if (!std.meta.eql(new_style.underline_color, old_style.underline_color)) {
154+        try addSep(&buf, allocator, &first);
155+        switch (new_style.underline_color) {
156+            .none => try buf.appendSlice("59"),
157+            .palette => |idx| {
158+                try buf.appendSlice("58;5;");
159+                try buf.writer().print("{d}", .{idx});
160+            },
161+            .rgb => |rgb| {
162+                try buf.appendSlice("58;2;");
163+                try buf.writer().print("{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
164+            },
165+        }
166+    }
167+
168+    // End escape sequence
169+    try buf.append('m');
170+
171+    // If we only added the escape opener and closer with nothing in between,
172+    // return empty string (no change needed)
173+    if (first) {
174+        buf.deinit();
175+        return allocator.dupe(u8, "");
176+    }
177+
178+    return buf.toOwnedSlice();
179+}
180+
181+test "emitStyleChange: default to default" {
182+    const allocator = std.testing.allocator;
183+    const result = try emitStyleChange(allocator, .{}, .{});
184+    defer allocator.free(result);
185+    try std.testing.expectEqualStrings("", result);
186+}
187+
188+test "emitStyleChange: bold" {
189+    const allocator = std.testing.allocator;
190+    var new_style = ghostty.Style{};
191+    new_style.flags.bold = true;
192+    const result = try emitStyleChange(allocator, .{}, new_style);
193+    defer allocator.free(result);
194+    try std.testing.expectEqualStrings("\x1b[1m", result);
195+}
196+
197+test "emitStyleChange: reset to default" {
198+    const allocator = std.testing.allocator;
199+    var old_style = ghostty.Style{};
200+    old_style.flags.bold = true;
201+    old_style.flags.italic = true;
202+    const result = try emitStyleChange(allocator, old_style, .{});
203+    defer allocator.free(result);
204+    try std.testing.expectEqualStrings("\x1b[0m", result);
205+}
206+
207+test "emitStyleChange: palette color" {
208+    const allocator = std.testing.allocator;
209+    var new_style = ghostty.Style{};
210+    new_style.fg_color = .{ .palette = 196 }; // red
211+    const result = try emitStyleChange(allocator, .{}, new_style);
212+    defer allocator.free(result);
213+    try std.testing.expectEqualStrings("\x1b[38;5;196m", result);
214+}
215+
216+test "emitStyleChange: rgb color" {
217+    const allocator = std.testing.allocator;
218+    var new_style = ghostty.Style{};
219+    new_style.bg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } };
220+    const result = try emitStyleChange(allocator, .{}, new_style);
221+    defer allocator.free(result);
222+    try std.testing.expectEqualStrings("\x1b[48;2;255;128;64m", result);
223+}
224+
225+test "emitStyleChange: multiple attributes" {
226+    const allocator = std.testing.allocator;
227+    var new_style = ghostty.Style{};
228+    new_style.flags.bold = true;
229+    new_style.flags.italic = true;
230+    new_style.fg_color = .{ .palette = 10 };
231+    const result = try emitStyleChange(allocator, .{}, new_style);
232+    defer allocator.free(result);
233+    try std.testing.expectEqualStrings("\x1b[1;3;38;5;10m", result);
234+}