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 { | |
| 201 | 201 | return null; | |
| 202 | 202 | } | |
| 203 | 203 | ||
| 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. | |
| 206 | 213 | 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; | |
| 209 | 284 | } | |
| 210 | 285 | ||
| 211 | 286 | pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 { |
| ... | ... | @@ -533,11 +608,86 @@ test "shellQuote" { | |
| 533 | 608 | } | |
| 534 | 609 | ||
| 535 | 610 | 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")); | |
| 541 | 691 | } | |
| 542 | 692 | ||
| 543 | 693 | test "serializeTerminalState excludes synchronized output replay" { |