Files
pi-config/extensions/pi-interactive-shell/key-encoding.ts

271 lines
6.4 KiB
TypeScript

/**
* Terminal key encoding utilities for translating named keys and modifiers
* into terminal escape sequences.
*/
// Named key sequences (without modifiers)
const NAMED_KEYS: Record<string, string> = {
// 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<string, string> = {};
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;
}