fix(cli): support CJK input and full Unicode scalar values in terminal protocols (#22353)

This commit is contained in:
Tommaso Sciortino
2026-03-13 21:24:26 +00:00
committed by GitHub
parent fa024133e6
commit 24933a90d0
6 changed files with 78 additions and 38 deletions

View File

@@ -647,6 +647,15 @@ describe('KeypressContext', () => {
sequence: `\x1b[27;6;9~`,
expected: { name: 'tab', shift: true, ctrl: true },
},
// Unicode CJK (Kitty/modifyOtherKeys scalar values)
{
sequence: '\x1b[44032u',
expected: { name: '가', sequence: '가', insertable: true },
},
{
sequence: '\x1b[27;1;44032~',
expected: { name: '가', sequence: '가', insertable: true },
},
// XTerm Function Key
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
@@ -1403,7 +1412,7 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledTimes(inputString.length);
for (const char of inputString) {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ sequence: char }),
expect.objectContaining({ sequence: char, name: char.toLowerCase() }),
);
}
});

View File

@@ -610,20 +610,28 @@ function* emitKeys(
if (code.endsWith('u') || code.endsWith('~')) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (codeNumber >= 33 && codeNumber <= 126) {
const char = String.fromCharCode(codeNumber);
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
} else if (
codeNumber >= 33 && // Printable characters start after space (32),
codeNumber <= 0x10ffff && // Valid Unicode scalar values (excluding control characters)
(codeNumber < 0xd800 || codeNumber > 0xdfff) // Exclude UTF-16 surrogate halves
) {
// Valid printable Unicode scalar values (up to Unicode maximum)
// Note: Kitty maps its special keys to the PUA (57344+), which are handled by KITTY_CODE_MAP above.
const char = String.fromCodePoint(codeNumber);
name = char.toLowerCase();
if (char >= 'A' && char <= 'Z') {
if (char !== name) {
shift = true;
}
} else {
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
if (!ctrl && !cmd && !alt) {
sequence = char;
insertable = true;
}
}
}
@@ -696,6 +704,10 @@ function* emitKeys(
alt = ch.length > 0;
} else {
// Any other character is considered printable.
name = ch.toLowerCase();
if (ch !== name) {
shift = true;
}
insertable = true;
}

View File

@@ -22,7 +22,7 @@ describe('KeyBinding', () => {
describe('constructor', () => {
it('should parse a simple key', () => {
const binding = new KeyBinding('a');
expect(binding.key).toBe('a');
expect(binding.name).toBe('a');
expect(binding.ctrl).toBe(false);
expect(binding.shift).toBe(false);
expect(binding.alt).toBe(false);
@@ -31,45 +31,45 @@ describe('KeyBinding', () => {
it('should parse ctrl+key', () => {
const binding = new KeyBinding('ctrl+c');
expect(binding.key).toBe('c');
expect(binding.name).toBe('c');
expect(binding.ctrl).toBe(true);
});
it('should parse shift+key', () => {
const binding = new KeyBinding('shift+z');
expect(binding.key).toBe('z');
expect(binding.name).toBe('z');
expect(binding.shift).toBe(true);
});
it('should parse alt+key', () => {
const binding = new KeyBinding('alt+left');
expect(binding.key).toBe('left');
expect(binding.name).toBe('left');
expect(binding.alt).toBe(true);
});
it('should parse cmd+key', () => {
const binding = new KeyBinding('cmd+f');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.cmd).toBe(true);
});
it('should handle aliases (option/opt/meta)', () => {
const optionBinding = new KeyBinding('option+b');
expect(optionBinding.key).toBe('b');
expect(optionBinding.name).toBe('b');
expect(optionBinding.alt).toBe(true);
const optBinding = new KeyBinding('opt+b');
expect(optBinding.key).toBe('b');
expect(optBinding.name).toBe('b');
expect(optBinding.alt).toBe(true);
const metaBinding = new KeyBinding('meta+enter');
expect(metaBinding.key).toBe('enter');
expect(metaBinding.name).toBe('enter');
expect(metaBinding.cmd).toBe(true);
});
it('should parse multiple modifiers', () => {
const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
expect(binding.key).toBe('x');
expect(binding.name).toBe('x');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
expect(binding.alt).toBe(true);
@@ -78,14 +78,14 @@ describe('KeyBinding', () => {
it('should be case-insensitive', () => {
const binding = new KeyBinding('CTRL+Shift+F');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
});
it('should handle named keys with modifiers', () => {
const binding = new KeyBinding('ctrl+enter');
expect(binding.key).toBe('enter');
expect(binding.name).toBe('enter');
expect(binding.ctrl).toBe(true);
});

View File

@@ -144,14 +144,14 @@ export class KeyBinding {
]);
/** The key name (e.g., 'a', 'enter', 'tab', 'escape') */
readonly key: string;
readonly name: string;
readonly shift: boolean;
readonly alt: boolean;
readonly ctrl: boolean;
readonly cmd: boolean;
constructor(pattern: string) {
let remains = pattern.toLowerCase().trim();
let remains = pattern.trim();
let shift = false;
let alt = false;
let ctrl = false;
@@ -160,31 +160,32 @@ export class KeyBinding {
let matched: boolean;
do {
matched = false;
if (remains.startsWith('ctrl+')) {
const lowerRemains = remains.toLowerCase();
if (lowerRemains.startsWith('ctrl+')) {
ctrl = true;
remains = remains.slice(5);
matched = true;
} else if (remains.startsWith('shift+')) {
} else if (lowerRemains.startsWith('shift+')) {
shift = true;
remains = remains.slice(6);
matched = true;
} else if (remains.startsWith('alt+')) {
} else if (lowerRemains.startsWith('alt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('option+')) {
} else if (lowerRemains.startsWith('option+')) {
alt = true;
remains = remains.slice(7);
matched = true;
} else if (remains.startsWith('opt+')) {
} else if (lowerRemains.startsWith('opt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('cmd+')) {
} else if (lowerRemains.startsWith('cmd+')) {
cmd = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('meta+')) {
} else if (lowerRemains.startsWith('meta+')) {
cmd = true;
remains = remains.slice(5);
matched = true;
@@ -193,15 +194,17 @@ export class KeyBinding {
const key = remains;
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
const isSingleChar = [...key].length === 1;
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
throw new Error(
`Invalid keybinding key: "${key}" in "${pattern}".` +
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
);
}
this.key = key;
this.shift = shift;
this.name = key.toLowerCase();
this.shift = shift || (isSingleChar && this.name !== key);
this.alt = alt;
this.ctrl = ctrl;
this.cmd = cmd;
@@ -209,7 +212,7 @@ export class KeyBinding {
matches(key: Key): boolean {
return (
this.key === key.name &&
key.name === this.name &&
!!key.shift === !!this.shift &&
!!key.alt === !!this.alt &&
!!key.ctrl === !!this.ctrl &&
@@ -219,7 +222,7 @@ export class KeyBinding {
equals(other: KeyBinding): boolean {
return (
this.key === other.key &&
this.name === other.name &&
this.shift === other.shift &&
this.alt === other.alt &&
this.ctrl === other.ctrl &&

View File

@@ -475,6 +475,22 @@ describe('keyMatchers', () => {
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true);
});
it('should support matching non-ASCII and CJK characters', () => {
const config = new Map(defaultKeyBindingConfig);
config.set(Command.QUIT, [new KeyBinding('Å'), new KeyBinding('가')]);
const matchers = createKeyMatchers(config);
// Å is normalized to å with shift=true by the parser
expect(matchers[Command.QUIT](createKey('å', { shift: true }))).toBe(
true,
);
expect(matchers[Command.QUIT](createKey('å'))).toBe(false);
// CJK characters do not have a lower/upper case
expect(matchers[Command.QUIT](createKey('가'))).toBe(true);
expect(matchers[Command.QUIT](createKey('나'))).toBe(false);
});
});
describe('Edge Cases', () => {

View File

@@ -86,7 +86,7 @@ export function formatKeyBinding(
if (binding.shift) parts.push(modMap.shift);
if (binding.cmd) parts.push(modMap.cmd);
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
const keyName = KEY_NAME_MAP[binding.name] || binding.name.toUpperCase();
parts.push(keyName);
return parts.join('+');