fix(cli): add Ctrl+Tab alternative for @ completion tab switching (#8074)

* fix(cli): add Ctrl+Tab alternative for @ completion tab switching (#8069)

Ctrl+←/→ is intercepted by most terminals (iTerm2, Windows Terminal,
macOS Terminal.app) for word-jump, making @ completion tab switching
unusable. Added Ctrl+Tab (next) and Ctrl+Shift+Tab (previous) as
alternative keybindings that are less commonly intercepted.

Also fixed the hint text: "(Ctrl+←/→ to switch)" was displayed even
when availableCategories <= 2 (where switching is disabled). Now the
hint only shows when there are >2 categories and mentions Ctrl+Tab
as an alternative.

* fix(cli): keep Ctrl+Shift+Tab from cycling approval mode (#8069)

Ctrl+Shift+Tab was added as a completion-category navigation binding, but the approval-mode cycling handlers matched any Shift+Tab without excluding ctrl. In Kitty-protocol terminals (which preserve the ctrl modifier on Shift+Tab) the navigation keystroke also silently cycled the approval mode. Exclude ctrl from the Shift+Tab match and mention both Ctrl+Tab directions in the switching hint.

* test(cli): cover Ctrl+Shift+Tab guard in AgentComposer (#8074)

* refactor(cli): drop redundant category-count guard in SuggestionsDisplay (#8074)

* test(cli): cover Ctrl+Tab completion tab-switch bindings (#8074)

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-31 08:35:52 +00:00
1 parent 5d4fa392ea
commit 31a26ea99c
7 files changed
+109 -6

No files matched your search

+5
View File
@@ -192,11 +192,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
// 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).
[Command.COMPLETION_TAB_LEFT]: [
{ key: 'left', shift: false, ctrl: true, command: false },
{ key: 'tab', shift: true, ctrl: true, command: false },
],
[Command.COMPLETION_TAB_RIGHT]: [
{ key: 'right', shift: false, ctrl: true, command: false },
{ key: 'tab', shift: false, ctrl: true, command: false },
],
// Text input
@@ -187,9 +187,11 @@ export function SuggestionsDisplay({
</Box>
);
})}
{/* Mention Ctrl+Tab as an alternative since many terminals
intercept Ctrl+←/→ for word-jump (#8069). */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{t('(Ctrl+←/→ to switch)')}
{t('(Ctrl+Tab / Ctrl+Shift+Tab or Ctrl+←/→ to switch)')}
</Text>
</Box>
</Box>
@@ -6,14 +6,14 @@
import { render } from 'ink-testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentStatus } from '@qwen-code/qwen-code-core';
import { AgentStatus, ApprovalMode } from '@qwen-code/qwen-code-core';
import {
useAgentViewActions,
useAgentViewState,
} from '../../contexts/AgentViewContext.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { usePreferredEditor } from '../../hooks/usePreferredEditor.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { StreamingState } from '../../types.js';
@@ -34,13 +34,17 @@ vi.mock('../QueuedMessageDisplay.js', () => ({
}));
vi.mock('./AgentFooter.js', () => ({ AgentFooter: () => null }));
type KeypressHandler = (key: Key) => void;
describe('AgentComposer', () => {
const setAgentInputBufferText = vi.fn();
const setAgentTabBarFocused = vi.fn();
const setAgentApprovalMode = vi.fn();
let capturedKeypressHandlers: KeypressHandler[];
beforeEach(() => {
vi.clearAllMocks();
capturedKeypressHandlers = [];
vi.mocked(useAgentViewState).mockReturnValue({
activeView: 'agent-1',
@@ -74,7 +78,11 @@ describe('AgentComposer', () => {
} as never);
vi.mocked(usePreferredEditor).mockReturnValue(undefined);
vi.mocked(useTerminalSize).mockReturnValue({ columns: 80, rows: 24 });
vi.mocked(useKeypress).mockImplementation(() => {});
vi.mocked(useKeypress).mockImplementation(
(handler: KeypressHandler, _options) => {
capturedKeypressHandlers.push(handler);
},
);
vi.mocked(useAgentStreamingState).mockReturnValue({
status: AgentStatus.IDLE,
streamingState: StreamingState.Idle,
@@ -99,4 +107,29 @@ describe('AgentComposer', () => {
expect(setAgentInputBufferText).not.toHaveBeenCalled();
});
// The second useKeypress call is the Shift+Tab approval-mode cycler.
const getShiftTabHandler = (): KeypressHandler => {
render(<AgentComposer agentId="agent-1" />);
return capturedKeypressHandlers[1]!;
};
it('cycles approval mode on Shift+Tab', () => {
const handler = getShiftTabHandler();
handler({ name: 'tab', shift: true, ctrl: false } as Key);
expect(setAgentApprovalMode).toHaveBeenCalledWith(
'agent-1',
ApprovalMode.AUTO_EDIT,
);
});
it('does not cycle approval mode on Ctrl+Shift+Tab', () => {
const handler = getShiftTabHandler();
handler({ name: 'tab', shift: true, ctrl: true } as Key);
expect(setAgentApprovalMode).not.toHaveBeenCalled();
});
});
@@ -104,7 +104,7 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
useKeypress(
(key) => {
const isShiftTab = key.shift && key.name === 'tab';
const isShiftTab = key.shift && key.name === 'tab' && !key.ctrl;
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
@@ -248,6 +248,29 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.DEFAULT);
});
it('should not cycle approval modes when Ctrl+Shift+Tab is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
addItem: vi.fn(),
}),
);
// Ctrl+Shift+Tab is a completion-category navigation binding (#8069); it
// must not also cycle the approval mode in Kitty-protocol terminals that
// report the ctrl modifier on Shift+Tab.
act(() => {
capturedUseKeypressHandler({
name: 'tab',
shift: true,
ctrl: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
expect(result.current).toBe(ApprovalMode.DEFAULT);
});
it('should not toggle if only one key or other keys combinations are pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
renderHook(() =>
@@ -81,7 +81,7 @@ export function useAutoAcceptIndicator({
// Handle Shift+Tab to cycle through all modes
// On Windows, Shift+Tab is indistinguishable from Tab (\t) in some terminals,
// so we allow Tab to switch modes as well to support the shortcut.
const isShiftTab = key.shift && key.name === 'tab';
const isShiftTab = key.shift && key.name === 'tab' && !key.ctrl;
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
+40
View File
@@ -516,6 +516,46 @@ 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 = {