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;