mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 00:21:09 -07:00
fix(cli): support CJK input and full Unicode scalar values in terminal protocols (#22353)
This commit is contained in:
committed by
GitHub
parent
fa024133e6
commit
24933a90d0
@@ -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() }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('+');
|
||||
|
||||
Reference in New Issue
Block a user