Commit d3b01c7
MyeongjunKim
·
2026-04-08 05:58:30 -0400 EDT
parent 5778a84
fix(attach): detect ctrl+\ encoded by xterm modifyOtherKeys PR #92 made the ctrl+\ matcher handle the kitty CSI u encoding. The older xterm modifyOtherKeys encoding (CSI 27 ; mod ; key ~) was still missed, which broke detach when the inner program (e.g. claude) enables modifyOtherKeys level 2 and the outer terminal honors it (e.g. iTerm2). Add a separate matcher mirroring keypressWithMod, with the same lock modifier tolerance. Also force modifyOtherKeys back to level 0 in the detach restore sequence; xterm modifyOtherKeys has no push/pop, so this is unconditional, mirroring the existing unconditional disables for mouse modes, bracketed paste, focus events, and alt screen. Without it the outer shell ends up encoding ctrl combos as escape sequences after detach, corrupting input at the prompt.
2 files changed,
+110,
-3
+1,
-1
1@@ -2303,7 +2303,7 @@ fn attach(daemon: *Daemon) !void {
2 if (stdin_is_tty) {
3 _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
4 }
5- // Reset terminal modes on detach:
6+ // Reset terminal modes on detach
7 const restore_seq = "\x1bc";
8 _ = posix.write(posix.STDOUT_FILENO, restore_seq) catch {};
9 }
+109,
-2
1@@ -404,10 +404,56 @@ pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
2 return result.toOwnedSlice(alloc);
3 }
4
5-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
6+/// Detects Ctrl+\ across raw, Kitty CSI u, and xterm modifyOtherKeys encodings.
7 pub fn isCtrlBackslash(buf: []const u8) bool {
8 if (buf.len == 0) return false;
9- return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100);
10+ return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100) or isModifyOtherKey(buf, 0x5c, 0b100);
11+}
12+
13+/// Scans the buffer for an xterm modifyOtherKeys-encoded keypress.
14+/// Format: CSI 27 ; <modifier> ; <keycode> ~
15+/// Reference: invisible-island.net/xterm/ctlseqs/ctlseqs.html (modifyOtherKeys).
16+fn isModifyOtherKey(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
17+ var i: usize = 0;
18+ while (i + 1 < buf.len) : (i += 1) {
19+ if (buf[i] == 0x1b and buf[i + 1] == '[') {
20+ if (modifyOtherMatches(buf[i + 2 ..], expected_key, expected_mods)) return true;
21+ }
22+ }
23+ return false;
24+}
25+
26+/// Parses the body of an xterm modifyOtherKeys CSI sequence (after the leading
27+/// `\x1b[`). Mirrors keypressWithMod's tolerance for lock modifiers.
28+fn modifyOtherMatches(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
29+ var pos: usize = 0;
30+
31+ // 1. Sentinel: literal "27" identifies xterm modifyOtherKeys.
32+ const sentinel = parseDecimal(buf, &pos) orelse return false;
33+ if (sentinel != 27) return false;
34+
35+ // 2. Expect ';' before modifier.
36+ if (pos >= buf.len or buf[pos] != ';') return false;
37+ pos += 1;
38+
39+ // 3. Parse modifier (xterm encodes as 1 + bitfield, same as kitty).
40+ const mod_encoded = parseDecimal(buf, &pos) orelse return false;
41+ if (mod_encoded < 1) return false;
42+ const mod_raw = mod_encoded - 1;
43+ // Tolerate ambient lock modifiers (caps_lock=64, num_lock=128).
44+ const intentional_mods = mod_raw & 0b00111111;
45+ if (expected_mods > 0 and expected_mods != intentional_mods) return false;
46+
47+ // 4. Expect ';' before keycode.
48+ if (pos >= buf.len or buf[pos] != ';') return false;
49+ pos += 1;
50+
51+ // 5. Parse keycode.
52+ const key_code = parseDecimal(buf, &pos) orelse return false;
53+ if (key_code != expected_key) return false;
54+
55+ // 6. Expect '~' terminator.
56+ return pos < buf.len and buf[pos] == '~';
57 }
58
59 /// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
60@@ -1028,6 +1074,67 @@ test "isCtrlBackslash" {
61 try expect(!isCtrlBackslash("\x1b[65;92u"));
62 }
63
64+test "isCtrlBackslash xterm modifyOtherKeys" {
65+ const expect = std.testing.expect;
66+
67+ // Basic: ctrl only (modifier 5 = 1 + 4), key 92 = '\'
68+ // Format: CSI 27 ; <mod> ; <key> ~
69+ try expect(isCtrlBackslash("\x1b[27;5;92~"));
70+
71+ // Lock modifiers tolerated
72+ // ctrl + caps_lock = 1 + (4 + 64) = 69
73+ try expect(isCtrlBackslash("\x1b[27;69;92~"));
74+ // ctrl + num_lock = 1 + (4 + 128) = 133
75+ try expect(isCtrlBackslash("\x1b[27;133;92~"));
76+ // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
77+ try expect(isCtrlBackslash("\x1b[27;197;92~"));
78+
79+ // Combined intentional modifiers must NOT match
80+ // ctrl + shift = 1 + (4 + 1) = 6
81+ try expect(!isCtrlBackslash("\x1b[27;6;92~"));
82+ // ctrl + alt = 1 + (4 + 2) = 7
83+ try expect(!isCtrlBackslash("\x1b[27;7;92~"));
84+ // ctrl + super = 1 + (4 + 8) = 13
85+ try expect(!isCtrlBackslash("\x1b[27;13;92~"));
86+ // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
87+ try expect(!isCtrlBackslash("\x1b[27;70;92~"));
88+ // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
89+ try expect(!isCtrlBackslash("\x1b[27;134;92~"));
90+
91+ // Modifier without ctrl bit -- must NOT match
92+ try expect(!isCtrlBackslash("\x1b[27;1;92~"));
93+ try expect(!isCtrlBackslash("\x1b[27;2;92~"));
94+
95+ // Wrong key code -- must NOT match
96+ try expect(!isCtrlBackslash("\x1b[27;5;91~"));
97+ try expect(!isCtrlBackslash("\x1b[27;5;93~"));
98+ try expect(!isCtrlBackslash("\x1b[27;5;65~"));
99+
100+ // Wrong sentinel -- must NOT match
101+ try expect(!isCtrlBackslash("\x1b[28;5;92~"));
102+ try expect(!isCtrlBackslash("\x1b[26;5;92~"));
103+
104+ // Wrong terminator -- must NOT match
105+ try expect(!isCtrlBackslash("\x1b[27;5;92u"));
106+ try expect(!isCtrlBackslash("\x1b[27;5;92m"));
107+
108+ // CSI sequences that look similar but are not modifyOtherKeys
109+ try expect(!isCtrlBackslash("\x1b[27m")); // SGR reset reverse
110+ try expect(!isCtrlBackslash("\x1b[27~")); // xterm F4
111+ try expect(!isCtrlBackslash("\x1b[27;5R")); // truncated cursor report
112+
113+ // Sequence embedded in larger buffer
114+ try expect(isCtrlBackslash("abc\x1b[27;5;92~"));
115+ try expect(isCtrlBackslash("\x1b[A\x1b[27;5;92~"));
116+
117+ // Garbage / malformed
118+ try expect(!isCtrlBackslash("\x1b[27"));
119+ try expect(!isCtrlBackslash("\x1b[27;"));
120+ try expect(!isCtrlBackslash("\x1b[27;5"));
121+ try expect(!isCtrlBackslash("\x1b[27;5;"));
122+ try expect(!isCtrlBackslash("\x1b[27;5;92"));
123+}
124+
125 test "serializeTerminalState excludes synchronized output replay" {
126 const alloc = testing.allocator;
127