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
......@@ -2303,7 +2303,7 @@ fn attach(daemon: *Daemon) !void {
23032303 if (stdin_is_tty) {
23042304 _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
23052305 }
2306- // Reset terminal modes on detach:
2306+ // Reset terminal modes on detach
23072307 const restore_seq = "\x1bc";
23082308 _ = posix.write(posix.STDOUT_FILENO, restore_seq) catch {};
23092309 }
+109, -2
......@@ -404,10 +404,56 @@ pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
404404 return result.toOwnedSlice(alloc);
405405 }
406406
407-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
407+/// Detects Ctrl+\ across raw, Kitty CSI u, and xterm modifyOtherKeys encodings.
408408 pub fn isCtrlBackslash(buf: []const u8) bool {
409409 if (buf.len == 0) return false;
410- return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100);
410+ return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100) or isModifyOtherKey(buf, 0x5c, 0b100);
411+}
412+
413+/// Scans the buffer for an xterm modifyOtherKeys-encoded keypress.
414+/// Format: CSI 27 ; <modifier> ; <keycode> ~
415+/// Reference: invisible-island.net/xterm/ctlseqs/ctlseqs.html (modifyOtherKeys).
416+fn isModifyOtherKey(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
417+ var i: usize = 0;
418+ while (i + 1 < buf.len) : (i += 1) {
419+ if (buf[i] == 0x1b and buf[i + 1] == '[') {
420+ if (modifyOtherMatches(buf[i + 2 ..], expected_key, expected_mods)) return true;
421+ }
422+ }
423+ return false;
424+}
425+
426+/// Parses the body of an xterm modifyOtherKeys CSI sequence (after the leading
427+/// `\x1b[`). Mirrors keypressWithMod's tolerance for lock modifiers.
428+fn modifyOtherMatches(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
429+ var pos: usize = 0;
430+
431+ // 1. Sentinel: literal "27" identifies xterm modifyOtherKeys.
432+ const sentinel = parseDecimal(buf, &pos) orelse return false;
433+ if (sentinel != 27) return false;
434+
435+ // 2. Expect ';' before modifier.
436+ if (pos >= buf.len or buf[pos] != ';') return false;
437+ pos += 1;
438+
439+ // 3. Parse modifier (xterm encodes as 1 + bitfield, same as kitty).
440+ const mod_encoded = parseDecimal(buf, &pos) orelse return false;
441+ if (mod_encoded < 1) return false;
442+ const mod_raw = mod_encoded - 1;
443+ // Tolerate ambient lock modifiers (caps_lock=64, num_lock=128).
444+ const intentional_mods = mod_raw & 0b00111111;
445+ if (expected_mods > 0 and expected_mods != intentional_mods) return false;
446+
447+ // 4. Expect ';' before keycode.
448+ if (pos >= buf.len or buf[pos] != ';') return false;
449+ pos += 1;
450+
451+ // 5. Parse keycode.
452+ const key_code = parseDecimal(buf, &pos) orelse return false;
453+ if (key_code != expected_key) return false;
454+
455+ // 6. Expect '~' terminator.
456+ return pos < buf.len and buf[pos] == '~';
411457 }
412458
413459 /// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
......@@ -1028,6 +1074,67 @@ test "isCtrlBackslash" {
10281074 try expect(!isCtrlBackslash("\x1b[65;92u"));
10291075 }
10301076
1077+test "isCtrlBackslash xterm modifyOtherKeys" {
1078+ const expect = std.testing.expect;
1079+
1080+ // Basic: ctrl only (modifier 5 = 1 + 4), key 92 = '\'
1081+ // Format: CSI 27 ; <mod> ; <key> ~
1082+ try expect(isCtrlBackslash("\x1b[27;5;92~"));
1083+
1084+ // Lock modifiers tolerated
1085+ // ctrl + caps_lock = 1 + (4 + 64) = 69
1086+ try expect(isCtrlBackslash("\x1b[27;69;92~"));
1087+ // ctrl + num_lock = 1 + (4 + 128) = 133
1088+ try expect(isCtrlBackslash("\x1b[27;133;92~"));
1089+ // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
1090+ try expect(isCtrlBackslash("\x1b[27;197;92~"));
1091+
1092+ // Combined intentional modifiers must NOT match
1093+ // ctrl + shift = 1 + (4 + 1) = 6
1094+ try expect(!isCtrlBackslash("\x1b[27;6;92~"));
1095+ // ctrl + alt = 1 + (4 + 2) = 7
1096+ try expect(!isCtrlBackslash("\x1b[27;7;92~"));
1097+ // ctrl + super = 1 + (4 + 8) = 13
1098+ try expect(!isCtrlBackslash("\x1b[27;13;92~"));
1099+ // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
1100+ try expect(!isCtrlBackslash("\x1b[27;70;92~"));
1101+ // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
1102+ try expect(!isCtrlBackslash("\x1b[27;134;92~"));
1103+
1104+ // Modifier without ctrl bit -- must NOT match
1105+ try expect(!isCtrlBackslash("\x1b[27;1;92~"));
1106+ try expect(!isCtrlBackslash("\x1b[27;2;92~"));
1107+
1108+ // Wrong key code -- must NOT match
1109+ try expect(!isCtrlBackslash("\x1b[27;5;91~"));
1110+ try expect(!isCtrlBackslash("\x1b[27;5;93~"));
1111+ try expect(!isCtrlBackslash("\x1b[27;5;65~"));
1112+
1113+ // Wrong sentinel -- must NOT match
1114+ try expect(!isCtrlBackslash("\x1b[28;5;92~"));
1115+ try expect(!isCtrlBackslash("\x1b[26;5;92~"));
1116+
1117+ // Wrong terminator -- must NOT match
1118+ try expect(!isCtrlBackslash("\x1b[27;5;92u"));
1119+ try expect(!isCtrlBackslash("\x1b[27;5;92m"));
1120+
1121+ // CSI sequences that look similar but are not modifyOtherKeys
1122+ try expect(!isCtrlBackslash("\x1b[27m")); // SGR reset reverse
1123+ try expect(!isCtrlBackslash("\x1b[27~")); // xterm F4
1124+ try expect(!isCtrlBackslash("\x1b[27;5R")); // truncated cursor report
1125+
1126+ // Sequence embedded in larger buffer
1127+ try expect(isCtrlBackslash("abc\x1b[27;5;92~"));
1128+ try expect(isCtrlBackslash("\x1b[A\x1b[27;5;92~"));
1129+
1130+ // Garbage / malformed
1131+ try expect(!isCtrlBackslash("\x1b[27"));
1132+ try expect(!isCtrlBackslash("\x1b[27;"));
1133+ try expect(!isCtrlBackslash("\x1b[27;5"));
1134+ try expect(!isCtrlBackslash("\x1b[27;5;"));
1135+ try expect(!isCtrlBackslash("\x1b[27;5;92"));
1136+}
1137+
10311138 test "serializeTerminalState excludes synchronized output replay" {
10321139 const alloc = testing.allocator;
10331140