const ESC = "\x1b"; const CR = "\r"; const TAB = "\t"; const BACKSPACE = "\x7f"; export const BRACKETED_PASTE_START = `${ESC}[200~`; export const BRACKETED_PASTE_END = `${ESC}[201~`; type Modifiers = { ctrl: boolean; alt: boolean; shift: boolean; }; function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } const namedKeyMap = new Map([ ["enter", CR], ["return", CR], ["tab", TAB], ["escape", ESC], ["esc", ESC], ["space", " "], ["bspace", BACKSPACE], ["backspace", BACKSPACE], ["up", `${ESC}[A`], ["down", `${ESC}[B`], ["right", `${ESC}[C`], ["left", `${ESC}[D`], ["home", `${ESC}[1~`], ["end", `${ESC}[4~`], ["pageup", `${ESC}[5~`], ["pgup", `${ESC}[5~`], ["ppage", `${ESC}[5~`], ["pagedown", `${ESC}[6~`], ["pgdn", `${ESC}[6~`], ["npage", `${ESC}[6~`], ["insert", `${ESC}[2~`], ["ic", `${ESC}[2~`], ["delete", `${ESC}[3~`], ["del", `${ESC}[3~`], ["dc", `${ESC}[3~`], ["btab", `${ESC}[Z`], ["f1", `${ESC}OP`], ["f2", `${ESC}OQ`], ["f3", `${ESC}OR`], ["f4", `${ESC}OS`], ["f5", `${ESC}[15~`], ["f6", `${ESC}[17~`], ["f7", `${ESC}[18~`], ["f8", `${ESC}[19~`], ["f9", `${ESC}[20~`], ["f10", `${ESC}[21~`], ["f11", `${ESC}[23~`], ["f12", `${ESC}[24~`], ["kp/", `${ESC}Oo`], ["kp*", `${ESC}Oj`], ["kp-", `${ESC}Om`], ["kp+", `${ESC}Ok`], ["kp7", `${ESC}Ow`], ["kp8", `${ESC}Ox`], ["kp9", `${ESC}Oy`], ["kp4", `${ESC}Ot`], ["kp5", `${ESC}Ou`], ["kp6", `${ESC}Ov`], ["kp1", `${ESC}Oq`], ["kp2", `${ESC}Or`], ["kp3", `${ESC}Os`], ["kp0", `${ESC}Op`], ["kp.", `${ESC}On`], ["kpenter", `${ESC}OM`], ]); const modifiableNamedKeys = new Set([ "up", "down", "left", "right", "home", "end", "pageup", "pgup", "ppage", "pagedown", "pgdn", "npage", "insert", "ic", "delete", "del", "dc", ]); export type KeyEncodingRequest = { keys?: string[]; hex?: string[]; literal?: string; }; export type KeyEncodingResult = { data: string; warnings: string[]; }; export function encodeKeySequence(request: KeyEncodingRequest): KeyEncodingResult { const warnings: string[] = []; let data = ""; if (request.literal) { data += request.literal; } if (request.hex?.length) { for (const raw of request.hex) { const byte = parseHexByte(raw); if (byte === null) { warnings.push(`Invalid hex byte: ${raw}`); continue; } data += String.fromCharCode(byte); } } if (request.keys?.length) { for (const token of request.keys) { data += encodeKeyToken(token, warnings); } } return { data, warnings }; } export function encodePaste(text: string, bracketed = true): string { if (!bracketed) return text; return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`; } function encodeKeyToken(raw: string, warnings: string[]): string { const token = raw.trim(); if (!token) return ""; if (token.length === 2 && token.startsWith("^")) { const ctrl = toCtrlChar(token[1]); if (ctrl) return ctrl; } const parsed = parseModifiers(token); const base = parsed.base; const baseLower = base.toLowerCase(); if (baseLower === "tab" && parsed.mods.shift) { return `${ESC}[Z`; } const baseSeq = namedKeyMap.get(baseLower); if (baseSeq) { let seq = baseSeq; if (modifiableNamedKeys.has(baseLower) && hasAnyModifier(parsed.mods)) { const mod = xtermModifier(parsed.mods); if (mod > 1) { const modified = applyXtermModifier(seq, mod); if (modified) { seq = modified; return seq; } } } if (parsed.mods.alt) { return `${ESC}${seq}`; } return seq; } if (base.length === 1) { return applyCharModifiers(base, parsed.mods); } if (parsed.hasModifiers) { warnings.push(`Unknown key "${base}" for modifiers; sending literal.`); } return base; } function parseModifiers(token: string) { const mods: Modifiers = { ctrl: false, alt: false, shift: false }; let rest = token; let sawModifiers = false; while (rest.length > 2 && rest[1] === "-") { const mod = rest[0].toLowerCase(); if (mod === "c") mods.ctrl = true; else if (mod === "m") mods.alt = true; else if (mod === "s") mods.shift = true; else break; sawModifiers = true; rest = rest.slice(2); } return { mods, base: rest, hasModifiers: sawModifiers }; } function applyCharModifiers(char: string, mods: Modifiers): string { let value = char; if (mods.shift && value.length === 1 && /[a-z]/.test(value)) { value = value.toUpperCase(); } if (mods.ctrl) { const ctrl = toCtrlChar(value); if (ctrl) value = ctrl; } if (mods.alt) { value = `${ESC}${value}`; } return value; } function toCtrlChar(char: string): string | null { if (char.length !== 1) return null; if (char === "?") return "\x7f"; const code = char.toUpperCase().charCodeAt(0); if (code >= 64 && code <= 95) { return String.fromCharCode(code & 0x1f); } return null; } function xtermModifier(mods: Modifiers): number { let mod = 1; if (mods.shift) mod += 1; if (mods.alt) mod += 2; if (mods.ctrl) mod += 4; return mod; } function applyXtermModifier(sequence: string, modifier: number): string | null { const escPattern = escapeRegExp(ESC); const csiNumber = new RegExp(`^${escPattern}\\[(\\d+)([~A-Z])$`); const csiArrow = new RegExp(`^${escPattern}\\[(A|B|C|D|H|F)$`); const numberMatch = sequence.match(csiNumber); if (numberMatch) { return `${ESC}[${numberMatch[1]};${modifier}${numberMatch[2]}`; } const arrowMatch = sequence.match(csiArrow); if (arrowMatch) { return `${ESC}[1;${modifier}${arrowMatch[1]}`; } return null; } function hasAnyModifier(mods: Modifiers): boolean { return mods.ctrl || mods.alt || mods.shift; } function parseHexByte(raw: string): number | null { const trimmed = raw.trim().toLowerCase(); const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed; if (!/^[0-9a-f]{1,2}$/.test(normalized)) return null; const value = Number.parseInt(normalized, 16); if (Number.isNaN(value) || value < 0 || value > 0xff) return null; return value; }