source dump of claude code
at main 801 lines 24 kB view raw
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 */ 7import { Buffer } from 'buffer' 8import { PASTE_END, PASTE_START } from './termio/csi.js' 9import { createTokenizer, type Tokenizer } from './termio/tokenize.js' 10 11// eslint-disable-next-line no-control-regex 12const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/ 13 14// eslint-disable-next-line no-control-regex 15const 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 23const 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 31const 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 36const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/ 37// DA1: CSI ? Ps ; ... c — primary device attributes response 38// eslint-disable-next-line no-control-regex 39const DA1_RE = /^\x1b\[\?([\d;]*)c$/ 40// DA2: CSI > Ps ; ... c — secondary device attributes response 41// eslint-disable-next-line no-control-regex 42const 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 46const 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 51const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/ 52// OSC response: OSC code ; data (BEL|ST) 53// eslint-disable-next-line no-control-regex 54const 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 60const 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 65const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/ 66 67function 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) */ 84export 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 */ 96export 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 */ 122function 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 177function splitNumericParams(params: string): number[] { 178 if (!params) return [] 179 return params.split(';').map(p => parseInt(p, 10)) 180} 181 182export type KeyParseState = { 183 mode: 'NORMAL' | 'IN_PASTE' 184 incomplete: string 185 pasteBuffer: string 186 // Internal tokenizer instance 187 _tokenizer?: Tokenizer 188} 189 190export const INITIAL_STATE: KeyParseState = { 191 mode: 'NORMAL', 192 incomplete: '', 193 pasteBuffer: '', 194} 195 196function 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 213export 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 304const 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 408export 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 423const 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 440const 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 */ 465function 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 */ 487function 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 543export 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. */ 561export 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). */ 571export 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. */ 587export 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 */ 594function 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 611function 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 787function 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}