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