Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions docs/users/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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

Expand Down
26 changes: 15 additions & 11 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +190 to +191

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The PR says "Closes #8069", but #8069 was already closed on 2026-07-31 by merged PR #8074 (the Ctrl+Tab alternative this PR removes). The live gap this PR actually fixes — Ctrl+Tab being intercepted by Warp — is tracked in still-OPEN issue #8330, and the complementary mouse-click fix is open PR #8395; neither is referenced. — Concrete cost: after merge, #8330 stays open with no cross-reference, "Closes #8069" is a no-op link, and two open PRs touching the same picker files (#8395, #8576) are not connected anywhere, risking conflicting review/merge decisions. Suggested fix: update the PR description, e.g. "Addresses the remaining gap from #8330; complements the mouse-click fix in #8395; #8069 was already closed by #8074".

中文说明

[Suggestion] 本 PR 声明 "Closes #8069",但 #8069 已于 2026-07-31 被合并的 PR #8074(即本 PR 所移除的 Ctrl+Tab 备选方案)关闭。本 PR 实际修复的活跃缺口——Ctrl+Tab 被 Warp 拦截——记录在仍开放的 issue #8330 中;互补的鼠标点击修复是开放 PR #8395;二者均未被引用。— 具体代价:合并后 #8330 仍然开放且没有交叉引用;"Closes #8069" 成为无效链接;两个触及同一补全选择器文件的开放 PR(#8395#8576)互不关联,可能导致冲突的评审/合并决定。建议更新 PR 描述,例如 "Addresses the remaining gap from #8330; complements the mouse-click fix in #8395; #8069 was already closed by #8074"。

— qwen3.8-max via Qwen Code /review (v0.21.5)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate — #8069 was already closed by merged PR #8074, and the live gap this PR actually closes is #8330 (Ctrl+Tab intercepted by Warp), with the complementary mouse-click fix in #8395. Updating the PR description is a GitHub write that this automated round cannot perform (it has no GitHub credentials; the workflow owns all GitHub writes), so it must be applied by a maintainer rather than in code. Suggested description wording: "Addresses the remaining gap from #8330; complements the mouse-click fix in #8395; #8069 was already closed by #8074." No code change is required for this finding.

中文说明

确实如此—— #8069 已被合并的 PR #8074 关闭,本 PR 实际修复的活跃缺口是 #8330(Ctrl+Tab 被 Warp 拦截),互补的鼠标点击修复是 #8395。更新 PR 描述属于 GitHub 写操作,本轮自动化无法执行(没有 GitHub 凭据,所有 GitHub 写操作由工作流负责),因此需要由维护者应用而非通过代码完成。建议的描述措辞为:"Addresses the remaining gap from #8330; complements the mouse-click fix in #8395; #8069 was already closed by #8074"。该发现无需代码改动。

//
// 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 },
],
Comment on lines 201 to 203

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Removing the Ctrl+Tab / Ctrl+Shift+Tab bindings also deleted the only thing shadowing the modifier-agnostic ACCEPT_SUGGESTION { key: 'tab' } binding. On terminals that deliver Ctrl+Tab distinctly (kitty CSI-u ESC[9;5u, xterm modifyOtherKeys ESC[27;5;9~ — both decoded by KeypressContext into {name:'tab', ctrl:true}), pressing Ctrl+Tab with the tabbed menu open now inserts the highlighted suggestion instead of switching the tab — an unexpected buffer mutation on exactly the UI where the previous release's hint advertised that gesture as navigation. Pre-PR this fallthrough only occurred with ≤2 categories; the diff extends it to the tabbed UI. — Suggested fix: if "no longer bind" is meant literally, pin ctrl: false on ACCEPT_SUGGESTION's tab entry ({ key: 'tab', ctrl: false }), keeping bare Tab and Shift+Tab accepting; alternatively state in the PR/tests that Ctrl+Tab ≡ Tab is intentional.

中文说明

[Suggestion] 删除 Ctrl+Tab / Ctrl+Shift+Tab 绑定的同时,也移除了唯一遮蔽不区分修饰键的 ACCEPT_SUGGESTION { key: 'tab' } 绑定的东西。在能区分递送 Ctrl+Tab 的终端上(kitty CSI-u ESC[9;5u、xterm modifyOtherKeys ESC[27;5;9~——KeypressContext 均解码为 {name:'tab', ctrl:true}),在显示分类标签的菜单打开时按 Ctrl+Tab,现在会插入高亮建议而不是切换分类——恰恰在上一版本提示文字把该手势标注为导航的界面上,产生了意外的输入框变更。PR 之前该穿透只发生在 ≤2 个分类时;本 diff 把它扩展到了带标签栏的界面。— 建议:若"不再绑定"是字面意图,给 ACCEPT_SUGGESTION 的 tab 条目固定 ctrl: false{ key: 'tab', ctrl: false }),保留裸 Tab 与 Shift+Tab 的接受行为;或在 PR/测试中明确 Ctrl+Tab ≡ Tab 是有意为之。

— qwen3.8-max via Qwen Code /review (v0.21.5)

[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
Expand Down
100 changes: 88 additions & 12 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -2726,18 +2726,18 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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();
Comment on lines 2734 to 2736

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The hidden-tab-bar tests assert only that switching does not happen (switchCategory not called); the other half of the arrow contract — that the arrows still reach the input buffer when the tab bar is hidden — is never asserted. Probe-confirmed at this commit: inserting if (!categoryTabsVisible && (key.name === 'left' || key.name === 'right')) return true; into the completion branch survives the full suite (207/207 pass), silently freezing caret movement in the common ≤2-category case. Note the fall-through goes via BaseTextInput.tsx:305buffer.handleInput(key), and mockBuffer.move receives zero calls in this state, so the pinning assertion must target mockBuffer.handleInput. — Failure scenario: a future regression that eats bare arrows while the tab bar is hidden ships green — half of the PR's stated behaviour ("arrows keep their usual caret behaviour everywhere else") is unpinned.

Suggested change
// 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();
// With only 2 entries (all + one real category) the tab bar is hidden,
// so the arrows must not trigger category switching.
expect(switchCategory).not.toHaveBeenCalled();
// The arrows must still reach the buffer so the caret stays movable.
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
expect.objectContaining({ name: 'right' }),
);
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
expect.objectContaining({ name: 'left' }),
);
中文说明

[Suggestion] 标签栏隐藏的测试只断言了切换不会发生(switchCategory 未被调用);方向键契约的另一半——标签栏隐藏时方向键仍能到达输入缓冲区——从未被断言。已在本提交通过变异探针确认:在补全分支中插入 if (!categoryTabsVisible && (key.name === 'left' || key.name === 'right')) return true; 后整个测试套件仍然全部通过(207/207),会在常见的 ≤2 分类场景中悄悄冻结光标移动。注意穿透路径经由 BaseTextInput.tsx:305buffer.handleInput(key),此状态下 mockBuffer.move 的调用次数为零,因此固定该行为的断言必须以 mockBuffer.handleInput 为目标。— 故障场景:未来某个在标签栏隐藏时吞掉裸方向键的回归将一路绿灯地合入——PR 所声明行为的一半("其他场景下方向键保持正常的光标行为")没有任何测试固定。

— qwen3.8-max via Qwen Code /review (v0.21.7)

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,
Expand All @@ -2753,23 +2753,99 @@ describe('InputPrompt', () => {
switchCategory,
});
props.buffer.setText('@');
props.vimHandleInput = vi.fn().mockReturnValue(true);

const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();

stdin.write('\x1b[1;5C'); // Ctrl+right arrow
stdin.write('\x1b[C'); // plain right arrow
await wait();
Comment on lines +2761 to 2762

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-6: The positive category-switching tests assert switchCategory was called but never assert the arrow was consumed — half of the PR's stated behaviour ("the arrows no longer move the caret") is unpinned. Probe-confirmed at this commit: removing the return true after switchCategory(1) makes the arrow both switch the category AND fall through to caret movement, and this test still passes. — Failure scenario: a future dispatch mutation that switches and then falls through leaves every test in this PR green while the headline tradeoff ("arrows no longer move the caret while the tab bar is visible") silently regresses.

Fix idea: after the arrow, also assert the buffer state — e.g. cursor position / buffer.text unchanged, or spy that buffer.handleInput was not called with the arrow key.

中文说明

[Suggestion] R2-6:正向的分类切换测试只断言了 switchCategory 被调用,从未断言方向键被消费——PR 声明行为的一半(“方向键不再移动光标”)没有被测试固定。已在当前提交上用探针证实:删除 switchCategory(1) 后的 return true,方向键会既切换分类又继续移动光标,而本测试仍然通过。— 故障场景:未来某次派发修改让方向键先切换再穿透,本 PR 的所有测试依旧全绿,而核心取舍(“标签栏可见时方向键不再移动光标”)已悄悄回归。

修复建议:在方向键之后追加缓冲区状态断言——例如光标位置/buffer.text 不变,或用 spy 断言 buffer.handleInput 没有收到该方向键。

— qwen3.8-max via Qwen Code /review (v0.21.6)


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);
Comment on lines +2766 to +2769

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] These positive tests assert the category switched but not that the arrow was consumed — half of the PR's stated behaviour ("the arrows no longer move the caret") is unverified.

Failure scenario (mutation proven at this commit): deleting both return true; after completion.switchCategory(±1) (InputPrompt.tsx:1428/1433) keeps every PR test green while the arrow also reaches BaseTextInput — the caret drifts one character on every category switch. The probe assertion below fails under the mutation and passes on the original code, so it distinguishes correct from buggy.

Suggested fix: after each arrow write in the > 2 test, also assert the key did not reach the buffer:

expect(mockBuffer.move).not.toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
中文说明

这些正向测试断言了分类发生切换,却没有断言方向键被消费——PR 所声明行为的一半("方向键不再移动光标")未被验证。

故障场景(已在本提交上做变异验证):删除 completion.switchCategory(±1) 之后的两处 return true;(InputPrompt.tsx:1428/1433),PR 的所有测试仍然全绿,而方向键同时会到达 BaseTextInput——每次切换分类光标都会漂移一个字符。下面的探针断言在该变异下失败、在原始代码上通过,因此能区分正确与错误实现。

建议修复:在 > 2 测试中每次写入方向键后,追加断言按键没有到达输入缓冲区:

expect(mockBuffer.move).not.toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();

— qwen3.8-max via Qwen Code /review (v0.21.5)

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(<InputPrompt {...props} />);
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(
<InputPrompt {...props} />,
);
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,
Expand All @@ -2789,13 +2865,13 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
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();
});
Expand Down
45 changes: 23 additions & 22 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
);
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<{
Expand Down Expand Up @@ -1081,6 +1087,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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) {
Comment on lines +1090 to +1092

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Consuming bare Left/Right here — before vimHandleInput — bypasses vim's arrow-motion branch (vim.ts:1825-1860), which pre-PR applied/cancelled the pending operator and cleared the repeat count. With Vim mode on and the category tabs visible, a pending d/c/y/</> operator or a typed digit count survives a category switch and fires on the user's next motion key. This is distinct from the R3-2 thread (the missing Esc dismiss path): pre-PR the bare arrow always reached vim's arrow branch, which cleaned this state up; post-PR vim never sees the key at all. — Failure scenario: Vim mode enabled; type @ with ≥3 categories (tab bar visible); press Esc — vim consumes it (menu persists, NORMAL mode); press d (pendingOperator set); press Right Arrow (category switches, vim never sees the key); press l → the stale delete executes, removing text the user did not ask to delete. Probe-verified at this commit: with the PR's routing, d → intercepted Right → l performs the delete; with pre-PR routing the same sequence cancels the operator. Counts and c leak the same way.

Suggested fix direction: when this block consumes an arrow while vim is active, clear vim's pending operator/count first (expose a reset from useVim), or let vim observe the key for state purposes before consuming it.

中文说明

[Critical] 在此处——vimHandleInput 之前——消费裸 Left/Right 会绕过 vim 的方向键分支(vim.ts:1825-1860);该分支在本 PR 之前会应用/取消待命操作符并清空重复计数。启用 Vim 模式且分类标签可见时,待命的 d/c/y/</> 操作符或已输入的次数会越过分类切换存活下来,并在用户的下一个移动键上触发。这与 R3-2 线程(缺少 Esc 关闭菜单路径)是不同的问题:本 PR 之前裸方向键总会到达 vim 的方向键分支并清理这些状态;本 PR 之后 vim 完全看不到该按键。— 故障场景:启用 Vim 模式;输入 @ 且 ≥3 个分类(标签栏可见);按 Esc——被 vim 消费(菜单仍在,进入 NORMAL 模式);按 d(设置 pendingOperator);按 Right Arrow(切换分类,vim 看不到该键);按 l → 悬置的删除被执行,删掉用户并未要求删除的文本。已在本提交通过探针验证:按本 PR 的路由,d → 被拦截的 Right → l 会执行删除;按 PR 之前的路由,同样的按键序列会取消操作符。计数与 c 以同样方式泄漏。

修复方向建议:当此分支在 vim 激活时消费方向键时,先清除 vim 的待命操作符/计数(从 useVim 暴露一个重置方法),或在消费按键前先让 vim 观察该键以维护状态。

— qwen3.8-max via Qwen Code /review (v0.21.8)

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;
}
Expand Down Expand Up @@ -1442,23 +1463,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}

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);
Expand Down Expand Up @@ -1907,6 +1911,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
exportCompletion,
isHistoryRestoredText,
showCompletionSuggestions,
categoryTabsVisible,
voiceInput,
targetDir,
],
Expand Down Expand Up @@ -2308,11 +2313,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
: completion.activeCategory
}
availableCategories={
suggestionsFromExport ||
commandSearchActive ||
reverseSearchActive
? undefined
: completion.availableCategories
categoryTabsVisible ? completion.availableCategories : undefined
}
Comment on lines 2315 to 2317

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] availableCategories now derives from categoryTabsVisible (which includes !isAttachmentMode), but the sibling activeCategory prop on this same call is still gated only by suggestionsFromExport. useCompletion filters the suggestion list by activeCategory upstream (useCompletion.ts:91-99) and nothing resets the category when entering attachment mode — so hiding the tab bar leaves an invisible filter narrowing the list, and the arrows are now owned by attachment navigation, so the user cannot switch back to 'All' until leaving attachment mode. Pre-PR the tab bar stayed rendered in attachment mode, so the filter state was always visible. — Failure scenario: type @foo with ≥3 categories, switch to a category tab holding ≤1 match, paste an image and press Up at row 0 → attachment mode hides the tab bar while the list stays filtered to the now-invisible category; the user cannot see or change the filter until leaving attachment mode or dismissing the menu. Probe-confirmed at this commit (with flip check).

Suggested fix: reset the completion category to 'all' when entering attachment mode (e.g. near setIsAttachmentMode(true)); gating the activeCategory prop alone is not enough because the filtering happens upstream in useCompletion.

中文说明

[Suggestion] availableCategories 现在由 categoryTabsVisible(包含 !isAttachmentMode)派生,但同一处的 activeCategory 属性仍只以 suggestionsFromExport 为门控。useCompletion 在上游按 activeCategory 过滤建议列表(useCompletion.ts:91-99),且进入附件模式时没有任何逻辑重置分类——于是标签栏被隐藏后,一个不可见的过滤器仍在收窄列表,而方向键已归附件导航所有,用户在离开附件模式之前无法切回 'All'。本 PR 之前标签栏在附件模式下仍然渲染,过滤状态始终可见。— 故障场景:输入 @foo(≥3 个分类),切换到只有 ≤1 条匹配的分类标签,粘贴图片后在第 0 行按 Up → 附件模式隐藏标签栏,而列表仍被过滤到这个已不可见的分类;用户在离开附件模式或关闭菜单之前无法看到或更改该过滤。已在本提交通过探针验证(含翻转检查)。

修复建议:进入附件模式时将补全分类重置为 'all'(例如在 setIsAttachmentMode(true) 附近);只门控 activeCategory 属性是不够的,因为过滤发生在上游的 useCompletion

— qwen3.8-max via Qwen Code /review (v0.21.8)

onHoverIndex={
suggestionsFromExport ? undefined : handleSuggestionHover
Expand Down
8 changes: 3 additions & 5 deletions packages/cli/src/ui/components/SuggestionsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,10 @@ export function SuggestionsDisplay({
</Box>
);
})}
{/* 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). */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{t('(Ctrl+Tab / Ctrl+Shift+Tab or Ctrl+←/→ to switch)')}
</Text>
<Text color={theme.text.secondary}>{t('(←/→ to switch)')}</Text>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] [test] The test-efficacy probe (harness validated) reverted this hunk alone and every affected test stayed green — no test asserts the rendered (←/→ to switch) hint; the PR's test changes exercise only key escape sequences. — Failure scenario: if a future change reverts or mangles the hint — e.g. restores the old (Ctrl+Tab / Ctrl+Shift+Tab or Ctrl+←/→ to switch) — CI stays green and users again see shortcut hints pointing at bindings that terminals / macOS Mission Control intercept: exactly the unreachable-binding confusion (#8069) this PR removes. Suggested fix: add a render assertion (SuggestionsDisplay or InputPrompt test) that with availableCategories > 2 the rendered output contains (←/→ to switch).

中文说明

[Suggestion] [test] 测试有效性探针(harness 已验证)单独回退该 hunk 后,所有受影响测试仍为绿色——没有任何测试断言渲染出的 (←/→ to switch) 提示;本 PR 的测试改动只覆盖了按键转义序列。— 故障场景:若未来某个改动回退或篡改该提示——例如恢复旧的 (Ctrl+Tab / Ctrl+Shift+Tab or Ctrl+←/→ to switch)——CI 依然全绿,而用户会再次看到指向被终端 / macOS 调度中心拦截的绑定的快捷键提示:正是本 PR 要消除的"绑定不可达"困惑(#8069)。建议:增加渲染断言(SuggestionsDisplay 或 InputPrompt 测试),验证 availableCategories > 2 时渲染输出包含 (←/→ to switch)

— qwen3.8-max via Qwen Code /review (v0.21.5)

</Box>
</Box>
)}
Expand Down
Loading
Loading