@linxiraos/pi-tui 1.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
package/src/keys.ts ADDED
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Keyboard input handling for terminal applications.
3
+ *
4
+ * Supports both legacy terminal sequences and Kitty keyboard protocol.
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
7
+ *
8
+ * Symbol keys are also supported, however some ctrl+symbol combos
9
+ * overlap with ASCII codes, e.g. ctrl+[ = ESC.
10
+ * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys
11
+ * Those can still be * used for ctrl+shift combos
12
+ *
13
+ * API:
14
+ * - matchesKey(data, keyId) - Check if input matches a key identifier
15
+ * - parseKey(data) - Parse input and return the key identifier
16
+ * - Key - Helper object for creating typed key identifiers
17
+ * - setKittyProtocolActive(active) - Set global Kitty protocol state
18
+ * - isKittyProtocolActive() - Query global Kitty protocol state
19
+ */
20
+
21
+ import type { KeyEventType } from "@linxiraos/pi-natives";
22
+ import {
23
+ matchesKey as matchesKeyNative,
24
+ parseKey as parseKeyNative,
25
+ parseKittySequence as parseKittySequenceNative,
26
+ } from "@linxiraos/pi-natives";
27
+ import { isInsideTerminalMultiplexer } from "./terminal-capabilities";
28
+
29
+ // =============================================================================
30
+ // Platform Detection
31
+ // =============================================================================
32
+
33
+ /** Whether the local process is running directly under Windows Terminal. */
34
+ export function isWindowsTerminalSession(): boolean {
35
+ return (
36
+ Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Match ambiguous legacy Backspace bytes against an expected modifier mask.
42
+ *
43
+ * Windows Terminal encodes Ctrl+Backspace as raw `0x08` (BS) and plain
44
+ * Backspace as `0x7f` (DEL). Remote/container sessions lose terminal identity,
45
+ * and multiplexers (tmux/screen/Zellij) inherit `WT_SESSION` while emitting
46
+ * raw `0x08` for plain Backspace themselves, so the automatic heuristic is
47
+ * limited to direct Windows Terminal sessions. `PI_TUI_RAW_BACKSPACE_IS_CTRL=1`
48
+ * explicitly opts into the mapping everywhere.
49
+ */
50
+ export function matchesRawBackspace(data: string, expectedModifier: number): boolean {
51
+ if (data === "\x7f") return expectedModifier === 0;
52
+ if (data !== "\x08") return false;
53
+ const rawBackspaceIsCtrl =
54
+ process.env.PI_TUI_RAW_BACKSPACE_IS_CTRL === "1" ||
55
+ (isWindowsTerminalSession() && !isInsideTerminalMultiplexer(process.env));
56
+ return rawBackspaceIsCtrl ? expectedModifier === 4 : expectedModifier === 0;
57
+ }
58
+
59
+ // =============================================================================
60
+ // Global Kitty Protocol State
61
+ // =============================================================================
62
+
63
+ let kittyProtocolActive = false;
64
+
65
+ /**
66
+ * Set the global Kitty keyboard protocol state.
67
+ * Called by ProcessTerminal after detecting protocol support.
68
+ */
69
+ export function setKittyProtocolActive(active: boolean): void {
70
+ kittyProtocolActive = active;
71
+ }
72
+
73
+ /**
74
+ * Query whether Kitty keyboard protocol is currently active.
75
+ */
76
+ export function isKittyProtocolActive(): boolean {
77
+ return kittyProtocolActive;
78
+ }
79
+
80
+ // =============================================================================
81
+ // Type-Safe Key Identifiers
82
+ // =============================================================================
83
+
84
+ type Letter =
85
+ | "a"
86
+ | "b"
87
+ | "c"
88
+ | "d"
89
+ | "e"
90
+ | "f"
91
+ | "g"
92
+ | "h"
93
+ | "i"
94
+ | "j"
95
+ | "k"
96
+ | "l"
97
+ | "m"
98
+ | "n"
99
+ | "o"
100
+ | "p"
101
+ | "q"
102
+ | "r"
103
+ | "s"
104
+ | "t"
105
+ | "u"
106
+ | "v"
107
+ | "w"
108
+ | "x"
109
+ | "y"
110
+ | "z";
111
+
112
+ type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
113
+
114
+ type SymbolKey =
115
+ | "`"
116
+ | "-"
117
+ | "="
118
+ | "["
119
+ | "]"
120
+ | "\\"
121
+ | ";"
122
+ | "'"
123
+ | ","
124
+ | "."
125
+ | "/"
126
+ | "!"
127
+ | "@"
128
+ | "#"
129
+ | "$"
130
+ | "%"
131
+ | "^"
132
+ | "&"
133
+ | "*"
134
+ | "("
135
+ | ")"
136
+ | "_"
137
+ | "+"
138
+ | "|"
139
+ | "~"
140
+ | "{"
141
+ | "}"
142
+ | ":"
143
+ | "<"
144
+ | ">"
145
+ | "?";
146
+
147
+ type SpecialKey =
148
+ | "escape"
149
+ | "esc"
150
+ | "enter"
151
+ | "return"
152
+ | "tab"
153
+ | "space"
154
+ | "backspace"
155
+ | "delete"
156
+ | "insert"
157
+ | "clear"
158
+ | "home"
159
+ | "end"
160
+ | "pageUp"
161
+ | "pageDown"
162
+ | "up"
163
+ | "down"
164
+ | "left"
165
+ | "right"
166
+ | "f1"
167
+ | "f2"
168
+ | "f3"
169
+ | "f4"
170
+ | "f5"
171
+ | "f6"
172
+ | "f7"
173
+ | "f8"
174
+ | "f9"
175
+ | "f10"
176
+ | "f11"
177
+ | "f12";
178
+
179
+ type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
180
+ type ModifierName = "ctrl" | "shift" | "alt" | "super";
181
+
182
+ type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
183
+ [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
184
+ }[RemainingModifiers];
185
+
186
+ /**
187
+ * Union type of all valid key identifiers.
188
+ * Provides autocomplete and catches typos at compile time.
189
+ */
190
+ export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
191
+
192
+ /**
193
+ * Typed helper for constructing key identifiers with autocomplete.
194
+ *
195
+ * The runtime values are just the canonical key-name strings (so `Key.enter`
196
+ * is literally `"enter"`); the value of `Key` over a bag of magic strings is
197
+ * that each property is typed to the exact `KeyId` literal it produces and the
198
+ * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")`
199
+ * is `"ctrl+c"`, not just `string`). This mirrors the upstream
200
+ * `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any
201
+ * scope alias (`@mariozechner`, `@earendil-works`, `@oh-my-pi`) keep working
202
+ * once the specifier shim remaps them to this package.
203
+ */
204
+ export const Key = {
205
+ escape: "escape",
206
+ esc: "esc",
207
+ enter: "enter",
208
+ return: "return",
209
+ tab: "tab",
210
+ space: "space",
211
+ backspace: "backspace",
212
+ delete: "delete",
213
+ insert: "insert",
214
+ clear: "clear",
215
+ home: "home",
216
+ end: "end",
217
+ pageUp: "pageUp",
218
+ pageDown: "pageDown",
219
+ up: "up",
220
+ down: "down",
221
+ left: "left",
222
+ right: "right",
223
+ f1: "f1",
224
+ f2: "f2",
225
+ f3: "f3",
226
+ f4: "f4",
227
+ f5: "f5",
228
+ f6: "f6",
229
+ f7: "f7",
230
+ f8: "f8",
231
+ f9: "f9",
232
+ f10: "f10",
233
+ f11: "f11",
234
+ f12: "f12",
235
+ backtick: "`",
236
+ hyphen: "-",
237
+ equals: "=",
238
+ leftbracket: "[",
239
+ rightbracket: "]",
240
+ backslash: "\\",
241
+ semicolon: ";",
242
+ quote: "'",
243
+ comma: ",",
244
+ period: ".",
245
+ slash: "/",
246
+ exclamation: "!",
247
+ at: "@",
248
+ hash: "#",
249
+ dollar: "$",
250
+ percent: "%",
251
+ caret: "^",
252
+ ampersand: "&",
253
+ asterisk: "*",
254
+ leftparen: "(",
255
+ rightparen: ")",
256
+ underscore: "_",
257
+ plus: "+",
258
+ pipe: "|",
259
+ tilde: "~",
260
+ leftbrace: "{",
261
+ rightbrace: "}",
262
+ colon: ":",
263
+ lessthan: "<",
264
+ greaterthan: ">",
265
+ question: "?",
266
+ ctrl: <K extends BaseKey>(key: K) => `ctrl+${key}` as const,
267
+ shift: <K extends BaseKey>(key: K) => `shift+${key}` as const,
268
+ alt: <K extends BaseKey>(key: K) => `alt+${key}` as const,
269
+ super: <K extends BaseKey>(key: K) => `super+${key}` as const,
270
+ ctrlShift: <K extends BaseKey>(key: K) => `ctrl+shift+${key}` as const,
271
+ shiftCtrl: <K extends BaseKey>(key: K) => `shift+ctrl+${key}` as const,
272
+ ctrlAlt: <K extends BaseKey>(key: K) => `ctrl+alt+${key}` as const,
273
+ altCtrl: <K extends BaseKey>(key: K) => `alt+ctrl+${key}` as const,
274
+ shiftAlt: <K extends BaseKey>(key: K) => `shift+alt+${key}` as const,
275
+ altShift: <K extends BaseKey>(key: K) => `alt+shift+${key}` as const,
276
+ ctrlSuper: <K extends BaseKey>(key: K) => `ctrl+super+${key}` as const,
277
+ superCtrl: <K extends BaseKey>(key: K) => `super+ctrl+${key}` as const,
278
+ shiftSuper: <K extends BaseKey>(key: K) => `shift+super+${key}` as const,
279
+ superShift: <K extends BaseKey>(key: K) => `super+shift+${key}` as const,
280
+ altSuper: <K extends BaseKey>(key: K) => `alt+super+${key}` as const,
281
+ superAlt: <K extends BaseKey>(key: K) => `super+alt+${key}` as const,
282
+ ctrlShiftAlt: <K extends BaseKey>(key: K) => `ctrl+shift+alt+${key}` as const,
283
+ ctrlShiftSuper: <K extends BaseKey>(key: K) => `ctrl+shift+super+${key}` as const,
284
+ } as const;
285
+
286
+ // =============================================================================
287
+ // Kitty Protocol Parsing
288
+ // =============================================================================
289
+
290
+ interface ParsedKittySequence {
291
+ codepoint: number;
292
+ shiftedKey?: number; // Shifted version of the key (when shift is pressed)
293
+ baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts)
294
+ modifier: number;
295
+ eventType?: KeyEventType;
296
+ }
297
+
298
+ // Regex for Kitty protocol event type detection
299
+ // Matches CSI sequences with :2 (repeat) or :3 (release) event type
300
+ // Format: \x1b[...;modifier:event_type<terminator> where terminator is u, ~, or A-F/H
301
+ const KITTY_RELEASE_PATTERN = /^\x1b\[[\d:;]*:3[u~ABCDHF]$/;
302
+ const KITTY_REPEAT_PATTERN = /^\x1b\[[\d:;]*:2[u~ABCDHF]$/;
303
+ const KITTY_CSI_U_PATTERN = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?(?:;([\d:]*))?u$/;
304
+ const KITTY_MOD_SHIFT = 1;
305
+ const KITTY_MOD_ALT = 2;
306
+ const KITTY_MOD_CTRL = 4;
307
+ const KITTY_MOD_SUPER = 8;
308
+ const KITTY_MOD_NUM_LOCK = 128;
309
+ const KITTY_LOCK_MASK = 64 + KITTY_MOD_NUM_LOCK; // Caps Lock + Num Lock
310
+ const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
311
+ const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
312
+ 57410: "/",
313
+ 57411: "*",
314
+ 57412: "-",
315
+ 57413: "+",
316
+ 57415: "=",
317
+ };
318
+ const KITTY_NUMPAD_TEXT: Record<number, string> = {
319
+ 57399: "0",
320
+ 57400: "1",
321
+ 57401: "2",
322
+ 57402: "3",
323
+ 57403: "4",
324
+ 57404: "5",
325
+ 57405: "6",
326
+ 57406: "7",
327
+ 57407: "8",
328
+ 57408: "9",
329
+ 57409: ".",
330
+ };
331
+
332
+ /**
333
+ * Check if the input is a key release event.
334
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
335
+ * Returns false if Kitty protocol is not active.
336
+ */
337
+ export function isKeyRelease(data: string): boolean {
338
+ // Only detect release events when Kitty protocol is active
339
+ if (!kittyProtocolActive) {
340
+ return false;
341
+ }
342
+
343
+ // Don't treat bracketed paste content as key release
344
+ if (data.includes("\x1b[200~")) {
345
+ return false;
346
+ }
347
+
348
+ // Match the full CSI sequence pattern for release events
349
+ return KITTY_RELEASE_PATTERN.test(data);
350
+ }
351
+
352
+ /**
353
+ * Check if the input is a key repeat event.
354
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
355
+ * Returns false if Kitty protocol is not active.
356
+ */
357
+ export function isKeyRepeat(data: string): boolean {
358
+ // Only detect repeat events when Kitty protocol is active
359
+ if (!kittyProtocolActive) {
360
+ return false;
361
+ }
362
+
363
+ // Don't treat bracketed paste content as key repeat
364
+ if (data.includes("\x1b[200~")) {
365
+ return false;
366
+ }
367
+
368
+ // Match the full CSI sequence pattern for repeat events
369
+ return KITTY_REPEAT_PATTERN.test(data);
370
+ }
371
+
372
+ export function parseKittySequence(data: string): ParsedKittySequence | null {
373
+ const result = parseKittySequenceNative(data);
374
+ if (!result) return null;
375
+ return {
376
+ codepoint: result.codepoint,
377
+ shiftedKey: result.shiftedKey ?? undefined,
378
+ baseLayoutKey: result.baseLayoutKey ?? undefined,
379
+ modifier: result.modifier,
380
+ eventType: result.eventType,
381
+ };
382
+ }
383
+
384
+ function hasControlChars(data: string): boolean {
385
+ return [...data].some(ch => {
386
+ const code = ch.charCodeAt(0);
387
+ return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
388
+ });
389
+ }
390
+
391
+ function decodeKittyPrintable(data: string): string | undefined {
392
+ const match = data.match(KITTY_CSI_U_PATTERN);
393
+ if (!match) return undefined;
394
+
395
+ const codepoint = Number.parseInt(match[1] ?? "", 10);
396
+ if (!Number.isFinite(codepoint)) return undefined;
397
+
398
+ if (match[5] === "3") return undefined;
399
+
400
+ const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
401
+ const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
402
+ const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
403
+ const effectiveMod = modifier & ~KITTY_LOCK_MASK;
404
+ const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER;
405
+
406
+ if (effectiveMod & ~supportedModifierMask) return undefined;
407
+ if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER)) return undefined;
408
+
409
+ const textField = match[6];
410
+ if (textField && textField.length > 0) {
411
+ const codepoints = textField
412
+ .split(":")
413
+ .filter(Boolean)
414
+ .map(value => Number.parseInt(value, 10))
415
+ .filter(value => Number.isFinite(value) && value >= 32 && value !== 127);
416
+ if (codepoints.length > 0) {
417
+ try {
418
+ return String.fromCodePoint(...codepoints);
419
+ } catch {
420
+ return undefined;
421
+ }
422
+ }
423
+ }
424
+ const keypadOperatorText = KITTY_KEYPAD_OPERATOR_TEXT[codepoint];
425
+ if (keypadOperatorText) return keypadOperatorText;
426
+
427
+ if (effectiveMod === 0) {
428
+ const numpadText = KITTY_NUMPAD_TEXT[codepoint];
429
+ if (numpadText) return numpadText;
430
+ }
431
+
432
+ let effectiveCodepoint = codepoint;
433
+ if (effectiveMod & KITTY_MOD_SHIFT && typeof shiftedKey === "number") {
434
+ effectiveCodepoint = shiftedKey;
435
+ }
436
+
437
+ if (effectiveCodepoint >= 0xe000 && effectiveCodepoint <= 0xf8ff) {
438
+ return undefined;
439
+ }
440
+
441
+ if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32 || effectiveCodepoint === 127) return undefined;
442
+
443
+ try {
444
+ return String.fromCodePoint(effectiveCodepoint);
445
+ } catch {
446
+ return undefined;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Extract printable text from raw terminal input.
452
+ *
453
+ * Handles Kitty CSI-u text-producing keys so text-entry components can treat
454
+ * keypad digits, keypad operators, and shifted symbols the same as direct character input.
455
+ */
456
+ export function extractPrintableText(data: string): string | undefined {
457
+ const printable = decodePrintableKey(data);
458
+ if (printable !== undefined) return printable;
459
+ if (data.length === 0 || hasControlChars(data)) return undefined;
460
+ return data;
461
+ }
462
+
463
+ interface ParsedModifyOtherKeysSequence {
464
+ codepoint: number;
465
+ modifier: number;
466
+ }
467
+
468
+ /**
469
+ * Parse an xterm `modifyOtherKeys` format sequence: `CSI 27 ; modifiers ; keycode ~`.
470
+ * Modifier values are 1-indexed in the wire format; we normalize to a 0-based bitmask.
471
+ */
472
+ function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null {
473
+ const match = data.match(MODIFY_OTHER_KEYS_PATTERN);
474
+ if (!match) return null;
475
+ const modValue = Number.parseInt(match[1] ?? "", 10);
476
+ const codepoint = Number.parseInt(match[2] ?? "", 10);
477
+ if (!Number.isFinite(modValue) || !Number.isFinite(codepoint)) return null;
478
+ return { codepoint, modifier: modValue - 1 };
479
+ }
480
+
481
+ /**
482
+ * Decode an xterm modifyOtherKeys sequence into the printable character it represents.
483
+ *
484
+ * Only sequences with no modifiers or Shift alone produce text; Ctrl/Alt/Super combos
485
+ * are treated as bindings, not text input.
486
+ */
487
+ function decodeModifyOtherKeysPrintable(data: string): string | undefined {
488
+ const parsed = parseModifyOtherKeysSequence(data);
489
+ if (!parsed) return undefined;
490
+ const modifier = parsed.modifier & ~KITTY_LOCK_MASK;
491
+ if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined;
492
+ if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32 || parsed.codepoint === 127) return undefined;
493
+ try {
494
+ return String.fromCodePoint(parsed.codepoint);
495
+ } catch {
496
+ return undefined;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Decode terminal input into the printable character it represents.
502
+ *
503
+ * Tries Kitty CSI-u first, then falls back to xterm modifyOtherKeys. Returns
504
+ * undefined for control sequences and modifier-only events.
505
+ */
506
+ export function decodePrintableKey(data: string): string | undefined {
507
+ return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data);
508
+ }
509
+
510
+ /**
511
+ * Decode a Kitty CSI-u keypad sequence (numpad digits / keypad operators) into the
512
+ * text it produces, or `undefined` for any non-keypad sequence.
513
+ *
514
+ * The native key matcher classifies bare numpad codepoints (those without a NumLock
515
+ * modifier bit) as navigation keys, but terminals such as the VS Code integrated
516
+ * terminal emit those codepoints for real digit input. Restricting the fast path to
517
+ * keypad codepoints keeps canonical named keys (space, backspace, shifted keys, and
518
+ * modifyOtherKeys sequences) flowing through native normalization.
519
+ */
520
+ function decodeKittyKeypadText(data: string): string | undefined {
521
+ const match = data.match(KITTY_CSI_U_PATTERN);
522
+ if (!match) return undefined;
523
+ const codepoint = Number.parseInt(match[1] ?? "", 10);
524
+ if (!(codepoint in KITTY_NUMPAD_TEXT) && !(codepoint in KITTY_KEYPAD_OPERATOR_TEXT)) return undefined;
525
+ return decodeKittyPrintable(data);
526
+ }
527
+
528
+ function matchesKeypadKey(data: string, keyId: KeyId): boolean | undefined {
529
+ const printable = decodeKittyKeypadText(data);
530
+ if (printable === undefined) return undefined;
531
+ return printable === keyId;
532
+ }
533
+
534
+ /**
535
+ * Match input data against a key identifier string.
536
+ *
537
+ * Supported key identifiers:
538
+ * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space"
539
+ * - Arrow keys: "up", "down", "left", "right"
540
+ * - Ctrl combinations: "ctrl+c", "ctrl+z", etc.
541
+ * - Shift combinations: "shift+tab", "shift+enter"
542
+ * - Alt combinations: "alt+enter", "alt+backspace"
543
+ * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x"
544
+ *
545
+ * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p")
546
+ *
547
+ * @param data - Raw input data from terminal
548
+ * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
549
+ */
550
+ export function matchesKey(data: string, keyId: KeyId): boolean {
551
+ if (matchesRawBackspace(data, 4)) return keyId === "ctrl+backspace";
552
+ return matchesKeypadKey(data, keyId) ?? matchesKeyNative(data, keyId, kittyProtocolActive);
553
+ }
554
+
555
+ /**
556
+ * Parse terminal input and return a normalized key identifier.
557
+ *
558
+ * Returns key names like "escape", "ctrl+c", "shift+tab", "alt+enter".
559
+ * Returns undefined if the input is not a recognized key sequence.
560
+ *
561
+ * @param data - Raw input data from terminal
562
+ */
563
+ export function parseKey(data: string): string | undefined {
564
+ if (matchesRawBackspace(data, 4)) return "ctrl+backspace";
565
+ return decodeKittyKeypadText(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined;
566
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Ring buffer for Emacs-style kill/yank operations.
3
+ *
4
+ * Tracks killed (deleted) text entries. Consecutive kills can accumulate
5
+ * into a single entry. Supports yank (paste most recent) and yank-pop
6
+ * (cycle through older entries).
7
+ */
8
+ const MAX_ENTRIES = 60;
9
+
10
+ export class KillRing {
11
+ #ring: string[] = [];
12
+
13
+ /**
14
+ * Add text to the kill ring.
15
+ *
16
+ * @param text - The killed text to add
17
+ * @param opts - Push options
18
+ * @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion)
19
+ * @param opts.accumulate - Merge with the most recent entry instead of creating a new one
20
+ */
21
+ push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void {
22
+ if (!text) return;
23
+
24
+ if (opts.accumulate && this.#ring.length > 0) {
25
+ const last = this.#ring.pop()!;
26
+ this.#ring.push(opts.prepend ? text + last : last + text);
27
+ } else {
28
+ this.#ring.push(text);
29
+ if (this.#ring.length > MAX_ENTRIES) {
30
+ this.#ring.shift();
31
+ }
32
+ }
33
+ }
34
+
35
+ /** Get most recent entry without modifying the ring. */
36
+ peek(): string | undefined {
37
+ return this.#ring.length > 0 ? this.#ring[this.#ring.length - 1] : undefined;
38
+ }
39
+
40
+ /** Move last entry to front (for yank-pop cycling). */
41
+ rotate(): void {
42
+ if (this.#ring.length > 1) {
43
+ const last = this.#ring.pop()!;
44
+ this.#ring.unshift(last);
45
+ }
46
+ }
47
+
48
+ get length(): number {
49
+ return this.#ring.length;
50
+ }
51
+ }