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" {