ink
Version:
React for CLI
493 lines • 13.4 kB
JavaScript
// Copied from https://github.com/enquirer/enquirer/blob/36785f3399a41cd61e9d28d1eb9c2fcd73d69b4c/lib/keypress.js
import { Buffer } from 'node:buffer';
import { kittyModifiers } from './kitty-keyboard.js';
const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/;
const fnKeyRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/;
const keyName = {
/* xterm/gnome ESC O letter */
OP: 'f1',
OQ: 'f2',
OR: 'f3',
OS: 'f4',
/* xterm/rxvt ESC [ number ~ */
'[11~': 'f1',
'[12~': 'f2',
'[13~': 'f3',
'[14~': 'f4',
/* from Cygwin and used in libuv */
'[[A': 'f1',
'[[B': 'f2',
'[[C': 'f3',
'[[D': 'f4',
'[[E': 'f5',
/* common */
'[15~': 'f5',
'[17~': 'f6',
'[18~': 'f7',
'[19~': 'f8',
'[20~': 'f9',
'[21~': 'f10',
'[23~': 'f11',
'[24~': 'f12',
/* xterm ESC [ letter */
'[A': 'up',
'[B': 'down',
'[C': 'right',
'[D': 'left',
'[E': 'clear',
'[F': 'end',
'[H': 'home',
/* xterm/gnome ESC O letter */
OA: 'up',
OB: 'down',
OC: 'right',
OD: 'left',
OE: 'clear',
OF: 'end',
OH: 'home',
/* xterm/rxvt ESC [ number ~ */
'[1~': 'home',
'[2~': 'insert',
'[3~': 'delete',
'[4~': 'end',
'[5~': 'pageup',
'[6~': 'pagedown',
/* putty */
'[[5~': 'pageup',
'[[6~': 'pagedown',
/* rxvt */
'[7~': 'home',
'[8~': 'end',
/* rxvt keys with modifiers */
'[a': 'up',
'[b': 'down',
'[c': 'right',
'[d': 'left',
'[e': 'clear',
'[2$': 'insert',
'[3$': 'delete',
'[5$': 'pageup',
'[6$': 'pagedown',
'[7$': 'home',
'[8$': 'end',
Oa: 'up',
Ob: 'down',
Oc: 'right',
Od: 'left',
Oe: 'clear',
'[2^': 'insert',
'[3^': 'delete',
'[5^': 'pageup',
'[6^': 'pagedown',
'[7^': 'home',
'[8^': 'end',
/* misc. */
'[Z': 'tab',
};
export const nonAlphanumericKeys = [...Object.values(keyName), 'backspace'];
const isShiftKey = (code) => {
return [
'[a',
'[b',
'[c',
'[d',
'[e',
'[2$',
'[3$',
'[5$',
'[6$',
'[7$',
'[8$',
'[Z',
].includes(code);
};
const isCtrlKey = (code) => {
return [
'Oa',
'Ob',
'Oc',
'Od',
'Oe',
'[2^',
'[3^',
'[5^',
'[6^',
'[7^',
'[8^',
].includes(code);
};
// Kitty keyboard protocol: CSI codepoint ; modifiers [: eventType] [; text-as-codepoints] u
const kittyKeyRe = /^\x1b\[(\d+)(?:;(\d+)(?::(\d+))?(?:;([\d:]+))?)?u$/;
// Kitty-enhanced special keys: CSI number ; modifiers : eventType {letter|~}
// These are legacy CSI sequences enhanced with the :eventType field.
// Examples: \x1b[1;1:1A (up arrow press), \x1b[3;1:3~ (delete release)
const kittySpecialKeyRe = /^\x1b\[(\d+);(\d+):(\d+)([A-Za-z~])$/;
// Letter-terminated special key names (CSI 1 ; mods letter)
const kittySpecialLetterKeys = {
A: 'up',
B: 'down',
C: 'right',
D: 'left',
E: 'clear',
F: 'end',
H: 'home',
P: 'f1',
Q: 'f2',
R: 'f3',
S: 'f4',
};
// Number-terminated special key names (CSI number ; mods ~)
const kittySpecialNumberKeys = {
2: 'insert',
3: 'delete',
5: 'pageup',
6: 'pagedown',
7: 'home',
8: 'end',
11: 'f1',
12: 'f2',
13: 'f3',
14: 'f4',
15: 'f5',
17: 'f6',
18: 'f7',
19: 'f8',
20: 'f9',
21: 'f10',
23: 'f11',
24: 'f12',
};
// Map of special codepoints to key names in kitty protocol
const kittyCodepointNames = {
27: 'escape',
// 13 (return) and 32 (space) are handled before this lookup
// in parseKittyKeypress so they can be marked as printable.
9: 'tab',
127: 'delete',
8: 'backspace',
57358: 'capslock',
57359: 'scrolllock',
57360: 'numlock',
57361: 'printscreen',
57362: 'pause',
57363: 'menu',
57376: 'f13',
57377: 'f14',
57378: 'f15',
57379: 'f16',
57380: 'f17',
57381: 'f18',
57382: 'f19',
57383: 'f20',
57384: 'f21',
57385: 'f22',
57386: 'f23',
57387: 'f24',
57388: 'f25',
57389: 'f26',
57390: 'f27',
57391: 'f28',
57392: 'f29',
57393: 'f30',
57394: 'f31',
57395: 'f32',
57396: 'f33',
57397: 'f34',
57398: 'f35',
57399: 'kp0',
57400: 'kp1',
57401: 'kp2',
57402: 'kp3',
57403: 'kp4',
57404: 'kp5',
57405: 'kp6',
57406: 'kp7',
57407: 'kp8',
57408: 'kp9',
57409: 'kpdecimal',
57410: 'kpdivide',
57411: 'kpmultiply',
57412: 'kpsubtract',
57413: 'kpadd',
57414: 'kpenter',
57415: 'kpequal',
57416: 'kpseparator',
57417: 'kpleft',
57418: 'kpright',
57419: 'kpup',
57420: 'kpdown',
57421: 'kppageup',
57422: 'kppagedown',
57423: 'kphome',
57424: 'kpend',
57425: 'kpinsert',
57426: 'kpdelete',
57427: 'kpbegin',
57428: 'mediaplay',
57429: 'mediapause',
57430: 'mediaplaypause',
57431: 'mediareverse',
57432: 'mediastop',
57433: 'mediafastforward',
57434: 'mediarewind',
57435: 'mediatracknext',
57436: 'mediatrackprevious',
57437: 'mediarecord',
57438: 'lowervolume',
57439: 'raisevolume',
57440: 'mutevolume',
57441: 'leftshift',
57442: 'leftcontrol',
57443: 'leftalt',
57444: 'leftsuper',
57445: 'lefthyper',
57446: 'leftmeta',
57447: 'rightshift',
57448: 'rightcontrol',
57449: 'rightalt',
57450: 'rightsuper',
57451: 'righthyper',
57452: 'rightmeta',
57453: 'isoLevel3Shift',
57454: 'isoLevel5Shift',
};
// Valid Unicode codepoint range, excluding surrogates
const isValidCodepoint = (cp) => cp >= 0 && cp <= 0x10_ffff && !(cp >= 0xd8_00 && cp <= 0xdf_ff);
const safeFromCodePoint = (cp) => isValidCodepoint(cp) ? String.fromCodePoint(cp) : '?';
function resolveEventType(value) {
if (value === 3)
return 'release';
if (value === 2)
return 'repeat';
return 'press';
}
function parseKittyModifiers(modifiers) {
return {
ctrl: !!(modifiers & kittyModifiers.ctrl),
shift: !!(modifiers & kittyModifiers.shift),
meta: !!(modifiers & kittyModifiers.meta),
option: !!(modifiers & kittyModifiers.alt),
super: !!(modifiers & kittyModifiers.super),
hyper: !!(modifiers & kittyModifiers.hyper),
capsLock: !!(modifiers & kittyModifiers.capsLock),
numLock: !!(modifiers & kittyModifiers.numLock),
};
}
const parseKittyKeypress = (s) => {
const match = kittyKeyRe.exec(s);
if (!match)
return null;
const codepoint = parseInt(match[1], 10);
const modifiers = match[2] ? Math.max(0, parseInt(match[2], 10) - 1) : 0;
const eventType = match[3] ? parseInt(match[3], 10) : 1;
const textField = match[4];
// Bail on invalid primary codepoint
if (!isValidCodepoint(codepoint)) {
return null;
}
// Parse text-as-codepoints field (colon-separated Unicode codepoints)
let text;
if (textField) {
text = textField
.split(':')
.map(cp => safeFromCodePoint(parseInt(cp, 10)))
.join('');
}
// Determine key name from codepoint
let name;
let isPrintable;
if (codepoint === 32) {
name = 'space';
isPrintable = true;
}
else if (codepoint === 13) {
name = 'return';
isPrintable = true;
}
else if (kittyCodepointNames[codepoint]) {
name = kittyCodepointNames[codepoint];
isPrintable = false;
}
else if (codepoint >= 1 && codepoint <= 26) {
// Ctrl+letter comes as codepoint 1-26
name = String.fromCodePoint(codepoint + 96); // 'a' is 97
isPrintable = false;
}
else {
name = safeFromCodePoint(codepoint).toLowerCase();
isPrintable = true;
}
// Default text to the character from the codepoint when not explicitly
// provided by the protocol, so keys like space and return produce their
// expected text input (' ' and '\r' respectively).
if (isPrintable && !text) {
text = safeFromCodePoint(codepoint);
}
return {
name,
...parseKittyModifiers(modifiers),
eventType: resolveEventType(eventType),
sequence: s,
raw: s,
isKittyProtocol: true,
isPrintable,
text,
};
};
// Parse kitty-enhanced special key sequences (arrow keys, function keys, etc.)
// These use the legacy CSI format but with an added :eventType field.
const parseKittySpecialKey = (s) => {
const match = kittySpecialKeyRe.exec(s);
if (!match)
return null;
const number = parseInt(match[1], 10);
const modifiers = Math.max(0, parseInt(match[2], 10) - 1);
const eventType = parseInt(match[3], 10);
const terminator = match[4];
const name = terminator === '~'
? kittySpecialNumberKeys[number]
: kittySpecialLetterKeys[terminator];
if (!name)
return null;
return {
name,
...parseKittyModifiers(modifiers),
eventType: resolveEventType(eventType),
sequence: s,
raw: s,
isKittyProtocol: true,
isPrintable: false,
};
};
const parseKeypress = (s = '') => {
let parts;
if (Buffer.isBuffer(s)) {
if (s[0] > 127 && s[1] === undefined) {
s[0] -= 128;
s = '\x1b' + String(s);
}
else {
s = String(s);
}
}
else if (s !== undefined && typeof s !== 'string') {
s = String(s);
}
else if (!s) {
s = '';
}
// Try kitty keyboard protocol parsers first
const kittyResult = parseKittyKeypress(s);
if (kittyResult)
return kittyResult;
const kittySpecialResult = parseKittySpecialKey(s);
if (kittySpecialResult)
return kittySpecialResult;
// If the input matched the kitty CSI-u pattern but was rejected (e.g.,
// invalid codepoint), return a safe empty keypress instead of falling
// through to legacy parsing which can produce unsafe states (undefined name)
if (kittyKeyRe.test(s)) {
return {
name: '',
ctrl: false,
meta: false,
shift: false,
option: false,
sequence: s,
raw: s,
isKittyProtocol: true,
isPrintable: false,
};
}
const key = {
name: '',
ctrl: false,
meta: false,
shift: false,
option: false,
sequence: s,
raw: s,
};
key.sequence = key.sequence || s || key.name;
if (s === '\r' || s === '\x1b\r') {
// carriage return (or option+return on macOS)
key.raw = undefined;
key.name = 'return';
key.option = s.length === 2;
}
else if (s === '\n') {
// enter, should have been called linefeed
key.name = 'enter';
}
else if (s === '\t') {
// tab
key.name = 'tab';
}
else if (s === '\b' || s === '\x1b\b') {
// backspace or ctrl+h
key.name = 'backspace';
key.meta = s.charAt(0) === '\x1b';
}
else if (s === '\x7f' || s === '\x1b\x7f') {
// TODO(vadimdemedes): `enquirer` detects delete key as backspace, but I had to split them up to avoid breaking changes in Ink. Merge them back together in the next major version.
// delete
key.name = 'delete';
key.meta = s.charAt(0) === '\x1b';
}
else if (s === '\x1b' || s === '\x1b\x1b') {
// escape key
key.name = 'escape';
key.meta = s.length === 2;
}
else if (s === ' ' || s === '\x1b ') {
key.name = 'space';
key.meta = s.length === 2;
}
else if (s.length === 1 && s <= '\x1a') {
// ctrl+letter
key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
key.ctrl = true;
}
else if (s.length === 1 && s >= '0' && s <= '9') {
// number
key.name = 'number';
}
else if (s.length === 1 && s >= 'a' && s <= 'z') {
// lowercase letter
key.name = s;
}
else if (s.length === 1 && s >= 'A' && s <= 'Z') {
// shift+letter
key.name = s.toLowerCase();
key.shift = true;
}
else if ((parts = metaKeyCodeRe.exec(s))) {
// meta+character key
key.meta = true;
key.shift = /^[A-Z]$/.test(parts[1]);
}
else if ((parts = fnKeyRe.exec(s))) {
const segs = [...s];
if (segs[0] === '\u001b' && segs[1] === '\u001b') {
key.option = true;
}
// ansi escape sequence
// reassemble the key code leaving out leading \x1b's,
// the modifier key bitflag and any meaningless "1;" sequence
const code = [parts[1], parts[2], parts[4], parts[6]]
.filter(Boolean)
.join('');
const modifier = (parts[3] || parts[5] || 1) - 1;
// Parse the key modifier
key.ctrl = !!(modifier & 4);
key.meta = !!(modifier & 10);
key.shift = !!(modifier & 1);
key.code = code;
key.name = keyName[code];
key.shift = isShiftKey(code) || key.shift;
key.ctrl = isCtrlKey(code) || key.ctrl;
}
return key;
};
export default parseKeypress;
//# sourceMappingURL=parse-keypress.js.map