/** * tui-input.ts — Dueño de stdin. Parsea keyboard + mouse desde raw bytes. * * Al ser el primer listener registrado en process.stdin, intercepta mouse * escape sequences ANTES de que lleguen a ningún otro handler. Así el * renderer custom nunca ve los "[<65;..." que rompían Ink en Windows. * * Uso: * const input = new TuiInput() * input.on('key', (key, char) => { ... }) * input.on('wheel', (dir) => { ... }) // 'up' | 'down' * input.start() // habilita mouse reporting * input.stop() // limpia al salir */ import { EventEmitter } from 'node:events' export type KeyName = | 'enter' | 'escape' | 'backspace' | 'delete' | 'up' | 'down' | 'left' | 'right' | 'pageup' | 'pagedown' | 'tab' | 'shift-tab' | 'ctrl-a' | 'ctrl-b' | 'ctrl-c' | 'ctrl-d' | 'ctrl-e' | 'ctrl-k' | 'ctrl-l' | 'ctrl-n' | 'ctrl-o' | 'ctrl-p' | 'ctrl-r' | 'ctrl-t' | 'ctrl-u' | 'ctrl-v' | 'ctrl-w' | 'alt-v' // Mouse events emitted separately — not part of KeyName union: // 'click' → (col, row) // 'drag' → (col, row) // 'release' → (col, row) // 'wheel' → (dir, col) | 'char' // Secuencias de escape → nombre de tecla const KEY_MAP: Record = { '\r': 'enter', '\n': 'enter', '\x03': 'ctrl-c', '\x1b': 'escape', '\x7f': 'backspace', '\x08': 'backspace', '\x1b[A': 'up', '\x1b[B': 'down', '\x1b[C': 'right', '\x1b[D': 'left', '\x1bOA': 'up', // application cursor keys '\x1bOB': 'down', '\x1bOC': 'right', '\x1bOD': 'left', '\x1b[5~': 'pageup', '\x1b[6~': 'pagedown', '\x1b[Z': 'shift-tab', // Ctrl+letra (^A=\x01 ... ^Z=\x1a, mapped via charCode) '\x01': 'ctrl-a', '\x02': 'ctrl-b', '\x04': 'ctrl-d', '\x05': 'ctrl-e', '\x0b': 'ctrl-k', '\x0c': 'ctrl-l', '\x0f': 'ctrl-o', '\x14': 'ctrl-t', '\x0e': 'ctrl-n', // Ctrl+N — history next (readline standard) '\x10': 'ctrl-p', // Ctrl+P — history prev (readline standard) '\x12': 'ctrl-r', '\x16': 'ctrl-v', // Ctrl+V — paste image from clipboard '\x15': 'ctrl-u', '\x17': 'ctrl-w', } const MOUSE_RE = /\x1b\[<(6[45]);(\d+);(\d+)M/g // wheel up(64)/down(65) const CLICK_RE = /\x1b\[<0;(\d+);(\d+)M/g // left button press const DRAG_RE = /\x1b\[<32;(\d+);(\d+)M/g // left button drag (1002h) const RELEASE_RE = /\x1b\[<0;(\d+);(\d+)m/g // left button release (lowercase m) const HAS_MOUSE = /\x1b\[ = { '\x1bv': 'alt-v', // Alt+V — paste image from clipboard } export class TuiInput extends EventEmitter { private _active = false /** * Activa raw mode, instala el listener, habilita mouse reporting. */ start(): void { if (this._active) return this._active = true const stdin = process.stdin if (stdin.isTTY) stdin.setRawMode(true) stdin.resume() stdin.setEncoding('binary') // recibir bytes crudos, no UTF-8 roto stdin.on('data', this._onData) // Mouse reporting: wheel scroll + scrollbar drag. // Select text: hold Shift while dragging (universal TUI standard). if (process.stdout.isTTY) { process.stdout.write('\x1b[?1000h') // button reporting (click + wheel) process.stdout.write('\x1b[?1002h') // button-event tracking (drag while held) process.stdout.write('\x1b[?1006h') // SGR extended coords } } /** * Deshabilita mouse reporting y raw mode. Llamar en cleanup. */ stop(): void { if (!this._active) return this._active = false if (process.stdout.isTTY) { process.stdout.write('\x1b[?1006l') process.stdout.write('\x1b[?1002l') process.stdout.write('\x1b[?1000l') } const stdin = process.stdin stdin.off('data', this._onData) if (stdin.isTTY) stdin.setRawMode(false) } private _onData = (raw: string): void => { // raw es string con encoding 'binary' (Latin-1, bytes 1:1) let s = raw // ── Filtrar mouse PRIMERO ───────────────────────────────────────────── if (HAS_MOUSE.test(s)) { MOUSE_RE.lastIndex = 0 let m: RegExpExecArray | null while ((m = MOUSE_RE.exec(s)) !== null) { const mouseCol = parseInt(m[2], 10) this.emit('wheel', m[1] === '64' ? 'up' : 'down', mouseCol) } CLICK_RE.lastIndex = 0 let mc: RegExpExecArray | null while ((mc = CLICK_RE.exec(s)) !== null) { this.emit('click', parseInt(mc[1], 10), parseInt(mc[2], 10)) } DRAG_RE.lastIndex = 0 let md: RegExpExecArray | null while ((md = DRAG_RE.exec(s)) !== null) { this.emit('drag', parseInt(md[1], 10), parseInt(md[2], 10)) } RELEASE_RE.lastIndex = 0 let mr: RegExpExecArray | null while ((mr = RELEASE_RE.exec(s)) !== null) { this.emit('release', parseInt(mr[1], 10), parseInt(mr[2], 10)) } s = s.replace(/\x1b\[<[\d;]*[Mm]/g, '') if (!s) return } // ── Teclas conocidas ───────────────────────────────────────────────── const mapped = KEY_MAP[s] if (mapped) { this.emit('key', mapped, '') return } // ── Ctrl+letra no mapeada arriba ───────────────────────────────────── if (s.length === 1 && s.charCodeAt(0) < 32) { // Ctrl+letra → charCode 1-26 map a 'ctrl-a'..'ctrl-z' const letter = String.fromCharCode(s.charCodeAt(0) + 96) this.emit('key', `ctrl-${letter}` as KeyName, '') return } // ── Alt+key sequences (checked before discarding unknown escapes) ──── if (s.startsWith('\x1b') && ALT_SEQUENCES[s]) { this.emit('key', ALT_SEQUENCES[s], '') return } // ── Escape sequences desconocidas — ignorar silenciosamente ────────── if (s.startsWith('\x1b')) return // ── Caracteres imprimibles (puede ser multibyte UTF-8 reconvertido) ── // stdin está en 'binary' encoding, así que multibyte UTF-8 llega como // bytes individuales en una sola string. Convertimos back a UTF-8. try { const utf8 = Buffer.from(s, 'binary').toString('utf-8') for (const char of utf8) { if (char >= ' ' || char === '\t') { this.emit('key', 'char', char) } } } catch { // Si falla la conversión, ignorar } } }