Commit 94d0675
Simon Labrecque
·
2026-04-27 14:28:45 -0400 EDT
parent d3b01c7
fix(attach): reclaim leader on kitty keyboard input When a non-leader client sends keyboard input like: - `\x1b[102;1:1u` - `\x1b[57444;1:1u` libghostty warns and does not classify the sequence as user input. As a result, handleInput() treats the payload as non-user input and refuses to transfer leadership to the active client. In practice, that leaves a re-attached client unable to take control even though its input is reaching the daemon.
1 files changed,
+55,
-20
+55,
-20
| ... | ... | @@ -480,11 +480,31 @@ fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool { | |
| 480 | 480 | /// and alternate key sub-fields from the kitty protocol's progressive | |
| 481 | 481 | /// enhancement flags. | |
| 482 | 482 | fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool { | |
| 483 | + | const parsed = parseKittyCsiU(buf) orelse return false; | |
| 484 | + | if (parsed.key_code != expected_key) return false; | |
| 485 | + | ||
| 486 | + | // Only accept intentional modifiers. Lock modifiers | |
| 487 | + | // (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because | |
| 488 | + | // they are ambient state, not deliberate key combinations. | |
| 489 | + | const intentional_mods = parsed.modifiers & 0b00111111; | |
| 490 | + | if (expected_mods > 0 and expected_mods != intentional_mods) return false; | |
| 491 | + | ||
| 492 | + | // 3 = release -- reject. Accept press (1) and repeat (2). | |
| 493 | + | return parsed.event_type != 3; | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | const KittyCsiU = struct { | |
| 497 | + | key_code: u32, | |
| 498 | + | modifiers: u32, | |
| 499 | + | event_type: u32, | |
| 500 | + | consumed: usize, | |
| 501 | + | }; | |
| 502 | + | ||
| 503 | + | fn parseKittyCsiU(buf: []const u8) ?KittyCsiU { | |
| 483 | 504 | var pos: usize = 0; | |
| 484 | 505 | ||
| 485 | 506 | // 1. Parse key code. | |
| 486 | - | const key_code = parseDecimal(buf, &pos) orelse return false; | |
| 487 | - | if (key_code != expected_key) return false; | |
| 507 | + | const key_code = parseDecimal(buf, &pos) orelse return null; | |
| 488 | 508 | ||
| 489 | 509 | // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key). | |
| 490 | 510 | while (pos < buf.len and buf[pos] == ':') { |
| ... | ... | @@ -493,29 +513,22 @@ fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool | |
| 493 | 513 | } | |
| 494 | 514 | ||
| 495 | 515 | // 3. Expect ';' separator before modifiers. | |
| 496 | - | if (pos >= buf.len or buf[pos] != ';') return false; | |
| 516 | + | if (pos >= buf.len or buf[pos] != ';') return null; | |
| 497 | 517 | pos += 1; | |
| 498 | 518 | ||
| 499 | 519 | // 4. Parse modifier value. Kitty encodes as 1 + bitfield. | |
| 500 | - | const mod_encoded = parseDecimal(buf, &pos) orelse return false; | |
| 501 | - | if (mod_encoded < 1) return false; | |
| 520 | + | const mod_encoded = parseDecimal(buf, &pos) orelse return null; | |
| 521 | + | if (mod_encoded < 1) return null; | |
| 502 | 522 | const mod_raw = mod_encoded - 1; | |
| 503 | 523 | ||
| 504 | - | // 5. Only accept intentional modifiers. Lock modifiers | |
| 505 | - | // (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because | |
| 506 | - | // they are ambient state, not deliberate key combinations. | |
| 507 | - | const intentional_mods = mod_raw & 0b00111111; | |
| 508 | - | if (expected_mods > 0 and expected_mods != intentional_mods) return false; | |
| 509 | - | ||
| 510 | - | // 6. Parse optional event type after ':'. | |
| 524 | + | var event_type: u32 = 1; | |
| 525 | + | // 5. Parse optional event type after ':'. | |
| 511 | 526 | if (pos < buf.len and buf[pos] == ':') { | |
| 512 | 527 | pos += 1; | |
| 513 | - | const event_type = parseDecimal(buf, &pos) orelse return false; | |
| 514 | - | // 3 = release -- reject. Accept press (1) and repeat (2). | |
| 515 | - | if (event_type == 3) return false; | |
| 528 | + | event_type = parseDecimal(buf, &pos) orelse return null; | |
| 516 | 529 | } | |
| 517 | 530 | ||
| 518 | - | // 7. Skip optional ';text-codepoints' section. | |
| 531 | + | // 6. Skip optional ';text-codepoints' section. | |
| 519 | 532 | if (pos < buf.len and buf[pos] == ';') { | |
| 520 | 533 | pos += 1; | |
| 521 | 534 | // Consume remaining digits and colons until 'u'. |
| ... | ... | @@ -524,8 +537,16 @@ fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool | |
| 524 | 537 | } | |
| 525 | 538 | } | |
| 526 | 539 | ||
| 527 | - | // 8. Expect terminal 'u'. | |
| 528 | - | return pos < buf.len and buf[pos] == 'u'; | |
| 540 | + | // 7. Expect terminal 'u'. | |
| 541 | + | if (pos >= buf.len or buf[pos] != 'u') return null; | |
| 542 | + | pos += 1; | |
| 543 | + | ||
| 544 | + | return .{ | |
| 545 | + | .key_code = key_code, | |
| 546 | + | .modifiers = mod_raw, | |
| 547 | + | .event_type = event_type, | |
| 548 | + | .consumed = pos, | |
| 549 | + | }; | |
| 529 | 550 | } | |
| 530 | 551 | ||
| 531 | 552 | /// Parse a decimal integer from buf starting at pos, advancing pos past the |
| ... | ... | @@ -545,8 +566,17 @@ fn parseDecimal(buf: []const u8, pos: *usize) ?u32 { | |
| 545 | 566 | /// is a key combination like up-arrow, backspace, enter, ctrl+f, etc. | |
| 546 | 567 | pub fn isUserInput(payload: []const u8) bool { | |
| 547 | 568 | var parser = ghostty_vt.Parser.init(); | |
| 548 | - | for (payload) |c| { | |
| 549 | - | const actions = parser.next(c); | |
| 569 | + | var i: usize = 0; | |
| 570 | + | while (i < payload.len) { | |
| 571 | + | if (payload[i] == 0x1b and i + 2 < payload.len and payload[i + 1] == '[') { | |
| 572 | + | if (parseKittyCsiU(payload[i + 2 ..])) |kitty| { | |
| 573 | + | if (kitty.event_type != 3) return true; | |
| 574 | + | i += 2 + kitty.consumed; | |
| 575 | + | continue; | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | const actions = parser.next(payload[i]); | |
| 550 | 580 | for (actions) |action_opt| { | |
| 551 | 581 | const action = action_opt orelse continue; | |
| 552 | 582 | switch (action) { |
| ... | ... | @@ -572,6 +602,7 @@ pub fn isUserInput(payload: []const u8) bool { | |
| 572 | 602 | else => {}, | |
| 573 | 603 | } | |
| 574 | 604 | } | |
| 605 | + | i += 1; | |
| 575 | 606 | } | |
| 576 | 607 | return false; | |
| 577 | 608 | } |
| ... | ... | @@ -1555,6 +1586,10 @@ test "isUserInput: kitty keyboard sequences" { | |
| 1555 | 1586 | // Kitty keyboard protocol uses CSI u | |
| 1556 | 1587 | try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier | |
| 1557 | 1588 | try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier | |
| 1589 | + | try testing.expect(isUserInput("\x1b[102;1:1u")); // literal "f" press | |
| 1590 | + | try testing.expect(isUserInput("\x1b[57444;1:1u")); // Kitty functional key press | |
| 1591 | + | try testing.expect(!isUserInput("\x1b[102;1:3u")); // literal "f" release only | |
| 1592 | + | try testing.expect(isUserInput("\x1b[102;1:1u\x1b[67;65;31M")); // key press with mouse noise | |
| 1558 | 1593 | } | |
| 1559 | 1594 | ||
| 1560 | 1595 | test "isUserInput: mouse events (CSI M) excluded" { |