diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index f0c4030d33..7a979bd5c8 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1021,7 +1021,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.', + 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.', showInDialog: true, }, shellOutputMaxLines: { diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index bd05fa4ad8..e3dfb5f5a4 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -23,6 +23,7 @@ import type { ReactNode } from 'react'; import { useCallback, useInsertionEffect, useRef } from 'react'; import { Box, Text, type DOMElement, useBoxMetrics, useCursor } from 'ink'; import type { TextBuffer } from './shared/text-buffer.js'; +import { TextInputMouseController } from './shared/TextInputMouseController.js'; import type { Key } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; @@ -81,6 +82,8 @@ export interface BaseTextInputProps { * When not provided, lines are rendered as plain text with cursor overlay. */ renderLine?: (opts: RenderLineOptions) => ReactNode; + /** Enable click-to-position-cursor (alternate-screen / ui.useTerminalBuffer mode). */ + mouseEnabled?: boolean; } // ─── Default line renderer ────────────────────────────────── @@ -197,6 +200,7 @@ export const BaseTextInput = ({ topRightLabel, isActive = true, renderLine = defaultRenderLine, + mouseEnabled = false, }: BaseTextInputProps): ReactNode => { // ── Keyboard handling ── @@ -313,6 +317,7 @@ export const BaseTextInput = ({ // ── Physical cursor positioning for IME ── const boxRef = useRef(null); + const linesRef = useRef(null); const { hasMeasured } = useBoxMetrics(boxRef); const { setCursorPosition } = useCursor(); const cursorPosition = getPhysicalCursorPosition(boxRef.current, { @@ -346,6 +351,13 @@ export const BaseTextInput = ({ return ( + {mouseEnabled && isActive && ( + + )} {topBorderLine} @@ -360,7 +372,7 @@ export const BaseTextInput = ({ {resolvedPrefix} {/* No background fill: the input area blends into the terminal's own background so it stays consistent across terminals and themes. */} - + {buffer.text.length === 0 && placeholder ? ( showCursor ? ( diff --git a/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx new file mode 100644 index 0000000000..de48948d80 --- /dev/null +++ b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx @@ -0,0 +1,319 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { act } from '@testing-library/react'; +import type { InputPromptProps } from './InputPrompt.js'; +import { InputPrompt } from './InputPrompt.js'; +import type { TextBuffer } from './shared/text-buffer.js'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import * as path from 'node:path'; +import type { CommandContext, SlashCommand } from '../commands/types.js'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { UseShellHistoryReturn } from '../hooks/useShellHistory.js'; +import { useShellHistory } from '../hooks/useShellHistory.js'; +import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js'; +import { + useCommandCompletion, + CompletionMode, +} from '../hooks/useCommandCompletion.js'; +import { useInputHistory } from '../hooks/useInputHistory.js'; +import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; +import { useVoiceInput } from '../hooks/use-voice-input.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +// Capture the props handed to SuggestionsDisplay so we can drive the mouse +// hover/select callbacks directly, without simulating raw SGR mouse bytes. +const captured = vi.hoisted(() => ({ + props: null as Record | null, +})); + +vi.mock('./SuggestionsDisplay.js', async (importActual) => { + const actual = (await importActual()) as Record; + return { + ...actual, + SuggestionsDisplay: (props: Record) => { + captured.props = props; + return null; + }, + }; +}); + +vi.mock('../hooks/useShellHistory.js'); +vi.mock('../hooks/useCommandCompletion.js'); +vi.mock('../hooks/useInputHistory.js'); +vi.mock('../hooks/useReverseSearchCompletion.js'); +vi.mock('../hooks/use-voice-input.js'); +vi.mock('../contexts/UIStateContext.js', () => ({ + useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false, messageQueue: [] })), +})); +vi.mock('../contexts/UIActionsContext.js', () => ({ + useUIActions: vi.fn(() => ({ + handleRetryLastPrompt: vi.fn(), + temporaryCloseFeedbackDialog: vi.fn(), + popAllQueuedMessages: vi.fn(() => null), + })), +})); +vi.mock('../contexts/AgentViewContext.js', () => ({ + useAgentViewState: vi.fn(() => ({ + activeView: 'main', + agents: new Map(), + agentShellFocused: false, + agentInputBufferText: '', + agentTabBarFocused: false, + agentApprovalModes: new Map(), + })), + useAgentViewActions: vi.fn(() => ({ setAgentTabBarFocused: vi.fn() })), +})); +vi.mock('../contexts/BackgroundTaskViewContext.js', () => ({ + useBackgroundTaskViewState: vi.fn(() => ({ + entries: [], + selectedIndex: 0, + dialogMode: 'closed', + dialogOpen: false, + pillFocused: false, + })), + useBackgroundTaskViewActions: vi.fn(() => ({ + setPillFocused: vi.fn(), + setLivePanelFocused: vi.fn(), + setLivePanelSelectedIndex: vi.fn(), + })), +})); + +const mockSlashCommands: SlashCommand[] = []; + +describe('InputPrompt suggestion mouse routing', () => { + let props: InputPromptProps; + let mockBuffer: TextBuffer; + let mockCommandCompletion: UseCommandCompletionReturn; + + const makeBuffer = (text: string): TextBuffer => + ({ + text, + cursor: [0, text.length], + lines: [text], + setText: vi.fn(), + replaceRangeByOffset: vi.fn(), + viewportVisualLines: [text], + allVisualLines: [text], + visualCursor: [0, text.length], + visualScrollRow: 0, + handleInput: vi.fn(), + move: vi.fn(), + moveToOffset: vi.fn(), + killLineRight: vi.fn(), + killLineLeft: vi.fn(), + openInExternalEditor: vi.fn(), + newline: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + backspace: vi.fn(), + preferredCol: null, + selectionAnchor: null, + insert: vi.fn(), + del: vi.fn(), + replaceRange: vi.fn(), + deleteWordLeft: vi.fn(), + deleteWordRight: vi.fn(), + visualToLogicalMap: [[0, 0]], + }) as unknown as TextBuffer; + + beforeEach(() => { + captured.props = null; + vi.clearAllMocks(); + + mockBuffer = makeBuffer('/sk'); + vi.mocked(useShellHistory).mockReturnValue({ + history: [], + addCommandToHistory: vi.fn(), + getPreviousCommand: vi.fn().mockReturnValue(null), + getNextCommand: vi.fn().mockReturnValue(null), + resetHistoryPosition: vi.fn(), + } as UseShellHistoryReturn); + + mockCommandCompletion = { + suggestions: [ + { label: 'skills', value: 'skills', submitOnAccept: true }, + { label: 'stats', value: 'stats' }, + ], + activeSuggestionIndex: 0, + isLoadingSuggestions: false, + showSuggestions: true, + visibleStartIndex: 0, + isPerfectMatch: false, + midInputGhostText: null, + completionMode: CompletionMode.SLASH, + navigateUp: vi.fn(), + navigateDown: vi.fn(), + resetCompletionState: vi.fn(), + dismissCompletion: vi.fn(), + setActiveSuggestionIndex: vi.fn(), + setShowSuggestions: vi.fn(), + handleAutocomplete: vi.fn(), + } as unknown as UseCommandCompletionReturn; + vi.mocked(useCommandCompletion).mockReturnValue(mockCommandCompletion); + + vi.mocked(useInputHistory).mockReturnValue({ + navigateUp: vi.fn(), + navigateDown: vi.fn(), + handleSubmit: vi.fn(), + resetHistoryNav: vi.fn(), + }); + vi.mocked(useReverseSearchCompletion).mockReturnValue({ + suggestions: [], + activeSuggestionIndex: -1, + visibleStartIndex: 0, + showSuggestions: false, + isLoadingSuggestions: false, + navigateUp: vi.fn(), + navigateDown: vi.fn(), + handleAutocomplete: vi.fn(), + resetCompletionState: vi.fn(), + setActiveSuggestionIndex: vi.fn(), + }); + vi.mocked(useVoiceInput).mockReturnValue({ + status: 'idle', + interimText: '', + audioLevel: 0, + handleKeypress: vi.fn(() => false), + }); + + props = { + buffer: mockBuffer, + onSubmit: vi.fn(), + userMessages: [], + onClearScreen: vi.fn(), + config: { + getProjectRoot: () => path.join('test', 'project'), + getTargetDir: () => path.join('test', 'project', 'src'), + getVimMode: () => false, + getFastModel: () => undefined, + getWorkspaceContext: () => ({ + getDirectories: () => ['/test/project/src'], + }), + } as unknown as Config, + slashCommands: mockSlashCommands, + commandContext: createMockCommandContext() as CommandContext, + shellModeActive: false, + setShellModeActive: vi.fn(), + approvalMode: ApprovalMode.DEFAULT, + inputWidth: 80, + suggestionsWidth: 80, + focus: true, + placeholder: ' Type your message or @path/to/file', + }; + }); + + it('passes the default-source mouse handlers to SuggestionsDisplay', () => { + const { unmount } = renderWithProviders(); + expect(captured.props).not.toBeNull(); + expect(typeof captured.props!['onSelectIndex']).toBe('function'); + expect(typeof captured.props!['onHoverIndex']).toBe('function'); + unmount(); + }); + + it('hovering a suggestion updates the active index on the default source', () => { + const { unmount } = renderWithProviders(); + act(() => { + (captured.props!['onHoverIndex'] as (i: number) => void)(1); + }); + expect(mockCommandCompletion.setActiveSuggestionIndex).toHaveBeenCalledWith( + 1, + ); + unmount(); + }); + + it('clicking a leaf command auto-submits it (submitOnAccept), matching Enter', () => { + const { unmount } = renderWithProviders(); + act(() => { + (captured.props!['onSelectIndex'] as (i: number) => void)(0); + }); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(props.onSubmit).toHaveBeenCalledWith('/skills'); + unmount(); + }); + + it('clicking a non-leaf suggestion accepts without submitting', () => { + const { unmount } = renderWithProviders(); + act(() => { + (captured.props!['onSelectIndex'] as (i: number) => void)(1); + }); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + expect(props.onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + + it('routes hover/select to the command-search source while command search is active', async () => { + // Ctrl+R (not in shell mode) enters command search. The mouse handlers + // must then drive the command-search completion (not the default + // completion), and a click must accept + reset it and exit search mode. + const searchCompletion = { + suggestions: [ + { label: 'first cmd', value: 'first cmd' }, + { label: 'second cmd', value: 'second cmd' }, + ], + activeSuggestionIndex: 0, + visibleStartIndex: 0, + showSuggestions: true, + isLoadingSuggestions: false, + navigateUp: vi.fn(), + navigateDown: vi.fn(), + handleAutocomplete: vi.fn(), + resetCompletionState: vi.fn(), + setActiveSuggestionIndex: vi.fn(), + }; + vi.mocked(useReverseSearchCompletion).mockReturnValue(searchCompletion); + + const { stdin, unmount } = renderWithProviders(); + // Enter command-search mode (Ctrl+R). + await act(async () => { + stdin.write('\x12'); + await Promise.resolve(); + }); + + // Hover routes to the command-search source, not the default completion. + act(() => { + (captured.props!['onHoverIndex'] as (i: number) => void)(1); + }); + expect(searchCompletion.setActiveSuggestionIndex).toHaveBeenCalledWith(1); + expect( + mockCommandCompletion.setActiveSuggestionIndex, + ).not.toHaveBeenCalled(); + + // Clicking accepts via the command-search source, resets it, and exits + // search mode (so the UI can't get stuck in search after a click). + act(() => { + (captured.props!['onSelectIndex'] as (i: number) => void)(1); + }); + expect(searchCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + expect(searchCompletion.resetCompletionState).toHaveBeenCalled(); + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); + 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 + // dropdown unless the completion is explicitly dismissed. + mockBuffer = makeBuffer('@src'); + props.buffer = mockBuffer; + vi.mocked(useCommandCompletion).mockReturnValue({ + ...mockCommandCompletion, + completionMode: CompletionMode.AT, + suggestions: [{ label: 'src/', value: 'src/', isDirectory: true }], + } as unknown as UseCommandCompletionReturn); + + const { unmount } = renderWithProviders(); + act(() => { + (captured.props!['onSelectIndex'] as (i: number) => void)(0); + }); + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); + expect(mockCommandCompletion.dismissCompletion).toHaveBeenCalled(); + expect(props.onSubmit).not.toHaveBeenCalled(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e3ab80ff25..fcbba2452b 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -213,6 +213,9 @@ export const InputPrompt: React.FC = ({ 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,88 @@ export const InputPrompt: React.FC = ({ ? 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( + (index: number) => { + if (commandSearchActive) { + commandSearchCompletion.setActiveSuggestionIndex(index); + } else if (reverseSearchActive) { + reverseSearchCompletion.setActiveSuggestionIndex(index); + } else { + completion.setActiveSuggestionIndex(index); + } + }, + [ + commandSearchActive, + reverseSearchActive, + commandSearchCompletion, + reverseSearchCompletion, + completion, + ], + ); + const handleSuggestionSelect = useCallback( + (index: number) => { + if (commandSearchActive || reverseSearchActive) { + const isCommandSearch = commandSearchActive; + const sc = isCommandSearch + ? commandSearchCompletion + : reverseSearchCompletion; + sc.handleAutocomplete(index); + sc.resetCompletionState(); + (isCommandSearch ? setCommandSearchActive : setReverseSearchActive)( + false, + ); + } else { + // Mirror the keyboard accept path (acceptActiveCompletionSuggestion + + // the ACCEPT_SUGGESTION handler) so a click behaves like Enter on the + // highlighted suggestion. Capture the suggestion BEFORE + // handleAutocomplete mutates the buffer/suggestions. + const accepted = + index >= 0 && index < completion.suggestions.length + ? completion.suggestions[index] + : undefined; + completion.handleAutocomplete(index); + exportCompletion.navigatedRef.current = false; + setExpandedSuggestionIndex(-1); + // For @folder paths, dismiss the completion so the dropdown stays + // closed (folder paths append no trailing space, so the @ pattern + // would otherwise re-match and re-open it). + if ( + accepted?.isDirectory && + completion.completionMode === CompletionMode.AT + ) { + dismissCompletion(); + } + // A click is an explicit accept (the mouse equivalent of Enter, never + // Tab), so honor submitOnAccept unconditionally — clicking a leaf + // command like `/skills` submits it and opens the dialog in one click, + // matching the keyboard behavior. + if (accepted?.submitOnAccept) { + handleSubmitAndClear(`/${accepted.value}`); + } + } + }, + [ + commandSearchActive, + reverseSearchActive, + commandSearchCompletion, + reverseSearchCompletion, + completion, + exportCompletion, + dismissCompletion, + handleSubmitAndClear, + setExpandedSuggestionIndex, + 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 +2110,7 @@ export const InputPrompt: React.FC = ({ topRightLabel={voiceStatusLabel ?? uiState.sessionName ?? undefined} isActive={!isEmbeddedShellFocused} renderLine={renderLineWithHighlighting} + mouseEnabled={mouseInteractionsEnabled} /> {shouldShowSuggestions && ( @@ -2043,6 +2129,13 @@ export const InputPrompt: React.FC = ({ : 'reverse' } expandedIndex={expandedSuggestionIndex} + mouseEnabled={mouseInteractionsEnabled} + onHoverIndex={ + suggestionsFromExport ? undefined : handleSuggestionHover + } + onSelectIndex={ + suggestionsFromExport ? undefined : handleSuggestionSelect + } /> )} diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx new file mode 100644 index 0000000000..c2353ce15a --- /dev/null +++ b/packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(RowMouseController).not.toHaveBeenCalled(); + }); + + it('does not mount RowMouseController when the callbacks are absent', () => { + render( + , + ); + expect(RowMouseController).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 3117288abe..6d84e6c8a4 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Text } from 'ink'; +import { useRef } from 'react'; +import { Box, Text, type DOMElement } from 'ink'; import { theme } from '../semantic-colors.js'; +import { RowMouseController } from './shared/RowMouseController.js'; import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js'; import type { CommandKind, @@ -50,6 +52,12 @@ interface SuggestionsDisplayProps { userInput: string; mode: 'reverse' | 'slash'; expandedIndex?: number; + /** Highlight a suggestion on hover (mouse). */ + onHoverIndex?: (index: number) => void; + /** Accept a suggestion on click (mouse). */ + onSelectIndex?: (index: number) => void; + /** Whether mouse interactions are enabled (alternate-screen mode + setting). */ + mouseEnabled?: boolean; } export const MAX_SUGGESTIONS_TO_SHOW = 8; @@ -82,7 +90,13 @@ export function SuggestionsDisplay({ userInput, mode, expandedIndex, + onHoverIndex, + onSelectIndex, + mouseEnabled, }: SuggestionsDisplayProps) { + const containerRef = useRef(null); + const itemRefs = useRef>([]); + if (isLoading) { return ( @@ -131,7 +145,16 @@ export function SuggestionsDisplay({ : 0; return ( - + + {mouseEnabled && onHoverIndex && onSelectIndex && ( + + )} {scrollOffset > 0 && } {visibleSuggestions.map((suggestion, index) => { @@ -157,7 +180,13 @@ export function SuggestionsDisplay({ ); return ( - + { + itemRefs.current[index] = node; + }} + > {isActive ? '> ' : ' '} diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx new file mode 100644 index 0000000000..5852ee0fea --- /dev/null +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx @@ -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( + {}} />, + { 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( + {}} />, + { settings: settingsWithMouse(false) }, + ); + expect(lastFrame()).toContain('Alpha'); + expect(lastFrame()).toContain('Beta'); + expect(frames.join('\n')).not.toContain(ENABLE_ANY); + }); +}); diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 2044743a83..27eb405c43 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -64,6 +64,7 @@ describe('BaseSelectionList', () => { vi.mocked(useSelectionList).mockReturnValue({ activeIndex, setActiveIndex: vi.fn(), + selectIndex: vi.fn(), }); mockRenderItem.mockImplementation( @@ -288,6 +289,7 @@ describe('BaseSelectionList', () => { vi.mocked(useSelectionList).mockReturnValue({ activeIndex: initialActiveIndex, setActiveIndex: vi.fn(), + selectIndex: vi.fn(), }); mockRenderItem.mockImplementation( @@ -305,6 +307,7 @@ describe('BaseSelectionList', () => { vi.mocked(useSelectionList).mockReturnValue({ activeIndex: newIndex, setActiveIndex: vi.fn(), + selectIndex: vi.fn(), }); await act(async () => { diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx index 3e5baaa73a..3b3a5bc17c 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx @@ -5,10 +5,12 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; -import { Text, Box } from 'ink'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { Text, Box, type DOMElement } from 'ink'; import { theme } from '../../semantic-colors.js'; import { useSelectionList } from '../../hooks/useSelectionList.js'; +import { SettingsContext } from '../../contexts/SettingsContext.js'; +import { RowMouseController } from './RowMouseController.js'; import type { SelectionListItem } from '../../hooks/useSelectionList.js'; @@ -75,7 +77,7 @@ export function BaseSelectionList< itemGap = 0, renderItem, }: BaseSelectionListProps): React.JSX.Element { - const { activeIndex } = useSelectionList({ + const { activeIndex, setActiveIndex, selectIndex } = useSelectionList({ items, initialIndex, onSelect, @@ -102,11 +104,33 @@ export function BaseSelectionList< } }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); + // Mouse input is enabled in alternate-screen mode (ui.useTerminalBuffer): + // the hit-test relies on alternate-screen coordinates where + // measureElementPosition rows line up with mouse event rows. In inline mode + // the live region floats, so the layer is not mounted there. It is mounted + // only when enabled, so dialogs that don't use it pull in no extra providers. + // Read the context raw (not the throwing useSettings) so the component still + // renders outside a SettingsProvider — e.g. in unit tests. + const settings = useContext(SettingsContext); + const mouseEnabled = !!settings?.merged.ui?.useTerminalBuffer; + const containerRef = useRef(null); + const itemRefs = useRef>([]); + const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); const numberColumnWidth = String(items.length).length; return ( - + + {mouseEnabled && isFocused && items.length > 0 && ( + !!items[index]?.disabled} + onHoverIndex={setActiveIndex} + onSelectIndex={selectIndex} + /> + )} {/* Use conditional coloring instead of conditional rendering */} {showScrollArrows && ( + { + itemRefs.current[index] = node; + }} + > {/* Radio button indicator */} ({ useMouseEvents: vi.fn() })); +vi.mock('../../hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn() })); +vi.mock('../../utils/measure-element-position.js', () => ({ + measureElementPosition: vi.fn(), + layoutRowForEvent: vi.fn(), +})); + +// Stand-in for the real layoutRowForEvent: apply the frame-anchor correction +// for a given frame height (anchor = min(0, terminalHeight - frameHeight)). +const mockLayoutRowForEvent = (frameHeight: number) => + vi + .mocked(layoutRowForEvent) + .mockImplementation((_node, terminalRow1Based, terminalHeight) => { + const anchor = Math.min(0, terminalHeight - frameHeight); + return terminalRow1Based - 1 - anchor; + }); + +const ref = (current: T): MutableRefObject => ({ current }); + +function makeEvent( + partial: Partial & Pick, +): MouseEvent { + return { + col: 5, + row: 1, + shift: false, + meta: false, + ctrl: false, + button: 'left', + ...partial, + } as MouseEvent; +} + +describe('RowMouseController', () => { + const containerNode = { tag: 'container' } as unknown as DOMElement; + const itemNodes = [ + { tag: 'i0' }, + { tag: 'i1' }, + { tag: 'i2' }, + ] as unknown as DOMElement[]; + + let onHoverIndex: ReturnType; + let onSelectIndex: ReturnType; + + // Frame exactly fills the terminal here → anchor 0 → layoutRow = event.row - 1. + // Each item is one row tall, stacked from the top, so item i sits at row i. + beforeEach(() => { + vi.clearAllMocks(); + onHoverIndex = vi.fn(); + onSelectIndex = vi.fn(); + + vi.mocked(useTerminalSize).mockReturnValue({ rows: 40, columns: 80 }); + mockLayoutRowForEvent(40); // frame fills the terminal → anchor 0 + vi.mocked(measureElementPosition).mockImplementation((node) => { + if (node === containerNode) { + return { x: 0, y: 0, width: 20, height: itemNodes.length }; + } + const index = itemNodes.indexOf(node); + return { x: 0, y: index, width: 20, height: 1 }; + }); + }); + + function mountAndGetHandler(opts?: { + scrollOffset?: number; + isDisabled?: (index: number) => boolean; + }): (event: MouseEvent) => void { + render( + , + ); + const call = vi.mocked(useMouseEvents).mock.calls.at(-1)!; + // Subscribes at the 'any' level so bare hover is reported. + expect(call[1]).toMatchObject({ isActive: true, tracking: 'any' }); + return call[0]; + } + + it('highlights the row under the pointer on move', () => { + const handler = mountAndGetHandler(); + handler(makeEvent({ name: 'move', row: 3 })); // layout row 2 → item 2 + expect(onHoverIndex).toHaveBeenCalledWith(2); + expect(onSelectIndex).not.toHaveBeenCalled(); + }); + + it('selects the row under the pointer on left-press', () => { + const handler = mountAndGetHandler(); + handler(makeEvent({ name: 'left-press', row: 1 })); // layout row 0 → item 0 + expect(onSelectIndex).toHaveBeenCalledWith(0); + expect(onHoverIndex).not.toHaveBeenCalled(); + }); + + it('ignores disabled rows for both hover and click', () => { + const handler = mountAndGetHandler({ isDisabled: (i) => i === 1 }); + handler(makeEvent({ name: 'move', row: 2 })); // item 1 (disabled) + handler(makeEvent({ name: 'left-press', row: 2 })); + expect(onHoverIndex).not.toHaveBeenCalled(); + expect(onSelectIndex).not.toHaveBeenCalled(); + }); + + it('maps through the scroll offset', () => { + const handler = mountAndGetHandler({ scrollOffset: 5 }); + handler(makeEvent({ name: 'move', row: 1 })); // visible pos 0 → index 5 + expect(onHoverIndex).toHaveBeenCalledWith(5); + }); + + it('applies a negative anchor when the frame overflows the screen', () => { + // Frame 4 rows taller than the terminal → top 4 rows scrolled off → + // anchor -4, i.e. a +4-row correction. Items live near the bottom (high y). + vi.mocked(useTerminalSize).mockReturnValue({ rows: 8, columns: 80 }); + mockLayoutRowForEvent(12); // frame 12 rows, terminal 8 → anchor -4 + vi.mocked(measureElementPosition).mockImplementation((node) => { + if (node === containerNode) { + return { x: 0, y: 10, width: 20, height: 3 }; + } + const index = itemNodes.indexOf(node); + return { x: 0, y: 10 + index, width: 20, height: 1 }; + }); + const handler = mountAndGetHandler(); + // event.row 7 → layoutRow = 7 - 1 - (-4) = 10 → item at y=10 → index 0. + handler(makeEvent({ name: 'move', row: 7 })); + expect(onHoverIndex).toHaveBeenCalledWith(0); + }); + + it('ignores rows below the last item', () => { + const handler = mountAndGetHandler(); + handler(makeEvent({ name: 'move', row: 10 })); + expect(onHoverIndex).not.toHaveBeenCalled(); + }); + + it('ignores interactions outside the list columns', () => { + const handler = mountAndGetHandler(); + handler(makeEvent({ name: 'left-press', row: 1, col: 30 })); // col0 29 >= width 20 + expect(onSelectIndex).not.toHaveBeenCalled(); + }); + + it('ignores scroll and release events', () => { + const handler = mountAndGetHandler(); + handler(makeEvent({ name: 'scroll-down', row: 1 })); + handler(makeEvent({ name: 'left-release', row: 1 })); + expect(onHoverIndex).not.toHaveBeenCalled(); + expect(onSelectIndex).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/RowMouseController.tsx b/packages/cli/src/ui/components/shared/RowMouseController.tsx new file mode 100644 index 0000000000..52de97b148 --- /dev/null +++ b/packages/cli/src/ui/components/shared/RowMouseController.tsx @@ -0,0 +1,127 @@ +/** + * @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 { + measureElementPosition, + layoutRowForEvent, +} from '../../utils/measure-element-position.js'; +import { + findItemAtLayoutRow, + type VisibleItemRect, +} from '../../utils/list-mouse.js'; + +export interface RowMouseControllerProps { + /** Outer list container node — bounds interactions horizontally. */ + containerRef: MutableRefObject; + /** Visible item nodes, indexed by visible position (0..visibleCount-1). */ + itemRefs: MutableRefObject>; + /** Index of the first visible item within the full list. */ + scrollOffset: number; + /** Optional: indices that are non-interactive (skipped for hover/select). */ + isDisabled?: (index: number) => boolean; + /** Highlight the row under the pointer (hover). */ + onHoverIndex: (index: number) => void; + /** Select the row under the pointer (click). */ + onSelectIndex: (index: number) => void; +} + +/** + * Headless mouse layer for a vertical list of rows — shared by select menus + * (BaseSelectionList) and completion suggestions (SuggestionsDisplay). Rendered + * only while mouse input is enabled, so the providers it depends on + * (KeypressProvider, via useMouseEvents) are only required when the feature is + * on. + * + * Subscribes at the `'any'` tracking level so bare pointer motion (hover, no + * button held) is reported. `move` highlights the row under the pointer; + * `left-press` selects it. Disabled rows and interactions outside the list's + * columns are ignored. + * + * Coordinates assume alternate-screen mode. When the frame fits within the + * terminal (`frameHeight <= terminalHeight`) the anchor is 0 and the layout row + * is just `event.row - 1`. When the frame overflows, Ink bottom-pins it and the + * top rows scroll off-screen; the frame anchor (`terminalHeight - frameHeight`, + * negative) corrects terminal rows back into layout space. This is why the code + * below routes through `frameAnchor`/`terminalRowToLayoutRow` rather than a bare + * `event.row - 1`. VirtualizedList.hitTestScrollbar does not apply this + * correction (it operates only on the scrollbar track, which is always in the + * visible region); list-item hit-testing needs it. The owning component only + * mounts this layer in alternate-screen mode; inline mode, where the live region + * floats, is intentionally unsupported here. + */ +export function RowMouseController({ + containerRef, + itemRefs, + scrollOffset, + isDisabled, + onHoverIndex, + onSelectIndex, +}: RowMouseControllerProps): null { + const { rows: terminalHeight } = useTerminalSize(); + + const resolveIndex = useCallback( + (event: MouseEvent): number | null => { + 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); + }, + [containerRef, itemRefs, scrollOffset, terminalHeight], + ); + + const handleMouse = useCallback( + (event: MouseEvent) => { + if (event.name !== 'move' && event.name !== 'left-press') return; + + const index = resolveIndex(event); + if (index === null || isDisabled?.(index)) return; + + if (event.name === 'move') { + onHoverIndex(index); + } else { + onSelectIndex(index); + } + }, + [resolveIndex, isDisabled, onHoverIndex, onSelectIndex], + ); + + useMouseEvents(handleMouse, { isActive: true, tracking: 'any' }); + + return null; +} diff --git a/packages/cli/src/ui/components/shared/TextInputMouseController.test.tsx b/packages/cli/src/ui/components/shared/TextInputMouseController.test.tsx new file mode 100644 index 0000000000..a3e288c393 --- /dev/null +++ b/packages/cli/src/ui/components/shared/TextInputMouseController.test.tsx @@ -0,0 +1,161 @@ +/** + * @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 { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TextInputMouseController } from './TextInputMouseController.js'; +import { useMouseEvents } from '../../hooks/useMouseEvents.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { + measureElementPosition, + layoutRowForEvent, +} 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', () => ({ + measureElementPosition: vi.fn(), + layoutRowForEvent: 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('TextInputMouseController', () => { + const linesNode = { tag: 'lines' } as unknown as DOMElement; + let moveToOffset: ReturnType; + + // Lines container rendered at screen row 5, col 2, two visual lines tall. + function makeBuffer(overrides?: Partial<{ visualScrollRow: number }>) { + return { + lines: ['abc', 'def'], + allVisualLines: ['abc', 'def'], + visualToLogicalMap: [ + [0, 0], + [1, 0], + ] as Array<[number, number]>, + visualScrollRow: overrides?.visualScrollRow ?? 0, + moveToOffset, + }; + } + + // Frame fills the terminal here → anchor 0 → clickVisualRow = event.row-1-y. + beforeEach(() => { + vi.clearAllMocks(); + moveToOffset = vi.fn(); + vi.mocked(useTerminalSize).mockReturnValue({ rows: 40, columns: 80 }); + // Frame fills the terminal → anchor 0 → layout row = terminalRow - 1. + vi.mocked(layoutRowForEvent).mockImplementation( + (_node, terminalRow1Based) => terminalRow1Based - 1, + ); + vi.mocked(measureElementPosition).mockReturnValue({ + x: 2, + y: 5, + width: 20, + height: 2, + }); + }); + + function mountAndGetHandler( + buffer = makeBuffer(), + visibleLineCount = 2, + ): (event: MouseEvent) => void { + render( + , + ); + const call = vi.mocked(useMouseEvents).mock.calls.at(-1)!; + // Input click needs no hover, so the cheaper 'button' level is used. + expect(call[1]).toMatchObject({ isActive: true, tracking: 'button' }); + return call[0]; + } + + it('moves the cursor to the clicked offset on left-press', () => { + const handler = mountAndGetHandler(); + // row 6 → visual row 0 ('abc'); col 4 → text col 1 → between 'a' and 'b'. + handler(makeEvent({ name: 'left-press', row: 6, col: 4 })); + expect(moveToOffset).toHaveBeenCalledWith(1); + }); + + it('maps a click on the second visual line through the newline', () => { + const handler = mountAndGetHandler(); + // row 7 → visual row 1 ('def'); col 4 → text col 1 → logical (1,1) → offset 5. + handler(makeEvent({ name: 'left-press', row: 7, col: 4 })); + expect(moveToOffset).toHaveBeenCalledWith(5); + }); + + it('applies the visual scroll offset', () => { + const handler = mountAndGetHandler(makeBuffer({ visualScrollRow: 1 })); + // row 6 → visual row 0 + scroll 1 = absolute visual row 1 ('def'). + handler(makeEvent({ name: 'left-press', row: 6, col: 3 })); + // col 3 → text col 0 → start of 'def' → offset 4. + expect(moveToOffset).toHaveBeenCalledWith(4); + }); + + it('applies a negative anchor when the frame overflows the screen', () => { + // Frame 4 rows taller than the terminal → anchor -4 → +4-row correction. + vi.mocked(useTerminalSize).mockReturnValue({ rows: 8, columns: 80 }); + vi.mocked(layoutRowForEvent).mockImplementation( + (_node, terminalRow1Based, terminalHeight) => { + const anchor = Math.min(0, terminalHeight - 12); // frame height 12 + return terminalRow1Based - 1 - anchor; + }, + ); + vi.mocked(measureElementPosition).mockReturnValue({ + x: 2, + y: 9, + width: 20, + height: 2, + }); + const handler = mountAndGetHandler(); + // row 6 → layoutRow = 6 - 1 - (-4) = 9; clickVisualRow = 9 - 9 = 0 ('abc'); + // col 4 → text col 1 → offset 1. + handler(makeEvent({ name: 'left-press', row: 6, col: 4 })); + expect(moveToOffset).toHaveBeenCalledWith(1); + }); + + it('clamps a click in the prefix columns to the line start', () => { + const handler = mountAndGetHandler(); + // col 1 < lines x (2) → clickVisualCol clamps to 0 → start of line → offset 0. + handler(makeEvent({ name: 'left-press', row: 6, col: 1 })); + expect(moveToOffset).toHaveBeenCalledWith(0); + }); + + it('ignores clicks above or below the rendered lines', () => { + const handler = mountAndGetHandler(); + handler(makeEvent({ name: 'left-press', row: 5, col: 4 })); // row above lines (y=5 → visual -0? ) + handler(makeEvent({ name: 'left-press', row: 99, col: 4 })); // far below + // row 5 → clickVisualRow = 5-1-5 = -1 (above) → ignored; row 99 → below → ignored. + expect(moveToOffset).not.toHaveBeenCalled(); + }); + + it('ignores non-left-press events (hover, release, scroll)', () => { + const handler = mountAndGetHandler(); + handler(makeEvent({ name: 'move', row: 6, col: 4 })); + handler(makeEvent({ name: 'left-release', row: 6, col: 4 })); + handler(makeEvent({ name: 'scroll-down', row: 6, col: 4 })); + expect(moveToOffset).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/TextInputMouseController.tsx b/packages/cli/src/ui/components/shared/TextInputMouseController.tsx new file mode 100644 index 0000000000..0d5af2f0e5 --- /dev/null +++ b/packages/cli/src/ui/components/shared/TextInputMouseController.tsx @@ -0,0 +1,83 @@ +/** + * @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 { + measureElementPosition, + layoutRowForEvent, +} from '../../utils/measure-element-position.js'; +import { + visualClickToOffset, + type ClickableBufferState, +} from '../../utils/input-mouse.js'; + +export interface TextInputMouseControllerProps { + /** The lines container node (the text area, positioned after the prefix). */ + linesRef: MutableRefObject; + /** Buffer visual state plus the cursor mover. */ + buffer: ClickableBufferState & { + visualScrollRow: number; + moveToOffset: (offset: number) => void; + }; + /** Number of visual lines currently rendered (linesToRender.length). */ + visibleLineCount: number; +} + +/** + * Headless mouse layer for the prompt input: a left-click positions the text + * cursor under the pointer. Rendered only while mouse input is enabled, so its + * provider dependencies (KeypressProvider, via useMouseEvents) are only + * required then. + * + * Only `left-press` is handled — the input has no hover behavior — so this + * subscribes at the cheaper `'button'` tracking level (no bare-motion stream). + * + * Coordinates are taken relative to the measured lines container, so the input + * border row and the prefix column are accounted for automatically. Like the + * other mouse layers this assumes alternate-screen coordinates; the owning + * component only mounts it in that mode. + */ +export function TextInputMouseController({ + linesRef, + buffer, + visibleLineCount, +}: TextInputMouseControllerProps): null { + const { rows: terminalHeight } = useTerminalSize(); + + const handleMouse = useCallback( + (event: MouseEvent) => { + if (event.name !== 'left-press') return; + + const lines = linesRef.current; + if (!lines) return; + + const rect = measureElementPosition(lines); + if (rect.height <= 0) return; + + const clickVisualRow = + layoutRowForEvent(lines, event.row, terminalHeight) - rect.y; + if (clickVisualRow < 0 || clickVisualRow >= visibleLineCount) return; + + const clickVisualCol = Math.max(0, event.col - 1 - rect.x); + const absoluteVisualRow = buffer.visualScrollRow + clickVisualRow; + const offset = visualClickToOffset( + buffer, + absoluteVisualRow, + clickVisualCol, + ); + if (offset !== null) buffer.moveToOffset(offset); + }, + [linesRef, buffer, visibleLineCount, terminalHeight], + ); + + useMouseEvents(handleMouse, { isActive: true, tracking: 'button' }); + + return null; +} diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 0d67b5403e..a7648e2200 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -642,6 +642,117 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); + it('should not dispatch SGR mouse events embedded in pasted content', async () => { + const keyHandler = vi.fn(); + const mouseHandler = vi.fn(); + // An SGR left-press (\x1b[<0;5;5M) hidden inside bracketed paste must + // be treated as paste content, never reconstructed into a real click — + // otherwise a pasted payload could select a dialog option or move the + // cursor without the user pressing anything. + const mouseSequence = '\x1b[<0;5;5M'; + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, pasteWorkaround: true }), + }); + + act(() => { + result.current.subscribe(keyHandler); + result.current.subscribeMouse(mouseHandler); + }); + + act(() => { + stdin.emit('data', Buffer.from(`\x1b[200~${mouseSequence}\x1b[201~`)); + }); + + await waitFor(() => { + expect(keyHandler).toHaveBeenCalledTimes(1); + }); + + // No mouse event should ever be dispatched from pasted bytes. + expect(mouseHandler).not.toHaveBeenCalled(); + // The bytes are delivered as a single paste event instead, carrying + // the SGR payload as literal content. + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ paste: true }), + ); + expect(keyHandler.mock.calls[0][0].sequence).toContain('0;5;5'); + }); + + it('should not dispatch SGR mouse events when a paste begins mid-reassembly', async () => { + const keyHandler = vi.fn(); + const mouseHandler = vi.fn(); + // Race: a real mouse-move starts an SGR fragment (`\x1b[<…` with no + // terminating `M` yet), then a bracketed paste begins. paste-start must + // not be swallowed into the SGR buffer — otherwise `isPaste` stays false + // and an SGR left-press embedded in the pasted content gets + // reconstructed into a real click (e.g. auto-selecting a dialog option). + const partialMouseMove = '\x1b[<35;10;5'; + const embeddedClick = '\x1b[<0;5;5M'; + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, pasteWorkaround: true }), + }); + + act(() => { + result.current.subscribe(keyHandler); + result.current.subscribeMouse(mouseHandler); + }); + + act(() => { + // Partial mouse-move fragment arrives first (no terminating M). + stdin.emit('data', Buffer.from(partialMouseMove)); + // Then a paste carrying an embedded SGR left-press. + stdin.emit('data', Buffer.from(`\x1b[200~${embeddedClick}\x1b[201~`)); + }); + + await waitFor(() => { + expect(keyHandler).toHaveBeenCalled(); + }); + + // No mouse event should ever be dispatched from the pasted bytes. + expect(mouseHandler).not.toHaveBeenCalled(); + // The embedded SGR payload arrives as literal paste content. + const pasteCall = keyHandler.mock.calls.find((c) => c[0]?.paste); + expect(pasteCall).toBeDefined(); + expect(pasteCall?.[0].sequence).toContain('0;5;5'); + }); + + it('abandons a runaway SGR mouse buffer so later keystrokes still arrive', async () => { + const keyHandler = vi.fn(); + const mouseHandler = vi.fn(); + // A malformed `\x1b[<` with no terminator (e.g. stray subprocess output) + // must not swallow input indefinitely: once the reassembly buffer passes + // the SGR length cap it is abandoned, so a following keystroke is + // delivered normally instead of being buffered and discarded. + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, pasteWorkaround: true }), + }); + + act(() => { + result.current.subscribe(keyHandler); + result.current.subscribeMouse(mouseHandler); + }); + + act(() => { + // Start SGR reassembly, then feed long garbage without a terminator + // to overflow the cap, followed by a plain 'a'. + stdin.emit('data', Buffer.from('\x1b[<')); + stdin.emit('data', Buffer.from('1'.repeat(60))); + stdin.emit('data', Buffer.from('a')); + }); + + await waitFor(() => { + expect( + keyHandler.mock.calls.some((c) => c[0]?.sequence === 'a'), + ).toBe(true); + }); + // The garbage never reconstructs into a real mouse event. + expect(mouseHandler).not.toHaveBeenCalled(); + }); + it('should handle empty paste sequence', async () => { const keyHandler = vi.fn(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 9f9ecb79b3..9c3f4d9dcb 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -24,6 +24,7 @@ import { PassThrough } from 'node:stream'; import { noteInteraction } from '../../utils/housekeeping/lastInteractionAt.js'; import { parseSGRMouseEvent, + MAX_SGR_MOUSE_SEQUENCE_LENGTH, type MouseEvent as SgrMouseEvent, } from '../utils/mouse.js'; import { @@ -228,6 +229,18 @@ export function KeypressProvider({ let sgrMouseBuffer = ''; let sgrMouseTimeout: NodeJS.Timeout | null = null; + // Abandon any in-progress SGR mouse reassembly: clear the swallow flag, the + // partial buffer, and the reassembly timeout. Shared by every branch that + // bails out of mouse parsing (bracketed-paste takeover, ctrl+c, teardown). + const resetSgrMouse = () => { + swallowingSgrMouse = false; + sgrMouseBuffer = ''; + if (sgrMouseTimeout) { + clearTimeout(sgrMouseTimeout); + sgrMouseTimeout = null; + } + }; + const updateKittyBuffer = (value: string) => { kittySequenceBufferRef.current = value; }; @@ -714,14 +727,29 @@ export function KeypressProvider({ // \x1b[< followed by individual character events. We buffer the // fragments, reconstruct the full sequence, parse it, and forward // to registered mouse handlers. - if (swallowingSgrMouse) { + // + // While a bracketed paste is in progress, never reconstruct or dispatch + // SGR mouse events: pasted content can embed `\x1b[<0;col;rowM`, and + // delivering that as a real click would let a paste choose dialog + // options or move the cursor. Let those bytes fall through to the paste + // buffer instead, and discard any half-built mouse fragment. + if (isPaste) { + // resetSgrMouse() is idempotent (a no-op when nothing is in flight), + // so call it unconditionally — matching the other reset call sites. + resetSgrMouse(); + } else if (swallowingSgrMouse) { if (key.ctrl && key.name === 'c') { - swallowingSgrMouse = false; - sgrMouseBuffer = ''; - if (sgrMouseTimeout) { - clearTimeout(sgrMouseTimeout); - sgrMouseTimeout = null; - } + resetSgrMouse(); + } else if (key.name === 'paste-start') { + // Bracketed paste takes priority over a half-built SGR mouse + // fragment. If a paste begins mid-reassembly (e.g. a mouse-move + // `\x1b[<…` arrived without its terminating `M`, then paste-start), + // we must NOT swallow paste-start into sgrMouseBuffer — doing so + // would skip the paste-start handler below, leave `isPaste` false, + // and let an SGR sequence embedded in the pasted content be + // reconstructed into a real click. Discard the fragment and fall + // through so the paste-start handler sets `isPaste = true`. + resetSgrMouse(); } else { sgrMouseBuffer += key.sequence; if (key.name === 'm' || key.sequence === 'M') { @@ -737,11 +765,18 @@ export function KeypressProvider({ } } sgrMouseBuffer = ''; + } else if (sgrMouseBuffer.length >= MAX_SGR_MOUSE_SEQUENCE_LENGTH) { + // A malformed `\x1b[<` (e.g. from subprocess output) without a + // terminator would otherwise swallow every subsequent keystroke + // until the 200ms timeout fires. Mirror the 50-byte cap in + // isIncompleteMouseSequence: bail out early so the next keystroke + // is handled normally instead of being buffered and discarded. + resetSgrMouse(); } return; } } - if (key.sequence === `${ESC}[<`) { + if (!isPaste && key.sequence === `${ESC}[<`) { swallowingSgrMouse = true; sgrMouseBuffer = `${ESC}[<`; if (sgrMouseTimeout) { @@ -1251,12 +1286,7 @@ export function KeypressProvider({ clearKittyBufferAndTimeout(); clearPasteIdleTimeout(); - if (sgrMouseTimeout) { - clearTimeout(sgrMouseTimeout); - sgrMouseTimeout = null; - } - swallowingSgrMouse = false; - sgrMouseBuffer = ''; + resetSgrMouse(); if (rawFlushTimeout) { clearTimeout(rawFlushTimeout); diff --git a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx index e88bccb4e2..76debccd10 100644 --- a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx +++ b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx @@ -28,6 +28,8 @@ const mockedUseStdout = vi.mocked(useStdout); const ENABLE_MOUSE = '\x1b[?1002h\x1b[?1006h'; const DISABLE_MOUSE = '\x1b[?1006l\x1b[?1002l'; +const ENABLE_ANY = '\x1b[?1003h\x1b[?1006h'; +const DISABLE_ANY = '\x1b[?1006l\x1b[?1003l'; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} @@ -113,6 +115,74 @@ describe('useMouseEvents', () => { expect(stdout.write).not.toHaveBeenCalled(); }); + it('upgrades to ?1003h when a hover (any) subscriber is active, then restores ?1002h', () => { + const { rerender, unmount } = renderHook( + ({ + buttonActive, + anyActive, + }: { + buttonActive: boolean; + anyActive: boolean; + }) => { + useMouseEvents(() => {}, { + isActive: buttonActive, + bypassVpGate: true, + }); + useMouseEvents(() => {}, { + isActive: anyActive, + tracking: 'any', + bypassVpGate: true, + }); + }, + { + initialProps: { buttonActive: true, anyActive: false }, + wrapper, + }, + ); + + // Only the button subscriber is active → button-event tracking. + expect(stdout.write).toHaveBeenCalledTimes(1); + expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE); + + // Hover subscriber mounts → upgrade: disable 1002, enable 1003. + stdout.write.mockClear(); + rerender({ buttonActive: true, anyActive: true }); + expect(stdout.write).toHaveBeenCalledWith(DISABLE_MOUSE); + expect(stdout.write).toHaveBeenCalledWith(ENABLE_ANY); + + // Hover subscriber leaves → downgrade back to 1002. + stdout.write.mockClear(); + rerender({ buttonActive: true, anyActive: false }); + expect(stdout.write).toHaveBeenCalledWith(DISABLE_ANY); + expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE); + + // Last subscriber leaves → disable entirely. + stdout.write.mockClear(); + rerender({ buttonActive: false, anyActive: false }); + expect(stdout.write).toHaveBeenCalledWith(DISABLE_MOUSE); + + unmount(); + }); + + it('enables ?1003h directly when the only active subscriber wants hover', () => { + const { unmount } = renderHook( + () => + useMouseEvents(() => {}, { + isActive: true, + tracking: 'any', + bypassVpGate: true, + }), + { wrapper }, + ); + + expect(stdout.write).toHaveBeenCalledTimes(1); + expect(stdout.write).toHaveBeenCalledWith(ENABLE_ANY); + + stdout.write.mockClear(); + unmount(); + expect(stdout.write).toHaveBeenCalledWith(DISABLE_ANY); + }); + describe('VP gate', () => { it('non-VP without bypass: does NOT enable mouse mode (native scrollback preserved)', () => { renderHook(() => useMouseEvents(() => {}, { isActive: true }), { diff --git a/packages/cli/src/ui/hooks/useMouseEvents.ts b/packages/cli/src/ui/hooks/useMouseEvents.ts index 8989d6615d..546d4712f1 100644 --- a/packages/cli/src/ui/hooks/useMouseEvents.ts +++ b/packages/cli/src/ui/hooks/useMouseEvents.ts @@ -14,6 +14,7 @@ import { enableMouseEvents, disableMouseEvents, type MouseEvent, + type MouseTracking, } from '../utils/mouse.js'; import { useKeypressContext } from '../contexts/KeypressContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; @@ -23,6 +24,12 @@ export type MouseHandler = (event: MouseEvent) => void; export interface MouseEventsOptions { /** Subscribe + enable SGR mouse mode only while this is true. */ isActive: boolean; + /** + * Tracking level to request. `'button'` (?1002h) reports press/drag/release; + * `'any'` (?1003h) additionally reports bare motion, needed for hover. The + * effective terminal level is the highest any active subscriber requests. + */ + tracking?: MouseTracking; /** * Opt out of the VP gate. By default mouse tracking is enabled only in VP * mode (`ui.useTerminalBuffer`), so non-VP keeps native terminal scrollback. @@ -33,41 +40,73 @@ export interface MouseEventsOptions { bypassVpGate?: boolean; } +// Per-terminal reference counts, split by tracking level. The effective level +// is the highest requested: any active subscriber asking for 'any' (hover) +// upgrades the terminal to ?1003h; otherwise ?1002h. `active` records what is +// currently enabled on the terminal so a level switch disables the old mode +// before enabling the new one (1002 and 1003 are mutually exclusive). type MouseModeEntry = { - refs: number; + button: number; + any: number; + active: MouseTracking | null; }; const mouseModeRefs = new Map(); +function effectiveTracking(entry: MouseModeEntry): MouseTracking | null { + if (entry.any > 0) return 'any'; + if (entry.button > 0) return 'button'; + return null; +} + +// Bring the terminal's enabled mode in line with the desired effective level, +// writing escape sequences only when the level actually changes. +function reconcileMouseMode( + stdout: NodeJS.WriteStream, + entry: MouseModeEntry, +): void { + const desired = effectiveTracking(entry); + if (desired === entry.active) return; + if (entry.active) disableMouseEvents(stdout, entry.active); + if (desired) enableMouseEvents(stdout, desired); + entry.active = desired; +} + const disableAllMouseModes = () => { - for (const stdout of mouseModeRefs.keys()) { - disableMouseEvents(stdout); + for (const [stdout, entry] of mouseModeRefs) { + if (entry.active) disableMouseEvents(stdout, entry.active); } mouseModeRefs.clear(); }; -function acquireMouseMode(stdout: NodeJS.WriteStream): void { - const entry = mouseModeRefs.get(stdout); - if (entry) { - entry.refs += 1; - return; +function acquireMouseMode( + stdout: NodeJS.WriteStream, + tracking: MouseTracking, +): void { + let entry = mouseModeRefs.get(stdout); + if (!entry) { + if (mouseModeRefs.size === 0) { + process.on('exit', disableAllMouseModes); + } + entry = { button: 0, any: 0, active: null }; + mouseModeRefs.set(stdout, entry); } - - enableMouseEvents(stdout); - if (mouseModeRefs.size === 0) { - process.on('exit', disableAllMouseModes); - } - mouseModeRefs.set(stdout, { refs: 1 }); + entry[tracking] += 1; + reconcileMouseMode(stdout, entry); } -function releaseMouseMode(stdout: NodeJS.WriteStream): void { +function releaseMouseMode( + stdout: NodeJS.WriteStream, + tracking: MouseTracking, +): void { const entry = mouseModeRefs.get(stdout); if (!entry) return; - entry.refs -= 1; - if (entry.refs <= 0) { + entry[tracking] = Math.max(0, entry[tracking] - 1); + reconcileMouseMode(stdout, entry); + + if (entry.button === 0 && entry.any === 0) { mouseModeRefs.delete(stdout); - disableMouseEvents(stdout); if (mouseModeRefs.size === 0) { process.removeListener('exit', disableAllMouseModes); } @@ -77,12 +116,14 @@ function releaseMouseMode(stdout: NodeJS.WriteStream): void { /** * Subscribes to SGR mouse events while `isActive` is true. * - * On activation: writes `?1002h ?1006h` to enable button-event tracking and - * SGR coordinates. KeypressContext's readline pipeline receives the SGR - * fragments, reconstructs the full sequence, parses it, and forwards the - * parsed MouseEvent to subscribers registered via `subscribeMouse`. On - * cleanup (or when `isActive` flips false): writes `?1006l ?1002l` to - * restore the terminal. + * On activation: enables SGR mouse tracking at the requested `tracking` level + * (`'button'` → `?1002h`, `'any'` → `?1003h` for hover) plus `?1006h` for SGR + * coordinates. KeypressContext's readline pipeline receives the SGR fragments, + * reconstructs the full sequence, parses it, and forwards the parsed + * MouseEvent to subscribers registered via `subscribeMouse`. On cleanup (or + * when `isActive` flips false): disables the mode to restore the terminal. + * Reference counts are shared per terminal across all subscribers; the + * effective level is the highest any active subscriber requests. * * Earlier versions used ink's `useInput` to receive mouse events, but * readline's `emitKeypressEvents` drains stdin in flowing mode before @@ -94,7 +135,7 @@ function releaseMouseMode(stdout: NodeJS.WriteStream): void { */ export function useMouseEvents( handler: MouseHandler, - { isActive, bypassVpGate = false }: MouseEventsOptions, + { isActive, tracking = 'button', bypassVpGate = false }: MouseEventsOptions, ): void { const { isRawModeSupported } = useStdin(); const { stdout } = useStdout(); @@ -121,12 +162,12 @@ export function useMouseEvents( useEffect(() => { if (!enabled) return; - acquireMouseMode(stdout); + acquireMouseMode(stdout, tracking); return () => { - releaseMouseMode(stdout); + releaseMouseMode(stdout, tracking); }; - }, [enabled, stdout]); + }, [enabled, stdout, tracking]); const mouseCallback = useCallback((event: MouseEvent) => { handlerRef.current(event); diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx index d90875c10c..9f32e1c271 100644 --- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx @@ -26,6 +26,7 @@ export interface UseReverseSearchCompletionReturn { isLoadingSuggestions: boolean; navigateUp: () => void; navigateDown: () => void; + setActiveSuggestionIndex: React.Dispatch>; handleAutocomplete: (i: number) => void; resetCompletionState: () => void; } @@ -149,6 +150,7 @@ export function useReverseSearchCompletion( isLoadingSuggestions, navigateUp, navigateDown, + setActiveSuggestionIndex, handleAutocomplete, resetCompletionState, }; diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.ts b/packages/cli/src/ui/hooks/useSelectionList.test.ts index df03c1bc48..a879d0b072 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.test.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.test.ts @@ -1084,4 +1084,44 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(0); }); }); + + describe('selectIndex (click-to-choose)', () => { + it('moves the active index to the target and selects it', () => { + const { result } = renderHook(() => + useSelectionList({ + items, + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + }), + ); + act(() => { + result.current.selectIndex(2); + }); + expect(result.current.activeIndex).toBe(2); + expect(mockOnSelect).toHaveBeenCalledWith('C'); + }); + + it('selects the already-active row when targeted again', () => { + const { result } = renderHook(() => + useSelectionList({ items, onSelect: mockOnSelect }), + ); + expect(result.current.activeIndex).toBe(0); + act(() => { + result.current.selectIndex(0); + }); + expect(result.current.activeIndex).toBe(0); + expect(mockOnSelect).toHaveBeenCalledWith('A'); + }); + + it('ignores a disabled target (no move, no select)', () => { + const { result } = renderHook(() => + useSelectionList({ items, onSelect: mockOnSelect }), + ); + act(() => { + result.current.selectIndex(1); // 'B' is disabled + }); + expect(result.current.activeIndex).toBe(0); + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts index 373b4d58aa..b09682a79a 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.ts @@ -36,6 +36,8 @@ const debugLogger = createDebugLogger('SELECTION_LIST'); export interface UseSelectionListResult { activeIndex: number; setActiveIndex: (index: number) => void; + /** Move the active index to `index` and select it (click-to-choose). */ + selectIndex: (index: number) => void; } interface SelectionListState { @@ -415,8 +417,21 @@ export function useSelectionList({ }); }; + // Click-to-choose: move the active index to `index` and select it, the + // mouse counterpart of arrowing to a row and pressing Enter. Both dispatches + // are processed against the evolving reducer state in order, so SELECT_CURRENT + // sees the just-set activeIndex. Disabled rows are ignored (the selection + // side effect guards on `disabled` too, but bailing here avoids a redundant + // highlight dispatch). + const selectIndex = (index: number) => { + if (items[index]?.disabled) return; + dispatch({ type: 'SET_ACTIVE_INDEX', payload: { index, items } }); + dispatch({ type: 'SELECT_CURRENT', payload: { items } }); + }; + return { activeIndex: state.activeIndex, setActiveIndex, + selectIndex, }; } diff --git a/packages/cli/src/ui/utils/input-mouse.test.ts b/packages/cli/src/ui/utils/input-mouse.test.ts new file mode 100644 index 0000000000..db64fb89fa --- /dev/null +++ b/packages/cli/src/ui/utils/input-mouse.test.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + visualClickToOffset, + type ClickableBufferState, +} from './input-mouse.js'; + +describe('visualClickToOffset', () => { + it('maps a click within a single logical line to the char boundary', () => { + const buffer: ClickableBufferState = { + lines: ['abc', 'def'], + allVisualLines: ['abc', 'def'], + visualToLogicalMap: [ + [0, 0], + [1, 0], + ], + }; + // Row 0, col 1 → between 'a' and 'b' → logical (0,1) → offset 1. + expect(visualClickToOffset(buffer, 0, 1)).toBe(1); + // Row 0, col 0 → start of line → offset 0. + expect(visualClickToOffset(buffer, 0, 0)).toBe(0); + }); + + it('accounts for the newline between logical lines', () => { + const buffer: ClickableBufferState = { + lines: ['abc', 'def'], + allVisualLines: ['abc', 'def'], + visualToLogicalMap: [ + [0, 0], + [1, 0], + ], + }; + // Row 1 ('def'), col 2 → logical (1,2). Offset = len('abc')=3 + 1 newline + 2 = 6. + expect(visualClickToOffset(buffer, 1, 2)).toBe(6); + }); + + it('uses the visual line start column for wrapped lines', () => { + // 'abcdef' wrapped at width 3 → two visual rows of one logical line. + const buffer: ClickableBufferState = { + lines: ['abcdef'], + allVisualLines: ['abc', 'def'], + visualToLogicalMap: [ + [0, 0], + [0, 3], + ], + }; + // Row 1 ('def'), col 1 → startCol 3 + 1 = logical col 4 → offset 4. + expect(visualClickToOffset(buffer, 1, 1)).toBe(4); + }); + + it('maps wide (CJK) characters by display width', () => { + const buffer: ClickableBufferState = { + lines: ['abc', '日本'], + allVisualLines: ['abc', '日本'], + visualToLogicalMap: [ + [0, 0], + [1, 0], + ], + }; + // '日' is 2 cells wide. Col 0 → before '日' (logical col 0). Offset = 3+1+0 = 4. + expect(visualClickToOffset(buffer, 1, 0)).toBe(4); + // Col 2 → just past '日' → between '日' and '本' (logical col 1). Offset 5. + expect(visualClickToOffset(buffer, 1, 2)).toBe(5); + }); + + it('snaps to the right side when the right half of a wide char is clicked', () => { + const buffer: ClickableBufferState = { + lines: ['日本'], + allVisualLines: ['日本'], + visualToLogicalMap: [[0, 0]], + }; + // '日' occupies cells 0–1. Clicking its left cell (col 0) lands before it + // (logical col 0 → offset 0); clicking its right cell (col 1) lands after + // it (logical col 1 → offset 1). + expect(visualClickToOffset(buffer, 0, 0)).toBe(0); + expect(visualClickToOffset(buffer, 0, 1)).toBe(1); + // '本' occupies cells 2–3. Right cell (col 3) lands after it (offset 2). + expect(visualClickToOffset(buffer, 0, 2)).toBe(1); + expect(visualClickToOffset(buffer, 0, 3)).toBe(2); + }); + + it('keeps a combining mark attached to its base character', () => { + // 'e' + U+0301 (combining acute) renders as a single cell, followed by 'x'. + const decomposed = 'e\u0301x'; + const buffer: ClickableBufferState = { + lines: [decomposed], + allVisualLines: [decomposed], + visualToLogicalMap: [[0, 0]], + }; + // Col 0 → before the base 'e' (offset 0). + expect(visualClickToOffset(buffer, 0, 0)).toBe(0); + // Col 1 → the visible 'x'. The cursor must land after the full 'é' + // grapheme (code-point offset 2), not between 'e' and the accent. + expect(visualClickToOffset(buffer, 0, 1)).toBe(2); + // Col 2 → past 'x' → end of line (offset 3). + expect(visualClickToOffset(buffer, 0, 2)).toBe(3); + }); + + it('keeps a combining mark attached to a wide base character', () => { + // '日' (2 cells) + U+0301 (combining acute, 0 cells) renders as one + // grapheme, followed by 'x'. Clicking the right cell of '日' must snap + // past the full grapheme (code-point offset 2), not between '日' and its + // mark. This is the wide-base counterpart of the 'é' (single-width) case, + // which never exercises the snap-and-break branch. + const decomposed = '日́x'; + const buffer: ClickableBufferState = { + lines: [decomposed], + allVisualLines: [decomposed], + visualToLogicalMap: [[0, 0]], + }; + // Col 0 → left half of '日' → before it (offset 0). + expect(visualClickToOffset(buffer, 0, 0)).toBe(0); + // Col 1 → right half of '日' → after the full '日́' grapheme (offset 2), + // skipping the zero-width accent. + expect(visualClickToOffset(buffer, 0, 1)).toBe(2); + // Col 2 → the visible 'x' → after the grapheme (offset 2). + expect(visualClickToOffset(buffer, 0, 2)).toBe(2); + }); + + it('clicking past the end of the text lands at the line end', () => { + const buffer: ClickableBufferState = { + lines: ['hi'], + allVisualLines: ['hi'], + visualToLogicalMap: [[0, 0]], + }; + // Col 99 is well past 'hi' → clamp to end (logical col 2) → offset 2. + expect(visualClickToOffset(buffer, 0, 99)).toBe(2); + }); + + it('returns null for a visual row outside the map', () => { + const buffer: ClickableBufferState = { + lines: ['abc'], + allVisualLines: ['abc'], + visualToLogicalMap: [[0, 0]], + }; + expect(visualClickToOffset(buffer, 5, 0)).toBeNull(); + }); +}); diff --git a/packages/cli/src/ui/utils/input-mouse.ts b/packages/cli/src/ui/utils/input-mouse.ts new file mode 100644 index 0000000000..5cb2dbe14e --- /dev/null +++ b/packages/cli/src/ui/utils/input-mouse.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + * + * Pure helper for mapping a click inside the prompt input — a visual line + * (absolute index into the buffer's visual lines) and a visual column (terminal + * cells from the start of the text) — onto a logical cursor offset in the text + * buffer. The DOM measurement that produces the visual row/col lives in the + * component that owns the input box. + */ + +import { toCodePoints, cpLen, getCachedStringWidth } from './textUtils.js'; +import { logicalPosToOffset } from '../components/shared/text-buffer.js'; + +/** The slice of TextBuffer state this helper reads. */ +export interface ClickableBufferState { + /** All visual (wrapped) lines for the current text + width. */ + allVisualLines: string[]; + /** + * For each visual line, `[logicalLineIndex, startColInLogicalLine]` in code + * points — where that visual line begins within its logical line. + */ + visualToLogicalMap: Array<[number, number]>; + /** Logical lines (newline-split). */ + lines: string[]; +} + +/** + * Convert a click at `absoluteVisualRow` (index into allVisualLines) and + * `clickVisualCol` (terminal cells from the start of the text, with the prefix + * already excluded) into a logical cursor offset, or null if the row maps to no + * line. + * + * Walks code points accumulating display width so wide characters (CJK, emoji) + * map correctly, landing the cursor on the character boundary the click falls + * within. The resulting column is clamped to the logical line length. + */ +export function visualClickToOffset( + buffer: ClickableBufferState, + absoluteVisualRow: number, + clickVisualCol: number, +): number | null { + const mapping = buffer.visualToLogicalMap[absoluteVisualRow]; + if (!mapping) return null; + const [logicalLineIndex, startColInLogical] = mapping; + + const visualLineText = buffer.allVisualLines[absoluteVisualRow] ?? ''; + const chars = toCodePoints(visualLineText); + + let accumulatedWidth = 0; + let codePointIndex = 0; + for (let i = 0; i < chars.length; i++) { + const charWidth = getCachedStringWidth(chars[i]!); + if (charWidth <= 0) { + // Zero-width code points (combining marks, ZWJ) occupy no terminal cell + // and stay attached to the preceding glyph. Advance past them without + // consuming a column, so a click lands after the full grapheme rather + // than between a base character and its combining mark. + codePointIndex = i + 1; + continue; + } + if (accumulatedWidth + charWidth > clickVisualCol) { + // The click falls within this character's cells. For wide glyphs (CJK, + // emoji) snap to whichever side of the midpoint the click lands on, so + // clicking the right half places the cursor after the character. The + // midpoint is `ceil(charWidth / 2)` cells in: a 1-cell character always + // resolves to its left boundary (cell offset 0 < 1), while the right + // cell of a 2-cell character resolves to the right boundary (offset + // 1 >= 1). + const offsetWithinChar = clickVisualCol - accumulatedWidth; + if (offsetWithinChar >= Math.ceil(charWidth / 2)) { + // Snapped past this glyph — also step over any following zero-width + // marks (combining accents, ZWJ) so the cursor lands after the full + // grapheme rather than between the base char and its mark. + codePointIndex = i + 1; + while ( + codePointIndex < chars.length && + getCachedStringWidth(chars[codePointIndex]!) <= 0 + ) { + codePointIndex++; + } + } else { + codePointIndex = i; + } + break; + } + accumulatedWidth += charWidth; + codePointIndex = i + 1; + } + + const logicalCol = startColInLogical + codePointIndex; + const lineLength = cpLen(buffer.lines[logicalLineIndex] ?? ''); + return logicalPosToOffset( + buffer.lines, + logicalLineIndex, + Math.min(logicalCol, lineLength), + ); +} diff --git a/packages/cli/src/ui/utils/list-mouse.test.ts b/packages/cli/src/ui/utils/list-mouse.test.ts new file mode 100644 index 0000000000..bc63488ae2 --- /dev/null +++ b/packages/cli/src/ui/utils/list-mouse.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + frameAnchor, + terminalRowToLayoutRow, + findItemAtLayoutRow, + type VisibleItemRect, +} from './list-mouse.js'; + +describe('frameAnchor', () => { + it('is 0 when the frame exactly fills the terminal', () => { + expect(frameAnchor(40, 40)).toBe(0); + }); + + it('is NEGATIVE when the frame overflows the screen (top rows scrolled off)', () => { + // Frame 4 rows taller than the screen → top 4 layout rows are above the + // viewport, so the anchor is -4 (NOT clamped to 0 — that was the bug). + expect(frameAnchor(40, 44)).toBe(-4); + }); + + it('is 0 when the frame is shorter than the terminal (top-anchored, not bottom)', () => { + expect(frameAnchor(40, 12)).toBe(0); + }); +}); + +describe('terminalRowToLayoutRow', () => { + it('maps directly when the anchor is 0', () => { + expect(terminalRowToLayoutRow(1, 0)).toBe(0); + expect(terminalRowToLayoutRow(10, 0)).toBe(9); + }); + + it('adds the overflow back for a negative anchor', () => { + // anchor -4: terminal row 5 (1-based) maps to layout row 4 + 4 = 8. + expect(terminalRowToLayoutRow(5, -4)).toBe(8); + }); + + it('subtracts a positive anchor (short frame)', () => { + expect(terminalRowToLayoutRow(30, 28)).toBe(1); + }); +}); + +describe('findItemAtLayoutRow', () => { + // Item 1 is multi-line (height 2) — exercises the non-uniform-height path + // that justifies measuring each item instead of dividing by a row height. + const rects: VisibleItemRect[] = [ + { index: 0, top: 0, height: 1 }, + { index: 1, top: 1, height: 2 }, + { index: 2, top: 3, height: 1 }, + ]; + + it('finds a single-row item', () => { + expect(findItemAtLayoutRow(rects, 0)).toBe(0); + }); + + it('finds either row of a multi-row item', () => { + expect(findItemAtLayoutRow(rects, 1)).toBe(1); + expect(findItemAtLayoutRow(rects, 2)).toBe(1); + }); + + it('finds the item after a multi-row item', () => { + expect(findItemAtLayoutRow(rects, 3)).toBe(2); + }); + + it('returns null above and below the list', () => { + expect(findItemAtLayoutRow(rects, -1)).toBeNull(); + expect(findItemAtLayoutRow(rects, 4)).toBeNull(); + }); + + it('returns null for a gap row not covered by any rect', () => { + const gapped: VisibleItemRect[] = [ + { index: 0, top: 0, height: 1 }, + { index: 1, top: 2, height: 1 }, // row 1 is an itemGap + ]; + expect(findItemAtLayoutRow(gapped, 1)).toBeNull(); + }); + + it('maps a 1-based terminal row via the alternate-screen convention', () => { + // In alternate-screen mode the click's layout row is event.row - 1. + // Terminal row 4 → layout row 3 → item 2. + expect(findItemAtLayoutRow(rects, 4 - 1)).toBe(2); + }); +}); diff --git a/packages/cli/src/ui/utils/list-mouse.ts b/packages/cli/src/ui/utils/list-mouse.ts new file mode 100644 index 0000000000..eb4137b0f3 --- /dev/null +++ b/packages/cli/src/ui/utils/list-mouse.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + * + * Pure geometry helper for mapping a terminal mouse row onto a vertical list of + * items. The DOM measurement (reading yoga layout via measureElementPosition) + * lives in the component that owns the refs; this is pure arithmetic so it can + * be unit-tested without a renderer. + */ + +/** + * The 0-based terminal row of the layout's top edge. + * + * In alternate-screen mode the frame renders from the top of the screen and, if + * it overflows, scrolls so its BOTTOM is pinned to the terminal bottom: + * - fits (`frameHeight <= terminalHeight`): top-anchored → top at row 0 → anchor 0. + * - overflows (`frameHeight > terminalHeight`): bottom-anchored → the top rows + * scroll off → anchor `terminalHeight - frameHeight` (NEGATIVE). + * + * So the anchor is `min(0, terminalHeight - frameHeight)` — never positive. The + * negative value must NOT be clamped to 0 (that was the original bug); the + * positive value must be (a shorter-than-screen frame is top-anchored, not + * bottom-anchored). + */ +export function frameAnchor( + terminalHeight: number, + frameHeight: number, +): number { + return Math.min(0, terminalHeight - frameHeight); +} + +/** + * Convert a 1-based terminal mouse row into a 0-based layout row (directly + * comparable to a measured element's `y`), via the frame anchor. + */ +export function terminalRowToLayoutRow( + terminalRow1Based: number, + anchor: number, +): number { + return terminalRow1Based - 1 - anchor; +} + +/** A visible list item's layout-space vertical span (rows). */ +export interface VisibleItemRect { + /** Index into the full items array (not the visible slice). */ + index: number; + /** Top row of the item, in the same 0-based space as the click row. */ + top: number; + /** Item height in rows (>= 1; multi-line items span several rows). */ + height: number; +} + +/** + * Find the item whose row span contains `layoutRow`, or null if the row falls + * in no item (scroll arrows, gaps, or outside the list). Iterates measured + * rects so multi-line items and inter-item gaps are handled without assuming a + * uniform row height. + */ +export function findItemAtLayoutRow( + rects: VisibleItemRect[], + layoutRow: number, +): number | null { + for (const rect of rects) { + if (layoutRow >= rect.top && layoutRow < rect.top + rect.height) { + return rect.index; + } + } + return null; +} diff --git a/packages/cli/src/ui/utils/measure-element-position.test.tsx b/packages/cli/src/ui/utils/measure-element-position.test.tsx index 735a0cd06d..8949068f77 100644 --- a/packages/cli/src/ui/utils/measure-element-position.test.tsx +++ b/packages/cli/src/ui/utils/measure-element-position.test.tsx @@ -7,7 +7,10 @@ import { useEffect, useRef } from 'react'; import { describe, it, expect } from 'vitest'; import { render, Box, Text, type DOMElement } from 'ink'; -import { measureElementPosition } from './measure-element-position.js'; +import { + measureElementPosition, + layoutRowForEvent, +} from './measure-element-position.js'; function createTestStdout() { const stdout = Object.create(process.stdout, { @@ -130,3 +133,26 @@ describe('measureElementPosition', () => { expect(result).toEqual({ x: 0, y: 0, width: 0, height: 0 }); }); }); + +describe('layoutRowForEvent', () => { + // A root node whose frame is `frameHeight` rows tall. + const rootNode = (frameHeight: number): DOMElement => + ({ + yogaNode: { getComputedHeight: () => frameHeight }, + parentNode: undefined, + }) as unknown as DOMElement; + + it('maps a 1-based terminal row to a 0-based layout row when the frame fits', () => { + // Frame fits the terminal → anchor 0 → layout row = terminalRow - 1. + const node = rootNode(40); + expect(layoutRowForEvent(node, 1, 40)).toBe(0); + expect(layoutRowForEvent(node, 6, 40)).toBe(5); + }); + + it('applies the negative anchor correction when the frame overflows', () => { + // Frame 12 rows, terminal 8 → anchor -4 → +4-row correction. + const node = rootNode(12); + // terminalRow 7 → 7 - 1 - (-4) = 10. + expect(layoutRowForEvent(node, 7, 8)).toBe(10); + }); +}); diff --git a/packages/cli/src/ui/utils/measure-element-position.ts b/packages/cli/src/ui/utils/measure-element-position.ts index 30a014a142..8082a01cba 100644 --- a/packages/cli/src/ui/utils/measure-element-position.ts +++ b/packages/cli/src/ui/utils/measure-element-position.ts @@ -10,6 +10,7 @@ */ import { type DOMElement } from 'ink'; +import { frameAnchor, terminalRowToLayoutRow } from './list-mouse.js'; export interface ElementMetrics { /** Horizontal position (0-based column) within the live layout region. */ @@ -60,3 +61,45 @@ export function measureElementPosition(node: DOMElement): ElementMetrics { height: yogaNode.getComputedHeight(), }; } + +/** + * Height (in rows) of the Ink live frame — the computed height of the root of + * the yoga tree that `node` belongs to. + * + * In alternate-screen mode the frame is bottom-anchored to the terminal, so the + * frame top sits at `terminalHeight - frameHeight` (see utils/list-mouse.ts + * `frameAnchor`). When the frame is TALLER than the terminal that value is + * negative — the top rows are scrolled off the top edge — which is exactly the + * correction needed to map mouse rows back onto layout rows. + * + * Like {@link measureElementPosition}, must be called from post-render code and + * returns 0 during render. + */ +export function measureFrameHeight(node: DOMElement): number { + let root: DOMElement = node; + let current: DOMElement | undefined = node; + while (current) { + root = current; + current = current.parentNode; + } + return root.yogaNode?.getComputedHeight() ?? 0; +} + +/** + * Map a 1-based terminal mouse row onto the 0-based layout row of `node`'s + * frame — i.e. a row directly comparable to a measured element's `y`. Combines + * the frame-anchor correction ({@link frameAnchor} over {@link measureFrameHeight}) + * with {@link terminalRowToLayoutRow}. + * + * Single-sources the anchor→layout-row mapping shared by RowMouseController and + * TextInputMouseController, so the (previously off-by-one) correction can't + * drift between the two. Must be called from post-render code. + */ +export function layoutRowForEvent( + node: DOMElement, + terminalRow1Based: number, + terminalHeight: number, +): number { + const anchor = frameAnchor(terminalHeight, measureFrameHeight(node)); + return terminalRowToLayoutRow(terminalRow1Based, anchor); +} diff --git a/packages/cli/src/ui/utils/mouse.test.ts b/packages/cli/src/ui/utils/mouse.test.ts index 7df3c0226a..615cb4fd8e 100644 --- a/packages/cli/src/ui/utils/mouse.test.ts +++ b/packages/cli/src/ui/utils/mouse.test.ts @@ -10,6 +10,8 @@ import { parseX11MouseEvent, parseMouseEvent, isIncompleteMouseSequence, + enableMouseEvents, + disableMouseEvents, } from './mouse.js'; const ESC = '\x1b'; @@ -136,3 +138,31 @@ describe('isIncompleteMouseSequence', () => { expect(isIncompleteMouseSequence(longGarbage)).toBe(false); }); }); + +describe('enableMouseEvents / disableMouseEvents', () => { + function captureWrites() { + const writes: string[] = []; + const stdout = { + write: (s: string) => writes.push(s), + } as unknown as NodeJS.WriteStream; + return { writes, stdout }; + } + + it('defaults to button-event tracking (?1002h)', () => { + const { writes, stdout } = captureWrites(); + enableMouseEvents(stdout); + expect(writes).toEqual([`${ESC}[?1002h${ESC}[?1006h`]); + writes.length = 0; + disableMouseEvents(stdout); + expect(writes).toEqual([`${ESC}[?1006l${ESC}[?1002l`]); + }); + + it('uses any-event tracking (?1003h) for hover', () => { + const { writes, stdout } = captureWrites(); + enableMouseEvents(stdout, 'any'); + expect(writes).toEqual([`${ESC}[?1003h${ESC}[?1006h`]); + writes.length = 0; + disableMouseEvents(stdout, 'any'); + expect(writes).toEqual([`${ESC}[?1006l${ESC}[?1003l`]); + }); +}); diff --git a/packages/cli/src/ui/utils/mouse.ts b/packages/cli/src/ui/utils/mouse.ts index 3039561798..cb298b7292 100644 --- a/packages/cli/src/ui/utils/mouse.ts +++ b/packages/cli/src/ui/utils/mouse.ts @@ -18,6 +18,15 @@ const ESC = '\x1b'; export const SGR_EVENT_PREFIX = `${ESC}[<`; export const X11_EVENT_PREFIX = `${ESC}[M`; +/** + * Upper bound on an SGR mouse sequence's length while still incomplete. SGR + * sequences (`\x1b[ = { + button: '\x1b[?1002h\x1b[?1006h', + any: '\x1b[?1003h\x1b[?1006h', +}; +const DISABLE_SGR_MOUSE: Record = { + button: '\x1b[?1006l\x1b[?1002l', + any: '\x1b[?1006l\x1b[?1003l', +}; + +export function enableMouseEvents( + stdout: NodeJS.WriteStream, + tracking: MouseTracking = 'button', +): void { + stdout.write(ENABLE_SGR_MOUSE[tracking]); } -export function disableMouseEvents(stdout: NodeJS.WriteStream): void { - stdout.write(DISABLE_SGR_MOUSE); +export function disableMouseEvents( + stdout: NodeJS.WriteStream, + tracking: MouseTracking = 'button', +): void { + stdout.write(DISABLE_SGR_MOUSE[tracking]); } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 2ae943ad46..27ec365fcf 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -372,7 +372,7 @@ "default": false }, "useTerminalBuffer": { - "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.", + "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.", "type": "boolean", "default": false },