Commit 4f3739e
Varun Biniwale
·
2026-07-08 13:38:06 -0400 EDT
parent 70476a7
feat(attach): add ZMX_NO_DETACH_KEY to free up ctrl+\ for inner programs ctrl+\ as the detach key is hardcoded with no way to opt out, which conflicts with programs that use it themselves (e.g. vim's ctrl+\ ctrl+n to escape its own :terminal). Since the byte is intercepted in the client's input loop before it ever reaches the remote PTY, no remapping on the inner program's side can work around it. Setting ZMX_NO_DETACH_KEY (to any value) skips the ctrl+\ intercept so it passes through as normal input; zmx detach or closing the window still work as usual.
4 files changed,
+23,
-2
+1,
-1
| ... | ... | @@ -93,7 +93,7 @@ zig build -Doptimize=ReleaseSafe --prefix ~/.local | |
| 93 | 93 | ## usage | |
| 94 | 94 | ||
| 95 | 95 | > [!IMPORTANT] | |
| 96 | - | > We recommend closing the terminal window to detach from the session but you can also press `ctrl+\` or run `zmx detach`. | |
| 96 | + | > We recommend closing the terminal window to detach from the session but you can also press `ctrl+\` or run `zmx detach`. If you need `ctrl+\` for something else (e.g. vim's `ctrl+\ ctrl+n` to escape its own `:terminal`), set `ZMX_NO_DETACH_KEY` to disable the shortcut and rely on `zmx detach` or closing the window instead. | |
| 97 | 97 | ||
| 98 | 98 | Run `zmx help` for more information on usage, with examples. | |
| 99 | 99 |
+3,
-1
| ... | ... | @@ -63,6 +63,8 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 63 | 63 | _ = try lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags | lib_posix.O_NONBLOCK); | |
| 64 | 64 | defer _ = lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags) catch {}; | |
| 65 | 65 | ||
| 66 | + | const detach_key_disabled = util.isDetachKeyDisabled(); | |
| 67 | + | ||
| 66 | 68 | while (true) { | |
| 67 | 69 | poll_fds.clearRetainingCapacity(); | |
| 68 | 70 |
| ... | ... | @@ -113,7 +115,7 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 113 | 115 | if (n_opt) |n| { | |
| 114 | 116 | if (n > 0) { | |
| 115 | 117 | // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence) | |
| 116 | - | if (util.isCtrlBackslash(buf[0..n])) { | |
| 118 | + | if (!detach_key_disabled and util.isCtrlBackslash(buf[0..n])) { | |
| 117 | 119 | std.log.info("detach key detected", .{}); | |
| 118 | 120 | try ipc.appendMessage(gpa, &sock_write_buf, .Detach, ""); | |
| 119 | 121 | } else { |
+1,
-0
| ... | ... | @@ -539,6 +539,7 @@ fn help(io: std.Io) !void { | |
| 539 | 539 | \\ ZMX_SESSION_PREFIX Prefix added to all session names | |
| 540 | 540 | \\ ZMX_DIR_MODE Sets mode for socket and log directories (octal, defaults to 0750) | |
| 541 | 541 | \\ ZMX_LOG_MODE Sets mode for log files (octal, defaults to 0640) | |
| 542 | + | \\ ZMX_NO_DETACH_KEY Disables the ctrl+\ detach shortcut (set to any value) | |
| 542 | 543 | \\ | |
| 543 | 544 | ; | |
| 544 | 545 | var buf: [8192]u8 = undefined; |
+18,
-0
| ... | ... | @@ -2,7 +2,9 @@ const std = @import("std"); | |
| 2 | 2 | const ghostty_vt = @import("ghostty-vt"); | |
| 3 | 3 | const ipc = @import("ipc.zig"); | |
| 4 | 4 | const socket = @import("socket.zig"); | |
| 5 | + | const cross = @import("cross.zig"); | |
| 5 | 6 | const label = @import("label.zig"); | |
| 7 | + | const lib_posix = @import("posix.zig"); | |
| 6 | 8 | const testing = std.testing; | |
| 7 | 9 | ||
| 8 | 10 | pub const SessionEntry = struct { |
| ... | ... | @@ -467,6 +469,13 @@ fn modifyOtherMatches(buf: []const u8, expected_key: u32, expected_mods: u32) bo | |
| 467 | 469 | return pos < buf.len and buf[pos] == '~'; | |
| 468 | 470 | } | |
| 469 | 471 | ||
| 472 | + | /// Returns true when the user has opted out of the ctrl+\ detach shortcut | |
| 473 | + | /// via ZMX_NO_DETACH_KEY, e.g. to free up ctrl+\ for an inner program | |
| 474 | + | /// like vim, which uses ctrl+\ ctrl+n to escape its own terminal mode. | |
| 475 | + | pub fn isDetachKeyDisabled() bool { | |
| 476 | + | return lib_posix.getenv("ZMX_NO_DETACH_KEY") != null; | |
| 477 | + | } | |
| 478 | + | ||
| 470 | 479 | /// Detects vt100 or kitty keyboard protocol escape sequence for up arrow. | |
| 471 | 480 | pub fn isUpArrow(buf: []const u8) bool { | |
| 472 | 481 | return std.mem.eql(u8, buf, "\x1b[A") or std.mem.eql(u8, buf, "\x1b[1;1:1A"); |
| ... | ... | @@ -1183,6 +1192,15 @@ test "isCtrlBackslash xterm modifyOtherKeys" { | |
| 1183 | 1192 | try expect(!isCtrlBackslash("\x1b[27;5;92")); | |
| 1184 | 1193 | } | |
| 1185 | 1194 | ||
| 1195 | + | test "isDetachKeyDisabled" { | |
| 1196 | + | _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY"); | |
| 1197 | + | try testing.expect(!isDetachKeyDisabled()); | |
| 1198 | + | ||
| 1199 | + | _ = cross.c.setenv("ZMX_NO_DETACH_KEY", "1", 1); | |
| 1200 | + | defer _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY"); | |
| 1201 | + | try testing.expect(isDetachKeyDisabled()); | |
| 1202 | + | } | |
| 1203 | + | ||
| 1186 | 1204 | test "serializeTerminalState excludes synchronized output replay" { | |
| 1187 | 1205 | const alloc = testing.allocator; | |
| 1188 | 1206 | const io = testing.io; |