@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/package.json +54 -0
- package/src/core/index.ts +225 -0
- package/src/core/slice.ts +69 -0
- package/src/create-command-registry.ts +106 -0
- package/src/effects.ts +145 -0
- package/src/focus-events.ts +243 -0
- package/src/focus-manager.ts +491 -0
- package/src/focus-queries.ts +241 -0
- package/src/index.ts +213 -0
- package/src/keys.ts +1382 -0
- package/src/pipe.ts +110 -0
- package/src/plugins.ts +119 -0
- package/src/store/index.ts +306 -0
- package/src/streams/index.ts +405 -0
- package/src/tea/README.md +208 -0
- package/src/tea/index.ts +174 -0
- package/src/text-cursor.ts +206 -0
- package/src/text-decorations.ts +253 -0
- package/src/text-ops.ts +150 -0
- package/src/tree-utils.ts +27 -0
- package/src/types.ts +670 -0
- package/src/with-commands.ts +337 -0
- package/src/with-diagnostics.ts +955 -0
- package/src/with-dom-events.ts +168 -0
- package/src/with-focus.ts +162 -0
- package/src/with-keybindings.ts +180 -0
- package/src/with-react.ts +92 -0
- package/src/with-render.ts +92 -0
- package/src/with-terminal.ts +219 -0
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
|
+
}
|