[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

๐Ÿ› fix keycode mapping and legacy CSI parsing

Correct modifier key codepoint mapping to match the spec (all-lefts-first
57441โ€“57446, then all-rights 57447โ€“57452). Add parser for Kitty-enhanced
legacy CSI sequences (arrows, function keys, tilde-style) that include
`:action` sub-parameters.

Charles Lowell (Mar 27, 2026, 11:44 PM -0500) 92b123a7 c143098d

+484 -74
+1
.gitignore
··· 1 1 /.agent-shell/ 2 2 /clayterm.wasm 3 3 /build/ 4 + /node_modules/
+60 -66
demo/keyboard.ts
··· 1 + // deno-lint-ignore-file no-fallthrough 1 2 import { each, ensure, main, until } from "effection"; 2 3 import { 3 4 close, ··· 34 35 return e.type === "keydown" || e.type === "keyrepeat" || e.type === "keyup"; 35 36 } 36 37 37 - function is(char: string): (event: InputEvent) => boolean { 38 + function is(...codes: string[]): (event: InputEvent) => boolean { 38 39 return (e) => 39 - isKeyEvent(e) && e.key.toUpperCase() === char.toUpperCase(); 40 + isKeyEvent(e) && e.type === "keydown" && codes.some((c) => e.code.toUpperCase() === c.toUpperCase()); 40 41 } 41 42 42 - function mod(name: "ctrl" | "alt" | "shift"): (event: InputEvent) => boolean { 43 - return (e) => isKeyEvent(e) && e[name] === true; 44 - } 45 - 46 - function never(): boolean { 47 - return false; 48 - } 49 43 50 44 function key(ops: Op[], k: KeyDef, ctx: AppContext): void { 51 45 let bg = ctx.event && k.match(ctx.event) ? active : inactive; ··· 139 133 ], ctx); 140 134 141 135 row(ops, [ 142 - { label: "Caps", width: 9, match: never }, 136 + { label: "Caps", width: 9, match: is("CapsLock") }, 143 137 { label: "A", match: is("a") }, 144 138 { label: "S", match: is("s") }, 145 139 { label: "D", match: is("d") }, ··· 155 149 ], ctx); 156 150 157 151 row(ops, [ 158 - { label: "Shift", width: 11, match: mod("shift") }, 152 + { label: "Shift", width: 11, match: is("ShiftLeft") }, 159 153 { label: "Z", match: is("z") }, 160 154 { label: "X", match: is("x") }, 161 155 { label: "C", match: is("c") }, ··· 166 160 { label: ",", match: is(",") }, 167 161 { label: ".", match: is(".") }, 168 162 { label: "/", match: is("/") }, 169 - { label: "Shift", width: 13, match: mod("shift") }, 163 + { label: "Shift", width: 13, match: is("ShiftRight") }, 170 164 ], ctx); 171 165 172 166 row(ops, [ 173 - { label: "Ctrl", width: 7, match: mod("ctrl") }, 174 - { label: "Win", width: 6, match: never }, 175 - { label: "Alt", width: 6, match: mod("alt") }, 167 + { label: "Ctrl", width: 7, match: is("ControlLeft") }, 168 + { label: "Win", width: 6, match: is("SuperLeft") }, 169 + { label: "Alt", width: 6, match: is("AltLeft") }, 176 170 { label: "", width: 33, match: is(" ") }, 177 - { label: "Alt", width: 6, match: mod("alt") }, 178 - { label: "Win", width: 6, match: never }, 179 - { label: "Menu", width: 6, match: never }, 180 - { label: "Ctrl", width: 7, match: mod("ctrl") }, 171 + { label: "Alt", width: 6, match: is("AltRight") }, 172 + { label: "Win", width: 6, match: is("SuperRight") }, 173 + { label: "Menu", width: 6, match: is() }, 174 + { label: "Ctrl", width: 7, match: is("ControlRight") }, 181 175 ], ctx); 182 176 183 177 ops.push(close()); ··· 232 226 ); 233 227 234 228 row(ops, [ 235 - { label: "Num", width: 6, match: never }, 236 - { label: "/", width: 6, match: never }, 237 - { label: "*", width: 6, match: never }, 238 - { label: "-", width: 6, match: never }, 229 + { label: "Num", width: 6, match: is("NumLock") }, 230 + { label: "/", width: 6, match: is("NumpadDivide") }, 231 + { label: "*", width: 6, match: is("NumpadMultiply") }, 232 + { label: "-", width: 6, match: is("NumpadSubtract") }, 239 233 ], ctx); 240 234 241 235 // rows 2-3 grouped horizontally so + spans both ··· 248 242 open("box", { layout: { direction: "ttb", gap: GAP } }), 249 243 ); 250 244 row(ops, [ 251 - { label: "7", width: 6, match: never }, 252 - { label: "8", width: 6, match: never }, 253 - { label: "9", width: 6, match: never }, 245 + { label: "7", width: 6, match: is("Numpad7") }, 246 + { label: "8", width: 6, match: is("Numpad8") }, 247 + { label: "9", width: 6, match: is("Numpad9") }, 254 248 ], ctx); 255 249 row(ops, [ 256 - { label: "4", width: 6, match: never }, 257 - { label: "5", width: 6, match: never }, 258 - { label: "6", width: 6, match: never }, 250 + { label: "4", width: 6, match: is("Numpad4") }, 251 + { label: "5", width: 6, match: is("Numpad5") }, 252 + { label: "6", width: 6, match: is("Numpad6") }, 259 253 ], ctx); 260 254 ops.push(close()); 261 255 ··· 287 281 open("box", { layout: { direction: "ttb", gap: GAP } }), 288 282 ); 289 283 row(ops, [ 290 - { label: "1", width: 6, match: never }, 291 - { label: "2", width: 6, match: never }, 292 - { label: "3", width: 6, match: never }, 284 + { label: "1", width: 6, match: is("Numpad1") }, 285 + { label: "2", width: 6, match: is("Numpad2") }, 286 + { label: "3", width: 6, match: is("Numpad3") }, 293 287 ], ctx); 294 288 row(ops, [ 295 - { label: "0", width: 13, match: never }, 296 - { label: ".", width: 6, match: never }, 289 + { label: "0", width: 13, match: is("Numpad0") }, 290 + { label: ".", width: 6, match: is("NumpadDecimal") }, 297 291 ], ctx); 298 292 ops.push(close()); 299 293 ··· 357 351 358 352 ops.push( 359 353 open("box", { layout: { height: fixed(1) } }), 360 - text("Keyboard Protocol", { color: highlight }), 354 + text("Keyboard Protocol Level", { color: highlight }), 361 355 close(), 362 356 ); 363 357 ··· 401 395 let badgeLabel = ctx.mode === "input" ? "input" : "config"; 402 396 let badgeHint = ctx.mode === "input" 403 397 ? "Ctrl+X Ctrl+X to enter config" 404 - : "Set flags with keys [1-5], Escape to exit"; 398 + : "Set flags with keys [1-5], Enter to save"; 405 399 ops.push( 406 400 open("box", { layout: { direction: "ltr", height: fixed(1), padding: { bottom: 1 } } }), 407 401 open("box", { layout: { padding: { left: 1, right: 1 } }, bg: rgba(60, 60, 60) }), ··· 474 468 if (ctx["Report alternate keys"]) bits |= 4; 475 469 if (ctx["Report all keys as escapes"]) bits |= 8; 476 470 if (ctx["Report associated text"]) bits |= 16; 477 - return encoder.encode(`\x1b[=${bits};3u`); 471 + return encoder.encode(`\x1b[<u\x1b[>${bits}u`); 478 472 } 479 473 480 474 await main(function* () { ··· 489 483 490 484 let term = yield* until(createTerm({ width: columns, height: rows })); 491 485 492 - esc("\x1b[?1049h\x1b[?25l"); 486 + esc("\x1b[?1049h\x1b[?25l\x1b[>3u"); 493 487 yield* ensure(() => { 494 - esc("\x1b[?25h\x1b[?1049l"); 488 + esc("\x1b[<u\x1b[?25h\x1b[?1049l"); 495 489 }); 496 490 497 491 let modality = recognizer(); ··· 518 512 function* recognizer(): Iterator<AppContext, never, InputEvent> { 519 513 let current: AppContext = { 520 514 mode: "input", 521 - "Disambiguate escape codes": false, 522 - "Report event types": false, 515 + "Disambiguate escape codes": true, 516 + "Report event types": true, 523 517 "Report alternate keys": false, 524 518 "Report all keys as escapes": false, 525 519 "Report associated text": false, ··· 544 538 context = { ...context, event }; 545 539 if (event.type === "keydown" && event.key === "x" && event.ctrl) { 546 540 let next = yield context; 547 - context = { 548 - ...context, 549 - event: next, 550 - }; 551 - if (next.type === "keydown" && next.key === "x" && next.ctrl) { 541 + while (next.type !== "keydown") { 542 + context = { ...context, event: next }; 543 + next = yield context; 544 + } 545 + context = { ...context, event: next }; 546 + if (next.key === "x" && next.ctrl) { 552 547 return configmode({ 553 548 ...context, 554 549 event: null, ··· 563 558 context = { ...context, mode: "config" }; 564 559 let event = yield context; 565 560 while (true) { 566 - if (event.type === "keydown" && event.key === "Escape") { 561 + if (event.type === "keydown" && event.key === "Enter") { 567 562 return inputmode({...context, event: null }); 568 563 } 569 - if (event.type === "keydown") { 564 + if (event.type === "keydown" && "012345".indexOf(event.key) >= 0) { 565 + context = { ...context }; 566 + context["Report associated text"] = false; 567 + context["Report all keys as escapes"] = false; 568 + context["Report alternate keys"] = false; 569 + context["Report event types"] = false; 570 + context["Disambiguate escape codes"] = false; 570 571 switch (event.key) { 571 - case "1": { 572 - context = {...context, ["Disambiguate escape codes"]: !context["Disambiguate escape codes"]}; 572 + case "5": 573 + context["Report associated text"] = true; 574 + case "4": 575 + context["Report all keys as escapes"] = true; 576 + case "3": 577 + context["Report alternate keys"] = true; 578 + case "2": 579 + context["Report event types"] = true; 580 + case "1": 581 + context["Disambiguate escape codes"] = true; 573 582 break; 574 - } 575 - case "2": { 576 - context = {...context, ["Report event types"]: !context["Report event types"]}; 583 + case "0": 577 584 break; 578 - } 579 - case "3": { 580 - context = {...context, ["Report alternate keys"]: !context["Report alternate keys"]}; 581 - break; 582 - } 583 - case "4": { 584 - context = {...context, ["Report all keys as escapes"]: !context["Report all keys as escapes"]}; 585 - break; 586 - } 587 - case "5": { 588 - context = {...context, ["Report associated text"]: !context["Report associated text"]}; 589 - break; 590 - } 591 585 } 592 586 } 593 587 event = yield context;
+34
input-native.ts
··· 31 31 export const KEY_PGUP = 0xFFEB; 32 32 export const KEY_PGDN = 0xFFEA; 33 33 export const KEY_BACKTAB = 0xFFE9; 34 + export const KEY_NUMPAD_0 = 0xFFE2; 35 + export const KEY_NUMPAD_1 = 0xFFE1; 36 + export const KEY_NUMPAD_2 = 0xFFE0; 37 + export const KEY_NUMPAD_3 = 0xFFDF; 38 + export const KEY_NUMPAD_4 = 0xFFDE; 39 + export const KEY_NUMPAD_5 = 0xFFDD; 40 + export const KEY_NUMPAD_6 = 0xFFDC; 41 + export const KEY_NUMPAD_7 = 0xFFDB; 42 + export const KEY_NUMPAD_8 = 0xFFDA; 43 + export const KEY_NUMPAD_9 = 0xFFD9; 44 + export const KEY_NUMPAD_DECIMAL = 0xFFD8; 45 + export const KEY_NUMPAD_DIVIDE = 0xFFD7; 46 + export const KEY_NUMPAD_MULTIPLY = 0xFFD6; 47 + export const KEY_NUMPAD_SUBTRACT = 0xFFD5; 48 + export const KEY_NUMPAD_ADD = 0xFFD4; 49 + export const KEY_NUMPAD_ENTER = 0xFFD3; 50 + export const KEY_NUMPAD_EQUAL = 0xFFD2; 51 + 52 + export const KEY_SHIFT_LEFT = 0xFFD1; 53 + export const KEY_SHIFT_RIGHT = 0xFFD0; 54 + export const KEY_CONTROL_LEFT = 0xFFCF; 55 + export const KEY_CONTROL_RIGHT = 0xFFCE; 56 + export const KEY_ALT_LEFT = 0xFFCD; 57 + export const KEY_ALT_RIGHT = 0xFFCC; 58 + export const KEY_SUPER_LEFT = 0xFFCB; 59 + export const KEY_SUPER_RIGHT = 0xFFCA; 60 + export const KEY_HYPER_LEFT = 0xFFC9; 61 + export const KEY_HYPER_RIGHT = 0xFFC8; 62 + export const KEY_META_LEFT = 0xFFC7; 63 + export const KEY_META_RIGHT = 0xFFC6; 64 + export const KEY_CAPS_LOCK = 0xFFC5; 65 + export const KEY_NUM_LOCK = 0xFFC4; 66 + export const KEY_SCROLL_LOCK = 0xFFC3; 67 + 34 68 export const KEY_MOUSE_LEFT = 0xFFE8; 35 69 export const KEY_MOUSE_RIGHT = 0xFFE7; 36 70 export const KEY_MOUSE_MIDDLE = 0xFFE6;
+97 -5
input.ts
··· 35 35 KEY_F9, 36 36 KEY_HOME, 37 37 KEY_INSERT, 38 + KEY_NUMPAD_0, 39 + KEY_NUMPAD_1, 40 + KEY_NUMPAD_2, 41 + KEY_NUMPAD_3, 42 + KEY_NUMPAD_4, 43 + KEY_NUMPAD_5, 44 + KEY_NUMPAD_6, 45 + KEY_NUMPAD_7, 46 + KEY_NUMPAD_8, 47 + KEY_NUMPAD_9, 48 + KEY_NUMPAD_ADD, 49 + KEY_NUMPAD_DECIMAL, 50 + KEY_NUMPAD_DIVIDE, 51 + KEY_NUMPAD_ENTER, 52 + KEY_NUMPAD_EQUAL, 53 + KEY_NUMPAD_MULTIPLY, 54 + KEY_NUMPAD_SUBTRACT, 55 + KEY_META_LEFT, 56 + KEY_META_RIGHT, 57 + KEY_NUM_LOCK, 58 + KEY_SCROLL_LOCK, 59 + KEY_SHIFT_LEFT, 60 + KEY_SHIFT_RIGHT, 61 + KEY_CONTROL_LEFT, 62 + KEY_CONTROL_RIGHT, 63 + KEY_ALT_LEFT, 64 + KEY_ALT_RIGHT, 65 + KEY_SUPER_LEFT, 66 + KEY_SUPER_RIGHT, 67 + KEY_HYPER_LEFT, 68 + KEY_HYPER_RIGHT, 69 + KEY_CAPS_LOCK, 38 70 KEY_MOUSE_LEFT, 39 71 KEY_MOUSE_MIDDLE, 40 72 KEY_MOUSE_RELEASE, ··· 65 97 } 66 98 67 99 /** 100 + * Physical key identity on a US PC-101 layout. 101 + */ 102 + export type KeyCode = 103 + | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" 104 + | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" 105 + | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" 106 + | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" 107 + | "`" | "-" | "=" | "[" | "]" | "\\" | ";" | "'" | "," | "." | "/" | " " 108 + | "F1" | "F2" | "F3" | "F4" | "F5" | "F6" 109 + | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" 110 + | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" 111 + | "Home" | "End" | "Insert" | "Delete" 112 + | "PageUp" | "PageDown" | "Backtab" 113 + | "Backspace" | "Tab" | "Enter" | "Escape" 114 + | "Numpad0" | "Numpad1" | "Numpad2" | "Numpad3" | "Numpad4" 115 + | "Numpad5" | "Numpad6" | "Numpad7" | "Numpad8" | "Numpad9" 116 + | "NumpadDecimal" | "NumpadDivide" | "NumpadMultiply" 117 + | "NumpadSubtract" | "NumpadAdd" | "NumpadEnter" | "NumpadEqual" 118 + | "ShiftLeft" | "ShiftRight" 119 + | "ControlLeft" | "ControlRight" 120 + | "AltLeft" | "AltRight" 121 + | "SuperLeft" | "SuperRight" 122 + | "HyperLeft" | "HyperRight" 123 + | "MetaLeft" | "MetaRight" 124 + | "CapsLock" | "NumLock" | "ScrollLock"; 125 + 126 + /** 68 127 * Shared key information present on all keyboard events. 69 128 */ 70 129 export interface KeyInfo extends KeyModifiers { 71 130 key: string; 131 + code: KeyCode; 72 132 } 73 133 74 134 /** ··· 78 138 export interface KeyDown extends KeyInfo { 79 139 type: "keydown"; 80 140 shifted?: string; 81 - base?: string; 82 141 text?: string; 83 142 } 84 143 ··· 89 148 export interface KeyRepeat extends KeyInfo { 90 149 type: "keyrepeat"; 91 150 shifted?: string; 92 - base?: string; 93 151 text?: string; 94 152 } 95 153 ··· 349 407 [KEY_TAB, "Tab"], 350 408 [KEY_ENTER, "Enter"], 351 409 [KEY_ESC, "Escape"], 410 + [KEY_NUMPAD_0, "Numpad0"], 411 + [KEY_NUMPAD_1, "Numpad1"], 412 + [KEY_NUMPAD_2, "Numpad2"], 413 + [KEY_NUMPAD_3, "Numpad3"], 414 + [KEY_NUMPAD_4, "Numpad4"], 415 + [KEY_NUMPAD_5, "Numpad5"], 416 + [KEY_NUMPAD_6, "Numpad6"], 417 + [KEY_NUMPAD_7, "Numpad7"], 418 + [KEY_NUMPAD_8, "Numpad8"], 419 + [KEY_NUMPAD_9, "Numpad9"], 420 + [KEY_NUMPAD_DECIMAL, "NumpadDecimal"], 421 + [KEY_NUMPAD_DIVIDE, "NumpadDivide"], 422 + [KEY_NUMPAD_MULTIPLY, "NumpadMultiply"], 423 + [KEY_NUMPAD_SUBTRACT, "NumpadSubtract"], 424 + [KEY_NUMPAD_ADD, "NumpadAdd"], 425 + [KEY_NUMPAD_ENTER, "NumpadEnter"], 426 + [KEY_NUMPAD_EQUAL, "NumpadEqual"], 427 + [KEY_SHIFT_LEFT, "ShiftLeft"], 428 + [KEY_SHIFT_RIGHT, "ShiftRight"], 429 + [KEY_CONTROL_LEFT, "ControlLeft"], 430 + [KEY_CONTROL_RIGHT, "ControlRight"], 431 + [KEY_ALT_LEFT, "AltLeft"], 432 + [KEY_ALT_RIGHT, "AltRight"], 433 + [KEY_SUPER_LEFT, "SuperLeft"], 434 + [KEY_SUPER_RIGHT, "SuperRight"], 435 + [KEY_HYPER_LEFT, "HyperLeft"], 436 + [KEY_HYPER_RIGHT, "HyperRight"], 437 + [KEY_META_LEFT, "MetaLeft"], 438 + [KEY_META_RIGHT, "MetaRight"], 439 + [KEY_CAPS_LOCK, "CapsLock"], 440 + [KEY_NUM_LOCK, "NumLock"], 441 + [KEY_SCROLL_LOCK, "ScrollLock"], 352 442 ]); 353 443 354 444 const BUTTON_NAMES = new Map<number, MouseEvent["button"]>([ ··· 389 479 390 480 function mapKeyEvent(native: NativeInputEvent): KeyEvent { 391 481 let key = keyName(native); 482 + let code = (native.base > 0 483 + ? String.fromCodePoint(native.base) 484 + : key) as KeyCode; 392 485 let m = mods(native); 393 486 let isChar = !KEY_NAMES.has(native.key); 394 487 let text = textFromNative(native); 395 488 396 489 if (native.action === 3) { 397 - return { type: "keyup", key, ...m }; 490 + return { type: "keyup", key, code, ...m }; 398 491 } 399 492 400 493 let type: "keydown" | "keyrepeat" = native.action === 2 401 494 ? "keyrepeat" 402 495 : "keydown"; 403 496 404 - let ev: KeyDown | KeyRepeat = { type, key, ...m }; 497 + let ev: KeyDown | KeyRepeat = { type, key, code, ...m }; 405 498 406 499 if (native.shifted > 0) ev.shifted = String.fromCodePoint(native.shifted); 407 - if (native.base > 0) ev.base = String.fromCodePoint(native.base); 408 500 if (text) { 409 501 ev.text = text; 410 502 } else if (isChar && type === "keydown" && native.ch > 0) {
+190
src/input.c
··· 280 280 return KEY_F11; 281 281 case 57387: 282 282 return KEY_F12; 283 + case 57399: 284 + return KEY_NUMPAD_0; 285 + case 57400: 286 + return KEY_NUMPAD_1; 287 + case 57401: 288 + return KEY_NUMPAD_2; 289 + case 57402: 290 + return KEY_NUMPAD_3; 291 + case 57403: 292 + return KEY_NUMPAD_4; 293 + case 57404: 294 + return KEY_NUMPAD_5; 295 + case 57405: 296 + return KEY_NUMPAD_6; 297 + case 57406: 298 + return KEY_NUMPAD_7; 299 + case 57407: 300 + return KEY_NUMPAD_8; 301 + case 57408: 302 + return KEY_NUMPAD_9; 303 + case 57409: 304 + return KEY_NUMPAD_DECIMAL; 305 + case 57410: 306 + return KEY_NUMPAD_DIVIDE; 307 + case 57411: 308 + return KEY_NUMPAD_MULTIPLY; 309 + case 57412: 310 + return KEY_NUMPAD_SUBTRACT; 311 + case 57413: 312 + return KEY_NUMPAD_ADD; 313 + case 57414: 314 + return KEY_NUMPAD_ENTER; 315 + case 57415: 316 + return KEY_NUMPAD_EQUAL; 317 + case 57441: 318 + return KEY_SHIFT_LEFT; 319 + case 57442: 320 + return KEY_CONTROL_LEFT; 321 + case 57443: 322 + return KEY_ALT_LEFT; 323 + case 57444: 324 + return KEY_SUPER_LEFT; 325 + case 57445: 326 + return KEY_HYPER_LEFT; 327 + case 57446: 328 + return KEY_META_LEFT; 329 + case 57447: 330 + return KEY_SHIFT_RIGHT; 331 + case 57448: 332 + return KEY_CONTROL_RIGHT; 333 + case 57449: 334 + return KEY_ALT_RIGHT; 335 + case 57450: 336 + return KEY_SUPER_RIGHT; 337 + case 57451: 338 + return KEY_HYPER_RIGHT; 339 + case 57452: 340 + return KEY_META_RIGHT; 341 + case 57358: 342 + return KEY_CAPS_LOCK; 343 + case 57359: 344 + return KEY_NUM_LOCK; 345 + case 57360: 346 + return KEY_SCROLL_LOCK; 283 347 default: 284 348 return 0; 285 349 } ··· 424 488 ev->text[tc++] = (uint32_t)val; 425 489 ev->text_len = (uint8_t)tc; 426 490 } 491 + 492 + shift(st, i); 493 + return PARSE_OK; 494 + } 495 + 496 + static uint16_t csi_legacy_key(char term, int number) { 497 + switch (term) { 498 + case 'A': return KEY_ARROW_UP; 499 + case 'B': return KEY_ARROW_DOWN; 500 + case 'C': return KEY_ARROW_RIGHT; 501 + case 'D': return KEY_ARROW_LEFT; 502 + case 'H': return KEY_HOME; 503 + case 'F': return KEY_END; 504 + case 'P': return KEY_F1; 505 + case 'Q': return KEY_F2; 506 + case 'S': return KEY_F4; 507 + case '~': 508 + switch (number) { 509 + case 2: return KEY_INSERT; 510 + case 3: return KEY_DELETE; 511 + case 5: return KEY_PGUP; 512 + case 6: return KEY_PGDN; 513 + case 7: return KEY_HOME; 514 + case 8: return KEY_END; 515 + case 11: return KEY_F1; 516 + case 12: return KEY_F2; 517 + case 13: return KEY_F3; 518 + case 14: return KEY_F4; 519 + case 15: return KEY_F5; 520 + case 17: return KEY_F6; 521 + case 18: return KEY_F7; 522 + case 19: return KEY_F8; 523 + case 20: return KEY_F9; 524 + case 21: return KEY_F10; 525 + case 23: return KEY_F11; 526 + case 24: return KEY_F12; 527 + default: return 0; 528 + } 529 + default: return 0; 530 + } 531 + } 532 + 533 + /* Parse Kitty-enhanced legacy CSI sequences (non-u terminators). 534 + * Format: CSI [number] [; mod[:action]] terminator 535 + * Handles A-D, F, H, P, Q, S, ~ terminators with optional :action */ 536 + static int parse_csi_legacy(struct InputState *st, struct InputEvent *ev) { 537 + if (st->len < 2) 538 + return PARSE_NEED_MORE; 539 + if (st->buf[0] != '\x1b' || st->buf[1] != '[') 540 + return PARSE_ERR; 541 + if (st->len < 3) 542 + return PARSE_NEED_MORE; 543 + 544 + int number = -1; 545 + int mod = -1; 546 + int action = -1; 547 + int param = 0; 548 + int sub = 0; 549 + int i = 2; 550 + int cur = -1; 551 + char term = 0; 552 + 553 + while (i < st->len) { 554 + char c = st->buf[i]; 555 + if (c >= '0' && c <= '9') { 556 + if (cur == -1) 557 + cur = 0; 558 + cur = cur * 10 + (c - '0'); 559 + } else if (c == ';') { 560 + if (param == 0) 561 + number = cur; 562 + else if (param == 1 && sub == 0) 563 + mod = cur; 564 + cur = -1; 565 + param++; 566 + sub = 0; 567 + } else if (c == ':') { 568 + if (param == 1 && sub == 0) 569 + mod = cur; 570 + cur = -1; 571 + sub++; 572 + } else if ((c >= 'A' && c <= 'D') || c == 'F' || c == 'H' || 573 + c == 'P' || c == 'Q' || c == 'S' || c == '~') { 574 + if (param == 0) 575 + number = cur; 576 + else if (param == 1 && sub == 0) 577 + mod = cur; 578 + else if (param == 1 && sub > 0) 579 + action = cur; 580 + term = c; 581 + i++; 582 + break; 583 + } else { 584 + return PARSE_ERR; 585 + } 586 + i++; 587 + } 588 + 589 + if (term == 0) 590 + return PARSE_NEED_MORE; 591 + 592 + uint16_t key = csi_legacy_key(term, number > 0 ? number : 0); 593 + if (key == 0) 594 + return PARSE_ERR; 595 + 596 + ev->type = EVENT_KEY; 597 + ev->key = key; 598 + ev->mod = kitty_mod(mod > 0 ? mod : 1); 599 + if (action > 0) 600 + ev->action = (uint8_t)action; 427 601 428 602 shift(st, i); 429 603 return PARSE_OK; ··· 758 932 struct InputEvent kev; 759 933 memset(&kev, 0, sizeof(kev)); 760 934 int rv = parse_csi_u(st, &kev); 935 + if (rv == PARSE_OK) { 936 + struct InputEvent *ev = emit(st); 937 + *ev = kev; 938 + st->esc_time = 0; 939 + continue; 940 + } 941 + if (rv == PARSE_NEED_MORE) { 942 + return accepted; 943 + } 944 + } 945 + 946 + /* try Kitty-enhanced legacy CSI (arrows, fn keys, etc.) */ 947 + { 948 + struct InputEvent kev; 949 + memset(&kev, 0, sizeof(kev)); 950 + int rv = parse_csi_legacy(st, &kev); 761 951 if (rv == PARSE_OK) { 762 952 struct InputEvent *ev = emit(st); 763 953 *ev = kev;
+36
src/input.h
··· 103 103 #define KEY_PGDN 0xFFEA 104 104 #define KEY_BACKTAB 0xFFE9 105 105 106 + /* Numpad */ 107 + #define KEY_NUMPAD_0 0xFFE2 108 + #define KEY_NUMPAD_1 0xFFE1 109 + #define KEY_NUMPAD_2 0xFFE0 110 + #define KEY_NUMPAD_3 0xFFDF 111 + #define KEY_NUMPAD_4 0xFFDE 112 + #define KEY_NUMPAD_5 0xFFDD 113 + #define KEY_NUMPAD_6 0xFFDC 114 + #define KEY_NUMPAD_7 0xFFDB 115 + #define KEY_NUMPAD_8 0xFFDA 116 + #define KEY_NUMPAD_9 0xFFD9 117 + #define KEY_NUMPAD_DECIMAL 0xFFD8 118 + #define KEY_NUMPAD_DIVIDE 0xFFD7 119 + #define KEY_NUMPAD_MULTIPLY 0xFFD6 120 + #define KEY_NUMPAD_SUBTRACT 0xFFD5 121 + #define KEY_NUMPAD_ADD 0xFFD4 122 + #define KEY_NUMPAD_ENTER 0xFFD3 123 + #define KEY_NUMPAD_EQUAL 0xFFD2 124 + 125 + /* Modifier keys */ 126 + #define KEY_SHIFT_LEFT 0xFFD1 127 + #define KEY_SHIFT_RIGHT 0xFFD0 128 + #define KEY_CONTROL_LEFT 0xFFCF 129 + #define KEY_CONTROL_RIGHT 0xFFCE 130 + #define KEY_ALT_LEFT 0xFFCD 131 + #define KEY_ALT_RIGHT 0xFFCC 132 + #define KEY_SUPER_LEFT 0xFFCB 133 + #define KEY_SUPER_RIGHT 0xFFCA 134 + #define KEY_HYPER_LEFT 0xFFC9 135 + #define KEY_HYPER_RIGHT 0xFFC8 136 + #define KEY_META_LEFT 0xFFC7 137 + #define KEY_META_RIGHT 0xFFC6 138 + #define KEY_CAPS_LOCK 0xFFC5 139 + #define KEY_NUM_LOCK 0xFFC4 140 + #define KEY_SCROLL_LOCK 0xFFC3 141 + 106 142 /* Mouse */ 107 143 #define KEY_MOUSE_LEFT 0xFFE8 108 144 #define KEY_MOUSE_RIGHT 0xFFE7
+66 -3
test/input.test.ts
··· 473 473 expect(result.events[0]).toMatchObject({ 474 474 type: "keydown", 475 475 key: "a", 476 + code: "a", 476 477 alt: true, 477 478 shifted: "A", 478 - base: "a", 479 479 }); 480 480 }); 481 481 482 - it("parses empty shifted with base key", () => { 482 + it("parses empty shifted with base key as code", () => { 483 483 let result = input.scan(str("\x1b[1057::99;5u")); 484 484 expect(result.events.length).toBe(1); 485 485 expect(result.events[0]).toMatchObject({ 486 486 type: "keydown", 487 487 key: "\u0421", 488 + code: "c", 488 489 ctrl: true, 489 - base: "c", 490 490 }); 491 491 }); 492 492 }); ··· 541 541 expect("text" in ev).toBe(false); 542 542 expect("shifted" in ev).toBe(false); 543 543 expect("base" in ev).toBe(false); 544 + }); 545 + }); 546 + 547 + describe("Kitty legacy CSI sequences", () => { 548 + it("parses arrow up with action", () => { 549 + let result = input.scan(str("\x1b[1;1:1A")); 550 + expect(result.events.length).toBe(1); 551 + expect(result.events[0]).toMatchObject({ 552 + type: "keydown", 553 + key: "ArrowUp", 554 + code: "ArrowUp", 555 + }); 556 + }); 557 + 558 + it("parses arrow up release", () => { 559 + let result = input.scan(str("\x1b[1;1:3A")); 560 + expect(result.events.length).toBe(1); 561 + expect(result.events[0]).toMatchObject({ 562 + type: "keyup", 563 + key: "ArrowUp", 564 + code: "ArrowUp", 565 + }); 566 + }); 567 + 568 + it("parses arrow with shift modifier", () => { 569 + let result = input.scan(str("\x1b[1;2:1D")); 570 + expect(result.events.length).toBe(1); 571 + expect(result.events[0]).toMatchObject({ 572 + type: "keydown", 573 + key: "ArrowLeft", 574 + code: "ArrowLeft", 575 + shift: true, 576 + }); 577 + }); 578 + 579 + it("parses plain arrow without action", () => { 580 + let result = input.scan(str("\x1b[1;1A")); 581 + expect(result.events.length).toBe(1); 582 + expect(result.events[0]).toMatchObject({ 583 + type: "keydown", 584 + key: "ArrowUp", 585 + code: "ArrowUp", 586 + }); 587 + }); 588 + 589 + it("parses delete with action", () => { 590 + let result = input.scan(str("\x1b[3;1:1~")); 591 + expect(result.events.length).toBe(1); 592 + expect(result.events[0]).toMatchObject({ 593 + type: "keydown", 594 + key: "Delete", 595 + code: "Delete", 596 + }); 597 + }); 598 + 599 + it("parses F1 with action", () => { 600 + let result = input.scan(str("\x1b[1;1:1P")); 601 + expect(result.events.length).toBe(1); 602 + expect(result.events[0]).toMatchObject({ 603 + type: "keydown", 604 + key: "F1", 605 + code: "F1", 606 + }); 544 607 }); 545 608 }); 546 609