├── .gitattributes ├── examples ├── cursor.ts ├── example.ts ├── paste.ts ├── mouse.ts ├── stdin.ts ├── keymap.ts ├── drop-files.ts ├── prompt.ts ├── pasted-text.ts ├── emit-keycode.ts ├── mouse-select.ts ├── multiselect-mvc.ts ├── scrolling.ts ├── dragging.ts ├── select.ts ├── select-mvc.ts └── keycodes.ts ├── .editorconfig ├── prettier.config.js ├── tsup.config.ts ├── .gitignore ├── tsconfig.json ├── src ├── keyboard-protocol.ts ├── emit-keypress.ts ├── utils.ts ├── mousepress.ts ├── keypress.ts └── keycodes.ts ├── package.json ├── test ├── regex.ts ├── keypress.ts └── emit-keypress.ts ├── .eslintrc.js ├── .verb.md ├── README.md └── index.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Enforce Unix newlines 2 | * text eol=lf 3 | 4 | # binaries 5 | *.ai binary 6 | *.psd binary 7 | *.jpg binary 8 | *.gif binary 9 | *.png binary 10 | *.jpeg binary 11 | -------------------------------------------------------------------------------- /examples/cursor.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress } from '../index'; 2 | 3 | emitKeypress({ 4 | initialPosition: true, 5 | onKeypress: (input, key, close) => { 6 | if (input === '\x03' || input === '\r') { 7 | close(); 8 | } 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [.zsh_history] 12 | insert_final_newline = false 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'none', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | printWidth: 90, 7 | arrowParens: 'avoid', 8 | bracketSpacing: true, 9 | jsxBracketSameLine: false, 10 | jsxSingleQuote: false, 11 | quoteProps: 'consistent' 12 | }; 13 | -------------------------------------------------------------------------------- /examples/example.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress, keycodes } from '../index'; 2 | 3 | emitKeypress({ 4 | keymap: keycodes, 5 | escapeCodeTimeout: 100, 6 | onKeypress: async (input, key, close) => { 7 | console.log({ input, key }); 8 | 9 | if (input === '\x03' || input === '\r') { 10 | close(); 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /examples/paste.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress, keycodes } from '../index'; 2 | 3 | emitKeypress({ 4 | keymap: keycodes, 5 | enablePasteMode: true, 6 | onKeypress: async (input, key, close) => { 7 | console.log({ input, key }); 8 | 9 | if (input === '\x03' || input === '\r') { 10 | close(); 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | entry: ['index.ts'], 6 | cjsInterop: true, 7 | format: ['cjs', 'esm'], 8 | keepNames: true, 9 | minify: false, 10 | shims: true, 11 | splitting: false, 12 | sourcemap: true, 13 | target: 'node18' 14 | }); 15 | -------------------------------------------------------------------------------- /examples/mouse.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress } from '../index'; 2 | 3 | emitKeypress({ 4 | enablePasteMode: true, 5 | enableMouseEvents: true, 6 | onKeypress: (input, key, close) => { 7 | console.log('keypress:', { input, key }); 8 | 9 | if (input === '\x03' || input === '\r') { 10 | console.log('cleared'); 11 | close(); 12 | } 13 | }, 14 | onMousepress: key => { 15 | console.log([key.name, key.action]); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # always ignore files 2 | *.sublime-* 3 | *.code-* 4 | *.log 5 | .DS_Store 6 | .env 7 | .env.* 8 | 9 | # always ignore dirs 10 | temp 11 | tmp 12 | vendor 13 | 14 | # test related, or directories generated by tests 15 | test/actual 16 | actual 17 | coverage 18 | .nyc* 19 | 20 | # package managers 21 | node_modules 22 | package-lock.json 23 | yarn.lock 24 | pnpm-lock.yaml 25 | 26 | # misc 27 | _gh_pages 28 | _draft 29 | _drafts 30 | bower_components 31 | vendor 32 | temp 33 | tmp 34 | .chat 35 | dist 36 | .automations 37 | support 38 | -------------------------------------------------------------------------------- /examples/stdin.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress } from '../index'; 2 | 3 | const state = { input: '' }; 4 | 5 | emitKeypress({ input: process.stdin }); 6 | 7 | console.clear(); 8 | console.log(state.input); 9 | 10 | process.stdin.on('keypress', (input, key) => { 11 | if (input === '\x7f') { 12 | state.input = state.input.slice(0, -1); 13 | } else { 14 | state.input += input; 15 | } 16 | 17 | console.clear(); 18 | console.log(state.input); 19 | 20 | if (input === '\x03' || input === '\r') { 21 | process.stdin.pause(); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /examples/keymap.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress } from '../index'; 2 | 3 | emitKeypress({ 4 | input: process.stdin, 5 | keymap: [ 6 | { shortcut: 'space', command: 'toggle' }, 7 | { shortcut: 'escape', command: 'cancel' }, 8 | { shortcut: 'ctrl+c', command: 'cancel' }, 9 | { shortcut: 'ctrl+o', command: 'open' }, 10 | { shortcut: 'return', command: 'submit' } 11 | ], 12 | onKeypress: async (input, key, close) => { 13 | console.log({ input, key }); 14 | 15 | if (key.command === 'submit' || key.command === 'cancel') { 16 | close(); 17 | process.exit(-1); 18 | } 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /examples/drop-files.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { emitKeypress, keycodes } from '../index'; 3 | 4 | emitKeypress({ 5 | keymap: keycodes, 6 | enablePasteMode: true, 7 | 8 | onKeypress: async (input, key, close) => { 9 | const files = input.split(/(? { 5 | // process.stdout.write(`\r${input}`); 6 | // }; 7 | const render = () => { 8 | console.clear(); 9 | console.log(`? Name > ${state.input}`); 10 | }; 11 | 12 | // render('Name? '); 13 | render(); 14 | 15 | emitKeypress({ 16 | onKeypress: async (input, key, close) => { 17 | 18 | if (input === '\x03' || input === '\r') { 19 | close(); 20 | console.log(); 21 | console.log([state.input]); 22 | } else if (input === '\x7f') { 23 | state.input = state.input.slice(0, -1); 24 | } else { 25 | state.input += input; 26 | // render(`Name? ${state.input}`); 27 | } 28 | 29 | render(); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /examples/pasted-text.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { emitKeypress } from '../index'; 3 | 4 | // 5 | // Example of pasting text without using "paste" mode 6 | // 7 | 8 | // let start = Date.now(); 9 | const escapeText = (text: string) => text.replace(/([ \n\t\f\r'$%])/g, '\\$1'); 10 | const unescapeText = (text: string) => text.replace(/\\([ \n\t\f\r'$%])/g, '$1'); 11 | 12 | const clipboardCopy = (text: string) => { 13 | exec(`echo '${escapeText(text)}' | pbcopy`); 14 | }; 15 | 16 | emitKeypress({ 17 | bufferTimeout: 50, 18 | onKeypress: async (input, key, close) => { 19 | // console.log({ input, key, duration: Date.now() - start }); 20 | // start = Date.now(); 21 | 22 | if (key.pasted) { 23 | clipboardCopy(input); 24 | // console.log(input); 25 | // console.log('copied to clipboard'); 26 | const files = unescapeText(input).split(/(? new Promise(res => setTimeout(res, ms)); 6 | let expected; 7 | 8 | function emitKeyCode(escapeCode) { 9 | const decoder = new StringDecoder('utf8'); 10 | const buffer = Buffer.from(escapeCode, 'ascii'); 11 | const decoded = decoder.write(buffer); 12 | process.stdin.push(decoded); 13 | } 14 | 15 | emitKeypress({ 16 | input: process.stdin, 17 | keymap: [ 18 | ...keycodes, 19 | { sequence: '\x1B\x1C', shortcut: 'ctrl+4', ctrl: true } 20 | ], 21 | onKeypress: async (input, key, close) => { 22 | if (![].concat(expected).includes(key.shortcut)) { 23 | console.log({ expected, input, key }); 24 | } 25 | 26 | if (key.shortcut === 'return' || key.shortcut === 'ctrl+c') { 27 | close(); 28 | process.exit(-1); 29 | } 30 | } 31 | }); 32 | 33 | (async () => { 34 | for (const { sequence, shortcut } of keycodes) { 35 | if (shortcut === 'return') continue; 36 | expected = shortcut; 37 | emitKeyCode(sequence); 38 | await pause(10); 39 | } 40 | 41 | emitKeyCode('\r'); 42 | console.log('finished'); 43 | expected = 'return'; 44 | })(); 45 | -------------------------------------------------------------------------------- /src/keyboard-protocol.ts: -------------------------------------------------------------------------------- 1 | import { kEscape } from '~/keypress'; 2 | 3 | export interface KeyboardProtocol { 4 | name: 'kitty' | 'mok'; 5 | enable: string; 6 | disable: string; 7 | } 8 | 9 | export const KITTY_PROTOCOL: KeyboardProtocol = { 10 | name: 'kitty', 11 | enable: `${kEscape}[>1u`, 12 | disable: `${kEscape}[4;1m`, 18 | disable: `${kEscape}[>4;0m` 19 | }; 20 | 21 | const KITTY_PROTOCOL_TERMINALS = new Set([ 22 | 'kitty', 23 | 'alacritty', 24 | 'foot', 25 | 'ghostty', 26 | 'iterm', 27 | 'rio', 28 | 'wezterm' 29 | ]); 30 | 31 | const MOK_TERMINALS = new Set([ 32 | 'windows_terminal', 33 | 'xterm', 34 | 'gnome_terminal', 35 | 'konsole', 36 | 'vscode', 37 | 'xfce4_terminal', 38 | 'mate_terminal', 39 | 'terminator' 40 | ]); 41 | 42 | /** 43 | * Get the best keyboard protocol for a given terminal. 44 | * Returns null for terminals/multiplexers that don't support enhanced key reporting, 45 | * or where support is unreliable (tmux, screen). 46 | */ 47 | 48 | export const getKeyboardProtocol = (terminal: string): KeyboardProtocol | null => { 49 | if (KITTY_PROTOCOL_TERMINALS.has(terminal)) return KITTY_PROTOCOL; 50 | if (MOK_TERMINALS.has(terminal)) return MOK_PROTOCOL; 51 | return null; 52 | }; 53 | 54 | export const resetKeyboardProtocol = (output: NodeJS.WriteStream): void => { 55 | output.write(KITTY_PROTOCOL.disable); 56 | output.write(MOK_PROTOCOL.disable); 57 | }; 58 | 59 | /** 60 | * Enable enhanced keyboard protocol for the given terminal. 61 | * Returns a cleanup function to disable the protocol. 62 | */ 63 | 64 | export const enableKeyboardProtocol = ( 65 | terminal: string, 66 | output: NodeJS.WriteStream 67 | ): (() => void) | null => { 68 | const protocol = getKeyboardProtocol(terminal); 69 | if (!protocol) return null; 70 | 71 | output.write(protocol.enable); 72 | 73 | return () => { 74 | output.write(protocol.disable); 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emit-keypress", 3 | "description": "Drop-dead simple keypress event emitter for Node.js. Create powerful CLI applications and experiences with ease.", 4 | "version": "2.0.0", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "homepage": "https://github.com/enquirer/emit-keypress", 8 | "author": "Jon Schlinkert (https://github.com/jonschlinkert)", 9 | "repository": "enquirer/emit-keypress", 10 | "bugs": { 11 | "url": "https://github.com/enquirer/emit-keypress/issues" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "eslint": "npx eslint --ext .ts .", 18 | "test": "ts-mocha -r esbuild-register 'test/**/*.ts'", 19 | "tsup": "npx tsup", 20 | "prepublishOnly": "npm run tsup" 21 | }, 22 | "dependencies": { 23 | "detect-terminal": "^2.0.0" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^24.10.1", 27 | "@typescript-eslint/eslint-plugin": "^8.48.1", 28 | "@typescript-eslint/parser": "^8.48.1", 29 | "ansi-colors": "^4.1.3", 30 | "esbuild-register": "^3.6.0", 31 | "eslint": "^8.57.0", 32 | "gulp-format-md": "^2.0.0", 33 | "prettier": "^3.7.4", 34 | "ts-mocha": "^11.1.0", 35 | "tsconfig-paths": "^4.2.0", 36 | "tsup": "^8.5.1" 37 | }, 38 | "keywords": [ 39 | "ansi", 40 | "bin", 41 | "binding", 42 | "bindings", 43 | "chord", 44 | "cli", 45 | "command-line", 46 | "command", 47 | "console", 48 | "emit", 49 | "emitter", 50 | "event", 51 | "events", 52 | "input", 53 | "key", 54 | "keybinding", 55 | "keycode", 56 | "keymap", 57 | "keypress", 58 | "keys", 59 | "listen", 60 | "listener", 61 | "press", 62 | "readline", 63 | "sequence", 64 | "session", 65 | "shortcut", 66 | "stdin", 67 | "stream", 68 | "terminal", 69 | "tty", 70 | "unicode" 71 | ], 72 | "verb": { 73 | "toc": false, 74 | "layout": "default", 75 | "tasks": [ 76 | "readme" 77 | ], 78 | "plugins": [ 79 | "gulp-format-md" 80 | ], 81 | "reflinks": [ 82 | "verb" 83 | ], 84 | "lint": { 85 | "reflinks": true 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/emit-keypress.ts: -------------------------------------------------------------------------------- 1 | // This file is a modified version of the original file from the readline module of Node.js 2 | // Copyright Joyent, Inc. and other Node contributors. 3 | // SPDX-License-Identifier: MIT 4 | import { StringDecoder } from 'node:string_decoder'; 5 | import { clearTimeout, setTimeout } from 'node:timers'; 6 | import { charLengthAt, CSI, emitKeys } from '~/keypress'; 7 | 8 | const { kEscape } = CSI; 9 | const KEYPRESS_DECODER = Symbol('keypress-decoder'); 10 | const ESCAPE_DECODER = Symbol('escape-decoder'); 11 | const kSawKeyPress = Symbol('saw-key-press'); 12 | 13 | // GNU readline library - keyseq-timeout is 500ms (default) 14 | const ESCAPE_CODE_TIMEOUT = 500; 15 | 16 | /** 17 | * accepts a readable Stream instance and makes it emit "keypress" events 18 | */ 19 | 20 | export function emitKeypressEvents(stream, iface = {}) { 21 | if (stream[KEYPRESS_DECODER]) return; 22 | 23 | stream[KEYPRESS_DECODER] = new StringDecoder('utf8'); 24 | 25 | stream[ESCAPE_DECODER] = emitKeys(stream); 26 | stream[ESCAPE_DECODER].next(); 27 | 28 | const triggerEscape = () => stream[ESCAPE_DECODER].next(''); 29 | const { escapeCodeTimeout = ESCAPE_CODE_TIMEOUT } = iface; 30 | let timeoutId; 31 | 32 | function onData(input) { 33 | if (stream.listenerCount('keypress') > 0) { 34 | const string = stream[KEYPRESS_DECODER].write(input); 35 | if (string) { 36 | clearTimeout(timeoutId); 37 | 38 | // This supports characters of length 2. 39 | iface[kSawKeyPress] = charLengthAt(string, 0) === string.length; 40 | iface.isCompletionEnabled = false; 41 | 42 | let length = 0; 43 | for (const character of string) { 44 | length += character.length; 45 | 46 | if (length === string.length) { 47 | iface.isCompletionEnabled = true; 48 | } 49 | 50 | try { 51 | stream[ESCAPE_DECODER].next(character); 52 | // Escape letter at the tail position 53 | if (length === string.length && character === kEscape) { 54 | timeoutId = setTimeout(triggerEscape, escapeCodeTimeout); 55 | } 56 | } catch (err) { 57 | // If the generator throws (it could happen in the `keypress` 58 | // event), we need to restart it. 59 | stream[ESCAPE_DECODER] = emitKeys(stream); 60 | stream[ESCAPE_DECODER].next(); 61 | throw err; 62 | } 63 | } 64 | } 65 | } else { 66 | // Nobody's watching anyway 67 | stream.off('data', onData); 68 | stream.on('newListener', onNewListener); 69 | } 70 | } 71 | 72 | function onNewListener(event) { 73 | if (event === 'keypress') { 74 | stream.on('data', onData); 75 | stream.off('newListener', onNewListener); 76 | } 77 | } 78 | 79 | if (stream.listenerCount('keypress') > 0) { 80 | stream.on('data', onData); 81 | } else { 82 | stream.on('newListener', onNewListener); 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /examples/mouse-select.ts: -------------------------------------------------------------------------------- 1 | import colors, { cyan, dim, green, symbols } from 'ansi-colors'; 2 | import { emitKeypress } from '../index'; 3 | import { keymap } from './keycodes'; 4 | 5 | const pointer = cyan('❯'); 6 | const bell = '\x07'; 7 | 8 | const state = { 9 | index: 0, 10 | margin: { top: 0, bottom: 1, left: 0, right: 15 }, 11 | pos: { x: 0, y: 0 }, 12 | checked: false 13 | }; 14 | 15 | const check = () => state.checked ? green(symbols.check) : dim.gray(symbols.check); 16 | 17 | const center = (text: string, width: number) => { 18 | const raw = colors.unstyle(text); 19 | const len = raw.length; 20 | const pad = Math.floor((width - len) / 2); 21 | return ' '.repeat(pad) + text + ' '.repeat(pad); 22 | }; 23 | 24 | export const box = ({ indent = 0, text, width = text.length, color }) => { 25 | const hline = '─'.repeat(width + 2); 26 | const pad = ' '.repeat(indent); 27 | 28 | const lines = text.split('\n'); 29 | const output = [`${pad}${color(`╭${hline}╮`)}`]; 30 | 31 | const render = (text: string) => { 32 | return `${pad}${color(`│ ${center(text, width)} │`)}`; 33 | }; 34 | 35 | for (const line of lines) { 36 | output.push(render(line)); 37 | } 38 | 39 | output.push(`${pad}${color(`└${hline}╯`)}`); 40 | return output.join('\n'); 41 | }; 42 | 43 | const button = (text, active) => { 44 | return box({ text, width: text.length + 4, color: active ? cyan : dim }); 45 | }; 46 | 47 | const choices = [ 48 | 'apple', 49 | 'banana', 50 | 'cherry', 51 | 'date', 52 | 'elderberry', 53 | 'fig', 54 | 'grape', 55 | 'honeydew', 56 | 'kiwi' 57 | ]; 58 | 59 | const clear = () => { 60 | process.stdout.write('\x1b[2J\x1b[0;0H'); 61 | }; 62 | 63 | const longest = choices.reduce((acc, c) => (c.length > acc ? c.length : acc), 0) + state.margin.right; 64 | 65 | const render = () => { 66 | const pos = { ...state.pos }; 67 | state.pos = { x: null, y: null }; 68 | const isHovered = i => pos.y === i && pos.x <= longest; 69 | 70 | const list = choices.map((choice, index) => { 71 | if (isHovered(index) && state.action === 'mousemove' && state.index !== index) { 72 | return ` ${cyan(choice)}`; 73 | } 74 | 75 | if (state.index === index) { 76 | return `${pointer} ${cyan.underline(choice)}`; 77 | } 78 | 79 | return ` ${choice}`; 80 | }); 81 | 82 | clear(); 83 | console.log(list.join('\n')); 84 | console.log(check(), 'are you sure?'); 85 | console.log(button('Click me', pos.y > choices.length + 1)); 86 | state.action = null; 87 | state.key = null; 88 | }; 89 | 90 | render(); 91 | 92 | emitKeypress({ 93 | keymap, 94 | hideCursor: true, 95 | bufferTimeout: 20, 96 | enableMouseEvents: true, 97 | onMousepress: key => { 98 | state.key = key; 99 | state.pos.x = key.x; 100 | state.pos.y = key.y - 1; 101 | state.action = key.action; 102 | let shouldRender = false; 103 | 104 | if (state.pos.x < longest && state.pos.y < choices.length + 5) { 105 | if (state.action === 'mouseup' && state.pos.y < choices.length) { 106 | state.index = state.pos.y + state.margin.top; 107 | } 108 | 109 | shouldRender = true; 110 | } 111 | 112 | if (state.action === 'mouseup' && state.pos.y === choices.length) { 113 | state.checked = !state.checked; 114 | shouldRender = true; 115 | } 116 | 117 | if (shouldRender) { 118 | render(); 119 | } 120 | }, 121 | onKeypress: async (input, key, close) => { 122 | switch (key.shortcut) { 123 | case 'up': 124 | if (state.index === 0) { 125 | state.index = choices.length - 1; 126 | } else { 127 | state.index--; 128 | } 129 | break; 130 | case 'down': 131 | if (state.index === choices.length - 1) { 132 | state.index = 0; 133 | } else { 134 | state.index++; 135 | } 136 | break; 137 | default: { 138 | process.stdout.write(bell); 139 | break; 140 | } 141 | } 142 | 143 | render(); 144 | 145 | if (input === '\x03' || input === '\r') { 146 | close(); 147 | clear(); 148 | console.log(choices[state.index]); 149 | } 150 | } 151 | }); 152 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-control-regex */ 2 | import type readline from 'node:readline'; 3 | export const PRINTABLE_CHAR_REGEX = /^(?!.*[\uFEFF])[\p{L}\p{N}\p{P}\p{S}\p{Z}\p{M}\u200D\s]+$/u; 4 | export const NON_PRINTABLE_CHAR_REGEX = /[\p{Cc}\p{Cf}\u2028\u2029]/u; 5 | 6 | export const metaKeys = new Set(['alt', 'meta', 'option']); 7 | export const modifierKeys = new Set(['fn', 'ctrl', 'shift', ...[...metaKeys], 'cmd']); 8 | export const sortOrder = [ 9 | 'sequence', 10 | 'name', 11 | 'shortcut', 12 | 'command', 13 | 'ctrl', 14 | 'shift', 15 | 'alt', 16 | 'option', 17 | 'meta', 18 | 'fn', 19 | 'printable', 20 | 'pasted', 21 | 'weight' 22 | ]; 23 | 24 | export const isBuiltIn = k => !k.weight || k.weight <= 0; 25 | 26 | // Based on isMouse from chjj/blessed 27 | // Copyright (c) 2013-2015, Christopher Jeffrey and contributors 28 | export function isMousepress(input, key) { 29 | if (key?.code && (key.code === '[M' || key.code === '[I' || key.code === '[O')) { 30 | return true; 31 | } 32 | 33 | if (typeof input !== 'string') { 34 | return false; 35 | } 36 | 37 | return /\x1b\[M/.test(input) 38 | || /\x1b\[M([\x00\u0020-\uffff]{3})/.test(input) 39 | || /\x1b\[(\d+;\d+;\d+)M/.test(input) 40 | || /\x1b\[<(\d+;\d+;\d+)([mM])/.test(input) 41 | || /\x1b\[<(\d+;\d+;\d+;\d+)&w/.test(input) 42 | || /\x1b\[24([0135])~\[(\d+),(\d+)\]\r/.test(input) 43 | || /\x1b\[(O|I)/.test(input); 44 | } 45 | 46 | export const parsePosition = input => { 47 | if (!input) return null; 48 | // eslint-disable-next-line no-control-regex 49 | const match = /^\x1B\[([0-9]+);([0-9]+)R/.exec(String(input)); 50 | 51 | if (match) { 52 | return { 53 | name: 'position', 54 | position: { y: match[1], x: match[2] }, 55 | printable: false 56 | }; 57 | } 58 | 59 | return null; 60 | }; 61 | 62 | export const sortKeys = (obj, keys = sortOrder) => { 63 | const ordered = {}; 64 | 65 | for (const key of keys) { 66 | if (obj[key] !== undefined) { 67 | ordered[key] = obj[key]; 68 | } 69 | } 70 | 71 | for (const [k, v] of Object.entries(obj)) { 72 | if (!(k in ordered)) { 73 | ordered[k] = v; 74 | } 75 | } 76 | 77 | return ordered; 78 | }; 79 | 80 | const normalizeModifier = (key: string) => { 81 | if (key === 'cmd') return 'meta'; 82 | if (key === 'option') return 'alt'; 83 | if (key === 'control') return 'ctrl'; 84 | if (key === 'command') return 'cmd'; 85 | return key; 86 | }; 87 | 88 | export const sortModifiers = (names: string[]): string[] => { 89 | const normalized = names.map(name => normalizeModifier(name.toLowerCase())); 90 | const modifiers = []; 91 | const after = []; 92 | 93 | for (const name of modifierKeys) { 94 | if (normalized.includes(name)) { 95 | modifiers.push(name); 96 | } 97 | } 98 | 99 | for (const name of normalized) { 100 | if (!modifiers.includes(name)) { 101 | after.push(name); 102 | } 103 | } 104 | 105 | return modifiers.concat(after); 106 | }; 107 | 108 | export const createShortcut = (key: readline.Key): string => { 109 | const modifiers = new Set(); 110 | 111 | if (key.fn) modifiers.add('fn'); 112 | if (key.shift) modifiers.add('shift'); 113 | if (key.alt || key.option || key.meta) modifiers.add('meta'); 114 | if (key.ctrl || key.control) modifiers.add('ctrl'); 115 | if (key.cmd || key.command) modifiers.add('cmd'); 116 | 117 | let keyName = isPrintableCharacter(key.sequence) ? key.sequence : key.name; 118 | if (keyName === 'undefined') keyName = ''; 119 | 120 | if (keyName === 'end' || keyName === 'home') { 121 | modifiers.delete('fn'); 122 | } 123 | 124 | const output = modifiers.size > 0 && keyName 125 | ? `${sortModifiers([...modifiers]).join('+')}+${keyName}` 126 | : keyName; 127 | 128 | return output.length > 1 ? output : ''; 129 | }; 130 | 131 | export const sortShortcutModifier = shortcut => { 132 | return sortModifiers(shortcut.split('+')).join('+'); 133 | }; 134 | 135 | export const sortShortcutModifiers = (keymap = []) => { 136 | for (const key of keymap) { 137 | key.shortcut = sortShortcutModifier(key.shortcut); 138 | } 139 | 140 | return keymap; 141 | }; 142 | 143 | export const prioritizeKeymap = (keymap: any = []) => { 144 | const omit = keymap 145 | .filter(k => k.shortcut?.startsWith('-')) 146 | .map(k => sortShortcutModifier(k.shortcut.slice(1))); 147 | 148 | const bindings = sortShortcutModifiers(keymap) 149 | .filter(k => !isBuiltIn(k) || !omit.includes(k.shortcut)); 150 | 151 | bindings.sort((a, b) => { 152 | a.weight ||= 0; 153 | b.weight ||= 0; 154 | return a.weight === b.weight ? 0 : b.weight > a.weight ? 1 : -1; 155 | }); 156 | 157 | return bindings.filter(b => b.weight !== -1); 158 | }; 159 | 160 | // Unicode ranges for general printable characters including emojis 161 | export const isPrintableCharacter = s => { 162 | return s ? PRINTABLE_CHAR_REGEX.test(s) && !NON_PRINTABLE_CHAR_REGEX.test(s) : false; 163 | }; 164 | -------------------------------------------------------------------------------- /examples/multiselect-mvc.ts: -------------------------------------------------------------------------------- 1 | import type { Key } from 'node:readline'; 2 | import colors from 'ansi-colors'; 3 | import { emitKeypress } from '../index'; 4 | import { keycodes } from '~/keycodes'; 5 | import { Choice, SelectModel, SelectView, Select } from './select-mvc'; 6 | 7 | const { green, cyan, dim } = colors; 8 | 9 | const options = { 10 | name: 'fruit', 11 | message: 'Like fruit?', 12 | maxVisible: 5, 13 | nodes: [ 14 | { name: 'apple' }, 15 | { name: 'orange' }, 16 | { name: 'banana' }, 17 | { name: 'pear' }, 18 | { name: 'kiwi' }, 19 | { name: 'strawberry' }, 20 | { name: 'grape' }, 21 | { name: 'watermelon' }, 22 | { name: 'blueberry' }, 23 | { name: 'mango' }, 24 | { name: 'pineapple' }, 25 | { name: 'cherry' }, 26 | { name: 'peach' }, 27 | { name: 'blackberry' }, 28 | { name: 'apricot' }, 29 | { name: 'papaya' }, 30 | { name: 'cantaloupe' }, 31 | { name: 'honeydew' } 32 | ] 33 | }; 34 | 35 | class CheckboxModel { 36 | name: string; 37 | disabled: boolean; 38 | selected: boolean; 39 | 40 | constructor(control: Checkbox) { 41 | this.name = control.name; 42 | this.disabled = control.disabled; 43 | this.selected = control.selected; 44 | } 45 | } 46 | 47 | class CheckboxView { 48 | private control: Checkbox; 49 | private model: CheckboxModel; 50 | 51 | constructor(control: Checkbox) { 52 | this.control = control; 53 | this.model = this.control.model; 54 | } 55 | 56 | render() { 57 | return this.pointer() + ' ' + this.indicator() + ' ' + this.message(); 58 | } 59 | 60 | pointer() { 61 | return this.control.active ? cyan('❯') : ' '; 62 | } 63 | 64 | indicator() { 65 | return this.model.selected ? green('✓') : dim.gray('✓'); 66 | } 67 | 68 | message() { 69 | const color = this.control.active ? cyan.underline : v => v; 70 | return this.model.disabled 71 | ? dim(`${this.model.name} (disabled)`) 72 | : color(this.model.name); 73 | } 74 | } 75 | 76 | class Checkbox extends Choice { 77 | static Model = CheckboxModel; 78 | static View = CheckboxView; 79 | selected: boolean; 80 | 81 | constructor( 82 | options: { name: string; disabled?: boolean; selected?: boolean }, 83 | parent: Select 84 | ) { 85 | super(options, parent); 86 | this.selected = options.selected ?? false; 87 | this.model = new Checkbox.Model(this); 88 | this.view = new Checkbox.View(this); 89 | } 90 | 91 | render() { 92 | return this.view.render(); 93 | } 94 | 95 | toggle() { 96 | this.selected = !this.selected; 97 | this.model.selected = this.selected; 98 | } 99 | } 100 | 101 | class MultiSelectModel extends SelectModel { 102 | static Option = Checkbox; 103 | 104 | get selected() { 105 | return this.nodes.filter(node => node.selected).map(node => node.name); 106 | } 107 | } 108 | 109 | class MultiSelectView extends SelectView { 110 | footer() { 111 | const selected = this.model.selected; 112 | const output = []; 113 | 114 | if (selected.length > 0) { 115 | output.push(''); 116 | output.push(`Selected: ${selected.join(', ')}`); 117 | } 118 | 119 | output.push(super.footer()); 120 | return output.join('\n'); 121 | } 122 | } 123 | 124 | class MultiSelect extends Select { 125 | model: MultiSelectModel; 126 | view: MultiSelectView; 127 | 128 | constructor(options: any) { 129 | super(options); 130 | this.model = new MultiSelectModel(options, this); 131 | this.view = new MultiSelectView(this); 132 | 133 | if (!this.focused.isFocusable()) { 134 | this.down(); 135 | } 136 | } 137 | 138 | toggle() { 139 | return this.focused.toggle(); 140 | } 141 | 142 | get selected() { 143 | return this.model.selected; 144 | } 145 | } 146 | 147 | const prompt = new MultiSelect(options); 148 | 149 | const print = (output: string = '') => { 150 | // console.clear(); 151 | console.log(output); 152 | }; 153 | 154 | prompt.render().then(v => print(v)); 155 | 156 | emitKeypress({ 157 | enableMouseEvents: true, 158 | hideCursor: true, 159 | keymap: [ 160 | ...keycodes, 161 | { shortcut: 'meta+up', command: 'show_less', weight: 1 }, 162 | { shortcut: 'meta+down', command: 'show_more', weight: 1 }, 163 | { shortcut: 'meta+b', command: 'page_left', weight: 1 }, 164 | { shortcut: 'meta+f', command: 'page_right', weight: 1 }, 165 | { shortcut: 'return', command: 'submit', weight: 1 }, 166 | { shortcut: 'ctrl+c', command: 'cancel', weight: 1 }, 167 | { shortcut: 'space', command: 'toggle', weight: 1 } 168 | ], 169 | onKeypress: async (input, key, close) => { 170 | console.log(key); 171 | 172 | if (prompt.dispatch(key)) { 173 | print(await prompt.render()); 174 | return; 175 | } 176 | 177 | if (key.shortcut === 'ctrl+c') { 178 | prompt.canceled = true; 179 | print(await prompt.render()); 180 | close(); 181 | } 182 | 183 | if (input === '\r') { 184 | prompt.submitted = true; 185 | print(await prompt.render()); 186 | await close(); 187 | 188 | console.log(prompt.selected); 189 | } 190 | } 191 | }); 192 | -------------------------------------------------------------------------------- /examples/scrolling.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress } from '../index'; 2 | 3 | const rotate = (items, count = 0) => { 4 | if (items.length > 0) { 5 | const n = ((count % items.length) + items.length) % items.length; 6 | return n === 0 ? [...items] : [...items.slice(-n), ...items.slice(0, -n)]; 7 | } 8 | 9 | return [...items]; 10 | }; 11 | 12 | const items = [ 13 | 'Item 1', 14 | 'Item 2', 15 | 'Item 3', 16 | 'Item 4', 17 | 'Item 5', 18 | 'Item 6', 19 | 'Item 7', 20 | 'Item 8', 21 | 'Item 9', 22 | 'Item 10', 23 | 'Item 11', 24 | 'Item 12', 25 | 'Item 13', 26 | 'Item 14', 27 | 'Item 15', 28 | 'Item 16', 29 | 'Item 17', 30 | 'Item 18', 31 | 'Item 19', 32 | 'Item 20' 33 | ]; 34 | 35 | const defaultOptions = { 36 | autoResize: true, 37 | disablePointerEvents: true, 38 | easing: 0.1 39 | }; 40 | 41 | export class SmoothScroll { 42 | constructor(options = {}) { 43 | this.options = { ...defaultOptions, ...options }; 44 | this.x = 0; 45 | this.y = 0; 46 | this.internalY = 0; 47 | this.velocityY = 0; 48 | this.percent = 0; 49 | this.enabled = true; 50 | this.firstScroll = true; 51 | this.deltaArray = [0, 0, 0]; 52 | this.direction = 1; 53 | this.isStopped = true; 54 | } 55 | 56 | resize(height) { 57 | this.height = height; 58 | this.update(true); 59 | } 60 | 61 | reset() { 62 | this.internalY = 0; 63 | this.update(true); 64 | } 65 | 66 | update(immediate) { 67 | if (this.enabled) { 68 | if (immediate || !this.dragging && (this.mode === 'touch' || this.mode === 'trackpad')) { 69 | this.y = this.internalY; 70 | this.velocityY = 0; 71 | } else { 72 | this.y += (this.internalY - this.y) * this.options.easing; 73 | this.velocityY = this.internalY - this.y; 74 | } 75 | } 76 | 77 | this.percent = this.y / this.height; 78 | } 79 | 80 | analyzeArray(deltaY) { 81 | const deltaArrayFirstAbs = Math.abs(this.deltaArray[0]); 82 | const deltaArraySecondAbs = Math.abs(this.deltaArray[1]); 83 | const deltaArrayThirdAbs = Math.abs(this.deltaArray[2]); 84 | const deltaAbs = Math.abs(deltaY); 85 | 86 | if (deltaAbs > deltaArrayThirdAbs && deltaArrayThirdAbs > deltaArraySecondAbs && deltaArraySecondAbs > deltaArrayFirstAbs) { 87 | this.wheelAcceleration = true; 88 | } else if (deltaAbs < deltaArrayThirdAbs && deltaArrayThirdAbs <= deltaArraySecondAbs) { 89 | this.wheelAcceleration = false; 90 | this.mode = 'trackpad'; 91 | } 92 | 93 | this.deltaArray.shift(); 94 | this.deltaArray.push(deltaY); 95 | } 96 | 97 | handleMouseWheel(deltaY) { 98 | if (!this.mode || this.mode === 'touch') { 99 | this.mode = 'mouse'; 100 | } 101 | 102 | this.dragging = false; 103 | const direction = deltaY > 0 ? 1 : -1; 104 | 105 | if (direction !== this.direction) { 106 | this.deltaArray = [0, 0, 0]; 107 | } 108 | 109 | this.direction = direction; 110 | 111 | if (this.isStopped) { 112 | this.isStopped = false; 113 | 114 | if (this.wheelAcceleration) { 115 | this.mode = 'mouse'; 116 | } 117 | 118 | this.wheelAcceleration = true; 119 | } 120 | 121 | this.analyzeArray(deltaY); 122 | this.internalY += deltaY; 123 | } 124 | 125 | handleTouchStart() { 126 | this.dragging = false; 127 | this.mode = 'touch'; 128 | } 129 | } 130 | 131 | const scroll = new SmoothScroll(); 132 | 133 | const createState = () => { 134 | const length = Math.min(process.stdout.rows - 5, items.length); 135 | const rotated = startIndex === 0 ? items : rotate(items, startIndex); 136 | const visible = rotated.slice(0, length); 137 | return visible; 138 | }; 139 | 140 | let startIndex = 0; 141 | let cursor = 0; 142 | let visible = createState(); 143 | 144 | function renderList() { 145 | console.clear(); 146 | visible = createState(startIndex); 147 | 148 | for (let i = 0; i < visible.length; i++) { 149 | if (i === cursor) { 150 | console.log('> ' + visible[i]); 151 | } else { 152 | 153 | console.log(' ' + visible[i]); 154 | } 155 | 156 | } 157 | } 158 | 159 | renderList(); 160 | 161 | const update = () => { 162 | scroll.update(); 163 | startIndex = Math.floor(scroll.y) % items.length; 164 | renderList(); 165 | }; 166 | 167 | emitKeypress({ 168 | enableMouseEvents: true, 169 | onKeypress: (input, key, close) => { 170 | // console.log('keypress:', { input, key }); 171 | switch (key.name) { 172 | case 'up': 173 | cursor--; 174 | renderList(); 175 | break; 176 | case 'down': 177 | cursor++; 178 | renderList(); 179 | break; 180 | default: { 181 | if (input === '\x03' || input === '\r') { 182 | console.log(visible[cursor]); 183 | close(); 184 | } 185 | break; 186 | } 187 | } 188 | }, 189 | onMousepress: key => { 190 | // console.log(key); 191 | switch (key.action) { 192 | case 'wheeldown': 193 | scroll.handleMouseWheel(-3); 194 | update(); 195 | break; 196 | case 'wheelup': 197 | scroll.handleMouseWheel(3); 198 | update(); 199 | break; 200 | default: { 201 | break; 202 | } 203 | } 204 | 205 | } 206 | }); 207 | -------------------------------------------------------------------------------- /test/regex.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { PRINTABLE_CHAR_REGEX, NON_PRINTABLE_CHAR_REGEX } from '~/utils'; 3 | 4 | describe('PRINTABLE_CHAR_REGEX', () => { 5 | describe('Latin characters', () => { 6 | it('should match basic Latin letters', () => { 7 | assert.match('abcdefghijklmnopqrstuvwxyz', PRINTABLE_CHAR_REGEX); 8 | assert.match('ABCDEFGHIJKLMNOPQRSTUVWXYZ', PRINTABLE_CHAR_REGEX); 9 | }); 10 | 11 | it('should match letters with diacritical marks', () => { 12 | assert.match('áéíóúñüößàèìòùâêîôû', PRINTABLE_CHAR_REGEX); 13 | assert.match('ąćęłńóśźżĄĆĘŁŃÓŚŹŻ', PRINTABLE_CHAR_REGEX); 14 | }); 15 | 16 | it('should match combined characters', () => { 17 | assert.match('é', PRINTABLE_CHAR_REGEX); // Single code point 18 | assert.match('e\u0301', PRINTABLE_CHAR_REGEX); // Combining acute accent 19 | }); 20 | }); 21 | 22 | describe('Non-Latin scripts', () => { 23 | it('should match CJK characters', () => { 24 | assert.match('こんにちは', PRINTABLE_CHAR_REGEX); // Japanese Hiragana 25 | assert.match('안녕하세요', PRINTABLE_CHAR_REGEX); // Korean 26 | assert.match('你好世界', PRINTABLE_CHAR_REGEX); // Chinese 27 | }); 28 | 29 | it('should match Cyrillic characters', () => { 30 | assert.match('Привет', PRINTABLE_CHAR_REGEX); 31 | }); 32 | 33 | it('should match Hebrew with niqqud', () => { 34 | assert.match('שָׁלוֹם', PRINTABLE_CHAR_REGEX); 35 | }); 36 | }); 37 | 38 | describe('Numbers', () => { 39 | it('should match various number systems', () => { 40 | assert.match('0123456789', PRINTABLE_CHAR_REGEX); // Arabic numerals 41 | assert.match('٠١٢٣٤٥٦٧٨٩', PRINTABLE_CHAR_REGEX); // Arabic-Indic 42 | assert.match('०१२३४५६७८९', PRINTABLE_CHAR_REGEX); // Devanagari 43 | }); 44 | }); 45 | 46 | describe('Punctuation and Symbols', () => { 47 | it('should match common punctuation', () => { 48 | assert.match('/', PRINTABLE_CHAR_REGEX); 49 | assert.match('.,!?()[]{}<>:;\'"`~@#$%^&*-+=_\\|/', PRINTABLE_CHAR_REGEX); 50 | }); 51 | 52 | it('should match various symbols', () => { 53 | assert.match('©®™₿€£¥¢₹§¶†‡♠♣♥♦', PRINTABLE_CHAR_REGEX); 54 | }); 55 | }); 56 | 57 | describe('Whitespace', () => { 58 | it('should match various types of spaces', () => { 59 | assert.match('\n', PRINTABLE_CHAR_REGEX); // Newline 60 | assert.match('\r', PRINTABLE_CHAR_REGEX); // Carriage return 61 | assert.match('\t', PRINTABLE_CHAR_REGEX); // Tab 62 | assert.match(' ', PRINTABLE_CHAR_REGEX); // Regular space 63 | assert.match('\u00A0', PRINTABLE_CHAR_REGEX); // Non-breaking space 64 | assert.match('\u2003', PRINTABLE_CHAR_REGEX); // Em space 65 | assert.match('\u2009', PRINTABLE_CHAR_REGEX); // Thin space 66 | assert.match('\u202F', PRINTABLE_CHAR_REGEX); // Narrow no-break space 67 | assert.match('\u3000', PRINTABLE_CHAR_REGEX); // Ideographic space 68 | assert.doesNotMatch('\uFEFF', PRINTABLE_CHAR_REGEX); // Zero width no-break space 69 | }); 70 | }); 71 | 72 | describe('Invalid input', () => { 73 | it('should reject control characters', () => { 74 | assert.doesNotMatch('\u0000', PRINTABLE_CHAR_REGEX); // Null 75 | assert.doesNotMatch('\u0007', PRINTABLE_CHAR_REGEX); // Bell 76 | assert.doesNotMatch('\u001B', PRINTABLE_CHAR_REGEX); // Escape 77 | assert.doesNotMatch('\u009F', PRINTABLE_CHAR_REGEX); // APC 78 | }); 79 | 80 | it('should reject empty string', () => { 81 | assert.doesNotMatch('', PRINTABLE_CHAR_REGEX); 82 | }); 83 | 84 | it('should reject strings containing control characters', () => { 85 | assert.doesNotMatch('Hello\u0000World', PRINTABLE_CHAR_REGEX); 86 | assert.doesNotMatch('\u0007Test', PRINTABLE_CHAR_REGEX); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('NON_PRINTABLE_CHAR_REGEX', () => { 92 | describe('Control characters', () => { 93 | it('should match ASCII control characters', () => { 94 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u0000'), 'Null'); 95 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u0001'), 'Start of Heading'); 96 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u0007'), 'Bell'); 97 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u001B'), 'Escape'); 98 | }); 99 | 100 | it('should match format control characters', () => { 101 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u200B'), 'Zero Width Space'); 102 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u200E'), 'Left-to-Right Mark'); 103 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u2028'), 'Line Separator'); 104 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\u2029'), 'Paragraph Separator'); 105 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('\n'), 'Newline'); 106 | 107 | assert.ok(!NON_PRINTABLE_CHAR_REGEX.test('/'), 'Slash'); 108 | }); 109 | }); 110 | 111 | describe('Invalid input', () => { 112 | it('should reject printable characters', () => { 113 | assert.equal(NON_PRINTABLE_CHAR_REGEX.test('a'), false); 114 | assert.equal(NON_PRINTABLE_CHAR_REGEX.test('1'), false); 115 | assert.equal(NON_PRINTABLE_CHAR_REGEX.test('!'), false); 116 | assert.equal(NON_PRINTABLE_CHAR_REGEX.test(' '), false); 117 | assert.equal(NON_PRINTABLE_CHAR_REGEX.test('£'), false); 118 | }); 119 | 120 | it('should reject empty string', () => { 121 | assert.equal(NON_PRINTABLE_CHAR_REGEX.test(''), false); 122 | }); 123 | }); 124 | 125 | describe('Mixed content', () => { 126 | it('should detect control characters in mixed content', () => { 127 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('Hello\u0000World')); 128 | assert.ok(NON_PRINTABLE_CHAR_REGEX.test('Test\u200BTest')); 129 | }); 130 | }); 131 | 132 | describe('Additional Tests for PRINTABLE_CHAR_REGEX', () => { 133 | it('should handle mixed scripts properly', () => { 134 | assert.match('Hello こんにちは Привет', PRINTABLE_CHAR_REGEX); 135 | assert.match('123! שלום', PRINTABLE_CHAR_REGEX); 136 | }); 137 | 138 | it('should handle extremely long input strings', () => { 139 | const longString = 'a'.repeat(10 ** 6) + 'z'; 140 | assert.match(longString, PRINTABLE_CHAR_REGEX); 141 | }); 142 | 143 | it('should allow specific symbols like emojis', () => { 144 | assert.match('😀🎉💯👌', PRINTABLE_CHAR_REGEX); 145 | }); 146 | 147 | it('should match emoji sequences', () => { 148 | assert.match('👩‍👩‍👧‍👦', PRINTABLE_CHAR_REGEX); 149 | }); 150 | 151 | describe('Boundary Checks', () => { 152 | it('should match a single complex character', () => { 153 | assert.match('\u00A9', PRINTABLE_CHAR_REGEX); // Copyright Symbol 154 | assert.match('\u20AC', PRINTABLE_CHAR_REGEX); // Euro Sign 155 | }); 156 | 157 | it('should properly handle edge whitespace', () => { 158 | assert.match(' Hello World ', PRINTABLE_CHAR_REGEX); 159 | }); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /examples/dragging.ts: -------------------------------------------------------------------------------- 1 | import { emitKeypress } from '../index'; 2 | 3 | const rotate = (items, count = 0) => { 4 | if (items.length > 0) { 5 | const n = ((count % items.length) + items.length) % items.length; 6 | return n === 0 ? [...items] : [...items.slice(-n), ...items.slice(0, -n)]; 7 | } 8 | return [...items]; 9 | }; 10 | 11 | const items = [ 12 | 'Item 1', 13 | 'Item 2', 14 | 'Item 3', 15 | 'Item 4', 16 | 'Item 5', 17 | 'Item 6', 18 | 'Item 7', 19 | 'Item 8', 20 | 'Item 9', 21 | 'Item 10', 22 | 'Item 11', 23 | 'Item 12', 24 | 'Item 13', 25 | 'Item 14', 26 | 'Item 15', 27 | 'Item 16', 28 | 'Item 17', 29 | 'Item 18', 30 | 'Item 19', 31 | 'Item 20' 32 | ]; 33 | 34 | const defaultOptions = { 35 | autoResize: true, 36 | disablePointerEvents: true, 37 | easing: 0.1 38 | }; 39 | 40 | export class SmoothScroll { 41 | constructor(options = {}) { 42 | this.deltaArray = [0, 0, 0]; 43 | this.direction = 1; 44 | this.dragging = false; 45 | this.enabled = true; 46 | this.firstScroll = true; 47 | this.height = 0; 48 | this.internalX = 0; 49 | this.internalY = 0; 50 | this.isStopped = true; 51 | this.mode = null; 52 | this.options = { ...defaultOptions, ...options }; 53 | this.percentX = 0; 54 | this.percentY = 0; 55 | this.velocityX = 0; 56 | this.velocityY = 0; 57 | this.wheelAcceleration = false; 58 | this.width = 0; 59 | this.x = 0; 60 | this.y = 0; 61 | } 62 | 63 | resize(height, width) { 64 | this.height = height; 65 | this.width = width; 66 | this.update(true); 67 | } 68 | 69 | reset() { 70 | this.internalY = 0; 71 | this.internalX = 0; 72 | this.update(true); 73 | } 74 | 75 | update(immediate) { 76 | if (this.enabled) { 77 | if (immediate || !this.dragging && (this.mode === 'touch' || this.mode === 'trackpad')) { 78 | this.y = this.internalY; 79 | this.velocityY = 0; 80 | this.x = this.internalX; 81 | this.velocityX = 0; 82 | } else if (!this.dragging) { 83 | this.y += (this.internalY - this.y) * this.options.easing; 84 | this.velocityY = this.internalY - this.y; 85 | this.x += (this.internalX - this.x) * this.options.easing; 86 | this.velocityX = this.internalX - this.x; 87 | } 88 | } 89 | 90 | this.percentY = this.y / this.height; 91 | this.percentX = this.x / this.width; 92 | } 93 | 94 | analyzeArray(deltaY, deltaX) { 95 | const deltaArrayFirstAbsY = Math.abs(this.deltaArray[0].y); 96 | const deltaArraySecondAbsY = Math.abs(this.deltaArray[1].y); 97 | const deltaArrayThirdAbsY = Math.abs(this.deltaArray[2].y); 98 | const deltaAbsY = Math.abs(deltaY); 99 | const deltaArrayFirstAbsX = Math.abs(this.deltaArray[0].x); 100 | const deltaArraySecondAbsX = Math.abs(this.deltaArray[1].x); 101 | const deltaArrayThirdAbsX = Math.abs(this.deltaArray[2].x); 102 | const deltaAbsX = Math.abs(deltaX); 103 | 104 | if ((deltaAbsY > deltaArrayThirdAbsY && deltaArrayThirdAbsY > deltaArraySecondAbsY && deltaArraySecondAbsY > deltaArrayFirstAbsY) 105 | || (deltaAbsX > deltaArrayThirdAbsX && deltaArrayThirdAbsX > deltaArraySecondAbsX && deltaArraySecondAbsX > deltaArrayFirstAbsX)) { 106 | this.wheelAcceleration = true; 107 | } else if ((deltaAbsY < deltaArrayThirdAbsY && deltaArrayThirdAbsY <= deltaArraySecondAbsY) 108 | || (deltaAbsX < deltaArrayThirdAbsX && deltaArrayThirdAbsX <= deltaArraySecondAbsX)) { 109 | this.wheelAcceleration = false; 110 | this.mode = 'trackpad'; 111 | } 112 | 113 | this.deltaArray.shift(); 114 | this.deltaArray.push({ x: deltaX, y: deltaY }); 115 | } 116 | 117 | handleMouseWheel(deltaY, deltaX) { 118 | if (!this.mode || this.mode === 'touch') { 119 | this.mode = 'scroll'; 120 | } 121 | 122 | // this.dragging = false; 123 | const directionY = deltaY > 0 ? 1 : -1; 124 | 125 | if (directionY !== this.direction) { 126 | this.deltaArray = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }]; 127 | } 128 | 129 | this.direction = directionY; 130 | 131 | if (this.isStopped) { 132 | this.isStopped = false; 133 | 134 | if (this.wheelAcceleration) { 135 | this.mode = 'scroll'; 136 | } 137 | 138 | this.wheelAcceleration = true; 139 | } 140 | 141 | this.analyzeArray(deltaY, deltaX); 142 | this.internalY += deltaY; 143 | this.internalX += deltaX; 144 | 145 | if (this.stopped) { 146 | this.dragging = false; 147 | this.stopped = false; 148 | } 149 | } 150 | 151 | handleTouchStart() { 152 | this.dragging = false; 153 | this.mode = 'touch'; 154 | } 155 | 156 | handleMouseUp() { 157 | this.stopped = true; 158 | } 159 | 160 | handleMouseMove(key) { 161 | if (this.dragging) { 162 | const deltaY = key.y - this.lastMouseY; 163 | const deltaX = key.x - this.lastMouseX; 164 | this.handleMouseWheel(deltaY, deltaX); 165 | this.lastMouseY = key.y; 166 | this.lastMouseX = key.x; 167 | } else if (key.action === 'mousedown') { 168 | this.dragging = true; 169 | this.lastMouseY = key.y; 170 | this.lastMouseX = key.x; 171 | } 172 | } 173 | } 174 | 175 | const scroll = new SmoothScroll(); 176 | 177 | const createState = () => { 178 | const length = Math.min(process.stdout.rows - 5, items.length); 179 | const rotated = startIndex === 0 ? items : rotate(items, startIndex); 180 | const visible = rotated.slice(0, length); 181 | return visible; 182 | }; 183 | 184 | let startIndex = 0; 185 | let cursor = 0; 186 | let visible = createState(); 187 | 188 | function renderList() { 189 | console.clear(); 190 | 191 | const details = [ 192 | ['scroll mode:', scroll.mode].join(''), 193 | ['wheel acceleration:', scroll.wheelAcceleration].join(''), 194 | ['direction:', scroll.direction === -1 ? 'up' : 'down'].join(''), 195 | ['dragging:', scroll.dragging].join('') 196 | ]; 197 | 198 | console.log(details.join(' | ')); 199 | 200 | visible = createState(startIndex); 201 | 202 | for (let i = 0; i < visible.length; i++) { 203 | if (i === cursor) { 204 | console.log('> ' + visible[i]); 205 | } else { 206 | console.log(' ' + visible[i]); 207 | } 208 | } 209 | } 210 | 211 | renderList(); 212 | 213 | const update = () => { 214 | scroll.update(); 215 | startIndex = Math.floor(scroll.y) % items.length; 216 | renderList(); 217 | }; 218 | 219 | emitKeypress({ 220 | enableMouseEvents: true, 221 | onKeypress: (input, key, close) => { 222 | switch (key.name) { 223 | case 'up': 224 | cursor--; 225 | renderList(); 226 | break; 227 | case 'down': 228 | cursor++; 229 | renderList(); 230 | break; 231 | default: { 232 | if (input === '\x03' || input === '\r') { 233 | console.log(visible[cursor]); 234 | close(); 235 | } 236 | break; 237 | } 238 | } 239 | }, 240 | onMousepress: key => { 241 | // console.log(key); 242 | switch (key.action) { 243 | case 'mousedown': 244 | case 'mousemove': 245 | scroll.handleMouseMove(key); 246 | update(); 247 | break; 248 | case 'mouseup': 249 | scroll.handleMouseUp(); 250 | update(); 251 | break; 252 | case 'wheeldown': 253 | scroll.handleMouseWheel(-3, 0); 254 | update(); 255 | break; 256 | case 'wheelup': 257 | scroll.handleMouseWheel(3, 0); 258 | update(); 259 | break; 260 | default: 261 | break; 262 | } 263 | } 264 | }); 265 | -------------------------------------------------------------------------------- /test/keypress.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { charLengthLeft, charLengthAt } from '~/keypress'; 3 | 4 | describe('keypress Unicode handling', () => { 5 | describe('charLengthAt', () => { 6 | it('should return 1 for basic ASCII characters', () => { 7 | assert.equal(charLengthAt('abc', 0), 1); 8 | assert.equal(charLengthAt('abc', 1), 1); 9 | assert.equal(charLengthAt('123', 0), 1); 10 | }); 11 | 12 | it('should return 2 for surrogate pairs (emojis)', () => { 13 | assert.equal(charLengthAt('👍', 0), 2); // thumbs up emoji 14 | assert.equal(charLengthAt('a👍b', 1), 2); 15 | assert.equal(charLengthAt('🌸🍜', 0), 2); // multiple emojis 16 | assert.equal(charLengthAt('🌸🍜', 2), 2); 17 | }); 18 | 19 | it('should handle mixed ASCII and surrogate pairs', () => { 20 | const str = 'hi👋there'; 21 | assert.equal(charLengthAt(str, 0), 1); // 'h' 22 | assert.equal(charLengthAt(str, 1), 1); // 'i' 23 | assert.equal(charLengthAt(str, 2), 2); // '👋' 24 | assert.equal(charLengthAt(str, 4), 1); // 't' 25 | }); 26 | 27 | it('should return 1 when at string boundary', () => { 28 | assert.equal(charLengthAt('abc', 3), 1); 29 | assert.equal(charLengthAt('👍', 2), 1); 30 | }); 31 | 32 | it('should handle complex emoji sequences', () => { 33 | // Family emoji (multiple surrogate pairs with ZWJ) 34 | assert.equal(charLengthAt('👨‍👩‍👧‍👦', 0), 2); // First emoji 35 | 36 | // Flag emoji (regional indicators) 37 | assert.equal(charLengthAt('🇯🇵', 0), 2); // Japanese flag 38 | assert.equal(charLengthAt('🇺🇸', 0), 2); // US flag 39 | assert.equal(charLengthAt('🇺🇸', 2), 2); // Second part of US flag 40 | 41 | // Add more specific tests for ZWJ sequences 42 | const personTechnologist = '🧑‍💻'; 43 | assert.equal(charLengthAt(personTechnologist, 0), 2); // First emoji 44 | assert.equal(charLengthAt(personTechnologist, 3), 2); // After ZWJ 45 | }); 46 | }); 47 | 48 | describe('charLengthLeft', () => { 49 | it('should return 0 at start of string', () => { 50 | assert.equal(charLengthLeft('abc', 0), 0); 51 | assert.equal(charLengthLeft('👍', 0), 0); 52 | }); 53 | 54 | it('should return 1 for basic ASCII characters', () => { 55 | assert.equal(charLengthLeft('abc', 1), 1); 56 | assert.equal(charLengthLeft('abc', 2), 1); 57 | }); 58 | 59 | it('should return 2 when looking left at surrogate pairs', () => { 60 | assert.equal(charLengthLeft('👍', 2), 2); 61 | assert.equal(charLengthLeft('a👍', 3), 2); 62 | }); 63 | 64 | it('should handle mixed ASCII and surrogate pairs', () => { 65 | const str = 'hi👋there'; 66 | assert.equal(charLengthLeft(str, 1), 1); // left of 'i' 67 | assert.equal(charLengthLeft(str, 2), 1); // left of '👋' 68 | assert.equal(charLengthLeft(str, 4), 2); // left of 't' 69 | }); 70 | 71 | it('should handle surrogate pairs at string boundaries', () => { 72 | assert.equal(charLengthLeft('👍x', 2), 2); 73 | assert.equal(charLengthLeft('👍x', 3), 1); 74 | }); 75 | 76 | it('should handle complex emoji sequences', () => { 77 | const str = 'hi🧑‍💻bye'; // person technologist emoji (ZWJ sequence) 78 | assert.equal(charLengthLeft(str, 2), 1); // left of emoji 79 | assert.equal(charLengthLeft(str, str.length), 1); // left of 'e' 80 | assert.equal(charLengthLeft(str, str.length - 1), 1); // left of 'y' 81 | }); 82 | 83 | it('should handle skin tone modifiers', () => { 84 | const str = '👍🏽'; // thumbs up with medium skin tone 85 | assert.equal(charLengthLeft(str, str.length), 2); 86 | assert.equal(charLengthLeft(str, 2), 2); 87 | }); 88 | }); 89 | 90 | describe('charLengthAt edge cases', () => { 91 | it('should handle empty strings', () => { 92 | assert.equal(charLengthAt('', 0), 1); 93 | assert.equal(charLengthAt('', 1), 1); 94 | }); 95 | 96 | it('should handle undefined and null positions', () => { 97 | const str = 'test'; 98 | assert.equal(charLengthAt(str, undefined), 1); 99 | assert.equal(charLengthAt(str, null), 1); 100 | }); 101 | 102 | it('should handle out of bounds indices', () => { 103 | const str = 'test'; 104 | assert.equal(charLengthAt(str, -1), 1); 105 | assert.equal(charLengthAt(str, str.length), 1); 106 | assert.equal(charLengthAt(str, str.length + 1), 1); 107 | }); 108 | 109 | it('should handle lone surrogates', () => { 110 | // High surrogate alone 111 | assert.equal(charLengthAt('\uD83D', 0), 1); 112 | // Low surrogate alone 113 | assert.equal(charLengthAt('\uDE00', 0), 1); 114 | }); 115 | 116 | it('should handle control characters', () => { 117 | assert.equal(charLengthAt('\x1b', 0), 1); // ESC 118 | assert.equal(charLengthAt('\r', 0), 1); // CR 119 | assert.equal(charLengthAt('\n', 0), 1); // LF 120 | assert.equal(charLengthAt('\t', 0), 1); // TAB 121 | }); 122 | 123 | it('should handle ANSI escape sequences', () => { 124 | assert.equal(charLengthAt('\x1B[0m', 0), 1); 125 | assert.equal(charLengthAt('\x1B[31m', 0), 1); 126 | assert.equal(charLengthAt('\x1B[1;31m', 0), 1); 127 | }); 128 | }); 129 | 130 | describe('charLengthLeft edge cases', () => { 131 | it('should handle empty strings', () => { 132 | // empty string at position 0 matches the i <= 0 condition 133 | assert.equal(charLengthLeft('', 0), 0); 134 | // empty string at any other position should return 1 (default case) 135 | assert.equal(charLengthLeft('', 1), 1); 136 | }); 137 | 138 | it('should handle undefined and null positions', () => { 139 | const str = 'test'; 140 | // null coerces to 0 in <= comparison 141 | assert.equal(charLengthLeft(str, null), 0); 142 | // undefined becomes NaN, fails all comparisons, returns default 1 143 | assert.equal(charLengthLeft(str, undefined), 1); 144 | }); 145 | 146 | it('should handle out of bounds indices', () => { 147 | const str = 'test'; 148 | assert.equal(charLengthLeft(str, -1), 0); 149 | assert.equal(charLengthLeft(str, str.length + 1), 1); 150 | }); 151 | 152 | it('should handle lone surrogates', () => { 153 | // After high surrogate 154 | assert.equal(charLengthLeft('\uD83Dx', 1), 1); 155 | // After low surrogate 156 | assert.equal(charLengthLeft('\uDE00x', 1), 1); 157 | }); 158 | 159 | it('should handle control characters', () => { 160 | assert.equal(charLengthLeft('x\x1b', 1), 1); // ESC 161 | assert.equal(charLengthLeft('x\r', 1), 1); // CR 162 | assert.equal(charLengthLeft('x\n', 1), 1); // LF 163 | assert.equal(charLengthLeft('x\t', 1), 1); // TAB 164 | }); 165 | 166 | it('should handle ANSI escape sequences', () => { 167 | assert.equal(charLengthLeft('x\x1B[0m', 1), 1); 168 | assert.equal(charLengthLeft('x\x1B[31m', 1), 1); 169 | assert.equal(charLengthLeft('x\x1B[1;31m', 1), 1); 170 | }); 171 | }); 172 | 173 | describe('mixed content scenarios', () => { 174 | it('should handle mixed surrogate pairs and control characters', () => { 175 | const str = '\x1b[31m👍\x1b[0m'; 176 | assert.equal(charLengthAt(str, 0), 1); // ANSI start 177 | assert.equal(charLengthAt(str, 5), 2); // emoji 178 | assert.equal(charLengthAt(str, 7), 1); // ANSI end 179 | }); 180 | 181 | it('should handle interleaved normal and surrogate pair characters', () => { 182 | const str = 'a👍b👍c'; 183 | for (let i = 0; i < str.length; i++) { 184 | const expected = i % 3 === 1 ? 2 : 1; 185 | assert.equal(charLengthAt(str, i), expected); 186 | } 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | root: true, 6 | extends: 'eslint:recommended', 7 | 8 | env: { 9 | commonjs: true, 10 | es2023: true, 11 | mocha: true, 12 | node: true 13 | }, 14 | 15 | globals: { 16 | NodeJS: 'readonly' 17 | }, 18 | 19 | plugins: ['@typescript-eslint'], 20 | parser: '@typescript-eslint/parser', 21 | parserOptions: { 22 | ecmaVersion: 'latest', 23 | sourceType: 'module', 24 | requireConfigFile: false 25 | }, 26 | 27 | rules: { 28 | 'accessor-pairs': 2, 29 | 'array-bracket-newline': [1, 'consistent'], 30 | 'array-bracket-spacing': [1, 'never'], 31 | 'array-callback-return': 1, 32 | 'array-element-newline': [1, 'consistent'], 33 | 'arrow-body-style': 0, 34 | 'arrow-parens': [1, 'as-needed'], 35 | 'arrow-spacing': [1, { before: true, after: true }], 36 | 'block-scoped-var': 1, 37 | 'block-spacing': [1, 'always'], 38 | 'brace-style': [1, '1tbs', { allowSingleLine: true }], 39 | 'callback-return': 0, 40 | 'camelcase': [0, { allow: [] }], 41 | 'capitalized-comments': 0, 42 | 'class-methods-use-this': 0, 43 | 'comma-dangle': [1, 'never'], 44 | 'comma-spacing': [1, { before: false, after: true }], 45 | 'comma-style': [1, 'last'], 46 | 'complexity': 1, 47 | 'computed-property-spacing': 1, 48 | 'consistent-return': 0, 49 | 'consistent-this': 1, 50 | 'constructor-super': 2, 51 | 'curly': [1, 'multi-line', 'consistent'], 52 | 'default-case': 1, 53 | 'dot-location': [1, 'property'], 54 | 'dot-notation': 1, 55 | 'eol-last': 1, 56 | 'eqeqeq': [1, 'allow-null'], 57 | 'for-direction': 1, 58 | 'func-call-spacing': 2, 59 | 'generator-star-spacing': [1, { before: true, after: true }], 60 | 'handle-callback-err': [2, '^(err|error)$'], 61 | 'indent': [1, 2, { SwitchCase: 1 }], 62 | 'key-spacing': [1, { beforeColon: false, afterColon: true }], 63 | 'keyword-spacing': [1, { before: true, after: true }], 64 | 'linebreak-style': [1, 'unix'], 65 | 'new-cap': [1, { newIsCap: true, capIsNew: false }], 66 | 'new-parens': 2, 67 | 'no-alert': 1, 68 | 'no-array-constructor': 1, 69 | 'no-async-promise-executor': 1, 70 | 'no-await-in-loop': 1, 71 | 'no-caller': 2, 72 | 'no-case-declarations': 1, 73 | 'no-class-assign': 2, 74 | 'no-cond-assign': 2, 75 | 'no-console': 0, 76 | 'no-const-assign': 2, 77 | 'no-constant-condition': [1, { checkLoops: false }], 78 | 'no-control-regex': 2, 79 | 'no-debugger': 2, 80 | 'no-delete-var': 2, 81 | 'no-dupe-args': 2, 82 | 'no-dupe-class-members': 2, 83 | 'no-dupe-keys': 2, 84 | 'no-duplicate-case': 2, 85 | 'no-duplicate-imports': 0, 86 | 'no-else-return': 0, 87 | 'no-empty-character-class': 2, 88 | 'no-empty-function': 0, 89 | 'no-empty-pattern': 0, 90 | 'no-empty': [1, { allowEmptyCatch: true }], 91 | 'no-eval': 0, 92 | 'no-ex-assign': 2, 93 | 'no-extend-native': 2, 94 | 'no-extra-bind': 1, 95 | 'no-extra-boolean-cast': 1, 96 | 'no-extra-label': 1, 97 | 'no-extra-parens': [1, 'all', { conditionalAssign: false, returnAssign: false, nestedBinaryExpressions: false, ignoreJSX: 'multi-line', enforceForArrowConditionals: false }], 98 | 'no-extra-semi': 1, 99 | 'no-fallthrough': 2, 100 | 'no-floating-decimal': 2, 101 | 'no-func-assign': 2, 102 | 'no-global-assign': 2, 103 | 'no-implicit-coercion': 2, 104 | 'no-implicit-globals': 1, 105 | 'no-implied-eval': 2, 106 | 'no-inner-declarations': [1, 'functions'], 107 | 'no-invalid-regexp': 2, 108 | 'no-invalid-this': 1, 109 | 'no-irregular-whitespace': 2, 110 | 'no-iterator': 2, 111 | 'no-label-var': 2, 112 | 'no-labels': 2, 113 | 'no-lone-blocks': 2, 114 | 'no-lonely-if': 2, 115 | 'no-loop-func': 1, 116 | 'no-mixed-requires': 1, 117 | 'no-mixed-spaces-and-tabs': 2, 118 | 'no-multi-assign': 0, 119 | 'no-multi-spaces': 1, 120 | 'no-multi-str': 2, 121 | 'no-multiple-empty-lines': [1, { max: 1 }], 122 | 'no-native-reassign': 2, 123 | 'no-negated-condition': 0, 124 | 'no-negated-in-lhs': 2, 125 | 'no-new-func': 2, 126 | 'no-new-object': 2, 127 | 'no-new-require': 2, 128 | 'no-new-symbol': 1, 129 | 'no-new-wrappers': 2, 130 | 'no-new': 1, 131 | 'no-obj-calls': 2, 132 | 'no-octal-escape': 2, 133 | 'no-octal': 2, 134 | 'no-path-concat': 1, 135 | 'no-proto': 2, 136 | 'no-prototype-builtins': 0, 137 | 'no-redeclare': 2, 138 | 'no-regex-spaces': 2, 139 | 'no-restricted-globals': 2, 140 | 'no-return-assign': 1, 141 | 'no-return-await': 2, 142 | 'no-script-url': 1, 143 | 'no-self-assign': 1, 144 | 'no-self-compare': 1, 145 | 'no-sequences': 2, 146 | 'no-shadow-restricted-names': 2, 147 | 'no-shadow': 0, 148 | 'no-spaced-func': 2, 149 | 'no-sparse-arrays': 2, 150 | 'no-template-curly-in-string': 0, 151 | 'no-this-before-super': 2, 152 | 'no-throw-literal': 2, 153 | 'no-trailing-spaces': 1, 154 | 'no-undef-init': 2, 155 | 'no-undef': 2, 156 | 'no-unexpected-multiline': 2, 157 | 'no-unneeded-ternary': [1, { defaultAssignment: false }], 158 | 'no-unreachable-loop': 1, 159 | 'no-unreachable': 2, 160 | 'no-unsafe-assignment': 0, 161 | 'no-unsafe-call': 0, 162 | 'no-unsafe-finally': 2, 163 | 'no-unsafe-member-access': 0, 164 | 'no-unsafe-negation': 2, 165 | 'no-unsafe-optional-chaining': 0, 166 | 'no-unsafe-return': 0, 167 | 'no-unused-expressions': 2, 168 | 'no-unused-vars': [1, { vars: 'all', args: 'after-used' }], 169 | 'no-use-before-define': 0, 170 | 'no-useless-call': 2, 171 | 'no-useless-catch': 0, 172 | 'no-useless-escape': 0, 173 | 'no-useless-rename': 1, 174 | 'no-useless-return': 1, 175 | 'no-var': 1, 176 | 'no-void': 1, 177 | 'no-warning-comments': 0, 178 | 'no-with': 2, 179 | 'object-curly-spacing': [1, 'always', { objectsInObjects: true }], 180 | 'object-shorthand': 1, 181 | 'one-var': [1, { initialized: 'never' }], 182 | 'operator-linebreak': [0, 'after', { overrides: { '?': 'before', ':': 'before' } }], 183 | 'padded-blocks': [1, { switches: 'never' }], 184 | 'prefer-const': [1, { destructuring: 'all', ignoreReadBeforeAssign: false }], 185 | 'prefer-promise-reject-errors': 1, 186 | 'quotes': [1, 'single', 'avoid-escape'], 187 | 'radix': 2, 188 | 'rest-spread-spacing': 1, 189 | 'semi-spacing': [1, { before: false, after: true }], 190 | 'semi-style': 1, 191 | 'semi': [1, 'always'], 192 | 'space-before-blocks': [1, 'always'], 193 | 'space-before-function-paren': [1, { anonymous: 'never', named: 'never', asyncArrow: 'always' }], 194 | 'space-in-parens': [1, 'never'], 195 | 'space-infix-ops': 1, 196 | 'space-unary-ops': [1, { words: true, nonwords: false }], 197 | 'spaced-comment': [0, 'always', { markers: ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ','] }], 198 | 'strict': 2, 199 | 'switch-colon-spacing': 1, 200 | 'symbol-description': 1, 201 | 'template-curly-spacing': [2, 'never'], 202 | 'template-tag-spacing': [2, 'never'], 203 | 'unicode-bom': 1, 204 | 'use-isnan': 2, 205 | 'valid-jsdoc': 1, 206 | 'valid-typeof': 2, 207 | 'wrap-iife': [1, 'any'], 208 | 'yoda': [1, 'never'], 209 | 210 | // TypeScript 211 | '@typescript-eslint/consistent-type-imports': 1, 212 | '@typescript-eslint/no-unused-vars': [1, { vars: 'all', args: 'after-used', argsIgnorePattern: '^_' }] 213 | }, 214 | 215 | ignorePatterns: [ 216 | '.cache', 217 | '.config', 218 | '.vscode', 219 | '.git', 220 | '**/node_modules/**', 221 | 'build', 222 | 'dist', 223 | // 'tmp', 224 | 'temp' 225 | ] 226 | }; 227 | -------------------------------------------------------------------------------- /.verb.md: -------------------------------------------------------------------------------- 1 | ## Why another keypress module? 2 | 3 | Node's built-in `readline` module is great for handling user input if you need something simple. But when using `createInterface`, there are a number of built-in behaviors that are difficult or impossible to override when you need more control over the input stream. 4 | 5 | Other keypress modules I found either did too much, or didn't allow for enough customization. This module is designed to be simple, flexible, easy to use, and easy to customize. 6 | 7 | ## Why use this? 8 | 9 | **Create your own CLI interface** 10 | 11 | It's lightweight with only one dependency for detecting the terminal. It's easy to use, and easy to customize. It's designed to be used in a wide range of use-cases, from simple command-line utilities to complex terminal applications. 12 | 13 | Powerful CLI applications can be built with this module. 14 | 15 | ## Usage 16 | 17 | ```js 18 | import { emitKeypress } from '{%= name %}'; 19 | 20 | emitKeypress({ input: process.stdin }); 21 | 22 | process.stdin.on('keypress', (input, key) => { 23 | console.log({ input, key }); 24 | 25 | if (input === '\x03' || input === '\r') { 26 | process.stdin.pause(); 27 | } 28 | }); 29 | ``` 30 | 31 | ## API 32 | 33 | ### onKeypress 34 | 35 | Pass an `onKeypress` function to `emitKeypress` to handle keypress events. 36 | 37 | ```ts 38 | // The "close" function is passed as the third argument 39 | const onKeypress = async (input, key, close) => { 40 | // do stuff with keypress events 41 | console.log({ input, key }); 42 | 43 | // Close the stream if the user presses `Ctrl+C` or `Enter` 44 | if (input === '\x03' || input === '\r') { 45 | close(); 46 | } 47 | }; 48 | 49 | emitKeypress({ onKeypress }); 50 | ``` 51 | 52 | A `close` function is also returned from `emitKeypress` that can be called to close the stream. 53 | 54 | ```ts 55 | const { close } = emitKeypress({ onKeypress }); 56 | 57 | // close the stream 58 | setTimeout(() => { 59 | close(); 60 | }, 10_000); 61 | ``` 62 | 63 | 64 | ### keymap 65 | 66 | Pass a `keymap` array to map keypress events to specific shortcuts. 67 | 68 | ```ts 69 | emitKeypress({ 70 | keymap: [ 71 | { sequence: '\x03', shortcut: 'ctrl+c' }, 72 | { sequence: '\r', shortcut: 'return' } 73 | ], 74 | onKeypress: async (input, key, close) => { 75 | // do stuff with keypress events 76 | console.log({ input, key }); 77 | 78 | if (key.shortcut === 'return' || key.shortcut === 'ctrl+c') { 79 | close(); 80 | } 81 | } 82 | }); 83 | ``` 84 | 85 | Note that you can add arbitrary properties the keymap objects. This is useful for mapping shortcuts to commands. 86 | 87 | **Example** 88 | 89 | ```ts 90 | emitKeypress({ 91 | keymap: [ 92 | { sequence: '\x1B', shortcut: 'escape', command: 'cancel' }, 93 | { sequence: '\x03', shortcut: 'ctrl+c', command: 'cancel' }, 94 | { sequence: '\r', shortcut: 'return', command: 'submit' } 95 | ], 96 | onKeypress: async (input, key, close) => { 97 | // do stuff with keypress events 98 | switch (key.command) { 99 | case 'cancel': 100 | console.log('canceled'); 101 | close(); 102 | break; 103 | 104 | case 'submit': 105 | console.log('submitted'); 106 | break; 107 | } 108 | } 109 | }); 110 | ``` 111 | 112 | 113 | ### input 114 | 115 | Pass a `ReadableStream` to `input` to listen for keypress events on the stream. 116 | 117 | ```ts 118 | emitKeypress({ 119 | input: process.stdin, 120 | onKeypress: async (input, key, close) => { 121 | // do stuff with keypress events 122 | console.log({ input, key }); 123 | 124 | if (key.shortcut === 'return' || key.shortcut === 'ctrl+c') { 125 | close(); 126 | } 127 | } 128 | }); 129 | ``` 130 | ### onMousepress 131 | 132 | Pass an `onMousepress` function to handle mouse events. When provided, mouse tracking is automatically enabled. 133 | 134 | ```ts 135 | emitKeypress({ 136 | onKeypress: (input, key, close) => { 137 | console.log('key:', key); 138 | }, 139 | onMousepress: (mouse, close) => { 140 | console.log('mouse:', mouse); 141 | // mouse.x, mouse.y, mouse.button, mouse.action, etc. 142 | } 143 | }); 144 | ``` 145 | 146 | ### Paste Mode 147 | 148 | Enable bracketed paste mode to handle multi-line pastes as a single event. 149 | 150 | ```ts 151 | emitKeypress({ 152 | enablePasteMode: true, 153 | pasteModeTimeout: 100, // timeout in ms 154 | maxPasteBuffer: 1024 * 1024, // 1MB limit 155 | onKeypress: (input, key, close) => { 156 | if (key.name === 'paste') { 157 | console.log('pasted:', key.sequence); 158 | } 159 | } 160 | }); 161 | ``` 162 | 163 | ### Enhanced Keyboard Protocol 164 | 165 | Enable the Kitty or modifyOtherKeys keyboard protocol for better key detection in supported terminals. 166 | 167 | ```ts 168 | emitKeypress({ 169 | keyboardProtocol: true, 170 | onKeypress: (input, key, close) => { 171 | // Enhanced key reporting with better modifier detection 172 | console.log({ input, key }); 173 | } 174 | }); 175 | ``` 176 | 177 | Supported terminals include: kitty, alacritty, foot, ghostty, iterm, rio, wezterm (Kitty protocol), and windows_terminal, xterm, gnome_terminal, konsole, vscode, xfce4_terminal, mate_terminal, terminator (modifyOtherKeys protocol). 178 | 179 | ### Cursor Control 180 | 181 | Control cursor visibility and get cursor position. 182 | 183 | ```ts 184 | import { cursor, emitKeypress } from '{%= name %}'; 185 | 186 | // Hide/show cursor 187 | cursor.hide(process.stdout); 188 | cursor.show(process.stdout); 189 | 190 | // Or use the hideCursor option 191 | emitKeypress({ 192 | hideCursor: true, 193 | onKeypress: (input, key, close) => { 194 | // cursor is automatically shown when close() is called 195 | } 196 | }); 197 | 198 | // Get initial cursor position 199 | emitKeypress({ 200 | initialPosition: true, 201 | onKeypress: (input, key, close) => { 202 | if (key.name === 'position') { 203 | console.log('cursor at:', key.x, key.y); 204 | } 205 | } 206 | }); 207 | ``` 208 | 209 | ### Options 210 | 211 | | Option | Type | Default | Description | 212 | | --- | --- | --- | --- | 213 | | `input` | `ReadStream` | `process.stdin` | Input stream to listen on | 214 | | `output` | `WriteStream` | `process.stdout` | Output stream for escape sequences | 215 | | `keymap` | `Array` | `[]` | Custom key mappings | 216 | | `onKeypress` | `Function` | required | Keypress event handler | 217 | | `onMousepress` | `Function` | `undefined` | Mouse event handler (enables mouse tracking) | 218 | | `onExit` | `Function` | `undefined` | Called when the stream closes | 219 | | `escapeCodeTimeout` | `number` | `500` | Timeout for escape sequences (ms) | 220 | | `handleClose` | `boolean` | `true` | Register cleanup on process exit | 221 | | `hideCursor` | `boolean` | `false` | Hide cursor while listening | 222 | | `initialPosition` | `boolean` | `false` | Request initial cursor position | 223 | | `enablePasteMode` | `boolean` | `false` | Enable bracketed paste mode | 224 | | `pasteModeTimeout` | `number` | `100` | Paste mode timeout (ms) | 225 | | `maxPasteBuffer` | `number` | `1048576` | Max paste buffer size (bytes) | 226 | | `keyboardProtocol` | `boolean` | `false` | Enable enhanced keyboard protocol | 227 | 228 | ### createEmitKeypress 229 | 230 | Create an isolated instance with its own exit handlers. 231 | 232 | ```ts 233 | import { createEmitKeypress } from '{%= name %}'; 234 | 235 | const { emitKeypress, onExitHandlers } = createEmitKeypress({ 236 | setupProcessHandlers: true 237 | }); 238 | ``` 239 | 240 | ## History 241 | 242 | ### v2.0.0 243 | 244 | - Added `onMousepress` option for mouse event handling with automatic mouse tracking 245 | - Added bracketed paste mode support (`enablePasteMode`, `pasteModeTimeout`, `maxPasteBuffer`) 246 | - Added enhanced keyboard protocol support for Kitty and modifyOtherKeys (`keyboardProtocol` option) 247 | - Added `createEmitKeypress` factory for creating isolated instances with separate exit handlers 248 | - Added `cursor` utilities for hiding/showing cursor and getting position 249 | - Added `initialPosition` option to request cursor position on start 250 | - Added `hideCursor` option for automatic cursor visibility management 251 | - Added `onExit` callback option 252 | - Added CSI u (Kitty) and modifyOtherKeys protocol parsing for better modifier key detection 253 | - Added `fn` modifier support to key objects 254 | - Added automatic terminal detection for keyboard protocol selection 255 | - Added `keycodes` export with comprehensive key sequence mappings 256 | - Improved exit handler management with WeakMap-based session counting 257 | - Improved cleanup: protocols are reset, mouse/paste modes disabled on close 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # emit-keypress [![NPM version](https://img.shields.io/npm/v/emit-keypress.svg?style=flat)](https://www.npmjs.com/package/emit-keypress) [![NPM monthly downloads](https://img.shields.io/npm/dm/emit-keypress.svg?style=flat)](https://npmjs.org/package/emit-keypress) [![NPM total downloads](https://img.shields.io/npm/dt/emit-keypress.svg?style=flat)](https://npmjs.org/package/emit-keypress) 2 | 3 | > Drop-dead simple keypress event emitter for Node.js. Create powerful CLI applications and experiences with ease. 4 | 5 | Please consider following this project's author, [Jon Schlinkert](https://github.com/jonschlinkert), and consider starring the project to show your :heart: and support. 6 | 7 | ## Install 8 | 9 | Install with [npm](https://www.npmjs.com/): 10 | 11 | ```sh 12 | $ npm install --save emit-keypress 13 | ``` 14 | 15 | ## Why another keypress module? 16 | 17 | Node's built-in `readline` module is great for handling user input if you need something simple. But when using `createInterface`, there are a number of built-in behaviors that are difficult or impossible to override when you need more control over the input stream. 18 | 19 | Other keypress modules I found either did too much, or didn't allow for enough customization. This module is designed to be simple, flexible, easy to use, and easy to customize. 20 | 21 | ## Why use this? 22 | 23 | **Create your own CLI interface** 24 | 25 | It's lightweight with only one dependency for detecting the terminal. It's easy to use, and easy to customize. It's designed to be used in a wide range of use-cases, from simple command-line utilities to complex terminal applications. 26 | 27 | Powerful CLI applications can be built with this module. 28 | 29 | ## Usage 30 | 31 | ```js 32 | import { emitKeypress } from 'emit-keypress'; 33 | 34 | emitKeypress({ input: process.stdin }); 35 | 36 | process.stdin.on('keypress', (input, key) => { 37 | console.log({ input, key }); 38 | 39 | if (input === '\x03' || input === '\r') { 40 | process.stdin.pause(); 41 | } 42 | }); 43 | ``` 44 | 45 | ## API 46 | 47 | ### onKeypress 48 | 49 | Pass an `onKeypress` function to `emitKeypress` to handle keypress events. 50 | 51 | ```ts 52 | // The "close" function is passed as the third argument 53 | const onKeypress = async (input, key, close) => { 54 | // do stuff with keypress events 55 | console.log({ input, key }); 56 | 57 | // Close the stream if the user presses `Ctrl+C` or `Enter` 58 | if (input === '\x03' || input === '\r') { 59 | close(); 60 | } 61 | }; 62 | 63 | emitKeypress({ onKeypress }); 64 | ``` 65 | 66 | A `close` function is also returned from `emitKeypress` that can be called to close the stream. 67 | 68 | ```ts 69 | const { close } = emitKeypress({ onKeypress }); 70 | 71 | // close the stream 72 | setTimeout(() => { 73 | close(); 74 | }, 10_000); 75 | ``` 76 | 77 | ### keymap 78 | 79 | Pass a `keymap` array to map keypress events to specific shortcuts. 80 | 81 | ```ts 82 | emitKeypress({ 83 | keymap: [ 84 | { sequence: '\x03', shortcut: 'ctrl+c' }, 85 | { sequence: '\r', shortcut: 'return' } 86 | ], 87 | onKeypress: async (input, key, close) => { 88 | // do stuff with keypress events 89 | console.log({ input, key }); 90 | 91 | if (key.shortcut === 'return' || key.shortcut === 'ctrl+c') { 92 | close(); 93 | } 94 | } 95 | }); 96 | ``` 97 | 98 | Note that you can add arbitrary properties the keymap objects. This is useful for mapping shortcuts to commands. 99 | 100 | **Example** 101 | 102 | ```ts 103 | emitKeypress({ 104 | keymap: [ 105 | { sequence: '\x1B', shortcut: 'escape', command: 'cancel' }, 106 | { sequence: '\x03', shortcut: 'ctrl+c', command: 'cancel' }, 107 | { sequence: '\r', shortcut: 'return', command: 'submit' } 108 | ], 109 | onKeypress: async (input, key, close) => { 110 | // do stuff with keypress events 111 | switch (key.command) { 112 | case 'cancel': 113 | console.log('canceled'); 114 | close(); 115 | break; 116 | 117 | case 'submit': 118 | console.log('submitted'); 119 | break; 120 | } 121 | } 122 | }); 123 | ``` 124 | 125 | ### input 126 | 127 | Pass a `ReadableStream` to `input` to listen for keypress events on the stream. 128 | 129 | ```ts 130 | emitKeypress({ 131 | input: process.stdin, 132 | onKeypress: async (input, key, close) => { 133 | // do stuff with keypress events 134 | console.log({ input, key }); 135 | 136 | if (key.shortcut === 'return' || key.shortcut === 'ctrl+c') { 137 | close(); 138 | } 139 | } 140 | }); 141 | ``` 142 | 143 | ### onMousepress 144 | 145 | Pass an `onMousepress` function to handle mouse events. When provided, mouse tracking is automatically enabled. 146 | 147 | ```ts 148 | emitKeypress({ 149 | onKeypress: (input, key, close) => { 150 | console.log('key:', key); 151 | }, 152 | onMousepress: (mouse, close) => { 153 | console.log('mouse:', mouse); 154 | // mouse.x, mouse.y, mouse.button, mouse.action, etc. 155 | } 156 | }); 157 | ``` 158 | 159 | ### Paste Mode 160 | 161 | Enable bracketed paste mode to handle multi-line pastes as a single event. 162 | 163 | ```ts 164 | emitKeypress({ 165 | enablePasteMode: true, 166 | pasteModeTimeout: 100, // timeout in ms 167 | maxPasteBuffer: 1024 * 1024, // 1MB limit 168 | onKeypress: (input, key, close) => { 169 | if (key.name === 'paste') { 170 | console.log('pasted:', key.sequence); 171 | } 172 | } 173 | }); 174 | ``` 175 | 176 | ### Enhanced Keyboard Protocol 177 | 178 | Enable the Kitty or modifyOtherKeys keyboard protocol for better key detection in supported terminals. 179 | 180 | ```ts 181 | emitKeypress({ 182 | keyboardProtocol: true, 183 | onKeypress: (input, key, close) => { 184 | // Enhanced key reporting with better modifier detection 185 | console.log({ input, key }); 186 | } 187 | }); 188 | ``` 189 | 190 | Supported terminals include: kitty, alacritty, foot, ghostty, iterm, rio, wezterm (Kitty protocol), and windows_terminal, xterm, gnome_terminal, konsole, vscode, xfce4_terminal, mate_terminal, terminator (modifyOtherKeys protocol). 191 | 192 | ### Cursor Control 193 | 194 | Control cursor visibility and get cursor position. 195 | 196 | ```ts 197 | import { cursor, emitKeypress } from 'emit-keypress'; 198 | 199 | // Hide/show cursor 200 | cursor.hide(process.stdout); 201 | cursor.show(process.stdout); 202 | 203 | // Or use the hideCursor option 204 | emitKeypress({ 205 | hideCursor: true, 206 | onKeypress: (input, key, close) => { 207 | // cursor is automatically shown when close() is called 208 | } 209 | }); 210 | 211 | // Get initial cursor position 212 | emitKeypress({ 213 | initialPosition: true, 214 | onKeypress: (input, key, close) => { 215 | if (key.name === 'position') { 216 | console.log('cursor at:', key.x, key.y); 217 | } 218 | } 219 | }); 220 | ``` 221 | 222 | ### Options 223 | 224 | | Option | Type | Default | Description | 225 | | --- | --- | --- | --- | 226 | | `input` | `ReadStream` | `process.stdin` | Input stream to listen on | 227 | | `output` | `WriteStream` | `process.stdout` | Output stream for escape sequences | 228 | | `keymap` | `Array` | `[]` | Custom key mappings | 229 | | `onKeypress` | `Function` | required | Keypress event handler | 230 | | `onMousepress` | `Function` | `undefined` | Mouse event handler (enables mouse tracking) | 231 | | `onExit` | `Function` | `undefined` | Called when the stream closes | 232 | | `escapeCodeTimeout` | `number` | `500` | Timeout for escape sequences (ms) | 233 | | `handleClose` | `boolean` | `true` | Register cleanup on process exit | 234 | | `hideCursor` | `boolean` | `false` | Hide cursor while listening | 235 | | `initialPosition` | `boolean` | `false` | Request initial cursor position | 236 | | `enablePasteMode` | `boolean` | `false` | Enable bracketed paste mode | 237 | | `pasteModeTimeout` | `number` | `100` | Paste mode timeout (ms) | 238 | | `maxPasteBuffer` | `number` | `1048576` | Max paste buffer size (bytes) | 239 | | `keyboardProtocol` | `boolean` | `false` | Enable enhanced keyboard protocol | 240 | 241 | ### createEmitKeypress 242 | 243 | Create an isolated instance with its own exit handlers. 244 | 245 | ```ts 246 | import { createEmitKeypress } from 'emit-keypress'; 247 | 248 | const { emitKeypress, onExitHandlers } = createEmitKeypress({ 249 | setupProcessHandlers: true 250 | }); 251 | ``` 252 | 253 | ## History 254 | 255 | ### v2.0.0 256 | 257 | * Added `onMousepress` option for mouse event handling with automatic mouse tracking 258 | * Added bracketed paste mode support (`enablePasteMode`, `pasteModeTimeout`, `maxPasteBuffer`) 259 | * Added enhanced keyboard protocol support for Kitty and modifyOtherKeys (`keyboardProtocol` option) 260 | * Added `createEmitKeypress` factory for creating isolated instances with separate exit handlers 261 | * Added `cursor` utilities for hiding/showing cursor and getting position 262 | * Added `initialPosition` option to request cursor position on start 263 | * Added `hideCursor` option for automatic cursor visibility management 264 | * Added `onExit` callback option 265 | * Added CSI u (Kitty) and modifyOtherKeys protocol parsing for better modifier key detection 266 | * Added `fn` modifier support to key objects 267 | * Added automatic terminal detection for keyboard protocol selection 268 | * Added `keycodes` export with comprehensive key sequence mappings 269 | * Improved exit handler management with WeakMap-based session counting 270 | * Improved cleanup: protocols are reset, mouse/paste modes disabled on close 271 | 272 | ## About 273 | 274 |
275 | Contributing 276 | 277 | Pull requests and stars are always welcome. For bugs and feature requests, [please create an issue](../../issues/new). 278 | 279 |
280 | 281 |
282 | Running Tests 283 | 284 | Running and reviewing unit tests is a great way to get familiarized with a library and its API. You can install dependencies and run tests with the following command: 285 | 286 | ```sh 287 | $ npm install && npm test 288 | ``` 289 | 290 |
291 | 292 |
293 | Building docs 294 | 295 | _(This project's readme.md is generated by [verb](https://github.com/verbose/verb-generate-readme), please don't edit the readme directly. Any changes to the readme must be made in the [.verb.md](.verb.md) readme template.)_ 296 | 297 | To generate the readme, run the following command: 298 | 299 | ```sh 300 | $ npm install -g verbose/verb#dev verb-generate-readme && verb 301 | ``` 302 | 303 |
304 | 305 | ### Author 306 | 307 | **Jon Schlinkert** 308 | 309 | * [GitHub Profile](https://github.com/jonschlinkert) 310 | * [Twitter Profile](https://twitter.com/jonschlinkert) 311 | * [LinkedIn Profile](https://linkedin.com/in/jonschlinkert) 312 | 313 | ### License 314 | 315 | Copyright © 2025, [Jon Schlinkert](https://github.com/jonschlinkert). 316 | Released under the MIT License. 317 | 318 | *** 319 | 320 | _This file was generated by [verb-generate-readme](https://github.com/verbose/verb-generate-readme), v0.8.0, on December 10, 2025._ -------------------------------------------------------------------------------- /src/mousepress.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-control-regex */ 2 | // Based on code from chjj/blessed 3 | // Copyright (c) 2013-2015, Christopher Jeffrey and contributors 4 | // eslint-disable-next-line complexity 5 | export const mousepress = (s: string, buf: Buffer, state = {}) => { 6 | let parts; 7 | let b; 8 | let x; 9 | let y; 10 | let mod; 11 | let params; 12 | let down; 13 | let page; 14 | let button; 15 | 16 | const key = { 17 | name: undefined, 18 | ctrl: false, 19 | meta: false, 20 | shift: false 21 | }; 22 | 23 | if (Buffer.isBuffer(s)) { 24 | if (s[0] > 127 && s[1] === undefined) { 25 | s[0] -= 128; 26 | s = '\x1b' + s.toString('utf8'); 27 | } else { 28 | s = s.toString('utf8'); 29 | } 30 | } 31 | 32 | // XTerm / X10 for buggy VTE 33 | // VTE can only send unsigned chars and no unicode for coords. This limits 34 | // them to 0xff. However, normally the x10 protocol does not allow a byte 35 | // under 0x20, but since VTE can have the bytes overflow, we can consider 36 | // bytes below 0x20 to be up to 0xff + 0x20. This gives a limit of 287. Since 37 | // characters ranging from 223 to 248 confuse javascript's utf parser, we 38 | // need to parse the raw binary. We can detect whether the terminal is using 39 | // a bugged VTE version by examining the coordinates and seeing whether they 40 | // are a value they would never otherwise be with a properly implemented x10 41 | // protocol. This method of detecting VTE is only 99% reliable because we 42 | // can't check if the coords are 0x00 (255) since that is a valid x10 coord 43 | // technically. 44 | const bx = s.charCodeAt(4); 45 | const by = s.charCodeAt(5); 46 | 47 | if (buf[0] === 0x1b && buf[1] === 0x5b && buf[2] === 0x4d && (state.isVTE 48 | || bx >= 65533 || by >= 65533 49 | || (bx > 0x00 && bx < 0x20) 50 | || (by > 0x00 && by < 0x20) 51 | || (buf[4] > 223 && buf[4] < 248 && buf.length === 6) 52 | || (buf[5] > 223 && buf[5] < 248 && buf.length === 6))) { 53 | b = buf[3]; 54 | x = buf[4]; 55 | y = buf[5]; 56 | 57 | // Handle coordinate, unsigned char overflow 58 | if (x < 0x20) x += 0xff; 59 | if (y < 0x20) y += 0xff; 60 | 61 | // Handle large terminal windows (coordinates > 255) 62 | if (x === 0xff) x = 255; 63 | if (y === 0xff) y = 255; 64 | 65 | // Convert the coordinates into a 66 | // properly formatted x10 utf8 sequence. 67 | s = '\x1b[M' + String.fromCharCode(b) + String.fromCharCode(x) + String.fromCharCode(y); 68 | } 69 | 70 | // XTerm / X10 71 | if ((parts = /^\x1b\[M([\x00\u0020-\uffff]{3})/.exec(s))) { 72 | b = parts[1].charCodeAt(0); 73 | x = parts[1].charCodeAt(1); 74 | y = parts[1].charCodeAt(2); 75 | 76 | key.name = 'mouse'; 77 | key.type = 'X10'; 78 | 79 | key.raw = [b, x, y, parts[0]]; 80 | key.buf = buf; 81 | key.x = x - 32; 82 | key.y = y - 32; 83 | 84 | if (state.zero) { 85 | key.x--; 86 | key.y--; 87 | } 88 | 89 | if (x === 0) key.x = 255; 90 | if (y === 0) key.y = 255; 91 | 92 | mod = b >> 2; 93 | key.shift = Boolean(mod & 1); 94 | key.meta = Boolean((mod >> 1) & 1); 95 | key.ctrl = Boolean((mod >> 2) & 1); 96 | 97 | b -= 32; 98 | 99 | if ((b >> 6) & 1) { 100 | key.action = b & 1 ? 'wheeldown' : 'wheelup'; 101 | key.button = 'middle'; 102 | } else if (b === 3) { 103 | // NOTE: x10 and urxvt have no way 104 | // of telling which button mouseup used. 105 | key.action = 'mouseup'; 106 | key.button = state._lastButton || 'unknown'; 107 | delete state._lastButton; 108 | } else { 109 | key.action = 'mousedown'; 110 | button = b & 3; 111 | key.button = button === 0 112 | ? 'left' : button === 1 113 | ? 'middle' : button === 2 114 | ? 'right' : 'unknown'; 115 | 116 | state._lastButton = key.button; 117 | } 118 | 119 | // Probably a movement. 120 | // The *newer* VTE gets mouse movements comepletely wrong. 121 | // This presents a problem: older versions of VTE that get it right might 122 | // be confused by the second conditional in the if statement. 123 | // NOTE: Possibly just switch back to the if statement below. 124 | // none, shift, ctrl, alt 125 | // gnome: 32, 36, 48, 40 126 | // xterm: 35, _, 51, _ 127 | // urxvt: 35, _, _, _ 128 | // if (key.action === 'mousedown' && key.button === 'unknown') { 129 | if (b === 35 || b === 39 || b === 51 || b === 43 130 | || (state.isVTE && (b === 32 || b === 36 || b === 48 || b === 40))) { 131 | delete key.button; 132 | key.action = 'mousemove'; 133 | } 134 | 135 | key.sequence = s; 136 | return key; 137 | } 138 | 139 | // URxvt 140 | if ((parts = /^\x1b\[(\d+;\d+;\d+)M/.exec(s))) { 141 | params = parts[1].split(';'); 142 | b = Number(params[0]); 143 | x = Number(params[1]); 144 | y = Number(params[2]); 145 | 146 | key.name = 'mouse'; 147 | key.type = 'urxvt'; 148 | 149 | key.raw = [b, x, y, parts[0]]; 150 | key.buf = buf; 151 | key.x = x; 152 | key.y = y; 153 | 154 | if (state.zero) { 155 | key.x--; 156 | key.y--; 157 | } 158 | 159 | mod = b >> 2; 160 | key.shift = Boolean(mod & 1); 161 | key.meta = Boolean((mod >> 1) & 1); 162 | key.ctrl = Boolean((mod >> 2) & 1); 163 | 164 | // XXX Bug in urxvt after wheelup/down on mousemove 165 | // NOTE: This may be different than 128/129 depending 166 | // on mod keys. 167 | if (b === 128 || b === 129) b = 67; 168 | b -= 32; 169 | 170 | if ((b >> 6) & 1) { 171 | key.action = b & 1 ? 'wheeldown' : 'wheelup'; 172 | key.button = 'middle'; 173 | } else if (b === 3) { 174 | // NOTE: x10 and urxvt have no way 175 | // of telling which button mouseup used. 176 | key.action = 'mouseup'; 177 | key.button = state._lastButton || 'unknown'; 178 | delete state._lastButton; 179 | } else { 180 | key.action = 'mousedown'; 181 | button = b & 3; 182 | key.button = button === 0 ? 'left' 183 | : button === 1 ? 'middle' 184 | : button === 2 ? 'right' 185 | : 'unknown'; 186 | 187 | // NOTE: 0/32 = mousemove, 32/64 = mousemove with left down 188 | // if ((b >> 1) === 32) 189 | state._lastButton = key.button; 190 | } 191 | 192 | // Probably a movement. 193 | // The *newer* VTE gets mouse movements comepletely wrong. 194 | // This presents a problem: older versions of VTE that get it right might 195 | // be confused by the second conditional in the if statement. 196 | // NOTE: Possibly just switch back to the if statement below. 197 | // none, shift, ctrl, alt 198 | // urxvt: 35, _, _, _ 199 | // gnome: 32, 36, 48, 40 200 | // if (key.action === 'mousedown' && key.button === 'unknown') { 201 | if (b === 35 || b === 39 || b === 51 || b === 43 202 | || (state.isVTE && (b === 32 || b === 36 || b === 48 || b === 40))) { 203 | delete key.button; 204 | key.action = 'mousemove'; 205 | } 206 | 207 | key.sequence = s; 208 | return key; 209 | } 210 | 211 | // SGR 212 | if ((parts = /^\x1b\[<(\d+;\d+;\d+)([mM])/.exec(s))) { 213 | down = parts[2] === 'M'; 214 | params = parts[1].split(';'); 215 | b = Number(params[0]); 216 | x = Number(params[1]); 217 | y = Number(params[2]); 218 | 219 | key.name = 'mouse'; 220 | key.type = 'sgr'; 221 | 222 | key.raw = [b, x, y, parts[0]]; 223 | key.buf = buf; 224 | key.x = x; 225 | key.y = y; 226 | 227 | if (state.zero) { 228 | key.x--; 229 | key.y--; 230 | } 231 | 232 | mod = b >> 2; 233 | key.shift = Boolean(mod & 1); 234 | key.meta = Boolean((mod >> 1) & 1); 235 | key.ctrl = Boolean((mod >> 2) & 1); 236 | 237 | if ((b >> 6) & 1) { 238 | key.action = b & 1 ? 'wheeldown' : 'wheelup'; 239 | key.button = 'middle'; 240 | } else { 241 | key.action = down ? 'mousedown' : 'mouseup'; 242 | button = b & 3; 243 | key.button = button === 0 244 | ? 'left' : button === 1 245 | ? 'middle' : button === 2 246 | ? 'right' : 'unknown'; 247 | } 248 | 249 | // Probably a movement. 250 | // The *newer* VTE gets mouse movements comepletely wrong. 251 | // This presents a problem: older versions of VTE that get it right might 252 | // be confused by the second conditional in the if statement. 253 | // NOTE: Possibly just switch back to the if statement below. 254 | // none, shift, ctrl, alt 255 | // xterm: 35, _, 51, _ 256 | // gnome: 32, 36, 48, 40 257 | // if (key.action === 'mousedown' && key.button === 'unknown') { 258 | if (b === 35 || b === 39 || b === 51 || b === 43 259 | || (state.isVTE && (b === 32 || b === 36 || b === 48 || b === 40))) { 260 | delete key.button; 261 | key.action = 'mousemove'; 262 | } 263 | 264 | key.sequence = s; 265 | return key; 266 | } 267 | 268 | // DEC 269 | // The xterm mouse documentation says there is a 270 | // `<` prefix, the DECRQLP says there is no prefix. 271 | if ((parts = /^\x1b\[<(\d+;\d+;\d+;\d+)&w/.exec(s))) { 272 | params = parts[1].split(';'); 273 | b = Number(params[0]); 274 | x = Number(params[1]); 275 | y = Number(params[2]); 276 | page = Number(params[3]); 277 | 278 | key.name = 'mouse'; 279 | key.type = 'dec'; 280 | 281 | key.raw = [b, x, y, parts[0]]; 282 | key.buf = buf; 283 | key.x = x; 284 | key.y = y; 285 | key.page = page; 286 | 287 | if (state.zero) { 288 | key.x--; 289 | key.y--; 290 | } 291 | 292 | key.action = b === 3 ? 'mouseup' : 'mousedown'; 293 | key.button = b === 2 294 | ? 'left' : b === 4 295 | ? 'middle' : b === 6 296 | ? 'right' : 'unknown'; 297 | 298 | key.sequence = s; 299 | return key; 300 | } 301 | 302 | // vt300 303 | if ((parts = /^\x1b\[24([0135])~\[(\d+),(\d+)\]\r/.exec(s))) { 304 | b = Number(parts[1]); 305 | x = Number(parts[2]); 306 | y = Number(parts[3]); 307 | 308 | key.name = 'mouse'; 309 | key.type = 'vt300'; 310 | 311 | key.raw = [b, x, y, parts[0]]; 312 | key.buf = buf; 313 | key.x = x; 314 | key.y = y; 315 | 316 | if (state.zero) { 317 | key.x--; 318 | key.y--; 319 | } 320 | 321 | key.action = 'mousedown'; 322 | key.button = b === 1 ? 'left' : b === 2 ? 'middle' : b === 5 ? 'right' : 'unknown'; 323 | 324 | key.sequence = s; 325 | return key; 326 | } 327 | 328 | if ((parts = /^\x1b\[(O|I)/.exec(s))) { 329 | key.action = parts[1] === 'I' ? 'focus' : 'blur'; 330 | } 331 | 332 | // s = key.sequence; 333 | // b = s.charCodeAt(3); 334 | key.x = s.charCodeAt(4) - 0o040; 335 | key.y = s.charCodeAt(5) - 0o040; 336 | 337 | key.scroll = 0; 338 | 339 | key.ctrl = Boolean(1 << 4 & b); 340 | key.meta = Boolean(1 << 3 & b); 341 | key.shift = Boolean(1 << 2 & b); 342 | 343 | key.release = (3 & b) === 3; 344 | 345 | if (1 << 6 & b) { //scroll 346 | key.scroll = 1 & b ? 1 : -1; 347 | } 348 | 349 | if (!key.release && !key.scroll) { 350 | key.button = b & 3; 351 | } 352 | 353 | key.sequence = s; 354 | return key; 355 | }; 356 | -------------------------------------------------------------------------------- /examples/select.ts: -------------------------------------------------------------------------------- 1 | import type { Key } from 'node:readline'; 2 | import colors from 'ansi-colors'; 3 | import { emitKeypress } from '../index'; 4 | import { keycodes } from '~/keycodes'; 5 | 6 | const { cyan, dim } = colors; 7 | 8 | const options = { 9 | type: 'autocomplete', 10 | name: 'fruit', 11 | message: 'Select an item', 12 | maxVisible: 5, 13 | nodes: [ 14 | { name: 'apple' }, 15 | { name: 'orange' }, 16 | { name: 'banana' }, 17 | { name: 'pear' }, 18 | { name: 'kiwi' }, 19 | { name: 'strawberry' }, 20 | { name: 'grape' }, 21 | { name: 'watermelon' }, 22 | { name: 'blueberry' }, 23 | { name: 'mango' }, 24 | { name: 'pineapple' }, 25 | { name: 'cherry' }, 26 | { name: 'peach' }, 27 | { name: 'blackberry' }, 28 | { name: 'apricot' }, 29 | { name: 'papaya' }, 30 | { name: 'cantaloupe' }, 31 | { name: 'honeydew' } 32 | // { name: 'dragonfruit' }, 33 | // { name: 'lychee' }, 34 | // { name: 'pomegranate' }, 35 | // { name: 'fig' }, 36 | // { name: 'date' }, 37 | // { name: 'jackfruit' }, 38 | // { name: 'passionfruit' }, 39 | // { name: 'tangerine' } 40 | ] 41 | }; 42 | 43 | class Choice { 44 | name: string; 45 | disabled: boolean; 46 | 47 | constructor(options: { name: string; disabled?: boolean }, parent: Select) { 48 | this.parent = parent; 49 | this.name = options.name; 50 | this.disabled = options.disabled || false; 51 | } 52 | 53 | pointer() { 54 | return this.active ? cyan('❯') : ' '; 55 | } 56 | 57 | message() { 58 | const color = this.active ? cyan.underline : v => v; 59 | return this.disabled ? dim(`${this.name} (disabled)`) : color(this.name); 60 | } 61 | 62 | render() { 63 | return this.pointer() + ' ' + this.message(); 64 | } 65 | 66 | isFocusable() { 67 | return !this.disabled; 68 | } 69 | 70 | get active() { 71 | return this.parent.focused === this; 72 | } 73 | } 74 | 75 | class Select { 76 | private nodes: Choice[]; 77 | private maxVisible: number; 78 | private adjustment: number; 79 | private offset: number; 80 | private index: number; 81 | 82 | constructor(options: { nodes: { name: string; disabled?: boolean }[] }) { 83 | this.options = { cycle: true, scroll: true, ...options }; 84 | this.state = { 85 | collapsed: false, 86 | maxVisible: options.maxVisible || 7, 87 | adjustment: 0, 88 | offset: 0, 89 | index: options.index || 0, 90 | append: [] 91 | }; 92 | 93 | this.nodes = options.nodes.map(item => new Choice(item, this)); 94 | 95 | if (!this.nodes.some(item => item.isFocusable())) { 96 | throw new Error('At least one item must be focusable'); 97 | } 98 | 99 | if (!this.focused.isFocusable()) { 100 | this.down(); 101 | } 102 | } 103 | 104 | indicator() { 105 | // const indicator = this.isCollapsible() ? this.state.collapsed ? '+' : '-' : ''; 106 | } 107 | 108 | footer() { 109 | const output = []; 110 | const start = this.state.offset + 1; 111 | const end = Math.min(this.state.offset + this.pageSize, this.nodes.length); 112 | const remaining = Math.max(0, this.nodes.length - this.pageSize) - this.state.offset; 113 | 114 | output.push(''); 115 | output.push(['', 'Showing', start, 'to', end, 'of', this.nodes.length].join(' ')); 116 | output.push(['', 'Offset:', this.state.offset].join(' ')); 117 | output.push(['', 'Adjustment:', this.state.adjustment].join(' ')); 118 | output.push(['', 'Remaining Items:', remaining].join(' ')); 119 | return output.join('\n'); 120 | } 121 | 122 | async render() { 123 | const output = [this.options.message]; 124 | const items = this.visible(); 125 | 126 | for (const item of items) { 127 | output.push(item.render()); 128 | } 129 | 130 | output.push(this.footer()); 131 | output.push(''); 132 | output.push(this.state.append.join('\n')); 133 | this.state.append = []; 134 | return output.join('\n'); 135 | } 136 | 137 | dispatch(key: Key) { 138 | if (this[key.command]) { 139 | this[key.command](key); 140 | return true; 141 | } 142 | 143 | if (this[key.name]) { 144 | this[key.name](key); 145 | return true; 146 | } 147 | 148 | this.alert(); 149 | return false; 150 | } 151 | 152 | toggle() { 153 | if (this.isCollapsible()) { 154 | this.state.collapsed = !this.state.collapsed; 155 | } else { 156 | this.alert(); 157 | } 158 | } 159 | 160 | collapse() { 161 | this.state.collapsed = true; 162 | } 163 | 164 | expand() { 165 | this.state.collapsed = false; 166 | } 167 | 168 | focus(index: number) { 169 | this.state.index = Math.max(0, Math.min(index, this.nodes.length - 1)); 170 | } 171 | 172 | home() { 173 | this.state.offset = 0; 174 | this.first(); 175 | } 176 | 177 | end() { 178 | this.state.offset = Math.max(0, this.nodes.length - this.pageSize); 179 | this.last(); 180 | } 181 | 182 | first() { 183 | this.state.index = 0; 184 | 185 | if (!this.focused.isFocusable()) { 186 | this.down(); 187 | } 188 | } 189 | 190 | last() { 191 | this.state.index = this.pageSize - 1; 192 | 193 | if (!this.focused.isFocusable()) { 194 | this.up(); 195 | } 196 | } 197 | 198 | up(key: Key) { 199 | if (this.state.index > 0) { 200 | this.state.index--; 201 | } else if (this.isScrollable()) { 202 | this.cycle_up(key); 203 | } else { 204 | this.scroll_up(key); 205 | } 206 | 207 | if (!this.focused.isFocusable()) { 208 | this.up(); 209 | } 210 | } 211 | 212 | down(key: Key) { 213 | if (this.state.index < this.pageSize - 1) { 214 | this.state.index++; 215 | } else if (this.isScrollable()) { 216 | this.cycle_down(key); 217 | } else { 218 | this.scroll_down(key); 219 | } 220 | 221 | if (!this.focused.isFocusable()) { 222 | this.down(); 223 | } 224 | } 225 | 226 | cycle_up(key: Key) { 227 | if (this.options.cycle !== false) { 228 | this.state.append.push('cycle_up'); 229 | this.state.index--; 230 | 231 | if (this.state.index < 0) { 232 | this.last(key); 233 | 234 | } else if (!this.focused?.isFocusable()) { 235 | this.cycle_up(key); 236 | } 237 | } else { 238 | this.alert(); 239 | } 240 | } 241 | 242 | cycle_down(key: Key) { 243 | if (this.options.cycle !== false) { 244 | this.state.append.push('cycle_down'); 245 | this.state.index++; 246 | 247 | if (this.state.index > this.pageSize - 1) { 248 | this.first(key); 249 | 250 | } else if (!this.focused?.isFocusable()) { 251 | this.cycle_down(key); 252 | } 253 | } else { 254 | this.alert(); 255 | } 256 | } 257 | 258 | scroll_up(key: Key, n: number = 1) { 259 | if (this.options.scroll !== false) { 260 | this.state.append.push('scroll_up'); 261 | this.state.offset = (this.state.offset - n + this.nodes.length) % this.nodes.length; 262 | 263 | if (!this.focused?.isFocusable()) { 264 | this.scroll_up(key); 265 | } 266 | } else { 267 | this.alert(); 268 | } 269 | } 270 | 271 | scroll_down(key: Key, n: number = 1) { 272 | if (this.options.scroll !== false) { 273 | this.state.append.push('scroll_down'); 274 | this.state.offset = (this.state.offset + n) % this.nodes.length; 275 | 276 | if (!this.focused?.isFocusable()) { 277 | this.scroll_down(key); 278 | } 279 | } else { 280 | this.alert(); 281 | } 282 | } 283 | 284 | page_left() { 285 | if (this.state.offset > 0) { 286 | this.scroll_up(null, Math.min(this.pageSize, this.state.offset)); 287 | } else { 288 | this.alert(); 289 | } 290 | } 291 | 292 | page_right() { 293 | const remaining = Math.max(0, this.nodes.length - (this.state.offset + this.pageSize)); 294 | const offset = this.nodes.length - remaining; 295 | // const offset = Math.min(this.pageSize, remaining); 296 | // this.state.append.push('offset: ' + offset); 297 | 298 | // if (offset === 0) { 299 | // this.alert(); 300 | // } else { 301 | // this.state.offset = offset; 302 | // } 303 | 304 | // const remaining = Math.max(0, this.nodes.length - this.pageSize) - this.state.offset; 305 | if (offset > 0) { 306 | this.scroll_down(null, Math.min(this.pageSize, offset)); 307 | } else { 308 | this.alert(); 309 | } 310 | } 311 | 312 | show_fewer() { 313 | if (this.pageSize > 1) { 314 | this.state.adjustment--; 315 | } 316 | 317 | if (this.state.index >= this.pageSize) { 318 | this.state.index = this.pageSize - 1; 319 | } 320 | 321 | if (!this.focused.isFocusable()) { 322 | this.down(); 323 | } 324 | } 325 | 326 | show_more() { 327 | if (this.pageSize < this.nodes.length) { 328 | this.state.adjustment++; 329 | } 330 | } 331 | 332 | visible() { 333 | const nodes = []; 334 | 335 | for (let i = 0; i < this.pageSize; i++) { 336 | nodes.push(this.nodes[(this.state.offset + i) % this.nodes.length]); 337 | } 338 | 339 | return nodes; 340 | } 341 | 342 | isCollapsible() { 343 | return this.options.collapsible !== false && this.nodes.some(item => item.disabled); 344 | } 345 | 346 | isScrollable() { 347 | return this.options.cycle !== false && this.pageSize > this.nodes.length - 1; 348 | } 349 | 350 | isFocusable() { 351 | return this.focused.disabled !== true; 352 | } 353 | 354 | alert() { 355 | process.stdout.write('\u0007'); 356 | } 357 | 358 | get range(): [number, number] { 359 | return [ 360 | this.state.offset, 361 | (this.state.offset + this.pageSize - 1) % this.nodes.length 362 | ]; 363 | } 364 | 365 | get pageSize() { 366 | return Math.max(1, Math.min(this.state.adjustment + this.state.maxVisible, this.nodes.length)); 367 | } 368 | 369 | get focused() { 370 | return this.nodes[(this.state.offset + this.state.index) % this.nodes.length]; 371 | } 372 | } 373 | 374 | console.clear(); 375 | const prompt = new Select(options); 376 | 377 | const print = (output: string = '') => { 378 | console.clear(); 379 | console.log(output); 380 | }; 381 | 382 | prompt.render().then(v => print(v)); 383 | 384 | emitKeypress({ 385 | enableMouseEvents: true, 386 | hideCursor: true, 387 | keymap: [ 388 | ...keycodes, 389 | { shortcut: 'meta+up', command: 'show_fewer', weight: 1 }, 390 | { shortcut: 'meta+down', command: 'show_more', weight: 1 }, 391 | { shortcut: 'meta+b', command: 'page_left', weight: 1 }, 392 | { shortcut: 'meta+f', command: 'page_right', weight: 1 }, 393 | { shortcut: 'return', command: 'submit', weight: 1 }, 394 | { shortcut: 'ctrl+c', command: 'cancel', weight: 1 }, 395 | { shortcut: 'space', command: 'toggle', weight: 1 } 396 | ], 397 | onKeypress: async (input, key, close) => { 398 | if (prompt.dispatch(key)) { 399 | print(await prompt.render()); 400 | return; 401 | } 402 | 403 | if (key.shortcut === 'ctrl+c') { 404 | prompt.canceled = true; 405 | print(await prompt.render()); 406 | close(); 407 | } 408 | 409 | if (input === '\r') { 410 | prompt.submitted = true; 411 | print(await prompt.render()); 412 | await close(); 413 | 414 | console.log(prompt.focused.name); 415 | } 416 | } 417 | }); 418 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-control-regex */ 2 | import type readline from 'node:readline'; 3 | import { stdin, stdout } from 'node:process'; 4 | import { detectTerminal } from 'detect-terminal'; 5 | import { emitKeypressEvents } from '~/emit-keypress'; 6 | import { mousepress } from '~/mousepress'; 7 | import { keycodes } from '~/keycodes'; 8 | import { kEscape } from '~/keypress'; 9 | import { enableKeyboardProtocol, resetKeyboardProtocol } from '~/keyboard-protocol'; 10 | import { 11 | createShortcut, 12 | isMousepress, 13 | isPrintableCharacter, 14 | parsePosition, 15 | prioritizeKeymap, 16 | sortShortcutModifier 17 | } from '~/utils'; 18 | 19 | export * from '~/utils'; 20 | 21 | export const isWindows = globalThis.process.platform === 'win32'; 22 | export const MAX_PASTE_BUFFER = 1024 * 1024; // 1MB limit for paste buffer 23 | export const ENABLE_PASTE_BRACKET_MODE = `${kEscape}[?2004h`; 24 | export const DISABLE_PASTE_BRACKET_MODE = `${kEscape}[?2004l`; 25 | export const ENABLE_MOUSE = `${kEscape}[?1003h`; 26 | export const DISABLE_MOUSE = `${kEscape}[?1003l`; 27 | 28 | export const enablePaste = (stdout: NodeJS.WriteStream) => { 29 | stdout.write(ENABLE_PASTE_BRACKET_MODE); 30 | }; 31 | 32 | export const disablePaste = (stdout: NodeJS.WriteStream) => { 33 | stdout.write(DISABLE_PASTE_BRACKET_MODE); 34 | }; 35 | 36 | export const enableMouse = (stdout: NodeJS.WriteStream) => { 37 | stdout.write(ENABLE_MOUSE); 38 | }; 39 | 40 | export const disableMouse = (stdout: NodeJS.WriteStream) => { 41 | stdout.write(DISABLE_MOUSE); 42 | }; 43 | 44 | export const cursor = { 45 | hide: (stdout: NodeJS.WriteStream) => { 46 | stdout.write(`${kEscape}[?25l`); 47 | }, 48 | show: (stdout: NodeJS.WriteStream) => { 49 | stdout.write(`${kEscape}[?25h`); 50 | }, 51 | position: (stdout: NodeJS.WriteStream) => { 52 | stdout.write(`${kEscape}[6n`); 53 | } 54 | }; 55 | 56 | export const hasMatchingModifiers = (a: readline.Key, b) => { 57 | return ( 58 | (!hasModifier(a) && !hasModifier(b)) || 59 | (a.ctrl === b.ctrl && a.shift === b.shift && a.meta === b.meta && a.fn === b.fn) 60 | ); 61 | }; 62 | 63 | export const hasModifier = (key: readline.Key) => { 64 | return key.ctrl || key.shift || key.meta || key.fn; 65 | }; 66 | 67 | export const createEmitKeypress = (config?: { setupProcessHandlers?: boolean }) => { 68 | const sessionCounts = new WeakMap(); 69 | 70 | function acquireInput(input) { 71 | sessionCounts.set(input, (sessionCounts.get(input) || 0) + 1); 72 | } 73 | 74 | function releaseInput(input) { 75 | const count = sessionCounts.get(input) || 0; 76 | if (count > 1) { 77 | sessionCounts.set(input, count - 1); 78 | } else { 79 | sessionCounts.delete(input); 80 | input.pause(); // actually pause only when last session closes 81 | } 82 | } 83 | 84 | // If this is the singleton, use the (possibly global) handlers array 85 | const setupProcessHandlers = config?.setupProcessHandlers === true; 86 | let onExitHandlers: Set<() => void>; 87 | 88 | // If not explicitly told to skip, AND we are the singleton (first created), 89 | // use the process global array 90 | if (setupProcessHandlers || !config) { 91 | // Use process-global handlers for the singleton instance only 92 | onExitHandlers = globalThis.onExitHandlers ||= new Set(); 93 | 94 | if (!globalThis.exitHandlers) { 95 | globalThis.exitHandlers = onExitHandlers; 96 | } 97 | 98 | const hasListener = (name, fn) => { 99 | return process.listeners(name).includes(fn); 100 | }; 101 | 102 | // Register process listeners ONLY ONCE (singleton) 103 | if (!hasListener('uncaughtException', onExitHandler)) { 104 | process.once('uncaughtException', onExitHandler); 105 | } 106 | 107 | if (!hasListener('SIGINT', onExitHandler)) { 108 | process.once('SIGINT', onExitHandler); 109 | } 110 | 111 | if (!hasListener('exit', onExitHandler)) { 112 | process.once('exit', onExitHandler); 113 | } 114 | 115 | } else { 116 | // For non-singleton, just use a local handlers array 117 | onExitHandlers = new Set(); 118 | } 119 | 120 | function onExitHandler() { 121 | for (const fn of onExitHandlers) { 122 | try { 123 | fn(); 124 | onExitHandlers.delete(fn); 125 | } catch (err) { 126 | console.error('Error in exit handler:', err); 127 | } 128 | } 129 | } 130 | 131 | const emitKeypress = ({ 132 | input = stdin, 133 | output = stdout, 134 | keymap = [], 135 | onKeypress, 136 | onMousepress, 137 | onExit, 138 | maxPasteBuffer = MAX_PASTE_BUFFER, 139 | escapeCodeTimeout = 500, 140 | handleClose = true, 141 | hideCursor = false, 142 | initialPosition = false, 143 | enablePasteMode = false, 144 | pasteModeTimeout = 100, 145 | keyboardProtocol = false 146 | }: { 147 | // eslint-disable-next-line no-undef 148 | input?: NodeJS.ReadStream; 149 | output?: NodeJS.WriteStream; 150 | keymap?: Array<{ sequence: string; shortcut: string }>; 151 | // eslint-disable-next-line no-unused-vars 152 | onKeypress: (input: string, key: readline.Key, close: () => void) => void; 153 | // eslint-disable-next-line no-unused-vars 154 | onMousepress?: (input: string, key: any, close: () => void) => void; 155 | onExit?: () => void; 156 | maxPasteBuffer?: number; 157 | escapeCodeTimeout?: number; 158 | handleClose?: boolean; 159 | hideCursor?: boolean; 160 | initialPosition?: boolean; 161 | enablePasteMode?: boolean; 162 | pasteModeTimeout?: number; 163 | keyboardProtocol?: boolean; 164 | }) => { 165 | if (!input || (input !== process.stdin && !input.isTTY)) { 166 | throw new Error('Invalid stream passed'); 167 | } 168 | 169 | const isRaw = input.isRaw; 170 | let closed = false; 171 | let pasting = false; 172 | let initial = true; 173 | let sorted = false; 174 | let buffer = ''; 175 | let pasteTimeout: NodeJS.Timeout | null = null; 176 | let disableProtocol: (() => void) | null = null; 177 | 178 | const clearPasteState = () => { 179 | pasting = false; 180 | buffer = ''; 181 | 182 | if (pasteTimeout) { 183 | clearTimeout(pasteTimeout); 184 | pasteTimeout = null; 185 | } 186 | }; 187 | 188 | if (typeof keymap === 'function') { 189 | keymap = keymap(); 190 | } 191 | 192 | // eslint-disable-next-line complexity 193 | async function handleKeypress(input: string, key: readline.Key) { 194 | if (initialPosition && initial && key.name === 'position') { 195 | const parsed = parsePosition(key.sequence); 196 | if (parsed) { 197 | initial = false; 198 | onKeypress('', parsed, close); 199 | return; 200 | } 201 | } 202 | 203 | if (key.name === 'paste-start' || /\x1B\[200~/.test(key.sequence)) { 204 | clearPasteState(); 205 | pasting = true; 206 | pasteTimeout = setTimeout(clearPasteState, pasteModeTimeout); 207 | return; 208 | } 209 | 210 | if (key.name === 'paste-end' || /\x1B\[201~/.test(key.sequence)) { 211 | clearTimeout(pasteTimeout); 212 | pasteTimeout = null; 213 | 214 | if (pasting) { 215 | key.name = 'paste'; 216 | key.sequence = buffer.replace(/\x1B\[201~/g, ''); 217 | key.ctrl = false; 218 | key.shift = false; 219 | key.meta = false; 220 | key.fn = false; 221 | key.printable = true; 222 | onKeypress(buffer, key, close); 223 | clearPasteState(); 224 | } 225 | return; 226 | } 227 | 228 | if (pasting) { 229 | if (buffer.length < maxPasteBuffer) { 230 | buffer += key.sequence?.replace(/\r/g, '\n') || ''; 231 | } 232 | 233 | // Ignore any more characters, but don't clear state yet! 234 | return; 235 | } 236 | 237 | if (!buffer && isMousepress(key.sequence, key)) { 238 | const k = mousepress(key.sequence, Buffer.from(key.sequence)); 239 | k.mouse = true; 240 | onMousepress?.(k, close); 241 | } else { 242 | let addShortcut = false; 243 | 244 | if (!sorted) { 245 | keymap = prioritizeKeymap(keymap); 246 | sorted = true; 247 | } 248 | 249 | const found = keymap.filter(k => k.sequence === key.sequence); 250 | 251 | if (found.length === 1) { 252 | key = { ...key, ...found[0] }; 253 | addShortcut = false; 254 | } 255 | 256 | // console.log({ found, key }); 257 | 258 | const shortcut = key.shortcut 259 | ? sortShortcutModifier(key.shortcut) 260 | : createShortcut(key); 261 | 262 | if (!key.shortcut && hasModifier(key)) { 263 | key.shortcut = shortcut; 264 | } 265 | 266 | for (const mapping of keymap) { 267 | if (mapping.sequence) { 268 | if (key.sequence === mapping.sequence && hasMatchingModifiers(key, mapping)) { 269 | key = { ...key, ...mapping }; 270 | addShortcut = false; 271 | break; 272 | } 273 | 274 | continue; 275 | } 276 | 277 | // Only continue comparison if the custom key mapping does not have a sequence 278 | if ( 279 | shortcut === mapping.shortcut || 280 | (key.name && key.name === mapping.shortcut && hasMatchingModifiers(key, mapping)) 281 | ) { 282 | key = { ...key, ...mapping }; 283 | addShortcut = false; 284 | break; 285 | } 286 | } 287 | 288 | if (/^f[0-9]+$/.test(key.name)) { 289 | addShortcut = true; 290 | } 291 | 292 | if (addShortcut) { 293 | key.shortcut ||= shortcut; 294 | } 295 | 296 | key.printable ||= isPrintableCharacter(key.sequence); 297 | onKeypress(key.sequence, key, close); 298 | } 299 | } 300 | 301 | acquireInput(input); 302 | 303 | function close() { 304 | if (closed) return; 305 | closed = true; 306 | 307 | onExitHandlers.delete(close); 308 | if (!isWindows && input.isTTY) input.setRawMode(isRaw); 309 | if (hideCursor) cursor.show(output); 310 | if (onMousepress) disableMouse(output); 311 | if (enablePasteMode) disablePaste(output); 312 | if (disableProtocol) disableProtocol(); 313 | if (onKeypress) input.off('keypress', handleKeypress); 314 | if (pasteTimeout) clearTimeout(pasteTimeout); 315 | input.off('pause', close); 316 | releaseInput(input); 317 | onExit?.(); 318 | } 319 | 320 | emitKeypressEvents(input, { escapeCodeTimeout }); 321 | 322 | if (onMousepress) { 323 | enableMouse(output); 324 | } 325 | 326 | if (enablePasteMode === true) { 327 | enablePaste(output); 328 | } 329 | 330 | resetKeyboardProtocol(output); 331 | 332 | if (keyboardProtocol) { 333 | disableProtocol = enableKeyboardProtocol(detectTerminal(), output); 334 | } 335 | 336 | // Disable automatic character echoing 337 | if (!isWindows && input.isTTY) input.setRawMode(true); 338 | if (hideCursor) cursor.hide(output); 339 | if (onKeypress) input.on('keypress', handleKeypress); 340 | 341 | input.setEncoding('utf8'); 342 | input.once('pause', close); 343 | input.resume(); 344 | 345 | if (initialPosition) { 346 | cursor.position(output); 347 | } 348 | 349 | if (handleClose !== false && !onExitHandlers.has(close)) { 350 | onExitHandlers.add(close); 351 | } 352 | 353 | return close; 354 | }; 355 | 356 | return { 357 | emitKeypress, 358 | onExitHandlers 359 | }; 360 | }; 361 | 362 | export declare global { 363 | var onExitHandlers: Set<() => void>; // eslint-disable-line no-var 364 | var exitHandlers: Array<() => void>; // eslint-disable-line no-var 365 | } 366 | 367 | export const { emitKeypress } = createEmitKeypress(); 368 | 369 | export { 370 | createShortcut, 371 | emitKeypressEvents, 372 | isMousepress, 373 | isPrintableCharacter, 374 | keycodes, 375 | mousepress 376 | }; 377 | 378 | export default emitKeypress; 379 | -------------------------------------------------------------------------------- /examples/select-mvc.ts: -------------------------------------------------------------------------------- 1 | import type { Key } from 'node:readline'; 2 | import colors from 'ansi-colors'; 3 | import { emitKeypress } from '../index'; 4 | import { keycodes } from '~/keycodes'; 5 | 6 | const { cyan, dim } = colors; 7 | 8 | export const options = { 9 | type: 'autocomplete', 10 | name: 'fruit', 11 | message: 'Select an item', 12 | maxVisible: 5, 13 | nodes: [ 14 | { name: 'apple' }, 15 | { name: 'orange' }, 16 | { name: 'banana' }, 17 | { name: 'pear' }, 18 | { name: 'kiwi' }, 19 | { name: 'strawberry' }, 20 | { name: 'grape' }, 21 | { name: 'watermelon' }, 22 | { name: 'blueberry' }, 23 | { name: 'mango' }, 24 | { name: 'pineapple' }, 25 | { name: 'cherry' }, 26 | { name: 'peach' }, 27 | { name: 'blackberry' }, 28 | { name: 'apricot' }, 29 | { name: 'papaya' }, 30 | { name: 'cantaloupe' }, 31 | { name: 'honeydew' } 32 | // { name: 'dragonfruit' }, 33 | // { name: 'lychee' }, 34 | // { name: 'pomegranate' }, 35 | // { name: 'fig' }, 36 | // { name: 'date' }, 37 | // { name: 'jackfruit' }, 38 | // { name: 'passionfruit' }, 39 | // { name: 'tangerine' } 40 | ] 41 | }; 42 | 43 | export class Choice { 44 | name: string; 45 | disabled: boolean; 46 | parent: Select; 47 | 48 | constructor(options: { name: string; disabled?: boolean }, parent: Select) { 49 | this.parent = parent; 50 | this.name = options.name; 51 | this.disabled = options.disabled || false; 52 | } 53 | 54 | pointer() { 55 | return this.active ? cyan('❯') : ' '; 56 | } 57 | 58 | message() { 59 | const color = this.active ? cyan.underline : v => v; 60 | return this.disabled ? dim(`${this.name} (disabled)`) : color(this.name); 61 | } 62 | 63 | render() { 64 | return this.pointer() + ' ' + this.message(); 65 | } 66 | 67 | isFocusable() { 68 | return !this.disabled; 69 | } 70 | 71 | get active() { 72 | return this.parent.focused === this; 73 | } 74 | } 75 | 76 | export class SelectModel { 77 | static Option = Choice; 78 | 79 | constructor( 80 | options: { 81 | nodes: { name: string; disabled?: boolean }[]; 82 | maxVisible?: number; 83 | index?: number; 84 | }, 85 | parent: Select 86 | ) { 87 | this.options = { cycle: true, scroll: true, ...options }; 88 | this.collapsed = false; 89 | this.maxVisible = options.maxVisible || 7; 90 | this.index = options.index || 0; 91 | this.nodes = options.nodes.map(item => new SelectModel.Option(item, parent)); 92 | this.append = []; 93 | this.adjustment = 0; 94 | this.offset = 0; 95 | 96 | if (!this.nodes.some(item => item.isFocusable())) { 97 | throw new Error('At least one item must be focusable'); 98 | } 99 | } 100 | 101 | visible() { 102 | const nodes = []; 103 | 104 | for (let i = 0; i < this.pageSize; i++) { 105 | nodes.push(this.nodes[(this.offset + i) % this.nodes.length]); 106 | } 107 | 108 | return nodes; 109 | } 110 | 111 | isScrollable() { 112 | return this.options.cycle !== false && this.pageSize > this.nodes.length - 1; 113 | } 114 | 115 | isCollapsible() { 116 | return this.options.collapsible !== false && this.nodes.some(item => item.disabled); 117 | } 118 | 119 | get pageSize() { 120 | return Math.max(1, Math.min(this.adjustment + this.maxVisible, this.nodes.length)); 121 | } 122 | 123 | get focused() { 124 | return this.nodes[(this.offset + this.index) % this.nodes.length]; 125 | } 126 | 127 | get range(): [number, number] { 128 | return [this.offset, (this.offset + this.pageSize - 1) % this.nodes.length]; 129 | } 130 | } 131 | 132 | export class SelectView { 133 | private control: Select; 134 | private model: SelectModel; 135 | 136 | constructor(control: Select) { 137 | this.control = control; 138 | this.model = this.control.model; 139 | } 140 | 141 | header() { 142 | return this.model.options.message; 143 | } 144 | 145 | body() { 146 | const items = this.model.visible(); 147 | return items.map(item => item.render()).join('\n'); 148 | } 149 | 150 | footer() { 151 | const output = []; 152 | const start = this.model.offset + 1; 153 | const end = Math.min( 154 | this.model.offset + this.model.pageSize, 155 | this.model.nodes.length 156 | ); 157 | 158 | const remaining = Math.max(0, this.model.nodes.length - this.model.pageSize) - this.model.offset; 159 | 160 | output.push(''); 161 | output.push(['', 'Showing', start, 'to', end, 'of', this.model.nodes.length].join(' ')); 162 | output.push(['', 'Offset:', this.model.offset].join(' ')); 163 | output.push(['', 'Adjustment:', this.model.adjustment].join(' ')); 164 | output.push(['', 'Remaining Items:', remaining].join(' ')); 165 | return output.join('\n'); 166 | } 167 | 168 | input() { 169 | return this.model.append.join('\n'); 170 | } 171 | 172 | render() { 173 | const output = [this.header()]; 174 | output.push(this.body()); 175 | output.push(this.footer()); 176 | output.push(''); 177 | output.push(this.input()); 178 | this.model.append = []; 179 | return output.join('\n'); 180 | } 181 | } 182 | 183 | export class Select { 184 | model: SelectModel; 185 | view: SelectView; 186 | canceled?: boolean; 187 | submitted?: boolean; 188 | 189 | constructor(options: { 190 | nodes: { name: string; disabled?: boolean }[]; 191 | maxVisible?: number; 192 | index?: number; 193 | }) { 194 | this.model = new SelectModel(options, this); 195 | this.view = new SelectView(this); 196 | 197 | if (!this.focused.isFocusable()) { 198 | this.down(); 199 | } 200 | } 201 | 202 | dispatch(key: Key) { 203 | if (this[key.command]) { 204 | this[key.command](key); 205 | return true; 206 | } 207 | 208 | if (this[key.name]) { 209 | this[key.name](key); 210 | return true; 211 | } 212 | 213 | this.alert(); 214 | return false; 215 | } 216 | 217 | toggle() { 218 | if (this.model.isCollapsible()) { 219 | this.model.collapsed = !this.model.collapsed; 220 | } else { 221 | this.alert(); 222 | } 223 | } 224 | 225 | collapse() { 226 | this.model.collapsed = true; 227 | } 228 | 229 | expand() { 230 | this.model.collapsed = false; 231 | } 232 | 233 | focus(index: number) { 234 | this.model.index = Math.max(0, Math.min(index, this.model.nodes.length - 1)); 235 | } 236 | 237 | home() { 238 | this.model.offset = 0; 239 | this.first(); 240 | } 241 | 242 | end() { 243 | this.model.offset = Math.max(0, this.model.nodes.length - this.model.pageSize); 244 | this.last(); 245 | } 246 | 247 | first() { 248 | this.model.index = 0; 249 | 250 | if (!this.focused.isFocusable()) { 251 | this.down(); 252 | } 253 | } 254 | 255 | last() { 256 | this.model.index = this.model.pageSize - 1; 257 | 258 | if (!this.focused.isFocusable()) { 259 | this.up(); 260 | } 261 | } 262 | 263 | up(key?: Key) { 264 | if (this.model.index > 0) { 265 | this.model.index--; 266 | } else if (this.model.isScrollable()) { 267 | this.cycle_up(key); 268 | } else { 269 | this.scroll_up(key); 270 | } 271 | 272 | if (!this.focused.isFocusable()) { 273 | this.up(); 274 | } 275 | } 276 | 277 | down(key?: Key) { 278 | if (this.model.index < this.model.pageSize - 1) { 279 | this.model.index++; 280 | } else if (this.model.isScrollable()) { 281 | this.cycle_down(key); 282 | } else { 283 | this.scroll_down(key); 284 | } 285 | 286 | if (!this.focused.isFocusable()) { 287 | this.down(); 288 | } 289 | } 290 | 291 | cycle_up(key?: Key) { 292 | if (this.model.options.cycle !== false) { 293 | this.model.append.push('cycle_up'); 294 | this.model.index--; 295 | 296 | if (this.model.index < 0) { 297 | this.last(key); 298 | } else if (!this.focused?.isFocusable()) { 299 | this.cycle_up(key); 300 | } 301 | } else { 302 | this.alert(); 303 | } 304 | } 305 | 306 | cycle_down(key?: Key) { 307 | if (this.model.options.cycle !== false) { 308 | this.model.append.push('cycle_down'); 309 | this.model.index++; 310 | 311 | if (this.model.index > this.model.pageSize - 1) { 312 | this.first(key); 313 | } else if (!this.focused?.isFocusable()) { 314 | this.cycle_down(key); 315 | } 316 | } else { 317 | this.alert(); 318 | } 319 | } 320 | 321 | scroll_up(key?: Key, n: number = 1) { 322 | if (this.model.options.scroll !== false) { 323 | this.model.append.push('scroll_up'); 324 | this.model.offset = 325 | (this.model.offset - n + this.model.nodes.length) % this.model.nodes.length; 326 | 327 | if (!this.focused?.isFocusable()) { 328 | this.scroll_up(key); 329 | } 330 | } else { 331 | this.alert(); 332 | } 333 | } 334 | 335 | scroll_down(key?: Key, n: number = 1) { 336 | if (this.model.options.scroll !== false) { 337 | this.model.append.push('scroll_down'); 338 | this.model.offset = (this.model.offset + n) % this.model.nodes.length; 339 | 340 | if (!this.focused?.isFocusable()) { 341 | this.scroll_down(key); 342 | } 343 | } else { 344 | this.alert(); 345 | } 346 | } 347 | 348 | page_left() { 349 | if (this.model.offset > 0) { 350 | this.scroll_up(null, Math.min(this.model.pageSize, this.model.offset)); 351 | } else { 352 | this.alert(); 353 | } 354 | } 355 | 356 | page_right() { 357 | const remaining = Math.max(0, this.model.nodes.length - (this.model.offset + this.model.pageSize)); 358 | const offset = this.model.nodes.length - remaining; 359 | 360 | if (offset > 0) { 361 | this.scroll_down(null, Math.min(this.model.pageSize, offset)); 362 | } else { 363 | this.alert(); 364 | } 365 | } 366 | 367 | show_less() { 368 | if (this.model.pageSize > 1) { 369 | this.model.adjustment--; 370 | } 371 | 372 | if (this.model.index >= this.model.pageSize) { 373 | this.model.index = this.model.pageSize - 1; 374 | } 375 | 376 | if (!this.focused.isFocusable()) { 377 | this.down(); 378 | } 379 | } 380 | 381 | show_more() { 382 | if (this.model.pageSize < this.model.nodes.length) { 383 | this.model.adjustment++; 384 | } 385 | } 386 | 387 | async render() { 388 | return this.view.render(); 389 | } 390 | 391 | isFocusable() { 392 | return this.focused.disabled !== true; 393 | } 394 | 395 | alert() { 396 | process.stdout.write('\u0007'); 397 | } 398 | 399 | get focused() { 400 | return this.model.focused; 401 | } 402 | } 403 | 404 | // console.clear(); 405 | // const prompt = new Select(options); 406 | 407 | // const print = (output: string = '') => { 408 | // console.clear(); 409 | // console.log(output); 410 | // }; 411 | 412 | // prompt.render().then(v => print(v)); 413 | 414 | // emitKeypress({ 415 | // enableMouseEvents: true, 416 | // hideCursor: true, 417 | // keymap: [ 418 | // ...keycodes, 419 | // { shortcut: 'meta+up', command: 'show_less', weight: 1 }, 420 | // { shortcut: 'meta+down', command: 'show_more', weight: 1 }, 421 | // { shortcut: 'meta+b', command: 'page_left', weight: 1 }, 422 | // { shortcut: 'meta+f', command: 'page_right', weight: 1 }, 423 | // { shortcut: 'return', command: 'submit', weight: 1 }, 424 | // { shortcut: 'ctrl+c', command: 'cancel', weight: 1 }, 425 | // { shortcut: 'space', command: 'toggle', weight: 1 } 426 | // ], 427 | // onKeypress: async (input, key, close) => { 428 | // if (prompt.dispatch(key)) { 429 | // print(await prompt.render()); 430 | // return; 431 | // } 432 | 433 | // if (key.shortcut === 'ctrl+c') { 434 | // prompt.canceled = true; 435 | // print(await prompt.render()); 436 | // close(); 437 | // } 438 | 439 | // if (input === '\r') { 440 | // prompt.submitted = true; 441 | // print(await prompt.render()); 442 | // await close(); 443 | 444 | // console.log(prompt.focused.name); 445 | // } 446 | // } 447 | // }); 448 | -------------------------------------------------------------------------------- /test/emit-keypress.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { EventEmitter } from 'node:events'; 3 | import { emitKeypress, emitKeypressEvents } from '../index'; 4 | 5 | const initialListeners = process.listenerCount('SIGINT'); 6 | 7 | function createMockInput() { 8 | const input = new EventEmitter() as any; 9 | input.isTTY = true; 10 | input.isRaw = false; 11 | input.setRawMode = function(isRaw: boolean) { this.isRaw = isRaw; }; 12 | input.setEncoding = function(encoding: string) { this.encoding = encoding; }; 13 | input.resume = () => {}; 14 | input.pause = () => {}; 15 | return input; 16 | } 17 | 18 | function createMockOutput() { 19 | const output = new EventEmitter() as any; 20 | // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars 21 | output.write = function(sequence: string) { 22 | // Capture the write sequence for the sake of testing 23 | return true; 24 | }; 25 | return output; 26 | } 27 | 28 | describe('emitKeypress', () => { 29 | it('should throw an error if invalid stream is passed', () => { 30 | const invalidInput = new EventEmitter(); 31 | 32 | assert.throws(() => { 33 | emitKeypress({ 34 | input: invalidInput as any, 35 | onKeypress: () => {} 36 | }); 37 | }, /Invalid stream passed/); 38 | }); 39 | 40 | it('should handle keypress events', cb => { 41 | const mockInput = createMockInput(); 42 | const mockOutput = createMockOutput(); 43 | 44 | const keySequence = 'a'; 45 | const key = { sequence: keySequence, name: 'a' }; 46 | 47 | emitKeypress({ 48 | input: mockInput, 49 | output: mockOutput, 50 | onKeypress: (input, k, close) => { 51 | assert.equal(input, keySequence); 52 | assert.equal(k.name, key.name); 53 | close(); 54 | cb(); 55 | } 56 | }); 57 | 58 | emitKeypressEvents(mockInput); // Ensure keypress events are emitted 59 | mockInput.emit('keypress', keySequence, key); 60 | }); 61 | 62 | it('should handle fast consecutive keypress events', cb => { 63 | const mockInput = createMockInput(); 64 | const mockOutput = createMockOutput(); 65 | 66 | const key = { sequence: '\x1B[1;9A', shortcut: 'meta+up' }; 67 | let i = 0; 68 | 69 | emitKeypress({ 70 | input: mockInput, 71 | output: mockOutput, 72 | onKeypress: (input, k, close) => { 73 | assert.equal(input, key.sequence); 74 | assert.equal(k.sequence, key.sequence); 75 | assert.equal(k.shortcut, key.shortcut); 76 | 77 | if (i === 999) { 78 | close(); 79 | cb(); 80 | } 81 | } 82 | }); 83 | 84 | emitKeypressEvents(mockInput); 85 | 86 | for (; i < 1000; i++) { 87 | mockInput.emit('keypress', key.sequence, key); 88 | } 89 | }); 90 | 91 | it('should handle paste events', cb => { 92 | const mockInput = createMockInput(); 93 | const mockOutput = createMockOutput(); 94 | 95 | const pasteStartSequence = '\x1b[200~'; 96 | const pasteEndSequence = '\x1b[201~'; 97 | const pasteContent = 'this\nis\npasted\ncontent\n\n'; 98 | 99 | emitKeypress({ 100 | input: mockInput, 101 | output: mockOutput, 102 | enablePasteMode: true, 103 | onKeypress: (input, k, close) => { 104 | if (input === pasteContent) { 105 | assert.equal(k.sequence, pasteContent); 106 | assert.equal(k.name, 'paste'); 107 | close(); 108 | cb(); 109 | } 110 | } 111 | }); 112 | 113 | emitKeypressEvents(mockInput); // Ensure keypress events are emitted 114 | 115 | mockInput.emit('keypress', pasteStartSequence, { 116 | name: 'paste-start', 117 | sequence: pasteStartSequence 118 | }); 119 | 120 | pasteContent.split('').forEach(char => { 121 | mockInput.emit('keypress', char, { sequence: char }); 122 | }); 123 | 124 | mockInput.emit('keypress', pasteEndSequence, { name: 'paste-end', sequence: pasteEndSequence }); 125 | }); 126 | 127 | it('should handle large pasted test', cb => { 128 | const mockInput = createMockInput(); 129 | const mockOutput = createMockOutput(); 130 | 131 | const pasteStartSequence = '\x1b[200~'; 132 | const pasteEndSequence = '\x1b[201~'; 133 | const pasteContent = 'this\nis\npasted\ncontent\n\n'.repeat(10_000); 134 | 135 | emitKeypress({ 136 | input: mockInput, 137 | output: mockOutput, 138 | enablePasteMode: true, 139 | onKeypress: (input, k, close) => { 140 | if (input === pasteContent) { 141 | assert.equal(k.sequence, pasteContent); 142 | assert.equal(k.name, 'paste'); 143 | close(); 144 | cb(); 145 | } 146 | } 147 | }); 148 | 149 | emitKeypressEvents(mockInput); // Ensure keypress events are emitted 150 | mockInput.emit('keypress', pasteStartSequence, { name: 'paste-start', sequence: pasteStartSequence }); 151 | pasteContent.split('').forEach(char => { 152 | mockInput.emit('keypress', char, { sequence: char }); 153 | }); 154 | mockInput.emit('keypress', pasteEndSequence, { name: 'paste-end', sequence: pasteEndSequence }); 155 | }); 156 | 157 | it('should enable paste mode', cb => { 158 | const mockInput = createMockInput(); 159 | const mockOutput = createMockOutput(); 160 | 161 | let pasteModeCall = 0; 162 | 163 | mockOutput.write = (sequence: string) => { 164 | if (sequence === '\x1b[?2004h') pasteModeCall++; 165 | return true; 166 | }; 167 | 168 | emitKeypress({ 169 | input: mockInput, 170 | output: mockOutput, 171 | onKeypress: () => {}, 172 | enablePasteMode: true 173 | }); 174 | 175 | setTimeout(() => { 176 | assert.equal(pasteModeCall, 1); 177 | cb(); 178 | }, 10); 179 | }); 180 | 181 | it('should change cursor visibility', cb => { 182 | const mockInput = createMockInput(); 183 | const mockOutput = createMockOutput(); 184 | 185 | let hideCalls = 0; 186 | let showCalls = 0; 187 | 188 | mockOutput.write = (sequence: string) => { 189 | if (sequence === '\x1b[?25l') hideCalls++; 190 | if (sequence === '\x1b[?25h') showCalls++; 191 | return true; 192 | }; 193 | 194 | const close = emitKeypress({ 195 | input: mockInput, 196 | output: mockOutput, 197 | onKeypress: () => {}, 198 | hideCursor: true 199 | }); 200 | 201 | setTimeout(() => { 202 | assert.equal(hideCalls, 1); 203 | close(); 204 | setTimeout(() => { 205 | assert.equal(showCalls, 1); 206 | cb(); 207 | }, 10); 208 | }, 10); 209 | }); 210 | 211 | it('should handle multiple rapid escape sequences correctly', cb => { 212 | const mockInput = createMockInput(); 213 | const mockOutput = createMockOutput(); 214 | 215 | const escapeSequences = [ 216 | { sequence: '\x1B[A', shortcut: 'up' }, 217 | { sequence: '\x1B[B', shortcut: 'down' }, 218 | { sequence: '\x1B[C', shortcut: 'right' } 219 | ]; 220 | let completed = 0; 221 | 222 | emitKeypress({ 223 | input: mockInput, 224 | output: mockOutput, 225 | onKeypress: (input, k, close) => { 226 | const expected = escapeSequences[completed]; 227 | assert.equal(input, expected.sequence); 228 | assert.equal(k.shortcut, expected.shortcut); 229 | completed++; 230 | 231 | if (completed === escapeSequences.length) { 232 | close(); 233 | cb(); 234 | } 235 | } 236 | }); 237 | 238 | emitKeypressEvents(mockInput); 239 | 240 | // Emit sequences rapidly with only 1ms delay 241 | escapeSequences.forEach((seq, i) => { 242 | setTimeout(() => { 243 | mockInput.emit('keypress', seq.sequence, { sequence: seq.sequence, shortcut: seq.shortcut }); 244 | }, Number(i)); 245 | }); 246 | }); 247 | 248 | it('should cleanup paste state if paste-end is never received', cb => { 249 | const mockInput = createMockInput(); 250 | const mockOutput = createMockOutput(); 251 | 252 | const pasteStartSequence = '\x1b[200~'; 253 | const pasteContent = 'incomplete paste'; 254 | let keypressAfterTimeout = false; 255 | 256 | emitKeypress({ 257 | input: mockInput, 258 | output: mockOutput, 259 | enablePasteMode: true, 260 | onKeypress: (input, k, close) => { 261 | if (keypressAfterTimeout) { 262 | // Should receive normal keypress after timeout 263 | assert.equal(input, 'x'); 264 | assert.equal(k.sequence, 'x'); 265 | close(); 266 | cb(); 267 | } 268 | } 269 | }); 270 | 271 | emitKeypressEvents(mockInput); 272 | 273 | mockInput.emit('keypress', pasteStartSequence, { 274 | name: 'paste-start', 275 | sequence: pasteStartSequence 276 | }); 277 | 278 | pasteContent.split('').forEach(char => { 279 | mockInput.emit('keypress', char, { sequence: char }); 280 | }); 281 | 282 | // Wait for paste timeout (10 seconds in the implementation) 283 | setTimeout(() => { 284 | keypressAfterTimeout = true; 285 | // Should be handled as normal keypress after timeout 286 | mockInput.emit('keypress', 'x', { sequence: 'x' }); 287 | }, 110); 288 | }).timeout(11000); 289 | 290 | it('should handle paste buffer overflow', cb => { 291 | const mockInput = createMockInput(); 292 | const mockOutput = createMockOutput(); 293 | 294 | const pasteStartSequence = '\x1b[200~'; 295 | const pasteEndSequence = '\x1b[201~'; 296 | const pasteContent = 'x'.repeat(2 * 1024 * 1024); // 2MB of data (over the 1MB limit) 297 | let received = false; 298 | 299 | emitKeypress({ 300 | input: mockInput, 301 | output: mockOutput, 302 | enablePasteMode: true, 303 | pasteModeTimeout: 100, 304 | onKeypress: (input, k, close) => { 305 | if (k.name === 'paste') { 306 | assert.ok(input.length <= 1024 * 1024, 'Paste buffer should be limited to 1MB'); 307 | received = true; 308 | close(); 309 | cb(); 310 | } 311 | } 312 | }); 313 | 314 | emitKeypressEvents(mockInput); 315 | 316 | mockInput.emit('keypress', pasteStartSequence, { 317 | name: 'paste-start', 318 | sequence: pasteStartSequence 319 | }); 320 | 321 | pasteContent.split('').forEach(char => { 322 | mockInput.emit('keypress', char, { sequence: char }); 323 | }); 324 | 325 | mockInput.emit('keypress', pasteEndSequence, { 326 | name: 'paste-end', 327 | sequence: pasteEndSequence 328 | }); 329 | 330 | setTimeout(() => { 331 | assert.ok(received, 'Should have received paste event'); 332 | }, 150); 333 | }); 334 | 335 | it('should properly clean up all event listeners on close', cb => { 336 | const mockInput = createMockInput(); 337 | const mockOutput = createMockOutput(); 338 | 339 | const close = emitKeypress({ 340 | input: mockInput, 341 | output: mockOutput, 342 | onKeypress: () => {}, 343 | handleClose: true 344 | }); 345 | 346 | assert.ok(process.listenerCount('SIGINT') >= initialListeners, 'SIGINT listeners should be added'); 347 | close(); 348 | 349 | setTimeout(() => { 350 | assert.equal(process.listenerCount('SIGINT'), initialListeners, 'SIGINT listeners should be cleaned up'); 351 | assert.equal(mockInput.listenerCount('keypress'), 0, 'keypress listeners should be cleaned up'); 352 | assert.equal(mockInput.listenerCount('pause'), 0, 'pause listeners should be cleaned up'); 353 | cb(); 354 | }, 10); 355 | }); 356 | 357 | it('should handle combining characters correctly', cb => { 358 | const mockInput = createMockInput(); 359 | const mockOutput = createMockOutput(); 360 | 361 | const combiningChar = 'e\u0301'; // é (e + combining acute accent) 362 | let received = false; 363 | 364 | emitKeypress({ 365 | input: mockInput, 366 | output: mockOutput, 367 | onKeypress: (input, k, close) => { 368 | assert.equal(input, combiningChar); 369 | assert.ok(k.printable); 370 | received = true; 371 | close(); 372 | cb(); 373 | } 374 | }); 375 | 376 | emitKeypressEvents(mockInput); 377 | mockInput.emit('keypress', combiningChar, { sequence: combiningChar }); 378 | 379 | setTimeout(() => { 380 | assert.ok(received, 'Should have received combining character event'); 381 | }, 100); 382 | }); 383 | }); 384 | -------------------------------------------------------------------------------- /examples/keycodes.ts: -------------------------------------------------------------------------------- 1 | export const keycodes = [ 2 | { shortcut: 'return', command: 'submit', sequence: '\r', weight: Infinity }, 3 | { shortcut: 'escape', command: 'cancel', sequence: '\x1B', weight: Infinity }, 4 | 5 | { shortcut: 'up', command: 'up', sequence: '\x1B[A', weight: 0 }, 6 | { shortcut: 'down', command: 'down', sequence: '\x1B[B', weight: 0 }, 7 | 8 | { shortcut: 'right', command: 'right', sequence: '\x1B[C', weight: 0 }, 9 | { shortcut: 'left', command: 'left', sequence: '\x1B[D', weight: 0 }, 10 | 11 | { shortcut: 'end', command: 'end', sequence: '\x1B[F', weight: 0 }, 12 | { shortcut: 'home', command: 'home', sequence: '\x1B[H', weight: 0 }, 13 | { shortcut: 'backspace', command: 'backspace', sequence: '\x7f', weight: 0 }, 14 | { shortcut: 'delete', command: 'delete', sequence: '\x1B[3~', weight: 0, when: 'kind === "string"' }, 15 | { shortcut: 'delete', command: 'delete_forward', sequence: '\u007F', weight: 0, when: 'kind === "string"' }, 16 | { shortcut: 'delete', command: 'delete', sequence: '\u007F', weight: 0, when: 'kind === "string"' }, 17 | { shortcut: 'space', command: 'toggle', when: 'type === "multiselect" || focused.kind === "boolean"', sequence: '\u0020', weight: 0 }, 18 | { shortcut: 'space', command: 'toggle', when: 'type === "select" && focused.is_collapsible', sequence: '\u0020', weight: 0 }, 19 | { shortcut: 'space', command: 'append', sequence: '\u0020', weight: 0, when: 'kind === "string"' }, 20 | { shortcut: 'space', command: 'toggle', sequence: ' ', weight: 0 }, 21 | { shortcut: 'backspace', command: 'backspace', sequence: '\u0008', weight: 0, when: 'kind === "string"' }, 22 | { shortcut: 'backspace', command: 'delete', sequence: '\u0008', weight: 0, when: 'kind === "string"' }, 23 | { shortcut: 'enter', command: 'enter', sequence: '\u000D', weight: 0 }, 24 | 25 | { shortcut: 'fn+left', command: 'home', sequence: '\x1B[1~', weight: 0 }, 26 | { shortcut: 'fn+delete', command: 'delete', sequence: '\x1B[3~', weight: 0 }, 27 | { shortcut: 'fn+right', command: 'end', sequence: '\x1B[4~', weight: 0 }, 28 | 29 | { shortcut: 'ctrl+_', command: 'undo', sequence: '\x1f', weight: -1 }, 30 | { shortcut: 'ctrl+a', command: 'first', sequence: '\x01', weight: 0 }, 31 | { shortcut: 'ctrl+a', command: 'home', sequence: '\x01', weight: 0 }, 32 | { shortcut: 'ctrl+b', command: 'left', description: 'move cursor left', sequence: '\x02', weight: 0, when: 'kind === "string"' }, 33 | { shortcut: 'ctrl+b', command: 'backward', sequence: '\x02', weight: 0 }, 34 | { shortcut: 'ctrl+c', command: 'cancel', sequence: '\x03', weight: Infinity }, 35 | { shortcut: 'ctrl+d', command: 'delete', sequence: '\x04', weight: 0 }, 36 | { shortcut: 'ctrl+e', command: 'last', sequence: '\x05', weight: 0 }, 37 | { shortcut: 'ctrl+e', command: 'end', sequence: '\x05', weight: 0 }, 38 | { shortcut: 'ctrl+f', command: 'right', sequence: '\x06', description: 'move cursor right', weight: 0, when: 'kind === "string"' }, 39 | { shortcut: 'ctrl+f', command: 'forward', sequence: '\x06', weight: -1 }, 40 | { shortcut: 'ctrl+g', command: 'reset', sequence: '\x07', weight: 0 }, 41 | { shortcut: 'ctrl+h', command: 'help', sequence: '\b', weight: 0 }, 42 | { shortcut: 'ctrl+i', command: 'tab', sequence: '\x09', weight: -1 }, 43 | { shortcut: 'ctrl+j', command: 'enter', sequence: '\x0a', weight: -1 }, 44 | { shortcut: 'ctrl+j', command: 'newline', sequence: '\x0a', weight: -1 }, 45 | { shortcut: 'ctrl+k', command: 'cut_to_end', sequence: '\x0b', weight: 0 }, 46 | { shortcut: 'ctrl+l', command: 'clear', sequence: '\x0c', weight: 0 }, 47 | { shortcut: 'ctrl+m', command: 'enter', sequence: '\x0d', weight: 0 }, 48 | { shortcut: 'ctrl+m', command: 'submit', sequence: '\x0d', weight: 0 }, 49 | { shortcut: 'ctrl+n', command: 'down', sequence: '\x0e', weight: 0 }, 50 | { shortcut: 'ctrl+n', command: 'new', sequence: '\x0e', weight: 0 }, 51 | { shortcut: 'ctrl+p', command: 'search', sequence: '\x10', weight: -1 }, 52 | { shortcut: 'ctrl+p', command: 'up', sequence: '\x10', weight: -1 }, 53 | { shortcut: 'ctrl+q', command: 'quit', sequence: '\x11', weight: 0 }, 54 | { shortcut: 'ctrl+r', command: 'redo', sequence: '\x12', weight: -1 }, 55 | { shortcut: 'ctrl+r', command: 'remove', sequence: '\x12', weight: -1 }, 56 | { shortcut: 'ctrl+s', command: 'search', when: '!searching', sequence: '\x13', weight: -1 }, 57 | { shortcut: 'ctrl+s', command: 'restore', when: 'searching', sequence: '\x13', weight: -1 }, 58 | { shortcut: 'ctrl+s', command: 'save', sequence: '\x13', weight: 0 }, 59 | { shortcut: 'ctrl+t', command: 'toggle', sequence: '\x14', weight: 0 }, 60 | { shortcut: 'ctrl+t', command: 'toggle_cursor', sequence: '\x14', weight: 0 }, 61 | { shortcut: 'ctrl+u', command: 'undo', sequence: '\x15', weight: 0 }, 62 | { shortcut: 'ctrl+v', command: 'paste', sequence: '\x16', weight: 0 }, 63 | { shortcut: 'ctrl+w', command: 'cut_word_left', sequence: '\x17', weight: 0 }, 64 | { shortcut: 'ctrl+x', command: 'cut', sequence: '\x18', weight: 0 }, 65 | { shortcut: 'ctrl+y', command: 'redo', sequence: '\x19', weight: 0 }, 66 | { shortcut: 'ctrl+z', command: 'undo', sequence: '\x1a', weight: 0 }, 67 | 68 | { shortcut: 'ctrl+right', command: 'end', sequence: '\x1B[1;5C', weight: 0 }, 69 | { shortcut: 'ctrl+left', command: 'home', sequence: '\x1B[1;5D', weight: 0 }, 70 | 71 | { shortcut: 'a', command: 'a', when: 'kind === "array"', sequence: '\u0061', weight: 0 }, 72 | { shortcut: 'g', command: 'g', when: 'kind === "array"', sequence: '\u0067', weight: 0 }, 73 | { shortcut: 'i', command: 'i', when: 'kind === "array"', sequence: '\u0069', weight: 0 }, 74 | { shortcut: 'p', command: 'p', when: 'kind === "array"', sequence: '\u0070', weight: 0 }, 75 | 76 | { shortcut: 'a', command: 'toggle_all', when: 'kind === "array"', sequence: '\u0061', weight: 0 }, 77 | { shortcut: 'g', command: 'toggle_group', when: 'kind === "array"', sequence: '\u0067', weight: 0 }, 78 | { shortcut: 'i', command: 'invert', when: 'kind === "array"', sequence: '\u0069', weight: 0 }, 79 | { shortcut: 'p', command: 'toggle_page', when: 'kind === "array"', sequence: '\u0070', weight: 0 }, 80 | 81 | { shortcut: 'number', command: 'number', sequence: '[0-9]', test: /^[0-9.]+/, weight: 0 }, 82 | 83 | { shortcut: 'left', command: 'prev_tabstop', when: 'options.tabstops === true', sequence: '\x1B[D' }, 84 | { shortcut: 'left', command: 'prev_page', when: 'options.paginate !== false', sequence: '\x1B[D' }, 85 | { shortcut: 'left', command: 'move_left', sequence: '\x1B[D' }, 86 | { shortcut: 'left', command: 'left', sequence: '\x1B[D', weight: 0 }, 87 | 88 | { shortcut: 'right', command: 'next_tabstop', when: 'options.tabstops === true', sequence: '\x1B[C' }, 89 | { shortcut: 'right', command: 'next_page', when: 'options.paginate !== false', sequence: '\x1B[C' }, 90 | { shortcut: 'right', command: 'move_right', sequence: '\x1B[C' }, 91 | { shortcut: 'right', command: 'right', sequence: '\x1B[C', weight: 0 }, 92 | 93 | { shortcut: 'tab', command: 'tab', sequence: '\t', weight: 0 }, 94 | { shortcut: 'tab', command: 'next', sequence: '\t', weight: 0 }, 95 | 96 | { shortcut: 'shift+tab', command: 'shift_tab', sequence: '\x1B[Z', weight: 0, when: 'kind === "string"' }, 97 | { shortcut: 'shift+tab', command: 'prev', sequence: '\x1B[Z', weight: 0 }, 98 | 99 | { shortcut: 'shift+up', command: 'move_up', when: 'options.sort !== false', sequence: '\x1B[1;2A', weight: 0 }, 100 | { shortcut: 'shift+up', command: 'scroll_up', when: 'options.paginate !== true && options.scroll !== false', sequence: '\x1B[1;2A', weight: 0 }, 101 | 102 | { shortcut: 'shift+down', command: 'move_down', when: 'options.sort !== false', sequence: '\x1B[1;2B', weight: 0 }, 103 | { shortcut: 'shift+down', command: 'scroll_down', when: 'options.paginate !== true && options.scroll !== false', sequence: '\x1B[1;2B', weight: 0 }, 104 | 105 | { shortcut: 'shift+left', command: 'select_left', sequence: '\x1B[1;2D', weight: 0 }, 106 | { shortcut: 'shift+right', command: 'select_right', sequence: '\x1B[1;2C', weight: 0 }, 107 | 108 | { shortcut: 'shift+meta+left', command: 'select_word_left', sequence: '\x1B[1;10D', weight: 0 }, 109 | { shortcut: 'shift+meta+right', command: 'select_word_right', sequence: '\x1B[1;10C', weight: 0 }, 110 | 111 | { shortcut: 'shift+meta+down', command: 'rotate_down', sequence: '\x1B[1;10B', weight: 1 }, 112 | { shortcut: 'shift+meta+left', command: 'rotate_right', sequence: '\x1B[1;10D' }, 113 | { shortcut: 'shift+meta+right', command: 'rotate_left', sequence: '\x1B[1;10C' }, 114 | { shortcut: 'shift+meta+up', command: 'rotate_up', sequence: '\x1B[1;10A', weight: 1 }, 115 | 116 | // { shortcut: 'shift+home', command: 'select_home', sequence: '\x1B[1;2H', weight: 0 }, 117 | // { shortcut: 'shift+end', command: 'select_end', sequence: '\x1B[1;2F', weight: 0 }, 118 | 119 | // { shortcut: 'shift+up', command: 'select_up', sequence: '\x1B[1;2A', weight: 0 }, 120 | // { shortcut: 'shift+down', command: 'select_down', sequence: '\x1B[1;2B', weight: 0 }, 121 | 122 | // { shortcut: 'shift+pageup', command: 'select_pageup', sequence: '\x1B[5;2~', weight: 0 }, 123 | // { shortcut: 'shift+pagedown', command: 'select_pagedown', sequence: '\x1B[6;2~', weight: 0 }, 124 | 125 | /* + */ 126 | { shortcut: 'ctrl+shift+right', command: 'select_word_right', sequence: '\x1B[1;6C', weight: 0 }, 127 | { shortcut: 'ctrl+shift+left', command: 'select_word_left', sequence: '\x1B[1;6D', weight: 0 }, 128 | { shortcut: 'ctrl+shift+up', command: 'select_paragraph_up', sequence: '\x1B[1;6A', weight: 0 }, 129 | { shortcut: 'ctrl+shift+down', command: 'select_paragraph_down', sequence: '\x1B[1;6B', weight: 0 }, 130 | 131 | { shortcut: 'pageup', command: 'pageup', sequence: '\x1B[5~', notes: '+ (mac), (windows)', weight: 0 }, 132 | { shortcut: 'pagedown', command: 'pagedown', sequence: '\x1B[6~', notes: '+ (mac), (windows)', weight: 0 }, 133 | { shortcut: 'home', command: 'pageleft', sequence: '\x1B[H', notes: '+ (mac), (windows), ', weight: 0 }, 134 | { shortcut: 'end', command: 'pageright', sequence: '\x1B[F', notes: '+ (mac), (windows), ', weight: 0 }, 135 | { shortcut: 'home', command: 'home', sequence: '\x1B[H', notes: '+ (mac), (windows)', weight: 0 }, 136 | { shortcut: 'end', command: 'end', sequence: '\x1B[F', notes: '+ (mac), (windows)', weight: 0 }, 137 | 138 | { shortcut: 'fn+up', command: 'pageup', sequence: '\x1B[5~', weight: 0 }, 139 | { shortcut: 'fn+pageup', command: 'pageup', sequence: '\x1B[5~', weight: 0 }, 140 | { shortcut: 'fn+down', command: 'pagedown', sequence: '\x1B[6~', weight: 0 }, 141 | { shortcut: 'fn+pagedown', command: 'pagedown', sequence: '\x1B[6~', weight: 0 }, 142 | { shortcut: 'fn+left', command: 'pageleft', sequence: '\x1B[H', notes: '', weight: 0 143 | }, 144 | { shortcut: 'fn+right', command: 'pageright', sequence: '\x1B[F', notes: '', weight: 0 }, 145 | { shortcut: 'fn+delete', command: 'delete_forward', sequence: '\x1B[3~', weight: 0 }, 146 | { shortcut: 'ctrl+end', command: 'end', sequence: '\x1B[1;5F', weight: 0 }, 147 | { shortcut: 'ctrl+home', command: 'home', sequence: '\x1B[1;5H', weight: 0 }, 148 | { shortcut: 'ctrl+shift+end', command: 'select_end', sequence: '\x1B[1;6F', weight: 0 }, 149 | { shortcut: 'ctrl+shift+home', command: 'select_home', sequence: '\x1B[1;6H', weight: 0 }, 150 | { shortcut: 'fn+ctrl+left', command: 'select_word_left', sequence: '\x1B[1;5D', weight: 0 }, 151 | { shortcut: 'fn+ctrl+right', command: 'select_word_right', sequence: '\x1B[1;5C', weight: 0 }, 152 | { shortcut: 'fn+meta+down', command: 'select_paragraph_down', sequence: '\x1B[1;3B', weight: 0 }, 153 | { shortcut: 'fn+meta+end', command: 'select_end', sequence: '\x1B[1;3F', weight: 0 }, 154 | { shortcut: 'fn+meta+home', command: 'select_home', sequence: '\x1B[1;3H', weight: 0 }, 155 | { shortcut: 'fn+meta+left', command: 'select_word_left', sequence: '\x1B[1;3D', weight: 0 }, 156 | { shortcut: 'fn+meta+right', command: 'select_word_right', sequence: '\x1B[1;3C', weight: 0 }, 157 | { shortcut: 'fn+meta+up', command: 'select_paragraph_up', sequence: '\x1B[1;3A', weight: 0 }, 158 | { shortcut: 'fn+shift+left', command: 'select_word_left', sequence: '\x1B[1;2D', weight: 0 }, 159 | { shortcut: 'fn+shift+right', command: 'select_word_right', sequence: '\x1B[1;2C', weight: 0 }, 160 | { shortcut: 'shift+end', command: 'select_end', sequence: '\x1B[1;2F', weight: 0 }, 161 | { shortcut: 'shift+home', command: 'select_home', sequence: '\x1B[1;2H', weight: 0 }, 162 | 163 | /* shortcut */ 164 | { shortcut: 'meta+pageup', command: 'pageup', sequence: '\x1B[5;3~', weight: 0 }, 165 | { shortcut: 'meta+pagedown', command: 'pagedown', sequence: '\x1B[6;3~', weight: 0 }, 166 | { shortcut: 'meta+a', command: 'ditto', sequence: '\x1Ba', weight: -1, description: 'make all choices the same as the currently selected value' }, 167 | { shortcut: 'meta+b', command: 'jump_backward', sequence: '\x1Bb', weight: -1, description: 'typically used for moving right one word' }, 168 | { shortcut: 'meta+f', command: 'jump_forward', sequence: '\x1Bf', weight: -1, description: 'typically used for moving left one word' }, 169 | 170 | { shortcut: 'meta+d', command: 'cut_word_right', sequence: '\x1Bd', weight: 0 }, 171 | { shortcut: 'fn+meta+down', command: 'expand_down', sequence: '\x1B[1;3B', weight: 0 }, 172 | { shortcut: 'meta+left', command: 'cut_word_left', sequence: '\x1B[1;3D', weight: 0 }, 173 | { shortcut: 'meta+left', command: 'move_word_left', sequence: '\x1B\x1B[C', weight: 0 }, 174 | { shortcut: 'meta+right', command: 'move_word_right', sequence: '\x1B\x1B[C', weight: 0 }, 175 | { shortcut: 'meta+space', command: 'alt_space', sequence: '\x1B ', weight: -1 }, 176 | { shortcut: 'meta+up', command: 'expand_up', sequence: '\x1B[1;3A', weight: 0 }, 177 | { shortcut: 'meta+up', command: 'move_paragraph_up', sequence: '\x1B[1;9A', weight: 0 }, 178 | { shortcut: 'meta+down', command: 'move_paragraph_down', sequence: '\x1B[1;9B', weight: 0 }, 179 | 180 | { shortcut: 'shift+f1', command: '', sequence: '\x1B[1;2P', weight: 0 }, 181 | { shortcut: 'shift+f2', command: '', sequence: '\x1B[1;2Q', weight: 0 }, 182 | { shortcut: 'shift+f3', command: '', sequence: '\x1B[1;2R', weight: 0 }, 183 | { shortcut: 'shift+f4', command: '', sequence: '\x1B[1;2S', weight: 0 }, 184 | { shortcut: 'shift+f5', command: '', sequence: '\x1B[15;2~', weight: 0 }, 185 | { shortcut: 'shift+f6', command: '', sequence: '\x1B[17;2~', weight: 0 }, 186 | { shortcut: 'shift+f7', command: '', sequence: '\x1B[18;2~', weight: 0 }, 187 | { shortcut: 'shift+f8', command: '', sequence: '\x1B[19;2~', weight: 0 }, 188 | { shortcut: 'shift+f9', command: '', sequence: '\x1B[20;2~', weight: 0 }, 189 | { shortcut: 'shift+f10', command: '', sequence: '\x1B[21;2~', weight: 0 }, 190 | { shortcut: 'shift+f11', command: '', sequence: '\x1B[23;2~', weight: 0 }, 191 | { shortcut: 'shift+f12', command: '', sequence: '\x1B[24;2~', weight: 0 }, 192 | 193 | { shortcut: 'f1', command: 'help', sequence: '\x1BOP', weight: 0 }, 194 | { shortcut: 'f2', command: 'rename', sequence: '\x1BOQ', weight: 0 }, 195 | { shortcut: 'f3', command: 'search', sequence: '\x1BOR', weight: 0 }, 196 | { shortcut: 'f4', command: 'close', sequence: '\x1BOS', weight: 0 }, 197 | { shortcut: 'f5', command: 'refresh', sequence: '\x1B[15~', weight: 0 }, 198 | { shortcut: 'f6', command: 'next_tab', sequence: '\x1B[17~', weight: 0 }, 199 | { shortcut: 'f7', command: 'previous_tab', sequence: '\x1B[18~', weight: 0 }, 200 | { shortcut: 'f8', command: 'execute', sequence: '\x1B[19~', weight: 0 }, 201 | { shortcut: 'f9', command: 'rebuild', sequence: '\x1B[20~', weight: 0 }, 202 | { shortcut: 'f10', command: 'step_over', sequence: '\x1B[21~', weight: 0 }, 203 | { shortcut: 'f12', command: 'inspect', sequence: '\x1B[24~', weight: 0 } 204 | ]; 205 | 206 | // const withDescriptions = keycodes.filter(b => b.description); 207 | // const withoutDescriptions = keycodes.filter(b => !b.description); 208 | 209 | // const shiftUp = keycodes.find(b => b.shortcut === 'shift+up'); 210 | // console.debug(shiftUp); 211 | -------------------------------------------------------------------------------- /src/keypress.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-extra-parens, no-control-regex */ 2 | // This file is a modified version of the original file from the readline module of Node.js 3 | // Copyright Joyent, Inc. and other Node contributors. 4 | // SPDX-License-Identifier: MIT 5 | export const kEscape = '\x1b'; 6 | export const kSubstringSearch = Symbol('kSubstringSearch'); 7 | export const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 8 | 9 | export function CSI(strings, ...args) { 10 | let ret = `${kEscape}[`; 11 | for (let n = 0; n < strings.length; n++) { 12 | ret += strings[n]; 13 | if (n < args.length) { 14 | ret += args[n]; 15 | } 16 | } 17 | return ret; 18 | } 19 | 20 | CSI.kEscape = kEscape; 21 | CSI.kClearToLineBeginning = CSI`1K`; 22 | CSI.kClearToLineEnd = CSI`0K`; 23 | CSI.kClearLine = CSI`2K`; 24 | CSI.kClearScreenDown = CSI`0J`; 25 | 26 | // CSI u (fixterms/kitty) protocol sequences for enabling enhanced key reporting 27 | // Enable with: process.stdout.write(CSI_U_ENABLE) 28 | // Disable with: process.stdout.write(CSI_U_DISABLE) 29 | export const CSI_U_ENABLE = `${kEscape}[>4;1m`; // modifyOtherKeys mode 1 30 | export const CSI_U_DISABLE = `${kEscape}[>4;0m`; 31 | export const KITTY_ENABLE = `${kEscape}[>1u`; // kitty protocol 32 | export const KITTY_DISABLE = `${kEscape}[ 1 && 41 | str.codePointAt(i - 2) >= kUTF16SurrogateThreshold) || 42 | str.codePointAt(i - 1) >= kUTF16SurrogateThreshold) { 43 | return 2; 44 | } 45 | return 1; 46 | } 47 | 48 | export function charLengthAt(str, i) { 49 | if (str.length <= i) { 50 | // Pretend to move to the right. This is necessary to autocomplete while 51 | // moving to the right. 52 | return 1; 53 | } 54 | return str.codePointAt(i) >= kUTF16SurrogateThreshold ? 2 : 1; 55 | } 56 | 57 | /* 58 | Some patterns seen in terminal key escape codes, derived from combos seen 59 | at http://www.midnight-commander.org/browser/lib/tty/key.c 60 | 61 | ESC letter 62 | ESC [ letter 63 | ESC [ modifier letter 64 | ESC [ 1 ; modifier letter 65 | ESC [ num char 66 | ESC [ num ; modifier char 67 | ESC O letter 68 | ESC O modifier letter 69 | ESC O 1 ; modifier letter 70 | ESC N letter 71 | ESC [ [ num ; modifier char 72 | ESC [ [ 1 ; modifier letter 73 | ESC ESC [ num char 74 | ESC ESC O letter 75 | 76 | - char is usually ~ but $ and ^ also happen with rxvt 77 | - modifier is 1 + 78 | (shift * 1) + 79 | (left_alt * 2) + 80 | (ctrl * 4) + 81 | (right_alt * 8) 82 | - two leading ESCs apparently mean the same as one leading ESC 83 | 84 | CSI u (fixterms/kitty) format: 85 | ESC [ charcode ; modifier u 86 | 87 | - charcode is the unicode codepoint 88 | - modifier follows same formula as above 89 | - e.g. Ctrl+Shift+S = ESC [ 115 ; 6 u (115 = 's', 6 = 1 + 1 + 4) 90 | */ 91 | 92 | // eslint-disable-next-line complexity 93 | export function * emitKeys(stream) { 94 | while (true) { 95 | let ch = yield; 96 | let s = ch; 97 | let escaped = false; 98 | 99 | const key = { 100 | sequence: null, 101 | name: undefined, 102 | ctrl: false, 103 | meta: false, 104 | shift: false, 105 | fn: false 106 | }; 107 | 108 | if (ch === kEscape) { 109 | escaped = true; 110 | s += (ch = yield); 111 | 112 | if (ch === kEscape) { 113 | s += (ch = yield); 114 | } 115 | } 116 | 117 | if (escaped && (ch === 'O' || ch === '[')) { 118 | // ANSI escape sequence 119 | let code = ch; 120 | let modifier = 0; 121 | 122 | if (ch === 'O') { 123 | // ESC O letter 124 | // ESC O modifier letter 125 | s += (ch = yield); 126 | 127 | if (ch >= '0' && ch <= '9') { 128 | modifier = (ch >> 0) - 1; 129 | s += (ch = yield); 130 | } 131 | 132 | code += ch; 133 | } else if (ch === '[') { 134 | // ESC [ letter 135 | // ESC [ modifier letter 136 | // ESC [ [ modifier letter 137 | // ESC [ [ num char 138 | s += (ch = yield); 139 | 140 | if (ch === '[') { 141 | // \x1b[[A 142 | // ^--- escape codes might have a second bracket 143 | code += ch; 144 | s += (ch = yield); 145 | } 146 | 147 | /* 148 | * Here and later we try to buffer just enough data to get 149 | * a complete ascii sequence. 150 | * 151 | * We have basically two classes of ascii characters to process: 152 | * 153 | * 154 | * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } 155 | * 156 | * This particular example is featuring Ctrl+F12 in xterm. 157 | * 158 | * - `;5` part is optional, e.g. it could be `\x1b[24~` 159 | * - first part can contain one or two digits 160 | * - there is also special case when there can be 3 digits 161 | * but without modifier. They are the case of paste bracket mode 162 | * 163 | * So the generic regexp is like /^(?:\d\d?(;\d)?[~^$]|\d{3}~)$/ 164 | * 165 | * 166 | * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } 167 | * 168 | * This particular example is featuring Ctrl+Home in xterm. 169 | * 170 | * - `1;5` part is optional, e.g. it could be `\x1b[H` 171 | * - `1;` part is optional, e.g. it could be `\x1b[5H` 172 | * 173 | * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ 174 | * 175 | */ 176 | const cmdStart = s.length - 1; 177 | 178 | // Skip one or two leading digits 179 | if (ch >= '0' && ch <= '9') { 180 | s += (ch = yield); 181 | 182 | if (ch >= '0' && ch <= '9') { 183 | s += (ch = yield); 184 | 185 | if (ch >= '0' && ch <= '9') { 186 | s += (ch = yield); 187 | } 188 | } 189 | } 190 | 191 | // skip modifier 192 | if (ch === ';') { 193 | s += (ch = yield); 194 | 195 | if (ch >= '0' && ch <= '9') { 196 | s += yield; 197 | } 198 | } 199 | 200 | /* 201 | * We buffered enough data, now trying to extract code 202 | * and modifier from it 203 | */ 204 | const cmd = s.slice(cmdStart); 205 | let match; 206 | 207 | // Special key names for CSI u parsing 208 | const SPECIAL_NAMES = { 209 | 8: 'backspace', // BS (ctrl+h legacy) 210 | 9: 'tab', 211 | 10: 'enter', // LF 212 | 13: 'return', // CR 213 | 27: 'escape', 214 | 32: 'space', 215 | 127: 'backspace' // DEL 216 | }; 217 | 218 | // CSI u (kitty) format: \x1b[;u 219 | // e.g. Ctrl+Shift+S = \x1b[115;6u or \x1b[83;5u 220 | if ((match = /^(\d+);(\d+)u$/.exec(cmd))) { 221 | const charCode = parseInt(match[1], 10); 222 | modifier = parseInt(match[2], 10) - 1; 223 | const char = String.fromCharCode(charCode); 224 | key.name = SPECIAL_NAMES[charCode] || char.toLowerCase(); 225 | key.ctrl = Boolean(modifier & 4); 226 | key.meta = Boolean(modifier & 10); 227 | key.shift = Boolean(modifier & 1) || char !== char.toLowerCase(); 228 | key.code = `[${match[1]}u`; 229 | key.sequence = s; 230 | 231 | stream.emit('keypress', undefined, key); 232 | continue; 233 | } 234 | 235 | // modifyOtherKeys format: \x1b[27;;~ 236 | // e.g. Ctrl+Shift+S = \x1b[27;6;115~ 237 | if ((match = /^27;(\d+);(\d+)~$/.exec(cmd))) { 238 | modifier = parseInt(match[1], 10) - 1; 239 | const charCode = parseInt(match[2], 10); 240 | const char = String.fromCharCode(charCode); 241 | key.name = SPECIAL_NAMES[charCode] || char.toLowerCase(); 242 | key.ctrl = Boolean(modifier & 4); 243 | key.meta = Boolean(modifier & 10); 244 | key.shift = Boolean(modifier & 1) || char !== char.toLowerCase(); 245 | key.code = `[27;${match[1]};${match[2]}~`; 246 | key.sequence = s; 247 | 248 | stream.emit('keypress', undefined, key); 249 | continue; 250 | } 251 | 252 | if ((match = /^(?:(\d\d?)(?:;(\d))?([~^$])|(\d{3}~))$/.exec(cmd))) { 253 | if (match[4]) { 254 | code += match[4]; 255 | } else { 256 | code += match[1] + match[3]; 257 | modifier = (match[2] || 1) - 1; 258 | } 259 | } else if ((match = /^((\d;)?(\d))?([A-Za-z])$/.exec(cmd))) { 260 | code += match[4]; 261 | modifier = (match[3] || 1) - 1; 262 | } else { 263 | code += cmd; 264 | } 265 | } 266 | 267 | if (/\[(?:1[5-9]|2[0-2]);10/.test(code)) { 268 | s += ch = yield; 269 | } 270 | 271 | // Parse the key modifier 272 | key.ctrl = Boolean(modifier & 4); 273 | key.meta = Boolean(modifier & 10); 274 | key.shift = Boolean(modifier & 1); 275 | key.code = code; 276 | 277 | if (!key.meta) { 278 | const parts = [...s]; 279 | 280 | if (parts[0] === '\u001b' && parts[1] === '\u001b') { 281 | key.meta = true; 282 | } 283 | } 284 | 285 | if (/\x1B\[1;[59][FH]/.test(s)) { 286 | key.fn = true; 287 | } 288 | 289 | // Parse the key itself 290 | switch (code) { 291 | case '[M': { 292 | key.name = 'mouse'; 293 | s += (ch = yield); // button-byte 294 | s += (ch = yield); // x-coordinate 295 | s += (ch = yield); // y-coordinate 296 | break; 297 | } 298 | 299 | /* xterm/gnome ESC [ letter (with modifier) */ 300 | case '[P': key.name = 'f1'; key.fn = true; break; 301 | case '[Q': key.name = 'f2'; key.fn = true; break; 302 | case '[R': key.name = 'f3'; key.fn = true; break; 303 | case '[S': key.name = 'f4'; key.fn = true; break; 304 | 305 | /* xterm/gnome ESC O letter (without modifier) */ 306 | case 'OP': key.name = 'f1'; key.fn = true; break; 307 | case 'OQ': key.name = 'f2'; key.fn = true; break; 308 | case 'OR': key.name = 'f3'; key.fn = true; break; 309 | case 'OS': key.name = 'f4'; key.fn = true; break; 310 | 311 | /* xterm/rxvt ESC [ number ~ */ 312 | case '[11~': key.name = 'f1'; key.fn = true; break; 313 | case '[12~': key.name = 'f2'; key.fn = true; break; 314 | case '[13~': key.name = 'f3'; key.fn = true; break; 315 | case '[14~': key.name = 'f4'; key.fn = true; break; 316 | 317 | /* paste bracket mode */ 318 | case '[200~': key.name = 'paste-start'; break; 319 | case '[201~': key.name = 'paste-end'; break; 320 | 321 | /* from Cygwin and used in libuv */ 322 | case '[[A': key.name = 'f1'; key.fn = true; break; 323 | case '[[B': key.name = 'f2'; key.fn = true; break; 324 | case '[[C': key.name = 'f3'; key.fn = true; break; 325 | case '[[D': key.name = 'f4'; key.fn = true; break; 326 | case '[[E': key.name = 'f5'; key.fn = true; break; 327 | 328 | /* common */ 329 | case '[15~': key.name = 'f5'; key.fn = true; break; 330 | case '[17~': key.name = 'f6'; key.fn = true; break; 331 | case '[18~': key.name = 'f7'; key.fn = true; break; 332 | case '[19~': key.name = 'f8'; key.fn = true; break; 333 | case '[20~': key.name = 'f9'; key.fn = true; break; 334 | case '[21~': key.name = 'f10'; key.fn = true; break; 335 | case '[23~': key.name = 'f11'; key.fn = true; break; 336 | case '[24~': key.name = 'f12'; key.fn = true; break; 337 | 338 | /* common */ 339 | case '[15;10': key.name = 'f5'; key.fn = true; key.shift = true; key.meta = true; break; 340 | case '[17;10': key.name = 'f7'; key.fn = true; key.shift = true; key.meta = true; break; 341 | case '[18;10': key.name = 'f8'; key.fn = true; key.shift = true; key.meta = true; break; 342 | case '[19;10': key.name = 'f9'; key.fn = true; key.shift = true; key.meta = true; break; 343 | case '[20;10': key.name = 'f10'; key.fn = true; key.shift = true; key.meta = true; break; 344 | case '[21;10': key.name = 'f11'; key.fn = true; key.shift = true; key.meta = true; break; 345 | case '[22;10': key.name = 'f12'; key.fn = true; key.shift = true; key.meta = true; break; 346 | 347 | /* xterm ESC [ letter */ 348 | case '[A': key.name = 'up'; break; 349 | case '[B': key.name = 'down'; break; 350 | case '[C': key.name = 'right'; break; 351 | case '[D': key.name = 'left'; break; 352 | case '[E': key.name = 'clear'; break; 353 | case '[F': key.name = 'end'; break; 354 | case '[H': key.name = 'home'; break; 355 | 356 | /* xterm/gnome ESC O letter */ 357 | case 'OA': key.name = 'up'; break; 358 | case 'OB': key.name = 'down'; break; 359 | case 'OC': key.name = 'right'; break; 360 | case 'OD': key.name = 'left'; break; 361 | case 'OE': key.name = 'clear'; break; 362 | case 'OF': key.name = 'end'; break; 363 | case 'OH': key.name = 'home'; break; 364 | 365 | /* xterm/rxvt ESC [ number ~ */ 366 | case '[1~': key.name = 'home'; break; 367 | case '[2~': key.name = 'insert'; break; 368 | case '[3~': 369 | key.name = 'delete'; 370 | key.shift = !s.includes('3;5~'); 371 | key.fn = /^\[3;[256]~$/.test(s.slice(1)); 372 | break; 373 | 374 | case '[4~': key.name = 'end'; break; 375 | case '[5~': key.name = 'pageup'; break; 376 | case '[6~': key.name = 'pagedown'; break; 377 | 378 | /* putty */ 379 | case '[[5~': key.name = 'pageup'; break; 380 | case '[[6~': key.name = 'pagedown'; break; 381 | 382 | /* rxvt */ 383 | case '[7~': key.name = 'home'; break; 384 | case '[8~': key.name = 'end'; break; 385 | 386 | /* rxvt keys with modifiers */ 387 | case '[a': key.name = 'up'; key.shift = true; break; 388 | case '[b': key.name = 'down'; key.shift = true; break; 389 | case '[c': key.name = 'right'; key.shift = true; break; 390 | case '[d': key.name = 'left'; key.shift = true; break; 391 | case '[e': key.name = 'clear'; key.shift = true; break; 392 | 393 | case '[2$': key.name = 'insert'; key.shift = true; break; 394 | case '[3$': key.name = 'delete'; key.shift = true; break; 395 | case '[5$': key.name = 'pageup'; key.shift = true; break; 396 | case '[6$': key.name = 'pagedown'; key.shift = true; break; 397 | case '[7$': key.name = 'home'; key.shift = true; break; 398 | case '[8$': key.name = 'end'; key.shift = true; break; 399 | 400 | case 'Oa': key.name = 'up'; key.ctrl = true; break; 401 | case 'Ob': key.name = 'down'; key.ctrl = true; break; 402 | case 'Oc': key.name = 'right'; key.ctrl = true; break; 403 | case 'Od': key.name = 'left'; key.ctrl = true; break; 404 | case 'Oe': key.name = 'clear'; key.ctrl = true; break; 405 | 406 | case '[2^': key.name = 'insert'; key.ctrl = true; break; 407 | case '[3^': key.name = 'delete'; key.ctrl = true; break; 408 | case '[5^': key.name = 'pageup'; key.ctrl = true; break; 409 | case '[6^': key.name = 'pagedown'; key.ctrl = true; break; 410 | case '[7^': key.name = 'home'; key.ctrl = true; break; 411 | case '[8^': key.name = 'end'; key.ctrl = true; break; 412 | 413 | case '[3;5~': 414 | key.name = 'delete'; 415 | key.ctrl = true; 416 | key.fn = true; 417 | break; 418 | 419 | case '[1;13': 420 | case '[1;14': 421 | key.shift = code === '[1;14'; 422 | key.ctrl = true; 423 | key.meta = true; 424 | 425 | if ((ch = yield)) { 426 | s += ch; 427 | 428 | switch (ch) { 429 | case 'A': key.name = 'up'; break; 430 | case 'B': key.name = 'down'; break; 431 | case 'C': key.name = 'right'; break; 432 | case 'D': key.name = 'left'; break; 433 | case 'F': key.name = 'right'; key.fn = true; break; 434 | case 'H': key.name = 'left'; key.fn = true; break; 435 | default: break; 436 | } 437 | } 438 | break; 439 | 440 | case '[3;10': 441 | if ((ch = yield)) { 442 | s += ch; 443 | key.name = 'delete'; 444 | key.shift = true; 445 | key.meta = true; 446 | key.fn = true; 447 | } 448 | break; 449 | 450 | case '[3;13': 451 | case '[3;14': 452 | if ((ch = yield)) { 453 | s += ch; 454 | key.name = 'delete'; 455 | key.ctrl = true; 456 | key.shift = code === '[3;14'; 457 | key.meta = true; 458 | key.fn = true; 459 | } 460 | break; 461 | 462 | case '[5;14': 463 | case '[6;14': 464 | if ((ch = yield)) { 465 | s += ch; 466 | key.name = code === '[5;14' ? 'up' : 'down'; 467 | key.ctrl = true; 468 | key.shift = true; 469 | key.meta = true; 470 | key.fn = true; 471 | } 472 | break; 473 | 474 | case '[1;10': 475 | if ((ch = yield)) { 476 | s += ch; 477 | } 478 | 479 | switch (ch) { 480 | case 'A': key.name = 'up'; break; 481 | case 'B': key.name = 'down'; break; 482 | case 'C': key.name = 'right'; break; 483 | case 'D': key.name = 'left'; break; 484 | case 'F': key.name = 'right'; key.fn = true; break; 485 | case 'H': key.name = 'left'; key.fn = true; break; 486 | case 'P': key.name = 'f1'; key.fn = true; break; 487 | case 'Q': key.name = 'f2'; key.fn = true; break; 488 | case 'R': key.name = 'f3'; key.fn = true; break; 489 | case 'S': key.name = 'f4'; key.fn = true; break; 490 | default: break; 491 | } 492 | 493 | key.shift = true; 494 | key.meta = true; 495 | break; 496 | 497 | case '[5;10': 498 | case '[6;10': 499 | if ((ch = yield)) { 500 | s += ch; 501 | key.name = code === '[5;10' ? 'up' : 'down'; 502 | key.shift = true; 503 | key.fn = true; 504 | } 505 | 506 | key.meta = true; 507 | break; 508 | 509 | /* misc. */ 510 | case '[Z': key.name = 'tab'; key.shift = true; break; 511 | default: key.name = undefined; break; 512 | } 513 | 514 | } else if (ch === '\r') { 515 | // carriage return / return 516 | key.name = 'return'; 517 | key.meta = escaped; 518 | } else if (ch === '\n') { 519 | // linefeed / newline 520 | key.name = 'enter'; 521 | key.meta = escaped; 522 | } else if (ch === '\t') { 523 | // tab 524 | key.name = 'tab'; 525 | key.meta = escaped; 526 | } else if (ch === '\b') { 527 | // ctrl+backspace or ctrl+h 528 | key.name = 'backspace'; 529 | key.ctrl = true; 530 | key.meta = escaped; 531 | } else if (ch === '\x7f') { 532 | // backspace 533 | key.name = 'backspace'; 534 | key.meta = escaped; 535 | } else if (ch === kEscape) { 536 | // escape key 537 | key.name = 'escape'; 538 | key.meta = escaped; 539 | } else if (ch === ' ') { 540 | key.name = 'space'; 541 | key.meta = escaped; 542 | } else if (!escaped && ch <= '\x1a') { 543 | // ctrl+letter 544 | key.name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1); 545 | key.ctrl = true; 546 | } else if (/^[0-9A-Za-z]$/.exec(ch) !== null) { 547 | // Letter, number, shift+letter 548 | key.name = ch; 549 | key.shift = /^[A-Z]$/.exec(ch) !== null; 550 | key.meta = escaped; 551 | } else if (escaped) { 552 | // Escape sequence timeout 553 | key.name = ch.length ? undefined : 'escape'; 554 | key.meta = false; 555 | } 556 | 557 | key.sequence = s; 558 | 559 | if (s.length !== 0 && (key.name !== undefined || escaped)) { 560 | /* Named character or sequence */ 561 | stream.emit('keypress', escaped ? undefined : s, key); 562 | } else if (charLengthAt(s, 0) === s.length) { 563 | /* Single unnamed character, e.g. "." */ 564 | stream.emit('keypress', s, key); 565 | } 566 | 567 | /* Unrecognized or broken escape sequence, don't emit anything */ 568 | } 569 | } 570 | 571 | // This runs in O(n log n). 572 | export function commonPrefix(strings) { 573 | if (strings.length === 0) { 574 | return ''; 575 | } 576 | if (strings.length === 1) { 577 | return strings[0]; 578 | } 579 | 580 | const sorted = strings.slice().sort(); 581 | const min = sorted[0]; 582 | const max = sorted[sorted.length - 1]; 583 | 584 | for (let i = 0; i < min.length; i++) { 585 | if (min[i] !== max[i]) { 586 | return min.slice(0, i); 587 | } 588 | } 589 | return min; 590 | } 591 | -------------------------------------------------------------------------------- /src/keycodes.ts: -------------------------------------------------------------------------------- 1 | export const keycodes = [ 2 | { sequence: '\r', shortcut: 'return' }, 3 | { sequence: '\x7F', shortcut: 'backspace' }, 4 | { sequence: '\x1B', shortcut: 'escape' }, 5 | { sequence: '\x1B[1~', shortcut: 'home' }, 6 | { sequence: '\x1B\x1B[1~', shortcut: 'home' }, 7 | { sequence: '\x1B[2~', shortcut: 'insert' }, 8 | { sequence: '\x1B[3~', shortcut: 'delete' }, 9 | { sequence: '\x1B[4~', shortcut: 'end' }, 10 | { sequence: '\x1B[A', shortcut: 'up' }, 11 | { sequence: '\x1B[B', shortcut: 'down' }, 12 | { sequence: '\x1B[C', shortcut: 'right' }, 13 | { sequence: '\x1B[D', shortcut: 'left' }, 14 | 15 | // 16 | { sequence: '\x1B[2;2~', shortcut: 'shift+insert' }, 17 | { sequence: '\x1B[5;2~', shortcut: 'shift+pageup' }, 18 | { sequence: '\x1B[6;2~', shortcut: 'shift+pagedown' }, 19 | { sequence: '\x1B[1;2A', shortcut: 'shift+up' }, 20 | { sequence: '\x1B[1;2B', shortcut: 'shift+down' }, 21 | { sequence: '\x1B[1;2C', shortcut: 'shift+right' }, 22 | { sequence: '\x1B[1;2D', shortcut: 'shift+left' }, 23 | { sequence: '\x1B[Z', shortcut: 'shift+tab' }, 24 | 25 | // 26 | { sequence: '\x1Bf', shortcut: 'meta+right' }, 27 | { sequence: '\x1Bb', shortcut: 'meta+left' }, 28 | { sequence: '\x1B[1;9A', shortcut: 'meta+up' }, 29 | { sequence: '\x1B[1;9B', shortcut: 'meta+down' }, 30 | { sequence: '\x1B[1;9C', shortcut: 'cmd+meta+right' }, 31 | { sequence: '\x1B[1;9D', shortcut: 'cmd+meta+left' }, 32 | { sequence: '\x1B[2;3~', shortcut: 'meta+insert' }, 33 | { sequence: '\x1B[3;3~', shortcut: 'meta+delete' }, 34 | { sequence: '\x1B[5;3~', shortcut: 'meta+pageup' }, 35 | { sequence: '\x1B[6;3~', shortcut: 'meta+pagedown' }, 36 | { sequence: '\x1B\x7F', shortcut: 'meta+backspace' }, 37 | 38 | // 39 | { sequence: '\x1B[1;10A', shortcut: 'shift+meta+up' }, 40 | { sequence: '\x1B[1;10B', shortcut: 'shift+meta+down' }, 41 | { sequence: '\x1B[1;10C', shortcut: 'shift+meta+right' }, 42 | { sequence: '\x1B[1;10D', shortcut: 'shift+meta+left' }, 43 | { sequence: '\x1B[2;4~', shortcut: 'shift+meta+insert' }, 44 | { sequence: '\x1B[3;4~', shortcut: 'shift+meta+delete' }, 45 | { sequence: '\x1B[5;4~', shortcut: 'shift+meta+pageup' }, 46 | { sequence: '\x1B[6;4~', shortcut: 'shift+meta+pagedown' }, 47 | 48 | // 49 | { sequence: '\x00', shortcut: 'ctrl+`' }, 50 | { sequence: '\x0c', shortcut: 'ctrl+4' }, 51 | // { sequence: '\x0d', shortcut: 'ctrl+5' }, 52 | { sequence: '\x0e', shortcut: 'ctrl+6' }, 53 | { sequence: '\x0f', shortcut: 'ctrl+7' }, 54 | { sequence: '\x01', shortcut: 'ctrl+a' }, 55 | { sequence: '\x02', shortcut: 'ctrl+b' }, 56 | { sequence: '\x03', shortcut: 'ctrl+c' }, 57 | { sequence: '\x04', shortcut: 'ctrl+d' }, 58 | { sequence: '\x05', shortcut: 'ctrl+e' }, 59 | { sequence: '\x06', shortcut: 'ctrl+f' }, 60 | { sequence: '\x07', shortcut: 'ctrl+g' }, 61 | // { sequence: '\b', shortcut: 'ctrl+h' }, 62 | { sequence: '\t', shortcut: 'ctrl+i' }, 63 | { sequence: '\n', shortcut: 'ctrl+j', command: 'enter' }, 64 | { sequence: '\x0B', shortcut: 'ctrl+k' }, 65 | { sequence: '\f', shortcut: 'ctrl+l' }, 66 | { sequence: '\x0E', shortcut: 'ctrl+n' }, 67 | { sequence: '\x0F', shortcut: 'ctrl+o' }, 68 | { sequence: '\x10', shortcut: 'ctrl+p' }, 69 | { sequence: '\x11', shortcut: 'ctrl+q' }, 70 | { sequence: '\x12', shortcut: 'ctrl+r' }, 71 | { sequence: '\x13', shortcut: 'ctrl+s' }, 72 | { sequence: '\x14', shortcut: 'ctrl+t' }, 73 | { sequence: '\x15', shortcut: 'ctrl+u' }, 74 | { sequence: '\x16', shortcut: 'ctrl+v' }, 75 | { sequence: '\x17', shortcut: 'ctrl+w' }, 76 | { sequence: '\x18', shortcut: 'ctrl+x' }, 77 | { sequence: '\x19', shortcut: 'ctrl+y' }, 78 | { sequence: '\x1A', shortcut: 'ctrl+z' }, 79 | { sequence: '\x1F', shortcut: 'ctrl+-' }, 80 | { sequence: '\x1C', shortcut: 'ctrl+|' }, 81 | { sequence: '\x1D', shortcut: 'ctrl+]' }, 82 | { sequence: '\x1B[1;5A', shortcut: 'ctrl+up', weight: -1 }, 83 | { sequence: '\x1B[1;5B', shortcut: 'ctrl+down', weight: -1 }, 84 | { sequence: '\x1B[1;5C', shortcut: 'ctrl+right', weight: -1 }, 85 | { sequence: '\x1B[1;5D', shortcut: 'ctrl+left', weight: -1 }, 86 | { sequence: '\x1B[2;5~', shortcut: 'ctrl+insert', weight: -1 }, 87 | { sequence: '\x1B[5;5~', shortcut: 'ctrl+pageup', weight: -1 }, 88 | { sequence: '\x1B[6;5~', shortcut: 'ctrl+pagedown', weight: -1 }, 89 | 90 | // 91 | { sequence: '\x1B[1;6B', shortcut: 'ctrl+shift+down' }, 92 | { sequence: '\x1B[1;6D', shortcut: 'ctrl+shift+left' }, 93 | { sequence: '\x1B[1;6C', shortcut: 'ctrl+shift+right' }, 94 | { sequence: '\x1B[1;6A', shortcut: 'ctrl+shift+up' }, 95 | 96 | // 97 | { sequence: '\x1B[1;14B', shortcut: 'ctrl+shift+meta+down' }, 98 | { sequence: '\x1B[1;14D', shortcut: 'ctrl+shift+meta+left' }, 99 | { sequence: '\x1B[1;14C', shortcut: 'ctrl+shift+meta+right' }, 100 | 101 | // 102 | { sequence: '\x1B[H', shortcut: 'fn+left', name: 'home' }, 103 | { sequence: '\x1B[F', shortcut: 'fn+right', name: 'end' }, 104 | { sequence: '\x1B[5~', shortcut: 'fn+up', name: 'pageup' }, 105 | { sequence: '\x1B[6~', shortcut: 'fn+down', name: 'pagedown' }, 106 | 107 | // 108 | { sequence: '\x1B[1;5F', shortcut: 'fn+ctrl+right' }, 109 | { sequence: '\x1B[1;5H', shortcut: 'fn+ctrl+left' }, 110 | { sequence: '\x1B[3;5~', shortcut: 'fn+ctrl+delete' }, 111 | 112 | // 113 | { sequence: '\x1B[1;2F', shortcut: 'fn+shift+right', name: 'shift+home' }, 114 | { sequence: '\x1B[1;2H', shortcut: 'fn+shift+left', name: 'shift+end' }, 115 | { sequence: '\x1B[3;2~', shortcut: 'fn+shift+delete' }, 116 | 117 | // 118 | { sequence: '\x1B[1;9F', shortcut: 'fn+meta+right' }, 119 | { sequence: '\x1B[1;9H', shortcut: 'fn+meta+left' }, 120 | { sequence: '\x1B[5;9~', shortcut: 'fn+meta+up' }, 121 | { sequence: '\x1B[6;9~', shortcut: 'fn+meta+down' }, 122 | { sequence: '\x1B\x1B[5~', shortcut: 'fn+meta+up' }, 123 | { sequence: '\x1B\x1B[6~', shortcut: 'fn+meta+down' }, 124 | { sequence: '\x1Bd', shortcut: 'fn+meta+delete', fn: true }, 125 | 126 | // 127 | { sequence: '\x1B[1;6F', shortcut: 'fn+ctrl+shift+right' }, 128 | { sequence: '\x1B[1;6H', shortcut: 'fn+ctrl+shift+left' }, 129 | { sequence: '\x1B[3;6~', shortcut: 'fn+ctrl+shift+delete' }, 130 | { sequence: '\x1B[5;6~', shortcut: 'fn+ctrl+shift+up' }, 131 | { sequence: '\x1B[6;6~', shortcut: 'fn+ctrl+shift+down' }, 132 | 133 | // 134 | { sequence: '\x1B[1;13F', shortcut: 'fn+ctrl+meta+right' }, 135 | { sequence: '\x1B[1;13H', shortcut: 'fn+ctrl+meta+left' }, 136 | { sequence: '\x1B[3;13~', shortcut: 'fn+ctrl+meta+delete' }, 137 | 138 | // 139 | { sequence: '\x1B[1;10F', shortcut: 'fn+shift+meta+right' }, 140 | { sequence: '\x1B[1;10H', shortcut: 'fn+shift+meta+left' }, 141 | { sequence: '\x1B[3;10~', shortcut: 'fn+shift+meta+delete' }, 142 | { sequence: '\x1B[5;10~', shortcut: 'fn+shift+meta+up' }, 143 | { sequence: '\x1B[6;10~', shortcut: 'fn+shift+meta+down' }, 144 | 145 | // 146 | { sequence: '\x1B[1;14F', shortcut: 'fn+ctrl+shift+meta+right' }, 147 | { sequence: '\x1B[1;14H', shortcut: 'fn+ctrl+shift+meta+left' }, 148 | { sequence: '\x1B[3;14~', shortcut: 'fn+ctrl+shift+meta+delete' }, 149 | { sequence: '\x1B[5;14~', shortcut: 'fn+ctrl+shift+meta+up' }, 150 | { sequence: '\x1B[6;14~', shortcut: 'fn+ctrl+shift+meta+down' }, 151 | 152 | // 153 | { sequence: '\x1BOP', shortcut: 'f1' }, 154 | { sequence: '\x1BOQ', shortcut: 'f2' }, 155 | { sequence: '\x1BOR', shortcut: 'f3' }, 156 | { sequence: '\x1BOS', shortcut: 'f4' }, 157 | 158 | // 159 | { sequence: '\x1B[11~', shortcut: 'f1' }, 160 | { sequence: '\x1B[12~', shortcut: 'f2' }, 161 | { sequence: '\x1B[13~', shortcut: 'f3' }, 162 | { sequence: '\x1B[14~', shortcut: 'f4' }, 163 | { sequence: '\x1B[15~', shortcut: 'f5' }, 164 | { sequence: '\x1B[17~', shortcut: 'f6' }, 165 | { sequence: '\x1B[18~', shortcut: 'f7' }, 166 | { sequence: '\x1B[19~', shortcut: 'f8' }, 167 | { sequence: '\x1B[20~', shortcut: 'f9' }, 168 | { sequence: '\x1B[21~', shortcut: 'f10' }, 169 | { sequence: '\x1B[23~', shortcut: 'f11' }, 170 | { sequence: '\x1B[24~', shortcut: 'f12' }, 171 | { sequence: '\x1B[25~', shortcut: 'f13' }, 172 | { sequence: '\x1B[26~', shortcut: 'f14' }, 173 | { sequence: '\x1B[28~', shortcut: 'f15' }, 174 | { sequence: '\x1B[29~', shortcut: 'f16' }, 175 | { sequence: '\x1B[31~', shortcut: 'f17' }, 176 | { sequence: '\x1B[32~', shortcut: 'f18' }, 177 | { sequence: '\x1B[33~', shortcut: 'f19' }, 178 | { sequence: '\x1B[34~', shortcut: 'f20' }, 179 | 180 | // 181 | { sequence: '\x1B[1;2P', shortcut: 'shift+f1' }, 182 | { sequence: '\x1B[1;2Q', shortcut: 'shift+f2' }, 183 | { sequence: '\x1B[1;2R', shortcut: 'shift+f3' }, 184 | { sequence: '\x1B[1;2S', shortcut: 'shift+f4' }, 185 | { sequence: '\x1B[15;2~', shortcut: 'shift+f5' }, 186 | { sequence: '\x1B[17;2~', shortcut: 'shift+f6' }, 187 | { sequence: '\x1B[18;2~', shortcut: 'shift+f7' }, 188 | { sequence: '\x1B[19;2~', shortcut: 'shift+f8' }, 189 | { sequence: '\x1B[20;2~', shortcut: 'shift+f9' }, 190 | { sequence: '\x1B[21;2~', shortcut: 'shift+f10' }, 191 | { sequence: '\x1B[23;2~', shortcut: 'shift+f11' }, 192 | { sequence: '\x1B[24;2~', shortcut: 'shift+f12' }, 193 | 194 | // 195 | { sequence: '\x1BOp', shortcut: 'num_key_0' }, 196 | { sequence: '\x1BOq', shortcut: 'num_key_1' }, 197 | { sequence: '\x1BOr', shortcut: 'num_key_2' }, 198 | { sequence: '\x1BOs', shortcut: 'num_key_3' }, 199 | { sequence: '\x1BOt', shortcut: 'num_key_4' }, 200 | { sequence: '\x1BOu', shortcut: 'num_key_5' }, 201 | { sequence: '\x1BOv', shortcut: 'num_key_6' }, 202 | { sequence: '\x1BOw', shortcut: 'num_key_7' }, 203 | { sequence: '\x1BOx', shortcut: 'num_key_8' }, 204 | { sequence: '\x1BOy', shortcut: 'num_key_9' }, 205 | { sequence: '\x1BOl', shortcut: 'num_key_comma' }, 206 | { sequence: '\x1BOm', shortcut: 'num_key_minus' }, 207 | { sequence: '\x1BOn', shortcut: 'num_key_period' } 208 | ].map(key => ({ ...key, weight: key.weight || 0 })); 209 | 210 | export const protocols = { 211 | 212 | kitty: [ 213 | // Enter variants 214 | { shortcut: 'enter', sequence: '\x1b[13u' }, 215 | { shortcut: 'shift+enter', sequence: '\x1b[13;2u' }, 216 | { shortcut: 'alt+enter', sequence: '\x1b[13;3u' }, 217 | { shortcut: 'shift+alt+enter', sequence: '\x1b[13;4u' }, 218 | { shortcut: 'ctrl+enter', sequence: '\x1b[13;5u' }, 219 | { shortcut: 'shift+ctrl+enter', sequence: '\x1b[13;6u' }, 220 | { shortcut: 'ctrl+alt+enter', sequence: '\x1b[13;7u' }, 221 | { shortcut: 'shift+ctrl+alt+enter', sequence: '\x1b[13;8u' }, 222 | 223 | // Tab variants 224 | { shortcut: 'tab', sequence: '\x1b[9u' }, 225 | { shortcut: 'shift+tab', sequence: '\x1b[9;2u' }, 226 | { shortcut: 'alt+tab', sequence: '\x1b[9;3u' }, 227 | { shortcut: 'ctrl+tab', sequence: '\x1b[9;5u' }, 228 | { shortcut: 'shift+ctrl+tab', sequence: '\x1b[9;6u' }, 229 | 230 | // Backspace variants 231 | { shortcut: 'backspace', sequence: '\x1b[127u' }, 232 | { shortcut: 'shift+backspace', sequence: '\x1b[127;2u' }, 233 | { shortcut: 'alt+backspace', sequence: '\x1b[127;3u' }, 234 | { shortcut: 'ctrl+backspace', sequence: '\x1b[127;5u' }, 235 | { shortcut: 'shift+ctrl+backspace', sequence: '\x1b[127;6u' }, 236 | 237 | // Escape variants 238 | { shortcut: 'escape', sequence: '\x1b[27u' }, 239 | { shortcut: 'shift+escape', sequence: '\x1b[27;2u' }, 240 | { shortcut: 'alt+escape', sequence: '\x1b[27;3u' }, 241 | { shortcut: 'ctrl+escape', sequence: '\x1b[27;5u' }, 242 | 243 | // Space variants 244 | { shortcut: 'space', sequence: '\x1b[32u' }, 245 | { shortcut: 'shift+space', sequence: '\x1b[32;2u' }, 246 | { shortcut: 'alt+space', sequence: '\x1b[32;3u' }, 247 | { shortcut: 'ctrl+space', sequence: '\x1b[32;5u' }, 248 | { shortcut: 'shift+ctrl+space', sequence: '\x1b[32;6u' }, 249 | 250 | // Arrow keys 251 | { shortcut: 'up', sequence: '\x1b[A' }, 252 | { shortcut: 'shift+up', sequence: '\x1b[1;2A' }, 253 | { shortcut: 'alt+up', sequence: '\x1b[1;3A' }, 254 | { shortcut: 'shift+alt+up', sequence: '\x1b[1;4A' }, 255 | { shortcut: 'ctrl+up', sequence: '\x1b[1;5A' }, 256 | { shortcut: 'shift+ctrl+up', sequence: '\x1b[1;6A' }, 257 | { shortcut: 'ctrl+alt+up', sequence: '\x1b[1;7A' }, 258 | { shortcut: 'shift+ctrl+alt+up', sequence: '\x1b[1;8A' }, 259 | 260 | { shortcut: 'down', sequence: '\x1b[B' }, 261 | { shortcut: 'shift+down', sequence: '\x1b[1;2B' }, 262 | { shortcut: 'alt+down', sequence: '\x1b[1;3B' }, 263 | { shortcut: 'shift+alt+down', sequence: '\x1b[1;4B' }, 264 | { shortcut: 'ctrl+down', sequence: '\x1b[1;5B' }, 265 | { shortcut: 'shift+ctrl+down', sequence: '\x1b[1;6B' }, 266 | { shortcut: 'ctrl+alt+down', sequence: '\x1b[1;7B' }, 267 | { shortcut: 'shift+ctrl+alt+down', sequence: '\x1b[1;8B' }, 268 | 269 | { shortcut: 'right', sequence: '\x1b[C' }, 270 | { shortcut: 'shift+right', sequence: '\x1b[1;2C' }, 271 | { shortcut: 'alt+right', sequence: '\x1b[1;3C' }, 272 | { shortcut: 'shift+alt+right', sequence: '\x1b[1;4C' }, 273 | { shortcut: 'ctrl+right', sequence: '\x1b[1;5C' }, 274 | { shortcut: 'shift+ctrl+right', sequence: '\x1b[1;6C' }, 275 | { shortcut: 'ctrl+alt+right', sequence: '\x1b[1;7C' }, 276 | { shortcut: 'shift+ctrl+alt+right', sequence: '\x1b[1;8C' }, 277 | 278 | { shortcut: 'left', sequence: '\x1b[D' }, 279 | { shortcut: 'shift+left', sequence: '\x1b[1;2D' }, 280 | { shortcut: 'alt+left', sequence: '\x1b[1;3D' }, 281 | { shortcut: 'shift+alt+left', sequence: '\x1b[1;4D' }, 282 | { shortcut: 'ctrl+left', sequence: '\x1b[1;5D' }, 283 | { shortcut: 'shift+ctrl+left', sequence: '\x1b[1;6D' }, 284 | { shortcut: 'ctrl+alt+left', sequence: '\x1b[1;7D' }, 285 | { shortcut: 'shift+ctrl+alt+left', sequence: '\x1b[1;8D' }, 286 | 287 | // Home/End 288 | { shortcut: 'home', sequence: '\x1b[H' }, 289 | { shortcut: 'shift+home', sequence: '\x1b[1;2H' }, 290 | { shortcut: 'alt+home', sequence: '\x1b[1;3H' }, 291 | { shortcut: 'ctrl+home', sequence: '\x1b[1;5H' }, 292 | { shortcut: 'shift+ctrl+home', sequence: '\x1b[1;6H' }, 293 | 294 | { shortcut: 'end', sequence: '\x1b[F' }, 295 | { shortcut: 'shift+end', sequence: '\x1b[1;2F' }, 296 | { shortcut: 'alt+end', sequence: '\x1b[1;3F' }, 297 | { shortcut: 'ctrl+end', sequence: '\x1b[1;5F' }, 298 | { shortcut: 'shift+ctrl+end', sequence: '\x1b[1;6F' }, 299 | 300 | // Insert/Delete 301 | { shortcut: 'insert', sequence: '\x1b[2~' }, 302 | { shortcut: 'shift+insert', sequence: '\x1b[2;2~' }, 303 | { shortcut: 'alt+insert', sequence: '\x1b[2;3~' }, 304 | { shortcut: 'ctrl+insert', sequence: '\x1b[2;5~' }, 305 | 306 | { shortcut: 'delete', sequence: '\x1b[3~' }, 307 | { shortcut: 'shift+delete', sequence: '\x1b[3;2~' }, 308 | { shortcut: 'alt+delete', sequence: '\x1b[3;3~' }, 309 | { shortcut: 'ctrl+delete', sequence: '\x1b[3;5~' }, 310 | { shortcut: 'shift+ctrl+delete', sequence: '\x1b[3;6~' }, 311 | 312 | // Page Up/Down 313 | { shortcut: 'pageup', sequence: '\x1b[5~' }, 314 | { shortcut: 'shift+pageup', sequence: '\x1b[5;2~' }, 315 | { shortcut: 'alt+pageup', sequence: '\x1b[5;3~' }, 316 | { shortcut: 'ctrl+pageup', sequence: '\x1b[5;5~' }, 317 | 318 | { shortcut: 'pagedown', sequence: '\x1b[6~' }, 319 | { shortcut: 'shift+pagedown', sequence: '\x1b[6;2~' }, 320 | { shortcut: 'alt+pagedown', sequence: '\x1b[6;3~' }, 321 | { shortcut: 'ctrl+pagedown', sequence: '\x1b[6;5~' }, 322 | 323 | // Function keys F1-F12 324 | { shortcut: 'f1', sequence: '\x1b[11~' }, 325 | { shortcut: 'shift+f1', sequence: '\x1b[11;2~' }, 326 | { shortcut: 'alt+f1', sequence: '\x1b[11;3~' }, 327 | { shortcut: 'ctrl+f1', sequence: '\x1b[11;5~' }, 328 | 329 | { shortcut: 'f2', sequence: '\x1b[12~' }, 330 | { shortcut: 'shift+f2', sequence: '\x1b[12;2~' }, 331 | { shortcut: 'alt+f2', sequence: '\x1b[12;3~' }, 332 | { shortcut: 'ctrl+f2', sequence: '\x1b[12;5~' }, 333 | 334 | { shortcut: 'f3', sequence: '\x1b[13~' }, 335 | { shortcut: 'shift+f3', sequence: '\x1b[13;2~' }, 336 | { shortcut: 'alt+f3', sequence: '\x1b[13;3~' }, 337 | { shortcut: 'ctrl+f3', sequence: '\x1b[13;5~' }, 338 | 339 | { shortcut: 'f4', sequence: '\x1b[14~' }, 340 | { shortcut: 'shift+f4', sequence: '\x1b[14;2~' }, 341 | { shortcut: 'alt+f4', sequence: '\x1b[14;3~' }, 342 | { shortcut: 'ctrl+f4', sequence: '\x1b[14;5~' }, 343 | 344 | { shortcut: 'f5', sequence: '\x1b[15~' }, 345 | { shortcut: 'shift+f5', sequence: '\x1b[15;2~' }, 346 | { shortcut: 'alt+f5', sequence: '\x1b[15;3~' }, 347 | { shortcut: 'ctrl+f5', sequence: '\x1b[15;5~' }, 348 | 349 | { shortcut: 'f6', sequence: '\x1b[17~' }, 350 | { shortcut: 'shift+f6', sequence: '\x1b[17;2~' }, 351 | { shortcut: 'alt+f6', sequence: '\x1b[17;3~' }, 352 | { shortcut: 'ctrl+f6', sequence: '\x1b[17;5~' }, 353 | 354 | { shortcut: 'f7', sequence: '\x1b[18~' }, 355 | { shortcut: 'shift+f7', sequence: '\x1b[18;2~' }, 356 | { shortcut: 'alt+f7', sequence: '\x1b[18;3~' }, 357 | { shortcut: 'ctrl+f7', sequence: '\x1b[18;5~' }, 358 | 359 | { shortcut: 'f8', sequence: '\x1b[19~' }, 360 | { shortcut: 'shift+f8', sequence: '\x1b[19;2~' }, 361 | { shortcut: 'alt+f8', sequence: '\x1b[19;3~' }, 362 | { shortcut: 'ctrl+f8', sequence: '\x1b[19;5~' }, 363 | 364 | { shortcut: 'f9', sequence: '\x1b[20~' }, 365 | { shortcut: 'shift+f9', sequence: '\x1b[20;2~' }, 366 | { shortcut: 'alt+f9', sequence: '\x1b[20;3~' }, 367 | { shortcut: 'ctrl+f9', sequence: '\x1b[20;5~' }, 368 | 369 | { shortcut: 'f10', sequence: '\x1b[21~' }, 370 | { shortcut: 'shift+f10', sequence: '\x1b[21;2~' }, 371 | { shortcut: 'alt+f10', sequence: '\x1b[21;3~' }, 372 | { shortcut: 'ctrl+f10', sequence: '\x1b[21;5~' }, 373 | 374 | { shortcut: 'f11', sequence: '\x1b[23~' }, 375 | { shortcut: 'shift+f11', sequence: '\x1b[23;2~' }, 376 | { shortcut: 'alt+f11', sequence: '\x1b[23;3~' }, 377 | { shortcut: 'ctrl+f11', sequence: '\x1b[23;5~' }, 378 | 379 | { shortcut: 'f12', sequence: '\x1b[24~' }, 380 | { shortcut: 'shift+f12', sequence: '\x1b[24;2~' }, 381 | { shortcut: 'alt+f12', sequence: '\x1b[24;3~' }, 382 | { shortcut: 'ctrl+f12', sequence: '\x1b[24;5~' }, 383 | 384 | // Kitty-specific: letters with modifiers (using CSI u format) 385 | // Pattern: \x1b[;u 386 | // Modifier bits: 1=shift, 2=alt, 4=ctrl, 8=super 387 | // Examples for common letter shortcuts: 388 | { shortcut: 'ctrl+a', sequence: '\x1b[97;5u' }, 389 | { shortcut: 'ctrl+b', sequence: '\x1b[98;5u' }, 390 | { shortcut: 'ctrl+c', sequence: '\x1b[99;5u' }, 391 | { shortcut: 'ctrl+d', sequence: '\x1b[100;5u' }, 392 | { shortcut: 'ctrl+e', sequence: '\x1b[101;5u' }, 393 | { shortcut: 'ctrl+f', sequence: '\x1b[102;5u' }, 394 | { shortcut: 'ctrl+g', sequence: '\x1b[103;5u' }, 395 | { shortcut: 'ctrl+h', sequence: '\x1b[104;5u' }, 396 | { shortcut: 'ctrl+i', sequence: '\x1b[105;5u' }, 397 | { shortcut: 'ctrl+j', sequence: '\x1b[106;5u' }, 398 | { shortcut: 'ctrl+k', sequence: '\x1b[107;5u' }, 399 | { shortcut: 'ctrl+l', sequence: '\x1b[108;5u' }, 400 | { shortcut: 'ctrl+m', sequence: '\x1b[109;5u' }, 401 | { shortcut: 'ctrl+n', sequence: '\x1b[110;5u' }, 402 | { shortcut: 'ctrl+o', sequence: '\x1b[111;5u' }, 403 | { shortcut: 'ctrl+p', sequence: '\x1b[112;5u' }, 404 | { shortcut: 'ctrl+q', sequence: '\x1b[113;5u' }, 405 | { shortcut: 'ctrl+r', sequence: '\x1b[114;5u' }, 406 | { shortcut: 'ctrl+s', sequence: '\x1b[115;5u' }, 407 | { shortcut: 'ctrl+t', sequence: '\x1b[116;5u' }, 408 | { shortcut: 'ctrl+u', sequence: '\x1b[117;5u' }, 409 | { shortcut: 'ctrl+v', sequence: '\x1b[118;5u' }, 410 | { shortcut: 'ctrl+w', sequence: '\x1b[119;5u' }, 411 | { shortcut: 'ctrl+x', sequence: '\x1b[120;5u' }, 412 | { shortcut: 'ctrl+y', sequence: '\x1b[121;5u' }, 413 | { shortcut: 'ctrl+z', sequence: '\x1b[122;5u' }, 414 | 415 | { shortcut: 'shift+ctrl+a', sequence: '\x1b[97;6u' }, 416 | { shortcut: 'shift+ctrl+b', sequence: '\x1b[98;6u' }, 417 | { shortcut: 'shift+ctrl+c', sequence: '\x1b[99;6u' }, 418 | { shortcut: 'shift+ctrl+d', sequence: '\x1b[100;6u' }, 419 | { shortcut: 'shift+ctrl+e', sequence: '\x1b[101;6u' }, 420 | { shortcut: 'shift+ctrl+f', sequence: '\x1b[102;6u' }, 421 | { shortcut: 'shift+ctrl+g', sequence: '\x1b[103;6u' }, 422 | { shortcut: 'shift+ctrl+h', sequence: '\x1b[104;6u' }, 423 | { shortcut: 'shift+ctrl+i', sequence: '\x1b[105;6u' }, 424 | { shortcut: 'shift+ctrl+j', sequence: '\x1b[106;6u' }, 425 | { shortcut: 'shift+ctrl+k', sequence: '\x1b[107;6u' }, 426 | { shortcut: 'shift+ctrl+l', sequence: '\x1b[108;6u' }, 427 | { shortcut: 'shift+ctrl+m', sequence: '\x1b[109;6u' }, 428 | { shortcut: 'shift+ctrl+n', sequence: '\x1b[110;6u' }, 429 | { shortcut: 'shift+ctrl+o', sequence: '\x1b[111;6u' }, 430 | { shortcut: 'shift+ctrl+p', sequence: '\x1b[112;6u' }, 431 | { shortcut: 'shift+ctrl+q', sequence: '\x1b[113;6u' }, 432 | { shortcut: 'shift+ctrl+r', sequence: '\x1b[114;6u' }, 433 | { shortcut: 'shift+ctrl+s', sequence: '\x1b[115;6u' }, 434 | { shortcut: 'shift+ctrl+t', sequence: '\x1b[116;6u' }, 435 | { shortcut: 'shift+ctrl+u', sequence: '\x1b[117;6u' }, 436 | { shortcut: 'shift+ctrl+v', sequence: '\x1b[118;6u' }, 437 | { shortcut: 'shift+ctrl+w', sequence: '\x1b[119;6u' }, 438 | { shortcut: 'shift+ctrl+x', sequence: '\x1b[120;6u' }, 439 | { shortcut: 'shift+ctrl+y', sequence: '\x1b[121;6u' }, 440 | { shortcut: 'shift+ctrl+z', sequence: '\x1b[122;6u' }, 441 | 442 | { shortcut: 'alt+a', sequence: '\x1b[97;3u' }, 443 | { shortcut: 'alt+b', sequence: '\x1b[98;3u' }, 444 | { shortcut: 'alt+c', sequence: '\x1b[99;3u' }, 445 | { shortcut: 'alt+d', sequence: '\x1b[100;3u' }, 446 | { shortcut: 'alt+e', sequence: '\x1b[101;3u' }, 447 | { shortcut: 'alt+f', sequence: '\x1b[102;3u' }, 448 | { shortcut: 'alt+g', sequence: '\x1b[103;3u' }, 449 | { shortcut: 'alt+h', sequence: '\x1b[104;3u' }, 450 | { shortcut: 'alt+i', sequence: '\x1b[105;3u' }, 451 | { shortcut: 'alt+j', sequence: '\x1b[106;3u' }, 452 | { shortcut: 'alt+k', sequence: '\x1b[107;3u' }, 453 | { shortcut: 'alt+l', sequence: '\x1b[108;3u' }, 454 | { shortcut: 'alt+m', sequence: '\x1b[109;3u' }, 455 | { shortcut: 'alt+n', sequence: '\x1b[110;3u' }, 456 | { shortcut: 'alt+o', sequence: '\x1b[111;3u' }, 457 | { shortcut: 'alt+p', sequence: '\x1b[112;3u' }, 458 | { shortcut: 'alt+q', sequence: '\x1b[113;3u' }, 459 | { shortcut: 'alt+r', sequence: '\x1b[114;3u' }, 460 | { shortcut: 'alt+s', sequence: '\x1b[115;3u' }, 461 | { shortcut: 'alt+t', sequence: '\x1b[116;3u' }, 462 | { shortcut: 'alt+u', sequence: '\x1b[117;3u' }, 463 | { shortcut: 'alt+v', sequence: '\x1b[118;3u' }, 464 | { shortcut: 'alt+w', sequence: '\x1b[119;3u' }, 465 | { shortcut: 'alt+x', sequence: '\x1b[120;3u' }, 466 | { shortcut: 'alt+y', sequence: '\x1b[121;3u' }, 467 | { shortcut: 'alt+z', sequence: '\x1b[122;3u' }, 468 | 469 | { shortcut: 'ctrl+alt+a', sequence: '\x1b[97;7u' }, 470 | { shortcut: 'ctrl+alt+b', sequence: '\x1b[98;7u' }, 471 | { shortcut: 'ctrl+alt+c', sequence: '\x1b[99;7u' }, 472 | { shortcut: 'ctrl+alt+d', sequence: '\x1b[100;7u' }, 473 | { shortcut: 'ctrl+alt+e', sequence: '\x1b[101;7u' }, 474 | { shortcut: 'ctrl+alt+f', sequence: '\x1b[102;7u' }, 475 | { shortcut: 'ctrl+alt+g', sequence: '\x1b[103;7u' }, 476 | { shortcut: 'ctrl+alt+h', sequence: '\x1b[104;7u' }, 477 | { shortcut: 'ctrl+alt+i', sequence: '\x1b[105;7u' }, 478 | { shortcut: 'ctrl+alt+j', sequence: '\x1b[106;7u' }, 479 | { shortcut: 'ctrl+alt+k', sequence: '\x1b[107;7u' }, 480 | { shortcut: 'ctrl+alt+l', sequence: '\x1b[108;7u' }, 481 | { shortcut: 'ctrl+alt+m', sequence: '\x1b[109;7u' }, 482 | { shortcut: 'ctrl+alt+n', sequence: '\x1b[110;7u' }, 483 | { shortcut: 'ctrl+alt+o', sequence: '\x1b[111;7u' }, 484 | { shortcut: 'ctrl+alt+p', sequence: '\x1b[112;7u' }, 485 | { shortcut: 'ctrl+alt+q', sequence: '\x1b[113;7u' }, 486 | { shortcut: 'ctrl+alt+r', sequence: '\x1b[114;7u' }, 487 | { shortcut: 'ctrl+alt+s', sequence: '\x1b[115;7u' }, 488 | { shortcut: 'ctrl+alt+t', sequence: '\x1b[116;7u' }, 489 | { shortcut: 'ctrl+alt+u', sequence: '\x1b[117;7u' }, 490 | { shortcut: 'ctrl+alt+v', sequence: '\x1b[118;7u' }, 491 | { shortcut: 'ctrl+alt+w', sequence: '\x1b[119;7u' }, 492 | { shortcut: 'ctrl+alt+x', sequence: '\x1b[120;7u' }, 493 | { shortcut: 'ctrl+alt+y', sequence: '\x1b[121;7u' }, 494 | { shortcut: 'ctrl+alt+z', sequence: '\x1b[122;7u' }, 495 | 496 | // Numbers with modifiers 497 | { shortcut: 'ctrl+0', sequence: '\x1b[48;5u' }, 498 | { shortcut: 'ctrl+1', sequence: '\x1b[49;5u' }, 499 | { shortcut: 'ctrl+2', sequence: '\x1b[50;5u' }, 500 | { shortcut: 'ctrl+3', sequence: '\x1b[51;5u' }, 501 | { shortcut: 'ctrl+4', sequence: '\x1b[52;5u' }, 502 | { shortcut: 'ctrl+5', sequence: '\x1b[53;5u' }, 503 | { shortcut: 'ctrl+6', sequence: '\x1b[54;5u' }, 504 | { shortcut: 'ctrl+7', sequence: '\x1b[55;5u' }, 505 | { shortcut: 'ctrl+8', sequence: '\x1b[56;5u' }, 506 | { shortcut: 'ctrl+9', sequence: '\x1b[57;5u' }, 507 | 508 | { shortcut: 'alt+0', sequence: '\x1b[48;3u' }, 509 | { shortcut: 'alt+1', sequence: '\x1b[49;3u' }, 510 | { shortcut: 'alt+2', sequence: '\x1b[50;3u' }, 511 | { shortcut: 'alt+3', sequence: '\x1b[51;3u' }, 512 | { shortcut: 'alt+4', sequence: '\x1b[52;3u' }, 513 | { shortcut: 'alt+5', sequence: '\x1b[53;3u' }, 514 | { shortcut: 'alt+6', sequence: '\x1b[54;3u' }, 515 | { shortcut: 'alt+7', sequence: '\x1b[55;3u' }, 516 | { shortcut: 'alt+8', sequence: '\x1b[56;3u' }, 517 | { shortcut: 'alt+9', sequence: '\x1b[57;3u' }, 518 | 519 | // Common punctuation with modifiers 520 | { shortcut: 'ctrl+/', sequence: '\x1b[47;5u' }, 521 | { shortcut: 'ctrl+\\', sequence: '\x1b[92;5u' }, 522 | { shortcut: 'ctrl+[', sequence: '\x1b[91;5u' }, 523 | { shortcut: 'ctrl+]', sequence: '\x1b[93;5u' }, 524 | { shortcut: 'ctrl+-', sequence: '\x1b[45;5u' }, 525 | { shortcut: 'ctrl+=', sequence: '\x1b[61;5u' }, 526 | { shortcut: 'ctrl+`', sequence: '\x1b[96;5u' }, 527 | { shortcut: 'ctrl+;', sequence: '\x1b[59;5u' }, 528 | { shortcut: "ctrl+'", sequence: '\x1b[39;5u' }, 529 | { shortcut: 'ctrl+,', sequence: '\x1b[44;5u' }, 530 | { shortcut: 'ctrl+.', sequence: '\x1b[46;5u' }, 531 | 532 | { shortcut: 'alt+/', sequence: '\x1b[47;3u' }, 533 | { shortcut: 'alt+\\', sequence: '\x1b[92;3u' }, 534 | { shortcut: 'alt+[', sequence: '\x1b[91;3u' }, 535 | { shortcut: 'alt+]', sequence: '\x1b[93;3u' }, 536 | { shortcut: 'alt+-', sequence: '\x1b[45;3u' }, 537 | { shortcut: 'alt+=', sequence: '\x1b[61;3u' }, 538 | { shortcut: 'alt+`', sequence: '\x1b[96;3u' }, 539 | { shortcut: 'alt+;', sequence: '\x1b[59;3u' }, 540 | { shortcut: "alt+'", sequence: '\x1b[39;3u' }, 541 | { shortcut: 'alt+,', sequence: '\x1b[44;3u' }, 542 | { shortcut: 'alt+.', sequence: '\x1b[46;3u' } 543 | ] 544 | }; 545 | --------------------------------------------------------------------------------