Skip to content

Commit 87f175b

Browse files
authored
feat: Support Alt+key combinations (google-gemini#10767)
1 parent 907e51a commit 87f175b

2 files changed

Lines changed: 170 additions & 3 deletions

File tree

packages/cli/src/ui/contexts/KeypressContext.test.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,3 +956,127 @@ describe('Drag and Drop Handling', () => {
956956
});
957957
});
958958
});
959+
960+
describe('Terminal-specific Alt+key combinations', () => {
961+
let stdin: MockStdin;
962+
const mockSetRawMode = vi.fn();
963+
964+
const wrapper = ({ children }: { children: React.ReactNode }) => (
965+
<KeypressProvider kittyProtocolEnabled={true}>{children}</KeypressProvider>
966+
);
967+
968+
beforeEach(() => {
969+
vi.clearAllMocks();
970+
stdin = new MockStdin();
971+
(useStdin as Mock).mockReturnValue({
972+
stdin,
973+
setRawMode: mockSetRawMode,
974+
});
975+
});
976+
977+
// Terminals to test
978+
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
979+
980+
// Key mappings: letter -> [keycode, accented character, shouldHaveMeta]
981+
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode
982+
const keys: Record<string, [number, string, boolean]> = {
983+
a: [97, 'å', true],
984+
o: [111, 'ø', true],
985+
m: [109, 'µ', false],
986+
};
987+
988+
it.each(
989+
terminals.flatMap((terminal) =>
990+
Object.entries(keys).map(
991+
([key, [keycode, accentedChar, shouldHaveMeta]]) => {
992+
if (terminal === 'Ghostty') {
993+
// Ghostty uses kitty protocol sequences
994+
return {
995+
terminal,
996+
key,
997+
kittySequence: `\x1b[${keycode};3u`,
998+
expected: {
999+
name: key,
1000+
ctrl: false,
1001+
meta: true,
1002+
shift: false,
1003+
paste: false,
1004+
kittyProtocol: true,
1005+
},
1006+
};
1007+
} else if (terminal === 'MacTerminal') {
1008+
// Mac Terminal sends ESC + letter
1009+
return {
1010+
terminal,
1011+
key,
1012+
input: {
1013+
sequence: `\x1b${key}`,
1014+
name: key,
1015+
ctrl: false,
1016+
meta: true,
1017+
shift: false,
1018+
paste: false,
1019+
},
1020+
expected: {
1021+
sequence: `\x1b${key}`,
1022+
name: key,
1023+
ctrl: false,
1024+
meta: true,
1025+
shift: false,
1026+
paste: false,
1027+
},
1028+
};
1029+
} else {
1030+
// iTerm2 and VSCode send accented characters (å, ø, µ)
1031+
// Note: µ comes with meta:false but gets converted to m with meta:true
1032+
return {
1033+
terminal,
1034+
key,
1035+
input: {
1036+
name: key,
1037+
ctrl: false,
1038+
meta: shouldHaveMeta,
1039+
shift: false,
1040+
paste: false,
1041+
sequence: accentedChar,
1042+
},
1043+
expected: {
1044+
name: key,
1045+
ctrl: false,
1046+
meta: true, // Always expect meta:true after conversion
1047+
shift: false,
1048+
paste: false,
1049+
sequence: accentedChar,
1050+
},
1051+
};
1052+
}
1053+
},
1054+
),
1055+
),
1056+
)(
1057+
'should handle Alt+$key in $terminal',
1058+
({
1059+
kittySequence,
1060+
input,
1061+
expected,
1062+
}: {
1063+
kittySequence?: string;
1064+
input?: Partial<Key>;
1065+
expected: Partial<Key>;
1066+
}) => {
1067+
const keyHandler = vi.fn();
1068+
const { result } = renderHook(() => useKeypressContext(), { wrapper });
1069+
act(() => result.current.subscribe(keyHandler));
1070+
1071+
if (kittySequence) {
1072+
act(() => stdin.sendKittySequence(kittySequence));
1073+
} else if (input) {
1074+
act(() => stdin.pressKey(input));
1075+
}
1076+
1077+
expect(keyHandler).toHaveBeenCalledWith(
1078+
expect.objectContaining(expected),
1079+
);
1080+
},
1081+
);
1082+
});

packages/cli/src/ui/contexts/KeypressContext.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m
4545
export const SINGLE_QUOTE = "'";
4646
export const DOUBLE_QUOTE = '"';
4747

48+
const ALT_KEY_CHARACTER_MAP: Record<string, string> = {
49+
'\u00E5': 'a',
50+
'\u222B': 'b',
51+
'\u00E7': 'c',
52+
'\u2202': 'd',
53+
'\u00B4': 'e',
54+
'\u0192': 'f',
55+
'\u00A9': 'g',
56+
'\u02D9': 'h',
57+
'\u02C6': 'i',
58+
'\u2206': 'j',
59+
'\u02DA': 'k',
60+
'\u00AC': 'l',
61+
'\u00B5': 'm',
62+
'\u02DC': 'n',
63+
'\u00F8': 'o',
64+
'\u03C0': 'p',
65+
'\u0153': 'q',
66+
'\u00AE': 'r',
67+
'\u00DF': 's',
68+
'\u2020': 't',
69+
'\u00A8': 'u',
70+
'\u221A': 'v',
71+
'\u2211': 'w',
72+
'\u2248': 'x',
73+
'\u00A5': 'y',
74+
'\\': 'y',
75+
'\u03A9': 'z',
76+
};
77+
4878
export interface Key {
4979
name: string;
5080
ctrl: boolean;
@@ -327,17 +357,17 @@ export function KeypressProvider({
327357
};
328358
}
329359

330-
// Ctrl+letters
360+
// Ctrl+letters and Alt+letters
331361
if (
332-
ctrl &&
362+
(ctrl || alt) &&
333363
keyCode >= 'a'.charCodeAt(0) &&
334364
keyCode <= 'z'.charCodeAt(0)
335365
) {
336366
const letter = String.fromCharCode(keyCode);
337367
return {
338368
key: {
339369
name: letter,
340-
ctrl: true,
370+
ctrl,
341371
meta: alt,
342372
shift,
343373
paste: false,
@@ -435,6 +465,19 @@ export function KeypressProvider({
435465
return;
436466
}
437467

468+
const mappedLetter = ALT_KEY_CHARACTER_MAP[key.sequence];
469+
if (mappedLetter && !key.meta) {
470+
broadcast({
471+
name: mappedLetter,
472+
ctrl: false,
473+
meta: true,
474+
shift: false,
475+
paste: isPaste,
476+
sequence: key.sequence,
477+
});
478+
return;
479+
}
480+
438481
if (key.name === 'return' && waitingForEnterAfterBackslash) {
439482
if (backslashTimeout) {
440483
clearTimeout(backslashTimeout);

0 commit comments

Comments
 (0)