/** * Keyboard input parser - converts terminal input to key events * * Uses the termio tokenizer for escape sequence boundary detection, * then interprets sequences as keypresses. */ import { Buffer } from 'buffer' import { PASTE_END, PASTE_START } from './termio/csi.js' import { createTokenizer, type Tokenizer } from './termio/tokenize.js' // eslint-disable-next-line no-control-regex const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/ // eslint-disable-next-line no-control-regex const FN_KEY_RE = // eslint-disable-next-line no-control-regex /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/ // CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers) // Modifier is optional - when absent, defaults to 1 (no modifiers) // eslint-disable-next-line no-control-regex const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/ // xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~ // Example: ESC[27;2;13~ = Shift+Enter. Emitted by Ghostty/tmux/xterm when // modifyOtherKeys=2 is active or via user keybinds, typically over SSH where // TERM sniffing misses Ghostty and we never push Kitty keyboard mode. // Note param order is reversed vs CSI u (modifier first, keycode second). // eslint-disable-next-line no-control-regex const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/ // -- Terminal response patterns (inbound sequences from the terminal itself) -- // DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode) // eslint-disable-next-line no-control-regex const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/ // DA1: CSI ? Ps ; ... c — primary device attributes response // eslint-disable-next-line no-control-regex const DA1_RE = /^\x1b\[\?([\d;]*)c$/ // DA2: CSI > Ps ; ... c — secondary device attributes response // eslint-disable-next-line no-control-regex const DA2_RE = /^\x1b\[>([\d;]*)c$/ // Kitty keyboard flags: CSI ? flags u — response to CSI ? u query // (private ? marker distinguishes from CSI u key events) // eslint-disable-next-line no-control-regex const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/ // DECXCPR cursor position: CSI ? row ; col R // The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R, // Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous. // eslint-disable-next-line no-control-regex const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/ // OSC response: OSC code ; data (BEL|ST) // eslint-disable-next-line no-control-regex const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s // XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q). // xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with // their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply // goes through the pty, not the environment. // eslint-disable-next-line no-control-regex const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s // SGR mouse event: CSI < button ; col ; row M (press) or m (release) // Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit). // Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click. // eslint-disable-next-line no-control-regex const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/ function createPasteKey(content: string): ParsedKey { return { kind: 'key', name: '', fn: false, ctrl: false, meta: false, shift: false, option: false, super: false, sequence: content, raw: content, isPasted: true, } } /** DECRPM status values (response to DECRQM) */ export const DECRPM_STATUS = { NOT_RECOGNIZED: 0, SET: 1, RESET: 2, PERMANENTLY_SET: 3, PERMANENTLY_RESET: 4, } as const /** * A response sequence received from the terminal (not a keypress). * Emitted in answer to queries like DECRQM, DA1, OSC 11, etc. */ export type TerminalResponse = /** DECRPM: answer to DECRQM (request DEC private mode status) */ | { type: 'decrpm'; mode: number; status: number } /** DA1: primary device attributes (used as a universal sentinel) */ | { type: 'da1'; params: number[] } /** DA2: secondary device attributes (terminal version info) */ | { type: 'da2'; params: number[] } /** Kitty keyboard protocol: current flags (answer to CSI ? u) */ | { type: 'kittyKeyboard'; flags: number } /** DSR: cursor position report (answer to CSI 6 n) */ | { type: 'cursorPosition'; row: number; col: number } /** OSC response: generic operating-system-command reply (e.g. OSC 11 bg color) */ | { type: 'osc'; code: number; data: string } /** XTVERSION: terminal name/version string (answer to CSI > 0 q). * Example values: "xterm.js(5.5.0)", "ghostty 1.2.0", "iTerm2 3.6". */ | { type: 'xtversion'; name: string } /** * Try to recognize a sequence token as a terminal response. * Returns null if the sequence is not a known response pattern * (i.e. it should be treated as a keypress). * * These patterns are syntactically distinguishable from keyboard input — * no physical key produces CSI ? ... c or CSI ? ... $ y, so they can be * safely parsed out of the input stream at any time. */ function parseTerminalResponse(s: string): TerminalResponse | null { // CSI-prefixed responses if (s.startsWith('\x1b[')) { let m: RegExpExecArray | null if ((m = DECRPM_RE.exec(s))) { return { type: 'decrpm', mode: parseInt(m[1]!, 10), status: parseInt(m[2]!, 10), } } if ((m = DA1_RE.exec(s))) { return { type: 'da1', params: splitNumericParams(m[1]!) } } if ((m = DA2_RE.exec(s))) { return { type: 'da2', params: splitNumericParams(m[1]!) } } if ((m = KITTY_FLAGS_RE.exec(s))) { return { type: 'kittyKeyboard', flags: parseInt(m[1]!, 10) } } if ((m = CURSOR_POSITION_RE.exec(s))) { return { type: 'cursorPosition', row: parseInt(m[1]!, 10), col: parseInt(m[2]!, 10), } } return null } // OSC responses (e.g. OSC 11 ; rgb:... for bg color query) if (s.startsWith('\x1b]')) { const m = OSC_RESPONSE_RE.exec(s) if (m) { return { type: 'osc', code: parseInt(m[1]!, 10), data: m[2]! } } } // DCS responses (e.g. XTVERSION: DCS > | name ST) if (s.startsWith('\x1bP')) { const m = XTVERSION_RE.exec(s) if (m) { return { type: 'xtversion', name: m[1]! } } } return null } function splitNumericParams(params: string): number[] { if (!params) return [] return params.split(';').map(p => parseInt(p, 10)) } export type KeyParseState = { mode: 'NORMAL' | 'IN_PASTE' incomplete: string pasteBuffer: string // Internal tokenizer instance _tokenizer?: Tokenizer } export const INITIAL_STATE: KeyParseState = { mode: 'NORMAL', incomplete: '', pasteBuffer: '', } function inputToString(input: Buffer | string): string { if (Buffer.isBuffer(input)) { if (input[0]! > 127 && input[1] === undefined) { ;(input[0] as unknown as number) -= 128 return '\x1b' + String(input) } else { return String(input) } } else if (input !== undefined && typeof input !== 'string') { return String(input) } else if (!input) { return '' } else { return input } } export function parseMultipleKeypresses( prevState: KeyParseState, input: Buffer | string | null = '', ): [ParsedInput[], KeyParseState] { const isFlush = input === null const inputString = isFlush ? '' : inputToString(input) // Get or create tokenizer const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true }) // Tokenize the input const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString) // Convert tokens to parsed keys, handling paste mode const keys: ParsedInput[] = [] let inPaste = prevState.mode === 'IN_PASTE' let pasteBuffer = prevState.pasteBuffer for (const token of tokens) { if (token.type === 'sequence') { if (token.value === PASTE_START) { inPaste = true pasteBuffer = '' } else if (token.value === PASTE_END) { // Always emit a paste key, even for empty pastes. This allows // downstream handlers to detect empty pastes (e.g., for clipboard // image handling on macOS). The paste content may be empty string. keys.push(createPasteKey(pasteBuffer)) inPaste = false pasteBuffer = '' } else if (inPaste) { // Sequences inside paste are treated as literal text pasteBuffer += token.value } else { const response = parseTerminalResponse(token.value) if (response) { keys.push({ kind: 'response', sequence: token.value, response }) } else { const mouse = parseMouseEvent(token.value) if (mouse) { keys.push(mouse) } else { keys.push(parseKeypress(token.value)) } } } } else if (token.type === 'text') { if (inPaste) { pasteBuffer += token.value } else if ( /^\[<\d+;\d+;\d+[Mm]$/.test(token.value) || /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value) ) { // Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off // otherwise). A heavy render blocked the event loop past App's 50ms // flush timer, so the buffered ESC was flushed as a lone Escape and // the continuation `[ = { /* xterm/gnome ESC O letter */ OP: 'f1', OQ: 'f2', OR: 'f3', OS: 'f4', /* Application keypad mode (numpad digits 0-9) */ Op: '0', Oq: '1', Or: '2', Os: '3', Ot: '4', Ou: '5', Ov: '6', Ow: '7', Ox: '8', Oy: '9', /* Application keypad mode (numpad operators) */ Oj: '*', Ok: '+', Ol: ',', Om: '-', On: '.', Oo: '/', OM: 'return', /* 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 = [ // Filter out single-character values (digits, operators from numpad) since // those are printable characters that should produce input ...Object.values(keyName).filter(v => v.length > 1), // escape and backspace are assigned directly in parseKeypress (not via the // keyName map), so the spread above misses them. Without these, ctrl+escape // via Kitty/modifyOtherKeys leaks the literal word "escape" as input text // (input-event.ts:58 assigns keypress.name when ctrl is set). 'escape', 'backspace', 'wheelup', 'wheeldown', 'mouse', ] const isShiftKey = (code: string): boolean => { return [ '[a', '[b', '[c', '[d', '[e', '[2$', '[3$', '[5$', '[6$', '[7$', '[8$', '[Z', ].includes(code) } const isCtrlKey = (code: string): boolean => { return [ 'Oa', 'Ob', 'Oc', 'Od', 'Oe', '[2^', '[3^', '[5^', '[6^', '[7^', '[8^', ].includes(code) } /** * Decode XTerm-style modifier value to individual flags. * Modifier encoding: 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0) + (super ? 8 : 0) * * Note: `meta` here means Alt/Option (bit 2). `super` is a distinct * modifier (bit 8, i.e. Cmd on macOS / Win key). Most legacy terminal * sequences can't express super — it only arrives via kitty keyboard * protocol (CSI u) or xterm modifyOtherKeys. */ function decodeModifier(modifier: number): { shift: boolean meta: boolean ctrl: boolean super: boolean } { const m = modifier - 1 return { shift: !!(m & 1), meta: !!(m & 2), ctrl: !!(m & 4), super: !!(m & 8), } } /** * Map keycode to key name for modifyOtherKeys/CSI u sequences. * Handles both ASCII keycodes and Kitty keyboard protocol functional keys. * * Numpad codepoints are from Unicode Private Use Area, defined at: * https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions */ function keycodeToName(keycode: number): string | undefined { switch (keycode) { case 9: return 'tab' case 13: return 'return' case 27: return 'escape' case 32: return 'space' case 127: return 'backspace' // Kitty keyboard protocol numpad keys (KP_0 through KP_9) case 57399: return '0' case 57400: return '1' case 57401: return '2' case 57402: return '3' case 57403: return '4' case 57404: return '5' case 57405: return '6' case 57406: return '7' case 57407: return '8' case 57408: return '9' case 57409: // KP_DECIMAL return '.' case 57410: // KP_DIVIDE return '/' case 57411: // KP_MULTIPLY return '*' case 57412: // KP_SUBTRACT return '-' case 57413: // KP_ADD return '+' case 57414: // KP_ENTER return 'return' case 57415: // KP_EQUAL return '=' default: // Printable ASCII characters if (keycode >= 32 && keycode <= 126) { return String.fromCharCode(keycode).toLowerCase() } return undefined } } export type ParsedKey = { kind: 'key' fn: boolean name: string | undefined ctrl: boolean meta: boolean shift: boolean option: boolean super: boolean sequence: string | undefined raw: string | undefined code?: string isPasted: boolean } /** A terminal response sequence (DECRPM, DA1, OSC reply, etc.) parsed * out of the input stream. Not user input — consumers should dispatch * to a response handler. */ export type ParsedResponse = { kind: 'response' /** Raw escape sequence bytes, for debugging/logging */ sequence: string response: TerminalResponse } /** SGR mouse event with coordinates. Emitted for clicks, drags, and * releases (wheel events remain ParsedKey). col/row are 1-indexed * from the terminal sequence (CSI < btn;col;row M/m). */ export type ParsedMouse = { kind: 'mouse' /** Raw SGR button code. Low 2 bits = button (0=left,1=mid,2=right), * bit 5 (0x20) = drag/motion, bit 6 (0x40) = wheel. */ button: number /** 'press' for M terminator, 'release' for m terminator */ action: 'press' | 'release' /** 1-indexed column (from terminal) */ col: number /** 1-indexed row (from terminal) */ row: number sequence: string } /** Everything that can come out of the input parser: a user keypress/paste, * a mouse click/drag event, or a terminal response to a query we sent. */ export type ParsedInput = ParsedKey | ParsedMouse | ParsedResponse /** * Parse an SGR mouse event sequence into a ParsedMouse, or null if not a * mouse event or if it's a wheel event (wheel stays as ParsedKey for the * keybinding system). Button bit 0x40 = wheel, bit 0x20 = drag/motion. */ function parseMouseEvent(s: string): ParsedMouse | null { const match = SGR_MOUSE_RE.exec(s) if (!match) return null const button = parseInt(match[1]!, 10) // Wheel events (bit 6 set, low bits 0/1 for up/down) stay as ParsedKey // so the keybinding system can route them to scroll handlers. if ((button & 0x40) !== 0) return null return { kind: 'mouse', button, action: match[4] === 'M' ? 'press' : 'release', col: parseInt(match[2]!, 10), row: parseInt(match[3]!, 10), sequence: s, } } function parseKeypress(s: string = ''): ParsedKey { let parts const key: ParsedKey = { kind: 'key', name: '', fn: false, ctrl: false, meta: false, shift: false, option: false, super: false, sequence: s, raw: s, isPasted: false, } key.sequence = key.sequence || s || key.name // Handle CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers) let match: RegExpExecArray | null if ((match = CSI_U_RE.exec(s))) { const codepoint = parseInt(match[1]!, 10) // Modifier defaults to 1 (no modifiers) when not present const modifier = match[2] ? parseInt(match[2], 10) : 1 const mods = decodeModifier(modifier) const name = keycodeToName(codepoint) return { kind: 'key', name, fn: false, ctrl: mods.ctrl, meta: mods.meta, shift: mods.shift, option: false, super: mods.super, sequence: s, raw: s, isPasted: false, } } // Handle xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~ // Must run before FN_KEY_RE — FN_KEY_RE only allows 2 params before ~ and // would leave the tail as garbage if it partially matched. if ((match = MODIFY_OTHER_KEYS_RE.exec(s))) { const mods = decodeModifier(parseInt(match[1]!, 10)) const name = keycodeToName(parseInt(match[2]!, 10)) return { kind: 'key', name, fn: false, ctrl: mods.ctrl, meta: mods.meta, shift: mods.shift, option: false, super: mods.super, sequence: s, raw: s, isPasted: false, } } // SGR mouse wheel events. Click/drag/release events are handled // earlier by parseMouseEvent and emitted as ParsedMouse, so they // never reach here. Mask with 0x43 (bits 6+1+0) to check wheel-flag // + direction while ignoring modifier bits (Shift=0x04, Meta=0x08, // Ctrl=0x10) — modified wheel events (e.g. Ctrl+scroll, button=80) // should still be recognized as wheelup/wheeldown. if ((match = SGR_MOUSE_RE.exec(s))) { const button = parseInt(match[1]!, 10) if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false) if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false) // Shouldn't reach here (parseMouseEvent catches non-wheel) but be safe return createNavKey(s, 'mouse', false) } // X10 mouse: CSI M + 3 raw bytes (Cb+32, Cx+32, Cy+32). Terminals that // ignore DECSET 1006 (SGR) but honor 1000/1002 emit this legacy encoding. // Button bits match SGR: 0x40 = wheel, low bit = direction. Non-wheel // X10 events (clicks/drags) are swallowed here — we only enable mouse // tracking in alt-screen and only need wheel for ScrollBox. if (s.length === 6 && s.startsWith('\x1b[M')) { const button = s.charCodeAt(3) - 32 if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false) if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false) return createNavKey(s, 'mouse', false) } if (s === '\r') { key.raw = undefined key.name = 'return' } else if (s === '\n') { key.name = 'enter' } else if (s === '\t') { key.name = 'tab' } else if (s === '\b' || s === '\x1b\b') { key.name = 'backspace' key.meta = s.charAt(0) === '\x1b' } else if (s === '\x7f' || s === '\x1b\x7f') { key.name = 'backspace' key.meta = s.charAt(0) === '\x1b' } else if (s === '\x1b' || s === '\x1b\x1b') { key.name = 'escape' key.meta = s.length === 2 } else if (s === ' ' || s === '\x1b ') { key.name = 'space' key.meta = s.length === 2 } else if (s === '\x1f') { key.name = '_' key.ctrl = true } else if (s <= '\x1a' && s.length === 1) { key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1) key.ctrl = true } else if (s.length === 1 && s >= '0' && s <= '9') { key.name = 'number' } else if (s.length === 1 && s >= 'a' && s <= 'z') { key.name = s } else if (s.length === 1 && s >= 'A' && s <= 'Z') { key.name = s.toLowerCase() key.shift = true } else if ((parts = META_KEY_CODE_RE.exec(s))) { key.meta = true key.shift = /^[A-Z]$/.test(parts[1]!) } else if ((parts = FN_KEY_RE.exec(s))) { const segs = [...s] if (segs[0] === '\u001b' && segs[1] === '\u001b') { key.option = true } const code = [parts[1], parts[2], parts[4], parts[6]] .filter(Boolean) .join('') const modifier = ((parts[3] || parts[5] || 1) as number) - 1 key.ctrl = !!(modifier & 4) key.meta = !!(modifier & 2) key.super = !!(modifier & 8) key.shift = !!(modifier & 1) key.code = code key.name = keyName[code] key.shift = isShiftKey(code) || key.shift key.ctrl = isCtrlKey(code) || key.ctrl } // iTerm in natural text editing mode if (key.raw === '\x1Bb') { key.meta = true key.name = 'left' } else if (key.raw === '\x1Bf') { key.meta = true key.name = 'right' } switch (s) { case '\u001b[1~': return createNavKey(s, 'home', false) case '\u001b[4~': return createNavKey(s, 'end', false) case '\u001b[5~': return createNavKey(s, 'pageup', false) case '\u001b[6~': return createNavKey(s, 'pagedown', false) case '\u001b[1;5D': return createNavKey(s, 'left', true) case '\u001b[1;5C': return createNavKey(s, 'right', true) } return key } function createNavKey(s: string, name: string, ctrl: boolean): ParsedKey { return { kind: 'key', name, ctrl, meta: false, shift: false, option: false, super: false, fn: false, sequence: s, raw: s, isPasted: false, } }