/** * Terminal key encoding utilities for translating named keys and modifiers * into terminal escape sequences. */ // Named key sequences (without modifiers) const NAMED_KEYS: Record = { // Arrow keys up: "\x1b[A", down: "\x1b[B", left: "\x1b[D", right: "\x1b[C", // Common keys enter: "\r", return: "\r", escape: "\x1b", esc: "\x1b", tab: "\t", space: " ", backspace: "\x7f", bspace: "\x7f", // tmux-style alias // Editing keys delete: "\x1b[3~", del: "\x1b[3~", dc: "\x1b[3~", // tmux-style alias insert: "\x1b[2~", ic: "\x1b[2~", // tmux-style alias // Navigation home: "\x1b[H", end: "\x1b[F", pageup: "\x1b[5~", pgup: "\x1b[5~", ppage: "\x1b[5~", // tmux-style alias pagedown: "\x1b[6~", pgdn: "\x1b[6~", npage: "\x1b[6~", // tmux-style alias // Shift+Tab (backtab) btab: "\x1b[Z", // Function keys f1: "\x1bOP", f2: "\x1bOQ", f3: "\x1bOR", f4: "\x1bOS", f5: "\x1b[15~", f6: "\x1b[17~", f7: "\x1b[18~", f8: "\x1b[19~", f9: "\x1b[20~", f10: "\x1b[21~", f11: "\x1b[23~", f12: "\x1b[24~", // Keypad keys (application mode) kp0: "\x1bOp", kp1: "\x1bOq", kp2: "\x1bOr", kp3: "\x1bOs", kp4: "\x1bOt", kp5: "\x1bOu", kp6: "\x1bOv", kp7: "\x1bOw", kp8: "\x1bOx", kp9: "\x1bOy", "kp/": "\x1bOo", "kp*": "\x1bOj", "kp-": "\x1bOm", "kp+": "\x1bOk", "kp.": "\x1bOn", kpenter: "\x1bOM", }; // Ctrl+key combinations (ctrl+a through ctrl+z, plus some special) const CTRL_KEYS: Record = {}; for (let i = 0; i < 26; i++) { const char = String.fromCharCode(97 + i); // a-z CTRL_KEYS[`ctrl+${char}`] = String.fromCharCode(i + 1); } // Special ctrl combinations CTRL_KEYS["ctrl+["] = "\x1b"; // Same as Escape CTRL_KEYS["ctrl+\\"] = "\x1c"; CTRL_KEYS["ctrl+]"] = "\x1d"; CTRL_KEYS["ctrl+^"] = "\x1e"; CTRL_KEYS["ctrl+_"] = "\x1f"; CTRL_KEYS["ctrl+?"] = "\x7f"; // Same as Backspace // Alt+key sends ESC followed by the key function altKey(char: string): string { return `\x1b${char}`; } // Keys that support xterm modifier encoding (CSI sequences) const MODIFIABLE_KEYS = new Set([ "up", "down", "left", "right", "home", "end", "pageup", "pgup", "ppage", "pagedown", "pgdn", "npage", "insert", "ic", "delete", "del", "dc", ]); // Calculate xterm modifier code: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0) function xtermModifier(shift: boolean, alt: boolean, ctrl: boolean): number { let mod = 1; if (shift) mod += 1; if (alt) mod += 2; if (ctrl) mod += 4; return mod; } // Apply xterm modifier to CSI sequence: ESC[A -> ESC[1;modA function applyXtermModifier(sequence: string, modifier: number): string | null { // Arrow keys: ESC[A -> ESC[1;modA const arrowMatch = sequence.match(/^\x1b\[([A-D])$/); if (arrowMatch) { return `\x1b[1;${modifier}${arrowMatch[1]}`; } // Numbered sequences: ESC[5~ -> ESC[5;mod~ const numMatch = sequence.match(/^\x1b\[(\d+)~$/); if (numMatch) { return `\x1b[${numMatch[1]};${modifier}~`; } // Home/End: ESC[H -> ESC[1;modH, ESC[F -> ESC[1;modF const hfMatch = sequence.match(/^\x1b\[([HF])$/); if (hfMatch) { return `\x1b[1;${modifier}${hfMatch[1]}`; } return null; } // Bracketed paste mode sequences const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; function encodePaste(text: string, bracketed = true): string { if (!bracketed) return text; return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`; } /** Parse a key token and return the escape sequence */ function encodeKeyToken(token: string): string { const normalized = token.trim().toLowerCase(); if (!normalized) return ""; // Check for direct match in named keys if (NAMED_KEYS[normalized]) { return NAMED_KEYS[normalized]; } // Check for ctrl+key if (CTRL_KEYS[normalized]) { return CTRL_KEYS[normalized]; } // Parse modifier prefixes: ctrl+alt+shift+key, c-m-s-key, etc. let rest = normalized; let ctrl = false, alt = false, shift = false; // Support both "ctrl+alt+x" and "c-m-x" syntax while (rest.length > 2) { if (rest.startsWith("ctrl+") || rest.startsWith("ctrl-")) { ctrl = true; rest = rest.slice(5); } else if (rest.startsWith("alt+") || rest.startsWith("alt-")) { alt = true; rest = rest.slice(4); } else if (rest.startsWith("shift+") || rest.startsWith("shift-")) { shift = true; rest = rest.slice(6); } else if (rest.startsWith("c-")) { ctrl = true; rest = rest.slice(2); } else if (rest.startsWith("m-")) { alt = true; rest = rest.slice(2); } else if (rest.startsWith("s-")) { shift = true; rest = rest.slice(2); } else { break; } } // Handle shift+tab specially if (shift && rest === "tab") { return "\x1b[Z"; } // Check if base key is a named key that supports modifiers const baseSeq = NAMED_KEYS[rest]; if (baseSeq && MODIFIABLE_KEYS.has(rest) && (ctrl || alt || shift)) { const mod = xtermModifier(shift, alt, ctrl); if (mod > 1) { const modified = applyXtermModifier(baseSeq, mod); if (modified) return modified; } } // For single character with modifiers if (rest.length === 1) { let char = rest; if (shift && /[a-z]/.test(char)) { char = char.toUpperCase(); } if (ctrl) { const ctrlChar = CTRL_KEYS[`ctrl+${char.toLowerCase()}`]; if (ctrlChar) char = ctrlChar; } if (alt) { return altKey(char); } return char; } // Named key with alt modifier if (baseSeq && alt) { return `\x1b${baseSeq}`; } // Return base sequence if found if (baseSeq) { return baseSeq; } // Unknown key, return as literal return token; } /** Translate input specification to terminal escape sequences */ export function translateInput(input: string | { text?: string; keys?: string[]; paste?: string; hex?: string[] }): string { if (typeof input === "string") { return input; } let result = ""; // Hex bytes (raw escape sequences) if (input.hex?.length) { for (const raw of input.hex) { const trimmed = raw.trim().toLowerCase(); const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed; if (/^[0-9a-f]{1,2}$/.test(normalized)) { const value = Number.parseInt(normalized, 16); if (!Number.isNaN(value) && value >= 0 && value <= 0xff) { result += String.fromCharCode(value); } } } } // Literal text if (input.text) { result += input.text; } // Bracketed paste if (input.paste) { result += encodePaste(input.paste); } // Named keys with modifier support if (input.keys) { for (const key of input.keys) { result += encodeKeyToken(key); } } return result; }