Files
clawdbot/src/agents/pty-keys.ts
2026-01-17 06:32:24 +00:00

267 lines
6.1 KiB
TypeScript

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<string, string>([
["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;
}