diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 357d4cf2cd..31e43af575 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -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() }), ); } }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 63e8a07a94..cdd6da7feb 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -610,20 +610,28 @@ function* emitKeys( if (code.endsWith('u') || code.endsWith('~')) { // CSI-u or tilde-coded functional keys: ESC [ ; (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; } diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts index 77237f128f..10f88dd4d9 100644 --- a/packages/cli/src/ui/key/keyBindings.test.ts +++ b/packages/cli/src/ui/key/keyBindings.test.ts @@ -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); }); diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index e8014b7429..5b1afc0735 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -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 && diff --git a/packages/cli/src/ui/key/keyMatchers.test.ts b/packages/cli/src/ui/key/keyMatchers.test.ts index b1d7ddc304..ab12ca1ddf 100644 --- a/packages/cli/src/ui/key/keyMatchers.test.ts +++ b/packages/cli/src/ui/key/keyMatchers.test.ts @@ -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', () => { diff --git a/packages/cli/src/ui/key/keybindingUtils.ts b/packages/cli/src/ui/key/keybindingUtils.ts index 0c79e67d13..b1b31d247d 100644 --- a/packages/cli/src/ui/key/keybindingUtils.ts +++ b/packages/cli/src/ui/key/keybindingUtils.ts @@ -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('+');