From 283444e87b7f9a8a649e9846f750b6eaf95736fe Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 13:55:30 +0800 Subject: [PATCH] feat: port parse-keypress (semantic mapper) from Ink Port Ink's parse-keypress.ts to map raw input event strings to semantic key objects with name, ctrl, shift, meta, and kitty protocol support. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/src/io/parse-keypress.test.ts | 91 +++ packages/runtime/src/io/parse-keypress.ts | 529 ++++++++++++++++++ 2 files changed, 620 insertions(+) create mode 100644 packages/runtime/src/io/parse-keypress.test.ts create mode 100644 packages/runtime/src/io/parse-keypress.ts diff --git a/packages/runtime/src/io/parse-keypress.test.ts b/packages/runtime/src/io/parse-keypress.test.ts new file mode 100644 index 0000000..234976a --- /dev/null +++ b/packages/runtime/src/io/parse-keypress.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from "vite-plus/test"; +import { parseKeypress } from "./parse-keypress.ts"; + +describe("parse-keypress", () => { + test("parses regular character", () => { + const key = parseKeypress("a"); + expect(key.name).toBe("a"); + expect(key.ctrl).toBe(false); + expect(key.shift).toBe(false); + expect(key.meta).toBe(false); + }); + + test("parses ctrl+c", () => { + const key = parseKeypress("\x03"); + expect(key.name).toBe("c"); + expect(key.ctrl).toBe(true); + }); + + test("parses arrow up", () => { + const key = parseKeypress("\x1b[A"); + expect(key.name).toBe("up"); + }); + + test("parses arrow down", () => { + const key = parseKeypress("\x1b[B"); + expect(key.name).toBe("down"); + }); + + test("parses meta+a", () => { + const key = parseKeypress("\x1ba"); + expect(key.name).toBe("a"); + expect(key.meta).toBe(true); + }); + + test("parses backspace", () => { + const key = parseKeypress("\x7F"); + expect(key.name).toBe("backspace"); + }); + + test("parses return", () => { + const key = parseKeypress("\r"); + expect(key.name).toBe("return"); + }); + + test("parses tab", () => { + const key = parseKeypress("\t"); + expect(key.name).toBe("tab"); + }); + + test("parses shift+tab", () => { + const key = parseKeypress("\x1b[Z"); + expect(key.name).toBe("tab"); + expect(key.shift).toBe(true); + }); + + test("parses escape", () => { + const key = parseKeypress("\x1b"); + expect(key.name).toBe("escape"); + }); + + test("parses F1", () => { + const key = parseKeypress("\x1bOP"); + expect(key.name).toBe("f1"); + }); + + test("parses home", () => { + const key = parseKeypress("\x1b[H"); + expect(key.name).toBe("home"); + }); + + test("parses end", () => { + const key = parseKeypress("\x1b[F"); + expect(key.name).toBe("end"); + }); + + test("parses delete", () => { + const key = parseKeypress("\x1b[3~"); + expect(key.name).toBe("delete"); + }); + + test("parses page up", () => { + const key = parseKeypress("\x1b[5~"); + expect(key.name).toBe("pageup"); + }); + + test("uppercase letter sets shift", () => { + const key = parseKeypress("A"); + expect(key.name).toBe("a"); + expect(key.shift).toBe(true); + }); +}); diff --git a/packages/runtime/src/io/parse-keypress.ts b/packages/runtime/src/io/parse-keypress.ts new file mode 100644 index 0000000..070e6bf --- /dev/null +++ b/packages/runtime/src/io/parse-keypress.ts @@ -0,0 +1,529 @@ +// Ported from Ink's parse-keypress.ts +// @see https://github.com/vadimdemedes/ink + +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); +}; + +export interface Keypress { + 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 modifier bits. +// The actual modifier value in CSI sequences is (modifiers - 1). +const kittyModifiers = { + shift: 1, + alt: 2, + ctrl: 4, + super: 8, + hyper: 16, + meta: 32, + capsLock: 64, + numLock: 128, +} as const; + +// 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): Keypress | 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): Keypress | 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 function parseKeypress(s: Uint8Array | string = ""): Keypress { + let parts; + + if (s instanceof Uint8Array) { + if (s[0]! > 127 && s[1] === undefined) { + (s[0] as unknown as number) -= 128; + s = "\x1b" + textDecoder.decode(s); + } 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: Keypress = { + name: "", + ctrl: false, + meta: false, + shift: false, + sequence: s, + raw: s, + }; + + key.sequence = key.sequence || s || key.name; + + if (s === "\r" || s === "\x1b\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 === "\x1b\b") { + // backspace or ctrl+h + key.name = "backspace"; + key.meta = s.charAt(0) === "\x1b"; + } else if (s === "\x7f" || s === "\x1b\x7f") { + // backspace + key.name = "backspace"; + 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.name = parts[1]!.toLowerCase(); + key.meta = true; + key.shift = /^[A-Z]$/.test(parts[1]!); + } else if ((parts = fnKeyRe.exec(s))) { + const segs = [...s]; + + if (segs[0] === "" && segs[1] === "") { + 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 = ((parts[3] || parts[5] || 1) as number) - 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; +}