diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 9ec371a255..0d67b5403e 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -122,6 +122,96 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); + it('rewrites macOS composed Option+t glyph "†" to Alt+t', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + writable: true, + }); + try { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper, + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.pressKey({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '†', + }); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 't', + meta: true, + sequence: '†', + }), + ); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + writable: true, + }); + } + }); + + it('leaves "†" untouched on non-macOS platforms', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + writable: true, + }); + try { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper, + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.pressKey({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '†', + }); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + meta: false, + sequence: '†', + }), + ); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + writable: true, + }); + } + }); + it('should recognize regular enter key (keycode 13) in kitty protocol', async () => { const keyHandler = vi.fn(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index e7dd1fe013..9f9ecb79b3 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -46,6 +46,15 @@ import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; const ESC = '\u001B'; +// On macOS, when the terminal's Option key is in its default "compose +// character" mode (iTerm2 "Normal", VS Code without macOptionIsMeta), Option+t +// is delivered to the app as the dagger glyph "†" (U+2020) with no modifier +// metadata — so there is no way to tell Option was held. Terminals that speak +// the Kitty keyboard protocol (e.g. Ghostty) instead report a real Alt+t event, +// which is why the shortcut already works there. We treat a lone "†" as Alt+t so +// the "expand thinking" shortcut works everywhere without requiring users to +// reconfigure their terminal. See handleKeypress for where this is applied. +const OPTION_T_COMPOSED_GLYPH = '†'; export const PASTE_MODE_PREFIX = `${ESC}[200~`; export const PASTE_MODE_SUFFIX = `${ESC}[201~`; export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input @@ -1048,6 +1057,22 @@ export function KeypressProvider({ if (key.name === 'return' && key.sequence === `${ESC}\r`) { key.meta = true; } + + // macOS "Option as compose character" terminals turn Option+t into the + // bare glyph "†" (U+2020) with no modifier metadata. Rewrite it to a + // synthetic Alt+t so the "expand thinking" shortcut fires; the meta flag + // also stops the glyph from being inserted into the input buffer (the + // text buffer skips printable input when meta/ctrl is set), so it looks + // exactly like Alt was pressed. + if ( + process.platform === 'darwin' && + !isPaste && + key.sequence === OPTION_T_COMPOSED_GLYPH + ) { + key.name = 't'; + key.meta = true; + } + broadcast({ ...key, paste: isPaste }); };