Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/cli/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
197 changes: 108 additions & 89 deletions packages/cli/src/ui/contexts/KeypressContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -872,99 +872,118 @@ describe('Kitty Sequence Parsing', () => {
vi.useRealTimers();
});

// Terminals to test
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];

// Key mappings: letter -> [keycode, accented character]
const keys: Record<string, [number, string]> = {
a: [97, 'å'],
o: [111, 'ø'],
m: [109, 'µ'],
};
describe('Cross-terminal Alt key handling (simulating macOS)', () => {
let originalPlatform: NodeJS.Platform;

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,
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<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', () => {
beforeEach(() => {
Expand Down
37 changes: 8 additions & 29 deletions packages/cli/src/ui/contexts/KeypressContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'\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<string, string> = {
'\u222B': 'b', // "∫" back one word
'\u0192': 'f', // "ƒ" forward one word
'\u00B5': 'm', // "µ" toggle markup view
};

/**
Expand Down Expand Up @@ -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,
Expand Down