repos / zmx

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

commit
927bc64
parent
dce3f04
author
Eric Bower
date
2025-12-27 19:12:56 -0500 EST
refactor: remove ctrl+b + d detach shortcut

This feature was annoying to implement because we can't simply check for
a single byte, we first have to detect if ctrl+b was pressed then check
for d.

In the end I don't like the complexity it introduces and it seems pretty low
value since we should recommend users simply close their terminal
window.
3 files changed,  +16, -126
M CHANGELOG.md
+0, -1
1@@ -11,7 +11,6 @@ Use spec: https://common-changelog.org/
2 
3 ### Changed
4 
5-- Use `ctrl+b + d` to detach from session instead of `ctrl+\` (deprecated)
6 - Updated `ghostty-vt` to latest HEAD
7 
8 ### Fixed
M README.md
+7, -3
 1@@ -70,9 +70,13 @@ zmx attach build make -j8   # run a build, reattach to check progress
 2 zmx attach mux dvtm         # run a multiplexer inside zmx
 3 ```
 4 
 5+### detach
 6+
 7+While we try our best to support detaching from the terminal session using multiple mechanisms (e.g. shortcut, detach command) we find ourselves simply closing terminal windows when we want to detach from a `zmx` session.  Whenever you attach to a `zmx` session we make sure that it stays alive so explicitly detaching feels unnecessary unless for some reason you need to reuse the same exact terminal window.
 8+
 9 ## shell prompt
10 
11-When you attach to a zmx session, we don't provide any indication that you are inside `zmx`. We do provide an environment variable `ZMX_SESSION` which contains the session name.
12+When you attach to a `zmx` session, we don't provide any indication that you are inside `zmx`. We do provide an environment variable `ZMX_SESSION` which contains the session name.
13 
14 We recommend checking for that env var inside your prompt and displaying some indication there.
15 
16@@ -106,13 +110,13 @@ PROMPT="${ZMX_SESSION:+[$ZMX_SESSION]} $BASE_PROMPT"
17 
18 ## philosophy
19 
20-The entire argument for `zmx` instead of something like `tmux` that has windows, panes, splits, etc. is that job should be handled by your os window manager. By using something like `tmux` you now have redundent functionality in your dev stack: a window manager for your os and a window manager for your terminal. Further, in order to use modern terminal features, your terminal emulator **and** `tmux` need to have support for them. This holds back the terminal enthusiast community and feature development.
21+The entire argument for `zmx` instead of something like `tmux` that has windows, panes, splits, etc. is that job should be handled by your os window manager. By using something like `tmux` you now have redundant functionality in your dev stack: a window manager for your os and a window manager for your terminal. Further, in order to use modern terminal features, your terminal emulator **and** `tmux` need to have support for them. This holds back the terminal enthusiast community and feature development.
22 
23 Instead, this tool specifically focuses on session persistence and defers window management to your os wm.
24 
25 ## ssh workflow
26 
27-Using `zmx` with `ssh` is a first-class citizen. Instead of `ssh`ing into your remote system with a single terminal and `n` tmux panes, you open `n` terminals and run `ssh` for all of them. This might sound tedious, but there are tools to make this a delightful workflow.
28+Using `zmx` with `ssh` is a first-class citizen. Instead of using `ssh` to remote into your system with a single terminal and `n` tmux panes, you open `n` terminals and run `ssh` for all of them. This might sound tedious, but there are tools to make this a delightful workflow.
29 
30 First, create an `ssh` config entry for your remote dev server:
31 
M src/main.zig
+9, -122
  1@@ -707,9 +707,6 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
  2 
  3     const stdin_fd = posix.STDIN_FILENO;
  4 
  5-    // Prefix key state for ctrl+b + <key> bindings
  6-    var prefix_active = false;
  7-
  8     // Make stdin non-blocking
  9     const flags = try posix.fcntl(stdin_fd, posix.F.GETFL, 0);
 10     _ = try posix.fcntl(stdin_fd, posix.F.SETFL, flags | posix.SOCK.NONBLOCK);
 11@@ -763,47 +760,11 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 12 
 13             if (n_opt) |n| {
 14                 if (n > 0) {
 15-                    // Check for Kitty keyboard protocol sequences first
 16-                    switch (parseStdinInput(buf[0..n], &prefix_active)) {
 17-                        .detach => {
 18-                            try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
 19-                            continue;
 20-                        },
 21-                        .send => |data| {
 22-                            try ipc.appendMessage(alloc, &sock_write_buf, .Input, data);
 23-                            continue;
 24-                        },
 25-                        .activate_prefix => continue,
 26-                        .none => {},
 27-                    }
 28-
 29-                    // Process byte-by-byte for non-Kitty input
 30-                    var i: usize = 0;
 31-                    while (i < n) : (i += 1) {
 32-                        const action = parseStdinByte(buf[i], prefix_active);
 33-                        switch (action) {
 34-                            .detach => {
 35-                                try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
 36-                                prefix_active = false;
 37-                            },
 38-                            .send => |data| {
 39-                                try ipc.appendMessage(alloc, &sock_write_buf, .Input, data);
 40-                                prefix_active = false;
 41-                            },
 42-                            .activate_prefix => {
 43-                                prefix_active = true;
 44-                            },
 45-                            .none => {
 46-                                if (prefix_active) {
 47-                                    // Unknown prefix command, forward both ctrl+b and this key
 48-                                    try ipc.appendMessage(alloc, &sock_write_buf, .Input, &[_]u8{0x02});
 49-                                    try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[i .. i + 1]);
 50-                                    prefix_active = false;
 51-                                } else {
 52-                                    try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[i .. i + 1]);
 53-                                }
 54-                            },
 55-                        }
 56+                    // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
 57+                    if (buf[0] == 0x1C or isKittyCtrlBackslash(buf[0..n])) {
 58+                        try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
 59+                    } else {
 60+                        try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
 61                     }
 62                 } else {
 63                     // EOF on stdin
 64@@ -1151,53 +1112,6 @@ fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeE
 65     return error.Unexpected;
 66 }
 67 
 68-const InputAction = union(enum) {
 69-    send: []const u8,
 70-    detach,
 71-    activate_prefix,
 72-    none,
 73-};
 74-
 75-fn parseStdinByte(byte: u8, prefix_active: bool) InputAction {
 76-    if (byte == 0x1C) { // Ctrl+\ (File Separator)
 77-        return .detach;
 78-    } else if (byte == 0x02) { // Ctrl+B
 79-        if (prefix_active) {
 80-            return .{ .send = &[_]u8{0x02} };
 81-        } else {
 82-            return .activate_prefix;
 83-        }
 84-    } else if (prefix_active) {
 85-        if (byte == 'd') {
 86-            return .detach;
 87-        } else {
 88-            return .none; // Unknown prefix command - caller handles forwarding
 89-        }
 90-    }
 91-    return .none; // Regular byte - caller handles forwarding
 92-}
 93-
 94-fn parseStdinInput(buf: []const u8, prefix_active: *bool) InputAction {
 95-    // Check for Kitty keyboard protocol escape sequences first
 96-    if (isKittyCtrlBackslash(buf)) {
 97-        prefix_active.* = false;
 98-        return .detach;
 99-    }
100-
101-    if (isKittyCtrlB(buf)) {
102-        prefix_active.* = true;
103-        return .activate_prefix;
104-    }
105-
106-    // Handle prefix mode for Kitty 'd' key
107-    if (prefix_active.* and isKittyKey(buf, 'd')) {
108-        prefix_active.* = false;
109-        return .detach;
110-    }
111-
112-    return .none; // Not a special sequence
113-}
114-
115 fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
116     std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
117     dir.deleteFile(session_name) catch |err| {
118@@ -1260,17 +1174,17 @@ fn getTerminalSize(fd: i32) ipc.Resize {
119 }
120 
121 /// Detects Kitty keyboard protocol escape sequence for Ctrl+\
122-/// Common sequences: \e[92;5u (basic), \e[92;133u (with event flags)
123+/// 92 = backslash, 5 = ctrl modifier, :1 = key press event
124 fn isKittyCtrlBackslash(buf: []const u8) bool {
125     return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
126-        std.mem.indexOf(u8, buf, "\x1b[92;133u") != null;
127+        std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null;
128 }
129 
130 test "isKittyCtrlBackslash" {
131     try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
132-    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;133u"));
133+    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
134+    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
135     try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
136-    try std.testing.expect(!isKittyCtrlBackslash("\x1b[93;5u"));
137     try std.testing.expect(!isKittyCtrlBackslash("garbage"));
138 }
139 
140@@ -1325,30 +1239,3 @@ fn serializeTerminalPlainText(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
141         return null;
142     };
143 }
144-
145-fn isKittyCtrlB(buf: []const u8) bool {
146-    return std.mem.indexOf(u8, buf, "\x1b[98;5u") != null or
147-        std.mem.indexOf(u8, buf, "\x1b[98;133u") != null;
148-}
149-
150-test "isKittyCtrlB" {
151-    try std.testing.expect(isKittyCtrlB("\x1b[98;5u"));
152-    try std.testing.expect(isKittyCtrlB("\x1b[98;133u"));
153-    try std.testing.expect(!isKittyCtrlB("\x1b[98;1u"));
154-    try std.testing.expect(!isKittyCtrlB("\x1b[99;5u"));
155-    try std.testing.expect(!isKittyCtrlB("garbage"));
156-}
157-
158-fn isKittyKey(buf: []const u8, key: u8) bool {
159-    var expected: [16]u8 = undefined;
160-    const seq = std.fmt.bufPrint(&expected, "\x1b[{d}u", .{key}) catch return false;
161-
162-    return std.mem.indexOf(u8, buf, seq) != null;
163-}
164-
165-test "isKittyKey" {
166-    try std.testing.expect(isKittyKey("\x1b[100u", 'd'));
167-    try std.testing.expect(!isKittyKey("\x1b[100;5u", 'd'));
168-    try std.testing.expect(!isKittyKey("\x1b[101u", 'd'));
169-    try std.testing.expect(!isKittyKey("d", 'd'));
170-}