import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js' import { Event } from './event.js' export type Key = { upArrow: boolean downArrow: boolean leftArrow: boolean rightArrow: boolean pageDown: boolean pageUp: boolean wheelUp: boolean wheelDown: boolean home: boolean end: boolean return: boolean escape: boolean ctrl: boolean shift: boolean fn: boolean tab: boolean backspace: boolean delete: boolean meta: boolean super: boolean } function parseKey(keypress: ParsedKey): [Key, string] { const key: Key = { upArrow: keypress.name === 'up', downArrow: keypress.name === 'down', leftArrow: keypress.name === 'left', rightArrow: keypress.name === 'right', pageDown: keypress.name === 'pagedown', pageUp: keypress.name === 'pageup', wheelUp: keypress.name === 'wheelup', wheelDown: keypress.name === 'wheeldown', home: keypress.name === 'home', end: keypress.name === 'end', return: keypress.name === 'return', escape: keypress.name === 'escape', fn: keypress.fn, ctrl: keypress.ctrl, shift: keypress.shift, tab: keypress.name === 'tab', backspace: keypress.name === 'backspace', delete: keypress.name === 'delete', // `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false // but with option = true, so we need to take this into account here // to avoid breaking changes in Ink. // TODO(vadimdemedes): consider removing this in the next major version. meta: keypress.meta || keypress.name === 'escape' || keypress.option, // Super (Cmd on macOS / Win key) — only arrives via kitty keyboard // protocol CSI u sequences. Distinct from meta (Alt/Option) so // bindings like cmd+c can be expressed separately from opt+c. super: keypress.super, } let input = keypress.ctrl ? keypress.name : keypress.sequence // Handle undefined input case if (input === undefined) { input = '' } // When ctrl is set, keypress.name for space is the literal word "space". // Convert to actual space character for consistency with the CSI u branch // (which maps 'space' → ' '). Without this, ctrl+space leaks the literal // word "space" into text input. if (keypress.ctrl && input === 'space') { input = ' ' } // Suppress unrecognized escape sequences that were parsed as function keys // (matched by FN_KEY_RE) but have no name in the keyName map. // Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc. // Without this, the ESC prefix is stripped below and the remainder (e.g., // "[25~") leaks into the input as literal text. if (keypress.code && !keypress.name) { input = '' } // Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks // the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across // stdin chunks gets its buffered ESC flushed as a lone Escape key, and the // continuation arrives as a text token with name='' — which falls through // all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys // clear below (name is falsy). The fragment then leaks into the prompt as // literal `[<64;74;16M`. This is the same defensive sink as the F13 guard // above; the underlying tokenizer-flush race is upstream of this layer. if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) { input = '' } // Strip meta if it's still remaining after `parseKeypress` // TODO(vadimdemedes): remove this in the next major version. if (input.startsWith('\u001B')) { input = input.slice(1) } // Track whether we've already processed this as a special sequence // that converted input to the key name (CSI u or application keypad mode). // For these, we don't want to clear input with nonAlphanumericKeys check. let processedAsSpecialSequence = false // Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC, // we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b). // Use the parsed key name instead for input handling. Require a digit // after [ — real CSI u is always […u, and a bare startsWith('[') // false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the // literal text "mouse" into the prompt via processedAsSpecialSequence. if (/^\[\d/.test(input) && input.endsWith('u')) { if (!keypress.name) { // Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav, // bare modifiers, etc.) — keycodeToName() returned undefined. Swallow // so the raw "[57358u" doesn't leak into the prompt. See #38781. input = '' } else { // 'space' → ' '; 'escape' → '' (key.escape carries it; // processedAsSpecialSequence bypasses the nonAlphanumericKeys // clear below, so we must handle it explicitly here); // otherwise use key name. input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name } processedAsSpecialSequence = true } // Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left // with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same // extraction as CSI u — without this, printable-char keycodes (single-letter // names) skip the nonAlphanumericKeys clear and leak "[27;..." as input. if (input.startsWith('[27;') && input.endsWith('~')) { if (!keypress.name) { // Unmapped modifyOtherKeys keycode — swallow for consistency with // the CSI u handler above. Practically untriggerable today (xterm // modifyOtherKeys only sends ASCII keycodes, all mapped), but // guards against future terminal behavior. input = '' } else { input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name } processedAsSpecialSequence = true } // Handle application keypad mode sequences: after stripping ESC, // we're left with "O" (e.g., "Op" for numpad 0, "Oy" for numpad 9). // Use the parsed key name (the digit character) for input handling. if ( input.startsWith('O') && input.length === 2 && keypress.name && keypress.name.length === 1 ) { input = keypress.name processedAsSpecialSequence = true } // Clear input for non-alphanumeric keys (arrows, function keys, etc.) // Skip this for CSI u and application keypad mode sequences since // those were already converted to their proper input characters. if ( !processedAsSpecialSequence && keypress.name && nonAlphanumericKeys.includes(keypress.name) ) { input = '' } // Set shift=true for uppercase letters (A-Z) // Must check it's actually a letter, not just any char unchanged by toUpperCase if ( input.length === 1 && typeof input[0] === 'string' && input[0] >= 'A' && input[0] <= 'Z' ) { key.shift = true } return [key, input] } export class InputEvent extends Event { readonly keypress: ParsedKey readonly key: Key readonly input: string constructor(keypress: ParsedKey) { super() const [key, input] = parseKey(keypress) this.keypress = keypress this.key = key this.input = input } }