Repository navigation
feat(ui): add mouse click & hover in alternate-screen mode #6011
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
5fdb875
8e18f81
ccad0b2
b74a71d
cd82d08
7cddec3
239e85e
09a9081
b3e985d
e2f673e
1885ac6
32f7e97
601e017
c7612b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -213,6 +213,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |
| const uiState = useUIState(); | ||
| const uiActions = useUIActions(); | ||
| const settings = useSettings(); | ||
| // Mouse interactions (suggestion list + click-to-position cursor) are enabled | ||
| // in alternate-screen mode (see RowMouseController's coordinate assumptions). | ||
| const mouseInteractionsEnabled = !!settings.merged.ui?.useTerminalBuffer; | ||
| const { pasteWorkaround } = useKeypressContext(); | ||
| const { agents, agentTabBarFocused } = useAgentViewState(); | ||
| const { setAgentTabBarFocused } = useAgentViewActions(); | ||
|
|
@@ -1877,6 +1880,58 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |
| ? activeCompletion.showSuggestions | ||
| : false); | ||
|
|
||
| // Mouse hover/select must target the SAME completion source that builds | ||
| // suggestionDisplayProps. For reverse/command search, selecting also resets | ||
| // that controller and exits search mode, mirroring keyboard acceptance. | ||
| // Export completion has no index-based handler, so mouse selection is left | ||
| // disabled for it (handlers are undefined when its suggestions are shown). | ||
| const suggestionsFromExport = | ||
| shouldUseExportSuggestions && !!exportCompletion.suggestionDisplayProps; | ||
| const handleSuggestionHover = useCallback( | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| (index: number) => { | ||
| if (commandSearchActive) { | ||
| commandSearchCompletion.setActiveSuggestionIndex(index); | ||
| } else if (reverseSearchActive) { | ||
| reverseSearchCompletion.setActiveSuggestionIndex(index); | ||
| } else { | ||
| completion.setActiveSuggestionIndex(index); | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| } | ||
| }, | ||
| [ | ||
| commandSearchActive, | ||
| reverseSearchActive, | ||
| commandSearchCompletion, | ||
| reverseSearchCompletion, | ||
| completion, | ||
| ], | ||
| ); | ||
| const handleSuggestionSelect = useCallback( | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| (index: number) => { | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| if (commandSearchActive || reverseSearchActive) { | ||
| const isCommandSearch = commandSearchActive; | ||
| const sc = isCommandSearch | ||
| ? commandSearchCompletion | ||
| : reverseSearchCompletion; | ||
| sc.handleAutocomplete(index); | ||
| sc.resetCompletionState(); | ||
| (isCommandSearch ? setCommandSearchActive : setReverseSearchActive)( | ||
| false, | ||
| ); | ||
| } else { | ||
|
DragonnZhang marked this conversation as resolved.
Resolved
|
||
| completion.handleAutocomplete(index); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium / maintainability]
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point on the drift risk — the click path does re-do I want to be careful unifying these because the two paths aren't identical, and a naive
So the right shape is probably Generated by Claude Code |
||
| } | ||
| }, | ||
| [ | ||
| commandSearchActive, | ||
| reverseSearchActive, | ||
| commandSearchCompletion, | ||
| reverseSearchCompletion, | ||
| completion, | ||
| setCommandSearchActive, | ||
| setReverseSearchActive, | ||
| ], | ||
| ); | ||
|
|
||
| // Whether any input-side handler would consume a Tab keystroke. AppContainer | ||
| // feeds this into useAutoAcceptIndicator's `shouldBlockTab` so the | ||
| // Windows-only "bare Tab cycles approval mode" fallback doesn't double-fire | ||
|
|
@@ -2025,6 +2080,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |
| topRightLabel={voiceStatusLabel ?? uiState.sessionName ?? undefined} | ||
| isActive={!isEmbeddedShellFocused} | ||
| renderLine={renderLineWithHighlighting} | ||
| mouseEnabled={mouseInteractionsEnabled} | ||
| /> | ||
| {shouldShowSuggestions && ( | ||
| <Box marginLeft={2} marginRight={2}> | ||
|
|
@@ -2043,6 +2099,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ | |
| : 'reverse' | ||
| } | ||
| expandedIndex={expandedSuggestionIndex} | ||
| mouseEnabled={mouseInteractionsEnabled} | ||
| onHoverIndex={ | ||
| suggestionsFromExport ? undefined : handleSuggestionHover | ||
| } | ||
| onSelectIndex={ | ||
| suggestionsFromExport ? undefined : handleSuggestionSelect | ||
| } | ||
| /> | ||
| </Box> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Qwen | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| /** @vitest-environment jsdom */ | ||
|
|
||
| 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'; | ||
|
|
||
| vi.mock('./shared/RowMouseController.js', () => ({ | ||
| RowMouseController: vi.fn(() => null), | ||
| })); | ||
|
|
||
| const suggestions: Suggestion[] = [ | ||
| { label: 'help', value: 'help' }, | ||
| { label: 'clear', value: 'clear' }, | ||
| { label: 'model', value: 'model' }, | ||
| ]; | ||
|
|
||
| describe('SuggestionsDisplay mouse wiring', () => { | ||
| beforeEach(() => vi.clearAllMocks()); | ||
|
|
||
| it('mounts RowMouseController with the scroll offset + callbacks when enabled', () => { | ||
| const onHoverIndex = vi.fn(); | ||
| const onSelectIndex = vi.fn(); | ||
| render( | ||
| <SuggestionsDisplay | ||
| suggestions={suggestions} | ||
| activeIndex={0} | ||
| isLoading={false} | ||
| width={60} | ||
| scrollOffset={2} | ||
| userInput="" | ||
| mode="slash" | ||
| mouseEnabled | ||
| onHoverIndex={onHoverIndex} | ||
| onSelectIndex={onSelectIndex} | ||
| />, | ||
| ); | ||
| expect(RowMouseController).toHaveBeenCalled(); | ||
| const props = vi.mocked(RowMouseController).mock.calls[0][0]; | ||
| // scrollOffset is the index of the first visible suggestion (startIndex), | ||
| // so RowMouseController maps visible position → original suggestion index. | ||
| expect(props.scrollOffset).toBe(2); | ||
| expect(props.onHoverIndex).toBe(onHoverIndex); | ||
| expect(props.onSelectIndex).toBe(onSelectIndex); | ||
| }); | ||
|
|
||
| it('does not mount RowMouseController when mouse is disabled', () => { | ||
| render( | ||
| <SuggestionsDisplay | ||
| suggestions={suggestions} | ||
| activeIndex={0} | ||
| isLoading={false} | ||
| width={60} | ||
| scrollOffset={0} | ||
| userInput="" | ||
| mode="slash" | ||
| mouseEnabled={false} | ||
| onHoverIndex={vi.fn()} | ||
| onSelectIndex={vi.fn()} | ||
| />, | ||
| ); | ||
| expect(RowMouseController).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('does not mount RowMouseController when the callbacks are absent', () => { | ||
| render( | ||
| <SuggestionsDisplay | ||
| suggestions={suggestions} | ||
| activeIndex={0} | ||
| isLoading={false} | ||
| width={60} | ||
| scrollOffset={0} | ||
| userInput="" | ||
| mode="slash" | ||
| mouseEnabled | ||
| />, | ||
| ); | ||
| expect(RowMouseController).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| 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 { describe, it, expect } from 'vitest'; | ||
| import { renderWithProviders } from '../../../test-utils/render.js'; | ||
| import { LoadedSettings } from '../../../config/settings.js'; | ||
| import { RadioButtonSelect } from './RadioButtonSelect.js'; | ||
|
|
||
| // Integration smoke test: with ui.useTerminalBuffer on, BaseSelectionList | ||
| // mounts the real RowMouseController (which subscribes via the real | ||
| // useMouseEvents/KeypressProvider). This guards the end-to-end gate + mount | ||
| // path — that turning mouse input on doesn't throw or break rendering. | ||
| // Coordinate accuracy is exercised by RowMouseController.test.tsx (unit) and | ||
| // validated in a real terminal. | ||
| function settingsWithMouse(enabled: boolean): LoadedSettings { | ||
| // Mouse input is enabled by alternate-screen mode. | ||
| const ui = { ui: { useTerminalBuffer: enabled } }; | ||
| return new LoadedSettings( | ||
| { path: '', settings: {}, originalSettings: {} }, | ||
| { path: '', settings: {}, originalSettings: {} }, | ||
| { path: '', settings: ui, originalSettings: ui }, | ||
| { path: '', settings: {}, originalSettings: {} }, | ||
| true, | ||
| new Set(), | ||
| ); | ||
| } | ||
|
|
||
| describe('BaseSelectionList with mouse enabled (integration)', () => { | ||
| const items = [ | ||
| { label: 'Alpha', value: 'a', key: 'a' }, | ||
| { label: 'Beta', value: 'b', key: 'b' }, | ||
| ]; | ||
|
|
||
| // `?1003h` = any-event tracking; the mouse layer enables it for hover. | ||
| const ENABLE_ANY = '[?1003h'; | ||
|
|
||
| it('mounts the mouse layer (enables any-event tracking) and still renders items', () => { | ||
| const { frames } = renderWithProviders( | ||
| <RadioButtonSelect items={items} onSelect={() => {}} />, | ||
| { settings: settingsWithMouse(true) }, | ||
| ); | ||
| // `frames` captures both rendered frames and the raw enable escape that | ||
| // useMouseEvents writes to the same stdout — assert across all of them. | ||
| const output = frames.join('\n'); | ||
| expect(output).toContain('Alpha'); | ||
| expect(output).toContain('Beta'); | ||
| expect(output).toContain(ENABLE_ANY); | ||
| }); | ||
|
|
||
| it('does not mount the mouse layer when ui.useTerminalBuffer is off', () => { | ||
| const { lastFrame, frames } = renderWithProviders( | ||
| <RadioButtonSelect items={items} onSelect={() => {}} />, | ||
| { settings: settingsWithMouse(false) }, | ||
| ); | ||
| expect(lastFrame()).toContain('Alpha'); | ||
| expect(lastFrame()).toContain('Beta'); | ||
| expect(frames.join('\n')).not.toContain(ENABLE_ANY); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.