Fix alt key mappings for mac (#12231)

This commit is contained in:
Tommaso Sciortino
2025-10-29 14:32:02 -07:00
committed by GitHub
parent 2e003ad8cf
commit 82c10421a0
3 changed files with 125 additions and 118 deletions

View File

@@ -67,3 +67,12 @@ This document lists the available keyboard shortcuts in the Gemini CLI.
| Shortcut | Description | | Shortcut | Description |
| -------- | --------------------------------- | | -------- | --------------------------------- |
| `Ctrl+G` | See context CLI received from IDE | | `Ctrl+G` | See context CLI received from IDE |
## Meta+key combos on mac
On Mac, all Meta+char combos should work normally except for these three which
are mapped to special functionality.
- `meta+b`: "∫" back one word
- `meta+f`: "ƒ" forward one word
- `meta+m`: "µ" toggle markup view

View File

@@ -872,99 +872,118 @@ describe('Kitty Sequence Parsing', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
// Terminals to test describe('Cross-terminal Alt key handling (simulating macOS)', () => {
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; let originalPlatform: NodeJS.Platform;
// Key mappings: letter -> [keycode, accented character] beforeEach(() => {
const keys: Record<string, [number, string]> = { originalPlatform = process.platform;
a: [97, 'å'], Object.defineProperty(process, 'platform', {
o: [111, 'ø'], value: 'darwin',
m: [109, 'µ'], configurable: true,
};
it.each(
terminals.flatMap((terminal) =>
Object.entries(keys).map(([key, [keycode, accentedChar]]) => {
if (terminal === 'Ghostty') {
// Ghostty uses kitty protocol sequences
return {
terminal,
key,
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
kittyProtocol: true,
},
};
} else if (terminal === 'MacTerminal') {
// Mac Terminal sends ESC + letter
return {
terminal,
key,
kitty: false,
chunk: `\x1b${key}`,
expected: {
sequence: `\x1b${key}`,
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
// gets converted to m with meta:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
paste: false,
sequence: accentedChar,
},
};
}
}),
),
)(
'should handle Alt+$key in $terminal',
({
chunk,
expected,
kitty = true,
}: {
chunk: string;
expected: Partial<Key>;
kitty?: boolean;
}) => {
const keyHandler = vi.fn();
const testWrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider kittyProtocolEnabled={kitty}>
{children}
</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), {
wrapper: testWrapper,
}); });
act(() => result.current.subscribe(keyHandler)); });
act(() => stdin.write(chunk)); afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
});
expect(keyHandler).toHaveBeenCalledWith( // Terminals to test
expect.objectContaining(expected), const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
);
}, // Key mappings: letter -> [keycode, accented character]
); const keys: Record<string, [number, string]> = {
b: [98, '\u222B'],
f: [102, '\u0192'],
m: [109, '\u00B5'],
};
it.each(
terminals.flatMap((terminal) =>
Object.entries(keys).map(([key, [keycode, accentedChar]]) => {
if (terminal === 'Ghostty') {
// Ghostty uses kitty protocol sequences
return {
terminal,
key,
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
kittyProtocol: true,
},
};
} else if (terminal === 'MacTerminal') {
// Mac Terminal sends ESC + letter
return {
terminal,
key,
kitty: false,
chunk: `\x1b${key}`,
expected: {
sequence: `\x1b${key}`,
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
// gets converted to m with meta:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
paste: false,
sequence: accentedChar,
},
};
}
}),
),
)(
'should handle Alt+$key in $terminal',
({
chunk,
expected,
kitty = true,
}: {
chunk: string;
expected: Partial<Key>;
kitty?: boolean;
}) => {
const keyHandler = vi.fn();
const testWrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider kittyProtocolEnabled={kitty}>
{children}
</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), {
wrapper: testWrapper,
});
act(() => result.current.subscribe(keyHandler));
act(() => stdin.write(chunk));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining(expected),
);
},
);
});
describe('Backslash key handling', () => { describe('Backslash key handling', () => {
beforeEach(() => { beforeEach(() => {

View File

@@ -48,33 +48,12 @@ export const PASTE_CODE_TIMEOUT_MS = 50; // Flush incomplete paste code after 50
export const SINGLE_QUOTE = "'"; export const SINGLE_QUOTE = "'";
export const DOUBLE_QUOTE = '"'; export const DOUBLE_QUOTE = '"';
const ALT_KEY_CHARACTER_MAP: Record<string, string> = { // On Mac, hitting alt+char will yield funny characters.
'\u00E5': 'a', // Remap these three since we listen for them.
'\u222B': 'b', const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u00E7': 'c', '\u222B': 'b', // "∫" back one word
'\u2202': 'd', '\u0192': 'f', // "ƒ" forward one word
'\u00B4': 'e', '\u00B5': 'm', // "µ" toggle markup view
'\u0192': 'f',
'\u00A9': 'g',
'\u02D9': 'h',
'\u02C6': 'i',
'\u2206': 'j',
'\u02DA': 'k',
'\u00AC': 'l',
'\u00B5': 'm',
'\u02DC': 'n',
'\u00F8': 'o',
'\u03C0': 'p',
'\u0153': 'q',
'\u00AE': 'r',
'\u00DF': 's',
'\u2020': 't',
'\u00A8': 'u',
'\u221A': 'v',
'\u2211': 'w',
'\u2248': 'x',
'\u00A5': 'y',
'\u03A9': 'z',
}; };
/** /**
@@ -615,8 +594,8 @@ export function KeypressProvider({
return; return;
} }
const mappedLetter = ALT_KEY_CHARACTER_MAP[key.sequence]; const mappedLetter = MAC_ALT_KEY_CHARACTER_MAP[key.sequence];
if (mappedLetter && !key.meta) { if (process.platform === 'darwin' && mappedLetter && !key.meta) {
broadcast({ broadcast({
name: mappedLetter, name: mappedLetter,
ctrl: false, ctrl: false,