diff --git a/.qwen/e2e-tests/2026-08-03-completion-category-mouse.md b/.qwen/e2e-tests/2026-08-03-completion-category-mouse.md new file mode 100644 index 00000000000..f37998598ec --- /dev/null +++ b/.qwen/e2e-tests/2026-08-03-completion-category-mouse.md @@ -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. diff --git a/packages/cli/src/ui/components/CompletionCategoryMouseController.test.tsx b/packages/cli/src/ui/components/CompletionCategoryMouseController.test.tsx new file mode 100644 index 00000000000..115a4bbaced --- /dev/null +++ b/packages/cli/src/ui/components/CompletionCategoryMouseController.test.tsx @@ -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 = (current: T): MutableRefObject => ({ current }); + +function makeEvent( + partial: Partial & Pick, +): 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; + + 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( + , + ); + 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'); + }); + + 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(); + + 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'); + }); +}); diff --git a/packages/cli/src/ui/components/CompletionCategoryMouseController.tsx b/packages/cli/src/ui/components/CompletionCategoryMouseController.tsx new file mode 100644 index 00000000000..a0937cdcd21 --- /dev/null +++ b/packages/cli/src/ui/components/CompletionCategoryMouseController.tsx @@ -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; + categoryRefs: MutableRefObject>; + 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]); + } + }, + [containerRef, categoryRefs, categories, onSelectCategory, terminalHeight], + ); + + useMouseEvents(handleMouse, { isActive: true, tracking: 'button' }); + + return null; +} diff --git a/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx index 7fae6541807..a1b71450de5 100644 --- a/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx @@ -24,6 +24,10 @@ import { import { useInputHistory } from '../hooks/useInputHistory.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import { useVoiceInput } from '../hooks/use-voice-input.js'; +import { + useExportCompletion, + type ExportCompletionResult, +} from '../hooks/useExportCompletion.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { VirtualViewportContext } from '../contexts/VirtualViewportContext.js'; import { LoadedSettings } from '../../config/settings.js'; @@ -50,6 +54,7 @@ vi.mock('../hooks/useCommandCompletion.js'); vi.mock('../hooks/useInputHistory.js'); vi.mock('../hooks/useReverseSearchCompletion.js'); vi.mock('../hooks/use-voice-input.js'); +vi.mock('../hooks/useExportCompletion.js'); vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false, messageQueue: [] })), })); @@ -157,6 +162,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); @@ -184,6 +193,15 @@ describe('InputPrompt suggestion mouse routing', () => { audioLevel: 0, handleKeypress: vi.fn(() => false), }); + vi.mocked(useExportCompletion).mockReturnValue({ + shouldShowSuggestions: false, + suggestionDisplayProps: null, + handleExportInput: vi.fn(() => false), + reset: vi.fn(), + markNextTextChangeAsUserInput: vi.fn(), + navigatedRef: { current: false }, + navigatedTextRef: { current: '' }, + } as ExportCompletionResult); props = { buffer: mockBuffer, @@ -219,6 +237,62 @@ describe('InputPrompt suggestion mouse routing', () => { unmount(); }); + it('routes category selection and clears an expanded suggestion', async () => { + const selectCategory = vi.fn(); + ( + mockCommandCompletion as UseCommandCompletionReturn & { + selectCategory: typeof selectCategory; + } + ).selectCategory = selectCategory; + vi.mocked(useReverseSearchCompletion).mockReturnValue({ + suggestions: [ + { + label: 'long search result', + value: 'x'.repeat(200), + }, + ], + activeSuggestionIndex: 0, + visibleStartIndex: 0, + showSuggestions: true, + isLoadingSuggestions: false, + navigateUp: vi.fn(), + navigateDown: vi.fn(), + handleAutocomplete: vi.fn(), + resetCompletionState: vi.fn(), + setActiveSuggestionIndex: vi.fn(), + }); + + const { stdin, unmount } = renderWithProviders(); + expect(captured.props).not.toBeNull(); + + await act(async () => { + stdin.write('\x12'); + await Promise.resolve(); + }); + expect(captured.props!['onSelectCategory']).toBeUndefined(); + + await act(async () => { + stdin.write('\u001B[C'); + await Promise.resolve(); + }); + expect(captured.props!['expandedIndex']).toBe(0); + + act(() => { + (captured.props!['onSelectIndex'] as (index: number) => void)(0); + }); + expect(captured.props!['expandedIndex']).toBe(0); + + act(() => { + (captured.props!['onSelectCategory'] as (category: 'session') => void)( + 'session', + ); + }); + + expect(selectCategory).toHaveBeenCalledWith('session'); + expect(captured.props!['expandedIndex']).toBe(-1); + unmount(); + }); + it('uses the startup VP decision for suggestion mouse when the raw setting is unset', () => { const { unmount } = renderWithProviders( @@ -309,6 +383,7 @@ describe('InputPrompt suggestion mouse routing', () => { stdin.write('\x12'); await Promise.resolve(); }); + expect(captured.props!['onSelectCategory']).toBeUndefined(); // Hover routes to the command-search source, not the default completion. act(() => { @@ -330,6 +405,55 @@ describe('InputPrompt suggestion mouse routing', () => { unmount(); }); + it('omits category selection while shell reverse search is active', async () => { + props.shellModeActive = true; + vi.mocked(useReverseSearchCompletion).mockReturnValue({ + suggestions: [{ label: 'history', value: 'history' }], + activeSuggestionIndex: 0, + visibleStartIndex: 0, + showSuggestions: true, + isLoadingSuggestions: false, + navigateUp: vi.fn(), + navigateDown: vi.fn(), + handleAutocomplete: vi.fn(), + resetCompletionState: vi.fn(), + setActiveSuggestionIndex: vi.fn(), + }); + + const { stdin, unmount } = renderWithProviders(); + await act(async () => { + stdin.write('\x12'); + await Promise.resolve(); + }); + + expect(captured.props!['onSelectCategory']).toBeUndefined(); + unmount(); + }); + + it('omits category mouse handlers while export completion is active', () => { + vi.mocked(useExportCompletion).mockReturnValue({ + shouldShowSuggestions: true, + suggestionDisplayProps: { + suggestions: [{ label: 'md', value: 'md' }], + activeIndex: 0, + isLoading: false, + scrollOffset: 0, + }, + handleExportInput: vi.fn(() => false), + reset: vi.fn(), + markNextTextChangeAsUserInput: vi.fn(), + navigatedRef: { current: false }, + navigatedTextRef: { current: '' }, + } as ExportCompletionResult); + + const { unmount } = renderWithProviders(); + + expect(captured.props!['onSelectCategory']).toBeUndefined(); + expect(captured.props!['onHoverIndex']).toBeUndefined(); + expect(captured.props!['onSelectIndex']).toBeUndefined(); + unmount(); + }); + it('clicking an @folder suggestion dismisses the completion so the dropdown stays closed', () => { // @-mention mode showing a directory suggestion: accepting a folder appends // no trailing space, so the @ pattern would re-match and re-open the diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index a0a9caad6a4..1ba52092ce7 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -322,6 +322,7 @@ describe('InputPrompt', () => { handleAutocomplete: vi.fn(), activeCategory: 'all' as const, availableCategories: ['all'] as Array<'all'>, + selectCategory: vi.fn(), switchCategory: vi.fn(), }; mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 7274689fc28..4cb74e882d0 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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'; @@ -2136,6 +2140,13 @@ export const InputPrompt: React.FC = ({ 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 @@ -2321,6 +2332,13 @@ export const InputPrompt: React.FC = ({ onSelectIndex={ suggestionsFromExport ? undefined : handleSuggestionSelect } + onSelectCategory={ + suggestionsFromExport || + commandSearchActive || + reverseSearchActive + ? undefined + : handleCategorySelect + } /> )} diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx index c2353ce15ab..2fc07382c00 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx @@ -10,10 +10,14 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SuggestionsDisplay, type Suggestion } from './SuggestionsDisplay.js'; import { RowMouseController } from './shared/RowMouseController.js'; +import { CompletionCategoryMouseController } from './CompletionCategoryMouseController.js'; vi.mock('./shared/RowMouseController.js', () => ({ RowMouseController: vi.fn(() => null), })); +vi.mock('./CompletionCategoryMouseController.js', () => ({ + CompletionCategoryMouseController: vi.fn(() => null), +})); const suggestions: Suggestion[] = [ { label: 'help', value: 'help' }, @@ -83,4 +87,109 @@ describe('SuggestionsDisplay mouse wiring', () => { ); expect(RowMouseController).not.toHaveBeenCalled(); }); + + it('mounts the category controller with exact tabs when enabled', () => { + const onSelectCategory = vi.fn(); + render( + , + ); + + expect(CompletionCategoryMouseController).toHaveBeenCalled(); + const props = vi.mocked(CompletionCategoryMouseController).mock.calls[0][0]; + expect(props.categories).toEqual(['all', 'file', 'session']); + expect(props.containerRef.current).not.toBeNull(); + expect(props.categoryRefs.current).toHaveLength(props.categories.length); + expect( + props.categoryRefs.current.map((node) => { + const textElement = node?.childNodes[0]; + if (!textElement || textElement.nodeName === '#text') return ''; + + return textElement.childNodes + .map((child) => (child.nodeName === '#text' ? child.nodeValue : '')) + .join('') + .trim(); + }), + ).toEqual(['All', 'Files', 'Sessions']); + expect(props.onSelectCategory).toBe(onSelectCategory); + }); + + it('does not mount the category controller when mouse is disabled', () => { + render( + , + ); + + expect(CompletionCategoryMouseController).not.toHaveBeenCalled(); + }); + + it('does not mount the category controller without a selection callback', () => { + render( + , + ); + + expect(CompletionCategoryMouseController).not.toHaveBeenCalled(); + }); + + it('does not mount the category controller when the tab bar is hidden', () => { + render( + , + ); + + expect(CompletionCategoryMouseController).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 70006eed43a..add80e5295d 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -8,6 +8,7 @@ import { useRef } from 'react'; import { Box, Text, type DOMElement } from 'ink'; import { theme } from '../semantic-colors.js'; import { RowMouseController } from './shared/RowMouseController.js'; +import { CompletionCategoryMouseController } from './CompletionCategoryMouseController.js'; import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js'; import { Colors } from '../colors.js'; import { t } from '../../i18n/index.js'; @@ -45,6 +46,8 @@ interface SuggestionsDisplayProps { activeCategory?: SuggestionCategory | 'all'; /** Ordered list of tabs to show. The tab bar renders only when >2 entries. */ availableCategories?: Array; + /** Activate an exact category tab on click (mouse). */ + onSelectCategory?: (category: SuggestionCategory | 'all') => void; } function categoryLabel(cat: SuggestionCategory | 'all'): string { @@ -98,9 +101,11 @@ export function SuggestionsDisplay({ mouseEnabled, activeCategory = 'all', availableCategories, + onSelectCategory, }: SuggestionsDisplayProps) { const containerRef = useRef(null); const itemRefs = useRef>([]); + const categoryRefs = useRef>([]); if (isLoading) { return ( @@ -170,12 +175,29 @@ export function SuggestionsDisplay({ onSelectIndex={onSelectIndex} /> )} + {mouseEnabled && + showTabBar && + availableCategories && + onSelectCategory && ( + + )} {showTabBar && availableCategories && ( {availableCategories.map((cat, i) => { const active = cat === activeCategory; return ( - + { + categoryRefs.current[i] = node; + }} + > { - const container = containerRef.current; - if (!container) return null; - - // Ignore interactions outside the list's columns so a click elsewhere on - // the same terminal row doesn't hijack a selection. - const containerRect = measureElementPosition(container); - const col0 = event.col - 1; - if ( - containerRect.width > 0 && - (col0 < containerRect.x || - col0 >= containerRect.x + containerRect.width) - ) { - return null; - } - - const layoutRow = layoutRowForEvent(container, event.row, terminalHeight); - - const rects: VisibleItemRect[] = []; - const nodes = itemRefs.current; - for (let visiblePos = 0; visiblePos < nodes.length; visiblePos++) { - const node = nodes[visiblePos]; - if (!node) continue; - const rect = measureElementPosition(node); - if (rect.height <= 0) continue; - rects.push({ - index: scrollOffset + visiblePos, - top: rect.y, - height: rect.height, - }); - } - - return findItemAtLayoutRow(rects, layoutRow); - }, + (event: MouseEvent): number | null => + findElementAtMouseEvent( + containerRef.current, + itemRefs.current, + event, + terminalHeight, + 'row', + scrollOffset, + ), [containerRef, itemRefs, scrollOffset, terminalHeight], ); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index b57cc0b112e..a14aaf3302b 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -84,6 +84,8 @@ export interface UseCommandCompletionReturn { activeCategory: SuggestionCategory | 'all'; /** Tabs available for the current suggestion set (always includes 'all'). */ availableCategories: Array; + /** Select an exact category tab; a category change resets active/scroll index. */ + selectCategory: (category: SuggestionCategory | 'all') => void; /** Cycle the active category tab; resets active/scroll index. */ switchCategory: (direction: 1 | -1) => void; } @@ -246,6 +248,7 @@ export function useCommandCompletion( navigateDown, activeCategory, availableCategories, + selectCategory, switchCategory, } = useCompletion({ query }); @@ -445,6 +448,7 @@ export function useCommandCompletion( midInputGhostText, activeCategory, availableCategories, + selectCategory, switchCategory, }; } diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index 3ec6efda302..14396c16ac0 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -238,7 +238,9 @@ describe('useCompletion', () => { describe('category tabs', () => { const mixed = [ { label: 'a.ts', value: 'a.ts', category: 'file' as const }, - { label: 'S', value: 'session:1', category: 'session' as const }, + { label: 'b.ts', value: 'b.ts', category: 'file' as const }, + { label: 'S1', value: 'session:1', category: 'session' as const }, + { label: 'S2', value: 'session:2', category: 'session' as const }, ]; it('derives availableCategories from present categories', () => { @@ -266,10 +268,62 @@ describe('useCompletion', () => { const { result } = renderHook(() => useCompletion()); act(() => { result.current.setSuggestions(mixed); + result.current.setActiveSuggestionIndex(1); + result.current.setVisibleStartIndex(1); }); act(() => result.current.switchCategory(1)); expect(result.current.activeCategory).toBe('file'); expect(result.current.activeSuggestionIndex).toBe(0); + expect(result.current.visibleStartIndex).toBe(0); + }); + + it('selects an exact category and resets active and scroll indices', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + result.current.setActiveSuggestionIndex(1); + result.current.setVisibleStartIndex(1); + }); + + act(() => result.current.selectCategory('session')); + + expect(result.current.activeCategory).toBe('session'); + expect(result.current.activeSuggestionIndex).toBe(0); + expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.suggestions).toEqual([mixed[2], mixed[3]]); + }); + + it('preserves indices when re-selecting the active category', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + result.current.setActiveSuggestionIndex(1); + result.current.setVisibleStartIndex(1); + }); + + act(() => result.current.selectCategory('all')); + + expect(result.current.activeCategory).toBe('all'); + expect(result.current.activeSuggestionIndex).toBe(1); + expect(result.current.visibleStartIndex).toBe(1); + }); + + it('ignores a category that is not available', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions([mixed[0]]); + }); + act(() => { + result.current.setActiveSuggestionIndex(1); + result.current.setVisibleStartIndex(1); + }); + + expect(result.current.availableCategories).toEqual(['all']); + act(() => result.current.selectCategory('session')); + + expect(result.current.activeCategory).toBe('all'); + expect(result.current.activeSuggestionIndex).toBe(1); + expect(result.current.visibleStartIndex).toBe(1); }); it('cycles backwards with direction -1 and wraps from all to last', () => { @@ -302,9 +356,9 @@ describe('useCompletion', () => { result.current.setSuggestions(mixed); }); act(() => result.current.switchCategory(1)); // → file - expect(result.current.suggestions).toEqual([mixed[0]]); + expect(result.current.suggestions).toEqual([mixed[0], mixed[1]]); act(() => result.current.switchCategory(1)); // → session - expect(result.current.suggestions).toEqual([mixed[1]]); + expect(result.current.suggestions).toEqual([mixed[2], mixed[3]]); }); it('falls back to "all" when the active tab disappears', () => { diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index a808f0c090b..0ef13b1e7c2 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -49,6 +49,8 @@ export interface UseCompletionReturn { activeCategory: SuggestionCategory | 'all'; /** Tabs available for the current suggestion set (always includes 'all'). */ availableCategories: Array; + /** Select an exact category tab; a category change resets active/scroll index. */ + selectCategory: (category: SuggestionCategory | 'all') => void; /** Cycle the active category tab; resets active/scroll index. */ switchCategory: (direction: 1 | -1) => void; } @@ -98,14 +100,21 @@ export function useCompletion( [rawSuggestions, activeCategory], ); + const changeCategory = useCallback( + (nextCategory: React.SetStateAction): void => { + setActiveCategory(nextCategory); + setActiveSuggestionIndex(0); + setVisibleStartIndex(0); + }, + [], + ); + // If the active tab disappears (suggestion set changed), fall back to 'all'. useEffect(() => { if (!availableCategories.includes(activeCategory)) { - setActiveCategory('all'); - setActiveSuggestionIndex(0); - setVisibleStartIndex(0); + changeCategory('all'); } - }, [availableCategories, activeCategory]); + }, [availableCategories, activeCategory, changeCategory]); // Clamp the active index when the filtered suggestion list shrinks within // a still-existing category (e.g. async search returns fewer items). @@ -122,9 +131,22 @@ export function useCompletion( ); }, [suggestions.length]); + const selectCategory = useCallback( + (category: SuggestionCategory | 'all') => { + if ( + category === activeCategory || + !availableCategories.includes(category) + ) { + return; + } + changeCategory(category); + }, + [activeCategory, availableCategories, changeCategory], + ); + const switchCategory = useCallback( (direction: 1 | -1) => { - setActiveCategory((cur) => { + changeCategory((cur) => { const idx = availableCategories.indexOf(cur); if (idx === -1) return 'all'; const next = @@ -132,10 +154,8 @@ export function useCompletion( availableCategories.length; return availableCategories[next]; }); - setActiveSuggestionIndex(0); - setVisibleStartIndex(0); }, - [availableCategories], + [availableCategories, changeCategory], ); const resetCompletionState = useCallback(() => { @@ -251,6 +271,7 @@ export function useCompletion( navigateDown, activeCategory, availableCategories, + selectCategory, switchCategory, }; } diff --git a/packages/cli/src/ui/hooks/useExportCompletion.test.ts b/packages/cli/src/ui/hooks/useExportCompletion.test.ts index 6cc980bd756..117f36d916b 100644 --- a/packages/cli/src/ui/hooks/useExportCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useExportCompletion.test.ts @@ -91,6 +91,7 @@ function createCompletion( midInputGhostText: null, activeCategory: 'all', availableCategories: ['all'], + selectCategory: vi.fn(), switchCategory: vi.fn(), ...overrides, }; diff --git a/packages/cli/src/ui/utils/mouse-hit.test.ts b/packages/cli/src/ui/utils/mouse-hit.test.ts new file mode 100644 index 00000000000..0c63398a0c4 --- /dev/null +++ b/packages/cli/src/ui/utils/mouse-hit.test.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type DOMElement } from 'ink'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { findElementAtMouseEvent } from './mouse-hit.js'; +import { + layoutRowForEvent, + measureElementPosition, +} from './measure-element-position.js'; + +vi.mock('./measure-element-position.js', () => ({ + layoutRowForEvent: vi.fn(), + measureElementPosition: vi.fn(), +})); + +describe('findElementAtMouseEvent', () => { + const container = { tag: 'container' } as unknown as DOMElement; + const first = { tag: 'first' } as unknown as DOMElement; + const second = { tag: 'second' } as unknown as DOMElement; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(layoutRowForEvent).mockReturnValue(4); + vi.mocked(measureElementPosition).mockImplementation((node) => { + if (node === container) return { x: 2, y: 4, width: 16, height: 2 }; + if (node === first) return { x: 2, y: 4, width: 6, height: 1 }; + return { x: 10, y: 4, width: 6, height: 1 }; + }); + }); + + it('maps 1-based SGR columns and uses full child rectangles for tabs', () => { + expect( + findElementAtMouseEvent( + container, + [first, second], + { col: 10, row: 5 }, + 40, + 'rect', + ), + ).toBeNull(); + expect( + findElementAtMouseEvent( + container, + [first, second], + { col: 11, row: 5 }, + 40, + 'rect', + ), + ).toBe(1); + }); + + it('keeps the right and bottom rectangle edges exclusive', () => { + expect( + findElementAtMouseEvent( + container, + [first, second], + { col: 9, row: 5 }, + 40, + 'rect', + ), + ).toBeNull(); + + vi.mocked(layoutRowForEvent).mockReturnValue(5); + expect( + findElementAtMouseEvent( + container, + [first, second], + { col: 3, row: 6 }, + 40, + 'rect', + ), + ).toBeNull(); + }); + + it('ignores mouse events before the container ref is attached', () => { + expect( + findElementAtMouseEvent(null, [first], { col: 3, row: 5 }, 40, 'row'), + ).toBeNull(); + expect(layoutRowForEvent).not.toHaveBeenCalled(); + }); + + it('passes terminal height into the shared layout mapping', () => { + findElementAtMouseEvent(container, [first], { col: 3, row: 5 }, 40, 'row'); + + expect(layoutRowForEvent).toHaveBeenCalledWith(container, 5, 40); + }); + + it('uses child rows after the container column bound for list rows', () => { + expect( + findElementAtMouseEvent( + container, + [first, second], + { col: 9, row: 5 }, + 40, + 'row', + ), + ).toBe(0); + expect( + findElementAtMouseEvent( + container, + [first, second], + { col: 19, row: 5 }, + 40, + 'row', + ), + ).toBeNull(); + }); + + it('returns full-list indices for a visible row slice', () => { + expect( + findElementAtMouseEvent( + container, + [first, second], + { col: 3, row: 5 }, + 40, + 'row', + 7, + ), + ).toBe(7); + }); + + it('keeps zero-width list rows hittable through their container width', () => { + vi.mocked(measureElementPosition).mockImplementation((node) => + node === container + ? { x: 2, y: 4, width: 16, height: 1 } + : { x: 2, y: 4, width: 0, height: 1 }, + ); + + expect( + findElementAtMouseEvent( + container, + [first], + { col: 9, row: 5 }, + 40, + 'row', + ), + ).toBe(0); + }); + + it('ignores missing and degenerate child elements', () => { + vi.mocked(measureElementPosition).mockImplementation((node) => { + if (node === null) { + throw new Error('null children must not be measured'); + } + return node === container + ? { x: 0, y: 0, width: 20, height: 2 } + : { x: 0, y: 4, width: 0, height: 1 }; + }); + + expect( + findElementAtMouseEvent( + container, + [null, first], + { col: 1, row: 5 }, + 40, + 'rect', + ), + ).toBeNull(); + }); +}); diff --git a/packages/cli/src/ui/utils/mouse-hit.ts b/packages/cli/src/ui/utils/mouse-hit.ts new file mode 100644 index 00000000000..b77f0260b5f --- /dev/null +++ b/packages/cli/src/ui/utils/mouse-hit.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type DOMElement } from 'ink'; +import { type MouseEvent } from './mouse.js'; +import { + layoutRowForEvent, + measureElementPosition, + type ElementMetrics, +} from './measure-element-position.js'; +import { findItemAtLayoutRow, type VisibleItemRect } from './list-mouse.js'; + +type ElementHitMode = 'rect' | 'row'; + +function containsPoint( + rect: ElementMetrics, + point: { x: number; y: number }, +): boolean { + const containsRow = point.y >= rect.y && point.y < rect.y + rect.height; + return containsRow && point.x >= rect.x && point.x < rect.x + rect.width; +} + +/** + * Resolve a terminal mouse event to the measured child element it hits. + * + * Both completion rows and their category tabs live in the same alternate- + * screen layout, so they must share the SGR coordinate conversion, container + * column bound, frame-anchor correction, and degenerate-rectangle handling. + * Row lists use the child's vertical span after the container column check; + * tabs use the child's full rectangle. + */ +export function findElementAtMouseEvent( + container: DOMElement | null, + elements: ReadonlyArray, + event: Pick, + terminalHeight: number, + mode: ElementHitMode, + indexOffset = 0, +): number | null { + if (!container) return null; + + const point = { + x: event.col - 1, + y: layoutRowForEvent(container, event.row, terminalHeight), + }; + const containerRect = measureElementPosition(container); + if ( + containerRect.width > 0 && + (point.x < containerRect.x || + point.x >= containerRect.x + containerRect.width) + ) { + return null; + } + + if (mode === 'row') { + const rects: VisibleItemRect[] = []; + for (let index = 0; index < elements.length; index++) { + const element = elements[index]; + if (!element) continue; + const rect = measureElementPosition(element); + if (rect.height <= 0) continue; + rects.push({ + index: indexOffset + index, + top: rect.y, + height: rect.height, + }); + } + return findItemAtLayoutRow(rects, point.y); + } + + for (let index = 0; index < elements.length; index++) { + const element = elements[index]; + if (!element) continue; + const rect = measureElementPosition(element); + if (rect.height <= 0 || rect.width <= 0) continue; + if (containsPoint(rect, point)) return index; + } + + return null; +}