Skip to content

Commit 0642ea4

Browse files
committed
✨ add Kitty keyboard protocol (CSI u) input parser
Parse CSI codepoint;modifiers u sequences from terminals that support the Kitty keyboard protocol (Kitty, Ghostty, WezTerm, iTerm2, etc). Maps special key codepoints (Tab, Enter, Escape, Backspace, F1-F12, arrows, etc.) to existing KEY_* constants, and decodes modifier bitfields to MOD_* flags. Protocol overview: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ Key encoding: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#key-event-encoding Functional keys: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions Modifier encoding: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#modifiers
1 parent e7c3807 commit 0642ea4

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

deno.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/input.c

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,157 @@ static int parse_mouse(struct InputState *st, struct InputEvent *ev) {
216216
return rv;
217217
}
218218

219+
/* ── Kitty keyboard protocol (CSI u) ──────────────────────────────── */
220+
221+
static uint16_t kitty_key(int cp) {
222+
switch (cp) {
223+
case 9:
224+
return KEY_TAB;
225+
case 13:
226+
return KEY_ENTER;
227+
case 27:
228+
return KEY_ESC;
229+
case 127:
230+
return KEY_BACKSPACE;
231+
case 57344:
232+
return KEY_ESC;
233+
case 57345:
234+
return KEY_ENTER;
235+
case 57346:
236+
return KEY_TAB;
237+
case 57347:
238+
return KEY_BACKSPACE;
239+
case 57348:
240+
return KEY_INSERT;
241+
case 57349:
242+
return KEY_DELETE;
243+
case 57350:
244+
return KEY_ARROW_LEFT;
245+
case 57351:
246+
return KEY_ARROW_RIGHT;
247+
case 57352:
248+
return KEY_ARROW_UP;
249+
case 57353:
250+
return KEY_ARROW_DOWN;
251+
case 57354:
252+
return KEY_PGUP;
253+
case 57355:
254+
return KEY_PGDN;
255+
case 57356:
256+
return KEY_HOME;
257+
case 57357:
258+
return KEY_END;
259+
case 57376:
260+
return KEY_F1;
261+
case 57377:
262+
return KEY_F2;
263+
case 57378:
264+
return KEY_F3;
265+
case 57379:
266+
return KEY_F4;
267+
case 57380:
268+
return KEY_F5;
269+
case 57381:
270+
return KEY_F6;
271+
case 57382:
272+
return KEY_F7;
273+
case 57383:
274+
return KEY_F8;
275+
case 57384:
276+
return KEY_F9;
277+
case 57385:
278+
return KEY_F10;
279+
case 57386:
280+
return KEY_F11;
281+
case 57387:
282+
return KEY_F12;
283+
default:
284+
return 0;
285+
}
286+
}
287+
288+
static uint8_t kitty_mod(int mod) {
289+
if (mod <= 1)
290+
return 0;
291+
int bits = mod - 1;
292+
uint8_t out = 0;
293+
if (bits & 1)
294+
out |= MOD_SHIFT;
295+
if (bits & 2)
296+
out |= MOD_ALT;
297+
if (bits & 4)
298+
out |= MOD_CTRL;
299+
return out;
300+
}
301+
302+
static int parse_csi_u(struct InputState *st, struct InputEvent *ev) {
303+
/* \x1b[ codepoint ; modifiers u or \x1b[ codepoint u */
304+
if (st->len < 2)
305+
return PARSE_NEED_MORE;
306+
if (st->buf[0] != '\x1b' || st->buf[1] != '[')
307+
return PARSE_ERR;
308+
if (st->len < 4)
309+
return PARSE_NEED_MORE;
310+
if (st->buf[2] < '0' || st->buf[2] > '9')
311+
return PARSE_ERR;
312+
313+
int cp = -1;
314+
int mod = -1;
315+
int cur = -1;
316+
int i = 2;
317+
int done = 0;
318+
319+
while (i < st->len && !done) {
320+
char c = st->buf[i];
321+
if (c >= '0' && c <= '9') {
322+
if (cur == -1)
323+
cur = 0;
324+
cur = cur * 10 + (c - '0');
325+
} else if (c == ';' && cp == -1 && cur != -1) {
326+
cp = cur;
327+
cur = -1;
328+
} else if (c == 'u' && cur != -1) {
329+
if (cp == -1) {
330+
cp = cur;
331+
mod = 1;
332+
} else {
333+
mod = cur;
334+
}
335+
done = 1;
336+
} else if (c == ':' && cur != -1) {
337+
/* sub-field separator (event-type, alternate keys) — skip */
338+
if (cp == -1) {
339+
cp = cur;
340+
}
341+
cur = -1;
342+
/* consume until next ';' or 'u' */
343+
i++;
344+
while (i < st->len && st->buf[i] != ';' && st->buf[i] != 'u')
345+
i++;
346+
continue;
347+
} else {
348+
return PARSE_ERR;
349+
}
350+
i++;
351+
}
352+
353+
if (!done)
354+
return PARSE_NEED_MORE;
355+
356+
ev->type = EVENT_KEY;
357+
ev->mod = kitty_mod(mod);
358+
359+
uint16_t key = kitty_key(cp);
360+
if (key) {
361+
ev->key = key;
362+
} else {
363+
ev->ch = (uint32_t)cp;
364+
}
365+
366+
shift(st, i);
367+
return PARSE_OK;
368+
}
369+
219370
/* ── Cap table (xterm defaults) ───────────────────────────────────── */
220371

221372
struct CapEntry {
@@ -540,6 +691,22 @@ int input_scan(struct InputState *st, const char *buf, int len, double now) {
540691
}
541692
}
542693

694+
/* try CSI u (Kitty keyboard protocol) */
695+
{
696+
struct InputEvent kev;
697+
memset(&kev, 0, sizeof(kev));
698+
int rv = parse_csi_u(st, &kev);
699+
if (rv == PARSE_OK) {
700+
struct InputEvent *ev = emit(st);
701+
*ev = kev;
702+
st->esc_time = 0;
703+
continue;
704+
}
705+
if (rv == PARSE_NEED_MORE) {
706+
return accepted;
707+
}
708+
}
709+
543710
/* unrecognized ESC sequence: treat as Alt + next byte */
544711
shift(st, 1);
545712
st->esc_time = 0;

test/input.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,88 @@ describe("input", () => {
342342
});
343343
});
344344

345+
describe("Kitty keyboard protocol (CSI u)", () => {
346+
it("parses character with alt modifier", () => {
347+
let result = input.scan(str("\x1b[97;3u")); // a + Alt
348+
expect(result.events.length).toBe(1);
349+
expect(result.events[0]).toMatchObject({
350+
type: "char",
351+
key: "a",
352+
alt: true,
353+
});
354+
});
355+
356+
it("parses Tab with alt modifier", () => {
357+
let result = input.scan(str("\x1b[9;3u"));
358+
expect(result.events.length).toBe(1);
359+
expect(result.events[0]).toMatchObject({
360+
type: "key",
361+
key: "Tab",
362+
alt: true,
363+
});
364+
});
365+
366+
it("parses Enter with ctrl modifier", () => {
367+
let result = input.scan(str("\x1b[13;5u"));
368+
expect(result.events.length).toBe(1);
369+
expect(result.events[0]).toMatchObject({
370+
type: "key",
371+
key: "Enter",
372+
ctrl: true,
373+
});
374+
});
375+
376+
it("parses Escape with shift modifier", () => {
377+
let result = input.scan(str("\x1b[27;2u"));
378+
expect(result.events.length).toBe(1);
379+
expect(result.events[0]).toMatchObject({
380+
type: "key",
381+
key: "Escape",
382+
shift: true,
383+
});
384+
});
385+
386+
it("parses Backspace without modifiers", () => {
387+
let result = input.scan(str("\x1b[127u"));
388+
expect(result.events.length).toBe(1);
389+
expect(result.events[0]).toMatchObject({
390+
type: "key",
391+
key: "Backspace",
392+
});
393+
});
394+
395+
it("parses combined modifiers", () => {
396+
let result = input.scan(str("\x1b[105;7u")); // i + Ctrl+Alt
397+
expect(result.events.length).toBe(1);
398+
expect(result.events[0]).toMatchObject({
399+
type: "char",
400+
key: "i",
401+
ctrl: true,
402+
alt: true,
403+
});
404+
});
405+
406+
it("parses F1 functional key codepoint", () => {
407+
let result = input.scan(str("\x1b[57376;2u")); // F1 + Shift
408+
expect(result.events.length).toBe(1);
409+
expect(result.events[0]).toMatchObject({
410+
type: "key",
411+
key: "F1",
412+
shift: true,
413+
});
414+
});
415+
416+
it("parses arrow key codepoint", () => {
417+
let result = input.scan(str("\x1b[57352;5u")); // ArrowUp + Ctrl
418+
expect(result.events.length).toBe(1);
419+
expect(result.events[0]).toMatchObject({
420+
type: "key",
421+
key: "ArrowUp",
422+
ctrl: true,
423+
});
424+
});
425+
});
426+
345427
describe("UTF-8", () => {
346428
it("parses 2-byte UTF-8 (é)", () => {
347429
let result = input.scan(bytes(0xc3, 0xa9));

0 commit comments

Comments
 (0)