// Copied from https://github.com/enquirer/enquirer/blob/36785f3399a41cd61e9d28d1eb9c2fcd73d69b4c/lib/keypress.js import { DEL, ESC } from "#/ansi/escapes.ts"; import { kittyModifiers } from "#/kitty-keyboard.ts"; const textDecoder = new TextDecoder(); const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/; const fnKeyRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/; const keyName: Record = { /* xterm/gnome ESC O letter */ OP: "f1", OQ: "f2", OR: "f3", OS: "f4", /* vt220-style ESC [ letter (e.g. Ctrl+F1 sends ESC [ 1 ; 5 P) */ "[P": "f1", "[Q": "f2", "[R": "f3", "[S": "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: string) => { return ["[a", "[b", "[c", "[d", "[e", "[2$", "[3$", "[5$", "[6$", "[7$", "[8$", "[Z"].includes( code, ); }; const isCtrlKey = (code: string) => { return ["Oa", "Ob", "Oc", "Od", "Oe", "[2^", "[3^", "[5^", "[6^", "[7^", "[8^"].includes(code); }; type ParsedKey = { name: string; ctrl: boolean; meta: boolean; shift: boolean; sequence: string; raw: string | undefined; code?: string; super?: boolean; hyper?: boolean; capsLock?: boolean; numLock?: boolean; eventType?: "press" | "repeat" | "release"; isKittyProtocol?: boolean; text?: string; // Whether this key represents printable text input. // When false, the key is a control/function/modifier key that should not // produce text input (e.g., arrows, function keys, capslock, media keys). // Only set by the kitty protocol parser. isPrintable?: boolean; }; // 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: Record = { 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: Record = { 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: Record = { 27: "escape", // 13 (return) and 32 (space) are handled before this lookup // in parseKittyKeypress so they can be marked as printable. 9: "tab", 127: "backspace", 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: number): boolean => cp >= 0 && cp <= 0x10_ffff && !(cp >= 0xd8_00 && cp <= 0xdf_ff); const safeFromCodePoint = (cp: number): string => isValidCodepoint(cp) ? String.fromCodePoint(cp) : "?"; type EventType = "press" | "repeat" | "release"; function resolveEventType(value: number): EventType { if (value === 3) return "release"; if (value === 2) return "repeat"; return "press"; } function parseKittyModifiers( modifiers: number, ): Pick { return { ctrl: !!(modifiers & kittyModifiers.ctrl), shift: !!(modifiers & kittyModifiers.shift), meta: !!(modifiers & (kittyModifiers.meta | kittyModifiers.alt)), super: !!(modifiers & kittyModifiers.super), hyper: !!(modifiers & kittyModifiers.hyper), capsLock: !!(modifiers & kittyModifiers.capsLock), numLock: !!(modifiers & kittyModifiers.numLock), }; } const parseKittyKeypress = (s: string): ParsedKey | null => { 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: string | undefined; if (textField) { text = textField .split(":") .map((cp) => safeFromCodePoint(parseInt(cp, 10))) .join(""); } // Determine key name from codepoint let name: string; let isPrintable: boolean; 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: string): ParsedKey | null => { 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, }; }; export const parseKeypress = (s: Uint8Array | string = ""): ParsedKey => { let parts; if (s instanceof Uint8Array) { if (s[0]! > 127 && s[1] === undefined) { s = ESC + textDecoder.decode(Uint8Array.of(s[0]! - 128)); } else { s = textDecoder.decode(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, sequence: s, raw: s, isKittyProtocol: true, isPrintable: false, }; } const key: ParsedKey = { name: "", ctrl: false, meta: false, shift: false, sequence: s, raw: s, }; key.sequence = key.sequence || s || key.name; if (s === "\r" || s === ESC + "\r") { // carriage return (or meta+return on macOS) key.raw = undefined; key.name = "return"; key.meta = 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 === ESC + "\b") { // backspace or ctrl+h key.name = "backspace"; key.meta = s.charAt(0) === ESC; } else if (s === DEL || s === ESC + DEL) { // backspace key.name = "backspace"; key.meta = s.charAt(0) === ESC; } else if (s === ESC || s === ESC + ESC) { // escape key key.name = "escape"; key.meta = s.length === 2; } else if (s === " " || s === ESC + " ") { 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.name = parts[1]!.toLowerCase(); key.meta = true; key.shift = /^[A-Z]$/.test(parts[1]!); } else if ((parts = fnKeyRe.exec(s))) { // oxlint-disable-next-line typescript/no-misused-spread const segs = [...s]; if (segs[0] === ESC && segs[1] === ESC) { key.meta = 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 = Number(parts[3] || parts[5] || 1) - 1; // Parse the key modifier key.ctrl = !!(modifier & 4); key.meta = 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; };