mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 14:03:49 +00:00
Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals. This is commonly used as SIGQUIT in Unix. Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals. This is commonly used as the telnet escape character. Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals. On US keyboards, - and _ are on the same physical key, so this also functions as an alias for Ctrl+-.
1133 lines
33 KiB
TypeScript
1133 lines
33 KiB
TypeScript
/**
|
||
* Keyboard input handling for terminal applications.
|
||
*
|
||
* Supports both legacy terminal sequences and Kitty keyboard protocol.
|
||
* See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||
* Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts
|
||
*
|
||
* Symbol keys are also supported, however some ctrl+symbol combos
|
||
* overlap with ASCII codes, e.g. ctrl+[ = ESC.
|
||
* See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys
|
||
* Those can still be * used for ctrl+shift combos
|
||
*
|
||
* API:
|
||
* - matchesKey(data, keyId) - Check if input matches a key identifier
|
||
* - parseKey(data) - Parse input and return the key identifier
|
||
* - Key - Helper object for creating typed key identifiers
|
||
* - setKittyProtocolActive(active) - Set global Kitty protocol state
|
||
* - isKittyProtocolActive() - Query global Kitty protocol state
|
||
*/
|
||
|
||
// =============================================================================
|
||
// Global Kitty Protocol State
|
||
// =============================================================================
|
||
|
||
let _kittyProtocolActive = false;
|
||
|
||
/**
|
||
* Set the global Kitty keyboard protocol state.
|
||
* Called by ProcessTerminal after detecting protocol support.
|
||
*/
|
||
export function setKittyProtocolActive(active: boolean): void {
|
||
_kittyProtocolActive = active;
|
||
}
|
||
|
||
/**
|
||
* Query whether Kitty keyboard protocol is currently active.
|
||
*/
|
||
export function isKittyProtocolActive(): boolean {
|
||
return _kittyProtocolActive;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Type-Safe Key Identifiers
|
||
// =============================================================================
|
||
|
||
type Letter =
|
||
| "a"
|
||
| "b"
|
||
| "c"
|
||
| "d"
|
||
| "e"
|
||
| "f"
|
||
| "g"
|
||
| "h"
|
||
| "i"
|
||
| "j"
|
||
| "k"
|
||
| "l"
|
||
| "m"
|
||
| "n"
|
||
| "o"
|
||
| "p"
|
||
| "q"
|
||
| "r"
|
||
| "s"
|
||
| "t"
|
||
| "u"
|
||
| "v"
|
||
| "w"
|
||
| "x"
|
||
| "y"
|
||
| "z";
|
||
|
||
type SymbolKey =
|
||
| "`"
|
||
| "-"
|
||
| "="
|
||
| "["
|
||
| "]"
|
||
| "\\"
|
||
| ";"
|
||
| "'"
|
||
| ","
|
||
| "."
|
||
| "/"
|
||
| "!"
|
||
| "@"
|
||
| "#"
|
||
| "$"
|
||
| "%"
|
||
| "^"
|
||
| "&"
|
||
| "*"
|
||
| "("
|
||
| ")"
|
||
| "_"
|
||
| "+"
|
||
| "|"
|
||
| "~"
|
||
| "{"
|
||
| "}"
|
||
| ":"
|
||
| "<"
|
||
| ">"
|
||
| "?";
|
||
|
||
type SpecialKey =
|
||
| "escape"
|
||
| "esc"
|
||
| "enter"
|
||
| "return"
|
||
| "tab"
|
||
| "space"
|
||
| "backspace"
|
||
| "delete"
|
||
| "insert"
|
||
| "clear"
|
||
| "home"
|
||
| "end"
|
||
| "pageUp"
|
||
| "pageDown"
|
||
| "up"
|
||
| "down"
|
||
| "left"
|
||
| "right"
|
||
| "f1"
|
||
| "f2"
|
||
| "f3"
|
||
| "f4"
|
||
| "f5"
|
||
| "f6"
|
||
| "f7"
|
||
| "f8"
|
||
| "f9"
|
||
| "f10"
|
||
| "f11"
|
||
| "f12";
|
||
|
||
type BaseKey = Letter | SymbolKey | SpecialKey;
|
||
|
||
/**
|
||
* Union type of all valid key identifiers.
|
||
* Provides autocomplete and catches typos at compile time.
|
||
*/
|
||
export type KeyId =
|
||
| BaseKey
|
||
| `ctrl+${BaseKey}`
|
||
| `shift+${BaseKey}`
|
||
| `alt+${BaseKey}`
|
||
| `ctrl+shift+${BaseKey}`
|
||
| `shift+ctrl+${BaseKey}`
|
||
| `ctrl+alt+${BaseKey}`
|
||
| `alt+ctrl+${BaseKey}`
|
||
| `shift+alt+${BaseKey}`
|
||
| `alt+shift+${BaseKey}`
|
||
| `ctrl+shift+alt+${BaseKey}`
|
||
| `ctrl+alt+shift+${BaseKey}`
|
||
| `shift+ctrl+alt+${BaseKey}`
|
||
| `shift+alt+ctrl+${BaseKey}`
|
||
| `alt+ctrl+shift+${BaseKey}`
|
||
| `alt+shift+ctrl+${BaseKey}`;
|
||
|
||
/**
|
||
* Helper object for creating typed key identifiers with autocomplete.
|
||
*
|
||
* Usage:
|
||
* - Key.escape, Key.enter, Key.tab, etc. for special keys
|
||
* - Key.backtick, Key.comma, Key.period, etc. for symbol keys
|
||
* - Key.ctrl("c"), Key.alt("x") for single modifier
|
||
* - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers
|
||
*/
|
||
export const Key = {
|
||
// Special keys
|
||
escape: "escape" as const,
|
||
esc: "esc" as const,
|
||
enter: "enter" as const,
|
||
return: "return" as const,
|
||
tab: "tab" as const,
|
||
space: "space" as const,
|
||
backspace: "backspace" as const,
|
||
delete: "delete" as const,
|
||
insert: "insert" as const,
|
||
clear: "clear" as const,
|
||
home: "home" as const,
|
||
end: "end" as const,
|
||
pageUp: "pageUp" as const,
|
||
pageDown: "pageDown" as const,
|
||
up: "up" as const,
|
||
down: "down" as const,
|
||
left: "left" as const,
|
||
right: "right" as const,
|
||
f1: "f1" as const,
|
||
f2: "f2" as const,
|
||
f3: "f3" as const,
|
||
f4: "f4" as const,
|
||
f5: "f5" as const,
|
||
f6: "f6" as const,
|
||
f7: "f7" as const,
|
||
f8: "f8" as const,
|
||
f9: "f9" as const,
|
||
f10: "f10" as const,
|
||
f11: "f11" as const,
|
||
f12: "f12" as const,
|
||
|
||
// Symbol keys
|
||
backtick: "`" as const,
|
||
hyphen: "-" as const,
|
||
equals: "=" as const,
|
||
leftbracket: "[" as const,
|
||
rightbracket: "]" as const,
|
||
backslash: "\\" as const,
|
||
semicolon: ";" as const,
|
||
quote: "'" as const,
|
||
comma: "," as const,
|
||
period: "." as const,
|
||
slash: "/" as const,
|
||
exclamation: "!" as const,
|
||
at: "@" as const,
|
||
hash: "#" as const,
|
||
dollar: "$" as const,
|
||
percent: "%" as const,
|
||
caret: "^" as const,
|
||
ampersand: "&" as const,
|
||
asterisk: "*" as const,
|
||
leftparen: "(" as const,
|
||
rightparen: ")" as const,
|
||
underscore: "_" as const,
|
||
plus: "+" as const,
|
||
pipe: "|" as const,
|
||
tilde: "~" as const,
|
||
leftbrace: "{" as const,
|
||
rightbrace: "}" as const,
|
||
colon: ":" as const,
|
||
lessthan: "<" as const,
|
||
greaterthan: ">" as const,
|
||
question: "?" as const,
|
||
|
||
// Single modifiers
|
||
ctrl: <K extends BaseKey>(key: K): `ctrl+${K}` => `ctrl+${key}`,
|
||
shift: <K extends BaseKey>(key: K): `shift+${K}` => `shift+${key}`,
|
||
alt: <K extends BaseKey>(key: K): `alt+${K}` => `alt+${key}`,
|
||
|
||
// Combined modifiers
|
||
ctrlShift: <K extends BaseKey>(key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`,
|
||
shiftCtrl: <K extends BaseKey>(key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`,
|
||
ctrlAlt: <K extends BaseKey>(key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`,
|
||
altCtrl: <K extends BaseKey>(key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`,
|
||
shiftAlt: <K extends BaseKey>(key: K): `shift+alt+${K}` => `shift+alt+${key}`,
|
||
altShift: <K extends BaseKey>(key: K): `alt+shift+${K}` => `alt+shift+${key}`,
|
||
|
||
// Triple modifiers
|
||
ctrlShiftAlt: <K extends BaseKey>(key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`,
|
||
} as const;
|
||
|
||
// =============================================================================
|
||
// Constants
|
||
// =============================================================================
|
||
|
||
const SYMBOL_KEYS = new Set([
|
||
"`",
|
||
"-",
|
||
"=",
|
||
"[",
|
||
"]",
|
||
"\\",
|
||
";",
|
||
"'",
|
||
",",
|
||
".",
|
||
"/",
|
||
"!",
|
||
"@",
|
||
"#",
|
||
"$",
|
||
"%",
|
||
"^",
|
||
"&",
|
||
"*",
|
||
"(",
|
||
")",
|
||
"_",
|
||
"+",
|
||
"|",
|
||
"~",
|
||
"{",
|
||
"}",
|
||
":",
|
||
"<",
|
||
">",
|
||
"?",
|
||
]);
|
||
|
||
const MODIFIERS = {
|
||
shift: 1,
|
||
alt: 2,
|
||
ctrl: 4,
|
||
} as const;
|
||
|
||
const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
|
||
|
||
const CODEPOINTS = {
|
||
escape: 27,
|
||
tab: 9,
|
||
enter: 13,
|
||
space: 32,
|
||
backspace: 127,
|
||
kpEnter: 57414, // Numpad Enter (Kitty protocol)
|
||
} as const;
|
||
|
||
const ARROW_CODEPOINTS = {
|
||
up: -1,
|
||
down: -2,
|
||
right: -3,
|
||
left: -4,
|
||
} as const;
|
||
|
||
const FUNCTIONAL_CODEPOINTS = {
|
||
delete: -10,
|
||
insert: -11,
|
||
pageUp: -12,
|
||
pageDown: -13,
|
||
home: -14,
|
||
end: -15,
|
||
} as const;
|
||
|
||
const LEGACY_KEY_SEQUENCES = {
|
||
up: ["\x1b[A", "\x1bOA"],
|
||
down: ["\x1b[B", "\x1bOB"],
|
||
right: ["\x1b[C", "\x1bOC"],
|
||
left: ["\x1b[D", "\x1bOD"],
|
||
home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"],
|
||
end: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"],
|
||
insert: ["\x1b[2~"],
|
||
delete: ["\x1b[3~"],
|
||
pageUp: ["\x1b[5~", "\x1b[[5~"],
|
||
pageDown: ["\x1b[6~", "\x1b[[6~"],
|
||
clear: ["\x1b[E", "\x1bOE"],
|
||
f1: ["\x1bOP", "\x1b[11~", "\x1b[[A"],
|
||
f2: ["\x1bOQ", "\x1b[12~", "\x1b[[B"],
|
||
f3: ["\x1bOR", "\x1b[13~", "\x1b[[C"],
|
||
f4: ["\x1bOS", "\x1b[14~", "\x1b[[D"],
|
||
f5: ["\x1b[15~", "\x1b[[E"],
|
||
f6: ["\x1b[17~"],
|
||
f7: ["\x1b[18~"],
|
||
f8: ["\x1b[19~"],
|
||
f9: ["\x1b[20~"],
|
||
f10: ["\x1b[21~"],
|
||
f11: ["\x1b[23~"],
|
||
f12: ["\x1b[24~"],
|
||
} as const;
|
||
|
||
const LEGACY_SHIFT_SEQUENCES = {
|
||
up: ["\x1b[a"],
|
||
down: ["\x1b[b"],
|
||
right: ["\x1b[c"],
|
||
left: ["\x1b[d"],
|
||
clear: ["\x1b[e"],
|
||
insert: ["\x1b[2$"],
|
||
delete: ["\x1b[3$"],
|
||
pageUp: ["\x1b[5$"],
|
||
pageDown: ["\x1b[6$"],
|
||
home: ["\x1b[7$"],
|
||
end: ["\x1b[8$"],
|
||
} as const;
|
||
|
||
const LEGACY_CTRL_SEQUENCES = {
|
||
up: ["\x1bOa"],
|
||
down: ["\x1bOb"],
|
||
right: ["\x1bOc"],
|
||
left: ["\x1bOd"],
|
||
clear: ["\x1bOe"],
|
||
insert: ["\x1b[2^"],
|
||
delete: ["\x1b[3^"],
|
||
pageUp: ["\x1b[5^"],
|
||
pageDown: ["\x1b[6^"],
|
||
home: ["\x1b[7^"],
|
||
end: ["\x1b[8^"],
|
||
} as const;
|
||
|
||
const LEGACY_SEQUENCE_KEY_IDS: Record<string, KeyId> = {
|
||
"\x1bOA": "up",
|
||
"\x1bOB": "down",
|
||
"\x1bOC": "right",
|
||
"\x1bOD": "left",
|
||
"\x1bOH": "home",
|
||
"\x1bOF": "end",
|
||
"\x1b[E": "clear",
|
||
"\x1bOE": "clear",
|
||
"\x1bOe": "ctrl+clear",
|
||
"\x1b[e": "shift+clear",
|
||
"\x1b[2~": "insert",
|
||
"\x1b[2$": "shift+insert",
|
||
"\x1b[2^": "ctrl+insert",
|
||
"\x1b[3$": "shift+delete",
|
||
"\x1b[3^": "ctrl+delete",
|
||
"\x1b[[5~": "pageUp",
|
||
"\x1b[[6~": "pageDown",
|
||
"\x1b[a": "shift+up",
|
||
"\x1b[b": "shift+down",
|
||
"\x1b[c": "shift+right",
|
||
"\x1b[d": "shift+left",
|
||
"\x1bOa": "ctrl+up",
|
||
"\x1bOb": "ctrl+down",
|
||
"\x1bOc": "ctrl+right",
|
||
"\x1bOd": "ctrl+left",
|
||
"\x1b[5$": "shift+pageUp",
|
||
"\x1b[6$": "shift+pageDown",
|
||
"\x1b[7$": "shift+home",
|
||
"\x1b[8$": "shift+end",
|
||
"\x1b[5^": "ctrl+pageUp",
|
||
"\x1b[6^": "ctrl+pageDown",
|
||
"\x1b[7^": "ctrl+home",
|
||
"\x1b[8^": "ctrl+end",
|
||
"\x1bOP": "f1",
|
||
"\x1bOQ": "f2",
|
||
"\x1bOR": "f3",
|
||
"\x1bOS": "f4",
|
||
"\x1b[11~": "f1",
|
||
"\x1b[12~": "f2",
|
||
"\x1b[13~": "f3",
|
||
"\x1b[14~": "f4",
|
||
"\x1b[[A": "f1",
|
||
"\x1b[[B": "f2",
|
||
"\x1b[[C": "f3",
|
||
"\x1b[[D": "f4",
|
||
"\x1b[[E": "f5",
|
||
"\x1b[15~": "f5",
|
||
"\x1b[17~": "f6",
|
||
"\x1b[18~": "f7",
|
||
"\x1b[19~": "f8",
|
||
"\x1b[20~": "f9",
|
||
"\x1b[21~": "f10",
|
||
"\x1b[23~": "f11",
|
||
"\x1b[24~": "f12",
|
||
"\x1bb": "alt+left",
|
||
"\x1bf": "alt+right",
|
||
"\x1bp": "alt+up",
|
||
"\x1bn": "alt+down",
|
||
} as const;
|
||
|
||
type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES;
|
||
|
||
const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean => sequences.includes(data);
|
||
|
||
const matchesLegacyModifierSequence = (data: string, key: LegacyModifierKey, modifier: number): boolean => {
|
||
if (modifier === MODIFIERS.shift) {
|
||
return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]);
|
||
}
|
||
if (modifier === MODIFIERS.ctrl) {
|
||
return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// =============================================================================
|
||
// Kitty Protocol Parsing
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Event types from Kitty keyboard protocol (flag 2)
|
||
* 1 = key press, 2 = key repeat, 3 = key release
|
||
*/
|
||
export type KeyEventType = "press" | "repeat" | "release";
|
||
|
||
interface ParsedKittySequence {
|
||
codepoint: number;
|
||
shiftedKey?: number; // Shifted version of the key (when shift is pressed)
|
||
baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts)
|
||
modifier: number;
|
||
eventType: KeyEventType;
|
||
}
|
||
|
||
// Store the last parsed event type for isKeyRelease() to query
|
||
let _lastEventType: KeyEventType = "press";
|
||
|
||
/**
|
||
* Check if the last parsed key event was a key release.
|
||
* Only meaningful when Kitty keyboard protocol with flag 2 is active.
|
||
*/
|
||
export function isKeyRelease(data: string): boolean {
|
||
// Don't treat bracketed paste content as key release, even if it contains
|
||
// patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5").
|
||
// Terminal.ts re-wraps paste content with bracketed paste markers before
|
||
// passing to TUI, so pasted data will always contain \x1b[200~.
|
||
if (data.includes("\x1b[200~")) {
|
||
return false;
|
||
}
|
||
|
||
// Quick check: release events with flag 2 contain ":3"
|
||
// Format: \x1b[<codepoint>;<modifier>:3u
|
||
if (
|
||
data.includes(":3u") ||
|
||
data.includes(":3~") ||
|
||
data.includes(":3A") ||
|
||
data.includes(":3B") ||
|
||
data.includes(":3C") ||
|
||
data.includes(":3D") ||
|
||
data.includes(":3H") ||
|
||
data.includes(":3F")
|
||
) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Check if the last parsed key event was a key repeat.
|
||
* Only meaningful when Kitty keyboard protocol with flag 2 is active.
|
||
*/
|
||
export function isKeyRepeat(data: string): boolean {
|
||
// Don't treat bracketed paste content as key repeat, even if it contains
|
||
// patterns like ":2F". See isKeyRelease() for details.
|
||
if (data.includes("\x1b[200~")) {
|
||
return false;
|
||
}
|
||
|
||
if (
|
||
data.includes(":2u") ||
|
||
data.includes(":2~") ||
|
||
data.includes(":2A") ||
|
||
data.includes(":2B") ||
|
||
data.includes(":2C") ||
|
||
data.includes(":2D") ||
|
||
data.includes(":2H") ||
|
||
data.includes(":2F")
|
||
) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function parseEventType(eventTypeStr: string | undefined): KeyEventType {
|
||
if (!eventTypeStr) return "press";
|
||
const eventType = parseInt(eventTypeStr, 10);
|
||
if (eventType === 2) return "repeat";
|
||
if (eventType === 3) return "release";
|
||
return "press";
|
||
}
|
||
|
||
function parseKittySequence(data: string): ParsedKittySequence | null {
|
||
// CSI u format with alternate keys (flag 4):
|
||
// \x1b[<codepoint>u
|
||
// \x1b[<codepoint>;<mod>u
|
||
// \x1b[<codepoint>;<mod>:<event>u
|
||
// \x1b[<codepoint>:<shifted>;<mod>u
|
||
// \x1b[<codepoint>:<shifted>:<base>;<mod>u
|
||
// \x1b[<codepoint>::<base>;<mod>u (no shifted key, only base)
|
||
//
|
||
// With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release
|
||
// With flag 4, alternate keys are appended after codepoint with colons
|
||
const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/);
|
||
if (csiUMatch) {
|
||
const codepoint = parseInt(csiUMatch[1]!, 10);
|
||
const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined;
|
||
const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined;
|
||
const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1;
|
||
const eventType = parseEventType(csiUMatch[5]);
|
||
_lastEventType = eventType;
|
||
return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType };
|
||
}
|
||
|
||
// Arrow keys with modifier: \x1b[1;<mod>A/B/C/D or \x1b[1;<mod>:<event>A/B/C/D
|
||
const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/);
|
||
if (arrowMatch) {
|
||
const modValue = parseInt(arrowMatch[1]!, 10);
|
||
const eventType = parseEventType(arrowMatch[2]);
|
||
const arrowCodes: Record<string, number> = { A: -1, B: -2, C: -3, D: -4 };
|
||
_lastEventType = eventType;
|
||
return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType };
|
||
}
|
||
|
||
// Functional keys: \x1b[<num>~ or \x1b[<num>;<mod>~ or \x1b[<num>;<mod>:<event>~
|
||
const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/);
|
||
if (funcMatch) {
|
||
const keyNum = parseInt(funcMatch[1]!, 10);
|
||
const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1;
|
||
const eventType = parseEventType(funcMatch[3]);
|
||
const funcCodes: Record<number, number> = {
|
||
2: FUNCTIONAL_CODEPOINTS.insert,
|
||
3: FUNCTIONAL_CODEPOINTS.delete,
|
||
5: FUNCTIONAL_CODEPOINTS.pageUp,
|
||
6: FUNCTIONAL_CODEPOINTS.pageDown,
|
||
7: FUNCTIONAL_CODEPOINTS.home,
|
||
8: FUNCTIONAL_CODEPOINTS.end,
|
||
};
|
||
const codepoint = funcCodes[keyNum];
|
||
if (codepoint !== undefined) {
|
||
_lastEventType = eventType;
|
||
return { codepoint, modifier: modValue - 1, eventType };
|
||
}
|
||
}
|
||
|
||
// Home/End with modifier: \x1b[1;<mod>H/F or \x1b[1;<mod>:<event>H/F
|
||
const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/);
|
||
if (homeEndMatch) {
|
||
const modValue = parseInt(homeEndMatch[1]!, 10);
|
||
const eventType = parseEventType(homeEndMatch[2]);
|
||
const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end;
|
||
_lastEventType = eventType;
|
||
return { codepoint, modifier: modValue - 1, eventType };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
|
||
const parsed = parseKittySequence(data);
|
||
if (!parsed) return false;
|
||
const actualMod = parsed.modifier & ~LOCK_MASK;
|
||
const expectedMod = expectedModifier & ~LOCK_MASK;
|
||
|
||
// Check if modifiers match
|
||
if (actualMod !== expectedMod) return false;
|
||
|
||
// Primary match: codepoint matches directly
|
||
if (parsed.codepoint === expectedCodepoint) return true;
|
||
|
||
// Alternate match: use base layout key for non-Latin keyboard layouts
|
||
// This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
|
||
// the base layout key (the key in standard PC-101 layout)
|
||
if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~
|
||
* This is used by terminals when Kitty protocol is not enabled.
|
||
* Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc.
|
||
*/
|
||
function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean {
|
||
const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/);
|
||
if (!match) return false;
|
||
const modValue = parseInt(match[1]!, 10);
|
||
const keycode = parseInt(match[2]!, 10);
|
||
// Convert from 1-indexed xterm format to our 0-indexed format
|
||
const actualMod = modValue - 1;
|
||
return keycode === expectedKeycode && actualMod === expectedModifier;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Generic Key Matching
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Get the control character for a key.
|
||
* Uses the universal formula: code & 0x1f (mask to lower 5 bits)
|
||
*
|
||
* Works for:
|
||
* - Letters a-z → 1-26
|
||
* - Symbols [\]_ → 27, 28, 29, 31
|
||
* - Also maps - to same as _ (same physical key on US keyboards)
|
||
*/
|
||
function rawCtrlChar(key: string): string | null {
|
||
const char = key.toLowerCase();
|
||
const code = char.charCodeAt(0);
|
||
if ((code >= 97 && code <= 122) || char === "[" || char === "\\" || char === "]" || char === "_") {
|
||
return String.fromCharCode(code & 0x1f);
|
||
}
|
||
// Handle - as _ (same physical key on US keyboards)
|
||
if (char === "-") {
|
||
return String.fromCharCode(31); // Same as Ctrl+_
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null {
|
||
const parts = keyId.toLowerCase().split("+");
|
||
const key = parts[parts.length - 1];
|
||
if (!key) return null;
|
||
return {
|
||
key,
|
||
ctrl: parts.includes("ctrl"),
|
||
shift: parts.includes("shift"),
|
||
alt: parts.includes("alt"),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Match input data against a key identifier string.
|
||
*
|
||
* Supported key identifiers:
|
||
* - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space"
|
||
* - Arrow keys: "up", "down", "left", "right"
|
||
* - Ctrl combinations: "ctrl+c", "ctrl+z", etc.
|
||
* - Shift combinations: "shift+tab", "shift+enter"
|
||
* - Alt combinations: "alt+enter", "alt+backspace"
|
||
* - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x"
|
||
*
|
||
* Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p")
|
||
*
|
||
* @param data - Raw input data from terminal
|
||
* @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
|
||
*/
|
||
export function matchesKey(data: string, keyId: KeyId): boolean {
|
||
const parsed = parseKeyId(keyId);
|
||
if (!parsed) return false;
|
||
|
||
const { key, ctrl, shift, alt } = parsed;
|
||
let modifier = 0;
|
||
if (shift) modifier |= MODIFIERS.shift;
|
||
if (alt) modifier |= MODIFIERS.alt;
|
||
if (ctrl) modifier |= MODIFIERS.ctrl;
|
||
|
||
switch (key) {
|
||
case "escape":
|
||
case "esc":
|
||
if (modifier !== 0) return false;
|
||
return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0);
|
||
|
||
case "space":
|
||
if (!_kittyProtocolActive) {
|
||
if (ctrl && !alt && !shift && data === "\x00") {
|
||
return true;
|
||
}
|
||
if (alt && !ctrl && !shift && data === "\x1b ") {
|
||
return true;
|
||
}
|
||
}
|
||
if (modifier === 0) {
|
||
return data === " " || matchesKittySequence(data, CODEPOINTS.space, 0);
|
||
}
|
||
return matchesKittySequence(data, CODEPOINTS.space, modifier);
|
||
|
||
case "tab":
|
||
if (shift && !ctrl && !alt) {
|
||
return data === "\x1b[Z" || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift);
|
||
}
|
||
if (modifier === 0) {
|
||
return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0);
|
||
}
|
||
return matchesKittySequence(data, CODEPOINTS.tab, modifier);
|
||
|
||
case "enter":
|
||
case "return":
|
||
if (shift && !ctrl && !alt) {
|
||
// CSI u sequences (standard Kitty protocol)
|
||
if (
|
||
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
|
||
matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift)
|
||
) {
|
||
return true;
|
||
}
|
||
// xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)
|
||
if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) {
|
||
return true;
|
||
}
|
||
// When Kitty protocol is active, legacy sequences are custom terminal mappings
|
||
// \x1b\r = Kitty's "map shift+enter send_text all \e\r"
|
||
// \n = Ghostty's "keybind = shift+enter=text:\n"
|
||
if (_kittyProtocolActive) {
|
||
return data === "\x1b\r" || data === "\n";
|
||
}
|
||
return false;
|
||
}
|
||
if (alt && !ctrl && !shift) {
|
||
// CSI u sequences (standard Kitty protocol)
|
||
if (
|
||
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
|
||
matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt)
|
||
) {
|
||
return true;
|
||
}
|
||
// xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)
|
||
if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) {
|
||
return true;
|
||
}
|
||
// \x1b\r is alt+enter only in legacy mode (no Kitty protocol)
|
||
// When Kitty protocol is active, alt+enter comes as CSI u sequence
|
||
if (!_kittyProtocolActive) {
|
||
return data === "\x1b\r";
|
||
}
|
||
return false;
|
||
}
|
||
if (modifier === 0) {
|
||
return (
|
||
data === "\r" ||
|
||
(!_kittyProtocolActive && data === "\n") ||
|
||
data === "\x1bOM" || // SS3 M (numpad enter in some terminals)
|
||
matchesKittySequence(data, CODEPOINTS.enter, 0) ||
|
||
matchesKittySequence(data, CODEPOINTS.kpEnter, 0)
|
||
);
|
||
}
|
||
return (
|
||
matchesKittySequence(data, CODEPOINTS.enter, modifier) ||
|
||
matchesKittySequence(data, CODEPOINTS.kpEnter, modifier)
|
||
);
|
||
|
||
case "backspace":
|
||
if (alt && !ctrl && !shift) {
|
||
if (data === "\x1b\x7f" || data === "\x1b\b") {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt);
|
||
}
|
||
if (modifier === 0) {
|
||
return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0);
|
||
}
|
||
return matchesKittySequence(data, CODEPOINTS.backspace, modifier);
|
||
|
||
case "insert":
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) ||
|
||
matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "insert", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, modifier);
|
||
|
||
case "delete":
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) ||
|
||
matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "delete", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier);
|
||
|
||
case "clear":
|
||
if (modifier === 0) {
|
||
return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear);
|
||
}
|
||
return matchesLegacyModifierSequence(data, "clear", modifier);
|
||
|
||
case "home":
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) ||
|
||
matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "home", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier);
|
||
|
||
case "end":
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) ||
|
||
matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "end", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier);
|
||
|
||
case "pageup":
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) ||
|
||
matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "pageUp", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier);
|
||
|
||
case "pagedown":
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) ||
|
||
matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "pageDown", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier);
|
||
|
||
case "up":
|
||
if (alt && !ctrl && !shift) {
|
||
return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt);
|
||
}
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.up, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "up", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier);
|
||
|
||
case "down":
|
||
if (alt && !ctrl && !shift) {
|
||
return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt);
|
||
}
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.down, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "down", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier);
|
||
|
||
case "left":
|
||
if (alt && !ctrl && !shift) {
|
||
return (
|
||
data === "\x1b[1;3D" ||
|
||
(!_kittyProtocolActive && data === "\x1bB") ||
|
||
data === "\x1bb" ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt)
|
||
);
|
||
}
|
||
if (ctrl && !alt && !shift) {
|
||
return (
|
||
data === "\x1b[1;5D" ||
|
||
matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl)
|
||
);
|
||
}
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.left, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "left", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier);
|
||
|
||
case "right":
|
||
if (alt && !ctrl && !shift) {
|
||
return (
|
||
data === "\x1b[1;3C" ||
|
||
(!_kittyProtocolActive && data === "\x1bF") ||
|
||
data === "\x1bf" ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt)
|
||
);
|
||
}
|
||
if (ctrl && !alt && !shift) {
|
||
return (
|
||
data === "\x1b[1;5C" ||
|
||
matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl)
|
||
);
|
||
}
|
||
if (modifier === 0) {
|
||
return (
|
||
matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) ||
|
||
matchesKittySequence(data, ARROW_CODEPOINTS.right, 0)
|
||
);
|
||
}
|
||
if (matchesLegacyModifierSequence(data, "right", modifier)) {
|
||
return true;
|
||
}
|
||
return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier);
|
||
|
||
case "f1":
|
||
case "f2":
|
||
case "f3":
|
||
case "f4":
|
||
case "f5":
|
||
case "f6":
|
||
case "f7":
|
||
case "f8":
|
||
case "f9":
|
||
case "f10":
|
||
case "f11":
|
||
case "f12": {
|
||
if (modifier !== 0) {
|
||
return false;
|
||
}
|
||
const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES;
|
||
return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]);
|
||
}
|
||
}
|
||
|
||
// Handle single letter keys (a-z) and some symbols
|
||
if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) {
|
||
const codepoint = key.charCodeAt(0);
|
||
const rawCtrl = rawCtrlChar(key);
|
||
|
||
if (ctrl && alt && !shift && !_kittyProtocolActive && rawCtrl) {
|
||
// Legacy: ctrl+alt+key is ESC followed by the control character
|
||
return data === `\x1b${rawCtrl}`;
|
||
}
|
||
|
||
if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") {
|
||
// Legacy: alt+letter is ESC followed by the letter
|
||
if (data === `\x1b${key}`) return true;
|
||
}
|
||
|
||
if (ctrl && !shift && !alt) {
|
||
// Legacy: ctrl+key sends the control character
|
||
if (rawCtrl && data === rawCtrl) return true;
|
||
return matchesKittySequence(data, codepoint, MODIFIERS.ctrl);
|
||
}
|
||
|
||
if (ctrl && shift && !alt) {
|
||
return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl);
|
||
}
|
||
|
||
if (shift && !ctrl && !alt) {
|
||
// Legacy: shift+letter produces uppercase
|
||
if (data === key.toUpperCase()) return true;
|
||
return matchesKittySequence(data, codepoint, MODIFIERS.shift);
|
||
}
|
||
|
||
if (modifier !== 0) {
|
||
return matchesKittySequence(data, codepoint, modifier);
|
||
}
|
||
|
||
// Check both raw char and Kitty sequence (needed for release events)
|
||
return data === key || matchesKittySequence(data, codepoint, 0);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Parse input data and return the key identifier if recognized.
|
||
*
|
||
* @param data - Raw input data from terminal
|
||
* @returns Key identifier string (e.g., "ctrl+c") or undefined
|
||
*/
|
||
export function parseKey(data: string): string | undefined {
|
||
const kitty = parseKittySequence(data);
|
||
if (kitty) {
|
||
const { codepoint, baseLayoutKey, modifier } = kitty;
|
||
const mods: string[] = [];
|
||
const effectiveMod = modifier & ~LOCK_MASK;
|
||
if (effectiveMod & MODIFIERS.shift) mods.push("shift");
|
||
if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl");
|
||
if (effectiveMod & MODIFIERS.alt) mods.push("alt");
|
||
|
||
// Prefer base layout key for consistent shortcut naming across keyboard layouts
|
||
// This ensures Ctrl+С (Cyrillic) is reported as "ctrl+c" (Latin)
|
||
const effectiveCodepoint = baseLayoutKey ?? codepoint;
|
||
|
||
let keyName: string | undefined;
|
||
if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape";
|
||
else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab";
|
||
else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter";
|
||
else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space";
|
||
else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace";
|
||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete";
|
||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert";
|
||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home";
|
||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
|
||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
|
||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
|
||
else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up";
|
||
else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down";
|
||
else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left";
|
||
else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right";
|
||
else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint);
|
||
else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint)))
|
||
keyName = String.fromCharCode(effectiveCodepoint);
|
||
|
||
if (keyName) {
|
||
return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName;
|
||
}
|
||
}
|
||
|
||
// Mode-aware legacy sequences
|
||
// When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings:
|
||
// - \x1b\r = shift+enter (Kitty mapping), not alt+enter
|
||
// - \n = shift+enter (Ghostty mapping)
|
||
if (_kittyProtocolActive) {
|
||
if (data === "\x1b\r" || data === "\n") return "shift+enter";
|
||
}
|
||
|
||
const legacySequenceKeyId = LEGACY_SEQUENCE_KEY_IDS[data];
|
||
if (legacySequenceKeyId) return legacySequenceKeyId;
|
||
|
||
// Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences)
|
||
if (data === "\x1b") return "escape";
|
||
if (data === "\x1c") return "ctrl+\\";
|
||
if (data === "\x1d") return "ctrl+]";
|
||
if (data === "\x1f") return "ctrl+-";
|
||
if (data === "\x1b\x1b") return "ctrl+alt+[";
|
||
if (data === "\x1b\x1c") return "ctrl+alt+\\";
|
||
if (data === "\x1b\x1d") return "ctrl+alt+]";
|
||
if (data === "\x1b\x1f") return "ctrl+alt+-";
|
||
if (data === "\t") return "tab";
|
||
if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter";
|
||
if (data === "\x00") return "ctrl+space";
|
||
if (data === " ") return "space";
|
||
if (data === "\x7f" || data === "\x08") return "backspace";
|
||
if (data === "\x1b[Z") return "shift+tab";
|
||
if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter";
|
||
if (!_kittyProtocolActive && data === "\x1b ") return "alt+space";
|
||
if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace";
|
||
if (!_kittyProtocolActive && data === "\x1bB") return "alt+left";
|
||
if (!_kittyProtocolActive && data === "\x1bF") return "alt+right";
|
||
if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") {
|
||
const code = data.charCodeAt(1);
|
||
if (code >= 1 && code <= 26) {
|
||
return `ctrl+alt+${String.fromCharCode(code + 96)}`;
|
||
}
|
||
// Legacy alt+letter (ESC followed by letter a-z)
|
||
if (code >= 97 && code <= 122) {
|
||
return `alt+${String.fromCharCode(code)}`;
|
||
}
|
||
}
|
||
if (data === "\x1b[A") return "up";
|
||
if (data === "\x1b[B") return "down";
|
||
if (data === "\x1b[C") return "right";
|
||
if (data === "\x1b[D") return "left";
|
||
if (data === "\x1b[H" || data === "\x1bOH") return "home";
|
||
if (data === "\x1b[F" || data === "\x1bOF") return "end";
|
||
if (data === "\x1b[3~") return "delete";
|
||
if (data === "\x1b[5~") return "pageUp";
|
||
if (data === "\x1b[6~") return "pageDown";
|
||
|
||
// Raw Ctrl+letter
|
||
if (data.length === 1) {
|
||
const code = data.charCodeAt(0);
|
||
if (code >= 1 && code <= 26) {
|
||
return `ctrl+${String.fromCharCode(code + 96)}`;
|
||
}
|
||
if (code >= 32 && code <= 126) {
|
||
return data;
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
}
|