959 lines
35 KiB
JavaScript
959 lines
35 KiB
JavaScript
/**
|
||
* 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) {
|
||
_kittyProtocolActive = active;
|
||
}
|
||
/**
|
||
* Query whether Kitty keyboard protocol is currently active.
|
||
*/
|
||
export function isKittyProtocolActive() {
|
||
return _kittyProtocolActive;
|
||
}
|
||
/**
|
||
* 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",
|
||
esc: "esc",
|
||
enter: "enter",
|
||
return: "return",
|
||
tab: "tab",
|
||
space: "space",
|
||
backspace: "backspace",
|
||
delete: "delete",
|
||
insert: "insert",
|
||
clear: "clear",
|
||
home: "home",
|
||
end: "end",
|
||
pageUp: "pageUp",
|
||
pageDown: "pageDown",
|
||
up: "up",
|
||
down: "down",
|
||
left: "left",
|
||
right: "right",
|
||
f1: "f1",
|
||
f2: "f2",
|
||
f3: "f3",
|
||
f4: "f4",
|
||
f5: "f5",
|
||
f6: "f6",
|
||
f7: "f7",
|
||
f8: "f8",
|
||
f9: "f9",
|
||
f10: "f10",
|
||
f11: "f11",
|
||
f12: "f12",
|
||
// Symbol keys
|
||
backtick: "`",
|
||
hyphen: "-",
|
||
equals: "=",
|
||
leftbracket: "[",
|
||
rightbracket: "]",
|
||
backslash: "\\",
|
||
semicolon: ";",
|
||
quote: "'",
|
||
comma: ",",
|
||
period: ".",
|
||
slash: "/",
|
||
exclamation: "!",
|
||
at: "@",
|
||
hash: "#",
|
||
dollar: "$",
|
||
percent: "%",
|
||
caret: "^",
|
||
ampersand: "&",
|
||
asterisk: "*",
|
||
leftparen: "(",
|
||
rightparen: ")",
|
||
underscore: "_",
|
||
plus: "+",
|
||
pipe: "|",
|
||
tilde: "~",
|
||
leftbrace: "{",
|
||
rightbrace: "}",
|
||
colon: ":",
|
||
lessthan: "<",
|
||
greaterthan: ">",
|
||
question: "?",
|
||
// Single modifiers
|
||
ctrl: (key) => `ctrl+${key}`,
|
||
shift: (key) => `shift+${key}`,
|
||
alt: (key) => `alt+${key}`,
|
||
// Combined modifiers
|
||
ctrlShift: (key) => `ctrl+shift+${key}`,
|
||
shiftCtrl: (key) => `shift+ctrl+${key}`,
|
||
ctrlAlt: (key) => `ctrl+alt+${key}`,
|
||
altCtrl: (key) => `alt+ctrl+${key}`,
|
||
shiftAlt: (key) => `shift+alt+${key}`,
|
||
altShift: (key) => `alt+shift+${key}`,
|
||
// Triple modifiers
|
||
ctrlShiftAlt: (key) => `ctrl+shift+alt+${key}`,
|
||
};
|
||
// =============================================================================
|
||
// Constants
|
||
// =============================================================================
|
||
const SYMBOL_KEYS = new Set([
|
||
"`",
|
||
"-",
|
||
"=",
|
||
"[",
|
||
"]",
|
||
"\\",
|
||
";",
|
||
"'",
|
||
",",
|
||
".",
|
||
"/",
|
||
"!",
|
||
"@",
|
||
"#",
|
||
"$",
|
||
"%",
|
||
"^",
|
||
"&",
|
||
"*",
|
||
"(",
|
||
")",
|
||
"_",
|
||
"+",
|
||
"|",
|
||
"~",
|
||
"{",
|
||
"}",
|
||
":",
|
||
"<",
|
||
">",
|
||
"?",
|
||
]);
|
||
const MODIFIERS = {
|
||
shift: 1,
|
||
alt: 2,
|
||
ctrl: 4,
|
||
};
|
||
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)
|
||
};
|
||
const ARROW_CODEPOINTS = {
|
||
up: -1,
|
||
down: -2,
|
||
right: -3,
|
||
left: -4,
|
||
};
|
||
const FUNCTIONAL_CODEPOINTS = {
|
||
delete: -10,
|
||
insert: -11,
|
||
pageUp: -12,
|
||
pageDown: -13,
|
||
home: -14,
|
||
end: -15,
|
||
};
|
||
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~"],
|
||
};
|
||
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$"],
|
||
};
|
||
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^"],
|
||
};
|
||
const LEGACY_SEQUENCE_KEY_IDS = {
|
||
"\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",
|
||
};
|
||
const matchesLegacySequence = (data, sequences) => sequences.includes(data);
|
||
const matchesLegacyModifierSequence = (data, key, modifier) => {
|
||
if (modifier === MODIFIERS.shift) {
|
||
return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]);
|
||
}
|
||
if (modifier === MODIFIERS.ctrl) {
|
||
return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]);
|
||
}
|
||
return false;
|
||
};
|
||
// Store the last parsed event type for isKeyRelease() to query
|
||
let _lastEventType = "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) {
|
||
// 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) {
|
||
// 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) {
|
||
if (!eventTypeStr)
|
||
return "press";
|
||
const eventType = parseInt(eventTypeStr, 10);
|
||
if (eventType === 2)
|
||
return "repeat";
|
||
if (eventType === 3)
|
||
return "release";
|
||
return "press";
|
||
}
|
||
function parseKittySequence(data) {
|
||
// 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 = { 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 = {
|
||
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, expectedCodepoint, expectedModifier) {
|
||
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).
|
||
//
|
||
// Only fall back to base layout key when the codepoint is NOT already a
|
||
// recognized Latin letter (a-z) or symbol (e.g., /, -, [, ;, etc.).
|
||
// When the codepoint is a recognized key, it is authoritative regardless
|
||
// of physical key position. This prevents remapped layouts (Dvorak, Colemak,
|
||
// xremap, etc.) from causing false matches: both letters and symbols move
|
||
// to different physical positions, so Ctrl+K could falsely match Ctrl+V
|
||
// (letter remapping) and Ctrl+/ could falsely match Ctrl+[ (symbol remapping)
|
||
// if the base layout key were always considered.
|
||
if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) {
|
||
const cp = parsed.codepoint;
|
||
const isLatinLetter = cp >= 97 && cp <= 122; // a-z
|
||
const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(cp));
|
||
if (!isLatinLetter && !isKnownSymbol)
|
||
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, expectedKeycode, expectedModifier) {
|
||
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) {
|
||
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) {
|
||
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, keyId) {
|
||
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;
|
||
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) {
|
||
const kitty = parseKittySequence(data);
|
||
if (kitty) {
|
||
const { codepoint, baseLayoutKey, modifier } = kitty;
|
||
const mods = [];
|
||
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");
|
||
// Use base layout key only when codepoint is not a recognized Latin
|
||
// letter (a-z) or symbol (/, -, [, ;, etc.). For those, the codepoint
|
||
// is authoritative regardless of physical key position. This prevents
|
||
// remapped layouts (Dvorak, Colemak, xremap, etc.) from reporting the
|
||
// wrong key name based on the QWERTY physical position.
|
||
const isLatinLetter = codepoint >= 97 && codepoint <= 122; // a-z
|
||
const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(codepoint));
|
||
const effectiveCodepoint = isLatinLetter || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint);
|
||
let keyName;
|
||
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;
|
||
}
|
||
//# sourceMappingURL=keys.js.map
|