@oh-my-pi/pi-tui 5.5.0 → 5.6.70

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 CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Supports both legacy terminal sequences and Kitty keyboard protocol.
5
5
  * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
6
+ * Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts
6
7
  *
7
8
  * Symbol keys are also supported, however some ctrl+symbol combos
8
9
  * overlap with ASCII codes, e.g. ctrl+[ = ESC.
@@ -21,21 +22,21 @@
21
22
  // Global Kitty Protocol State
22
23
  // =============================================================================
23
24
 
24
- let kittyProtocolActive = false;
25
+ let _kittyProtocolActive = false;
25
26
 
26
27
  /**
27
28
  * Set the global Kitty keyboard protocol state.
28
29
  * Called by ProcessTerminal after detecting protocol support.
29
30
  */
30
31
  export function setKittyProtocolActive(active: boolean): void {
31
- kittyProtocolActive = active;
32
+ _kittyProtocolActive = active;
32
33
  }
33
34
 
34
35
  /**
35
36
  * Query whether Kitty keyboard protocol is currently active.
36
37
  */
37
38
  export function isKittyProtocolActive(): boolean {
38
- return kittyProtocolActive;
39
+ return _kittyProtocolActive;
39
40
  }
40
41
 
41
42
  // =============================================================================
@@ -112,6 +113,8 @@ type SpecialKey =
112
113
  | "space"
113
114
  | "backspace"
114
115
  | "delete"
116
+ | "insert"
117
+ | "clear"
115
118
  | "home"
116
119
  | "end"
117
120
  | "pageUp"
@@ -119,7 +122,19 @@ type SpecialKey =
119
122
  | "up"
120
123
  | "down"
121
124
  | "left"
122
- | "right";
125
+ | "right"
126
+ | "f1"
127
+ | "f2"
128
+ | "f3"
129
+ | "f4"
130
+ | "f5"
131
+ | "f6"
132
+ | "f7"
133
+ | "f8"
134
+ | "f9"
135
+ | "f10"
136
+ | "f11"
137
+ | "f12";
123
138
 
124
139
  type BaseKey = Letter | SymbolKey | SpecialKey;
125
140
 
@@ -164,6 +179,8 @@ export const Key = {
164
179
  space: "space" as const,
165
180
  backspace: "backspace" as const,
166
181
  delete: "delete" as const,
182
+ insert: "insert" as const,
183
+ clear: "clear" as const,
167
184
  home: "home" as const,
168
185
  end: "end" as const,
169
186
  pageUp: "pageUp" as const,
@@ -172,6 +189,18 @@ export const Key = {
172
189
  down: "down" as const,
173
190
  left: "left" as const,
174
191
  right: "right" as const,
192
+ f1: "f1" as const,
193
+ f2: "f2" as const,
194
+ f3: "f3" as const,
195
+ f4: "f4" as const,
196
+ f5: "f5" as const,
197
+ f6: "f6" as const,
198
+ f7: "f7" as const,
199
+ f8: "f8" as const,
200
+ f9: "f9" as const,
201
+ f10: "f10" as const,
202
+ f11: "f11" as const,
203
+ f12: "f12" as const,
175
204
 
176
205
  // Symbol keys
177
206
  backtick: "`" as const,
@@ -294,6 +323,135 @@ const FUNCTIONAL_CODEPOINTS = {
294
323
  end: -15,
295
324
  } as const;
296
325
 
326
+ const LEGACY_KEY_SEQUENCES = {
327
+ up: ["\x1b[A", "\x1bOA"],
328
+ down: ["\x1b[B", "\x1bOB"],
329
+ right: ["\x1b[C", "\x1bOC"],
330
+ left: ["\x1b[D", "\x1bOD"],
331
+ home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"],
332
+ end: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"],
333
+ insert: ["\x1b[2~"],
334
+ delete: ["\x1b[3~"],
335
+ pageUp: ["\x1b[5~", "\x1b[[5~"],
336
+ pageDown: ["\x1b[6~", "\x1b[[6~"],
337
+ clear: ["\x1b[E", "\x1bOE"],
338
+ f1: ["\x1bOP", "\x1b[11~", "\x1b[[A"],
339
+ f2: ["\x1bOQ", "\x1b[12~", "\x1b[[B"],
340
+ f3: ["\x1bOR", "\x1b[13~", "\x1b[[C"],
341
+ f4: ["\x1bOS", "\x1b[14~", "\x1b[[D"],
342
+ f5: ["\x1b[15~", "\x1b[[E"],
343
+ f6: ["\x1b[17~"],
344
+ f7: ["\x1b[18~"],
345
+ f8: ["\x1b[19~"],
346
+ f9: ["\x1b[20~"],
347
+ f10: ["\x1b[21~"],
348
+ f11: ["\x1b[23~"],
349
+ f12: ["\x1b[24~"],
350
+ } as const;
351
+
352
+ const LEGACY_SHIFT_SEQUENCES = {
353
+ up: ["\x1b[a"],
354
+ down: ["\x1b[b"],
355
+ right: ["\x1b[c"],
356
+ left: ["\x1b[d"],
357
+ clear: ["\x1b[e"],
358
+ insert: ["\x1b[2$"],
359
+ delete: ["\x1b[3$"],
360
+ pageUp: ["\x1b[5$"],
361
+ pageDown: ["\x1b[6$"],
362
+ home: ["\x1b[7$"],
363
+ end: ["\x1b[8$"],
364
+ } as const;
365
+
366
+ const LEGACY_CTRL_SEQUENCES = {
367
+ up: ["\x1bOa"],
368
+ down: ["\x1bOb"],
369
+ right: ["\x1bOc"],
370
+ left: ["\x1bOd"],
371
+ clear: ["\x1bOe"],
372
+ insert: ["\x1b[2^"],
373
+ delete: ["\x1b[3^"],
374
+ pageUp: ["\x1b[5^"],
375
+ pageDown: ["\x1b[6^"],
376
+ home: ["\x1b[7^"],
377
+ end: ["\x1b[8^"],
378
+ } as const;
379
+
380
+ const LEGACY_SEQUENCE_KEY_IDS: Record<string, KeyId> = {
381
+ "\x1bOA": "up",
382
+ "\x1bOB": "down",
383
+ "\x1bOC": "right",
384
+ "\x1bOD": "left",
385
+ "\x1bOH": "home",
386
+ "\x1bOF": "end",
387
+ "\x1b[E": "clear",
388
+ "\x1bOE": "clear",
389
+ "\x1bOe": "ctrl+clear",
390
+ "\x1b[e": "shift+clear",
391
+ "\x1b[2~": "insert",
392
+ "\x1b[2$": "shift+insert",
393
+ "\x1b[2^": "ctrl+insert",
394
+ "\x1b[3$": "shift+delete",
395
+ "\x1b[3^": "ctrl+delete",
396
+ "\x1b[[5~": "pageUp",
397
+ "\x1b[[6~": "pageDown",
398
+ "\x1b[a": "shift+up",
399
+ "\x1b[b": "shift+down",
400
+ "\x1b[c": "shift+right",
401
+ "\x1b[d": "shift+left",
402
+ "\x1bOa": "ctrl+up",
403
+ "\x1bOb": "ctrl+down",
404
+ "\x1bOc": "ctrl+right",
405
+ "\x1bOd": "ctrl+left",
406
+ "\x1b[5$": "shift+pageUp",
407
+ "\x1b[6$": "shift+pageDown",
408
+ "\x1b[7$": "shift+home",
409
+ "\x1b[8$": "shift+end",
410
+ "\x1b[5^": "ctrl+pageUp",
411
+ "\x1b[6^": "ctrl+pageDown",
412
+ "\x1b[7^": "ctrl+home",
413
+ "\x1b[8^": "ctrl+end",
414
+ "\x1bOP": "f1",
415
+ "\x1bOQ": "f2",
416
+ "\x1bOR": "f3",
417
+ "\x1bOS": "f4",
418
+ "\x1b[11~": "f1",
419
+ "\x1b[12~": "f2",
420
+ "\x1b[13~": "f3",
421
+ "\x1b[14~": "f4",
422
+ "\x1b[[A": "f1",
423
+ "\x1b[[B": "f2",
424
+ "\x1b[[C": "f3",
425
+ "\x1b[[D": "f4",
426
+ "\x1b[[E": "f5",
427
+ "\x1b[15~": "f5",
428
+ "\x1b[17~": "f6",
429
+ "\x1b[18~": "f7",
430
+ "\x1b[19~": "f8",
431
+ "\x1b[20~": "f9",
432
+ "\x1b[21~": "f10",
433
+ "\x1b[23~": "f11",
434
+ "\x1b[24~": "f12",
435
+ "\x1bb": "alt+left",
436
+ "\x1bf": "alt+right",
437
+ "\x1bp": "alt+up",
438
+ "\x1bn": "alt+down",
439
+ } as const;
440
+
441
+ type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES;
442
+
443
+ const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean => sequences.includes(data);
444
+
445
+ const matchesLegacyModifierSequence = (data: string, key: LegacyModifierKey, modifier: number): boolean => {
446
+ if (modifier === MODIFIERS.shift) {
447
+ return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]);
448
+ }
449
+ if (modifier === MODIFIERS.ctrl) {
450
+ return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]);
451
+ }
452
+ return false;
453
+ };
454
+
297
455
  // =============================================================================
298
456
  // Kitty Protocol Parsing
299
457
  // =============================================================================
@@ -306,10 +464,15 @@ export type KeyEventType = "press" | "repeat" | "release";
306
464
 
307
465
  interface ParsedKittySequence {
308
466
  codepoint: number;
467
+ shiftedKey?: number; // Shifted version of the key (when shift is pressed)
468
+ baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts)
309
469
  modifier: number;
310
470
  eventType: KeyEventType;
311
471
  }
312
472
 
473
+ // Store the last parsed event type for isKeyRelease() to query
474
+ let _lastEventType: KeyEventType = "press";
475
+
313
476
  /**
314
477
  * Check if the last parsed key event was a key release.
315
478
  * Only meaningful when Kitty keyboard protocol with flag 2 is active.
@@ -374,14 +537,26 @@ function parseEventType(eventTypeStr: string | undefined): KeyEventType {
374
537
  return "press";
375
538
  }
376
539
 
377
- function parseKittySequence(data: string): ParsedKittySequence | null {
378
- // CSI u format: \x1b[<num>u or \x1b[<num>;<mod>u or \x1b[<num>;<mod>:<event>u
379
- const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/);
540
+ export function parseKittySequence(data: string): ParsedKittySequence | null {
541
+ // CSI u format with alternate keys (flag 4):
542
+ // \x1b[<codepoint>u
543
+ // \x1b[<codepoint>;<mod>u
544
+ // \x1b[<codepoint>;<mod>:<event>u
545
+ // \x1b[<codepoint>:<shifted>;<mod>u
546
+ // \x1b[<codepoint>:<shifted>:<base>;<mod>u
547
+ // \x1b[<codepoint>::<base>;<mod>u (no shifted key, only base)
548
+ //
549
+ // With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release
550
+ // With flag 4, alternate keys are appended after codepoint with colons
551
+ const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/);
380
552
  if (csiUMatch) {
381
553
  const codepoint = parseInt(csiUMatch[1]!, 10);
382
- const modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1;
383
- const eventType = parseEventType(csiUMatch[3]);
384
- return { codepoint, modifier: modValue - 1, eventType };
554
+ const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined;
555
+ const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined;
556
+ const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1;
557
+ const eventType = parseEventType(csiUMatch[5]);
558
+ _lastEventType = eventType;
559
+ return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType };
385
560
  }
386
561
 
387
562
  // Arrow keys with modifier: \x1b[1;<mod>A/B/C/D or \x1b[1;<mod>:<event>A/B/C/D
@@ -390,6 +565,7 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
390
565
  const modValue = parseInt(arrowMatch[1]!, 10);
391
566
  const eventType = parseEventType(arrowMatch[2]);
392
567
  const arrowCodes: Record<string, number> = { A: -1, B: -2, C: -3, D: -4 };
568
+ _lastEventType = eventType;
393
569
  return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType };
394
570
  }
395
571
 
@@ -409,6 +585,7 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
409
585
  };
410
586
  const codepoint = funcCodes[keyNum];
411
587
  if (codepoint !== undefined) {
588
+ _lastEventType = eventType;
412
589
  return { codepoint, modifier: modValue - 1, eventType };
413
590
  }
414
591
  }
@@ -419,6 +596,7 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
419
596
  const modValue = parseInt(homeEndMatch[1]!, 10);
420
597
  const eventType = parseEventType(homeEndMatch[2]);
421
598
  const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end;
599
+ _lastEventType = eventType;
422
600
  return { codepoint, modifier: modValue - 1, eventType };
423
601
  }
424
602
 
@@ -430,7 +608,34 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
430
608
  if (!parsed) return false;
431
609
  const actualMod = parsed.modifier & ~LOCK_MASK;
432
610
  const expectedMod = expectedModifier & ~LOCK_MASK;
433
- return parsed.codepoint === expectedCodepoint && actualMod === expectedMod;
611
+
612
+ // Check if modifiers match
613
+ if (actualMod !== expectedMod) return false;
614
+
615
+ // Primary match: codepoint matches directly
616
+ if (parsed.codepoint === expectedCodepoint) return true;
617
+
618
+ // Alternate match: use base layout key for non-Latin keyboard layouts
619
+ // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
620
+ // the base layout key (the key in standard PC-101 layout)
621
+ if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true;
622
+
623
+ return false;
624
+ }
625
+
626
+ /**
627
+ * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~
628
+ * This is used by terminals when Kitty protocol is not enabled.
629
+ * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc.
630
+ */
631
+ function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean {
632
+ const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/);
633
+ if (!match) return false;
634
+ const modValue = parseInt(match[1]!, 10);
635
+ const keycode = parseInt(match[2]!, 10);
636
+ // Convert from 1-indexed xterm format to our 0-indexed format
637
+ const actualMod = modValue - 1;
638
+ return keycode === expectedKeycode && actualMod === expectedModifier;
434
639
  }
435
640
 
436
641
  // =============================================================================
@@ -487,6 +692,14 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
487
692
  return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0);
488
693
 
489
694
  case "space":
695
+ if (!_kittyProtocolActive) {
696
+ if (ctrl && !alt && !shift && data === "\x00") {
697
+ return true;
698
+ }
699
+ if (alt && !ctrl && !shift && data === "\x1b ") {
700
+ return true;
701
+ }
702
+ }
490
703
  if (modifier === 0) {
491
704
  return data === " " || matchesKittySequence(data, CODEPOINTS.space, 0);
492
705
  }
@@ -504,25 +717,40 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
504
717
  case "enter":
505
718
  case "return":
506
719
  if (shift && !ctrl && !alt) {
720
+ // CSI u sequences (standard Kitty protocol)
507
721
  if (
508
722
  matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
509
723
  matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift)
510
724
  ) {
511
725
  return true;
512
726
  }
513
- if (kittyProtocolActive) {
727
+ // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)
728
+ if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) {
729
+ return true;
730
+ }
731
+ // When Kitty protocol is active, legacy sequences are custom terminal mappings
732
+ // \x1b\r = Kitty's "map shift+enter send_text all \e\r"
733
+ // \n = Ghostty's "keybind = shift+enter=text:\n"
734
+ if (_kittyProtocolActive) {
514
735
  return data === "\x1b\r" || data === "\n";
515
736
  }
516
737
  return false;
517
738
  }
518
739
  if (alt && !ctrl && !shift) {
740
+ // CSI u sequences (standard Kitty protocol)
519
741
  if (
520
742
  matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
521
743
  matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt)
522
744
  ) {
523
745
  return true;
524
746
  }
525
- if (!kittyProtocolActive) {
747
+ // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)
748
+ if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) {
749
+ return true;
750
+ }
751
+ // \x1b\r is alt+enter only in legacy mode (no Kitty protocol)
752
+ // When Kitty protocol is active, alt+enter comes as CSI u sequence
753
+ if (!_kittyProtocolActive) {
526
754
  return data === "\x1b\r";
527
755
  }
528
756
  return false;
@@ -530,6 +758,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
530
758
  if (modifier === 0) {
531
759
  return (
532
760
  data === "\r" ||
761
+ (!_kittyProtocolActive && data === "\n") ||
533
762
  data === "\x1bOM" || // SS3 M (numpad enter in some terminals)
534
763
  matchesKittySequence(data, CODEPOINTS.enter, 0) ||
535
764
  matchesKittySequence(data, CODEPOINTS.kpEnter, 0)
@@ -542,62 +771,121 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
542
771
 
543
772
  case "backspace":
544
773
  if (alt && !ctrl && !shift) {
545
- return data === "\x1b\x7f" || matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt);
774
+ if (data === "\x1b\x7f" || data === "\x1b\b") {
775
+ return true;
776
+ }
777
+ return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt);
546
778
  }
547
779
  if (modifier === 0) {
548
780
  return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0);
549
781
  }
550
782
  return matchesKittySequence(data, CODEPOINTS.backspace, modifier);
551
783
 
784
+ case "insert":
785
+ if (modifier === 0) {
786
+ return (
787
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) ||
788
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0)
789
+ );
790
+ }
791
+ if (matchesLegacyModifierSequence(data, "insert", modifier)) {
792
+ return true;
793
+ }
794
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, modifier);
795
+
552
796
  case "delete":
553
797
  if (modifier === 0) {
554
- return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0);
798
+ return (
799
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) ||
800
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0)
801
+ );
802
+ }
803
+ if (matchesLegacyModifierSequence(data, "delete", modifier)) {
804
+ return true;
555
805
  }
556
806
  return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier);
557
807
 
808
+ case "clear":
809
+ if (modifier === 0) {
810
+ return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear);
811
+ }
812
+ return matchesLegacyModifierSequence(data, "clear", modifier);
813
+
558
814
  case "home":
559
815
  if (modifier === 0) {
560
816
  return (
561
- data === "\x1b[H" ||
562
- data === "\x1b[1~" ||
563
- data === "\x1b[7~" ||
817
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) ||
564
818
  matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0)
565
819
  );
566
820
  }
821
+ if (matchesLegacyModifierSequence(data, "home", modifier)) {
822
+ return true;
823
+ }
567
824
  return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier);
568
825
 
569
826
  case "end":
570
827
  if (modifier === 0) {
571
828
  return (
572
- data === "\x1b[F" ||
573
- data === "\x1b[4~" ||
574
- data === "\x1b[8~" ||
829
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) ||
575
830
  matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0)
576
831
  );
577
832
  }
833
+ if (matchesLegacyModifierSequence(data, "end", modifier)) {
834
+ return true;
835
+ }
578
836
  return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier);
579
837
 
580
- case "pageUp":
838
+ case "pageup":
581
839
  if (modifier === 0) {
582
- return data === "\x1b[5~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0);
840
+ return (
841
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) ||
842
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0)
843
+ );
844
+ }
845
+ if (matchesLegacyModifierSequence(data, "pageUp", modifier)) {
846
+ return true;
583
847
  }
584
848
  return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier);
585
849
 
586
- case "pageDown":
850
+ case "pagedown":
587
851
  if (modifier === 0) {
588
- return data === "\x1b[6~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0);
852
+ return (
853
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) ||
854
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0)
855
+ );
856
+ }
857
+ if (matchesLegacyModifierSequence(data, "pageDown", modifier)) {
858
+ return true;
589
859
  }
590
860
  return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier);
591
861
 
592
862
  case "up":
863
+ if (alt && !ctrl && !shift) {
864
+ return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt);
865
+ }
593
866
  if (modifier === 0) {
594
- return data === "\x1b[A" || matchesKittySequence(data, ARROW_CODEPOINTS.up, 0);
867
+ return (
868
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) ||
869
+ matchesKittySequence(data, ARROW_CODEPOINTS.up, 0)
870
+ );
871
+ }
872
+ if (matchesLegacyModifierSequence(data, "up", modifier)) {
873
+ return true;
595
874
  }
596
875
  return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier);
597
876
 
598
877
  case "down":
878
+ if (alt && !ctrl && !shift) {
879
+ return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt);
880
+ }
599
881
  if (modifier === 0) {
600
- return data === "\x1b[B" || matchesKittySequence(data, ARROW_CODEPOINTS.down, 0);
882
+ return (
883
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) ||
884
+ matchesKittySequence(data, ARROW_CODEPOINTS.down, 0)
885
+ );
886
+ }
887
+ if (matchesLegacyModifierSequence(data, "down", modifier)) {
888
+ return true;
601
889
  }
602
890
  return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier);
603
891
 
@@ -605,15 +893,26 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
605
893
  if (alt && !ctrl && !shift) {
606
894
  return (
607
895
  data === "\x1b[1;3D" ||
896
+ (!_kittyProtocolActive && data === "\x1bB") ||
608
897
  data === "\x1bb" ||
609
898
  matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt)
610
899
  );
611
900
  }
612
901
  if (ctrl && !alt && !shift) {
613
- return data === "\x1b[1;5D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl);
902
+ return (
903
+ data === "\x1b[1;5D" ||
904
+ matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) ||
905
+ matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl)
906
+ );
614
907
  }
615
908
  if (modifier === 0) {
616
- return data === "\x1b[D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, 0);
909
+ return (
910
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) ||
911
+ matchesKittySequence(data, ARROW_CODEPOINTS.left, 0)
912
+ );
913
+ }
914
+ if (matchesLegacyModifierSequence(data, "left", modifier)) {
915
+ return true;
617
916
  }
618
917
  return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier);
619
918
 
@@ -621,23 +920,62 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
621
920
  if (alt && !ctrl && !shift) {
622
921
  return (
623
922
  data === "\x1b[1;3C" ||
923
+ (!_kittyProtocolActive && data === "\x1bF") ||
624
924
  data === "\x1bf" ||
625
925
  matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt)
626
926
  );
627
927
  }
628
928
  if (ctrl && !alt && !shift) {
629
- return data === "\x1b[1;5C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl);
929
+ return (
930
+ data === "\x1b[1;5C" ||
931
+ matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) ||
932
+ matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl)
933
+ );
630
934
  }
631
935
  if (modifier === 0) {
632
- return data === "\x1b[C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, 0);
936
+ return (
937
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) ||
938
+ matchesKittySequence(data, ARROW_CODEPOINTS.right, 0)
939
+ );
940
+ }
941
+ if (matchesLegacyModifierSequence(data, "right", modifier)) {
942
+ return true;
633
943
  }
634
944
  return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier);
945
+
946
+ case "f1":
947
+ case "f2":
948
+ case "f3":
949
+ case "f4":
950
+ case "f5":
951
+ case "f6":
952
+ case "f7":
953
+ case "f8":
954
+ case "f9":
955
+ case "f10":
956
+ case "f11":
957
+ case "f12": {
958
+ if (modifier !== 0) {
959
+ return false;
960
+ }
961
+ const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES;
962
+ return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]);
963
+ }
635
964
  }
636
965
 
637
966
  // Handle single letter keys (a-z) and some symbols
638
967
  if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) {
639
968
  const codepoint = key.charCodeAt(0);
640
969
 
970
+ if (ctrl && alt && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") {
971
+ return data === `\x1b${rawCtrlChar(key)}`;
972
+ }
973
+
974
+ if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") {
975
+ // Legacy: alt+letter is ESC followed by the letter
976
+ if (data === `\x1b${key}`) return true;
977
+ }
978
+
641
979
  if (ctrl && !shift && !alt) {
642
980
  const raw = rawCtrlChar(key);
643
981
  if (data === raw) return true;
@@ -659,6 +997,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
659
997
  return matchesKittySequence(data, codepoint, modifier);
660
998
  }
661
999
 
1000
+ // Check both raw char and Kitty sequence (needed for release events)
662
1001
  return data === key || matchesKittySequence(data, codepoint, 0);
663
1002
  }
664
1003
 
@@ -674,30 +1013,36 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
674
1013
  export function parseKey(data: string): string | undefined {
675
1014
  const kitty = parseKittySequence(data);
676
1015
  if (kitty) {
677
- const { codepoint, modifier } = kitty;
1016
+ const { codepoint, baseLayoutKey, modifier } = kitty;
678
1017
  const mods: string[] = [];
679
1018
  const effectiveMod = modifier & ~LOCK_MASK;
680
1019
  if (effectiveMod & MODIFIERS.shift) mods.push("shift");
681
1020
  if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl");
682
1021
  if (effectiveMod & MODIFIERS.alt) mods.push("alt");
683
1022
 
1023
+ // Prefer base layout key for consistent shortcut naming across keyboard layouts
1024
+ // This ensures Ctrl+С (Cyrillic) is reported as "ctrl+c" (Latin)
1025
+ const effectiveCodepoint = baseLayoutKey ?? codepoint;
1026
+
684
1027
  let keyName: string | undefined;
685
- if (codepoint === CODEPOINTS.escape) keyName = "escape";
686
- else if (codepoint === CODEPOINTS.tab) keyName = "tab";
687
- else if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) keyName = "enter";
688
- else if (codepoint === CODEPOINTS.space) keyName = "space";
689
- else if (codepoint === CODEPOINTS.backspace) keyName = "backspace";
690
- else if (codepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete";
691
- else if (codepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home";
692
- else if (codepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
693
- else if (codepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
694
- else if (codepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
695
- else if (codepoint === ARROW_CODEPOINTS.up) keyName = "up";
696
- else if (codepoint === ARROW_CODEPOINTS.down) keyName = "down";
697
- else if (codepoint === ARROW_CODEPOINTS.left) keyName = "left";
698
- else if (codepoint === ARROW_CODEPOINTS.right) keyName = "right";
699
- else if (codepoint >= 97 && codepoint <= 122) keyName = String.fromCharCode(codepoint);
700
- else if (SYMBOL_KEYS.has(String.fromCharCode(codepoint))) keyName = String.fromCharCode(codepoint);
1028
+ if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape";
1029
+ else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab";
1030
+ else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter";
1031
+ else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space";
1032
+ else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace";
1033
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete";
1034
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert";
1035
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home";
1036
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
1037
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
1038
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
1039
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up";
1040
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down";
1041
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left";
1042
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right";
1043
+ else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint);
1044
+ else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint)))
1045
+ keyName = String.fromCharCode(effectiveCodepoint);
701
1046
 
702
1047
  if (keyName) {
703
1048
  return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName;
@@ -708,25 +1053,42 @@ export function parseKey(data: string): string | undefined {
708
1053
  // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings:
709
1054
  // - \x1b\r = shift+enter (Kitty mapping), not alt+enter
710
1055
  // - \n = shift+enter (Ghostty mapping)
711
- if (kittyProtocolActive) {
1056
+ if (_kittyProtocolActive) {
712
1057
  if (data === "\x1b\r" || data === "\n") return "shift+enter";
713
1058
  }
714
1059
 
1060
+ const legacySequenceKeyId = LEGACY_SEQUENCE_KEY_IDS[data];
1061
+ if (legacySequenceKeyId) return legacySequenceKeyId;
1062
+
715
1063
  // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences)
716
1064
  if (data === "\x1b") return "escape";
717
1065
  if (data === "\t") return "tab";
718
- if (data === "\r" || data === "\x1bOM") return "enter";
1066
+ if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter";
1067
+ if (data === "\x00") return "ctrl+space";
719
1068
  if (data === " ") return "space";
720
1069
  if (data === "\x7f" || data === "\x08") return "backspace";
721
1070
  if (data === "\x1b[Z") return "shift+tab";
722
- if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter";
723
- if (data === "\x1b\x7f") return "alt+backspace";
1071
+ if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter";
1072
+ if (!_kittyProtocolActive && data === "\x1b ") return "alt+space";
1073
+ if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace";
1074
+ if (!_kittyProtocolActive && data === "\x1bB") return "alt+left";
1075
+ if (!_kittyProtocolActive && data === "\x1bF") return "alt+right";
1076
+ if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") {
1077
+ const code = data.charCodeAt(1);
1078
+ if (code >= 1 && code <= 26) {
1079
+ return `ctrl+alt+${String.fromCharCode(code + 96)}`;
1080
+ }
1081
+ // Legacy alt+letter (ESC followed by letter a-z)
1082
+ if (code >= 97 && code <= 122) {
1083
+ return `alt+${String.fromCharCode(code)}`;
1084
+ }
1085
+ }
724
1086
  if (data === "\x1b[A") return "up";
725
1087
  if (data === "\x1b[B") return "down";
726
1088
  if (data === "\x1b[C") return "right";
727
1089
  if (data === "\x1b[D") return "left";
728
- if (data === "\x1b[H") return "home";
729
- if (data === "\x1b[F") return "end";
1090
+ if (data === "\x1b[H" || data === "\x1bOH") return "home";
1091
+ if (data === "\x1b[F" || data === "\x1bOF") return "end";
730
1092
  if (data === "\x1b[3~") return "delete";
731
1093
  if (data === "\x1b[5~") return "pageUp";
732
1094
  if (data === "\x1b[6~") return "pageDown";
@@ -744,186 +1106,3 @@ export function parseKey(data: string): string | undefined {
744
1106
 
745
1107
  return undefined;
746
1108
  }
747
-
748
- // =============================================================================
749
- // Legacy helper wrappers (for compatibility)
750
- // =============================================================================
751
-
752
- export function isArrowUp(data: string): boolean {
753
- return matchesKey(data, "up");
754
- }
755
-
756
- export function isArrowDown(data: string): boolean {
757
- return matchesKey(data, "down");
758
- }
759
-
760
- export function isArrowLeft(data: string): boolean {
761
- return matchesKey(data, "left");
762
- }
763
-
764
- export function isArrowRight(data: string): boolean {
765
- return matchesKey(data, "right");
766
- }
767
-
768
- export function isPageUp(data: string): boolean {
769
- return matchesKey(data, "pageUp");
770
- }
771
-
772
- export function isPageDown(data: string): boolean {
773
- return matchesKey(data, "pageDown");
774
- }
775
-
776
- export function isEscape(data: string): boolean {
777
- return matchesKey(data, "escape") || matchesKey(data, "esc");
778
- }
779
-
780
- export function isEnter(data: string): boolean {
781
- return matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n";
782
- }
783
-
784
- export function isTab(data: string): boolean {
785
- return matchesKey(data, "tab");
786
- }
787
-
788
- export function isShiftTab(data: string): boolean {
789
- return matchesKey(data, "shift+tab");
790
- }
791
-
792
- export function isShiftEnter(data: string): boolean {
793
- return matchesKey(data, "shift+enter");
794
- }
795
-
796
- export function isAltEnter(data: string): boolean {
797
- return matchesKey(data, "alt+enter");
798
- }
799
-
800
- export function isAltBackspace(data: string): boolean {
801
- return matchesKey(data, "alt+backspace");
802
- }
803
-
804
- export function isBackspace(data: string): boolean {
805
- return matchesKey(data, "backspace");
806
- }
807
-
808
- export function isDelete(data: string): boolean {
809
- return matchesKey(data, "delete");
810
- }
811
-
812
- export function isHome(data: string): boolean {
813
- return matchesKey(data, "home");
814
- }
815
-
816
- export function isEnd(data: string): boolean {
817
- return matchesKey(data, "end");
818
- }
819
-
820
- export function isCtrlA(data: string): boolean {
821
- return matchesKey(data, "ctrl+a");
822
- }
823
-
824
- export function isCtrlC(data: string): boolean {
825
- return matchesKey(data, "ctrl+c");
826
- }
827
-
828
- export function isCtrlD(data: string): boolean {
829
- return matchesKey(data, "ctrl+d");
830
- }
831
-
832
- export function isCtrlE(data: string): boolean {
833
- return matchesKey(data, "ctrl+e");
834
- }
835
-
836
- export function isCtrlG(data: string): boolean {
837
- return matchesKey(data, "ctrl+g");
838
- }
839
-
840
- export function isCtrlK(data: string): boolean {
841
- return matchesKey(data, "ctrl+k");
842
- }
843
-
844
- export function isCtrlL(data: string): boolean {
845
- return matchesKey(data, "ctrl+l");
846
- }
847
-
848
- export function isCtrlO(data: string): boolean {
849
- return matchesKey(data, "ctrl+o");
850
- }
851
-
852
- export function isCtrlP(data: string): boolean {
853
- return matchesKey(data, "ctrl+p");
854
- }
855
-
856
- export function isCtrlT(data: string): boolean {
857
- return matchesKey(data, "ctrl+t");
858
- }
859
-
860
- export function isCtrlU(data: string): boolean {
861
- return matchesKey(data, "ctrl+u");
862
- }
863
-
864
- export function isCtrlV(data: string): boolean {
865
- return matchesKey(data, "ctrl+v");
866
- }
867
-
868
- export function isCtrlW(data: string): boolean {
869
- return matchesKey(data, "ctrl+w");
870
- }
871
-
872
- export function isCtrlY(data: string): boolean {
873
- return matchesKey(data, "ctrl+y");
874
- }
875
-
876
- export function isCtrlZ(data: string): boolean {
877
- return matchesKey(data, "ctrl+z");
878
- }
879
-
880
- export function isCtrlLeft(data: string): boolean {
881
- return matchesKey(data, "ctrl+left");
882
- }
883
-
884
- export function isCtrlRight(data: string): boolean {
885
- return matchesKey(data, "ctrl+right");
886
- }
887
-
888
- export function isAltLeft(data: string): boolean {
889
- return matchesKey(data, "alt+left");
890
- }
891
-
892
- export function isAltRight(data: string): boolean {
893
- return matchesKey(data, "alt+right");
894
- }
895
-
896
- export function isShiftCtrlD(data: string): boolean {
897
- return matchesKey(data, "shift+ctrl+d") || matchesKey(data, "ctrl+shift+d");
898
- }
899
-
900
- export function isShiftCtrlO(data: string): boolean {
901
- return matchesKey(data, "shift+ctrl+o") || matchesKey(data, "ctrl+shift+o");
902
- }
903
-
904
- export function isShiftCtrlP(data: string): boolean {
905
- return matchesKey(data, "shift+ctrl+p") || matchesKey(data, "ctrl+shift+p");
906
- }
907
-
908
- export function isShiftBackspace(data: string): boolean {
909
- return matchesKey(data, "shift+backspace");
910
- }
911
-
912
- export function isShiftDelete(data: string): boolean {
913
- return matchesKey(data, "shift+delete");
914
- }
915
-
916
- export function isShiftSpace(data: string): boolean {
917
- return matchesKey(data, "shift+space");
918
- }
919
-
920
- /**
921
- * Check if input indicates Caps Lock state change.
922
- * Kitty protocol reports Caps Lock via modifier bit 64.
923
- */
924
- export function isCapsLock(data: string): boolean {
925
- const parsed = parseKittySequence(data);
926
- if (!parsed) return false;
927
- // Caps Lock is modifier bit 64
928
- return (parsed.modifier & 64) !== 0;
929
- }