@silvery/tea 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/keys.ts ADDED
@@ -0,0 +1,1382 @@
1
+ /**
2
+ * Keyboard Constants and Utilities
3
+ *
4
+ * Single source of truth for all key parsing, mapping, and matching in silvery.
5
+ *
6
+ * - KEY_MAP: Playwright key names -> ANSI sequences (for sending input)
7
+ * - CODE_TO_KEY: ANSI escape suffixes -> key names (for parsing input)
8
+ * - Key interface: structured key object with boolean flags
9
+ * - parseKeypress(): raw terminal input -> ParsedKeypress
10
+ * - parseKey(): raw terminal input -> [input, Key]
11
+ * - keyToAnsi(): Playwright key string -> ANSI sequence
12
+ * - keyToName(): Key object -> named key string
13
+ * - keyToModifiers(): Key object -> modifier flags
14
+ * - parseHotkey(): "ctrl+shift+a" -> ParsedHotkey
15
+ * - matchHotkey(): match ParsedHotkey against Key
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * import { keyToAnsi } from '@silvery/test'
20
+ *
21
+ * // Convert key names to ANSI
22
+ * keyToAnsi('Enter') // '\r'
23
+ * keyToAnsi('ArrowUp') // '\x1b[A'
24
+ * keyToAnsi('Control+c') // '\x03'
25
+ * keyToAnsi('a') // 'a'
26
+ * ```
27
+ */
28
+
29
+ // ============================================================================
30
+ // Types
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Key object describing which special keys/modifiers were pressed.
35
+ */
36
+ export interface Key {
37
+ /** Up arrow key was pressed */
38
+ upArrow: boolean
39
+ /** Down arrow key was pressed */
40
+ downArrow: boolean
41
+ /** Left arrow key was pressed */
42
+ leftArrow: boolean
43
+ /** Right arrow key was pressed */
44
+ rightArrow: boolean
45
+ /** Page Down key was pressed */
46
+ pageDown: boolean
47
+ /** Page Up key was pressed */
48
+ pageUp: boolean
49
+ /** Home key was pressed */
50
+ home: boolean
51
+ /** End key was pressed */
52
+ end: boolean
53
+ /** Return (Enter) key was pressed */
54
+ return: boolean
55
+ /** Escape key was pressed */
56
+ escape: boolean
57
+ /** Ctrl key was pressed */
58
+ ctrl: boolean
59
+ /** Shift key was pressed */
60
+ shift: boolean
61
+ /** Tab key was pressed */
62
+ tab: boolean
63
+ /** Backspace key was pressed */
64
+ backspace: boolean
65
+ /** Delete key was pressed */
66
+ delete: boolean
67
+ /** Meta key (Alt/Option on macOS, Alt on other platforms) was pressed */
68
+ meta: boolean
69
+ /** Super key (Cmd on macOS, Win on Windows) was pressed. Requires Kitty protocol. */
70
+ super: boolean
71
+ /** Hyper key was pressed. Requires Kitty protocol. */
72
+ hyper: boolean
73
+ /** CapsLock is active. Requires Kitty protocol. */
74
+ capsLock: boolean
75
+ /** NumLock is active. Requires Kitty protocol. */
76
+ numLock: boolean
77
+ /** Kitty event type. Only set with Kitty flag 2 (report events). */
78
+ eventType?: "press" | "repeat" | "release"
79
+ }
80
+
81
+ /**
82
+ * Input handler callback type.
83
+ * Return 'exit' to exit the app.
84
+ */
85
+ export type InputHandler = (input: string, key: Key) => void | "exit"
86
+
87
+ /**
88
+ * Parsed hotkey from a string like "ctrl+shift+a" or "Control+ArrowUp".
89
+ */
90
+ export interface ParsedHotkey {
91
+ key: string
92
+ ctrl: boolean
93
+ meta: boolean
94
+ shift: boolean
95
+ alt: boolean
96
+ super: boolean
97
+ hyper: boolean
98
+ }
99
+
100
+ // ============================================================================
101
+ // Key -> ANSI Mapping (for sending input)
102
+ // ============================================================================
103
+
104
+ /**
105
+ * Playwright-compatible key names -> ANSI sequences.
106
+ * Keys that are modifier-only (Control, Shift, etc.) are null.
107
+ */
108
+ const KEY_MAP: Record<string, string | null> = {
109
+ // Navigation (Playwright names)
110
+ ArrowUp: "\x1b[A",
111
+ ArrowDown: "\x1b[B",
112
+ ArrowLeft: "\x1b[D",
113
+ ArrowRight: "\x1b[C",
114
+ Home: "\x1b[H",
115
+ End: "\x1b[F",
116
+ PageUp: "\x1b[5~",
117
+ PageDown: "\x1b[6~",
118
+
119
+ // Editing
120
+ Enter: "\r",
121
+ Tab: "\t",
122
+ Backspace: "\x7f",
123
+ Delete: "\x1b[3~",
124
+ Escape: "\x1b",
125
+ Space: " ",
126
+
127
+ // Modifiers (prefix only, not standalone sequences)
128
+ Control: null,
129
+ Shift: null,
130
+ Alt: null,
131
+ Meta: null,
132
+ Super: null,
133
+ Hyper: null,
134
+ }
135
+
136
+ const MODIFIER_ALIASES: Record<string, string> = {
137
+ ctrl: "Control",
138
+ control: "Control",
139
+ "โŒƒ": "Control",
140
+ shift: "Shift",
141
+ "โ‡ง": "Shift",
142
+ alt: "Alt",
143
+ meta: "Meta",
144
+ opt: "Alt",
145
+ option: "Alt",
146
+ "โŒฅ": "Alt",
147
+ cmd: "Super",
148
+ command: "Super",
149
+ super: "Super",
150
+ "โŒ˜": "Super",
151
+ hyper: "Hyper",
152
+ "โœฆ": "Hyper",
153
+ }
154
+
155
+ /** Modifier symbols that can be used as prefixes without + separator (e.g. โŒ˜J, โŒƒโ‡งJ) */
156
+ const MODIFIER_SYMBOLS = new Set(["โŒƒ", "โ‡ง", "โŒฅ", "โŒ˜", "โœฆ"])
157
+
158
+ function normalizeModifier(mod: string): string {
159
+ return MODIFIER_ALIASES[mod.toLowerCase()] ?? mod
160
+ }
161
+
162
+ /**
163
+ * Convert Playwright-style key string to ANSI sequence.
164
+ *
165
+ * Supports:
166
+ * - Single characters: 'a', 'A', '1', etc.
167
+ * - Named keys: 'Enter', 'ArrowUp', 'Escape', etc.
168
+ * - Modifier combos: 'Control+c', 'Shift+Tab', 'Control+Shift+a'
169
+ * - Lowercase modifier aliases: 'ctrl+c', 'shift+Tab', 'alt+x'
170
+ *
171
+ * @example
172
+ * ```tsx
173
+ * keyToAnsi('Enter') // '\r'
174
+ * keyToAnsi('ArrowUp') // '\x1b[A'
175
+ * keyToAnsi('Control+c') // '\x03'
176
+ * keyToAnsi('j') // 'j'
177
+ * ```
178
+ */
179
+ export function keyToAnsi(key: string): string {
180
+ // Split on + for combos: 'Control+Shift+a' -> ['Control', 'Shift', 'a']
181
+ const parts = key.split("+")
182
+ const mainKey = parts.pop()!
183
+ // Normalize modifier aliases: ctrl->Control, shift->Shift, alt->Alt, meta->Meta
184
+ const modifiers = parts.map(normalizeModifier)
185
+
186
+ // Super/Hyper modifiers require Kitty keyboard protocol encoding
187
+ // (standard ANSI cannot represent Cmd/Super)
188
+ if (modifiers.includes("Super") || modifiers.includes("Hyper")) {
189
+ return keyToKittyAnsi(key)
190
+ }
191
+
192
+ // Single char without modifiers
193
+ if (!modifiers.length && mainKey.length === 1) {
194
+ return mainKey
195
+ }
196
+
197
+ // Ctrl+letter -> control code (ASCII 1-26)
198
+ if (modifiers.includes("Control") && mainKey.length === 1) {
199
+ const code = mainKey.toLowerCase().charCodeAt(0) - 96
200
+ if (code >= 1 && code <= 26) return String.fromCharCode(code)
201
+ }
202
+
203
+ // Ctrl+/ -> \x1f (Unit Separator, standard terminal convention)
204
+ if (modifiers.includes("Control") && mainKey === "/") {
205
+ return "\x1f"
206
+ }
207
+
208
+ // Ctrl+Enter -> \n (legacy terminal: \r = Enter, \n = Ctrl+Enter/Ctrl+J)
209
+ if (modifiers.includes("Control") && mainKey === "Enter") {
210
+ return "\n"
211
+ }
212
+
213
+ // Alt+key -> ESC prefix (standard terminal convention)
214
+ // Alt/Meta/Option keys send ESC followed by the key
215
+ if ((modifiers.includes("Alt") || modifiers.includes("Meta")) && mainKey.length === 1) {
216
+ return `\x1b${mainKey}`
217
+ }
218
+
219
+ // Shift+Tab -> backtab (universally \x1b[Z across all terminal emulators)
220
+ if (modifiers.includes("Shift") && mainKey === "Tab") {
221
+ return "\x1b[Z"
222
+ }
223
+
224
+ // Modified arrow/function keys -> xterm-style CSI 1;modifier sequences
225
+ // E.g. Shift+ArrowUp -> \x1b[1;2A, Ctrl+ArrowDown -> \x1b[1;5B
226
+ const ARROW_SUFFIX: Record<string, string> = {
227
+ ArrowUp: "A",
228
+ ArrowDown: "B",
229
+ ArrowRight: "C",
230
+ ArrowLeft: "D",
231
+ Home: "H",
232
+ End: "F",
233
+ }
234
+ if (modifiers.length > 0 && mainKey in ARROW_SUFFIX) {
235
+ let mod = 1
236
+ if (modifiers.includes("Shift")) mod += 1
237
+ if (modifiers.includes("Alt") || modifiers.includes("Meta")) mod += 2
238
+ if (modifiers.includes("Control")) mod += 4
239
+ if (modifiers.includes("Super")) mod += 8
240
+ if (modifiers.includes("Hyper")) mod += 16
241
+ return `\x1b[1;${mod}${ARROW_SUFFIX[mainKey]}`
242
+ }
243
+
244
+ // Look up base key in map
245
+ const base = KEY_MAP[mainKey]
246
+ if (base !== undefined && base !== null) return base
247
+
248
+ // Fallback: return as-is (single char or unknown key)
249
+ return mainKey
250
+ }
251
+
252
+ // ============================================================================
253
+ // ANSI -> Key Mapping (for parsing input)
254
+ // ============================================================================
255
+
256
+ /**
257
+ * ANSI escape code suffix -> key name mapping.
258
+ * Used by useInput to parse incoming key sequences.
259
+ *
260
+ * The key is the escape sequence suffix (after ESC or ESC[).
261
+ * Multiple terminal emulators may use different sequences for the same key.
262
+ */
263
+ export const CODE_TO_KEY: Record<string, string> = {
264
+ // Arrow keys (xterm ESC [ letter)
265
+ "[A": "up",
266
+ "[B": "down",
267
+ "[C": "right",
268
+ "[D": "left",
269
+ "[E": "clear",
270
+ "[F": "end",
271
+ "[H": "home",
272
+
273
+ // Arrow keys (xterm/gnome ESC O letter)
274
+ OA: "up",
275
+ OB: "down",
276
+ OC: "right",
277
+ OD: "left",
278
+ OE: "clear",
279
+ OF: "end",
280
+ OH: "home",
281
+
282
+ // Function keys (xterm/gnome ESC O letter)
283
+ OP: "f1",
284
+ OQ: "f2",
285
+ OR: "f3",
286
+ OS: "f4",
287
+
288
+ // Function keys (xterm/rxvt ESC [ number ~)
289
+ "[11~": "f1",
290
+ "[12~": "f2",
291
+ "[13~": "f3",
292
+ "[14~": "f4",
293
+ "[15~": "f5",
294
+ "[17~": "f6",
295
+ "[18~": "f7",
296
+ "[19~": "f8",
297
+ "[20~": "f9",
298
+ "[21~": "f10",
299
+ "[23~": "f11",
300
+ "[24~": "f12",
301
+
302
+ // Function keys (Cygwin/libuv)
303
+ "[[A": "f1",
304
+ "[[B": "f2",
305
+ "[[C": "f3",
306
+ "[[D": "f4",
307
+ "[[E": "f5",
308
+
309
+ // Navigation keys (xterm/rxvt ESC [ number ~)
310
+ "[1~": "home",
311
+ "[2~": "insert",
312
+ "[3~": "delete",
313
+ "[4~": "end",
314
+ "[5~": "pageup",
315
+ "[6~": "pagedown",
316
+
317
+ // Navigation keys (putty)
318
+ "[[5~": "pageup",
319
+ "[[6~": "pagedown",
320
+
321
+ // Navigation keys (rxvt)
322
+ "[7~": "home",
323
+ "[8~": "end",
324
+
325
+ // Arrow keys with shift (rxvt lowercase)
326
+ "[a": "up",
327
+ "[b": "down",
328
+ "[c": "right",
329
+ "[d": "left",
330
+ "[e": "clear",
331
+
332
+ // Navigation keys with shift (rxvt $)
333
+ "[2$": "insert",
334
+ "[3$": "delete",
335
+ "[5$": "pageup",
336
+ "[6$": "pagedown",
337
+ "[7$": "home",
338
+ "[8$": "end",
339
+
340
+ // Arrow keys with ctrl (rxvt O lowercase)
341
+ Oa: "up",
342
+ Ob: "down",
343
+ Oc: "right",
344
+ Od: "left",
345
+ Oe: "clear",
346
+
347
+ // Navigation keys with ctrl (rxvt ^)
348
+ "[2^": "insert",
349
+ "[3^": "delete",
350
+ "[5^": "pageup",
351
+ "[6^": "pagedown",
352
+ "[7^": "home",
353
+ "[8^": "end",
354
+
355
+ // Shift+Tab
356
+ "[Z": "tab",
357
+ }
358
+
359
+ // ============================================================================
360
+ // Key Parsing Constants
361
+ // ============================================================================
362
+
363
+ const NON_ALPHANUMERIC_KEYS = [
364
+ ...Object.values(CODE_TO_KEY),
365
+ "backspace",
366
+ "tab",
367
+ "delete",
368
+ // Note: 'return', 'enter', 'escape', and 'space' are intentionally NOT included.
369
+ // Users may need the raw character as input ('\r' for return, '\x1b' for escape).
370
+ // Use key.return / key.escape boolean flags to detect these keys.
371
+ ]
372
+
373
+ const SHIFT_CODES = new Set(["[a", "[b", "[c", "[d", "[e", "[2$", "[3$", "[5$", "[6$", "[7$", "[8$", "[Z"])
374
+
375
+ const CTRL_CODES = new Set(["Oa", "Ob", "Oc", "Od", "Oe", "[2^", "[3^", "[5^", "[6^", "[7^", "[8^"])
376
+
377
+ const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
378
+ const FN_KEY_RE = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
379
+
380
+ // ============================================================================
381
+ // Kitty Keyboard Protocol
382
+ // ============================================================================
383
+
384
+ /**
385
+ * Matches Kitty keyboard protocol sequences:
386
+ * CSI codepoint[:shifted[:base]][;modifiers[:event_type][;text_codepoints]] u
387
+ *
388
+ * Groups:
389
+ * 1: codepoint
390
+ * 2: shifted_codepoint (optional)
391
+ * 3: base_layout_key (optional)
392
+ * 4: modifiers (optional, defaults to 1)
393
+ * 5: event_type (optional)
394
+ * 6: text_codepoints (colon-separated, optional โ€” requires REPORT_TEXT flag)
395
+ */
396
+ const KITTY_RE = /^\x1b\[(\d+)(?::(\d+))?(?::(\d+))?(?:;(\d+)(?::(\d+))?(?:;([\d:]+))?)?u$/
397
+
398
+ /** Matches xterm modifyOtherKeys format: CSI 27 ; modifier ; keycode ~ */
399
+ const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~$/
400
+
401
+ /**
402
+ * Kitty-enhanced special key sequences:
403
+ * CSI number ; modifiers : eventType {letter|~}
404
+ * These are legacy CSI sequences enhanced with the :eventType field.
405
+ * Examples: \x1b[1;1:1A (up arrow press), \x1b[3;1:3~ (delete release)
406
+ */
407
+ const KITTY_SPECIAL_RE = /^\x1b\[(\d+);(\d+):(\d+)([A-Za-z~])$/
408
+
409
+ /** Letter-terminated special key names (CSI 1 ; mods letter) */
410
+ const KITTY_SPECIAL_LETTER_KEYS: Record<string, string> = {
411
+ A: "up",
412
+ B: "down",
413
+ C: "right",
414
+ D: "left",
415
+ E: "clear",
416
+ F: "end",
417
+ H: "home",
418
+ P: "f1",
419
+ Q: "f2",
420
+ R: "f3",
421
+ S: "f4",
422
+ }
423
+
424
+ /** Number-terminated special key names (CSI number ; mods ~) */
425
+ const KITTY_SPECIAL_NUMBER_KEYS: Record<number, string> = {
426
+ 2: "insert",
427
+ 3: "delete",
428
+ 5: "pageup",
429
+ 6: "pagedown",
430
+ 7: "home",
431
+ 8: "end",
432
+ 11: "f1",
433
+ 12: "f2",
434
+ 13: "f3",
435
+ 14: "f4",
436
+ 15: "f5",
437
+ 17: "f6",
438
+ 18: "f7",
439
+ 19: "f8",
440
+ 20: "f9",
441
+ 21: "f10",
442
+ 23: "f11",
443
+ 24: "f12",
444
+ }
445
+
446
+ /** Valid Unicode codepoint range, excluding surrogates */
447
+ function isValidCodepoint(cp: number): boolean {
448
+ return cp >= 0 && cp <= 0x10_ffff && !(cp >= 0xd8_00 && cp <= 0xdf_ff)
449
+ }
450
+
451
+ /** Safely convert codepoint to string, returning '?' for invalid values */
452
+ function safeFromCodePoint(cp: number): string {
453
+ return isValidCodepoint(cp) ? String.fromCodePoint(cp) : "?"
454
+ }
455
+
456
+ /** Maps Kitty codepoints to key names for non-printable/functional keys */
457
+ const KITTY_CODEPOINT_MAP: Record<number, string> = {
458
+ // Standard control keys
459
+ 8: "backspace",
460
+ 9: "tab",
461
+ 13: "return",
462
+ 27: "escape",
463
+ 127: "delete",
464
+ // Function keys F13-F35 (F1-F12 use legacy CSI sequences in Kitty mode)
465
+ 57376: "f13",
466
+ 57377: "f14",
467
+ 57378: "f15",
468
+ 57379: "f16",
469
+ 57380: "f17",
470
+ 57381: "f18",
471
+ 57382: "f19",
472
+ 57383: "f20",
473
+ 57384: "f21",
474
+ 57385: "f22",
475
+ 57386: "f23",
476
+ 57387: "f24",
477
+ 57388: "f25",
478
+ 57389: "f26",
479
+ 57390: "f27",
480
+ 57391: "f28",
481
+ 57392: "f29",
482
+ 57393: "f30",
483
+ 57394: "f31",
484
+ 57395: "f32",
485
+ 57396: "f33",
486
+ 57397: "f34",
487
+ 57398: "f35",
488
+ // Lock/misc keys
489
+ 57358: "capslock",
490
+ 57359: "scrolllock",
491
+ 57360: "numlock",
492
+ 57361: "printscreen",
493
+ 57362: "pause",
494
+ 57363: "menu",
495
+ // Keypad keys
496
+ 57399: "kp0",
497
+ 57400: "kp1",
498
+ 57401: "kp2",
499
+ 57402: "kp3",
500
+ 57403: "kp4",
501
+ 57404: "kp5",
502
+ 57405: "kp6",
503
+ 57406: "kp7",
504
+ 57407: "kp8",
505
+ 57408: "kp9",
506
+ 57409: "kpdecimal",
507
+ 57410: "kpdivide",
508
+ 57411: "kpmultiply",
509
+ 57412: "kpsubtract",
510
+ 57413: "kpadd",
511
+ 57414: "kpenter",
512
+ 57415: "kpequal",
513
+ 57416: "kpseparator",
514
+ 57417: "kpleft",
515
+ 57418: "kpright",
516
+ 57419: "kpup",
517
+ 57420: "kpdown",
518
+ 57421: "kppageup",
519
+ 57422: "kppagedown",
520
+ 57423: "kphome",
521
+ 57424: "kpend",
522
+ 57425: "kpinsert",
523
+ 57426: "kpdelete",
524
+ 57427: "kpbegin",
525
+ // Media keys
526
+ 57428: "mediaplay",
527
+ 57429: "mediapause",
528
+ 57430: "mediaplaypause",
529
+ 57431: "mediareverse",
530
+ 57432: "mediastop",
531
+ 57433: "mediafastforward",
532
+ 57434: "mediarewind",
533
+ 57435: "mediatracknext",
534
+ 57436: "mediatrackprevious",
535
+ 57437: "mediarecord",
536
+ 57438: "lowervolume",
537
+ 57439: "raisevolume",
538
+ 57440: "mutevolume",
539
+ // Modifier-only keys
540
+ 57441: "leftshift",
541
+ 57442: "leftcontrol",
542
+ 57443: "leftalt",
543
+ 57444: "leftsuper",
544
+ 57445: "lefthyper",
545
+ 57446: "leftmeta",
546
+ 57447: "rightshift",
547
+ 57448: "rightcontrol",
548
+ 57449: "rightalt",
549
+ 57450: "rightsuper",
550
+ 57451: "righthyper",
551
+ 57452: "rightmeta",
552
+ 57453: "isoLevel3Shift",
553
+ 57454: "isoLevel5Shift",
554
+ }
555
+
556
+ /** Lookup a Kitty codepoint to a key name */
557
+ function kittyCodepointToName(cp: number): string | undefined {
558
+ return KITTY_CODEPOINT_MAP[cp]
559
+ }
560
+
561
+ /** Convert numeric Kitty event type (1/2/3) to string. */
562
+ function numericToEventType(n: number): "press" | "repeat" | "release" | undefined {
563
+ if (n === 1) return "press"
564
+ if (n === 2) return "repeat"
565
+ if (n === 3) return "release"
566
+ return undefined
567
+ }
568
+
569
+ // ============================================================================
570
+ // Key Parsing
571
+ // ============================================================================
572
+
573
+ export interface ParsedKeypress {
574
+ name: string
575
+ ctrl: boolean
576
+ meta: boolean
577
+ shift: boolean
578
+ option: boolean
579
+ super: boolean
580
+ hyper: boolean
581
+ /** Kitty event type. Only set with Kitty flag 2 (report events). */
582
+ eventType?: "press" | "repeat" | "release"
583
+ /** The character when Shift is held. From Kitty shifted_codepoint. */
584
+ shiftedKey?: string
585
+ /** The key on a standard US layout (for non-Latin keyboards). From Kitty base_layout_key. */
586
+ baseLayoutKey?: string
587
+ /** CapsLock is active. Kitty modifier bit 6. */
588
+ capsLock?: boolean
589
+ /** NumLock is active. Kitty modifier bit 7. */
590
+ numLock?: boolean
591
+ /** Decoded text from Kitty REPORT_TEXT mode. */
592
+ associatedText?: string
593
+ sequence: string
594
+ /** Raw input string, identical to sequence for most keys. */
595
+ raw?: string
596
+ code?: string
597
+ /** Whether this key was parsed from the Kitty keyboard protocol. */
598
+ isKittyProtocol?: boolean
599
+ /**
600
+ * Whether this key represents printable text input.
601
+ * When false, the key is a control/function/modifier key that should not
602
+ * produce text input (e.g., arrows, function keys, capslock, media keys).
603
+ * Only set by the kitty protocol parser.
604
+ */
605
+ isPrintable?: boolean
606
+ /**
607
+ * Text associated with the key.
608
+ * For printable kitty keys, defaults to the character from the codepoint.
609
+ * When REPORT_TEXT flag is active, contains the decoded text-as-codepoints.
610
+ */
611
+ text?: string
612
+ }
613
+
614
+ /**
615
+ * Parse a raw input sequence into a structured keypress object.
616
+ * Accepts string or Buffer (Buffer support for stdin compatibility).
617
+ */
618
+ export function parseKeypress(s: string | Buffer): ParsedKeypress {
619
+ let input: string
620
+
621
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(s)) {
622
+ if (s[0] !== undefined && s[0]! > 127 && s[1] === undefined) {
623
+ const buf = Buffer.from(s)
624
+ buf[0]! -= 128
625
+ input = `\x1b${buf.toString()}`
626
+ } else {
627
+ input = s.toString()
628
+ }
629
+ } else {
630
+ input = typeof s === "string" ? (s ?? "") : String(s)
631
+ }
632
+
633
+ const key: ParsedKeypress = {
634
+ name: "",
635
+ ctrl: false,
636
+ meta: false,
637
+ shift: false,
638
+ option: false,
639
+ super: false,
640
+ hyper: false,
641
+ sequence: input,
642
+ }
643
+
644
+ if (input === "\r") {
645
+ key.name = "return"
646
+ } else if (input === "\n") {
647
+ // In legacy terminal mode, Enter sends \r. The only way to get \n is
648
+ // Ctrl+Enter (or Ctrl+J, same byte). Treat it as ctrl+return so
649
+ // TextArea's submitKey="ctrl+enter" works without Kitty protocol.
650
+ key.name = "return"
651
+ key.ctrl = true
652
+ } else if (input === "\t") {
653
+ key.name = "tab"
654
+ } else if (input === "\b" || input === "\x1b\b") {
655
+ key.name = "backspace"
656
+ key.meta = input.charAt(0) === "\x1b"
657
+ } else if (input === "\x7f" || input === "\x1b\x7f") {
658
+ // Modern terminals send \x7f for Backspace key (not \x08).
659
+ // The actual Delete key sends \x1b[3~ (handled by CODE_TO_KEY).
660
+ key.name = "backspace"
661
+ key.meta = input.charAt(0) === "\x1b"
662
+ } else if (input === "\x1b\r") {
663
+ // Meta + Return (Alt+Enter / Option+Return on macOS)
664
+ key.name = "return"
665
+ key.meta = true
666
+ } else if (input === "\x1b" || input === "\x1b\x1b") {
667
+ key.name = "escape"
668
+ key.meta = input.length === 2
669
+ } else if (input === " " || input === "\x1b ") {
670
+ key.name = "space"
671
+ key.meta = input.length === 2
672
+ } else if (input.length === 1 && input <= "\x1a") {
673
+ // ctrl+letter
674
+ key.name = String.fromCharCode(input.charCodeAt(0) + "a".charCodeAt(0) - 1)
675
+ key.ctrl = true
676
+ } else if (input === "\x1f") {
677
+ // Ctrl+/ sends 0x1F (Unit Separator) in terminals
678
+ key.name = "/"
679
+ key.ctrl = true
680
+ } else if (input.length === 1 && input >= "0" && input <= "9") {
681
+ key.name = "number"
682
+ } else if (input.length === 1 && input >= "a" && input <= "z") {
683
+ key.name = input
684
+ } else if (input.length === 1 && input >= "A" && input <= "Z") {
685
+ key.name = input.toLowerCase()
686
+ key.shift = true
687
+ } else {
688
+ // Try Kitty keyboard protocol first (CSI codepoint ; modifiers u)
689
+ // Must be checked before FN_KEY_RE because 'u' matches [a-zA-Z]
690
+ const kittyParts = KITTY_RE.exec(input)
691
+ // Kitty-enhanced special keys: CSI number ; modifiers : eventType {letter|~}
692
+ const kittySpecialParts = !kittyParts && KITTY_SPECIAL_RE.exec(input)
693
+ // xterm modifyOtherKeys format: CSI 27 ; modifier ; keycode ~
694
+ // Sent by Ghostty, xterm, and others for modified keys like Ctrl+Enter
695
+ const modifyOtherKeysParts = !kittyParts && !kittySpecialParts && MODIFY_OTHER_KEYS_RE.exec(input)
696
+
697
+ if (kittySpecialParts) {
698
+ // Kitty-enhanced special key: CSI number ; modifiers : eventType {letter|~}
699
+ const number = Number(kittySpecialParts[1])
700
+ const modifier = Math.max(0, Number(kittySpecialParts[2]) - 1)
701
+ const eventType = Number(kittySpecialParts[3])
702
+ const terminator = kittySpecialParts[4]!
703
+
704
+ const name = terminator === "~" ? KITTY_SPECIAL_NUMBER_KEYS[number] : KITTY_SPECIAL_LETTER_KEYS[terminator]
705
+
706
+ key.isKittyProtocol = true
707
+ key.isPrintable = false
708
+ key.raw = input
709
+ key.name = name ?? ""
710
+
711
+ key.shift = !!(modifier & 1)
712
+ key.option = !!(modifier & 2) // alt
713
+ key.ctrl = !!(modifier & 4)
714
+ key.super = !!(modifier & 8)
715
+ key.hyper = !!(modifier & 16)
716
+ key.meta = !!(modifier & 32)
717
+ key.capsLock = !!(modifier & 64)
718
+ key.numLock = !!(modifier & 128)
719
+
720
+ const eventTypeStr = numericToEventType(eventType)
721
+ if (eventTypeStr) {
722
+ key.eventType = eventTypeStr
723
+ }
724
+ } else if (kittyParts || modifyOtherKeysParts) {
725
+ let codepoint: number
726
+ let modifier: number
727
+ if (kittyParts) {
728
+ codepoint = Number(kittyParts[1])
729
+ modifier = Math.max(0, Number(kittyParts[4] || 1) - 1)
730
+ } else {
731
+ const mokParts = modifyOtherKeysParts as RegExpExecArray
732
+ modifier = Math.max(0, Number(mokParts[1]) - 1)
733
+ codepoint = Number(mokParts[2])
734
+ }
735
+
736
+ // Mark as kitty protocol
737
+ if (kittyParts) {
738
+ key.isKittyProtocol = true
739
+ key.raw = input
740
+
741
+ // Handle invalid codepoints (above U+10FFFF or surrogates)
742
+ if (!isValidCodepoint(codepoint)) {
743
+ key.name = ""
744
+ key.isPrintable = false
745
+ return key
746
+ }
747
+ }
748
+
749
+ key.shift = !!(modifier & 1)
750
+ key.option = !!(modifier & 2) // alt (in kitty protocol, bit 2 = alt/option)
751
+ key.ctrl = !!(modifier & 4)
752
+ key.super = !!(modifier & 8) // super (Cmd on macOS)
753
+ key.hyper = !!(modifier & 16) // hyper
754
+ key.meta = !!(modifier & 32) // meta (kitty distinguishes meta from alt)
755
+ key.capsLock = !!(modifier & 64)
756
+ key.numLock = !!(modifier & 128)
757
+
758
+ // Event type from Kitty protocol (group 5): 1=press, 2=repeat, 3=release
759
+ if (kittyParts?.[5]) {
760
+ const et = numericToEventType(Number(kittyParts[5]))
761
+ if (et) key.eventType = et
762
+ }
763
+
764
+ // Shifted codepoint (group 2)
765
+ if (kittyParts?.[2]) {
766
+ key.shiftedKey = String.fromCodePoint(Number(kittyParts[2]))
767
+ }
768
+
769
+ // Base layout key (group 3)
770
+ if (kittyParts?.[3]) {
771
+ key.baseLayoutKey = String.fromCodePoint(Number(kittyParts[3]))
772
+ }
773
+
774
+ // Text-as-codepoints (group 6) โ€” requires REPORT_TEXT flag
775
+ let textFromProtocol: string | undefined
776
+ if (kittyParts?.[6]) {
777
+ textFromProtocol = kittyParts[6]
778
+ .split(":")
779
+ .map((cp) => safeFromCodePoint(Number(cp)))
780
+ .join("")
781
+ key.associatedText = textFromProtocol
782
+ key.text = textFromProtocol
783
+ }
784
+
785
+ // Map codepoint to key name and determine printability
786
+ if (codepoint === 32) {
787
+ key.name = "space"
788
+ key.isPrintable = true
789
+ } else if (codepoint === 13) {
790
+ key.name = "return"
791
+ key.isPrintable = true
792
+ } else {
793
+ const mapped = kittyCodepointToName(codepoint)
794
+ if (mapped) {
795
+ key.name = mapped
796
+ key.isPrintable = false
797
+ } else if (codepoint >= 1 && codepoint <= 26) {
798
+ // Ctrl+letter comes as codepoint 1-26
799
+ key.name = String.fromCodePoint(codepoint + 96) // 'a' is 97
800
+ key.isPrintable = false
801
+ } else if (codepoint >= 32 && codepoint <= 126) {
802
+ // Printable ASCII
803
+ key.name = String.fromCharCode(codepoint).toLowerCase()
804
+ if (codepoint >= 65 && codepoint <= 90) {
805
+ key.shift = true
806
+ key.name = String.fromCharCode(codepoint + 32)
807
+ }
808
+ key.isPrintable = true
809
+ } else if (isValidCodepoint(codepoint)) {
810
+ key.name = safeFromCodePoint(codepoint)
811
+ key.isPrintable = true
812
+ } else {
813
+ key.name = ""
814
+ key.isPrintable = false
815
+ }
816
+ }
817
+
818
+ // Default text to the character from the codepoint when not explicitly
819
+ // provided by the protocol, so keys like space and return produce their
820
+ // expected text input (' ' and '\r' respectively).
821
+ if (kittyParts && key.isPrintable && !textFromProtocol) {
822
+ key.text = safeFromCodePoint(codepoint)
823
+ }
824
+ } else if (KITTY_RE.test(input)) {
825
+ // Matched kitty pattern but was rejected (e.g., invalid codepoint in the
826
+ // parseKittyKeypress path above returned early). Return safe empty keypress.
827
+ key.isKittyProtocol = true
828
+ key.isPrintable = false
829
+ key.raw = input
830
+ return key
831
+ } else {
832
+ let parts = META_KEY_CODE_RE.exec(input)
833
+ if (parts) {
834
+ key.meta = true
835
+ key.shift = /^[A-Z]$/.test(parts[1] ?? "")
836
+ } else {
837
+ parts = FN_KEY_RE.exec(input)
838
+ if (parts) {
839
+ const segs = input.split("")
840
+ if (segs[0] === "\u001b" && segs[1] === "\u001b") {
841
+ key.option = true
842
+ }
843
+
844
+ // Reassemble key code
845
+ const code = [parts[1], parts[2], parts[4], parts[6]].filter(Boolean).join("")
846
+ const modifier = (Number(parts[3] || parts[5] || 1) - 1) as number
847
+
848
+ key.ctrl = !!(modifier & 4)
849
+ key.meta = !!(modifier & 2) // alt
850
+ key.super = !!(modifier & 8) // super (Cmd on macOS)
851
+ key.hyper = !!(modifier & 16) // hyper
852
+ key.shift = !!(modifier & 1)
853
+ key.capsLock = !!(modifier & 64)
854
+ key.numLock = !!(modifier & 128)
855
+ key.code = code
856
+ key.name = CODE_TO_KEY[code] ?? ""
857
+ key.shift = SHIFT_CODES.has(code) || key.shift
858
+ key.ctrl = CTRL_CODES.has(code) || key.ctrl
859
+ }
860
+ }
861
+ }
862
+ }
863
+
864
+ return key
865
+ }
866
+
867
+ /**
868
+ * Parse raw terminal input into a Key object and cleaned input string.
869
+ *
870
+ * @param rawInput Raw terminal input (string or Buffer)
871
+ * @returns Tuple of [cleanedInput, Key]
872
+ */
873
+ export function parseKey(rawInput: string | Buffer): [string, Key] {
874
+ const keypress = parseKeypress(rawInput)
875
+
876
+ const key: Key = {
877
+ upArrow: keypress.name === "up",
878
+ downArrow: keypress.name === "down",
879
+ leftArrow: keypress.name === "left",
880
+ rightArrow: keypress.name === "right",
881
+ pageDown: keypress.name === "pagedown",
882
+ pageUp: keypress.name === "pageup",
883
+ home: keypress.name === "home",
884
+ end: keypress.name === "end",
885
+ return: keypress.name === "return",
886
+ escape: keypress.name === "escape",
887
+ ctrl: keypress.ctrl,
888
+ shift: keypress.shift,
889
+ tab: keypress.name === "tab",
890
+ backspace: keypress.name === "backspace",
891
+ delete: keypress.name === "delete",
892
+ meta: keypress.name !== "escape" && (keypress.meta || keypress.option),
893
+ super: keypress.super,
894
+ hyper: keypress.hyper,
895
+ capsLock: keypress.capsLock ?? false,
896
+ numLock: keypress.numLock ?? false,
897
+ eventType: keypress.eventType,
898
+ }
899
+
900
+ let input: string
901
+
902
+ if (keypress.isKittyProtocol) {
903
+ // Kitty protocol: use text field for printable keys, key name for ctrl+letter
904
+ if (keypress.isPrintable) {
905
+ input = keypress.text ?? keypress.name
906
+ } else if (keypress.ctrl && keypress.name.length === 1) {
907
+ // Ctrl+letter via codepoint 1-26: provide the letter name so handlers
908
+ // (e.g., exitOnCtrlC checking input === 'c' && key.ctrl) still work.
909
+ input = keypress.name
910
+ } else {
911
+ input = ""
912
+ }
913
+ } else {
914
+ input = keypress.ctrl ? keypress.name : keypress.sequence
915
+
916
+ if (NON_ALPHANUMERIC_KEYS.includes(keypress.name)) {
917
+ input = ""
918
+ }
919
+
920
+ // Strip meta prefix if remaining
921
+ if (input.startsWith("\u001b")) {
922
+ input = input.slice(1)
923
+ }
924
+
925
+ // Filter out escape sequence fragments that leak through
926
+ // e.g., "[2~" from Insert key, "[A" from arrows when not fully parsed
927
+ // Single "[" and "]" are allowed โ€” they're valid key bindings
928
+ if ((input.startsWith("[") && input.length > 1) || (input.startsWith("O") && input.length > 1)) {
929
+ // For Kitty-encoded keys (Super/Hyper modifiers), preserve the key name
930
+ // since the raw sequence was CSI codepoint;modifiers u
931
+ if (keypress.super || keypress.hyper) {
932
+ input = keypress.name
933
+ } else {
934
+ input = ""
935
+ }
936
+ }
937
+ }
938
+
939
+ // Detect shift for uppercase letters
940
+ if (input.length === 1 && typeof input[0] === "string" && /[A-Z]/.test(input[0])) {
941
+ key.shift = true
942
+ }
943
+
944
+ return [input, key]
945
+ }
946
+
947
+ /**
948
+ * Create an empty Key object (all fields false).
949
+ */
950
+ export function emptyKey(): Key {
951
+ return {
952
+ upArrow: false,
953
+ downArrow: false,
954
+ leftArrow: false,
955
+ rightArrow: false,
956
+ pageDown: false,
957
+ pageUp: false,
958
+ home: false,
959
+ end: false,
960
+ return: false,
961
+ escape: false,
962
+ ctrl: false,
963
+ shift: false,
964
+ tab: false,
965
+ backspace: false,
966
+ delete: false,
967
+ meta: false,
968
+ super: false,
969
+ hyper: false,
970
+ capsLock: false,
971
+ numLock: false,
972
+ }
973
+ }
974
+
975
+ // ============================================================================
976
+ // Key Utility Functions
977
+ // ============================================================================
978
+
979
+ /**
980
+ * Convert a Key object to a named key string.
981
+ *
982
+ * Returns the Playwright-compatible name for special keys (ArrowUp, Enter, etc.)
983
+ * or "" if no special key is pressed.
984
+ */
985
+ export function keyToName(key: Key): string {
986
+ if (key.upArrow) return "ArrowUp"
987
+ if (key.downArrow) return "ArrowDown"
988
+ if (key.leftArrow) return "ArrowLeft"
989
+ if (key.rightArrow) return "ArrowRight"
990
+ if (key.return) return "Enter"
991
+ if (key.escape) return "Escape"
992
+ if (key.backspace) return "Backspace"
993
+ if (key.delete) return "Delete"
994
+ if (key.tab) return "Tab"
995
+ if (key.pageUp) return "PageUp"
996
+ if (key.pageDown) return "PageDown"
997
+ if (key.home) return "Home"
998
+ if (key.end) return "End"
999
+ return ""
1000
+ }
1001
+
1002
+ /**
1003
+ * Extract modifier flags from a Key object.
1004
+ * `alt` is always false (terminals cannot distinguish alt from meta).
1005
+ */
1006
+ export function keyToModifiers(key: Key): {
1007
+ ctrl: boolean
1008
+ meta: boolean
1009
+ shift: boolean
1010
+ alt: boolean
1011
+ super: boolean
1012
+ hyper: boolean
1013
+ } {
1014
+ return {
1015
+ ctrl: !!key.ctrl,
1016
+ meta: !!key.meta,
1017
+ shift: !!key.shift,
1018
+ alt: false,
1019
+ super: !!key.super,
1020
+ hyper: !!key.hyper,
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Parse a hotkey string into base key and modifiers.
1026
+ *
1027
+ * Supports Playwright-style ("Control+c", "Shift+ArrowUp") and
1028
+ * lowercase aliases ("ctrl+c", "shift+tab", "cmd+a").
1029
+ *
1030
+ * @example
1031
+ * ```tsx
1032
+ * parseHotkey('j') // { key: 'j', ctrl: false, meta: false, shift: false, alt: false }
1033
+ * parseHotkey('Control+c') // { key: 'c', ctrl: true, ... }
1034
+ * parseHotkey('Shift+ArrowUp') // { key: 'ArrowUp', shift: true, ... }
1035
+ * parseHotkey('โŒ˜j') // { key: 'j', super: true, ... } (macOS symbol prefix)
1036
+ * parseHotkey('โŒƒโ‡งa') // { key: 'a', ctrl: true, shift: true, ... }
1037
+ * ```
1038
+ */
1039
+ export function parseHotkey(keyStr: string): ParsedHotkey {
1040
+ // Support macOS symbol prefix format: โŒ˜J, โŒƒโ‡งJ, โœฆโŒ˜J
1041
+ let remaining = keyStr
1042
+ const symbolMods = new Set<string>()
1043
+ for (const char of remaining) {
1044
+ if (MODIFIER_SYMBOLS.has(char)) {
1045
+ symbolMods.add(char)
1046
+ } else {
1047
+ break
1048
+ }
1049
+ }
1050
+
1051
+ if (symbolMods.size > 0) {
1052
+ remaining = remaining.slice(symbolMods.size)
1053
+ if (remaining.startsWith("+")) remaining = remaining.slice(1)
1054
+ }
1055
+
1056
+ const parts = remaining.split("+")
1057
+ const key = parts.pop() || keyStr
1058
+ const modifiers = new Set([...parts.map((p) => p.toLowerCase()), ...symbolMods])
1059
+
1060
+ return {
1061
+ key,
1062
+ ctrl: modifiers.has("control") || modifiers.has("ctrl") || modifiers.has("โŒƒ"),
1063
+ meta:
1064
+ modifiers.has("meta") ||
1065
+ modifiers.has("alt") ||
1066
+ modifiers.has("opt") ||
1067
+ modifiers.has("option") ||
1068
+ modifiers.has("โŒฅ"),
1069
+ shift: modifiers.has("shift") || modifiers.has("โ‡ง"),
1070
+ alt: false, // alt and meta are indistinguishable in terminals; use meta
1071
+ super: modifiers.has("super") || modifiers.has("cmd") || modifiers.has("command") || modifiers.has("โŒ˜"),
1072
+ hyper: modifiers.has("hyper") || modifiers.has("โœฆ"),
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * Match a parsed hotkey against a Key object and input string.
1078
+ *
1079
+ * @param hotkey Parsed hotkey to match
1080
+ * @param key Key object from input event
1081
+ * @param input Optional input string (for matching character keys)
1082
+ * @returns true if the hotkey matches the key event
1083
+ */
1084
+ export function matchHotkey(hotkey: ParsedHotkey, key: Key, input?: string): boolean {
1085
+ // Check modifiers
1086
+ if (!!hotkey.ctrl !== !!key.ctrl) return false
1087
+ if (!!hotkey.meta !== !!key.meta) return false
1088
+ if (!!hotkey.super !== !!key.super) return false
1089
+ if (!!hotkey.hyper !== !!key.hyper) return false
1090
+ if (!!hotkey.alt !== false) return false // terminals can't distinguish alt from meta
1091
+
1092
+ // For single uppercase letters (A-Z), shift is implicit
1093
+ const isUppercaseLetter = hotkey.key.length === 1 && hotkey.key >= "A" && hotkey.key <= "Z" && !hotkey.shift
1094
+ if (!isUppercaseLetter && !!hotkey.shift !== !!key.shift) return false
1095
+
1096
+ // Check key name against Key boolean fields
1097
+ const name = keyToName(key)
1098
+ if (name && name === hotkey.key) return true
1099
+
1100
+ // Check against input string
1101
+ if (input !== undefined && input === hotkey.key) return true
1102
+
1103
+ return false
1104
+ }
1105
+
1106
+ // ============================================================================
1107
+ // Kitty Protocol Output
1108
+ // ============================================================================
1109
+
1110
+ /** Reverse map: key name โ†’ Kitty codepoint */
1111
+ const NAME_TO_KITTY_CODEPOINT: Record<string, number> = {}
1112
+ for (const [cp, name] of Object.entries(KITTY_CODEPOINT_MAP)) {
1113
+ NAME_TO_KITTY_CODEPOINT[name] = Number(cp)
1114
+ }
1115
+
1116
+ /** Playwright-style key name โ†’ CSI u codepoint for keys using CSI u format */
1117
+ const PLAYWRIGHT_TO_KITTY_CSI_U: Record<string, number> = {
1118
+ Enter: 13,
1119
+ Escape: 27,
1120
+ Backspace: 127,
1121
+ Tab: 9,
1122
+ Space: 32,
1123
+ }
1124
+
1125
+ /** Playwright-style key name โ†’ Kitty enhanced special key suffix (letter-terminated) */
1126
+ const PLAYWRIGHT_TO_KITTY_SPECIAL_LETTER: Record<string, string> = {
1127
+ ArrowUp: "A",
1128
+ ArrowDown: "B",
1129
+ ArrowRight: "C",
1130
+ ArrowLeft: "D",
1131
+ Home: "H",
1132
+ End: "F",
1133
+ F1: "P",
1134
+ F2: "Q",
1135
+ F3: "R",
1136
+ F4: "S",
1137
+ }
1138
+
1139
+ /** Playwright-style key name โ†’ Kitty enhanced special key number (tilde-terminated) */
1140
+ const PLAYWRIGHT_TO_KITTY_SPECIAL_TILDE: Record<string, number> = {
1141
+ Insert: 2,
1142
+ Delete: 3,
1143
+ PageUp: 5,
1144
+ PageDown: 6,
1145
+ F5: 15,
1146
+ F6: 17,
1147
+ F7: 18,
1148
+ F8: 19,
1149
+ F9: 20,
1150
+ F10: 21,
1151
+ F11: 23,
1152
+ F12: 24,
1153
+ }
1154
+
1155
+ /**
1156
+ * Convert a Playwright-style key string to a Kitty keyboard protocol ANSI sequence.
1157
+ *
1158
+ * Uses the appropriate Kitty format for each key type:
1159
+ * - Regular keys: CSI codepoint ; modifiers u
1160
+ * - Arrow/nav keys: CSI 1 ; modifiers letter (enhanced special key format)
1161
+ * - Tilde keys: CSI number ; modifiers ~ (enhanced special key format)
1162
+ *
1163
+ * @example
1164
+ * ```tsx
1165
+ * keyToKittyAnsi('a') // '\x1b[97u' (no modifiers โ†’ bare)
1166
+ * keyToKittyAnsi('Enter') // '\x1b[13u'
1167
+ * keyToKittyAnsi('Control+c') // '\x1b[99;5u' (ctrl = 4, modifier = 5)
1168
+ * keyToKittyAnsi('Shift+Enter') // '\x1b[13;2u' (shift = 1, modifier = 2)
1169
+ * keyToKittyAnsi('ArrowUp') // '\x1b[1;1A' (enhanced special key)
1170
+ * ```
1171
+ */
1172
+ export function keyToKittyAnsi(key: string): string {
1173
+ const parts = key.split("+")
1174
+ const mainKey = parts.pop()!
1175
+ const modifiers = parts.map(normalizeModifier)
1176
+
1177
+ // Calculate modifier bitfield
1178
+ let mod = 0
1179
+ if (modifiers.includes("Shift")) mod |= 1
1180
+ if (modifiers.includes("Alt") || modifiers.includes("Meta")) mod |= 2
1181
+ if (modifiers.includes("Control")) mod |= 4
1182
+ if (modifiers.includes("Super")) mod |= 8
1183
+ if (modifiers.includes("Hyper")) mod |= 16
1184
+
1185
+ // Check for letter-terminated special keys (arrow keys, home, end, F1-F4)
1186
+ const specialLetter = PLAYWRIGHT_TO_KITTY_SPECIAL_LETTER[mainKey]
1187
+ if (specialLetter) {
1188
+ return `\x1b[1;${mod + 1}${specialLetter}`
1189
+ }
1190
+
1191
+ // Check for tilde-terminated special keys (insert, delete, pageup, F5-F12)
1192
+ const specialNumber = PLAYWRIGHT_TO_KITTY_SPECIAL_TILDE[mainKey]
1193
+ if (specialNumber !== undefined) {
1194
+ return `\x1b[${specialNumber};${mod + 1}~`
1195
+ }
1196
+
1197
+ // Check CSI u format keys
1198
+ const csiUCodepoint = PLAYWRIGHT_TO_KITTY_CSI_U[mainKey]
1199
+ if (csiUCodepoint !== undefined) {
1200
+ if (mod > 0) {
1201
+ return `\x1b[${csiUCodepoint};${mod + 1}u`
1202
+ }
1203
+ return `\x1b[${csiUCodepoint}u`
1204
+ }
1205
+
1206
+ // Single character โ€” use Unicode codepoint in CSI u format
1207
+ if (mainKey.length === 1) {
1208
+ const codepoint = mainKey.charCodeAt(0)
1209
+ if (mod > 0) {
1210
+ return `\x1b[${codepoint};${mod + 1}u`
1211
+ }
1212
+ return `\x1b[${codepoint}u`
1213
+ }
1214
+
1215
+ // Try lowercase as direct kitty name (e.g., "return", "escape")
1216
+ const cp = NAME_TO_KITTY_CODEPOINT[mainKey.toLowerCase()]
1217
+ if (cp !== undefined) {
1218
+ if (mod > 0) {
1219
+ return `\x1b[${cp};${mod + 1}u`
1220
+ }
1221
+ return `\x1b[${cp}u`
1222
+ }
1223
+
1224
+ // Fallback: return as-is (not a kitty key)
1225
+ return keyToAnsi(key)
1226
+ }
1227
+
1228
+ // ============================================================================
1229
+ // Raw Input Splitting
1230
+ // ============================================================================
1231
+
1232
+ /** Grapheme segmenter for splitting non-escape text into visual characters */
1233
+ const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
1234
+
1235
+ /**
1236
+ * Split raw terminal input into individual keypresses.
1237
+ *
1238
+ * When stdin.read() returns multiple characters buffered together (e.g., rapid
1239
+ * typing, paste, or auto-repeat during heavy renders), this tokenizer splits
1240
+ * them into individual keypresses so each can be parsed and handled separately.
1241
+ *
1242
+ * Uses grapheme segmentation for non-escape text, so emoji with variation
1243
+ * selectors (โค๏ธ), ZWJ sequences (๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ), and combining marks stay intact.
1244
+ *
1245
+ * Handles:
1246
+ * - CSI sequences: ESC [ ... (arrow keys, function keys, Kitty protocol)
1247
+ * - SS3 sequences: ESC O + letter
1248
+ * - Meta sequences: ESC + single char
1249
+ * - Double ESC
1250
+ * - Grapheme clusters (emoji, combining marks, CJK)
1251
+ */
1252
+ export function* splitRawInput(data: string): Generator<string> {
1253
+ // Single character fast path (most common case in real terminal I/O)
1254
+ if (data.length <= 1) {
1255
+ if (data.length === 1) yield data
1256
+ return
1257
+ }
1258
+
1259
+ let i = 0
1260
+ let textStart = -1 // start of accumulated non-escape text
1261
+
1262
+ while (i < data.length) {
1263
+ if (data.charCodeAt(i) === 0x1b) {
1264
+ // Flush accumulated text before this escape sequence
1265
+ if (textStart >= 0) {
1266
+ yield* splitNonEscapeText(data.slice(textStart, i))
1267
+ textStart = -1
1268
+ }
1269
+
1270
+ // ESC โ€” start of escape sequence
1271
+ if (i + 1 >= data.length) {
1272
+ // Bare ESC at end of chunk
1273
+ yield "\x1b"
1274
+ i++
1275
+ continue
1276
+ }
1277
+
1278
+ const next = data.charCodeAt(i + 1)
1279
+
1280
+ if (next === 0x5b) {
1281
+ // CSI sequence: ESC [ params final-byte
1282
+ // Final byte is in range 0x40-0x7E (@A-Z[\]^_`a-z{|}~)
1283
+ let j = i + 2
1284
+ while (j < data.length) {
1285
+ const c = data.charCodeAt(j)
1286
+ if (c >= 0x40 && c <= 0x7e) {
1287
+ j++ // include the final byte
1288
+ break
1289
+ }
1290
+ j++
1291
+ }
1292
+ yield data.slice(i, j)
1293
+ i = j
1294
+ } else if (next === 0x4f) {
1295
+ // SS3 sequence: ESC O + one letter
1296
+ const end = Math.min(i + 3, data.length)
1297
+ yield data.slice(i, end)
1298
+ i = end
1299
+ } else if (next === 0x1b) {
1300
+ // Double ESC: meta + escape, OR meta + CSI/SS3 sequence
1301
+ // Check if a control sequence follows the double ESC
1302
+ if (i + 2 < data.length) {
1303
+ const third = data.charCodeAt(i + 2)
1304
+ if (third === 0x5b) {
1305
+ // Meta + CSI: ESC ESC [ params final-byte (e.g., meta+arrow)
1306
+ let j = i + 3
1307
+ while (j < data.length) {
1308
+ const c = data.charCodeAt(j)
1309
+ if (c >= 0x40 && c <= 0x7e) {
1310
+ j++ // include the final byte
1311
+ break
1312
+ }
1313
+ j++
1314
+ }
1315
+ yield data.slice(i, j)
1316
+ i = j
1317
+ } else if (third === 0x4f) {
1318
+ // Meta + SS3: ESC ESC O letter
1319
+ const end = Math.min(i + 4, data.length)
1320
+ yield data.slice(i, end)
1321
+ i = end
1322
+ } else {
1323
+ // Plain double ESC (meta+escape)
1324
+ yield "\x1b\x1b"
1325
+ i += 2
1326
+ }
1327
+ } else {
1328
+ // Double ESC at end of chunk
1329
+ yield "\x1b\x1b"
1330
+ i += 2
1331
+ }
1332
+ } else {
1333
+ // Meta + single char (Alt+key)
1334
+ yield data.slice(i, i + 2)
1335
+ i += 2
1336
+ }
1337
+ } else {
1338
+ // Non-escape: accumulate into text run
1339
+ if (textStart < 0) textStart = i
1340
+ i++
1341
+ }
1342
+ }
1343
+
1344
+ // Flush final text run
1345
+ if (textStart >= 0) {
1346
+ yield* splitNonEscapeText(data.slice(textStart))
1347
+ }
1348
+ }
1349
+
1350
+ /**
1351
+ * Split non-escape text, keeping most characters together as one chunk.
1352
+ *
1353
+ * Only delete (0x7F) and backspace (0x08) are split out individually because
1354
+ * they can arrive as rapid repeats in a single stdin chunk. Other control
1355
+ * characters like \r and \t may legitimately appear inside pasted text and
1356
+ * should NOT be split out (matching Ink's input-parser behavior).
1357
+ */
1358
+ function* splitNonEscapeText(text: string): Generator<string> {
1359
+ let segmentStart = 0
1360
+ for (let i = 0; i < text.length; i++) {
1361
+ const ch = text.charCodeAt(i)
1362
+ if (ch === 0x7f || ch === 0x08) {
1363
+ // Flush text before this delete/backspace
1364
+ if (i > segmentStart) {
1365
+ yield text.slice(segmentStart, i)
1366
+ }
1367
+ yield text[i]!
1368
+ segmentStart = i + 1
1369
+ }
1370
+ }
1371
+ // Flush remaining text
1372
+ if (segmentStart < text.length) {
1373
+ yield text.slice(segmentStart)
1374
+ }
1375
+ }
1376
+
1377
+ /** Split a non-escape text run into grapheme clusters */
1378
+ function* splitGraphemes(text: string): Generator<string> {
1379
+ for (const { segment } of graphemeSegmenter.segment(text)) {
1380
+ yield segment
1381
+ }
1382
+ }