diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index dce7a83e4e5..e1c529cd8a5 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -34,12 +34,12 @@ This document lists the available keyboard shortcuts in Qwen Code. | `Tab` | Autocomplete the current suggestion if one exists. | | `Up Arrow` | Row up, then snap to start, then history prev. | | `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. | -| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. | +| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. While the `@` completion menu shows category tabs, use `Ctrl+B` (the arrow switches tabs). | | `Ctrl+C` | Clear the input prompt | | `Esc` (double press) | Clear the input prompt. | | `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. | | `Ctrl+E` / `End` | Move the cursor to the end of the line. | -| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. | +| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. While the `@` completion menu shows category tabs, use `Ctrl+F` (the arrow switches tabs). | | `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. | | `Ctrl+K` | Delete from the cursor to the end of the line. | | `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. | @@ -81,14 +81,18 @@ Focus the Background tasks pill in the footer (use `Down Arrow` from an empty co ## Suggestions -| Shortcut | Description | -| ------------------------------------ | ------------------------------------------------------------------------ | -| `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. | -| `Tab` / `Enter` | Accept the selected suggestion. | -| `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. | -| `Right Arrow` | Accept a ghost-text suggestion when the prompt is empty. | -| `Ctrl+Tab` / `Ctrl+Right Arrow` | Switch to the next completion category when category tabs are shown. | -| `Ctrl+Shift+Tab` / `Ctrl+Left Arrow` | Switch to the previous completion category when category tabs are shown. | +| Shortcut | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. | +| `Tab` / `Enter` | Accept the selected suggestion. | +| `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. | +| `Right Arrow` | Switch to the next completion category when category tabs are shown. Also accepts a ghost-text suggestion when the prompt is empty. | +| `Left Arrow` | Switch to the previous completion category when category tabs are shown. | + +> Note: while the `@` completion menu is showing category tabs, `Left Arrow` and +> `Right Arrow` switch categories instead of moving the cursor. Press `Esc` to +> dismiss the menu first if you need to move the cursor. `Alt/Option+Arrow` word +> movement is unaffected. ## History Search diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index dcadcb6468a..e64e1cd6da9 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -185,20 +185,24 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'n', ctrl: true }, ], // Completion category tab switching (for the tabbed @ completion UI). - // Bound to Ctrl+arrows rather than plain arrows so the bare arrow keys keep - // moving the caret in the editable input buffer (plain arrows only switch - // tabs in modal dialogs, which have no text buffer). Alt/Option+arrows still - // perform word movement. - // Ctrl+←/→ is the primary binding but many terminals intercept it for - // word-jump. Ctrl+Tab / Ctrl+Shift+Tab are alternatives that are less - // commonly intercepted (#8069). + // Bound to the BARE arrow keys: Ctrl+←/→ was the original binding but many + // terminals intercept it for word-jump, and on macOS the system claims it + // for Mission Control, so the documented gesture was unreachable for most + // users (#8069). + // + // Tradeoff, accepted deliberately: while the `@` category tabs are visible, + // the bare arrows no longer move the caret in the input buffer — press Esc + // to dismiss the menu first. InputPrompt only renders and handles the tabs + // when they own the arrows, so search and attachment navigation keep their + // normal behavior. + // + // Modifiers are pinned false so Alt/Option+arrow word movement and any + // Ctrl+arrow terminal binding fall through untouched. [Command.COMPLETION_TAB_LEFT]: [ - { key: 'left', shift: false, ctrl: true, command: false }, - { key: 'tab', shift: true, ctrl: true, command: false }, + { key: 'left', shift: false, ctrl: false, meta: false }, ], [Command.COMPLETION_TAB_RIGHT]: [ - { key: 'right', shift: false, ctrl: true, command: false }, - { key: 'tab', shift: false, ctrl: true, command: false }, + { key: 'right', shift: false, ctrl: false, meta: false }, ], // Text input diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 4cc4a2313ce..a0a9caad6a4 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2709,7 +2709,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should NOT switch category on Ctrl+left/right when availableCategories is exactly 2', async () => { + it('should NOT switch category on left/right when availableCategories is exactly 2', async () => { const switchCategory = vi.fn(); mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, @@ -2726,18 +2726,18 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders(); await wait(); - stdin.write('\x1b[1;5C'); // Ctrl+right arrow + stdin.write('\x1b[C'); // right arrow await wait(); - stdin.write('\x1b[1;5D'); // Ctrl+left arrow + stdin.write('\x1b[D'); // left arrow await wait(); // With only 2 entries (all + one real category) the tab bar is hidden, - // so Ctrl+arrows must not trigger category switching. + // so the arrows must not trigger category switching. expect(switchCategory).not.toHaveBeenCalled(); unmount(); }); - it('should switch category on Ctrl+left/right when availableCategories > 2', async () => { + it('should switch category on plain arrows before Vim handling', async () => { const switchCategory = vi.fn(); mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, @@ -2753,23 +2753,99 @@ describe('InputPrompt', () => { switchCategory, }); props.buffer.setText('@'); + props.vimHandleInput = vi.fn().mockReturnValue(true); const { stdin, unmount } = renderWithProviders(); await wait(); - stdin.write('\x1b[1;5C'); // Ctrl+right arrow + stdin.write('\x1b[C'); // plain right arrow await wait(); expect(switchCategory).toHaveBeenCalledWith(1); - stdin.write('\x1b[1;5D'); // Ctrl+left arrow + stdin.write('\x1b[D'); // plain left arrow await wait(); expect(switchCategory).toHaveBeenCalledWith(-1); + expect(props.vimHandleInput).not.toHaveBeenCalled(); + unmount(); + }); + + it('should NOT switch category on bare arrows while command search is active', async () => { + props.shellModeActive = false; + const switchCategory = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + completionMode: CompletionMode.AT, + showSuggestions: true, + suggestions: [ + { label: 'file.ts', value: 'file.ts', category: 'file' }, + { label: 'sess', value: 'sess', category: 'session' }, + ], + activeSuggestionIndex: 0, + isPerfectMatch: false, + availableCategories: ['all', 'file', 'session'], + switchCategory, + }); + props.buffer.setText('@ses'); + + const { stdin, unmount } = renderWithProviders(); + await wait(); + + stdin.write('\x12'); + await wait(); + stdin.write('\x1b[C'); + await wait(); + stdin.write('\x1b[D'); + await wait(); + + expect(switchCategory).not.toHaveBeenCalled(); unmount(); }); - it('should NOT switch category on plain left/right when availableCategories > 2 (caret stays free)', async () => { + it('should hide category tabs and keep bare arrows for attachments', async () => { + const isWindows = process.platform === 'win32'; + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( + path.join('test', 'project', '.qwen', 'tmp', 'clipboard.png'), + ); + vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue( + undefined, + ); + + const switchCategory = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + completionMode: CompletionMode.AT, + showSuggestions: true, + suggestions: [{ label: 'file.ts', value: 'file.ts', category: 'file' }], + activeSuggestionIndex: 0, + isPerfectMatch: false, + availableCategories: ['all', 'file', 'session'], + switchCategory, + }); + props.buffer.setText('@'); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write(isWindows ? '\x1Bv' : '\x16'); + await wait(); + stdin.write('\x1b[A'); + await wait(); + stdin.write('\x1b[C'); + await wait(); + stdin.write('\x1b[D'); + await wait(); + + expect(switchCategory).not.toHaveBeenCalled(); + expect(stripAnsi(lastFrame() ?? '')).not.toContain('(←/→ to switch)'); + unmount(); + }); + + it('should NOT consume Ctrl+left/right for category switching (#8069)', async () => { const switchCategory = vi.fn(); mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, @@ -2789,13 +2865,13 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders(); await wait(); - stdin.write('\x1b[C'); // plain right arrow + stdin.write('\x1b[1;5C'); // Ctrl+right arrow await wait(); - stdin.write('\x1b[D'); // plain left arrow + stdin.write('\x1b[1;5D'); // Ctrl+left arrow await wait(); - // Plain arrows must not be hijacked for tab switching, so they remain - // available to move the caret in the editable buffer. + // Ctrl+arrows are no longer bound: terminals and macOS Mission Control + // intercept them, so they are left to fall through to the terminal. expect(switchCategory).not.toHaveBeenCalled(); unmount(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 52d075cae06..7274689fc28 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -382,6 +382,12 @@ export const InputPrompt: React.FC = ({ ); const showCompletionSuggestions = completion.showSuggestions && !isHistoryRestoredText; + const categoryTabsVisible = + !exportCompletion.suggestionDisplayProps && + !commandSearchActive && + !reverseSearchActive && + !isAttachmentMode && + (completion.availableCategories?.length ?? 0) > 2; // Ref so renderLineWithHighlighting (stable useCallback) can access fresh ghost text const midInputGhostTextRef = useRef<{ @@ -1081,6 +1087,21 @@ export const InputPrompt: React.FC = ({ return true; } + // The visible category tabs own the bare arrows, including in Vim mode. + // All other states fall through to their existing input owner. + if (showCompletionSuggestions && categoryTabsVisible) { + if (keyMatchers[Command.COMPLETION_TAB_RIGHT](key)) { + completion.switchCategory(1); + setExpandedSuggestionIndex(-1); + return true; + } + if (keyMatchers[Command.COMPLETION_TAB_LEFT](key)) { + completion.switchCategory(-1); + setExpandedSuggestionIndex(-1); + return true; + } + } + if (vimHandleInput && vimHandleInput(key)) { return true; } @@ -1442,23 +1463,6 @@ export const InputPrompt: React.FC = ({ } if (showCompletionSuggestions) { - // Category tab switching for the tabbed `@` completion UI. Only consume - // Ctrl+←/→ (per the COMPLETION_TAB_* bindings) and only when there are - // more than two tabs (at least 3 entries including 'all'). Plain ←/→ are - // never consumed here, so they always move the caret in the editable buffer. - if ((completion.availableCategories?.length ?? 0) > 2) { - if (keyMatchers[Command.COMPLETION_TAB_RIGHT](key)) { - completion.switchCategory(1); - setExpandedSuggestionIndex(-1); - return true; - } - if (keyMatchers[Command.COMPLETION_TAB_LEFT](key)) { - completion.switchCategory(-1); - setExpandedSuggestionIndex(-1); - return true; - } - } - if (completion.suggestions.length > 1) { const isCompletionUpKey = keyMatchers[Command.COMPLETION_UP](key); const isCompletionDownKey = keyMatchers[Command.COMPLETION_DOWN](key); @@ -1907,6 +1911,7 @@ export const InputPrompt: React.FC = ({ exportCompletion, isHistoryRestoredText, showCompletionSuggestions, + categoryTabsVisible, voiceInput, targetDir, ], @@ -2308,11 +2313,7 @@ export const InputPrompt: React.FC = ({ : completion.activeCategory } availableCategories={ - suggestionsFromExport || - commandSearchActive || - reverseSearchActive - ? undefined - : completion.availableCategories + categoryTabsVisible ? completion.availableCategories : undefined } onHoverIndex={ suggestionsFromExport ? undefined : handleSuggestionHover diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index d78183010fa..70006eed43a 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -187,12 +187,10 @@ export function SuggestionsDisplay({ ); })} - {/* Mention Ctrl+Tab as an alternative since many terminals - intercept Ctrl+←/→ for word-jump (#8069). */} + {/* Bare ←/→: the original Ctrl+←/→ was unreachable because terminals + and macOS Mission Control intercept it (#8069). */} - - {t('(Ctrl+Tab / Ctrl+Shift+Tab or Ctrl+←/→ to switch)')} - + {t('(←/→ to switch)')} )} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 501c5a07c38..8e84739827f 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -47,9 +47,9 @@ describe('keyMatchers', () => { [Command.COMPLETION_DOWN]: (key: Key) => (key.name === 'down' && !key.shift) || (key.ctrl && key.name === 'n'), [Command.COMPLETION_TAB_LEFT]: (key: Key) => - key.name === 'left' && !key.shift && key.ctrl && !key.meta, + key.name === 'left' && !key.shift && !key.ctrl && !key.meta, [Command.COMPLETION_TAB_RIGHT]: (key: Key) => - key.name === 'right' && !key.shift && key.ctrl && !key.meta, + key.name === 'right' && !key.shift && !key.ctrl && !key.meta, [Command.ESCAPE]: (key: Key) => key.name === 'escape', [Command.SUBMIT]: (key: Key) => key.name === 'return' && !key.ctrl && !key.meta && !key.paste, @@ -243,22 +243,26 @@ describe('keyMatchers', () => { }, { command: Command.COMPLETION_TAB_LEFT, - positive: [createKey('left', { ctrl: true })], + positive: [createKey('left')], negative: [ - createKey('left'), - createKey('left', { shift: true, ctrl: true }), - createKey('left', { ctrl: true, meta: true }), - createKey('right', { ctrl: true }), + createKey('left', { ctrl: true }), + createKey('left', { shift: true }), + createKey('left', { meta: true }), + createKey('right'), + createKey('tab'), + createKey('tab', { ctrl: true, shift: true }), ], }, { command: Command.COMPLETION_TAB_RIGHT, - positive: [createKey('right', { ctrl: true })], + positive: [createKey('right')], negative: [ - createKey('right'), - createKey('right', { shift: true, ctrl: true }), - createKey('right', { ctrl: true, meta: true }), - createKey('left', { ctrl: true }), + createKey('right', { ctrl: true }), + createKey('right', { shift: true }), + createKey('right', { meta: true }), + createKey('left'), + createKey('tab'), + createKey('tab', { ctrl: true }), ], }, @@ -518,46 +522,6 @@ describe('keyMatchers', () => { }); }); - // The Ctrl+Tab / Ctrl+Shift+Tab alternatives intentionally diverge from the - // original hard-coded matchers (which only knew Ctrl+←/→), so they are - // asserted against the data-driven matchers here rather than in the - // comparison block above (#8069). - describe('Completion tab-switching alternative bindings (#8069)', () => { - it('should match Ctrl+Tab as COMPLETION_TAB_RIGHT', () => { - expect( - keyMatchers[Command.COMPLETION_TAB_RIGHT]( - createKey('tab', { ctrl: true }), - ), - ).toBe(true); - // Bare Tab accepts the suggestion; Ctrl+Shift+Tab switches left. - expect(keyMatchers[Command.COMPLETION_TAB_RIGHT](createKey('tab'))).toBe( - false, - ); - expect( - keyMatchers[Command.COMPLETION_TAB_RIGHT]( - createKey('tab', { ctrl: true, shift: true }), - ), - ).toBe(false); - }); - - it('should match Ctrl+Shift+Tab as COMPLETION_TAB_LEFT', () => { - expect( - keyMatchers[Command.COMPLETION_TAB_LEFT]( - createKey('tab', { ctrl: true, shift: true }), - ), - ).toBe(true); - // Bare Tab accepts the suggestion; Ctrl+Tab switches right. - expect(keyMatchers[Command.COMPLETION_TAB_LEFT](createKey('tab'))).toBe( - false, - ); - expect( - keyMatchers[Command.COMPLETION_TAB_LEFT]( - createKey('tab', { ctrl: true }), - ), - ).toBe(false); - }); - }); - describe('Custom key bindings', () => { it('should work with custom configuration', () => { const customConfig: KeyBindingConfig = {