/ ink / parse-keypress.ts
parse-keypress.ts
  1  /**
  2   * Keyboard input parser - converts terminal input to key events
  3   *
  4   * Uses the termio tokenizer for escape sequence boundary detection,
  5   * then interprets sequences as keypresses.
  6   */
  7  import { Buffer } from 'buffer'
  8  import { PASTE_END, PASTE_START } from './termio/csi.js'
  9  import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
 10  
 11  // eslint-disable-next-line no-control-regex
 12  const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
 13  
 14  // eslint-disable-next-line no-control-regex
 15  const FN_KEY_RE =
 16    // eslint-disable-next-line no-control-regex
 17    /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
 18  
 19  // CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
 20  // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
 21  // Modifier is optional - when absent, defaults to 1 (no modifiers)
 22  // eslint-disable-next-line no-control-regex
 23  const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
 24  
 25  // xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
 26  // Example: ESC[27;2;13~ = Shift+Enter. Emitted by Ghostty/tmux/xterm when
 27  // modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
 28  // TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
 29  // Note param order is reversed vs CSI u (modifier first, keycode second).
 30  // eslint-disable-next-line no-control-regex
 31  const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
 32  
 33  // -- Terminal response patterns (inbound sequences from the terminal itself) --
 34  // DECRPM: CSI ? Ps ; Pm $ y  — response to DECRQM (request mode)
 35  // eslint-disable-next-line no-control-regex
 36  const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
 37  // DA1: CSI ? Ps ; ... c  — primary device attributes response
 38  // eslint-disable-next-line no-control-regex
 39  const DA1_RE = /^\x1b\[\?([\d;]*)c$/
 40  // DA2: CSI > Ps ; ... c  — secondary device attributes response
 41  // eslint-disable-next-line no-control-regex
 42  const DA2_RE = /^\x1b\[>([\d;]*)c$/
 43  // Kitty keyboard flags: CSI ? flags u  — response to CSI ? u query
 44  // (private ? marker distinguishes from CSI u key events)
 45  // eslint-disable-next-line no-control-regex
 46  const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
 47  // DECXCPR cursor position: CSI ? row ; col R
 48  // The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
 49  // Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
 50  // eslint-disable-next-line no-control-regex
 51  const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
 52  // OSC response: OSC code ; data (BEL|ST)
 53  // eslint-disable-next-line no-control-regex
 54  const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
 55  // XTVERSION: DCS > | name ST  — terminal name/version string (answer to CSI > 0 q).
 56  // xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with
 57  // their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
 58  // goes through the pty, not the environment.
 59  // eslint-disable-next-line no-control-regex
 60  const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
 61  // SGR mouse event: CSI < button ; col ; row M (press) or m (release)
 62  // Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
 63  // Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
 64  // eslint-disable-next-line no-control-regex
 65  const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
 66  
 67  function createPasteKey(content: string): ParsedKey {
 68    return {
 69      kind: 'key',
 70      name: '',
 71      fn: false,
 72      ctrl: false,
 73      meta: false,
 74      shift: false,
 75      option: false,
 76      super: false,
 77      sequence: content,
 78      raw: content,
 79      isPasted: true,
 80    }
 81  }
 82  
 83  /** DECRPM status values (response to DECRQM) */
 84  export const DECRPM_STATUS = {
 85    NOT_RECOGNIZED: 0,
 86    SET: 1,
 87    RESET: 2,
 88    PERMANENTLY_SET: 3,
 89    PERMANENTLY_RESET: 4,
 90  } as const
 91  
 92  /**
 93   * A response sequence received from the terminal (not a keypress).
 94   * Emitted in answer to queries like DECRQM, DA1, OSC 11, etc.
 95   */
 96  export type TerminalResponse =
 97    /** DECRPM: answer to DECRQM (request DEC private mode status) */
 98    | { type: 'decrpm'; mode: number; status: number }
 99    /** DA1: primary device attributes (used as a universal sentinel) */
100    | { type: 'da1'; params: number[] }
101    /** DA2: secondary device attributes (terminal version info) */
102    | { type: 'da2'; params: number[] }
103    /** Kitty keyboard protocol: current flags (answer to CSI ? u) */
104    | { type: 'kittyKeyboard'; flags: number }
105    /** DSR: cursor position report (answer to CSI 6 n) */
106    | { type: 'cursorPosition'; row: number; col: number }
107    /** OSC response: generic operating-system-command reply (e.g. OSC 11 bg color) */
108    | { type: 'osc'; code: number; data: string }
109    /** XTVERSION: terminal name/version string (answer to CSI > 0 q).
110     *  Example values: "xterm.js(5.5.0)", "ghostty 1.2.0", "iTerm2 3.6". */
111    | { type: 'xtversion'; name: string }
112  
113  /**
114   * Try to recognize a sequence token as a terminal response.
115   * Returns null if the sequence is not a known response pattern
116   * (i.e. it should be treated as a keypress).
117   *
118   * These patterns are syntactically distinguishable from keyboard input —
119   * no physical key produces CSI ? ... c or CSI ? ... $ y, so they can be
120   * safely parsed out of the input stream at any time.
121   */
122  function parseTerminalResponse(s: string): TerminalResponse | null {
123    // CSI-prefixed responses
124    if (s.startsWith('\x1b[')) {
125      let m: RegExpExecArray | null
126  
127      if ((m = DECRPM_RE.exec(s))) {
128        return {
129          type: 'decrpm',
130          mode: parseInt(m[1]!, 10),
131          status: parseInt(m[2]!, 10),
132        }
133      }
134  
135      if ((m = DA1_RE.exec(s))) {
136        return { type: 'da1', params: splitNumericParams(m[1]!) }
137      }
138  
139      if ((m = DA2_RE.exec(s))) {
140        return { type: 'da2', params: splitNumericParams(m[1]!) }
141      }
142  
143      if ((m = KITTY_FLAGS_RE.exec(s))) {
144        return { type: 'kittyKeyboard', flags: parseInt(m[1]!, 10) }
145      }
146  
147      if ((m = CURSOR_POSITION_RE.exec(s))) {
148        return {
149          type: 'cursorPosition',
150          row: parseInt(m[1]!, 10),
151          col: parseInt(m[2]!, 10),
152        }
153      }
154  
155      return null
156    }
157  
158    // OSC responses (e.g. OSC 11 ; rgb:... for bg color query)
159    if (s.startsWith('\x1b]')) {
160      const m = OSC_RESPONSE_RE.exec(s)
161      if (m) {
162        return { type: 'osc', code: parseInt(m[1]!, 10), data: m[2]! }
163      }
164    }
165  
166    // DCS responses (e.g. XTVERSION: DCS > | name ST)
167    if (s.startsWith('\x1bP')) {
168      const m = XTVERSION_RE.exec(s)
169      if (m) {
170        return { type: 'xtversion', name: m[1]! }
171      }
172    }
173  
174    return null
175  }
176  
177  function splitNumericParams(params: string): number[] {
178    if (!params) return []
179    return params.split(';').map(p => parseInt(p, 10))
180  }
181  
182  export type KeyParseState = {
183    mode: 'NORMAL' | 'IN_PASTE'
184    incomplete: string
185    pasteBuffer: string
186    // Internal tokenizer instance
187    _tokenizer?: Tokenizer
188  }
189  
190  export const INITIAL_STATE: KeyParseState = {
191    mode: 'NORMAL',
192    incomplete: '',
193    pasteBuffer: '',
194  }
195  
196  function inputToString(input: Buffer | string): string {
197    if (Buffer.isBuffer(input)) {
198      if (input[0]! > 127 && input[1] === undefined) {
199        ;(input[0] as unknown as number) -= 128
200        return '\x1b' + String(input)
201      } else {
202        return String(input)
203      }
204    } else if (input !== undefined && typeof input !== 'string') {
205      return String(input)
206    } else if (!input) {
207      return ''
208    } else {
209      return input
210    }
211  }
212  
213  export function parseMultipleKeypresses(
214    prevState: KeyParseState,
215    input: Buffer | string | null = '',
216  ): [ParsedInput[], KeyParseState] {
217    const isFlush = input === null
218    const inputString = isFlush ? '' : inputToString(input)
219  
220    // Get or create tokenizer
221    const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true })
222  
223    // Tokenize the input
224    const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString)
225  
226    // Convert tokens to parsed keys, handling paste mode
227    const keys: ParsedInput[] = []
228    let inPaste = prevState.mode === 'IN_PASTE'
229    let pasteBuffer = prevState.pasteBuffer
230  
231    for (const token of tokens) {
232      if (token.type === 'sequence') {
233        if (token.value === PASTE_START) {
234          inPaste = true
235          pasteBuffer = ''
236        } else if (token.value === PASTE_END) {
237          // Always emit a paste key, even for empty pastes. This allows
238          // downstream handlers to detect empty pastes (e.g., for clipboard
239          // image handling on macOS). The paste content may be empty string.
240          keys.push(createPasteKey(pasteBuffer))
241          inPaste = false
242          pasteBuffer = ''
243        } else if (inPaste) {
244          // Sequences inside paste are treated as literal text
245          pasteBuffer += token.value
246        } else {
247          const response = parseTerminalResponse(token.value)
248          if (response) {
249            keys.push({ kind: 'response', sequence: token.value, response })
250          } else {
251            const mouse = parseMouseEvent(token.value)
252            if (mouse) {
253              keys.push(mouse)
254            } else {
255              keys.push(parseKeypress(token.value))
256            }
257          }
258        }
259      } else if (token.type === 'text') {
260        if (inPaste) {
261          pasteBuffer += token.value
262        } else if (
263          /^\[<\d+;\d+;\d+[Mm]$/.test(token.value) ||
264          /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)
265        ) {
266          // Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off
267          // otherwise). A heavy render blocked the event loop past App's 50ms
268          // flush timer, so the buffered ESC was flushed as a lone Escape and
269          // the continuation `[<btn;col;rowM` arrived as text. Re-synthesize
270          // with the ESC prefix so the scroll event still fires instead of
271          // leaking into the prompt. The spurious Escape is gone; App.tsx's
272          // readableLength check prevents it. The X10 Cb slot is narrowed to
273          // the wheel range [\x60-\x7f] (0x40|modifiers + 32) — a full [\x20-]
274          // range would match typed input like `[MAX]` batched into one read
275          // and silently drop it as a phantom click. Click/drag orphans leak
276          // as visible garbage instead; deletable garbage beats silent loss.
277          const resynthesized = '\x1b' + token.value
278          const mouse = parseMouseEvent(resynthesized)
279          keys.push(mouse ?? parseKeypress(resynthesized))
280        } else {
281          keys.push(parseKeypress(token.value))
282        }
283      }
284    }
285  
286    // If flushing and still in paste mode, emit what we have
287    if (isFlush && inPaste && pasteBuffer) {
288      keys.push(createPasteKey(pasteBuffer))
289      inPaste = false
290      pasteBuffer = ''
291    }
292  
293    // Build new state
294    const newState: KeyParseState = {
295      mode: inPaste ? 'IN_PASTE' : 'NORMAL',
296      incomplete: tokenizer.buffer(),
297      pasteBuffer,
298      _tokenizer: tokenizer,
299    }
300  
301    return [keys, newState]
302  }
303  
304  const keyName: Record<string, string> = {
305    /* xterm/gnome ESC O letter */
306    OP: 'f1',
307    OQ: 'f2',
308    OR: 'f3',
309    OS: 'f4',
310    /* Application keypad mode (numpad digits 0-9) */
311    Op: '0',
312    Oq: '1',
313    Or: '2',
314    Os: '3',
315    Ot: '4',
316    Ou: '5',
317    Ov: '6',
318    Ow: '7',
319    Ox: '8',
320    Oy: '9',
321    /* Application keypad mode (numpad operators) */
322    Oj: '*',
323    Ok: '+',
324    Ol: ',',
325    Om: '-',
326    On: '.',
327    Oo: '/',
328    OM: 'return',
329    /* xterm/rxvt ESC [ number ~ */
330    '[11~': 'f1',
331    '[12~': 'f2',
332    '[13~': 'f3',
333    '[14~': 'f4',
334    /* from Cygwin and used in libuv */
335    '[[A': 'f1',
336    '[[B': 'f2',
337    '[[C': 'f3',
338    '[[D': 'f4',
339    '[[E': 'f5',
340    /* common */
341    '[15~': 'f5',
342    '[17~': 'f6',
343    '[18~': 'f7',
344    '[19~': 'f8',
345    '[20~': 'f9',
346    '[21~': 'f10',
347    '[23~': 'f11',
348    '[24~': 'f12',
349    /* xterm ESC [ letter */
350    '[A': 'up',
351    '[B': 'down',
352    '[C': 'right',
353    '[D': 'left',
354    '[E': 'clear',
355    '[F': 'end',
356    '[H': 'home',
357    /* xterm/gnome ESC O letter */
358    OA: 'up',
359    OB: 'down',
360    OC: 'right',
361    OD: 'left',
362    OE: 'clear',
363    OF: 'end',
364    OH: 'home',
365    /* xterm/rxvt ESC [ number ~ */
366    '[1~': 'home',
367    '[2~': 'insert',
368    '[3~': 'delete',
369    '[4~': 'end',
370    '[5~': 'pageup',
371    '[6~': 'pagedown',
372    /* putty */
373    '[[5~': 'pageup',
374    '[[6~': 'pagedown',
375    /* rxvt */
376    '[7~': 'home',
377    '[8~': 'end',
378    /* rxvt keys with modifiers */
379    '[a': 'up',
380    '[b': 'down',
381    '[c': 'right',
382    '[d': 'left',
383    '[e': 'clear',
384  
385    '[2$': 'insert',
386    '[3$': 'delete',
387    '[5$': 'pageup',
388    '[6$': 'pagedown',
389    '[7$': 'home',
390    '[8$': 'end',
391  
392    Oa: 'up',
393    Ob: 'down',
394    Oc: 'right',
395    Od: 'left',
396    Oe: 'clear',
397  
398    '[2^': 'insert',
399    '[3^': 'delete',
400    '[5^': 'pageup',
401    '[6^': 'pagedown',
402    '[7^': 'home',
403    '[8^': 'end',
404    /* misc. */
405    '[Z': 'tab',
406  }
407  
408  export const nonAlphanumericKeys = [
409    // Filter out single-character values (digits, operators from numpad) since
410    // those are printable characters that should produce input
411    ...Object.values(keyName).filter(v => v.length > 1),
412    // escape and backspace are assigned directly in parseKeypress (not via the
413    // keyName map), so the spread above misses them. Without these, ctrl+escape
414    // via Kitty/modifyOtherKeys leaks the literal word "escape" as input text
415    // (input-event.ts:58 assigns keypress.name when ctrl is set).
416    'escape',
417    'backspace',
418    'wheelup',
419    'wheeldown',
420    'mouse',
421  ]
422  
423  const isShiftKey = (code: string): boolean => {
424    return [
425      '[a',
426      '[b',
427      '[c',
428      '[d',
429      '[e',
430      '[2$',
431      '[3$',
432      '[5$',
433      '[6$',
434      '[7$',
435      '[8$',
436      '[Z',
437    ].includes(code)
438  }
439  
440  const isCtrlKey = (code: string): boolean => {
441    return [
442      'Oa',
443      'Ob',
444      'Oc',
445      'Od',
446      'Oe',
447      '[2^',
448      '[3^',
449      '[5^',
450      '[6^',
451      '[7^',
452      '[8^',
453    ].includes(code)
454  }
455  
456  /**
457   * Decode XTerm-style modifier value to individual flags.
458   * Modifier encoding: 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0) + (super ? 8 : 0)
459   *
460   * Note: `meta` here means Alt/Option (bit 2). `super` is a distinct
461   * modifier (bit 8, i.e. Cmd on macOS / Win key). Most legacy terminal
462   * sequences can't express super — it only arrives via kitty keyboard
463   * protocol (CSI u) or xterm modifyOtherKeys.
464   */
465  function decodeModifier(modifier: number): {
466    shift: boolean
467    meta: boolean
468    ctrl: boolean
469    super: boolean
470  } {
471    const m = modifier - 1
472    return {
473      shift: !!(m & 1),
474      meta: !!(m & 2),
475      ctrl: !!(m & 4),
476      super: !!(m & 8),
477    }
478  }
479  
480  /**
481   * Map keycode to key name for modifyOtherKeys/CSI u sequences.
482   * Handles both ASCII keycodes and Kitty keyboard protocol functional keys.
483   *
484   * Numpad codepoints are from Unicode Private Use Area, defined at:
485   * https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
486   */
487  function keycodeToName(keycode: number): string | undefined {
488    switch (keycode) {
489      case 9:
490        return 'tab'
491      case 13:
492        return 'return'
493      case 27:
494        return 'escape'
495      case 32:
496        return 'space'
497      case 127:
498        return 'backspace'
499      // Kitty keyboard protocol numpad keys (KP_0 through KP_9)
500      case 57399:
501        return '0'
502      case 57400:
503        return '1'
504      case 57401:
505        return '2'
506      case 57402:
507        return '3'
508      case 57403:
509        return '4'
510      case 57404:
511        return '5'
512      case 57405:
513        return '6'
514      case 57406:
515        return '7'
516      case 57407:
517        return '8'
518      case 57408:
519        return '9'
520      case 57409: // KP_DECIMAL
521        return '.'
522      case 57410: // KP_DIVIDE
523        return '/'
524      case 57411: // KP_MULTIPLY
525        return '*'
526      case 57412: // KP_SUBTRACT
527        return '-'
528      case 57413: // KP_ADD
529        return '+'
530      case 57414: // KP_ENTER
531        return 'return'
532      case 57415: // KP_EQUAL
533        return '='
534      default:
535        // Printable ASCII characters
536        if (keycode >= 32 && keycode <= 126) {
537          return String.fromCharCode(keycode).toLowerCase()
538        }
539        return undefined
540    }
541  }
542  
543  export type ParsedKey = {
544    kind: 'key'
545    fn: boolean
546    name: string | undefined
547    ctrl: boolean
548    meta: boolean
549    shift: boolean
550    option: boolean
551    super: boolean
552    sequence: string | undefined
553    raw: string | undefined
554    code?: string
555    isPasted: boolean
556  }
557  
558  /** A terminal response sequence (DECRPM, DA1, OSC reply, etc.) parsed
559   *  out of the input stream. Not user input — consumers should dispatch
560   *  to a response handler. */
561  export type ParsedResponse = {
562    kind: 'response'
563    /** Raw escape sequence bytes, for debugging/logging */
564    sequence: string
565    response: TerminalResponse
566  }
567  
568  /** SGR mouse event with coordinates. Emitted for clicks, drags, and
569   *  releases (wheel events remain ParsedKey). col/row are 1-indexed
570   *  from the terminal sequence (CSI < btn;col;row M/m). */
571  export type ParsedMouse = {
572    kind: 'mouse'
573    /** Raw SGR button code. Low 2 bits = button (0=left,1=mid,2=right),
574     *  bit 5 (0x20) = drag/motion, bit 6 (0x40) = wheel. */
575    button: number
576    /** 'press' for M terminator, 'release' for m terminator */
577    action: 'press' | 'release'
578    /** 1-indexed column (from terminal) */
579    col: number
580    /** 1-indexed row (from terminal) */
581    row: number
582    sequence: string
583  }
584  
585  /** Everything that can come out of the input parser: a user keypress/paste,
586   *  a mouse click/drag event, or a terminal response to a query we sent. */
587  export type ParsedInput = ParsedKey | ParsedMouse | ParsedResponse
588  
589  /**
590   * Parse an SGR mouse event sequence into a ParsedMouse, or null if not a
591   * mouse event or if it's a wheel event (wheel stays as ParsedKey for the
592   * keybinding system). Button bit 0x40 = wheel, bit 0x20 = drag/motion.
593   */
594  function parseMouseEvent(s: string): ParsedMouse | null {
595    const match = SGR_MOUSE_RE.exec(s)
596    if (!match) return null
597    const button = parseInt(match[1]!, 10)
598    // Wheel events (bit 6 set, low bits 0/1 for up/down) stay as ParsedKey
599    // so the keybinding system can route them to scroll handlers.
600    if ((button & 0x40) !== 0) return null
601    return {
602      kind: 'mouse',
603      button,
604      action: match[4] === 'M' ? 'press' : 'release',
605      col: parseInt(match[2]!, 10),
606      row: parseInt(match[3]!, 10),
607      sequence: s,
608    }
609  }
610  
611  function parseKeypress(s: string = ''): ParsedKey {
612    let parts
613  
614    const key: ParsedKey = {
615      kind: 'key',
616      name: '',
617      fn: false,
618      ctrl: false,
619      meta: false,
620      shift: false,
621      option: false,
622      super: false,
623      sequence: s,
624      raw: s,
625      isPasted: false,
626    }
627  
628    key.sequence = key.sequence || s || key.name
629  
630    // Handle CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
631    // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
632    let match: RegExpExecArray | null
633    if ((match = CSI_U_RE.exec(s))) {
634      const codepoint = parseInt(match[1]!, 10)
635      // Modifier defaults to 1 (no modifiers) when not present
636      const modifier = match[2] ? parseInt(match[2], 10) : 1
637      const mods = decodeModifier(modifier)
638      const name = keycodeToName(codepoint)
639      return {
640        kind: 'key',
641        name,
642        fn: false,
643        ctrl: mods.ctrl,
644        meta: mods.meta,
645        shift: mods.shift,
646        option: false,
647        super: mods.super,
648        sequence: s,
649        raw: s,
650        isPasted: false,
651      }
652    }
653  
654    // Handle xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
655    // Must run before FN_KEY_RE — FN_KEY_RE only allows 2 params before ~ and
656    // would leave the tail as garbage if it partially matched.
657    if ((match = MODIFY_OTHER_KEYS_RE.exec(s))) {
658      const mods = decodeModifier(parseInt(match[1]!, 10))
659      const name = keycodeToName(parseInt(match[2]!, 10))
660      return {
661        kind: 'key',
662        name,
663        fn: false,
664        ctrl: mods.ctrl,
665        meta: mods.meta,
666        shift: mods.shift,
667        option: false,
668        super: mods.super,
669        sequence: s,
670        raw: s,
671        isPasted: false,
672      }
673    }
674  
675    // SGR mouse wheel events. Click/drag/release events are handled
676    // earlier by parseMouseEvent and emitted as ParsedMouse, so they
677    // never reach here. Mask with 0x43 (bits 6+1+0) to check wheel-flag
678    // + direction while ignoring modifier bits (Shift=0x04, Meta=0x08,
679    // Ctrl=0x10) — modified wheel events (e.g. Ctrl+scroll, button=80)
680    // should still be recognized as wheelup/wheeldown.
681    if ((match = SGR_MOUSE_RE.exec(s))) {
682      const button = parseInt(match[1]!, 10)
683      if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false)
684      if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false)
685      // Shouldn't reach here (parseMouseEvent catches non-wheel) but be safe
686      return createNavKey(s, 'mouse', false)
687    }
688  
689    // X10 mouse: CSI M + 3 raw bytes (Cb+32, Cx+32, Cy+32). Terminals that
690    // ignore DECSET 1006 (SGR) but honor 1000/1002 emit this legacy encoding.
691    // Button bits match SGR: 0x40 = wheel, low bit = direction. Non-wheel
692    // X10 events (clicks/drags) are swallowed here — we only enable mouse
693    // tracking in alt-screen and only need wheel for ScrollBox.
694    if (s.length === 6 && s.startsWith('\x1b[M')) {
695      const button = s.charCodeAt(3) - 32
696      if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false)
697      if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false)
698      return createNavKey(s, 'mouse', false)
699    }
700  
701    if (s === '\r') {
702      key.raw = undefined
703      key.name = 'return'
704    } else if (s === '\n') {
705      key.name = 'enter'
706    } else if (s === '\t') {
707      key.name = 'tab'
708    } else if (s === '\b' || s === '\x1b\b') {
709      key.name = 'backspace'
710      key.meta = s.charAt(0) === '\x1b'
711    } else if (s === '\x7f' || s === '\x1b\x7f') {
712      key.name = 'backspace'
713      key.meta = s.charAt(0) === '\x1b'
714    } else if (s === '\x1b' || s === '\x1b\x1b') {
715      key.name = 'escape'
716      key.meta = s.length === 2
717    } else if (s === ' ' || s === '\x1b ') {
718      key.name = 'space'
719      key.meta = s.length === 2
720    } else if (s === '\x1f') {
721      key.name = '_'
722      key.ctrl = true
723    } else if (s <= '\x1a' && s.length === 1) {
724      key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1)
725      key.ctrl = true
726    } else if (s.length === 1 && s >= '0' && s <= '9') {
727      key.name = 'number'
728    } else if (s.length === 1 && s >= 'a' && s <= 'z') {
729      key.name = s
730    } else if (s.length === 1 && s >= 'A' && s <= 'Z') {
731      key.name = s.toLowerCase()
732      key.shift = true
733    } else if ((parts = META_KEY_CODE_RE.exec(s))) {
734      key.meta = true
735      key.shift = /^[A-Z]$/.test(parts[1]!)
736    } else if ((parts = FN_KEY_RE.exec(s))) {
737      const segs = [...s]
738  
739      if (segs[0] === '\u001b' && segs[1] === '\u001b') {
740        key.option = true
741      }
742  
743      const code = [parts[1], parts[2], parts[4], parts[6]]
744        .filter(Boolean)
745        .join('')
746  
747      const modifier = ((parts[3] || parts[5] || 1) as number) - 1
748  
749      key.ctrl = !!(modifier & 4)
750      key.meta = !!(modifier & 2)
751      key.super = !!(modifier & 8)
752      key.shift = !!(modifier & 1)
753      key.code = code
754  
755      key.name = keyName[code]
756      key.shift = isShiftKey(code) || key.shift
757      key.ctrl = isCtrlKey(code) || key.ctrl
758    }
759  
760    // iTerm in natural text editing mode
761    if (key.raw === '\x1Bb') {
762      key.meta = true
763      key.name = 'left'
764    } else if (key.raw === '\x1Bf') {
765      key.meta = true
766      key.name = 'right'
767    }
768  
769    switch (s) {
770      case '\u001b[1~':
771        return createNavKey(s, 'home', false)
772      case '\u001b[4~':
773        return createNavKey(s, 'end', false)
774      case '\u001b[5~':
775        return createNavKey(s, 'pageup', false)
776      case '\u001b[6~':
777        return createNavKey(s, 'pagedown', false)
778      case '\u001b[1;5D':
779        return createNavKey(s, 'left', true)
780      case '\u001b[1;5C':
781        return createNavKey(s, 'right', true)
782    }
783  
784    return key
785  }
786  
787  function createNavKey(s: string, name: string, ctrl: boolean): ParsedKey {
788    return {
789      kind: 'key',
790      name,
791      ctrl,
792      meta: false,
793      shift: false,
794      option: false,
795      super: false,
796      fn: false,
797      sequence: s,
798      raw: s,
799      isPasted: false,
800    }
801  }