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
1@@ -480,11 +480,31 @@ fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
2 /// and alternate key sub-fields from the kitty protocol's progressive
3 /// enhancement flags.
4 fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
5+ const parsed = parseKittyCsiU(buf) orelse return false;
6+ if (parsed.key_code != expected_key) return false;
7+
8+ // Only accept intentional modifiers. Lock modifiers
9+ // (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because
10+ // they are ambient state, not deliberate key combinations.
11+ const intentional_mods = parsed.modifiers & 0b00111111;
12+ if (expected_mods > 0 and expected_mods != intentional_mods) return false;
13+
14+ // 3 = release -- reject. Accept press (1) and repeat (2).
15+ return parsed.event_type != 3;
16+}
17+
18+const KittyCsiU = struct {
19+ key_code: u32,
20+ modifiers: u32,
21+ event_type: u32,
22+ consumed: usize,
23+};
24+
25+fn parseKittyCsiU(buf: []const u8) ?KittyCsiU {
26 var pos: usize = 0;
27
28 // 1. Parse key code.
29- const key_code = parseDecimal(buf, &pos) orelse return false;
30- if (key_code != expected_key) return false;
31+ const key_code = parseDecimal(buf, &pos) orelse return null;
32
33 // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key).
34 while (pos < buf.len and buf[pos] == ':') {
35@@ -493,29 +513,22 @@ fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool
36 }
37
38 // 3. Expect ';' separator before modifiers.
39- if (pos >= buf.len or buf[pos] != ';') return false;
40+ if (pos >= buf.len or buf[pos] != ';') return null;
41 pos += 1;
42
43 // 4. Parse modifier value. Kitty encodes as 1 + bitfield.
44- const mod_encoded = parseDecimal(buf, &pos) orelse return false;
45- if (mod_encoded < 1) return false;
46+ const mod_encoded = parseDecimal(buf, &pos) orelse return null;
47+ if (mod_encoded < 1) return null;
48 const mod_raw = mod_encoded - 1;
49
50- // 5. Only accept intentional modifiers. Lock modifiers
51- // (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because
52- // they are ambient state, not deliberate key combinations.
53- const intentional_mods = mod_raw & 0b00111111;
54- if (expected_mods > 0 and expected_mods != intentional_mods) return false;
55-
56- // 6. Parse optional event type after ':'.
57+ var event_type: u32 = 1;
58+ // 5. Parse optional event type after ':'.
59 if (pos < buf.len and buf[pos] == ':') {
60 pos += 1;
61- const event_type = parseDecimal(buf, &pos) orelse return false;
62- // 3 = release -- reject. Accept press (1) and repeat (2).
63- if (event_type == 3) return false;
64+ event_type = parseDecimal(buf, &pos) orelse return null;
65 }
66
67- // 7. Skip optional ';text-codepoints' section.
68+ // 6. Skip optional ';text-codepoints' section.
69 if (pos < buf.len and buf[pos] == ';') {
70 pos += 1;
71 // Consume remaining digits and colons until 'u'.
72@@ -524,8 +537,16 @@ fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool
73 }
74 }
75
76- // 8. Expect terminal 'u'.
77- return pos < buf.len and buf[pos] == 'u';
78+ // 7. Expect terminal 'u'.
79+ if (pos >= buf.len or buf[pos] != 'u') return null;
80+ pos += 1;
81+
82+ return .{
83+ .key_code = key_code,
84+ .modifiers = mod_raw,
85+ .event_type = event_type,
86+ .consumed = pos,
87+ };
88 }
89
90 /// Parse a decimal integer from buf starting at pos, advancing pos past the
91@@ -545,8 +566,17 @@ fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
92 /// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
93 pub fn isUserInput(payload: []const u8) bool {
94 var parser = ghostty_vt.Parser.init();
95- for (payload) |c| {
96- const actions = parser.next(c);
97+ var i: usize = 0;
98+ while (i < payload.len) {
99+ if (payload[i] == 0x1b and i + 2 < payload.len and payload[i + 1] == '[') {
100+ if (parseKittyCsiU(payload[i + 2 ..])) |kitty| {
101+ if (kitty.event_type != 3) return true;
102+ i += 2 + kitty.consumed;
103+ continue;
104+ }
105+ }
106+
107+ const actions = parser.next(payload[i]);
108 for (actions) |action_opt| {
109 const action = action_opt orelse continue;
110 switch (action) {
111@@ -572,6 +602,7 @@ pub fn isUserInput(payload: []const u8) bool {
112 else => {},
113 }
114 }
115+ i += 1;
116 }
117 return false;
118 }
119@@ -1555,6 +1586,10 @@ test "isUserInput: kitty keyboard sequences" {
120 // Kitty keyboard protocol uses CSI u
121 try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
122 try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
123+ try testing.expect(isUserInput("\x1b[102;1:1u")); // literal "f" press
124+ try testing.expect(isUserInput("\x1b[57444;1:1u")); // Kitty functional key press
125+ try testing.expect(!isUserInput("\x1b[102;1:3u")); // literal "f" release only
126+ try testing.expect(isUserInput("\x1b[102;1:1u\x1b[67;65;31M")); // key press with mouse noise
127 }
128
129 test "isUserInput: mouse events (CSI M) excluded" {