From f190b87223906758f5f224cfc462853f2851e4c1 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 21 Jan 2026 10:13:26 -0800 Subject: [PATCH] Support command/ctrl/alt backspace correctly (#17175) --- docs/cli/keyboard-shortcuts.md | 36 ++-- packages/cli/src/config/keyBindings.test.ts | 13 +- packages/cli/src/config/keyBindings.ts | 68 +++--- packages/cli/src/ui/AppContainer.test.tsx | 30 +-- .../cli/src/ui/auth/ApiAuthDialog.test.tsx | 10 +- .../LoginWithGoogleRestartDialog.test.tsx | 12 +- .../cli/src/ui/components/InputPrompt.tsx | 3 +- .../MultiFolderTrustDialog.test.tsx | 5 +- .../src/ui/components/SessionBrowser.test.tsx | 19 +- .../cli/src/ui/components/SessionBrowser.tsx | 5 +- .../ui/components/ShellInputPrompt.test.tsx | 25 ++- .../ui/components/shared/TextInput.test.tsx | 56 ++--- .../ui/components/shared/text-buffer.test.ts | 105 ++++++---- packages/cli/src/ui/constants/tips.ts | 2 +- .../src/ui/contexts/KeypressContext.test.tsx | 193 ++++++++++++++---- .../cli/src/ui/contexts/KeypressContext.tsx | 62 +++--- .../cli/src/ui/contexts/MouseContext.test.tsx | 16 +- packages/cli/src/ui/hooks/keyToAnsi.ts | 2 +- .../ui/hooks/useApprovalModeIndicator.test.ts | 2 +- .../cli/src/ui/hooks/useKeypress.test.tsx | 13 +- .../src/ui/hooks/useSelectionList.test.tsx | 9 +- packages/cli/src/ui/hooks/vim.test.tsx | 5 +- packages/cli/src/ui/hooks/vim.ts | 8 +- packages/cli/src/ui/keyMatchers.test.ts | 41 ++-- packages/cli/src/ui/keyMatchers.ts | 25 +-- scripts/generate-keybindings-doc.ts | 14 +- .../tests/generate-keybindings-doc.test.ts | 6 +- 27 files changed, 487 insertions(+), 298 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 265fce8319..831410b59c 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -19,14 +19,14 @@ available combinations. | Action | Keys | | ------------------------------------------- | ------------------------------------------------------------ | -| Move the cursor to the start of the line. | `Ctrl + A`
`Home (no Ctrl, no Shift)` | -| Move the cursor to the end of the line. | `Ctrl + E`
`End (no Ctrl, no Shift)` | -| Move the cursor up one line. | `Up Arrow (no Ctrl, no Cmd)` | -| Move the cursor down one line. | `Down Arrow (no Ctrl, no Cmd)` | -| Move the cursor one character to the left. | `Left Arrow (no Ctrl, no Cmd)`
`Ctrl + B` | -| Move the cursor one character to the right. | `Right Arrow (no Ctrl, no Cmd)`
`Ctrl + F` | -| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Cmd + Left Arrow`
`Cmd + B` | -| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Cmd + Right Arrow`
`Cmd + F` | +| Move the cursor to the start of the line. | `Ctrl + A`
`Home (no Shift, Ctrl)` | +| Move the cursor to the end of the line. | `Ctrl + E`
`End (no Shift, Ctrl)` | +| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` | +| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` | +| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + B` | +| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + F` | +| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` | +| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` | #### Editing @@ -35,12 +35,12 @@ available combinations. | Delete from the cursor to the end of the line. | `Ctrl + K` | | Delete from the cursor to the start of the line. | `Ctrl + U` | | Clear all text in the input field. | `Ctrl + C` | -| Delete the previous word. | `Ctrl + Backspace`
`Cmd + Backspace`
`Ctrl + W` | -| Delete the next word. | `Ctrl + Delete`
`Cmd + Delete` | +| Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | +| Delete the next word. | `Ctrl + Delete`
`Alt + Delete` | | Delete the character to the left. | `Backspace`
`Ctrl + H` | | Delete the character to the right. | `Delete`
`Ctrl + D` | | Undo the most recent text edit. | `Ctrl + Z (no Shift)` | -| Redo the most recent undone text edit. | `Ctrl + Shift + Z` | +| Redo the most recent undone text edit. | `Shift + Ctrl + Z` | #### Scrolling @@ -84,12 +84,12 @@ available combinations. #### Text Input -| Action | Keys | -| ---------------------------------------------- | ---------------------------------------------------------------------- | -| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Shift + Enter`
`Ctrl + J` | -| Open the current prompt in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V` | +| Action | Keys | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` | +| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | +| Open the current prompt in an external editor. | `Ctrl + X` | +| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | #### App Controls @@ -98,7 +98,7 @@ available combinations. | Toggle detailed error information. | `F12` | | Toggle the full TODO list. | `Ctrl + T` | | Show IDE context details. | `Ctrl + G` | -| Toggle Markdown rendering. | `Cmd + M` | +| Toggle Markdown rendering. | `Alt + M` | | Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` | diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts index ca2f91ea7e..c2abc32d27 100644 --- a/packages/cli/src/config/keyBindings.test.ts +++ b/packages/cli/src/config/keyBindings.test.ts @@ -33,14 +33,17 @@ describe('keyBindings config', () => { expect(binding.key.length).toBeGreaterThan(0); // Modifier properties should be boolean or undefined - if (binding.ctrl !== undefined) { - expect(typeof binding.ctrl).toBe('boolean'); - } if (binding.shift !== undefined) { expect(typeof binding.shift).toBe('boolean'); } - if (binding.command !== undefined) { - expect(typeof binding.command).toBe('boolean'); + if (binding.alt !== undefined) { + expect(typeof binding.alt).toBe('boolean'); + } + if (binding.ctrl !== undefined) { + expect(typeof binding.ctrl).toBe('boolean'); + } + if (binding.cmd !== undefined) { + expect(typeof binding.cmd).toBe('boolean'); } } } diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 455c9dba39..553bfeff47 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -90,12 +90,14 @@ export enum Command { export interface KeyBinding { /** The key name (e.g., 'a', 'return', 'tab', 'escape') */ key: string; - /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - ctrl?: boolean; /** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ shift?: boolean; - /** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - command?: boolean; + /** Alt/Option key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + alt?: boolean; + /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + ctrl?: boolean; + /** Command/Windows/Super key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ + cmd?: boolean; } /** @@ -119,51 +121,54 @@ export const defaultKeyBindings: KeyBindingConfig = { // Cursor Movement [Command.HOME]: [ { key: 'a', ctrl: true }, - { key: 'home', ctrl: false, shift: false }, + { key: 'home', shift: false, ctrl: false }, ], [Command.END]: [ { key: 'e', ctrl: true }, - { key: 'end', ctrl: false, shift: false }, + { key: 'end', shift: false, ctrl: false }, + ], + [Command.MOVE_UP]: [ + { key: 'up', shift: false, alt: false, ctrl: false, cmd: false }, + ], + [Command.MOVE_DOWN]: [ + { key: 'down', shift: false, alt: false, ctrl: false, cmd: false }, ], - [Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }], - [Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }], [Command.MOVE_LEFT]: [ - { key: 'left', ctrl: false, command: false }, + { key: 'left', shift: false, alt: false, ctrl: false, cmd: false }, { key: 'b', ctrl: true }, ], [Command.MOVE_RIGHT]: [ - { key: 'right', ctrl: false, command: false }, + { key: 'right', shift: false, alt: false, ctrl: false, cmd: false }, { key: 'f', ctrl: true }, ], [Command.MOVE_WORD_LEFT]: [ { key: 'left', ctrl: true }, - { key: 'left', command: true }, - { key: 'b', command: true }, + { key: 'left', alt: true }, + { key: 'b', alt: true }, ], [Command.MOVE_WORD_RIGHT]: [ { key: 'right', ctrl: true }, - { key: 'right', command: true }, - { key: 'f', command: true }, + { key: 'right', alt: true }, + { key: 'f', alt: true }, ], // Editing [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], - // Added command (meta/alt/option) for mac compatibility [Command.DELETE_WORD_BACKWARD]: [ { key: 'backspace', ctrl: true }, - { key: 'backspace', command: true }, + { key: 'backspace', alt: true }, { key: 'w', ctrl: true }, ], [Command.DELETE_WORD_FORWARD]: [ { key: 'delete', ctrl: true }, - { key: 'delete', command: true }, + { key: 'delete', alt: true }, ], [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }], [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }], - [Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }], - [Command.REDO]: [{ key: 'z', ctrl: true, shift: true }], + [Command.UNDO]: [{ key: 'z', shift: false, ctrl: true }], + [Command.REDO]: [{ key: 'z', shift: true, ctrl: true }], // Scrolling [Command.SCROLL_UP]: [{ key: 'up', shift: true }], @@ -180,10 +185,9 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.PAGE_DOWN]: [{ key: 'pagedown' }], // History & Search - [Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }], - [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }], + [Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }], + [Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }], [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], - // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], @@ -203,14 +207,13 @@ export const defaultKeyBindings: KeyBindingConfig = { // Suggestions & Completions [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], - // Completion navigation (arrow or Ctrl+P/N) [Command.COMPLETION_UP]: [ { key: 'up', shift: false }, - { key: 'p', ctrl: true, shift: false }, + { key: 'p', shift: false, ctrl: true }, ], [Command.COMPLETION_DOWN]: [ { key: 'down', shift: false }, - { key: 'n', ctrl: true, shift: false }, + { key: 'n', shift: false, ctrl: true }, ], [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], @@ -220,30 +223,31 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SUBMIT]: [ { key: 'return', - ctrl: false, - command: false, shift: false, + alt: false, + ctrl: false, + cmd: false, }, ], - // Split into multiple data-driven bindings - // Now also includes shift+enter for multi-line input [Command.NEWLINE]: [ { key: 'return', ctrl: true }, - { key: 'return', command: true }, + { key: 'return', cmd: true }, + { key: 'return', alt: true }, { key: 'return', shift: true }, { key: 'j', ctrl: true }, ], [Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }], [Command.PASTE_CLIPBOARD]: [ { key: 'v', ctrl: true }, - { key: 'v', command: true }, + { key: 'v', cmd: true }, + { key: 'v', alt: true }, ], // App Controls [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], [Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], - [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], + [Command.TOGGLE_MARKDOWN]: [{ key: 'm', alt: true }], [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], [Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }], [Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }], diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index dc5d4613f7..0e1db23583 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1660,9 +1660,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 'c', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, ...key, } as Key); }); @@ -1870,9 +1871,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 's', - ctrl: true, - meta: false, shift: false, + alt: false, + ctrl: true, + cmd: false, insertable: false, sequence: '\x13', }); @@ -1896,9 +1898,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 's', - ctrl: true, - meta: false, shift: false, + alt: false, + ctrl: true, + cmd: false, insertable: false, sequence: '\x13', }); @@ -1910,9 +1913,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 'any', // Any key should exit copy mode - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: 'a', }); @@ -1930,9 +1934,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 's', - ctrl: true, - meta: false, shift: false, + alt: false, + ctrl: true, + cmd: false, insertable: false, sequence: '\x13', }); @@ -1945,9 +1950,10 @@ describe('AppContainer State Management', () => { act(() => { handleGlobalKeypress({ name: 'a', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: 'a', }); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index e752c616a0..ea67bdcf6c 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -108,10 +108,10 @@ describe('ApiAuthDialog', () => { keypressHandler({ name: keyName, - sequence, - ctrl: false, - meta: false, shift: false, + ctrl: false, + cmd: false, + sequence, }); expect(expectedCall).toHaveBeenCalledWith(...args); @@ -137,9 +137,9 @@ describe('ApiAuthDialog', () => { await keypressHandler({ name: 'c', - ctrl: true, - meta: false, shift: false, + ctrl: true, + cmd: false, }); expect(clearApiKey).toHaveBeenCalled(); diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx index a7e857bd3b..907f1447db 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx @@ -48,10 +48,10 @@ describe('LoginWithGoogleRestartDialog', () => { keypressHandler({ name: 'escape', - sequence: '\u001b', - ctrl: false, - meta: false, shift: false, + ctrl: false, + cmd: false, + sequence: '\u001b', }); expect(onDismiss).toHaveBeenCalledTimes(1); @@ -67,10 +67,10 @@ describe('LoginWithGoogleRestartDialog', () => { keypressHandler({ name: keyName, - sequence: keyName, - ctrl: false, - meta: false, shift: false, + ctrl: false, + cmd: false, + sequence: keyName, }); // Advance timers to trigger the setTimeout callback diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b81dc4a601..064bb60d31 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -851,8 +851,9 @@ export const InputPrompt: React.FC = ({ completion.promptCompletion.text && key.sequence && key.sequence.length === 1 && + !key.alt && !key.ctrl && - !key.meta + !key.cmd ) { completion.promptCompletion.clear(); setExpandedSuggestionIndex(-1); diff --git a/packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx b/packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx index 9ec91f53c4..c03c36bf10 100644 --- a/packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/MultiFolderTrustDialog.test.tsx @@ -91,9 +91,10 @@ describe('MultiFolderTrustDialog', () => { await act(async () => { keypressCallback({ name: 'escape', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, sequence: '', insertable: false, }); diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx index 3fa4da896d..5a461a551e 100644 --- a/packages/cli/src/ui/components/SessionBrowser.test.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx @@ -93,10 +93,10 @@ const createMockConfig = (overrides: Partial = {}): Config => const triggerKey = ( partialKey: Partial<{ name: string; - ctrl: boolean; - meta: boolean; shift: boolean; - paste: boolean; + alt: boolean; + ctrl: boolean; + cmd: boolean; insertable: boolean; sequence: string; }>, @@ -108,9 +108,10 @@ const triggerKey = ( const key = { name: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '', ...partialKey, @@ -263,7 +264,13 @@ describe('SessionBrowser component', () => { // Type the query "query". for (const ch of ['q', 'u', 'e', 'r', 'y']) { - triggerKey({ sequence: ch, name: ch, ctrl: false, meta: false }); + triggerKey({ + sequence: ch, + name: ch, + alt: false, + ctrl: false, + cmd: false, + }); } await waitFor(() => { diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index 835028146a..9e5836057c 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -781,9 +781,10 @@ export const useSessionBrowserInput = ( state.setScrollOffset(0); } else if ( key.sequence && + key.sequence.length === 1 && + !key.alt && !key.ctrl && - !key.meta && - key.sequence.length === 1 + !key.cmd ) { state.setSearchQuery((prev) => prev + key.sequence); state.setActiveIndex(0); diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 815cfcadf7..5a204b0580 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -53,7 +53,14 @@ describe('ShellInputPrompt', () => { const handler = mockUseKeypress.mock.calls[0][0]; // Simulate keypress - handler({ name, sequence, ctrl: false, shift: false, meta: false }); + handler({ + name, + shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence, + }); expect(mockWriteToPty).toHaveBeenCalledWith(1, sequence); }); @@ -66,7 +73,7 @@ describe('ShellInputPrompt', () => { const handler = mockUseKeypress.mock.calls[0][0]; - handler({ name: key, ctrl: true, shift: true, meta: false }); + handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false }); expect(mockScrollPty).toHaveBeenCalledWith(1, direction); }); @@ -78,10 +85,11 @@ describe('ShellInputPrompt', () => { handler({ name: 'a', - sequence: 'a', - ctrl: false, shift: false, - meta: false, + alt: false, + ctrl: false, + cmd: false, + sequence: 'a', }); expect(mockWriteToPty).not.toHaveBeenCalled(); @@ -94,10 +102,11 @@ describe('ShellInputPrompt', () => { handler({ name: 'a', - sequence: 'a', - ctrl: false, shift: false, - meta: false, + alt: false, + ctrl: false, + cmd: false, + sequence: 'a', }); expect(mockWriteToPty).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index 56c3edbd37..d32480fc5b 100644 --- a/packages/cli/src/ui/components/shared/TextInput.test.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -151,18 +151,20 @@ describe('TextInput', () => { keypressHandler({ name: 'a', - sequence: 'a', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: 'a', }); expect(mockBuffer.handleInput).toHaveBeenCalledWith({ name: 'a', - sequence: 'a', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: 'a', }); expect(mockBuffer.text).toBe('a'); }); @@ -176,18 +178,20 @@ describe('TextInput', () => { keypressHandler({ name: 'backspace', - sequence: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', }); expect(mockBuffer.handleInput).toHaveBeenCalledWith({ name: 'backspace', - sequence: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', }); expect(mockBuffer.text).toBe('tes'); }); @@ -201,10 +205,11 @@ describe('TextInput', () => { keypressHandler({ name: 'left', - sequence: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', }); // Cursor moves from end to before 't' @@ -221,10 +226,11 @@ describe('TextInput', () => { keypressHandler({ name: 'right', - sequence: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', }); expect(mockBuffer.visualCursor[1]).toBe(3); @@ -239,10 +245,11 @@ describe('TextInput', () => { keypressHandler({ name: 'return', - sequence: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', }); expect(onSubmit).toHaveBeenCalledWith('test'); @@ -257,10 +264,11 @@ describe('TextInput', () => { keypressHandler({ name: 'escape', - sequence: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence: '', }); await vi.runAllTimersAsync(); diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index fbad68a1ed..308e7ea89e 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -1059,9 +1059,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'h', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: 'h', }), @@ -1069,9 +1070,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'i', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: 'i', }), @@ -1086,9 +1088,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'return', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: '\r', }), @@ -1103,9 +1106,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'j', - ctrl: true, - meta: false, shift: false, + alt: false, + ctrl: true, + cmd: false, insertable: false, sequence: '\n', }), @@ -1120,9 +1124,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'tab', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\t', }), @@ -1137,9 +1142,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'tab', - ctrl: false, - meta: false, shift: true, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\u001b[9;2u', }), @@ -1159,9 +1165,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'backspace', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\x7f', }), @@ -1183,25 +1190,28 @@ describe('useTextBuffer', () => { act(() => { result.current.handleInput({ name: 'backspace', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\x7f', }); result.current.handleInput({ name: 'backspace', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\x7f', }); result.current.handleInput({ name: 'backspace', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\x7f', }); @@ -1258,24 +1268,26 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'left', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\x1b[D', }), - ); // cursor [0,1] + ); expect(getBufferState(result).cursor).toEqual([0, 1]); act(() => result.current.handleInput({ name: 'right', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\x1b[C', }), - ); // cursor [0,2] + ); expect(getBufferState(result).cursor).toEqual([0, 2]); }); @@ -1288,9 +1300,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: textWithAnsi, }), @@ -1305,9 +1318,10 @@ describe('useTextBuffer', () => { act(() => result.current.handleInput({ name: 'return', - ctrl: false, - meta: false, shift: true, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: '\r', }), @@ -1509,13 +1523,13 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots describe('Input Sanitization', () => { const createInput = (sequence: string) => ({ name: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence, }); - it.each([ { input: '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m', @@ -1567,9 +1581,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots act(() => result.current.handleInput({ name: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: largeTextWithUnsafe, }), @@ -1601,9 +1616,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots act(() => result.current.handleInput({ name: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: largeTextWithAnsi, }), @@ -1625,9 +1641,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots act(() => result.current.handleInput({ name: '', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: emojis, }), @@ -1816,9 +1833,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots act(() => result.current.handleInput({ name: 'return', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: '\r', }), @@ -1837,9 +1855,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots act(() => result.current.handleInput({ name: 'f1', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: '\u001bOP', }), diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index e3c06e02b6..db6f0e2df9 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -91,7 +91,7 @@ export const INFORMATIVE_TIPS = [ 'See full, untruncated responses with Ctrl+S…', 'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y…', 'Cycle through approval modes (Default, Plan, Auto-Edit) with Shift+Tab…', - 'Toggle Markdown rendering (raw markdown mode) with Option+M…', + 'Toggle Markdown rendering (raw markdown mode) with Alt+M…', 'Toggle shell mode by typing ! in an empty prompt…', 'Insert a newline with a backslash (\\) followed by Enter…', 'Navigate your prompt history with the Up and Down arrows…', diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index c557706b64..974498e2cd 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -101,9 +101,9 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'return', - ctrl: false, - meta: false, shift: false, + ctrl: false, + cmd: false, }), ); }); @@ -116,9 +116,9 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'return', - ctrl: false, - meta: false, shift: true, + ctrl: false, + cmd: false, }), ); }); @@ -127,17 +127,17 @@ describe('KeypressContext', () => { { modifier: 'Shift', sequence: '\x1b[57414;2u', - expected: { ctrl: false, meta: false, shift: true }, + expected: { shift: true, ctrl: false, cmd: false }, }, { modifier: 'Ctrl', sequence: '\x1b[57414;5u', - expected: { ctrl: true, meta: false, shift: false }, + expected: { shift: false, ctrl: true, cmd: false }, }, { modifier: 'Alt', sequence: '\x1b[57414;3u', - expected: { ctrl: false, meta: true, shift: false }, + expected: { shift: false, alt: true, ctrl: false, cmd: false }, }, ])( 'should handle numpad enter with $modifier modifier', @@ -163,9 +163,9 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'j', - ctrl: true, - meta: false, shift: false, + ctrl: true, + cmd: false, }), ); }); @@ -178,9 +178,10 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'return', - ctrl: false, - meta: true, shift: false, + alt: true, + ctrl: false, + cmd: false, }), ); }); @@ -202,7 +203,13 @@ describe('KeypressContext', () => { act(() => stdin.write('a')); expect(keyHandler).toHaveBeenLastCalledWith( - expect.objectContaining({ name: 'a' }), + expect.objectContaining({ + name: 'a', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }), ); act(() => stdin.write('\r')); @@ -212,6 +219,10 @@ describe('KeypressContext', () => { name: 'return', sequence: '\r', insertable: true, + shift: false, + alt: false, + ctrl: false, + cmd: false, }), ); }); @@ -228,6 +239,10 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenLastCalledWith( expect.objectContaining({ name: 'return', + shift: false, + alt: false, + ctrl: false, + cmd: false, }), ); }); @@ -245,6 +260,10 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'escape', + shift: false, + alt: false, + ctrl: false, + cmd: false, }), ); }); @@ -266,11 +285,21 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ name: 'escape', meta: true }), + expect.objectContaining({ + name: 'escape', + shift: false, + alt: true, + cmd: false, + }), ); expect(keyHandler).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ name: 'escape', meta: true }), + expect.objectContaining({ + name: 'escape', + shift: false, + alt: true, + cmd: false, + }), ); }); }); @@ -296,7 +325,9 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'escape', - meta: true, + shift: false, + alt: true, + cmd: false, }), ); }); @@ -318,17 +349,17 @@ describe('KeypressContext', () => { { name: 'Backspace', inputSequence: '\x1b[127u', - expected: { name: 'backspace', meta: false }, + expected: { name: 'backspace', alt: false, cmd: false }, }, { - name: 'Option+Backspace', + name: 'Alt+Backspace', inputSequence: '\x1b[127;3u', - expected: { name: 'backspace', meta: true }, + expected: { name: 'backspace', alt: true, cmd: false }, }, { name: 'Ctrl+Backspace', inputSequence: '\x1b[127;5u', - expected: { name: 'backspace', ctrl: true }, + expected: { name: 'backspace', alt: false, ctrl: true, cmd: false }, }, { name: 'Shift+Space', @@ -612,14 +643,17 @@ describe('KeypressContext', () => { { sequence: `\x1b[27;5;9~`, expected: { name: 'tab', ctrl: true } }, { sequence: `\x1b[27;6;9~`, - expected: { name: 'tab', ctrl: true, shift: true }, + expected: { name: 'tab', shift: true, ctrl: true }, }, // XTerm Function Key { sequence: `\x1b[1;129A`, expected: { name: 'up' } }, { sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } }, { sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } }, { sequence: `\x1b[1;1P`, expected: { name: 'f1' } }, - { sequence: `\x1b[1;3Q`, expected: { name: 'f2', meta: true } }, + { + sequence: `\x1b[1;3Q`, + expected: { name: 'f2', alt: true, cmd: false }, + }, // Tilde Function Keys { sequence: `\x1b[3~`, expected: { name: 'delete' } }, { sequence: `\x1b[5~`, expected: { name: 'pageup' } }, @@ -637,33 +671,75 @@ describe('KeypressContext', () => { // Legacy Arrows { sequence: `\x1b[A`, - expected: { name: 'up', ctrl: false, meta: false, shift: false }, + expected: { + name: 'up', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }, }, { sequence: `\x1b[B`, - expected: { name: 'down', ctrl: false, meta: false, shift: false }, + expected: { + name: 'down', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }, }, { sequence: `\x1b[C`, - expected: { name: 'right', ctrl: false, meta: false, shift: false }, + expected: { + name: 'right', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }, }, { sequence: `\x1b[D`, - expected: { name: 'left', ctrl: false, meta: false, shift: false }, + expected: { + name: 'left', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }, }, // Legacy Home/End { sequence: `\x1b[H`, - expected: { name: 'home', ctrl: false, meta: false, shift: false }, + expected: { + name: 'home', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }, }, { sequence: `\x1b[F`, - expected: { name: 'end', ctrl: false, meta: false, shift: false }, + expected: { + name: 'end', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }, }, { sequence: `\x1b[5H`, - expected: { name: 'home', ctrl: true, meta: false, shift: false }, + expected: { + name: 'home', + shift: false, + alt: false, + ctrl: true, + cmd: false, + }, }, ])( 'should recognize sequence "$sequence" as $expected.name', @@ -690,11 +766,23 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ name: 'delete' }), + expect.objectContaining({ + name: 'delete', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }), ); expect(keyHandler).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ name: 'delete' }), + expect.objectContaining({ + name: 'delete', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }), ); }); @@ -751,9 +839,10 @@ describe('KeypressContext', () => { chunk: `\x1b[${keycode};3u`, expected: { name: key, - ctrl: false, - meta: true, shift: false, + alt: true, + ctrl: false, + cmd: false, }, }; } else if (terminal === 'MacTerminal') { @@ -766,24 +855,26 @@ describe('KeypressContext', () => { expected: { sequence: `\x1b${key}`, name: key, - ctrl: false, - meta: true, shift: false, + alt: true, + ctrl: false, + cmd: 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 + // Note: µ (mu) is sent with alt:false on iTerm2/VSCode but + // gets converted to m with alt:true return { terminal, key, chunk: accentedChar, expected: { name: key, - ctrl: false, - meta: true, // Always expect meta:true after conversion shift: false, + alt: true, // Always expect alt:true after conversion + ctrl: false, + cmd: false, sequence: accentedChar, }, }; @@ -825,7 +916,10 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ sequence: '\\', - meta: false, + shift: false, + alt: false, + ctrl: false, + cmd: false, }), ); }); @@ -858,6 +952,10 @@ describe('KeypressContext', () => { expect.objectContaining({ name: 'undefined', sequence: INCOMPLETE_KITTY_SEQUENCE, + shift: false, + alt: false, + ctrl: false, + cmd: false, }), ); }); @@ -876,6 +974,10 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ sequence: '\x1b[m', + shift: false, + alt: false, + ctrl: false, + cmd: false, }), ); }); @@ -1048,6 +1150,10 @@ describe('KeypressContext', () => { expect.objectContaining({ name: 'a', sequence: 'a', + shift: false, + alt: false, + ctrl: false, + cmd: false, }), ); }); @@ -1162,7 +1268,14 @@ describe('KeypressContext', () => { }); expect(keyHandler).toHaveBeenCalledWith( - expect.objectContaining({ name: 'f12', sequence: '\u001b[24~' }), + expect.objectContaining({ + name: 'f12', + sequence: '\u001b[24~', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }), ); }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 88aad71a27..2d5b121b84 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -251,9 +251,10 @@ function bufferPaste(keypressHandler: KeypressHandler): KeypressHandler { if (buffer.length > 0) { keypressHandler({ name: 'paste', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: buffer, }); @@ -300,9 +301,10 @@ function* emitKeys( let escaped = false; let name = undefined; - let ctrl = false; - let meta = false; let shift = false; + let alt = false; + let ctrl = false; + let cmd = false; let code = undefined; let insertable = false; @@ -353,9 +355,10 @@ function* emitKeys( const decoded = Buffer.from(base64Data, 'base64').toString('utf-8'); keypressHandler({ name: 'paste', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: decoded, }); @@ -490,9 +493,10 @@ function* emitKeys( } // Parse the key modifier - ctrl = !!(modifier & 4); - meta = !!(modifier & 10); // use 10 to catch both alt (2) and meta (8). shift = !!(modifier & 1); + alt = !!(modifier & 2); + ctrl = !!(modifier & 4); + cmd = !!(modifier & 8); const keyInfo = KEY_INFO_MAP[code]; if (keyInfo) { @@ -503,13 +507,16 @@ function* emitKeys( if (keyInfo.ctrl) { ctrl = true; } - if (name === 'space' && !ctrl && !meta) { + if (name === 'space' && !ctrl && !cmd && !alt) { sequence = ' '; insertable = true; } } else { name = 'undefined'; - if ((ctrl || meta) && (code.endsWith('u') || code.endsWith('~'))) { + if ( + (ctrl || cmd || alt) && + (code.endsWith('u') || code.endsWith('~')) + ) { // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) const codeNumber = parseInt(code.slice(1, -1), 10); if ( @@ -523,26 +530,26 @@ function* emitKeys( } else if (ch === '\r') { // carriage return name = 'return'; - meta = escaped; + alt = escaped; } else if (escaped && ch === '\n') { // Alt+Enter (linefeed), should be consistent with carriage return name = 'return'; - meta = escaped; + alt = escaped; } else if (ch === '\t') { // tab name = 'tab'; - meta = escaped; + alt = escaped; } else if (ch === '\b' || ch === '\x7f') { // backspace or ctrl+h name = 'backspace'; - meta = escaped; + alt = escaped; } else if (ch === ESC) { // escape key name = 'escape'; - meta = escaped; + alt = escaped; } else if (ch === ' ') { name = 'space'; - meta = escaped; + alt = escaped; insertable = true; } else if (!escaped && ch <= '\x1a') { // ctrl+letter @@ -552,29 +559,30 @@ function* emitKeys( // Letter, number, shift+letter name = ch.toLowerCase(); shift = /^[A-Z]$/.exec(ch) !== null; - meta = escaped; + alt = escaped; insertable = true; } else if (MAC_ALT_KEY_CHARACTER_MAP[ch] && process.platform === 'darwin') { name = MAC_ALT_KEY_CHARACTER_MAP[ch]; - meta = true; + alt = true; } else if (sequence === `${ESC}${ESC}`) { // Double escape name = 'escape'; - meta = true; + alt = true; // Emit first escape key here, then continue processing keypressHandler({ name: 'escape', - ctrl, - meta, shift, + alt, + ctrl, + cmd, insertable: false, sequence: ESC, }); } else if (escaped) { // Escape sequence timeout name = ch.length ? undefined : 'escape'; - meta = true; + alt = true; } else { // Any other character is considered printable. insertable = true; @@ -586,9 +594,10 @@ function* emitKeys( ) { keypressHandler({ name: name || '', - ctrl, - meta, shift, + alt, + ctrl, + cmd, insertable, sequence, }); @@ -599,9 +608,10 @@ function* emitKeys( export interface Key { name: string; - ctrl: boolean; - meta: boolean; shift: boolean; + alt: boolean; + ctrl: boolean; + cmd: boolean; // Command/Windows/Super key insertable: boolean; sequence: string; } diff --git a/packages/cli/src/ui/contexts/MouseContext.test.tsx b/packages/cli/src/ui/contexts/MouseContext.test.tsx index 0224721568..a3bf76a146 100644 --- a/packages/cli/src/ui/contexts/MouseContext.test.tsx +++ b/packages/cli/src/ui/contexts/MouseContext.test.tsx @@ -139,63 +139,63 @@ describe('MouseContext', () => { sequence: '\x1b[<0;10;20M', expected: { name: 'left-press', + shift: false, ctrl: false, meta: false, - shift: false, }, }, { sequence: '\x1b[<0;10;20m', expected: { name: 'left-release', + shift: false, ctrl: false, meta: false, - shift: false, }, }, { sequence: '\x1b[<2;10;20M', expected: { name: 'right-press', + shift: false, ctrl: false, meta: false, - shift: false, }, }, { sequence: '\x1b[<1;10;20M', expected: { name: 'middle-press', + shift: false, ctrl: false, meta: false, - shift: false, }, }, { sequence: '\x1b[<64;10;20M', expected: { name: 'scroll-up', + shift: false, ctrl: false, meta: false, - shift: false, }, }, { sequence: '\x1b[<65;10;20M', expected: { name: 'scroll-down', + shift: false, ctrl: false, meta: false, - shift: false, }, }, { sequence: '\x1b[<32;10;20M', expected: { name: 'move', + shift: false, ctrl: false, meta: false, - shift: false, }, }, { @@ -208,7 +208,7 @@ describe('MouseContext', () => { }, // Alt + left press { sequence: '\x1b[<20;10;20M', - expected: { name: 'left-press', ctrl: true, shift: true }, + expected: { name: 'left-press', shift: true, ctrl: true }, }, // Ctrl + Shift + left press { sequence: '\x1b[<68;10;20M', diff --git a/packages/cli/src/ui/hooks/keyToAnsi.ts b/packages/cli/src/ui/hooks/keyToAnsi.ts index 1d5549ab0f..56d8466a0e 100644 --- a/packages/cli/src/ui/hooks/keyToAnsi.ts +++ b/packages/cli/src/ui/hooks/keyToAnsi.ts @@ -69,7 +69,7 @@ export function keyToAnsi(key: Key): string | null { } // If it's a simple character, return it. - if (!key.ctrl && !key.meta && key.sequence) { + if (!key.ctrl && !key.cmd && key.sequence) { return key.sequence; } diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts index 3480791fe5..785e05aa15 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts @@ -314,8 +314,8 @@ describe('useApprovalModeIndicator', () => { act(() => { capturedUseKeypressHandler({ name: 'a', - ctrl: true, shift: true, + ctrl: true, } as Key); }); expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/hooks/useKeypress.test.tsx b/packages/cli/src/ui/hooks/useKeypress.test.tsx index 71d901bcb5..cde15186d9 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.tsx +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -114,7 +114,13 @@ describe(`useKeypress`, () => { const key = { name: 'return', sequence: '\x1B\r' }; act(() => stdin.write(key.sequence)); expect(onKeypress).toHaveBeenCalledWith( - expect.objectContaining({ ...key, meta: true }), + expect.objectContaining({ + ...key, + shift: false, + alt: true, + ctrl: false, + cmd: false, + }), ); }); @@ -140,9 +146,10 @@ describe(`useKeypress`, () => { expect(onKeypress).toHaveBeenCalledTimes(1); expect(onKeypress).toHaveBeenCalledWith({ name: 'paste', - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: true, sequence: pasteText, }); diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.tsx b/packages/cli/src/ui/hooks/useSelectionList.test.tsx index 7006f0f5d3..7c01e3cb71 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.test.tsx +++ b/packages/cli/src/ui/hooks/useSelectionList.test.tsx @@ -59,7 +59,8 @@ describe('useSelectionList', () => { name, sequence, ctrl: options.ctrl ?? false, - meta: false, + cmd: false, + alt: false, shift: options.shift ?? false, insertable: false, }; @@ -328,7 +329,8 @@ describe('useSelectionList', () => { name, sequence: name, ctrl: false, - meta: false, + cmd: false, + alt: false, shift: false, insertable: true, }; @@ -377,7 +379,8 @@ describe('useSelectionList', () => { name, sequence: name, ctrl: false, - meta: false, + cmd: false, + alt: false, shift: false, insertable: false, }; diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index db7d287425..88b7ebb415 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -36,9 +36,10 @@ vi.mock('../contexts/VimModeContext.js', () => ({ const createKey = (partial: Partial): Key => ({ name: partial.name || '', sequence: partial.sequence || '', - ctrl: partial.ctrl || false, - meta: partial.meta || false, shift: partial.shift || false, + alt: partial.alt || false, + ctrl: partial.ctrl || false, + cmd: partial.cmd || false, insertable: partial.insertable || false, ...partial, }); diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 46492220a0..2f39c38f43 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -280,8 +280,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Special handling for Enter key to allow command submission (lower priority than completion) if ( normalizedKey.name === 'return' && + !normalizedKey.alt && !normalizedKey.ctrl && - !normalizedKey.meta + !normalizedKey.cmd ) { if (buffer.text.trim() && onSubmit) { // Handle command submission directly @@ -309,9 +310,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { (key: Key): Key => ({ name: key.name || '', sequence: key.sequence || '', - ctrl: key.ctrl || false, - meta: key.meta || false, shift: key.shift || false, + alt: key.alt || false, + ctrl: key.ctrl || false, + cmd: key.cmd || false, insertable: key.insertable || false, }), [], diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index dc3003f8e4..8c3edfcfb3 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -13,9 +13,10 @@ import type { Key } from './hooks/useKeypress.js'; describe('keyMatchers', () => { const createKey = (name: string, mods: Partial = {}): Key => ({ name, - ctrl: false, - meta: false, shift: false, + alt: false, + ctrl: false, + cmd: false, insertable: false, sequence: name, ...mods, @@ -70,8 +71,8 @@ describe('keyMatchers', () => { command: Command.MOVE_WORD_LEFT, positive: [ createKey('left', { ctrl: true }), - createKey('left', { meta: true }), - createKey('b', { meta: true }), + createKey('left', { alt: true }), + createKey('b', { alt: true }), ], negative: [createKey('left'), createKey('b', { ctrl: true })], }, @@ -79,8 +80,8 @@ describe('keyMatchers', () => { command: Command.MOVE_WORD_RIGHT, positive: [ createKey('right', { ctrl: true }), - createKey('right', { meta: true }), - createKey('f', { meta: true }), + createKey('right', { alt: true }), + createKey('f', { alt: true }), ], negative: [createKey('right'), createKey('f', { ctrl: true })], }, @@ -115,7 +116,7 @@ describe('keyMatchers', () => { command: Command.DELETE_WORD_BACKWARD, positive: [ createKey('backspace', { ctrl: true }), - createKey('backspace', { meta: true }), + createKey('backspace', { alt: true }), createKey('w', { ctrl: true }), ], negative: [createKey('backspace'), createKey('delete', { ctrl: true })], @@ -124,19 +125,19 @@ describe('keyMatchers', () => { command: Command.DELETE_WORD_FORWARD, positive: [ createKey('delete', { ctrl: true }), - createKey('delete', { meta: true }), + createKey('delete', { alt: true }), ], negative: [createKey('delete'), createKey('backspace', { ctrl: true })], }, { command: Command.UNDO, - positive: [createKey('z', { ctrl: true, shift: false })], - negative: [createKey('z'), createKey('z', { ctrl: true, shift: true })], + positive: [createKey('z', { shift: false, ctrl: true })], + negative: [createKey('z'), createKey('z', { shift: true, ctrl: true })], }, { command: Command.REDO, - positive: [createKey('z', { ctrl: true, shift: true })], - negative: [createKey('z'), createKey('z', { ctrl: true, shift: false })], + positive: [createKey('z', { shift: true, ctrl: true })], + negative: [createKey('z'), createKey('z', { shift: false, ctrl: true })], }, // Screen control @@ -243,14 +244,16 @@ describe('keyMatchers', () => { positive: [createKey('return')], negative: [ createKey('return', { ctrl: true }), - createKey('return', { meta: true }), + createKey('return', { cmd: true }), + createKey('return', { alt: true }), ], }, { command: Command.NEWLINE, positive: [ createKey('return', { ctrl: true }), - createKey('return', { meta: true }), + createKey('return', { cmd: true }), + createKey('return', { alt: true }), ], negative: [createKey('return'), createKey('n')], }, @@ -285,13 +288,13 @@ describe('keyMatchers', () => { }, { command: Command.TOGGLE_MARKDOWN, - positive: [createKey('m', { meta: true })], + positive: [createKey('m', { alt: true })], negative: [createKey('m'), createKey('m', { shift: true })], }, { command: Command.TOGGLE_COPY_MODE, positive: [createKey('s', { ctrl: true })], - negative: [createKey('s'), createKey('s', { meta: true })], + negative: [createKey('s'), createKey('s', { alt: true })], }, { command: Command.QUIT, @@ -333,7 +336,7 @@ describe('keyMatchers', () => { { command: Command.TOGGLE_YOLO, positive: [createKey('y', { ctrl: true })], - negative: [createKey('y'), createKey('y', { meta: true })], + negative: [createKey('y'), createKey('y', { alt: true })], }, { command: Command.CYCLE_APPROVAL_MODE, @@ -401,13 +404,13 @@ describe('keyMatchers', () => { ...defaultKeyBindings, [Command.QUIT]: [ { key: 'q', ctrl: true }, - { key: 'q', command: true }, + { key: 'q', alt: true }, ], }; const matchers = createKeyMatchers(config); expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true); - expect(matchers[Command.QUIT](createKey('q', { meta: true }))).toBe(true); + expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true); }); }); diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts index 73636130be..07b6acf173 100644 --- a/packages/cli/src/ui/keyMatchers.ts +++ b/packages/cli/src/ui/keyMatchers.ts @@ -13,28 +13,17 @@ import { Command, defaultKeyBindings } from '../config/keyBindings.js'; * Pure data-driven matching logic */ function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean { - if (keyBinding.key !== key.name) { - return false; - } - // Check modifiers - follow original logic: // undefined = ignore this modifier (original behavior) // true = modifier must be pressed // false = modifier must NOT be pressed - - if (keyBinding.ctrl !== undefined && key.ctrl !== keyBinding.ctrl) { - return false; - } - - if (keyBinding.shift !== undefined && key.shift !== keyBinding.shift) { - return false; - } - - if (keyBinding.command !== undefined && key.meta !== keyBinding.command) { - return false; - } - - return true; + return ( + keyBinding.key === key.name && + (keyBinding.shift === undefined || key.shift === keyBinding.shift) && + (keyBinding.alt === undefined || key.alt === keyBinding.alt) && + (keyBinding.ctrl === undefined || key.ctrl === keyBinding.ctrl) && + (keyBinding.cmd === undefined || key.cmd === keyBinding.cmd) + ); } /** diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index 4c2e6c4618..a23dfe530c 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -154,9 +154,10 @@ function formatBindings(bindings: readonly KeyBinding[]): string[] { function formatBinding(binding: KeyBinding): string { const modifiers: string[] = []; - if (binding.ctrl) modifiers.push('Ctrl'); - if (binding.command) modifiers.push('Cmd'); if (binding.shift) modifiers.push('Shift'); + if (binding.alt) modifiers.push('Alt'); + if (binding.ctrl) modifiers.push('Ctrl'); + if (binding.cmd) modifiers.push('Cmd'); const keyName = formatKeyName(binding.key); if (!keyName) { @@ -167,12 +168,13 @@ function formatBinding(binding: KeyBinding): string { let combo = segments.join(' + '); const restrictions: string[] = []; - if (binding.ctrl === false) restrictions.push('no Ctrl'); - if (binding.shift === false) restrictions.push('no Shift'); - if (binding.command === false) restrictions.push('no Cmd'); + if (binding.shift === false) restrictions.push('Shift'); + if (binding.alt === false) restrictions.push('Alt'); + if (binding.ctrl === false) restrictions.push('Ctrl'); + if (binding.cmd === false) restrictions.push('Cmd'); if (restrictions.length > 0) { - combo = `${combo} (${restrictions.join(', ')})`; + combo = `${combo} (no ${restrictions.join(', ')})`; } return combo ? `\`${combo}\`` : ''; diff --git a/scripts/tests/generate-keybindings-doc.test.ts b/scripts/tests/generate-keybindings-doc.test.ts index 3f06147d3d..68a166609b 100644 --- a/scripts/tests/generate-keybindings-doc.test.ts +++ b/scripts/tests/generate-keybindings-doc.test.ts @@ -36,7 +36,7 @@ describe('generate-keybindings-doc', () => { }, { description: 'Submit with Enter if no modifiers are held.', - bindings: [{ key: 'return', ctrl: false, shift: false }], + bindings: [{ key: 'return', shift: false, ctrl: false }], }, ], }, @@ -47,7 +47,7 @@ describe('generate-keybindings-doc', () => { description: 'Move up through results.', bindings: [ { key: 'up', shift: false }, - { key: 'p', ctrl: true, shift: false }, + { key: 'p', shift: false, ctrl: true }, ], }, ], @@ -59,7 +59,7 @@ describe('generate-keybindings-doc', () => { expect(markdown).toContain('Trigger custom action.'); expect(markdown).toContain('`Ctrl + X`'); expect(markdown).toContain('Submit with Enter if no modifiers are held.'); - expect(markdown).toContain('`Enter (no Ctrl, no Shift)`'); + expect(markdown).toContain('`Enter (no Shift, Ctrl)`'); expect(markdown).toContain('#### Navigation'); expect(markdown).toContain('Move up through results.'); expect(markdown).toContain('`Up Arrow (no Shift)`');