Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
65 changes: 65 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,65 @@
# 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. Confirm that `Ctrl+Tab`, `Ctrl+Shift+Tab`, and `Ctrl+Left/Right` retain their
existing behavior in a terminal that forwards those keys.
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,106 @@
/**
* @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 {
layoutRowForEvent,
measureElementPosition,
} from '../utils/measure-element-position.js';
import { type MouseEvent } from '../utils/mouse.js';

vi.mock('../hooks/useMouseEvents.js', () => ({ useMouseEvents: vi.fn() }));
vi.mock('../hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn() }));
vi.mock('../utils/measure-element-position.js', () => ({
layoutRowForEvent: vi.fn(),
measureElementPosition: 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(layoutRowForEvent).mockImplementation(
(_node, terminalRow) => terminalRow - 1,
);
vi.mocked(measureElementPosition).mockImplementation((node) => {
const index = categoryNodes.indexOf(node);
return { x: 2 + index * 8, y: 4, width: 6, height: 1 };
});
});

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', () => {
const handler = mountAndGetHandler();

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

expect(onSelectCategory).toHaveBeenCalledWith('session');
});

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', () => {
const handler = mountAndGetHandler();

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

expect(onSelectCategory).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @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 {
layoutRowForEvent,
measureElementPosition,
} from '../utils/measure-element-position.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;
}

export function CompletionCategoryMouseController({
containerRef,
categoryRefs,
categories,
onSelectCategory,
}: CompletionCategoryMouseControllerProps): null {
const { rows: terminalHeight } = useTerminalSize();

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

const col = event.col - 1;
const row = layoutRowForEvent(
containerRef.current,
event.row,
terminalHeight,
);

for (let i = 0; i < categories.length; i++) {
const node = categoryRefs.current[i];
if (!node) continue;
const rect = measureElementPosition(node);
if (
rect.width > 0 &&
rect.height > 0 &&
col >= rect.x &&
col < rect.x + rect.width &&
row >= rect.y &&
row < rect.y + rect.height
) {
onSelectCategory(categories[i]);
return;
}
}
},
[containerRef, categoryRefs, categories, onSelectCategory, terminalHeight],
);

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

return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ describe('InputPrompt suggestion mouse routing', () => {
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
activeCategory: 'all',
availableCategories: ['all', 'file', 'session'],
switchCategory: vi.fn(),
selectCategory: vi.fn(),
} as unknown as UseCommandCompletionReturn;
vi.mocked(useCommandCompletion).mockReturnValue(mockCommandCompletion);

Expand Down Expand Up @@ -219,6 +223,27 @@ describe('InputPrompt suggestion mouse routing', () => {
unmount();
});

it('routes an exact category selection to the default completion source', () => {
const selectCategory = vi.fn();
(
mockCommandCompletion as UseCommandCompletionReturn & {
selectCategory: typeof selectCategory;
}
).selectCategory = selectCategory;

const { unmount } = renderWithProviders(<InputPrompt {...props} />);
expect(captured.props).not.toBeNull();

act(() => {
(captured.props!['onSelectCategory'] as (category: 'session') => void)(
'session',
);
});

expect(selectCategory).toHaveBeenCalledWith('session');
unmount();
});

it('uses the startup VP decision for suggestion mouse when the raw setting is unset', () => {
const { unmount } = renderWithProviders(
<VirtualViewportContext.Provider value={true}>
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ describe('InputPrompt', () => {
handleAutocomplete: vi.fn(),
activeCategory: 'all' as const,
availableCategories: ['all'] as Array<'all'>,
selectCategory: vi.fn(),
switchCategory: vi.fn(),
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
import type React from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import {
SuggestionsDisplay,
MAX_WIDTH,
type SuggestionCategory,
} from './SuggestionsDisplay.js';
import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
Expand Down Expand Up @@ -2099,6 +2103,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
uiActions,
],
);
const handleCategorySelect = useCallback(
(category: SuggestionCategory | 'all') => {
completion.selectCategory(category);
setExpandedSuggestionIndex(-1);
},
[completion],
);

// Whether any input-side handler would consume a Tab keystroke. AppContainer
// feeds this into useAutoAcceptIndicator's `shouldBlockTab` so the
Expand Down Expand Up @@ -2288,6 +2299,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSelectIndex={
suggestionsFromExport ? undefined : handleSuggestionSelect
}
onSelectCategory={
suggestionsFromExport ||
commandSearchActive ||
reverseSearchActive
? undefined
: handleCategorySelect
}
/>
</Box>
)}
Expand Down
Loading
Loading