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
1@@ -93,7 +93,7 @@ zig build -Doptimize=ReleaseSafe --prefix ~/.local
2 ## usage
3 
4 > [!IMPORTANT]
5-> We recommend closing the terminal window to detach from the session but you can also press `ctrl+\` or run `zmx detach`.
6+> 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.
7 
8 Run `zmx help` for more information on usage, with examples.
9 
+3, -1
 1@@ -63,6 +63,8 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
 2     _ = try lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags | lib_posix.O_NONBLOCK);
 3     defer _ = lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags) catch {};
 4 
 5+    const detach_key_disabled = util.isDetachKeyDisabled();
 6+
 7     while (true) {
 8         poll_fds.clearRetainingCapacity();
 9 
10@@ -113,7 +115,7 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
11             if (n_opt) |n| {
12                 if (n > 0) {
13                     // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
14-                    if (util.isCtrlBackslash(buf[0..n])) {
15+                    if (!detach_key_disabled and util.isCtrlBackslash(buf[0..n])) {
16                         std.log.info("detach key detected", .{});
17                         try ipc.appendMessage(gpa, &sock_write_buf, .Detach, "");
18                     } else {
+1, -0
1@@ -539,6 +539,7 @@ fn help(io: std.Io) !void {
2         \\  ZMX_SESSION_PREFIX   Prefix added to all session names
3         \\  ZMX_DIR_MODE         Sets mode for socket and log directories (octal, defaults to 0750)
4         \\  ZMX_LOG_MODE         Sets mode for log files (octal, defaults to 0640)
5+        \\  ZMX_NO_DETACH_KEY    Disables the ctrl+\ detach shortcut (set to any value)
6         \\
7     ;
8     var buf: [8192]u8 = undefined;
+18, -0
 1@@ -2,7 +2,9 @@ const std = @import("std");
 2 const ghostty_vt = @import("ghostty-vt");
 3 const ipc = @import("ipc.zig");
 4 const socket = @import("socket.zig");
 5+const cross = @import("cross.zig");
 6 const label = @import("label.zig");
 7+const lib_posix = @import("posix.zig");
 8 const testing = std.testing;
 9 
10 pub const SessionEntry = struct {
11@@ -467,6 +469,13 @@ fn modifyOtherMatches(buf: []const u8, expected_key: u32, expected_mods: u32) bo
12     return pos < buf.len and buf[pos] == '~';
13 }
14 
15+/// Returns true when the user has opted out of the ctrl+\ detach shortcut
16+/// via ZMX_NO_DETACH_KEY, e.g. to free up ctrl+\ for an inner program
17+/// like vim, which uses ctrl+\ ctrl+n to escape its own terminal mode.
18+pub fn isDetachKeyDisabled() bool {
19+    return lib_posix.getenv("ZMX_NO_DETACH_KEY") != null;
20+}
21+
22 /// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
23 pub fn isUpArrow(buf: []const u8) bool {
24     return std.mem.eql(u8, buf, "\x1b[A") or std.mem.eql(u8, buf, "\x1b[1;1:1A");
25@@ -1183,6 +1192,15 @@ test "isCtrlBackslash xterm modifyOtherKeys" {
26     try expect(!isCtrlBackslash("\x1b[27;5;92"));
27 }
28 
29+test "isDetachKeyDisabled" {
30+    _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY");
31+    try testing.expect(!isDetachKeyDisabled());
32+
33+    _ = cross.c.setenv("ZMX_NO_DETACH_KEY", "1", 1);
34+    defer _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY");
35+    try testing.expect(isDetachKeyDisabled());
36+}
37+
38 test "serializeTerminalState excludes synchronized output replay" {
39     const alloc = testing.allocator;
40     const io = testing.io;