diff --git a/package-lock.json b/package-lock.json index 371d621009d..0cdb8fe21ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16709,9 +16709,9 @@ } }, "node_modules/vite": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", - "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index d2107ff40bb..f5b303128bd 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -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 }) => ( + {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), + ); + }, + ); +}); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 4930f7101d0..b8b89dceeb3 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -45,6 +45,36 @@ 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', + '\\': 'y', + '\u03A9': 'z', +}; + export interface Key { name: string; ctrl: boolean; @@ -327,9 +357,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 +367,7 @@ export function KeypressProvider({ return { key: { name: letter, - ctrl: true, + ctrl, meta: alt, shift, paste: false, @@ -435,6 +465,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);