From c86ee4cc83e42f4eaf7206647918aba4acfecbe9 Mon Sep 17 00:00:00 2001 From: Srivats Jayaram Date: Tue, 14 Oct 2025 09:12:20 -0700 Subject: [PATCH] feat: Support Alt+key combinations (#11038) --- .../src/ui/contexts/KeypressContext.test.tsx | 163 ++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 48 +++++- 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index d2107ff40b..56d924015a 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -956,3 +956,166 @@ describe('Drag and Drop Handling', () => { }); }); }); + +describe('Terminal-specific Alt+key combinations', () => { + let stdin: MockStdin; + const mockSetRawMode = vi.fn(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + 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 = { + 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; + expected: Partial; + }) => { + 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), + ); + }, + ); + + describe('Backslash key handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should treat backslash as a regular keystroke', () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + act(() => + stdin.pressKey({ + name: undefined, + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\\', + }), + ); + + // Advance timers to trigger the backslash timeout + act(() => { + vi.runAllTimers(); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + sequence: '\\', + meta: false, + }), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 4930f7101d..027eea8d56 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -45,6 +45,35 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m 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', +}; + export interface Key { name: string; ctrl: boolean; @@ -327,9 +356,9 @@ export function KeypressProvider({ }; } - // Ctrl+letters + // Ctrl+letters and Alt+letters if ( - ctrl && + (ctrl || alt) && keyCode >= 'a'.charCodeAt(0) && keyCode <= 'z'.charCodeAt(0) ) { @@ -337,7 +366,7 @@ export function KeypressProvider({ return { key: { name: letter, - ctrl: true, + ctrl, meta: alt, shift, paste: false, @@ -435,6 +464,19 @@ export function KeypressProvider({ 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 (backslashTimeout) { clearTimeout(backslashTimeout);