repos / zmx

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

commit
0ca4141
parent
6f881c7
author
Eric Bower
date
2025-10-15 14:56:51 -0400 EDT
feat: rendering colors on reattach
2 files changed,  +56, -39
M src/sgr.zig
+36, -36
  1@@ -47,9 +47,9 @@ pub fn emitStyleChange(
  2     if (new_style.flags.faint != old_style.flags.faint) {
  3         try addSep(&buf, allocator, &first);
  4         if (new_style.flags.faint) {
  5-            try buf.append('2');
  6+            try buf.append(allocator, '2');
  7         } else {
  8-            try buf.appendSlice("22");
  9+            try buf.appendSlice(allocator, "22");
 10         }
 11     }
 12 
 13@@ -57,9 +57,9 @@ pub fn emitStyleChange(
 14     if (new_style.flags.italic != old_style.flags.italic) {
 15         try addSep(&buf, allocator, &first);
 16         if (new_style.flags.italic) {
 17-            try buf.append('3');
 18+            try buf.append(allocator, '3');
 19         } else {
 20-            try buf.appendSlice("23");
 21+            try buf.appendSlice(allocator, "23");
 22         }
 23     }
 24 
 25@@ -67,12 +67,12 @@ pub fn emitStyleChange(
 26     if (!std.meta.eql(new_style.flags.underline, old_style.flags.underline)) {
 27         try addSep(&buf, allocator, &first);
 28         switch (new_style.flags.underline) {
 29-            .none => try buf.appendSlice("24"),
 30-            .single => try buf.append('4'),
 31-            .double => try buf.appendSlice("21"),
 32-            .curly => try buf.appendSlice("4:3"),
 33-            .dotted => try buf.appendSlice("4:4"),
 34-            .dashed => try buf.appendSlice("4:5"),
 35+            .none => try buf.appendSlice(allocator, "24"),
 36+            .single => try buf.append(allocator, '4'),
 37+            .double => try buf.appendSlice(allocator, "21"),
 38+            .curly => try buf.appendSlice(allocator, "4:3"),
 39+            .dotted => try buf.appendSlice(allocator, "4:4"),
 40+            .dashed => try buf.appendSlice(allocator, "4:5"),
 41         }
 42     }
 43 
 44@@ -80,9 +80,9 @@ pub fn emitStyleChange(
 45     if (new_style.flags.blink != old_style.flags.blink) {
 46         try addSep(&buf, allocator, &first);
 47         if (new_style.flags.blink) {
 48-            try buf.append('5');
 49+            try buf.append(allocator, '5');
 50         } else {
 51-            try buf.appendSlice("25");
 52+            try buf.appendSlice(allocator, "25");
 53         }
 54     }
 55 
 56@@ -90,9 +90,9 @@ pub fn emitStyleChange(
 57     if (new_style.flags.inverse != old_style.flags.inverse) {
 58         try addSep(&buf, allocator, &first);
 59         if (new_style.flags.inverse) {
 60-            try buf.append('7');
 61+            try buf.append(allocator, '7');
 62         } else {
 63-            try buf.appendSlice("27");
 64+            try buf.appendSlice(allocator, "27");
 65         }
 66     }
 67 
 68@@ -100,9 +100,9 @@ pub fn emitStyleChange(
 69     if (new_style.flags.invisible != old_style.flags.invisible) {
 70         try addSep(&buf, allocator, &first);
 71         if (new_style.flags.invisible) {
 72-            try buf.append('8');
 73+            try buf.append(allocator, '8');
 74         } else {
 75-            try buf.appendSlice("28");
 76+            try buf.appendSlice(allocator, "28");
 77         }
 78     }
 79 
 80@@ -110,9 +110,9 @@ pub fn emitStyleChange(
 81     if (new_style.flags.strikethrough != old_style.flags.strikethrough) {
 82         try addSep(&buf, allocator, &first);
 83         if (new_style.flags.strikethrough) {
 84-            try buf.append('9');
 85+            try buf.append(allocator, '9');
 86         } else {
 87-            try buf.appendSlice("29");
 88+            try buf.appendSlice(allocator, "29");
 89         }
 90     }
 91 
 92@@ -120,14 +120,14 @@ pub fn emitStyleChange(
 93     if (!std.meta.eql(new_style.fg_color, old_style.fg_color)) {
 94         try addSep(&buf, allocator, &first);
 95         switch (new_style.fg_color) {
 96-            .none => try buf.appendSlice("39"),
 97+            .none => try buf.appendSlice(allocator, "39"),
 98             .palette => |idx| {
 99-                try buf.appendSlice("38;5;");
100-                try buf.writer().print("{d}", .{idx});
101+                try buf.appendSlice(allocator, "38;5;");
102+                try std.fmt.format(buf.writer(allocator), "{d}", .{idx});
103             },
104             .rgb => |rgb| {
105-                try buf.appendSlice("38;2;");
106-                try buf.writer().print("{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
107+                try buf.appendSlice(allocator, "38;2;");
108+                try std.fmt.format(buf.writer(allocator), "{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
109             },
110         }
111     }
112@@ -136,14 +136,14 @@ pub fn emitStyleChange(
113     if (!std.meta.eql(new_style.bg_color, old_style.bg_color)) {
114         try addSep(&buf, allocator, &first);
115         switch (new_style.bg_color) {
116-            .none => try buf.appendSlice("49"),
117+            .none => try buf.appendSlice(allocator, "49"),
118             .palette => |idx| {
119-                try buf.appendSlice("48;5;");
120-                try buf.writer().print("{d}", .{idx});
121+                try buf.appendSlice(allocator, "48;5;");
122+                try std.fmt.format(buf.writer(allocator), "{d}", .{idx});
123             },
124             .rgb => |rgb| {
125-                try buf.appendSlice("48;2;");
126-                try buf.writer().print("{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
127+                try buf.appendSlice(allocator, "48;2;");
128+                try std.fmt.format(buf.writer(allocator), "{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
129             },
130         }
131     }
132@@ -152,29 +152,29 @@ pub fn emitStyleChange(
133     if (!std.meta.eql(new_style.underline_color, old_style.underline_color)) {
134         try addSep(&buf, allocator, &first);
135         switch (new_style.underline_color) {
136-            .none => try buf.appendSlice("59"),
137+            .none => try buf.appendSlice(allocator, "59"),
138             .palette => |idx| {
139-                try buf.appendSlice("58;5;");
140-                try buf.writer().print("{d}", .{idx});
141+                try buf.appendSlice(allocator, "58;5;");
142+                try std.fmt.format(buf.writer(allocator), "{d}", .{idx});
143             },
144             .rgb => |rgb| {
145-                try buf.appendSlice("58;2;");
146-                try buf.writer().print("{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
147+                try buf.appendSlice(allocator, "58;2;");
148+                try std.fmt.format(buf.writer(allocator), "{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
149             },
150         }
151     }
152 
153     // End escape sequence
154-    try buf.append('m');
155+    try buf.append(allocator, 'm');
156 
157     // If we only added the escape opener and closer with nothing in between,
158     // return empty string (no change needed)
159     if (first) {
160-        buf.deinit();
161+        buf.deinit(allocator);
162         return allocator.dupe(u8, "");
163     }
164 
165-    return buf.toOwnedSlice();
166+    return buf.toOwnedSlice(allocator);
167 }
168 
169 test "emitStyleChange: default to default" {
M src/terminal_snapshot.zig
+20, -3
 1@@ -1,5 +1,6 @@
 2 const std = @import("std");
 3 const ghostty = @import("ghostty-vt");
 4+const sgr = @import("sgr.zig");
 5 
 6 /// Extract UTF-8 text content from a cell, including multi-codepoint graphemes
 7 fn extractCellText(pin: ghostty.Pin, cell: *const ghostty.Cell, buf: *std.ArrayList(u8), allocator: std.mem.Allocator) !void {
 8@@ -62,6 +63,9 @@ pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
 9         const page = &pin.node.data;
10         const cells = page.getCells(row);
11 
12+        // Track style changes to emit SGR sequences
13+        var last_style = ghostty.Style{}; // Start with default style
14+
15         // Extract text from each cell in the row
16         var col_idx: usize = 0;
17         while (col_idx < cells.len) : (col_idx += 1) {
18@@ -77,6 +81,17 @@ pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
19                 .x = @intCast(col_idx),
20             };
21 
22+            // Get the style for this cell
23+            const cell_style = cell_pin.style(cell);
24+
25+            // If style changed, emit SGR sequence
26+            if (!cell_style.eql(last_style)) {
27+                const sgr_seq = try sgr.emitStyleChange(allocator, last_style, cell_style);
28+                defer allocator.free(sgr_seq);
29+                try output.appendSlice(allocator, sgr_seq);
30+                last_style = cell_style;
31+            }
32+
33             try extractCellText(cell_pin, cell, &output, allocator);
34 
35             // If this is a wide character, skip the next cell (spacer_tail)
36@@ -84,6 +99,11 @@ pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
37                 col_idx += 1; // Skip the spacer cell that follows
38             }
39         }
40+
41+        // Reset style at end of row to avoid style bleeding
42+        if (!last_style.default()) {
43+            try output.appendSlice(allocator, "\x1b[0m");
44+        }
45     }
46 
47     // Restore cursor position from terminal state
48@@ -117,13 +137,10 @@ pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
49             // If we found content, position cursor after the last character
50             if (last_col > 0) {
51                 cursor_col = @intCast(last_col + 2); // +1 for after character, +1 for 1-based
52-                std.debug.print("Adjusted cursor from x=0 to col={d} (last content at col={d})\n", .{ cursor_col, last_col + 1 });
53             }
54         }
55     }
56 
57-    std.debug.print("Restoring cursor to row={d} col={d} (original: y={d} x={d})\n", .{ cursor_row, cursor_col, cursor.y, cursor.x });
58-
59     try std.fmt.format(output.writer(allocator), "\x1b[{d};{d}H", .{ cursor_row, cursor_col });
60 
61     // Show cursor