Fix some edge cases

This commit is contained in:
Daniel Imms
2026-01-09 10:36:13 -08:00
parent 566589f7c1
commit 3adf6be988
3 changed files with 277 additions and 183 deletions
+3 -2
View File
@@ -1118,8 +1118,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
}
// HACK: Process A-Z in the keypress event to fix an issue with macOS IMEs where lower case
// letters cannot be input while caps lock is on.
if (event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) {
// letters cannot be input while caps lock is on. Skip this hack when using kitty protocol
// as it needs to send proper CSI u sequences for all key events.
if (!useKitty && event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) {
if (event.key.charCodeAt(0) >= 65 && event.key.charCodeAt(0) <= 90) {
return true;
}
+28 -101
View File
@@ -547,115 +547,42 @@ describe('KittyKeyboard', () => {
});
});
describe('legacy ctrl mapping', () => {
it('ctrl+a → 0x01', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x01');
describe('REPORT_ALTERNATE_KEYS flag', () => {
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ALTERNATE_KEYS;
it('Shift+a includes shifted key → CSI 97:65 ; 2 u', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true, code: 'KeyA' }), flags);
assert.strictEqual(result.key, '\x1b[97:65;2u');
});
it('ctrl+b → 0x02', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'b', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x02');
it('unshifted key has no alternate', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', code: 'KeyA' }), flags);
assert.strictEqual(result.key, '\x1b[97u');
});
it('ctrl+c → 0x03', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'c', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x03');
it('Shift+5 includes shifted key → CSI 53:37 ; 2 u', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '%', shiftKey: true, code: 'Digit5' }), flags);
assert.strictEqual(result.key, '\x1b[53:37;2u');
});
it('ctrl+z → 0x1A', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'z', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1a');
it('functional keys have no shifted alternate', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape', shiftKey: true }), flags);
assert.strictEqual(result.key, '\x1b[27;2u');
});
});
describe('REPORT_ALTERNATE_KEYS with REPORT_ASSOCIATED_TEXT', () => {
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ALTERNATE_KEYS | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT;
it('Shift+a → CSI 97:65 ; 2 ; 65 u', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true, code: 'KeyA' }), flags);
assert.strictEqual(result.key, '\x1b[97:65;2;65u');
});
it('ctrl+space → 0x00', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: ' ', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x00');
});
it('ctrl+[ → 0x1B (ESC)', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '[', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1b');
});
it('ctrl+\\ → 0x1C', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '\\', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1c');
});
it('ctrl+] → 0x1D', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: ']', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1d');
});
it('ctrl+^ → 0x1E', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '^', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1e');
});
it('ctrl+_ → 0x1F', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '_', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1f');
});
it('ctrl+2 → 0x00', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '2', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x00');
});
it('ctrl+3 → 0x1B', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '3', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1b');
});
it('ctrl+4 → 0x1C', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '4', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1c');
});
it('ctrl+5 → 0x1D', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '5', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1d');
});
it('ctrl+6 → 0x1E', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '6', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1e');
});
it('ctrl+7 → 0x1F', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '7', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1f');
});
it('ctrl+8 → 0x7F', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '8', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x7f');
});
it('ctrl+/ → 0x1F', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '/', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x1f');
});
it('ctrl+? → 0x7F', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: '?', ctrlKey: true }), 0);
assert.strictEqual(result.key, '\x7f');
});
it('alt+a → ESC a', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', altKey: true }), 0);
assert.strictEqual(result.key, '\x1ba');
});
it('alt+A → ESC A', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', altKey: true, shiftKey: true }), 0);
assert.strictEqual(result.key, '\x1bA');
});
it('ctrl+alt+a → ESC ctrl+a', () => {
const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true, altKey: true }), 0);
assert.strictEqual(result.key, '\x1b\x01');
it('Shift+a release → CSI 97:65 ; 2:3 u (no text)', () => {
const flagsWithEvents = flags | KittyKeyboardFlags.REPORT_EVENT_TYPES;
const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true, code: 'KeyA' }), flagsWithEvents, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[97:65;2:3u');
});
});
+246 -80
View File
@@ -59,35 +59,13 @@ const FUNCTIONAL_KEY_CODES: { [key: string]: number } = {
'Enter': 13,
'Tab': 9,
'Backspace': 127,
'Insert': 2,
'Delete': 3,
'ArrowLeft': 57419,
'ArrowRight': 57421,
'ArrowUp': 57417,
'ArrowDown': 57420,
'PageUp': 57423,
'PageDown': 57424,
'Home': 57416,
'End': 57418,
'CapsLock': 57358,
'ScrollLock': 57359,
'NumLock': 57360,
'PrintScreen': 57361,
'Pause': 57362,
'ContextMenu': 57363,
// F1-F35
'F1': 57364,
'F2': 57365,
'F3': 57366,
'F4': 57367,
'F5': 57368,
'F6': 57369,
'F7': 57370,
'F8': 57371,
'F9': 57372,
'F10': 57373,
'F11': 57374,
'F12': 57375,
// F13-F35 (F1-F12 use legacy encoding)
'F13': 57376,
'F14': 57377,
'F15': 57378,
@@ -138,6 +116,46 @@ const FUNCTIONAL_KEY_CODES: { [key: string]: number } = {
'AudioVolumeMute': 57440
};
/**
* Keys that use CSI ~ encoding with a number parameter.
*/
const CSI_TILDE_KEYS: { [key: string]: number } = {
'Insert': 2,
'Delete': 3,
'PageUp': 5,
'PageDown': 6,
'F5': 15,
'F6': 17,
'F7': 18,
'F8': 19,
'F9': 20,
'F10': 21,
'F11': 23,
'F12': 24
};
/**
* Keys that use CSI letter encoding (arrows, Home, End).
*/
const CSI_LETTER_KEYS: { [key: string]: string } = {
'ArrowUp': 'A',
'ArrowDown': 'B',
'ArrowRight': 'C',
'ArrowLeft': 'D',
'Home': 'H',
'End': 'F'
};
/**
* Function keys F1-F4 use SS3 encoding without modifiers.
*/
const SS3_FUNCTION_KEYS: { [key: string]: string } = {
'F1': 'P',
'F2': 'Q',
'F3': 'R',
'F4': 'S'
};
/**
* Map browser key codes to Kitty numpad codes.
*/
@@ -194,6 +212,8 @@ function encodeModifiers(ev: IKeyboardEvent): number {
/**
* Get the unicode key code for a keyboard event.
* Returns the lowercase codepoint for letters.
* For shifted keys, uses the code property to get the base key.
*/
function getKeyCode(ev: IKeyboardEvent): number | undefined {
// Check for numpad first
@@ -214,9 +234,31 @@ function getKeyCode(ev: IKeyboardEvent): number | undefined {
return funcCode;
}
// For shifted keys, use code property to get base key
if (ev.shiftKey && ev.code) {
// Handle Digit0-Digit9
if (ev.code.startsWith('Digit') && ev.code.length === 6) {
const digit = ev.code.charAt(5);
if (digit >= '0' && digit <= '9') {
return digit.charCodeAt(0);
}
}
// Handle KeyA-KeyZ
if (ev.code.startsWith('Key') && ev.code.length === 4) {
const letter = ev.code.charAt(3).toLowerCase();
return letter.charCodeAt(0);
}
}
// For regular keys, use the key character's codepoint
// Always use lowercase for letters (per spec)
if (ev.key.length === 1) {
return ev.key.codePointAt(0);
const code = ev.key.codePointAt(0)!;
// Convert uppercase A-Z to lowercase a-z
if (code >= 65 && code <= 90) {
return code + 32;
}
return code;
}
return undefined;
@@ -248,36 +290,64 @@ export function evaluateKeyboardEventKitty(
key: undefined
};
// Get the key code
const modifiers = encodeModifiers(ev);
const isMod = isModifierKey(ev);
const reportEventTypes = !!(flags & KittyKeyboardFlags.REPORT_EVENT_TYPES);
// Don't report release events unless flag is set
if (!reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) {
return result;
}
// Modifier-only keys require REPORT_ALL_KEYS_AS_ESCAPE_CODES or REPORT_EVENT_TYPES
if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) && !reportEventTypes) {
return result;
}
// Check for CSI letter keys (arrows, Home, End)
const csiLetter = CSI_LETTER_KEYS[ev.key];
if (csiLetter) {
result.key = buildCsiLetterSequence(csiLetter, modifiers, eventType, reportEventTypes);
result.cancel = true;
return result;
}
// Check for SS3/CSI function keys (F1-F4)
const ss3Letter = SS3_FUNCTION_KEYS[ev.key];
if (ss3Letter) {
result.key = buildSs3Sequence(ss3Letter, modifiers, eventType, reportEventTypes);
result.cancel = true;
return result;
}
// Check for CSI ~ keys (Insert, Delete, PageUp/Down, F5-F12)
const tildeCode = CSI_TILDE_KEYS[ev.key];
if (tildeCode !== undefined) {
result.key = buildCsiTildeSequence(tildeCode, modifiers, eventType, reportEventTypes);
result.cancel = true;
return result;
}
// Get the key code for CSI u encoding
const keyCode = getKeyCode(ev);
if (keyCode === undefined) {
return result;
}
const modifiers = encodeModifiers(ev);
const isFunc = FUNCTIONAL_KEY_CODES[ev.key] !== undefined || getNumpadKeyCode(ev) !== undefined;
const isMod = isModifierKey(ev);
// Determine if we should use CSI u encoding or can use legacy
// Determine if we should use CSI u encoding
let useCsiU = false;
if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) {
// All keys use CSI u
useCsiU = true;
} else if (flags & KittyKeyboardFlags.REPORT_EVENT_TYPES) {
// When reporting event types, use CSI u for all keys
} else if (reportEventTypes) {
useCsiU = true;
} else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) {
// Modifier-only keys are never reported without REPORT_EVENT_TYPES
if (isMod) {
return result;
}
// Modifier-only keys already handled above
// Use CSI u for keys that would be ambiguous in legacy encoding
if (keyCode === 27 || keyCode === 127 || keyCode === 13) {
// Escape, Backspace, Enter
useCsiU = true;
} else if (keyCode === 9 && ev.shiftKey) {
// Shift+Tab
if (keyCode === 27 || keyCode === 127 || keyCode === 13 || keyCode === 9 || keyCode === 32) {
// Escape, Backspace, Enter, Tab, Space
useCsiU = true;
} else if (isFunc) {
useCsiU = true;
@@ -287,58 +357,154 @@ export function evaluateKeyboardEventKitty(
}
}
// Determine if we should report this event type
const reportEventTypes = !!(flags & KittyKeyboardFlags.REPORT_EVENT_TYPES);
if (!reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) {
// Don't report release events unless flag is set
return result;
}
if (useCsiU) {
// Build CSI u sequence: CSI keycode ; modifiers:event-type u
// Format: CSI <keycode>[:<shifted>][:<base>] ; <modifiers>[:<event>] u
let seq = C0.ESC + '[' + keyCode;
// Check if we need associated text (press and repeat events, not release)
const reportAssociatedText = !!(flags & KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT) &&
eventType !== KittyKeyboardEventType.RELEASE && ev.key.length === 1 && !isFunc && !isMod;
const textCode = reportAssociatedText ? ev.key.codePointAt(0) : undefined;
// When text is present, don't include event type marker (even for repeat)
// Release events always need event type marker
const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS && textCode === undefined;
if (modifiers > 0 || needsEventType || textCode !== undefined) {
seq += ';';
// Use 1 as base when event type needed but no modifiers (kitty format: 1 + modifier_bits)
if (modifiers > 0) {
seq += modifiers;
} else if (needsEventType) {
seq += '1';
}
if (needsEventType) {
seq += ':' + eventType;
}
}
// Add associated text if requested
if (textCode !== undefined) {
seq += ';' + textCode;
}
seq += 'u';
result.key = seq;
result.key = buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod);
result.cancel = true;
} else {
// Legacy-compatible encoding for text keys without modifiers
if (ev.key.length === 1 && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
result.key = ev.key;
}
// Otherwise no key sequence (will fall through to legacy handling)
}
return result;
}
/**
* Build CSI letter sequence for arrow keys, Home, End.
* Format: CSI [1;mod] letter
*/
function buildCsiLetterSequence(
letter: string,
modifiers: number,
eventType: KittyKeyboardEventType,
reportEventTypes: boolean
): string {
const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS;
if (modifiers > 0 || needsEventType) {
let seq = C0.ESC + '[1;' + (modifiers > 0 ? modifiers : '1');
if (needsEventType) {
seq += ':' + eventType;
}
seq += letter;
return seq;
}
return C0.ESC + '[' + letter;
}
/**
* Build SS3 sequence for F1-F4.
* Without modifiers: SS3 letter
* With modifiers: CSI 1;mod letter
*/
function buildSs3Sequence(
letter: string,
modifiers: number,
eventType: KittyKeyboardEventType,
reportEventTypes: boolean
): string {
const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS;
if (modifiers > 0 || needsEventType) {
let seq = C0.ESC + '[1;' + (modifiers > 0 ? modifiers : '1');
if (needsEventType) {
seq += ':' + eventType;
}
seq += letter;
return seq;
}
return C0.ESC + 'O' + letter;
}
/**
* Build CSI ~ sequence for Insert, Delete, PageUp/Down, F5-F12.
* Format: CSI number [;mod[:event]] ~
*/
function buildCsiTildeSequence(
number: number,
modifiers: number,
eventType: KittyKeyboardEventType,
reportEventTypes: boolean
): string {
const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS;
let seq = C0.ESC + '[' + number;
if (modifiers > 0 || needsEventType) {
seq += ';' + (modifiers > 0 ? modifiers : '1');
if (needsEventType) {
seq += ':' + eventType;
}
}
seq += '~';
return seq;
}
/**
* Build CSI u sequence.
* Format: CSI keycode[:shifted[:base]] [;mod[:event][;text]] u
*/
function buildCsiUSequence(
ev: IKeyboardEvent,
keyCode: number,
modifiers: number,
eventType: KittyKeyboardEventType,
flags: number,
isFunc: boolean,
isMod: boolean
): string {
const reportEventTypes = !!(flags & KittyKeyboardFlags.REPORT_EVENT_TYPES);
const reportAlternateKeys = !!(flags & KittyKeyboardFlags.REPORT_ALTERNATE_KEYS);
let seq = C0.ESC + '[' + keyCode;
// Add shifted key alternate if REPORT_ALTERNATE_KEYS is set and shift is pressed
// Only for text-producing keys (not functional or modifier keys)
let shiftedKey: number | undefined;
if (reportAlternateKeys && ev.shiftKey && ev.key.length === 1 && !isFunc && !isMod) {
shiftedKey = ev.key.codePointAt(0);
seq += ':' + shiftedKey;
}
// Check if we need associated text (press and repeat events, not release)
// Only for text-producing keys (not functional or modifier keys)
// Also don't include text when ctrl is pressed (produces control code)
const reportAssociatedText = !!(flags & KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT) &&
eventType !== KittyKeyboardEventType.RELEASE &&
ev.key.length === 1 &&
!isFunc &&
!isMod &&
!ev.ctrlKey;
const textCode = reportAssociatedText ? ev.key.codePointAt(0) : undefined;
// Determine if we need event type suffix
// For repeat: only include :2 when there's no text (text implies it's still useful input)
// For release: always include :3
const needsEventType = reportEventTypes &&
eventType !== KittyKeyboardEventType.PRESS &&
(eventType === KittyKeyboardEventType.RELEASE || textCode === undefined);
if (modifiers > 0 || needsEventType || textCode !== undefined) {
seq += ';';
if (modifiers > 0) {
seq += modifiers;
} else if (needsEventType) {
seq += '1';
}
if (needsEventType) {
seq += ':' + eventType;
}
}
// Add associated text if requested
if (textCode !== undefined) {
seq += ';' + textCode;
}
seq += 'u';
return seq;
}
/**
* Check if a keyboard event should be handled by Kitty protocol.
* Returns true if Kitty flags are active and the event should use Kitty encoding.