/** * KHAELOR * File: src/tui/renderer/input.ts * Description: Raw-mode key decoder — bytes to typed KeyEvents, with bracketed-paste reassembly across chunks. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export type ArrowKey = "up" | "down" | "left" | "right"; export type KeyEvent = | { type: "char"; ch: string } | { type: "enter" } | { type: "shift-enter" } | { type: "tab" } | { type: "backspace" } | { type: "delete" } | { type: "esc" } | { type: "home" } | { type: "end" } | { type: "arrow"; key: ArrowKey; alt: boolean; ctrl: boolean } | { type: "ctrl"; ch: string } // "a".."z", "_" (Ctrl+_ undo) | { type: "alt"; ch: string } // alt+b/f/d word ops | { type: "alt-backspace" } | { type: "paste"; text: string }; const PASTE_START = "\x1b[200~"; const PASTE_END = "\x1b[201~"; /** CSI final bytes we silently discard when the sequence is not a key (probe replies, mouse, …). */ const CSI_SEQ = /^\x1b\[([0-9;?]*)([ -/]*)([@-~])/; const OSC_SEQ = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/; function arrowFor(final: string): ArrowKey | null { switch (final) { case "A": return "up"; case "B": return "down"; case "C": return "right"; case "D": return "left"; default: return null; } } /** True when `s` could still grow into a complete escape sequence. */ function isPartialEscape(s: string): boolean { if (s === "\x1b" || s === "\x1b[" || s === "\x1b]" || s === "\x1bO") return true; if (s.startsWith("\x1b[")) return /^\x1b\[[0-9;?]*[ -/]*$/.test(s); if (s.startsWith("\x1b]")) return !/(?:\x07|\x1b\\)/.test(s); return false; } /** * Stateful raw-input decoder. State exists only for content split across * chunks (bracketed pastes, torn escape sequences); each `push` returns the * keys decoded so far. Unrecognized escape sequences (terminal query replies, * mouse reports) are dropped silently — they must never reach the composer * as garbage characters. */ export class KeyDecoder { private pending = ""; private pasting = false; private pasteBuffer = ""; push(chunk: Buffer | string): KeyEvent[] { this.pending += typeof chunk === "string" ? chunk : chunk.toString("utf8"); const events: KeyEvent[] = []; while (this.pending.length > 0) { if (this.pasting) { const end = this.pending.indexOf(PASTE_END); if (end === -1) { // Keep a suffix that might be a torn PASTE_END marker. const keep = tornSuffixLength(this.pending, PASTE_END); this.pasteBuffer += this.pending.slice(0, this.pending.length - keep); this.pending = this.pending.slice(this.pending.length - keep); break; } this.pasteBuffer += this.pending.slice(0, end); this.pending = this.pending.slice(end + PASTE_END.length); events.push({ type: "paste", text: this.pasteBuffer }); this.pasteBuffer = ""; this.pasting = false; continue; } const s = this.pending; if (s.startsWith(PASTE_START)) { this.pasting = true; this.pending = s.slice(PASTE_START.length); continue; } if (s[0] === "\x1b") { if (isPartialEscape(s)) break; // wait for the rest of the sequence // ESC + single printable → Alt+key family. const second = s[1]; if (second !== undefined && second !== "[" && second !== "]" && second !== "O") { this.pending = s.slice(2); if (second === "\x7f") events.push({ type: "alt-backspace" }); else if (second === "\r") events.push({ type: "ctrl", ch: "j" }); // Alt+Enter → newline else if (/[a-zA-Z]/.test(second)) events.push({ type: "alt", ch: second.toLowerCase() }); // other ESC+byte pairs dropped continue; } const csi = CSI_SEQ.exec(s); if (csi) { this.pending = s.slice(csi[0].length); const params = (csi[1] ?? "").split(";"); const final = csi[3] as string; const arrow = arrowFor(final); if (arrow) { const mod = params.length >= 2 ? Number(params[1]) : 1; events.push({ type: "arrow", key: arrow, alt: mod === 3 || mod === 9, ctrl: mod === 5 || mod === 7, }); continue; } if (final === "H") { events.push({ type: "home" }); continue; } if (final === "F") { events.push({ type: "end" }); continue; } if (final === "~") { const code = Number(params[0]); if (code === 1 || code === 7) events.push({ type: "home" }); else if (code === 4 || code === 8) events.push({ type: "end" }); else if (code === 3) events.push({ type: "delete" }); continue; } if (final === "u") { // kitty keyboard protocol: CSI unicode-key ; modifiers u const code = Number(params[0]); const mod = params.length >= 2 ? Number(params[1]) : 1; if (code === 13) { events.push(mod === 2 ? { type: "shift-enter" } : { type: "enter" }); } else if (code === 27) { events.push({ type: "esc" }); } else if (code >= 32 && mod <= 1) { events.push({ type: "char", ch: String.fromCodePoint(code) }); } continue; } continue; // unrecognized CSI (probe replies, mouse) — dropped } const osc = OSC_SEQ.exec(s); if (osc) { this.pending = s.slice(osc[0].length); // OSC reply — dropped continue; } if (s.startsWith("\x1bO") && s.length >= 3) { const final = s[2] as string; this.pending = s.slice(3); const arrow = arrowFor(final); if (arrow) events.push({ type: "arrow", key: arrow, alt: false, ctrl: false }); else if (final === "H") events.push({ type: "home" }); else if (final === "F") events.push({ type: "end" }); continue; } // Lone ESC key. this.pending = s.slice(1); events.push({ type: "esc" }); continue; } // Non-escape byte / code point. const cp = s.codePointAt(0) as number; const ch = String.fromCodePoint(cp); this.pending = s.slice(ch.length); if (ch === "\r") events.push({ type: "enter" }); else if (ch === "\n") events.push({ type: "ctrl", ch: "j" }); else if (ch === "\t") events.push({ type: "tab" }); else if (ch === "\x7f") events.push({ type: "backspace" }); else if (ch === "\x08") events.push({ type: "backspace" }); // Ctrl+H else if (cp === 0x1f) events.push({ type: "ctrl", ch: "_" }); else if (cp >= 1 && cp <= 26) { events.push({ type: "ctrl", ch: String.fromCharCode(96 + cp) }); } else if (cp >= 32) { events.push({ type: "char", ch }); } // NUL and other C0 leftovers dropped. } // Chunk-boundary heuristic: terminals deliver escape sequences within one // read. A chunk that ends on exactly ESC is the Esc key, not a torn CSI. if (this.pending === "\x1b" && !this.pasting) { this.pending = ""; events.push({ type: "esc" }); } return events; } } /** Length of the longest suffix of `s` that is a proper prefix of `marker`. */ function tornSuffixLength(s: string, marker: string): number { const max = Math.min(s.length, marker.length - 1); for (let len = max; len > 0; len--) { if (s.endsWith(marker.slice(0, len))) return len; } return 0; }