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
9393 ## usage
9494
9595 > [!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.
9797
9898 Run `zmx help` for more information on usage, with examples.
9999
+3, -1
......@@ -63,6 +63,8 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
6363 _ = try lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags | lib_posix.O_NONBLOCK);
6464 defer _ = lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags) catch {};
6565
66+ const detach_key_disabled = util.isDetachKeyDisabled();
67+
6668 while (true) {
6769 poll_fds.clearRetainingCapacity();
6870
......@@ -113,7 +115,7 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
113115 if (n_opt) |n| {
114116 if (n > 0) {
115117 // 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])) {
117119 std.log.info("detach key detected", .{});
118120 try ipc.appendMessage(gpa, &sock_write_buf, .Detach, "");
119121 } else {
+1, -0
......@@ -539,6 +539,7 @@ fn help(io: std.Io) !void {
539539 \\ ZMX_SESSION_PREFIX Prefix added to all session names
540540 \\ ZMX_DIR_MODE Sets mode for socket and log directories (octal, defaults to 0750)
541541 \\ 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)
542543 \\
543544 ;
544545 var buf: [8192]u8 = undefined;
+18, -0
......@@ -2,7 +2,9 @@ const std = @import("std");
22 const ghostty_vt = @import("ghostty-vt");
33 const ipc = @import("ipc.zig");
44 const socket = @import("socket.zig");
5+const cross = @import("cross.zig");
56 const label = @import("label.zig");
7+const lib_posix = @import("posix.zig");
68 const testing = std.testing;
79
810 pub const SessionEntry = struct {
......@@ -467,6 +469,13 @@ fn modifyOtherMatches(buf: []const u8, expected_key: u32, expected_mods: u32) bo
467469 return pos < buf.len and buf[pos] == '~';
468470 }
469471
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+
470479 /// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
471480 pub fn isUpArrow(buf: []const u8) bool {
472481 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" {
11831192 try expect(!isCtrlBackslash("\x1b[27;5;92"));
11841193 }
11851194
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+
11861204 test "serializeTerminalState excludes synchronized output replay" {
11871205 const alloc = testing.allocator;
11881206 const io = testing.io;