/ ink / events / input-event.ts
input-event.ts
  1  import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
  2  import { Event } from './event.js'
  3  
  4  export type Key = {
  5    upArrow: boolean
  6    downArrow: boolean
  7    leftArrow: boolean
  8    rightArrow: boolean
  9    pageDown: boolean
 10    pageUp: boolean
 11    wheelUp: boolean
 12    wheelDown: boolean
 13    home: boolean
 14    end: boolean
 15    return: boolean
 16    escape: boolean
 17    ctrl: boolean
 18    shift: boolean
 19    fn: boolean
 20    tab: boolean
 21    backspace: boolean
 22    delete: boolean
 23    meta: boolean
 24    super: boolean
 25  }
 26  
 27  function parseKey(keypress: ParsedKey): [Key, string] {
 28    const key: Key = {
 29      upArrow: keypress.name === 'up',
 30      downArrow: keypress.name === 'down',
 31      leftArrow: keypress.name === 'left',
 32      rightArrow: keypress.name === 'right',
 33      pageDown: keypress.name === 'pagedown',
 34      pageUp: keypress.name === 'pageup',
 35      wheelUp: keypress.name === 'wheelup',
 36      wheelDown: keypress.name === 'wheeldown',
 37      home: keypress.name === 'home',
 38      end: keypress.name === 'end',
 39      return: keypress.name === 'return',
 40      escape: keypress.name === 'escape',
 41      fn: keypress.fn,
 42      ctrl: keypress.ctrl,
 43      shift: keypress.shift,
 44      tab: keypress.name === 'tab',
 45      backspace: keypress.name === 'backspace',
 46      delete: keypress.name === 'delete',
 47      // `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
 48      // but with option = true, so we need to take this into account here
 49      // to avoid breaking changes in Ink.
 50      // TODO(vadimdemedes): consider removing this in the next major version.
 51      meta: keypress.meta || keypress.name === 'escape' || keypress.option,
 52      // Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
 53      // protocol CSI u sequences. Distinct from meta (Alt/Option) so
 54      // bindings like cmd+c can be expressed separately from opt+c.
 55      super: keypress.super,
 56    }
 57  
 58    let input = keypress.ctrl ? keypress.name : keypress.sequence
 59  
 60    // Handle undefined input case
 61    if (input === undefined) {
 62      input = ''
 63    }
 64  
 65    // When ctrl is set, keypress.name for space is the literal word "space".
 66    // Convert to actual space character for consistency with the CSI u branch
 67    // (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
 68    // word "space" into text input.
 69    if (keypress.ctrl && input === 'space') {
 70      input = ' '
 71    }
 72  
 73    // Suppress unrecognized escape sequences that were parsed as function keys
 74    // (matched by FN_KEY_RE) but have no name in the keyName map.
 75    // Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
 76    // Without this, the ESC prefix is stripped below and the remainder (e.g.,
 77    // "[25~") leaks into the input as literal text.
 78    if (keypress.code && !keypress.name) {
 79      input = ''
 80    }
 81  
 82    // Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks
 83    // the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across
 84    // stdin chunks gets its buffered ESC flushed as a lone Escape key, and the
 85    // continuation arrives as a text token with name='' — which falls through
 86    // all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys
 87    // clear below (name is falsy). The fragment then leaks into the prompt as
 88    // literal `[<64;74;16M`. This is the same defensive sink as the F13 guard
 89    // above; the underlying tokenizer-flush race is upstream of this layer.
 90    if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
 91      input = ''
 92    }
 93  
 94    // Strip meta if it's still remaining after `parseKeypress`
 95    // TODO(vadimdemedes): remove this in the next major version.
 96    if (input.startsWith('\u001B')) {
 97      input = input.slice(1)
 98    }
 99  
100    // Track whether we've already processed this as a special sequence
101    // that converted input to the key name (CSI u or application keypad mode).
102    // For these, we don't want to clear input with nonAlphanumericKeys check.
103    let processedAsSpecialSequence = false
104  
105    // Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
106    // we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
107    // Use the parsed key name instead for input handling. Require a digit
108    // after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
109    // false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
110    // literal text "mouse" into the prompt via processedAsSpecialSequence.
111    if (/^\[\d/.test(input) && input.endsWith('u')) {
112      if (!keypress.name) {
113        // Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
114        // bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
115        // so the raw "[57358u" doesn't leak into the prompt. See #38781.
116        input = ''
117      } else {
118        // 'space' → ' '; 'escape' → '' (key.escape carries it;
119        // processedAsSpecialSequence bypasses the nonAlphanumericKeys
120        // clear below, so we must handle it explicitly here);
121        // otherwise use key name.
122        input =
123          keypress.name === 'space'
124            ? ' '
125            : keypress.name === 'escape'
126              ? ''
127              : keypress.name
128      }
129      processedAsSpecialSequence = true
130    }
131  
132    // Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
133    // with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
134    // extraction as CSI u — without this, printable-char keycodes (single-letter
135    // names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
136    if (input.startsWith('[27;') && input.endsWith('~')) {
137      if (!keypress.name) {
138        // Unmapped modifyOtherKeys keycode — swallow for consistency with
139        // the CSI u handler above. Practically untriggerable today (xterm
140        // modifyOtherKeys only sends ASCII keycodes, all mapped), but
141        // guards against future terminal behavior.
142        input = ''
143      } else {
144        input =
145          keypress.name === 'space'
146            ? ' '
147            : keypress.name === 'escape'
148              ? ''
149              : keypress.name
150      }
151      processedAsSpecialSequence = true
152    }
153  
154    // Handle application keypad mode sequences: after stripping ESC,
155    // we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
156    // Use the parsed key name (the digit character) for input handling.
157    if (
158      input.startsWith('O') &&
159      input.length === 2 &&
160      keypress.name &&
161      keypress.name.length === 1
162    ) {
163      input = keypress.name
164      processedAsSpecialSequence = true
165    }
166  
167    // Clear input for non-alphanumeric keys (arrows, function keys, etc.)
168    // Skip this for CSI u and application keypad mode sequences since
169    // those were already converted to their proper input characters.
170    if (
171      !processedAsSpecialSequence &&
172      keypress.name &&
173      nonAlphanumericKeys.includes(keypress.name)
174    ) {
175      input = ''
176    }
177  
178    // Set shift=true for uppercase letters (A-Z)
179    // Must check it's actually a letter, not just any char unchanged by toUpperCase
180    if (
181      input.length === 1 &&
182      typeof input[0] === 'string' &&
183      input[0] >= 'A' &&
184      input[0] <= 'Z'
185    ) {
186      key.shift = true
187    }
188  
189    return [key, input]
190  }
191  
192  export class InputEvent extends Event {
193    readonly keypress: ParsedKey
194    readonly key: Key
195    readonly input: string
196  
197    constructor(keypress: ParsedKey) {
198      super()
199      const [key, input] = parseKey(keypress)
200  
201      this.keypress = keypress
202      this.key = key
203      this.input = input
204    }
205  }