From 82c10421a06f0e4934f44ce44e37f0a95e693b02 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 29 Oct 2025 14:32:02 -0700 Subject: [PATCH] Fix alt key mappings for mac (#12231) --- docs/cli/keyboard-shortcuts.md | 9 + .../src/ui/contexts/KeypressContext.test.tsx | 197 ++++++++++-------- .../cli/src/ui/contexts/KeypressContext.tsx | 37 +--- 3 files changed, 125 insertions(+), 118 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index b5ff0b8e00..a1fd5d8931 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -67,3 +67,12 @@ This document lists the available keyboard shortcuts in the Gemini CLI. | Shortcut | Description | | -------- | --------------------------------- | | `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 diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 3d11de50b7..7f60316ac8 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -872,99 +872,118 @@ describe('Kitty Sequence Parsing', () => { vi.useRealTimers(); }); - // Terminals to test - const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; + describe('Cross-terminal Alt key handling (simulating macOS)', () => { + let originalPlatform: NodeJS.Platform; - // Key mappings: letter -> [keycode, accented character] - const keys: Record = { - a: [97, 'å'], - o: [111, 'ø'], - m: [109, 'µ'], - }; - - 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; - kitty?: boolean; - }) => { - const keyHandler = vi.fn(); - const testWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - const { result } = renderHook(() => useKeypressContext(), { - wrapper: testWrapper, + beforeEach(() => { + originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, }); - act(() => result.current.subscribe(keyHandler)); + }); - act(() => stdin.write(chunk)); + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); - expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining(expected), - ); - }, - ); + // Terminals to test + const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; + + // Key mappings: letter -> [keycode, accented character] + const keys: Record = { + 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; + kitty?: boolean; + }) => { + const keyHandler = vi.fn(); + const testWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + 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', () => { beforeEach(() => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index c7c2f0aef5..ebe71a0238 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -48,33 +48,12 @@ export const PASTE_CODE_TIMEOUT_MS = 50; // Flush incomplete paste code after 50 export const SINGLE_QUOTE = "'"; export const DOUBLE_QUOTE = '"'; -const ALT_KEY_CHARACTER_MAP: Record = { - '\u00E5': 'a', - '\u222B': 'b', - '\u00E7': 'c', - '\u2202': 'd', - '\u00B4': 'e', - '\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', +// On Mac, hitting alt+char will yield funny characters. +// Remap these three since we listen for them. +const MAC_ALT_KEY_CHARACTER_MAP: Record = { + '\u222B': 'b', // "∫" back one word + '\u0192': 'f', // "ƒ" forward one word + '\u00B5': 'm', // "µ" toggle markup view }; /** @@ -615,8 +594,8 @@ export function KeypressProvider({ return; } - const mappedLetter = ALT_KEY_CHARACTER_MAP[key.sequence]; - if (mappedLetter && !key.meta) { + const mappedLetter = MAC_ALT_KEY_CHARACTER_MAP[key.sequence]; + if (process.platform === 'darwin' && mappedLetter && !key.meta) { broadcast({ name: mappedLetter, ctrl: false,