Commit 46b02ee

Patrik Sundberg  ·  2026-03-15 19:24:29 -0400 EDT
parent a0c915c
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
......@@ -201,11 +201,86 @@ pub fn findTaskExitMarker(output: []const u8) ?u8 {
201201 return null;
202202 }
203203
204-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
205-/// 92 = backslash, 5 = ctrl modifier, :1 = key press event
204+/// Detects Kitty keyboard protocol escape sequence for Ctrl+\.
205+/// Parses the general CSI u form:
206+/// CSI key-code[:alternates] ; modifiers[:event-type] [; text-codepoints] u
207+///
208+/// Matches when key-code is 92 (backslash), ctrl bit is set in modifiers,
209+/// and event type is press (1 or absent) or repeat (2). Rejects release (3).
210+/// Tolerates additional modifiers (shift, alt, caps_lock, num_lock, etc.)
211+/// and alternate key sub-fields from the kitty protocol's progressive
212+/// enhancement flags.
206213 pub fn isKittyCtrlBackslash(buf: []const u8) bool {
207- return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
208- std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null;
214+ // Scan for any CSI u sequence encoding Ctrl+\ in the buffer.
215+ // The sequence can appear at any offset (e.g. preceded by other input).
216+ var i: usize = 0;
217+ while (i + 2 < buf.len) : (i += 1) {
218+ if (buf[i] == 0x1b and buf[i + 1] == '[') {
219+ if (parseKittyCtrlBackslash(buf[i + 2 ..])) return true;
220+ }
221+ }
222+ return false;
223+}
224+
225+/// Parse a CSI u sequence (after the `\x1b[` prefix) and return true if it
226+/// encodes a Ctrl+\ press or repeat event.
227+fn parseKittyCtrlBackslash(buf: []const u8) bool {
228+ var pos: usize = 0;
229+
230+ // 1. Parse key code — must be 92 (backslash).
231+ const key_code = parseDecimal(buf, &pos) orelse return false;
232+ if (key_code != 92) return false;
233+
234+ // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key).
235+ while (pos < buf.len and buf[pos] == ':') {
236+ pos += 1; // consume ':'
237+ _ = parseDecimal(buf, &pos); // consume digits (may be empty for ::base)
238+ }
239+
240+ // 3. Expect ';' separator before modifiers.
241+ if (pos >= buf.len or buf[pos] != ';') return false;
242+ pos += 1;
243+
244+ // 4. Parse modifier value. Kitty encodes as 1 + bitfield.
245+ const mod_encoded = parseDecimal(buf, &pos) orelse return false;
246+ if (mod_encoded < 1) return false;
247+ const mod_raw = mod_encoded - 1;
248+
249+ // 5. Ctrl bit (0b100 = 4) must be set.
250+ if (mod_raw & 0b100 == 0) return false;
251+
252+ // 6. Parse optional event type after ':'.
253+ if (pos < buf.len and buf[pos] == ':') {
254+ pos += 1;
255+ const event_type = parseDecimal(buf, &pos) orelse return false;
256+ // 3 = release — reject. Accept press (1) and repeat (2).
257+ if (event_type == 3) return false;
258+ }
259+
260+ // 7. Skip optional ';text-codepoints' section.
261+ if (pos < buf.len and buf[pos] == ';') {
262+ pos += 1;
263+ // Consume remaining digits and colons until 'u'.
264+ while (pos < buf.len and (std.ascii.isDigit(buf[pos]) or buf[pos] == ':')) {
265+ pos += 1;
266+ }
267+ }
268+
269+ // 8. Expect terminal 'u'.
270+ return pos < buf.len and buf[pos] == 'u';
271+}
272+
273+/// Parse a decimal integer from buf starting at pos, advancing pos past the
274+/// consumed digits. Returns null if no digits are present.
275+fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
276+ const start = pos.*;
277+ var value: u32 = 0;
278+ while (pos.* < buf.len and std.ascii.isDigit(buf[pos.*])) {
279+ value = value *% 10 +% (buf[pos.*] - '0');
280+ pos.* += 1;
281+ }
282+ if (pos.* == start) return null;
283+ return value;
209284 }
210285
211286 pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
......@@ -533,11 +608,86 @@ test "shellQuote" {
533608 }
534609
535610 test "isKittyCtrlBackslash" {
536- try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
537- try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
538- try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
539- try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
540- try std.testing.expect(!isKittyCtrlBackslash("garbage"));
611+ const expect = std.testing.expect;
612+
613+ // Basic: ctrl only (modifier 5 = 1 + 4)
614+ try expect(isKittyCtrlBackslash("\x1b[92;5u"));
615+
616+ // Explicit press event type (:1)
617+ try expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
618+
619+ // Repeat event (:2) — user holding Ctrl+\
620+ try expect(isKittyCtrlBackslash("\x1b[92;5:2u"));
621+
622+ // Release event (:3) — must NOT trigger detach
623+ try expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
624+
625+ // Lock modifiers: caps_lock (bit 6) changes modifier value
626+ // ctrl + caps_lock = 1 + (4 + 64) = 69
627+ try expect(isKittyCtrlBackslash("\x1b[92;69u"));
628+ try expect(isKittyCtrlBackslash("\x1b[92;69:1u"));
629+ try expect(!isKittyCtrlBackslash("\x1b[92;69:3u"));
630+
631+ // ctrl + num_lock = 1 + (4 + 128) = 133
632+ try expect(isKittyCtrlBackslash("\x1b[92;133u"));
633+
634+ // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
635+ try expect(isKittyCtrlBackslash("\x1b[92;197u"));
636+
637+ // Combined modifiers: ctrl + shift = 1 + (4 + 1) = 6
638+ try expect(isKittyCtrlBackslash("\x1b[92;6u"));
639+
640+ // ctrl + alt = 1 + (4 + 2) = 7
641+ try expect(isKittyCtrlBackslash("\x1b[92;7u"));
642+
643+ // ctrl + super = 1 + (4 + 8) = 13
644+ try expect(isKittyCtrlBackslash("\x1b[92;13u"));
645+
646+ // Modifier without ctrl bit — must NOT match
647+ // shift only = 1 + 1 = 2
648+ try expect(!isKittyCtrlBackslash("\x1b[92;1u"));
649+ try expect(!isKittyCtrlBackslash("\x1b[92;2u"));
650+
651+ // Alternate key sub-fields (report_alternates flag)
652+ // shifted key | (124): \x1b[92:124;5u
653+ try expect(isKittyCtrlBackslash("\x1b[92:124;5u"));
654+
655+ // base layout key only (non-US keyboard): \x1b[92::92;5u
656+ try expect(isKittyCtrlBackslash("\x1b[92::92;5u"));
657+
658+ // both shifted and base layout: \x1b[92:124:92;5u
659+ try expect(isKittyCtrlBackslash("\x1b[92:124:92;5u"));
660+
661+ // Alternate keys + lock modifiers + event type
662+ try expect(isKittyCtrlBackslash("\x1b[92:124;69:1u"));
663+ try expect(!isKittyCtrlBackslash("\x1b[92:124;69:3u"));
664+
665+ // Text codepoints section (flag 0b10000) — tolerated and skipped
666+ // Even though ctrl+\ text is typically empty, terminals may vary
667+ try expect(isKittyCtrlBackslash("\x1b[92;5;28u"));
668+ try expect(isKittyCtrlBackslash("\x1b[92;5;28:92u"));
669+
670+ // Wrong key code — must NOT match
671+ try expect(!isKittyCtrlBackslash("\x1b[91;5u"));
672+ try expect(!isKittyCtrlBackslash("\x1b[93;5u"));
673+ try expect(!isKittyCtrlBackslash("\x1b[9;5u"));
674+ try expect(!isKittyCtrlBackslash("\x1b[920;5u"));
675+
676+ // Sequence embedded in larger buffer (e.g., preceded by other input)
677+ try expect(isKittyCtrlBackslash("abc\x1b[92;5u"));
678+ try expect(isKittyCtrlBackslash("\x1b[A\x1b[92;5u"));
679+
680+ // Garbage / malformed inputs
681+ try expect(!isKittyCtrlBackslash("garbage"));
682+ try expect(!isKittyCtrlBackslash(""));
683+ try expect(!isKittyCtrlBackslash("\x1b["));
684+ try expect(!isKittyCtrlBackslash("\x1b[92"));
685+ try expect(!isKittyCtrlBackslash("\x1b[92;"));
686+ try expect(!isKittyCtrlBackslash("\x1b[92;u"));
687+ try expect(!isKittyCtrlBackslash("\x1b[;5u"));
688+
689+ // Other CSI u sequences that happen to contain '92' elsewhere
690+ try expect(!isKittyCtrlBackslash("\x1b[65;92u"));
541691 }
542692
543693 test "serializeTerminalState excludes synchronized output replay" {