import { Buffer } from 'buffer'
import { PASTE_END, PASTE_START } from './termio/csi.js'
import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
const FN_KEY_RE =
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
const DA2_RE = /^\x1b\[>([\d;]*)c$/
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
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,
}
}
export const DECRPM_STATUS = {
NOT_RECOGNIZED: 0,
SET: 1,
RESET: 2,
PERMANENTLY_SET: 3,
PERMANENTLY_RESET: 4,
} as const
export type TerminalResponse =
| { type: 'decrpm'; mode: number; status: number }
| { type: 'da1'; params: number[] }
| { type: 'da2'; params: number[] }
| { type: 'kittyKeyboard'; flags: number }
| { type: 'cursorPosition'; row: number; col: number }
| { type: 'osc'; code: number; data: string }
| { type: 'xtversion'; name: string }
function parseTerminalResponse(s: string): TerminalResponse | null {
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
}
if (s.startsWith('\x1b]')) {
const m = OSC_RESPONSE_RE.exec(s)
if (m) {
return { type: 'osc', code: parseInt(m[1]!, 10), data: m[2]! }
}
}
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
_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)
const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true })
const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString)
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) {
keys.push(createPasteKey(pasteBuffer))
inPaste = false
pasteBuffer = ''
} else if (inPaste) {
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)
) {
const resynthesized = '\x1b' + token.value
const mouse = parseMouseEvent(resynthesized)
keys.push(mouse ?? parseKeypress(resynthesized))
} else {
keys.push(parseKeypress(token.value))
}
}
}
if (isFlush && inPaste && pasteBuffer) {
keys.push(createPasteKey(pasteBuffer))
inPaste = false
pasteBuffer = ''
}
const newState: KeyParseState = {
mode: inPaste ? 'IN_PASTE' : 'NORMAL',
incomplete: tokenizer.buffer(),
pasteBuffer,
_tokenizer: tokenizer,
}
return [keys, newState]
}
const keyName: Record<string, string> = {
OP: 'f1',
OQ: 'f2',
OR: 'f3',
OS: 'f4',
Op: '0',
Oq: '1',
Or: '2',
Os: '3',
Ot: '4',
Ou: '5',
Ov: '6',
Ow: '7',
Ox: '8',
Oy: '9',
Oj: '*',
Ok: '+',
Ol: ',',
Om: '-',
On: '.',
Oo: '/',
OM: 'return',
'[11~': 'f1',
'[12~': 'f2',
'[13~': 'f3',
'[14~': 'f4',
'[[A': 'f1',
'[[B': 'f2',
'[[C': 'f3',
'[[D': 'f4',
'[[E': 'f5',
'[15~': 'f5',
'[17~': 'f6',
'[18~': 'f7',
'[19~': 'f8',
'[20~': 'f9',
'[21~': 'f10',
'[23~': 'f11',
'[24~': 'f12',
'[A': 'up',
'[B': 'down',
'[C': 'right',
'[D': 'left',
'[E': 'clear',
'[F': 'end',
'[H': 'home',
OA: 'up',
OB: 'down',
OC: 'right',
OD: 'left',
OE: 'clear',
OF: 'end',
OH: 'home',
'[1~': 'home',
'[2~': 'insert',
'[3~': 'delete',
'[4~': 'end',
'[5~': 'pageup',
'[6~': 'pagedown',
'[[5~': 'pageup',
'[[6~': 'pagedown',
'[7~': 'home',
'[8~': 'end',
'[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',
'[Z': 'tab',
}
export const nonAlphanumericKeys: ReadonlySet<string> = new Set([
...Object.values(keyName).filter(v => v.length > 1),
'escape',
'backspace',
'wheelup',
'wheeldown',
'mouse',
])
const SHIFT_KEY_CODES: ReadonlySet<string> = new Set([
'[a', '[b', '[c', '[d', '[e',
'[2$', '[3$', '[5$', '[6$', '[7$', '[8$',
'[Z',
])
const CTRL_KEY_CODES: ReadonlySet<string> = new Set([
'Oa', 'Ob', 'Oc', 'Od', 'Oe',
'[2^', '[3^', '[5^', '[6^', '[7^', '[8^',
])
const isShiftKey = (code: string): boolean => SHIFT_KEY_CODES.has(code)
const isCtrlKey = (code: string): boolean => CTRL_KEY_CODES.has(code)
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),
}
}
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'
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:
return '.'
case 57410:
return '/'
case 57411:
return '*'
case 57412:
return '-'
case 57413:
return '+'
case 57414:
return 'return'
case 57415:
return '='
default:
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
}
export type ParsedResponse = {
kind: 'response'
sequence: string
response: TerminalResponse
}
export type ParsedMouse = {
kind: 'mouse'
button: number
action: 'press' | 'release'
col: number
row: number
sequence: string
}
export type ParsedInput = ParsedKey | ParsedMouse | ParsedResponse
function parseMouseEvent(s: string): ParsedMouse | null {
const match = SGR_MOUSE_RE.exec(s)
if (!match) return null
const button = parseInt(match[1]!, 10)
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
let match: RegExpExecArray | null
if ((match = CSI_U_RE.exec(s))) {
const codepoint = parseInt(match[1]!, 10)
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,
}
}
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,
}
}
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)
return createNavKey(s, 'mouse', false)
}
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
}
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,
}
}