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 {
480480 /// and alternate key sub-fields from the kitty protocol's progressive
481481 /// enhancement flags.
482482 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 {
483504 var pos: usize = 0;
484505
485506 // 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;
488508
489509 // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key).
490510 while (pos < buf.len and buf[pos] == ':') {
......@@ -493,29 +513,22 @@ fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool
493513 }
494514
495515 // 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;
497517 pos += 1;
498518
499519 // 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;
502522 const mod_raw = mod_encoded - 1;
503523
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 ':'.
511526 if (pos < buf.len and buf[pos] == ':') {
512527 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;
516529 }
517530
518- // 7. Skip optional ';text-codepoints' section.
531+ // 6. Skip optional ';text-codepoints' section.
519532 if (pos < buf.len and buf[pos] == ';') {
520533 pos += 1;
521534 // Consume remaining digits and colons until 'u'.
......@@ -524,8 +537,16 @@ fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool
524537 }
525538 }
526539
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+ };
529550 }
530551
531552 /// 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 {
545566 /// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
546567 pub fn isUserInput(payload: []const u8) bool {
547568 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]);
550580 for (actions) |action_opt| {
551581 const action = action_opt orelse continue;
552582 switch (action) {
......@@ -572,6 +602,7 @@ pub fn isUserInput(payload: []const u8) bool {
572602 else => {},
573603 }
574604 }
605+ i += 1;
575606 }
576607 return false;
577608 }
......@@ -1555,6 +1586,10 @@ test "isUserInput: kitty keyboard sequences" {
15551586 // Kitty keyboard protocol uses CSI u
15561587 try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
15571588 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
15581593 }
15591594
15601595 test "isUserInput: mouse events (CSI M) excluded" {