- 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
+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" {