/** * Keyboard input handling for terminal applications. * * Supports both legacy terminal sequences and Kitty keyboard protocol. * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ * Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts * * Symbol keys are also supported, however some ctrl+symbol combos * overlap with ASCII codes, e.g. ctrl+[ = ESC. * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys * Those can still be * used for ctrl+shift combos * * API: * - matchesKey(data, keyId) - Check if input matches a key identifier * - parseKey(data) - Parse input and return the key identifier * - Key - Helper object for creating typed key identifiers * - setKittyProtocolActive(active) - Set global Kitty protocol state * - isKittyProtocolActive() - Query global Kitty protocol state */ import type { KeyEventType } from "@vib-rato/natives"; type NativeKeyBindings = Pick; let nativeKeyBindings: NativeKeyBindings | undefined; function nativeKeys(): NativeKeyBindings { if (!nativeKeyBindings) nativeKeyBindings = require("@vib-rato/natives") as NativeKeyBindings; return nativeKeyBindings; } // ============================================================================= // Platform Detection // ============================================================================= function isWindowsTerminalSession(): boolean { return ( Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY ); } /** * Raw 0x08 (BS) is ambiguous in legacy terminals. * * - Windows Terminal uses it for Ctrl+Backspace. * - Some legacy terminals and tmux setups send it for plain Backspace. * * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are * available. Fall back to a Windows Terminal heuristic only for raw BS bytes. */ /** * Resolve the ambiguous raw backspace bytes against a chord's expected modifier. * * `\x7f` is always plain backspace. `\x08` is the ambiguous one: Windows * Terminal sends it for Ctrl+Backspace, while legacy terminals and some tmux * setups send it for plain Backspace. Returns undefined when the byte is neither, * meaning the caller should fall through to normal matching. */ function matchesRawBackspace(data: string, expectedModifier: number): boolean | undefined { if (data === "\x7f") return expectedModifier === 0; if (data !== "\x08") return undefined; // On Windows Terminal, 0x08 = Ctrl+Backspace. On others, it's plain Backspace. return isWindowsTerminalSession() ? expectedModifier === RAW_BACKSPACE_CTRL_MODIFIER : expectedModifier === 0; } /** Modifier code the raw-backspace heuristic treats as Ctrl. */ const RAW_BACKSPACE_CTRL_MODIFIER = 4; export { isWindowsTerminalSession, matchesRawBackspace }; // ============================================================================= // Global Kitty Protocol State // ============================================================================= let kittyProtocolActive = false; /** * Set the global Kitty keyboard protocol state. * Called by ProcessTerminal after detecting protocol support. */ export function setKittyProtocolActive(active: boolean): void { kittyProtocolActive = active; } /** * Query whether Kitty keyboard protocol is currently active. */ export function isKittyProtocolActive(): boolean { return kittyProtocolActive; } // ============================================================================= // Type-Safe Key Identifiers // ============================================================================= type Letter = | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"; type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; type SymbolKey = | "`" | "-" | "=" | "[" | "]" | "\\" | ";" | "'" | '"' | "," | "." | "/" | "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "_" | "+" | "|" | "~" | "{" | "}" | ":" | "<" | ">" | "?"; type SpecialKey = | "escape" | "esc" | "enter" | "return" | "tab" | "space" | "backspace" | "delete" | "insert" | "clear" | "home" | "end" | "pageUp" | "pageDown" | "up" | "down" | "left" | "right" | "f1" | "f2" | "f3" | "f4" | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" | "f12"; export type BaseKey = Letter | Digit | SymbolKey | SpecialKey; export type KeyModifier = "ctrl" | "shift" | "alt" | "super"; type ModifiedKeyId = { [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId>}`; }[RemainingModifiers]; /** * Union type of all valid key identifiers. * Provides autocomplete and catches typos at compile time. */ export type KeyId = BaseKey | ModifiedKeyId; export interface ParsedKeyId { keyId: KeyId; modifiers: KeyModifier[]; baseKey: BaseKey; } const BASE_KEYS = new Set([ ..."abcdefghijklmnopqrstuvwxyz", ..."0123456789", "`", "-", "=", "[", "]", "\\", ";", "'", '"', ",", ".", "/", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+", "|", "~", "{", "}", ":", "<", ">", "?", "escape", "esc", "enter", "return", "tab", "space", "backspace", "delete", "insert", "clear", "home", "end", "pageup", "pagedown", "up", "down", "left", "right", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", ]); const KEY_MODIFIER_ALIASES: Readonly>> = { ctrl: "ctrl", alt: "alt", shift: "shift", super: "super", option: "alt", meta: "alt", cmd: "super", command: "super", }; /** * Parse a case-insensitive key identifier into normalized dispatch parts. * The legacy `plus` base-key alias normalizes to `+`; the trailing `+` in * values such as `ctrl++` is the literal plus base. * Modifier aliases `option`/`meta` and `command`/`cmd` normalize to `alt` and `super`. */ export function parseKeyId(value: string): ParsedKeyId | undefined { if (hasControlChars(value) || value.length === 0) return undefined; const lowerCaseValue = value .trim() .toLowerCase() .replace(/\s*\+\s*/g, "+"); const normalized = lowerCaseValue === "plus" ? "+" : lowerCaseValue.replace(/\+plus$/, "++"); const baseKey = normalized.endsWith("+") ? "+" : normalized.split("+").pop(); if (!baseKey || !BASE_KEYS.has(baseKey)) return undefined; const modifierSource = normalized.slice(0, normalized.length - baseKey.length); const modifierParts = modifierSource.length === 0 ? [] : modifierSource .slice(0, -1) .split("+") .map(part => part.trim()); const modifiers: KeyModifier[] = []; for (const part of modifierParts) { const modifier = Object.hasOwn(KEY_MODIFIER_ALIASES, part) ? KEY_MODIFIER_ALIASES[part] : undefined; if (!modifier || modifiers.includes(modifier)) return undefined; modifiers.push(modifier); } const canonicalBase = baseKey === "pageup" ? "pageUp" : baseKey === "pagedown" ? "pageDown" : baseKey; return { keyId: [...modifiers, canonicalBase].join("+") as KeyId, modifiers, baseKey: canonicalBase as BaseKey, }; } /** Whether a value is a valid canonical key identifier. */ export function isKeyId(value: string): value is KeyId { return parseKeyId(value)?.keyId === value; } /** * Typed helper for constructing key identifiers with autocomplete. * * The runtime values are just the canonical key-name strings (so `Key.enter` * is literally `"enter"`); the value of `Key` over a bag of magic strings is * that each property is typed to the exact `KeyId` literal it produces and the * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")` * The canonical modifier helpers mirror the upstream `@mariozechner/pi-tui` `Key` export so plugins built against any scope alias (`@mariozechner`, `@earendil-works`, `@vib-rato`) keep working once the specifier shim remaps them to this package. The additional `option`/`command` helpers normalize macOS naming to the portable `alt`/`super` identifiers. */ export const Key = { escape: "escape", esc: "esc", enter: "enter", return: "return", tab: "tab", space: "space", backspace: "backspace", delete: "delete", insert: "insert", clear: "clear", home: "home", end: "end", pageUp: "pageUp", pageDown: "pageDown", up: "up", down: "down", left: "left", right: "right", f1: "f1", f2: "f2", f3: "f3", f4: "f4", f5: "f5", f6: "f6", f7: "f7", f8: "f8", f9: "f9", f10: "f10", f11: "f11", f12: "f12", backtick: "`", hyphen: "-", equals: "=", leftbracket: "[", rightbracket: "]", backslash: "\\", semicolon: ";", quote: "'", comma: ",", period: ".", slash: "/", exclamation: "!", at: "@", hash: "#", dollar: "$", percent: "%", caret: "^", ampersand: "&", asterisk: "*", leftparen: "(", rightparen: ")", underscore: "_", plus: "+", pipe: "|", tilde: "~", leftbrace: "{", rightbrace: "}", colon: ":", lessthan: "<", greaterthan: ">", question: "?", ctrl: (key: K) => `ctrl+${key}` as const, shift: (key: K) => `shift+${key}` as const, alt: (key: K) => `alt+${key}` as const, option: (key: K) => `alt+${key}` as const, super: (key: K) => `super+${key}` as const, cmd: (key: K) => `super+${key}` as const, command: (key: K) => `super+${key}` as const, ctrlShift: (key: K) => `ctrl+shift+${key}` as const, shiftCtrl: (key: K) => `shift+ctrl+${key}` as const, ctrlAlt: (key: K) => `ctrl+alt+${key}` as const, altCtrl: (key: K) => `alt+ctrl+${key}` as const, shiftAlt: (key: K) => `shift+alt+${key}` as const, altShift: (key: K) => `alt+shift+${key}` as const, ctrlSuper: (key: K) => `ctrl+super+${key}` as const, superCtrl: (key: K) => `super+ctrl+${key}` as const, shiftSuper: (key: K) => `shift+super+${key}` as const, superShift: (key: K) => `super+shift+${key}` as const, altSuper: (key: K) => `alt+super+${key}` as const, superAlt: (key: K) => `super+alt+${key}` as const, ctrlShiftAlt: (key: K) => `ctrl+shift+alt+${key}` as const, ctrlShiftSuper: (key: K) => `ctrl+shift+super+${key}` as const, } as const; // ============================================================================= // Kitty Protocol Parsing // ============================================================================= interface ParsedKittySequence { codepoint: number; shiftedKey?: number; // Shifted version of the key (when shift is pressed) baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts) modifier: number; eventType?: KeyEventType; } // Regex for Kitty protocol event type detection // Matches CSI sequences with :2 (repeat) or :3 (release) event type // Format: \x1b[...;modifier:event_type where terminator is u, ~, navigation, or function-key finals const KITTY_RELEASE_PATTERN = /^\x1b\[[\d:;]*:3[u~ABCDEFHPQRS]$/; const KITTY_REPEAT_PATTERN = /^\x1b\[[\d:;]*:2[u~ABCDEFHPQRS]$/; const KITTY_CSI_U_PATTERN = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?(?:;([\d:]*))?u$/; const KITTY_MOD_SHIFT = 1; const KITTY_MOD_ALT = 2; const KITTY_MOD_CTRL = 4; const KITTY_MOD_SUPER = 8; const KITTY_MOD_NUM_LOCK = 128; const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock const KOREAN_DUBEOLSIK_BASE_KEYS = new Map( [ ["ㄱ", "r"], ["ㄲ", "r"], ["ㄴ", "s"], ["ㄷ", "e"], ["ㄸ", "e"], ["ㄹ", "f"], ["ㅁ", "a"], ["ㅂ", "q"], ["ㅃ", "q"], ["ㅅ", "t"], ["ㅆ", "t"], ["ㅇ", "d"], ["ㅈ", "w"], ["ㅉ", "w"], ["ㅊ", "c"], ["ㅋ", "z"], ["ㅌ", "x"], ["ㅍ", "v"], ["ㅎ", "g"], ["ㅏ", "k"], ["ㅐ", "o"], ["ㅑ", "i"], ["ㅒ", "o"], ["ㅓ", "j"], ["ㅔ", "p"], ["ㅕ", "u"], ["ㅖ", "p"], ["ㅗ", "h"], ["ㅛ", "y"], ["ㅜ", "n"], ["ㅠ", "b"], ["ㅡ", "m"], ["ㅣ", "l"], ].map(([character, key]) => [character.codePointAt(0)!, key as BaseKey]), ); function matchesKoreanDubeolsikKittySequence(data: string, keyId: KeyId): boolean { const event = parseKittySequence(data); if (!event || event.eventType === 3 || event.baseLayoutKey !== undefined) return false; const baseKey = KOREAN_DUBEOLSIK_BASE_KEYS.get(event.codepoint); if (!baseKey) return false; const expected = parseKeyId(keyId); if ( !expected?.modifiers.some(modifier => modifier === "alt" || modifier === "ctrl" || modifier === "super") || expected.baseKey !== baseKey ) return false; let expectedModifier = 0; for (const modifier of expected.modifiers) { switch (modifier) { case "shift": expectedModifier |= KITTY_MOD_SHIFT; break; case "alt": expectedModifier |= KITTY_MOD_ALT; break; case "ctrl": expectedModifier |= KITTY_MOD_CTRL; break; case "super": expectedModifier |= KITTY_MOD_SUPER; break; } } return (event.modifier & ~KITTY_LOCK_MASK) === expectedModifier; } const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/; const KITTY_KEYPAD_OPERATOR_TEXT: Record = { 57410: "/", 57411: "*", 57412: "-", 57413: "+", 57415: "=", }; const KITTY_NUMPAD_TEXT: Record = { 57399: "0", 57400: "1", 57401: "2", 57402: "3", 57403: "4", 57404: "5", 57405: "6", 57406: "7", 57407: "8", 57408: "9", 57409: ".", }; /** * Check if the input is a key release event. * Only meaningful when Kitty keyboard protocol with flag 2 is active. * Returns false if Kitty protocol is not active. */ export function isKeyRelease(data: string): boolean { // Only detect release events when Kitty protocol is active if (!kittyProtocolActive) { return false; } // Don't treat bracketed paste content as key release if (data.includes("\x1b[200~")) { return false; } // Match the full CSI sequence pattern for release events return KITTY_RELEASE_PATTERN.test(data); } /** * Check if the input is a key repeat event. * Only meaningful when Kitty keyboard protocol with flag 2 is active. * Returns false if Kitty protocol is not active. */ export function isKeyRepeat(data: string): boolean { // Only detect repeat events when Kitty protocol is active if (!kittyProtocolActive) { return false; } // Don't treat bracketed paste content as key repeat if (data.includes("\x1b[200~")) { return false; } // Match the full CSI sequence pattern for repeat events return KITTY_REPEAT_PATTERN.test(data); } export function parseKittySequence(data: string): ParsedKittySequence | null { const result = nativeKeys().parseKittySequence(data); if (!result) return null; return { codepoint: result.codepoint, shiftedKey: result.shiftedKey ?? undefined, baseLayoutKey: result.baseLayoutKey ?? undefined, modifier: result.modifier, eventType: result.eventType, }; } function hasControlChars(data: string): boolean { return [...data].some(ch => { const code = ch.charCodeAt(0); return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); }); } function isValidProtocolNumber(value: number): boolean { return Number.isSafeInteger(value) && value >= 0 && value <= 0xffffffff; } function isPrintableCodePoint(value: number): boolean { return ( Number.isSafeInteger(value) && value >= 32 && value <= 0x10ffff && value !== 0x7f && !(value >= 0x80 && value <= 0x9f) && !(value >= 0xd800 && value <= 0xdfff) ); } function decodeKittyPrintable(data: string): string | undefined { const match = data.match(KITTY_CSI_U_PATTERN); if (!match) return undefined; const codepoint = Number.parseInt(match[1] ?? "", 10); if (!isValidProtocolNumber(codepoint)) return undefined; const eventTypeText = match[5]; const eventType = eventTypeText === undefined || eventTypeText.length === 0 ? undefined : Number.parseInt(eventTypeText, 10); if (eventType !== undefined && eventType !== 1 && eventType !== 2) return undefined; const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined; const modValue = match[4] ? Number.parseInt(match[4], 10) : 1; if (!isValidProtocolNumber(modValue) || modValue < 1) return undefined; const modifier = modValue - 1; const effectiveMod = modifier & ~KITTY_LOCK_MASK; const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER; if (effectiveMod & ~supportedModifierMask) return undefined; if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER)) return undefined; const textField = match[6]; if (textField && textField.length > 0) { const codepoints = textField .split(":") .filter(Boolean) .map(value => Number.parseInt(value, 10)) .filter(value => isPrintableCodePoint(value)); if (codepoints.length > 0) { try { return String.fromCodePoint(...codepoints); } catch { return undefined; } } return undefined; } const keypadOperatorText = KITTY_KEYPAD_OPERATOR_TEXT[codepoint]; if (keypadOperatorText) return keypadOperatorText; if (effectiveMod === 0 && modifier & KITTY_MOD_NUM_LOCK) { const numpadText = KITTY_NUMPAD_TEXT[codepoint]; if (numpadText) return numpadText; } let effectiveCodepoint = codepoint; if (effectiveMod & KITTY_MOD_SHIFT && typeof shiftedKey === "number") { effectiveCodepoint = shiftedKey; } if (effectiveCodepoint >= 0xe000 && effectiveCodepoint <= 0xf8ff) { return undefined; } if (!isPrintableCodePoint(effectiveCodepoint)) return undefined; try { return String.fromCodePoint(effectiveCodepoint); } catch { return undefined; } } /** * Extract printable text from raw terminal input. * * Handles Kitty CSI-u text-producing keys so text-entry components can treat * keypad digits, keypad operators, and shifted symbols the same as direct character input. */ export function extractPrintableText(data: string): string | undefined { const printable = decodePrintableKey(data); if (printable !== undefined && !hasControlChars(printable)) return printable; if (data.length === 0 || hasControlChars(data)) return undefined; return data; } interface ParsedModifyOtherKeysSequence { codepoint: number; modifier: number; } /** * Parse an xterm `modifyOtherKeys` format sequence: `CSI 27 ; modifiers ; keycode ~`. * Modifier values are 1-indexed in the wire format; we normalize to a 0-based bitmask. */ function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null { const match = data.match(MODIFY_OTHER_KEYS_PATTERN); if (!match) return null; const modValue = Number.parseInt(match[1] ?? "", 10); const codepoint = Number.parseInt(match[2] ?? "", 10); if (!isValidProtocolNumber(modValue) || modValue < 1 || !isValidProtocolNumber(codepoint)) return null; return { codepoint, modifier: modValue - 1 }; } /** * Decode an xterm modifyOtherKeys sequence into the printable character it represents. * * Only sequences with no modifiers or Shift alone produce text; Ctrl/Alt/Super combos * are treated as bindings, not text input. */ function decodeModifyOtherKeysPrintable(data: string): string | undefined { const parsed = parseModifyOtherKeysSequence(data); if (!parsed) return undefined; const modifier = parsed.modifier & ~KITTY_LOCK_MASK; if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined; if (!isPrintableCodePoint(parsed.codepoint)) return undefined; try { return String.fromCodePoint(parsed.codepoint); } catch { return undefined; } } /** * Decode terminal input into the printable character it represents. * * Tries Kitty CSI-u first, then falls back to xterm modifyOtherKeys. Returns * undefined for control sequences and modifier-only events. */ export function decodePrintableKey(data: string): string | undefined { return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data); } /** * Match input data against a key identifier string. * * Supported key identifiers: * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space" * - Arrow keys: "up", "down", "left", "right" * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. * - Shift combinations: "shift+tab", "shift+enter" * - Alt combinations: "alt+enter", "alt+backspace" * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x" * * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p") * * @param data - Raw input data from terminal * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ export function matchesKey(data: string, keyId: KeyId): boolean { if (data === "\x08") { // Raw 0x08 is ambiguous and the native matcher resolves it as unmodified // backspace only, so on Windows Terminal -- where the byte means // ctrl+backspace -- every chord declaring ctrl+backspace silently never // fired. One helper owns that decision for both chords so the two cannot // drift apart. if (keyId === "ctrl+backspace") return matchesRawBackspace(data, RAW_BACKSPACE_CTRL_MODIFIER) === true; if (keyId === "backspace") return matchesRawBackspace(data, 0) === true; } return ( nativeKeys().matchesKey(data, keyId, kittyProtocolActive) || (kittyProtocolActive && matchesKoreanDubeolsikKittySequence(data, keyId)) ); } /** * Parse terminal input and return a normalized key identifier. * * Returns key names like "escape", "ctrl+c", "shift+tab", "alt+enter". * Returns undefined if the input is not a recognized key sequence. * * @param data - Raw input data from terminal */ export function parseKey(data: string): string | undefined { return nativeKeys().parseKey(data, kittyProtocolActive) ?? undefined; }