repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

commit
46b02ee
parent
a0c915c
author
Patrik Sundberg
date
2026-03-15 19:24:29 -0400 EDT
fix(attach): handle all kitty keyboard protocol encodings for Ctrl+\

The old detection used rigid substring matching for exactly two
sequences (\x1b[92;5u and \x1b[92;5:1u). This failed when apps
enabled progressive enhancement flags that change the encoding:

- Lock modifiers (caps_lock, num_lock) alter the modifier value
- Alternate key sub-fields add :shifted:base after the key code
- Combined modifiers (ctrl+shift, ctrl+alt, etc.) change the value
- Text codepoint sections append an extra ;codepoints field

Replace with a proper CSI u parser that:
1. Matches key code 92 (backslash) with any alternate sub-fields
2. Checks the ctrl bit in the modifier value: (mod - 1) & 0b100
3. Accepts press/repeat events, rejects release
4. Tolerates optional text codepoint sections
1 files changed,  +159, -9
M src/util.zig
+159, -9
  1@@ -201,11 +201,86 @@ pub fn findTaskExitMarker(output: []const u8) ?u8 {
  2     return null;
  3 }
  4 
  5-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
  6-/// 92 = backslash, 5 = ctrl modifier, :1 = key press event
  7+/// Detects Kitty keyboard protocol escape sequence for Ctrl+\.
  8+/// Parses the general CSI u form:
  9+///   CSI key-code[:alternates] ; modifiers[:event-type] [; text-codepoints] u
 10+///
 11+/// Matches when key-code is 92 (backslash), ctrl bit is set in modifiers,
 12+/// and event type is press (1 or absent) or repeat (2). Rejects release (3).
 13+/// Tolerates additional modifiers (shift, alt, caps_lock, num_lock, etc.)
 14+/// and alternate key sub-fields from the kitty protocol's progressive
 15+/// enhancement flags.
 16 pub fn isKittyCtrlBackslash(buf: []const u8) bool {
 17-    return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
 18-        std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null;
 19+    // Scan for any CSI u sequence encoding Ctrl+\ in the buffer.
 20+    // The sequence can appear at any offset (e.g. preceded by other input).
 21+    var i: usize = 0;
 22+    while (i + 2 < buf.len) : (i += 1) {
 23+        if (buf[i] == 0x1b and buf[i + 1] == '[') {
 24+            if (parseKittyCtrlBackslash(buf[i + 2 ..])) return true;
 25+        }
 26+    }
 27+    return false;
 28+}
 29+
 30+/// Parse a CSI u sequence (after the `\x1b[` prefix) and return true if it
 31+/// encodes a Ctrl+\ press or repeat event.
 32+fn parseKittyCtrlBackslash(buf: []const u8) bool {
 33+    var pos: usize = 0;
 34+
 35+    // 1. Parse key code — must be 92 (backslash).
 36+    const key_code = parseDecimal(buf, &pos) orelse return false;
 37+    if (key_code != 92) return false;
 38+
 39+    // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key).
 40+    while (pos < buf.len and buf[pos] == ':') {
 41+        pos += 1; // consume ':'
 42+        _ = parseDecimal(buf, &pos); // consume digits (may be empty for ::base)
 43+    }
 44+
 45+    // 3. Expect ';' separator before modifiers.
 46+    if (pos >= buf.len or buf[pos] != ';') return false;
 47+    pos += 1;
 48+
 49+    // 4. Parse modifier value. Kitty encodes as 1 + bitfield.
 50+    const mod_encoded = parseDecimal(buf, &pos) orelse return false;
 51+    if (mod_encoded < 1) return false;
 52+    const mod_raw = mod_encoded - 1;
 53+
 54+    // 5. Ctrl bit (0b100 = 4) must be set.
 55+    if (mod_raw & 0b100 == 0) return false;
 56+
 57+    // 6. Parse optional event type after ':'.
 58+    if (pos < buf.len and buf[pos] == ':') {
 59+        pos += 1;
 60+        const event_type = parseDecimal(buf, &pos) orelse return false;
 61+        // 3 = release — reject. Accept press (1) and repeat (2).
 62+        if (event_type == 3) return false;
 63+    }
 64+
 65+    // 7. Skip optional ';text-codepoints' section.
 66+    if (pos < buf.len and buf[pos] == ';') {
 67+        pos += 1;
 68+        // Consume remaining digits and colons until 'u'.
 69+        while (pos < buf.len and (std.ascii.isDigit(buf[pos]) or buf[pos] == ':')) {
 70+            pos += 1;
 71+        }
 72+    }
 73+
 74+    // 8. Expect terminal 'u'.
 75+    return pos < buf.len and buf[pos] == 'u';
 76+}
 77+
 78+/// Parse a decimal integer from buf starting at pos, advancing pos past the
 79+/// consumed digits. Returns null if no digits are present.
 80+fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
 81+    const start = pos.*;
 82+    var value: u32 = 0;
 83+    while (pos.* < buf.len and std.ascii.isDigit(buf[pos.*])) {
 84+        value = value *% 10 +% (buf[pos.*] - '0');
 85+        pos.* += 1;
 86+    }
 87+    if (pos.* == start) return null;
 88+    return value;
 89 }
 90 
 91 pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
 92@@ -533,11 +608,86 @@ test "shellQuote" {
 93 }
 94 
 95 test "isKittyCtrlBackslash" {
 96-    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
 97-    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
 98-    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
 99-    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
100-    try std.testing.expect(!isKittyCtrlBackslash("garbage"));
101+    const expect = std.testing.expect;
102+
103+    // Basic: ctrl only (modifier 5 = 1 + 4)
104+    try expect(isKittyCtrlBackslash("\x1b[92;5u"));
105+
106+    // Explicit press event type (:1)
107+    try expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
108+
109+    // Repeat event (:2) — user holding Ctrl+\
110+    try expect(isKittyCtrlBackslash("\x1b[92;5:2u"));
111+
112+    // Release event (:3) — must NOT trigger detach
113+    try expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
114+
115+    // Lock modifiers: caps_lock (bit 6) changes modifier value
116+    // ctrl + caps_lock = 1 + (4 + 64) = 69
117+    try expect(isKittyCtrlBackslash("\x1b[92;69u"));
118+    try expect(isKittyCtrlBackslash("\x1b[92;69:1u"));
119+    try expect(!isKittyCtrlBackslash("\x1b[92;69:3u"));
120+
121+    // ctrl + num_lock = 1 + (4 + 128) = 133
122+    try expect(isKittyCtrlBackslash("\x1b[92;133u"));
123+
124+    // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
125+    try expect(isKittyCtrlBackslash("\x1b[92;197u"));
126+
127+    // Combined modifiers: ctrl + shift = 1 + (4 + 1) = 6
128+    try expect(isKittyCtrlBackslash("\x1b[92;6u"));
129+
130+    // ctrl + alt = 1 + (4 + 2) = 7
131+    try expect(isKittyCtrlBackslash("\x1b[92;7u"));
132+
133+    // ctrl + super = 1 + (4 + 8) = 13
134+    try expect(isKittyCtrlBackslash("\x1b[92;13u"));
135+
136+    // Modifier without ctrl bit — must NOT match
137+    // shift only = 1 + 1 = 2
138+    try expect(!isKittyCtrlBackslash("\x1b[92;1u"));
139+    try expect(!isKittyCtrlBackslash("\x1b[92;2u"));
140+
141+    // Alternate key sub-fields (report_alternates flag)
142+    // shifted key | (124): \x1b[92:124;5u
143+    try expect(isKittyCtrlBackslash("\x1b[92:124;5u"));
144+
145+    // base layout key only (non-US keyboard): \x1b[92::92;5u
146+    try expect(isKittyCtrlBackslash("\x1b[92::92;5u"));
147+
148+    // both shifted and base layout: \x1b[92:124:92;5u
149+    try expect(isKittyCtrlBackslash("\x1b[92:124:92;5u"));
150+
151+    // Alternate keys + lock modifiers + event type
152+    try expect(isKittyCtrlBackslash("\x1b[92:124;69:1u"));
153+    try expect(!isKittyCtrlBackslash("\x1b[92:124;69:3u"));
154+
155+    // Text codepoints section (flag 0b10000) — tolerated and skipped
156+    // Even though ctrl+\ text is typically empty, terminals may vary
157+    try expect(isKittyCtrlBackslash("\x1b[92;5;28u"));
158+    try expect(isKittyCtrlBackslash("\x1b[92;5;28:92u"));
159+
160+    // Wrong key code — must NOT match
161+    try expect(!isKittyCtrlBackslash("\x1b[91;5u"));
162+    try expect(!isKittyCtrlBackslash("\x1b[93;5u"));
163+    try expect(!isKittyCtrlBackslash("\x1b[9;5u"));
164+    try expect(!isKittyCtrlBackslash("\x1b[920;5u"));
165+
166+    // Sequence embedded in larger buffer (e.g., preceded by other input)
167+    try expect(isKittyCtrlBackslash("abc\x1b[92;5u"));
168+    try expect(isKittyCtrlBackslash("\x1b[A\x1b[92;5u"));
169+
170+    // Garbage / malformed inputs
171+    try expect(!isKittyCtrlBackslash("garbage"));
172+    try expect(!isKittyCtrlBackslash(""));
173+    try expect(!isKittyCtrlBackslash("\x1b["));
174+    try expect(!isKittyCtrlBackslash("\x1b[92"));
175+    try expect(!isKittyCtrlBackslash("\x1b[92;"));
176+    try expect(!isKittyCtrlBackslash("\x1b[92;u"));
177+    try expect(!isKittyCtrlBackslash("\x1b[;5u"));
178+
179+    // Other CSI u sequences that happen to contain '92' elsewhere
180+    try expect(!isKittyCtrlBackslash("\x1b[65;92u"));
181 }
182 
183 test "serializeTerminalState excludes synchronized output replay" {