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 = {