Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e71bc6c
fix(cli): make completion category tabs clickable
destire-mio Aug 2, 2026
93e2a5b
test(cli): cover completion category boundaries
destire-mio Aug 3, 2026
c34dd67
test(cli): cover completion category mouse state
destire-mio Aug 6, 2026
bec2894
Merge origin/main into codex/fix-8330-completion-tab-mouse
destire-mio Aug 7, 2026
28fc002
fix(cli): address completion category mouse review feedback
destire-mio Aug 7, 2026
a287ac2
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 12, 2026
35f0dfd
fix(cli): harden completion category mouse handling
destire-mio Aug 12, 2026
342a45a
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 12, 2026
c62d15e
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 12, 2026
2f8c0a3
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 12, 2026
57f4202
test(cli): cover completion category review gaps
destire-mio Aug 12, 2026
4bfe311
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 12, 2026
499b09f
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 12, 2026
8b62f58
fix(review): keep repository context within file limit
destire-mio Aug 12, 2026
4677ee0
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 12, 2026
a87f5fd
test(review): pin repository context headroom
destire-mio Aug 12, 2026
c2f3155
test(review): make context bound probe robust
destire-mio Aug 12, 2026
f12770d
test(review): avoid changed-path exclusion false positive
destire-mio Aug 12, 2026
e19de89
test(review): harden manifest policy probes
destire-mio Aug 12, 2026
a81d1f2
test(review): cover all top-level skill sources
destire-mio Aug 13, 2026
0e06548
test(cli): cover completion mouse guards
destire-mio Aug 13, 2026
214e60f
test(review): share repository file walker
destire-mio Aug 13, 2026
10aa640
test(cli): pin completion tab ref order
destire-mio Aug 13, 2026
eb79645
test(review): normalize walked repository paths
destire-mio Aug 13, 2026
a06c440
test(cli): narrow completion tab node types
destire-mio Aug 13, 2026
7d7cc37
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 13, 2026
66de09e
chore(review): drop temporary context CI backport
destire-mio Aug 13, 2026
efae094
refactor(cli): unify completion category changes
destire-mio Aug 13, 2026
c16e993
Merge remote-tracking branch 'origin/main' into work/fix-8395
destire-mio Aug 13, 2026
749804f
fix(cli): preserve full completion row indices
destire-mio Aug 13, 2026
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
66 changes: 66 additions & 0 deletions .qwen/e2e-tests/2026-08-03-completion-category-mouse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Completion Category Mouse Selection

## Scope

Verify that the tabbed `@` completion picker can switch to an exact category
with a mouse click when terminal mouse tracking is enabled. Keyboard category
navigation and suggestion-row mouse behavior must remain unchanged.

## Baseline

A released global `qwen` executable was not available in this environment, so
the before-change Warp dry-run could not be repeated locally. The issue's SGR
mouse reproduction reports that suggestion rows accept clicks while category
labels do not. Source inspection on `upstream/main` confirms that suggestion
rows have a mouse controller, while category labels have no refs or click
handler.

## Manual Scenario

1. Run Qwen Code in Warp with `ui.mouseTracking` enabled and the terminal
buffer enabled.
2. Open a workspace where `@` completion shows at least three tabs, such as
**All / Files / Sessions**.
3. Click **Files**, then **Sessions**, then **All**.
4. Confirm that each click activates the exact tab under the pointer, resets
the highlighted suggestion to the first visible result, and filters the
suggestion rows for that category.
5. Confirm that clicking outside every tab does not change the active tab.
6. While the tab bar is visible, confirm that bare `Left` / `Right` cycles the
categories. Press `Esc` to dismiss the picker and confirm the arrows return
to normal input-caret behavior.
7. Set `ui.mouseTracking` to `false`, restart Qwen Code, and confirm that tab
clicks are not intercepted by Qwen Code.

## Regression Checks

- Only a left-button press activates a category; pointer movement and button
release do not.
- The category hit area follows the rendered tab bounds in both normal and
overflow layouts.
- Suggestion-row hover and click handling still use their existing controller.
- Export completion, command search, and reverse search do not receive a
category-selection callback.

## Automated Verification

```bash
cd packages/cli
npx vitest run \
src/ui/components/CompletionCategoryMouseController.test.tsx \
src/ui/components/SuggestionsDisplay.mouse.test.tsx \
src/ui/components/InputPrompt.suggestionMouse.test.tsx \
src/ui/hooks/useCompletion.test.ts

cd ../..
npm run lint
npm run build
npm run typecheck
```

## Results

- The focused mouse/category regression suite passes: 4 files, 41 tests.
- Repository lint, build, and typecheck pass.
- Manual Warp execution remains for reviewer verification because Warp and a
released global `qwen` executable were not available in this environment.
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { type MutableRefObject } from 'react';
import { type DOMElement } from 'ink';
import { render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CompletionCategoryMouseController } from './CompletionCategoryMouseController.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { type MouseEvent } from '../utils/mouse.js';
import { findElementAtMouseEvent } from '../utils/mouse-hit.js';

vi.mock('../hooks/useMouseEvents.js', () => ({ useMouseEvents: vi.fn() }));
vi.mock('../hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn() }));
vi.mock('../utils/mouse-hit.js', () => ({ findElementAtMouseEvent: vi.fn() }));

const ref = <T,>(current: T): MutableRefObject<T> => ({ current });

function makeEvent(
partial: Partial<MouseEvent> & Pick<MouseEvent, 'name'>,
): MouseEvent {
return {
col: 1,
row: 1,
shift: false,
meta: false,
ctrl: false,
button: 'left',
...partial,
} as MouseEvent;
}

describe('CompletionCategoryMouseController', () => {
const containerNode = { tag: 'container' } as unknown as DOMElement;
const categoryNodes = [
{ tag: 'all' },
{ tag: 'file' },
{ tag: 'session' },
] as unknown as DOMElement[];
const categories = ['all', 'file', 'session'] as const;
let onSelectCategory: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.clearAllMocks();
onSelectCategory = vi.fn();
vi.mocked(useTerminalSize).mockReturnValue({ rows: 40, columns: 80 });
vi.mocked(findElementAtMouseEvent).mockReturnValue(null);
});

function mountAndGetHandler(): (event: MouseEvent) => void {
render(
<CompletionCategoryMouseController
containerRef={ref(containerNode)}
categoryRefs={ref(categoryNodes)}
categories={categories}
onSelectCategory={onSelectCategory}
/>,
);
const call = vi.mocked(useMouseEvents).mock.calls.at(-1)!;
expect(call[1]).toMatchObject({ isActive: true, tracking: 'button' });
return call[0];
}

it('selects the exact category under a left click', () => {
vi.mocked(findElementAtMouseEvent).mockReturnValue(2);
const handler = mountAndGetHandler();

handler(makeEvent({ name: 'left-press', col: 19, row: 5 }));

expect(onSelectCategory).toHaveBeenCalledWith('session');
Comment on lines +72 to +74

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 SGR→0-based column offset (event.col - 1) in CompletionCategoryMouseController is not pinned by any test — the mutation event.col - 1event.col survives all three controller tests. — Concrete cost: every test coordinate is strictly interior/exterior under both mappings (col 19 → 18 or 19 both land inside the mocked session rect [18,24); col 9 → 8 or 9 both miss). A click at SGR col 18 (layout col 17, the gap just before the Sessions tab) selects nothing under correct code but selects 'session' under the mutant, so a one-column hit-region shift on the column side would ship green.

Add a boundary case that flips under the mutation:

handler(makeEvent({ name: 'left-press', col: 18, row: 5 }));
expect(onSelectCategory).not.toHaveBeenCalled();
中文说明

CompletionCategoryMouseController 里的 SGR→0 基列偏移(event.col - 1)没有被任何测试钉住——把 event.col - 1 突变成 event.col 后,三个控制器测试仍全部通过。具体代价:所有测试坐标在两种映射下都严格落在命中区内部或外部(col 19 → 18 或 19 都落进 mock 的 session 矩形 [18,24);col 9 → 8 或 9 都错过)。真实输入可以区分二者:在 SGR col 18(布局 col 17,即 Sessions 标签前的空隙)点击时,正确代码什么都不选,而突变体会选中 'session',因此列方向上一格的命中区偏移会在测试全绿的情况下溜进去。

建议补充一个能在该突变下翻转的边界用例(见上方代码块)。

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

});

it('ignores clicks outside category bounds', () => {
const handler = mountAndGetHandler();

handler(makeEvent({ name: 'left-press', col: 18, row: 5 }));
handler(makeEvent({ name: 'left-press', col: 9, row: 5 }));
handler(makeEvent({ name: 'left-press', col: 19, row: 6 }));

expect(onSelectCategory).not.toHaveBeenCalled();
});

it('ignores non-press mouse events', () => {
vi.mocked(findElementAtMouseEvent).mockReturnValue(2);
const handler = mountAndGetHandler();
Comment on lines +87 to +89

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 test that pins the left-press-only guard cannot discriminate it — probe-verified: findElementAtMouseEvent is mocked to return null in beforeEach and is never overridden here, so deleting if (event.name !== 'left-press') return; from the controller keeps the whole suite green (4/4 with the mutant). The sibling 'ignores clicks outside category bounds' test shares the same shape. — Failure scenario: a refactor that broadens the guard (e.g. also accepting left-release) ships green; in production SGR button tracking emits a release event at the press coordinates for every click and motion events while held, so a press-on-tab-A / drag / release-on-tab-B gesture would then select the tab under the release point instead of the pressed tab.

Make the mock return a hittable index so only the guard can prevent the callback:

Suggested change
it('ignores non-press mouse events', () => {
const handler = mountAndGetHandler();
it('ignores non-press mouse events', () => {
const handler = mountAndGetHandler();
vi.mocked(findElementAtMouseEvent).mockReturnValue(2);
中文说明

[Suggestion] 本应锁定「仅响应 left-press」守卫的测试实际上无法区分该守卫——已通过变异探测验证:findElementAtMouseEventbeforeEach 中被 mock 为返回 null,且该测试从未覆盖这个返回值,因此即使从控制器中删除 if (event.name !== 'left-press') return;,整个测试套件仍然全绿(变异体下 4/4 通过)。相邻的 'ignores clicks outside category bounds' 测试也是同样的形态。——失败场景:任何放宽该守卫的重构(例如同时接受 left-release)都会在测试全绿的情况下合入;生产环境中 SGR 按键追踪会为每次点击在按下坐标处发出释放事件、在按住时发出移动事件,因此「在标签 A 按下、拖动、在标签 B 释放」的手势将选中释放位置下的标签而不是按下位置的标签。修复方式:让 mock 返回一个可命中的索引,使只有该守卫能阻止回调触发(见英文部分 suggestion 块;已验证该修复对正确代码 4/4 通过、对变异体生效)。

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


handler(makeEvent({ name: 'move', col: 19, row: 5 }));
handler(makeEvent({ name: 'left-release', col: 19, row: 5 }));

expect(onSelectCategory).not.toHaveBeenCalled();
});

it('uses terminal height when the composited frame overflows', () => {
vi.mocked(findElementAtMouseEvent).mockReturnValue(2);
const handler = mountAndGetHandler();

handler(makeEvent({ name: 'left-press', col: 19, row: 5 }));

expect(findElementAtMouseEvent).toHaveBeenCalledWith(
containerNode,
categoryNodes,
expect.objectContaining({ col: 19, row: 5 }),
40,
'rect',
);
expect(onSelectCategory).toHaveBeenCalledWith('session');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { type MutableRefObject, useCallback } from 'react';
import { type DOMElement } from 'ink';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import { type MouseEvent } from '../utils/mouse.js';
import { findElementAtMouseEvent } from '../utils/mouse-hit.js';
import { type SuggestionCategory } from '../utils/suggestions.js';

type CompletionCategory = SuggestionCategory | 'all';

interface CompletionCategoryMouseControllerProps {
containerRef: MutableRefObject<DOMElement | null>;
categoryRefs: MutableRefObject<Array<DOMElement | null>>;
categories: readonly CompletionCategory[];
onSelectCategory: (category: CompletionCategory) => void;
}

/**
* Headless click layer for the completion category tabs.
*
* Coordinates assume the alternate-screen virtual viewport used by the owning
* suggestion UI. Ink bottom-pins an overflowing frame, so terminal rows must
* pass through `layoutRowForEvent` before they are compared with layout-space
* tab rectangles. Inline mode is intentionally unsupported; mount this only
* behind the owning surface's `mouseEnabled` gate.
*/
export function CompletionCategoryMouseController({
containerRef,
categoryRefs,
categories,
onSelectCategory,
}: CompletionCategoryMouseControllerProps): null {
const { rows: terminalHeight } = useTerminalSize();

const handleMouse = useCallback(
(event: MouseEvent) => {
if (event.name !== 'left-press') return;

const index = findElementAtMouseEvent(
containerRef.current,
categoryRefs.current,
event,
terminalHeight,
'rect',
);
if (index !== null && index < categories.length) {
onSelectCategory(categories[index]);
}
Comment on lines +52 to +54

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 index < categories.length stale-ref guard is paired with no test — probe-verified: deleting the clause leaves all 30 tests across the four mouse suites green. — Failure scenario: SuggestionsDisplay fills categoryRefs.current[i] per render but never truncates the array; if the tab count shrinks while the picker is open, a stale detached DOM node at the old index could resolve a click to an index >= categories.length, and without this guard onSelectCategory(undefined) would fire. Downstream selectCategory ignores unavailable categories, so no incorrect behaviour ships today — but the guard exists solely for this scenario and nothing exercises it. Suggested fix — add one case to CompletionCategoryMouseController.test.tsx:

it('ignores a resolved index beyond the category list', () => {
  vi.mocked(findElementAtMouseEvent).mockReturnValue(3);
  // render with 3 categories, send a left-press, then:
  expect(onSelectCategory).not.toHaveBeenCalled();
});
中文说明

[建议] index < categories.length 这个防陈旧 ref 的守卫没有任何测试覆盖——已通过探针验证:删除该条件后,四个鼠标测试套件的全部 30 个测试仍然全部通过。— 失败场景:SuggestionsDisplay 每次渲染都会填充 categoryRefs.current[i],但从不截断该数组;如果选择器打开期间分类数量减少,旧索引位置上残留的已卸载 DOM 节点可能把一次点击解析为 >= categories.length 的索引;若没有这个守卫,onSelectCategory(undefined) 就会被触发。下游的 selectCategory 会忽略不可用的分类,所以今天不会有错误行为上线——但该守卫正是为这个场景而存在的,却没有任何测试覆盖它。建议修复——在 CompletionCategoryMouseController.test.tsx 中补一个用例:

it('ignores a resolved index beyond the category list', () => {
  vi.mocked(findElementAtMouseEvent).mockReturnValue(3);
  // 用 3 个分类渲染,发送一次鼠标左键按下,然后断言:
  expect(onSelectCategory).not.toHaveBeenCalled();
});

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

Comment on lines +52 to +54

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 index < categories.length stale-ref guard is paired with no test — probe-verified: deleting the clause leaves all 30 tests across the four mouse suites green. — Failure scenario: SuggestionsDisplay fills categoryRefs.current[i] per render but never truncates the array; if the tab count shrinks while the picker is open, a stale detached DOM node at the old index could resolve a click to an index >= categories.length, and without this guard onSelectCategory(undefined) would fire. Downstream selectCategory ignores unavailable categories, so no incorrect behaviour ships today — but the guard exists solely for this scenario and nothing exercises it. Suggested fix — add one case to CompletionCategoryMouseController.test.tsx:

it('ignores a resolved index beyond the category list', () => {
  vi.mocked(findElementAtMouseEvent).mockReturnValue(3);
  // render with 3 categories, send a left-press, then:
  expect(onSelectCategory).not.toHaveBeenCalled();
});
中文说明

[建议] index < categories.length 这个防陈旧 ref 的守卫没有任何测试覆盖——已通过探针验证:删除该条件后,四个鼠标测试套件的全部 30 个测试仍然全部通过。— 失败场景:SuggestionsDisplay 每次渲染都会填充 categoryRefs.current[i],但从不截断该数组;如果选择器打开期间分类数量减少,旧索引位置上残留的已卸载 DOM 节点可能把一次点击解析为 >= categories.length 的索引;若没有这个守卫,onSelectCategory(undefined) 就会被触发。下游的 selectCategory 会忽略不可用的分类,所以今天不会有错误行为上线——但该守卫正是为这个场景而存在的,却没有任何测试覆盖它。建议修复——在 CompletionCategoryMouseController.test.tsx 中补一个用例:

it('ignores a resolved index beyond the category list', () => {
  vi.mocked(findElementAtMouseEvent).mockReturnValue(3);
  // 用 3 个分类渲染,发送一次鼠标左键按下,然后断言:
  expect(onSelectCategory).not.toHaveBeenCalled();
});

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

},
[containerRef, categoryRefs, categories, onSelectCategory, terminalHeight],
);

useMouseEvents(handleMouse, { isActive: true, tracking: 'button' });

return null;
}
Loading
Loading