mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 05:24:34 -07:00
feat: Support Alt+key combinations (#10767)
This commit is contained in:
@@ -956,3 +956,127 @@ describe('Drag and Drop Handling', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Terminal-specific Alt+key combinations', () => {
|
||||||
|
let stdin: MockStdin;
|
||||||
|
const mockSetRawMode = vi.fn();
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<KeypressProvider kittyProtocolEnabled={true}>{children}</KeypressProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
stdin = new MockStdin();
|
||||||
|
(useStdin as Mock).mockReturnValue({
|
||||||
|
stdin,
|
||||||
|
setRawMode: mockSetRawMode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terminals to test
|
||||||
|
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
|
||||||
|
|
||||||
|
// Key mappings: letter -> [keycode, accented character, shouldHaveMeta]
|
||||||
|
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode
|
||||||
|
const keys: Record<string, [number, string, boolean]> = {
|
||||||
|
a: [97, 'å', true],
|
||||||
|
o: [111, 'ø', true],
|
||||||
|
m: [109, 'µ', false],
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
terminals.flatMap((terminal) =>
|
||||||
|
Object.entries(keys).map(
|
||||||
|
([key, [keycode, accentedChar, shouldHaveMeta]]) => {
|
||||||
|
if (terminal === 'Ghostty') {
|
||||||
|
// Ghostty uses kitty protocol sequences
|
||||||
|
return {
|
||||||
|
terminal,
|
||||||
|
key,
|
||||||
|
kittySequence: `\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,
|
||||||
|
input: {
|
||||||
|
sequence: `\x1b${key}`,
|
||||||
|
name: key,
|
||||||
|
ctrl: false,
|
||||||
|
meta: true,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
sequence: `\x1b${key}`,
|
||||||
|
name: key,
|
||||||
|
ctrl: false,
|
||||||
|
meta: true,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// iTerm2 and VSCode send accented characters (å, ø, µ)
|
||||||
|
// Note: µ comes with meta:false but gets converted to m with meta:true
|
||||||
|
return {
|
||||||
|
terminal,
|
||||||
|
key,
|
||||||
|
input: {
|
||||||
|
name: key,
|
||||||
|
ctrl: false,
|
||||||
|
meta: shouldHaveMeta,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
sequence: 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',
|
||||||
|
({
|
||||||
|
kittySequence,
|
||||||
|
input,
|
||||||
|
expected,
|
||||||
|
}: {
|
||||||
|
kittySequence?: string;
|
||||||
|
input?: Partial<Key>;
|
||||||
|
expected: Partial<Key>;
|
||||||
|
}) => {
|
||||||
|
const keyHandler = vi.fn();
|
||||||
|
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||||
|
act(() => result.current.subscribe(keyHandler));
|
||||||
|
|
||||||
|
if (kittySequence) {
|
||||||
|
act(() => stdin.sendKittySequence(kittySequence));
|
||||||
|
} else if (input) {
|
||||||
|
act(() => stdin.pressKey(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(keyHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining(expected),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,6 +45,36 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m
|
|||||||
export const SINGLE_QUOTE = "'";
|
export const SINGLE_QUOTE = "'";
|
||||||
export const DOUBLE_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',
|
||||||
|
'\\': 'y',
|
||||||
|
'\u03A9': 'z',
|
||||||
|
};
|
||||||
|
|
||||||
export interface Key {
|
export interface Key {
|
||||||
name: string;
|
name: string;
|
||||||
ctrl: boolean;
|
ctrl: boolean;
|
||||||
@@ -327,9 +357,9 @@ export function KeypressProvider({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+letters
|
// Ctrl+letters and Alt+letters
|
||||||
if (
|
if (
|
||||||
ctrl &&
|
(ctrl || alt) &&
|
||||||
keyCode >= 'a'.charCodeAt(0) &&
|
keyCode >= 'a'.charCodeAt(0) &&
|
||||||
keyCode <= 'z'.charCodeAt(0)
|
keyCode <= 'z'.charCodeAt(0)
|
||||||
) {
|
) {
|
||||||
@@ -337,7 +367,7 @@ export function KeypressProvider({
|
|||||||
return {
|
return {
|
||||||
key: {
|
key: {
|
||||||
name: letter,
|
name: letter,
|
||||||
ctrl: true,
|
ctrl,
|
||||||
meta: alt,
|
meta: alt,
|
||||||
shift,
|
shift,
|
||||||
paste: false,
|
paste: false,
|
||||||
@@ -435,6 +465,19 @@ export function KeypressProvider({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mappedLetter = ALT_KEY_CHARACTER_MAP[key.sequence];
|
||||||
|
if (mappedLetter && !key.meta) {
|
||||||
|
broadcast({
|
||||||
|
name: mappedLetter,
|
||||||
|
ctrl: false,
|
||||||
|
meta: true,
|
||||||
|
shift: false,
|
||||||
|
paste: isPaste,
|
||||||
|
sequence: key.sequence,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (key.name === 'return' && waitingForEnterAfterBackslash) {
|
if (key.name === 'return' && waitingForEnterAfterBackslash) {
|
||||||
if (backslashTimeout) {
|
if (backslashTimeout) {
|
||||||
clearTimeout(backslashTimeout);
|
clearTimeout(backslashTimeout);
|
||||||
|
|||||||
Reference in New Issue
Block a user