repos / zmx

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

commit
fe21350
parent
1136218
author
Eric Bower
date
2025-11-24 22:44:58 -0500 EST
feat: ctrl+\ detach client
2 files changed,  +31, -9
M README.md
+1, -1
 1@@ -6,6 +6,7 @@ session persistence for terminal processes
 2 
 3 - Persist terminal shell sessions (pty processes)
 4 - Ability to attach and detach from a shell session without killing it
 5+- Supports all the terminal features that the client's terminal emulator supports
 6 - Native terminal scrollback
 7 - Manage shell sessions
 8 - Multiple clients can connect to the same session
 9@@ -13,7 +14,6 @@ session persistence for terminal processes
10 - Re-attaching to a session restores previous terminal state and output
11 - The `daemon` and client processes communicate via a unix socket
12 - This project does **NOT** provide windows, tabs, or window splits
13-- It supports all the terminal features that the client's terminal emulator supports
14 - Works on mac and linux
15 
16 ## usage
M src/main.zig
+30, -8
 1@@ -44,8 +44,6 @@ const Client = struct {
 2 
 3 const Cfg = struct {
 4     socket_dir: []const u8 = "/tmp/zmx",
 5-    prefix_key: []const u8 = "^", // control key
 6-    detach_key: []const u8 = "\\", // backslash key
 7 
 8     pub fn mkdir(self: *Cfg) !void {
 9         std.log.info("creating socket dir: socket_dir={s}", .{self.socket_dir});
10@@ -323,6 +321,8 @@ fn attach(daemon: *Daemon) !void {
11     // Additional granular raw mode settings for precise control
12     // (matches what abduco and shpool do)
13     raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
14+    // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key
15+    raw_termios.c_cc[c.VQUIT] = c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
16     raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte
17     raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately
18 
19@@ -333,10 +333,10 @@ fn attach(daemon: *Daemon) !void {
20     const alt_buffer_seq = "\x1b[?1049h\x1b[H";
21     _ = try posix.write(posix.STDOUT_FILENO, alt_buffer_seq);
22 
23-    try clientLoop(client_sock);
24+    try clientLoop(daemon.cfg, client_sock);
25 }
26 
27-fn clientLoop(client_sock_fd: i32) !void {
28+fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
29     std.log.info("starting client loop client_sock_fd={d}", .{client_sock_fd});
30     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
31     const alloc = std.heap.c_allocator;
32@@ -394,10 +394,32 @@ fn clientLoop(client_sock_fd: i32) !void {
33 
34             if (n_opt) |n| {
35                 if (n > 0) {
36-                    ipc.send(client_sock_fd, .Input, buf[0..n]) catch |err| switch (err) {
37-                        error.BrokenPipe, error.ConnectionResetByPeer => return,
38-                        else => return err,
39-                    };
40+                    // Check for extended escape sequence for Ctrl+\ (ESC [ 92 ; 5 u)
41+                    // This is sent by some terminals (like ghostty/kitty) in CSI u mode
42+                    const esc_seq = "\x1b[92;5u";
43+                    if (std.mem.indexOf(u8, buf[0..n], esc_seq) != null) {
44+                        ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) {
45+                            error.BrokenPipe, error.ConnectionResetByPeer => return,
46+                            else => return err,
47+                        };
48+                        continue;
49+                    }
50+
51+                    var i: usize = 0;
52+                    while (i < n) : (i += 1) {
53+                        if (buf[i] == 0x1C) { // Ctrl+\ (File Separator)
54+                            ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) {
55+                                error.BrokenPipe, error.ConnectionResetByPeer => return,
56+                                else => return err,
57+                            };
58+                        } else {
59+                            const payload = buf[i .. i + 1];
60+                            ipc.send(client_sock_fd, .Input, payload) catch |err| switch (err) {
61+                                error.BrokenPipe, error.ConnectionResetByPeer => return,
62+                                else => return err,
63+                            };
64+                        }
65+                    }
66                 } else {
67                     // EOF on stdin
68                     return;