repos / zmx

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

commit
e3a2063
parent
85cda06
author
Eric Bower
date
2025-10-14 13:13:41 -0400 EDT
feat: restore terminal state
1 files changed,  +155, -10
M src/daemon.zig
+155, -10
  1@@ -95,6 +95,105 @@ const VTHandler = struct {
  2         self.terminal.tabReset();
  3     }
  4 
  5+    // Cursor movement (relative)
  6+    pub fn cursorUp(self: *VTHandler, count: usize) !void {
  7+        self.terminal.cursorUp(count);
  8+    }
  9+
 10+    pub fn cursorDown(self: *VTHandler, count: usize) !void {
 11+        self.terminal.cursorDown(count);
 12+    }
 13+
 14+    pub fn cursorForward(self: *VTHandler, count: usize) !void {
 15+        self.terminal.cursorRight(count);
 16+    }
 17+
 18+    pub fn cursorBack(self: *VTHandler, count: usize) !void {
 19+        self.terminal.cursorLeft(count);
 20+    }
 21+
 22+    pub fn setCursorColRelative(self: *VTHandler, count: usize) !void {
 23+        const new_col = self.terminal.screen.cursor.x + count;
 24+        self.terminal.setCursorPos(self.terminal.screen.cursor.y, new_col);
 25+    }
 26+
 27+    pub fn setCursorRowRelative(self: *VTHandler, count: usize) !void {
 28+        const new_row = self.terminal.screen.cursor.y + count;
 29+        self.terminal.setCursorPos(new_row, self.terminal.screen.cursor.x);
 30+    }
 31+
 32+    // Special movement (ESC sequences)
 33+    pub fn index(self: *VTHandler) !void {
 34+        try self.terminal.index();
 35+    }
 36+
 37+    pub fn reverseIndex(self: *VTHandler) !void {
 38+        self.terminal.reverseIndex();
 39+    }
 40+
 41+    pub fn nextLine(self: *VTHandler) !void {
 42+        try self.terminal.linefeed();
 43+        self.terminal.carriageReturn();
 44+    }
 45+
 46+    pub fn prevLine(self: *VTHandler) !void {
 47+        self.terminal.reverseIndex();
 48+        self.terminal.carriageReturn();
 49+    }
 50+
 51+    // Line/char editing
 52+    pub fn insertLines(self: *VTHandler, count: usize) !void {
 53+        self.terminal.insertLines(count);
 54+    }
 55+
 56+    pub fn deleteLines(self: *VTHandler, count: usize) !void {
 57+        self.terminal.deleteLines(count);
 58+    }
 59+
 60+    pub fn deleteChars(self: *VTHandler, count: usize) !void {
 61+        self.terminal.deleteChars(count);
 62+    }
 63+
 64+    pub fn eraseChars(self: *VTHandler, count: usize) !void {
 65+        self.terminal.eraseChars(count);
 66+    }
 67+
 68+    pub fn scrollUp(self: *VTHandler, count: usize) !void {
 69+        self.terminal.scrollUp(count);
 70+    }
 71+
 72+    pub fn scrollDown(self: *VTHandler, count: usize) !void {
 73+        self.terminal.scrollDown(count);
 74+    }
 75+
 76+    // Basic control characters
 77+    pub fn carriageReturn(self: *VTHandler) !void {
 78+        self.terminal.carriageReturn();
 79+    }
 80+
 81+    pub fn linefeed(self: *VTHandler) !void {
 82+        try self.terminal.linefeed();
 83+    }
 84+
 85+    pub fn backspace(self: *VTHandler) !void {
 86+        self.terminal.backspace();
 87+    }
 88+
 89+    pub fn horizontalTab(self: *VTHandler, count: usize) !void {
 90+        _ = count; // stream always passes 1
 91+        try self.terminal.horizontalTab();
 92+    }
 93+
 94+    pub fn horizontalTabBack(self: *VTHandler, count: usize) !void {
 95+        _ = count; // stream always passes 1
 96+        try self.terminal.horizontalTabBack();
 97+    }
 98+
 99+    pub fn bell(self: *VTHandler) !void {
100+        _ = self;
101+        // Ignore bell in daemon context - no UI to notify
102+    }
103+
104     pub fn deviceAttributes(
105         self: *VTHandler,
106         req: ghostty.DeviceAttributeReq,
107@@ -789,11 +888,9 @@ fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8
108     var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
109     errdefer output.deinit(allocator);
110 
111-    // Clear screen and move to home
112-    try output.appendSlice(allocator, "\x1b[2J\x1b[H");
113-
114     const vt = &session.vt;
115     const screen = &session.vt.screen;
116+    const scroll_region = &vt.scrolling_region;
117 
118     // Debug: Print all enabled modes
119     std.debug.print("Terminal modes for session {s}:\n", .{session.name});
120@@ -813,11 +910,48 @@ fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8
121         }
122     }
123 
124-    // Restore terminal modes by sending appropriate escape sequences
125-    // Iterate over all modes and generate the correct escape sequence for each
126+    // Step 1: Neutralize constraints for predictable snapshot printing
127+    try output.appendSlice(allocator, "\x1b[?69l"); // Disable left/right margins
128+    try output.appendSlice(allocator, "\x1b[r"); // Reset scroll region to full screen
129+    try output.appendSlice(allocator, "\x1b[?6l"); // Disable origin mode temporarily
130+    try output.appendSlice(allocator, "\x1b[2J\x1b[H"); // Clear and home
131+
132+    // Step 2: Print terminal content
133+    const content = try session.vt.plainStringUnwrapped(allocator);
134+    defer allocator.free(content);
135+    try output.appendSlice(allocator, content);
136+
137+    // Step 3: Restore scroll regions (if non-default)
138+    const default_top: u16 = 0;
139+    const default_bottom: u16 = vt.rows - 1;
140+    const default_left: u16 = 0;
141+    const default_right: u16 = vt.cols - 1;
142+
143+    if (scroll_region.top != default_top or scroll_region.bottom != default_bottom) {
144+        // DECSTBM - set top/bottom margins (1-based)
145+        try output.writer(allocator).print("\x1b[{d};{d}r", .{
146+            scroll_region.top + 1,
147+            scroll_region.bottom + 1,
148+        });
149+    }
150+
151+    if (scroll_region.left != default_left or scroll_region.right != default_right) {
152+        // Enable LRMM first, then set left/right margins (1-based)
153+        try output.appendSlice(allocator, "\x1b[?69h"); // Enable left/right margin mode
154+        try output.writer(allocator).print("\x1b[{d};{d}s", .{
155+            scroll_region.left + 1,
156+            scroll_region.right + 1,
157+        });
158+    }
159+
160+    // Step 4: Restore terminal modes (except origin, which we do later)
161     inline for (@typeInfo(ghostty.Mode).@"enum".fields) |field| {
162         @setEvalBranchQuota(6000);
163         const mode: ghostty.Mode = @field(ghostty.Mode, field.name);
164+
165+        // Skip origin mode - we'll restore it after cursor positioning
166+        if (mode == .origin) continue;
167+
168         const value = vt.modes.get(mode);
169         const tag: ghostty.modes.ModeTag = @bitCast(@as(ghostty.modes.ModeTag.Backing, field.value));
170         const mode_default = @field(vt.modes.default, field.name);
171@@ -835,13 +969,24 @@ fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8
172         }
173     }
174 
175-    const content = try session.vt.plainStringUnwrapped(allocator);
176-    defer allocator.free(content);
177-    try output.appendSlice(allocator, content);
178+    // Step 5: Restore origin mode if it was enabled
179+    const origin_mode = vt.modes.get(.origin);
180+    if (origin_mode) {
181+        try output.appendSlice(allocator, "\x1b[?6h");
182+    }
183 
184-    // Position cursor at correct location (ANSI is 1-based)
185+    // Step 6: Position cursor (origin-aware, 1-based)
186     const cursor = screen.cursor;
187-    try output.writer(allocator).print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 });
188+    const cursor_row: u16 = if (origin_mode)
189+        (cursor.y -| scroll_region.top) + 1
190+    else
191+        cursor.y + 1;
192+    const cursor_col: u16 = if (origin_mode)
193+        (cursor.x -| scroll_region.left) + 1
194+    else
195+        cursor.x + 1;
196+
197+    try output.writer(allocator).print("\x1b[{d};{d}H", .{ cursor_row, cursor_col });
198 
199     return output.toOwnedSlice(allocator);
200 }