From 4746bc1667e09754d322e979906c2b6aa3b1faec Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:52:12 +0800 Subject: [PATCH] feat(ui): add mouse click & hover in alternate-screen mode (#6011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add mouse click & hover in alternate-screen mode Enable mouse interactions when Virtualized History (ui.useTerminalBuffer) is on — it comes along with VP mode, mirroring the existing mouse-wheel support; there is no separate setting: - select menus / dialogs (permission prompts, /model, /config, theme, …): hover highlights the row under the pointer, click selects it - / and @ suggestion lists: hover highlights, click accepts - prompt input: left-click positions the text cursor Adds a 'button'/'any' SGR tracking-level split to useMouseEvents (hover needs ?1003h any-event tracking; input click uses the cheaper ?1002h), a shared RowMouseController (menus + suggestions) and TextInputMouseController (prompt), plus pure, unit-tested coordinate helpers (list-mouse, input-mouse). Terminal mouse rows map to layout rows via `min(0, terminalHeight - frameHeight)` so alternate-screen overflow (content taller than the screen, top rows scrolled off) is corrected while a shorter, top-anchored frame stays at anchor 0. Inline mode is unsupported (its live region floats in native scrollback). Keyboard navigation is unchanged. * fix(ui): address review feedback on mouse interactions - Route suggestion-list hover/select to the same completion source that builds suggestionDisplayProps. Previously the mouse handlers were hardwired to the normal completion controller, so in reverse-search / command-search mode hovering updated hidden state and clicking could no-op or accept the wrong suggestion. Selecting in search mode now also resets that controller and exits search mode, mirroring keyboard acceptance. Export completion has no index-based handler, so mouse selection is disabled while it is shown. - Suppress SGR mouse parsing while a bracketed paste is in progress in KeypressContext. Pasted content containing `\x1b[<...M/m` was reconstructed and dispatched as a real click, which (now that selection lists and the prompt subscribe to mouse events) could let a pasted payload select a dialog option or move the cursor. Those bytes now fall through to the paste buffer. Adds a regression test with bracketed-paste content carrying an SGR press. - Mount TextInputMouseController whenever mouse input is active rather than only when the buffer is non-empty, so clicking an empty prompt works and enable/disable escape sequences aren't churned on every empty<->filled toggle. handleMouse already guards null lines and zero-height rects. - Expose setActiveSuggestionIndex from useReverseSearchCompletion to support hover targeting the active search source. * docs(ui): correct RowMouseController frame-anchor comment The header comment claimed the layout row is just event.row - 1 with no frame anchor needed (mirroring VirtualizedList.hitTestScrollbar), but the code routes through frameAnchor/terminalRowToLayoutRow, which subtracts a negative anchor when the frame overflows the terminal. Correct the comment to describe the anchor and why hit-testing needs the correction that the scrollbar track does not. * fix(ui): give bracketed paste priority over half-built SGR mouse fragment When an SGR mouse fragment is mid-reassembly (e.g. a mouse-move \x1b[<… arrives without its terminating M) and a bracketed paste begins, the paste-start event was swallowed into the SGR buffer instead of setting isPaste. That left isPaste false so an SGR left-press embedded in the pasted content was reconstructed into a real click (e.g. selecting a dialog option). Discard the half-built fragment on paste-start and fall through to the paste handler. Adds a regression test. * fix(ui): align mouse suggestion-accept with keyboard and snap wide-char clicks Address review feedback on the TUI mouse PR: - Mouse clicks on a completion suggestion now mirror the keyboard accept path: reset the expanded-suggestion view and export navigation ref, dismiss @folder completions, and honor `submitOnAccept` so clicking a leaf command (e.g. `/skills`) submits it in one click, matching Enter. - `visualClickToOffset` snaps to a wide (CJK/emoji) character's right boundary when its right half is clicked, instead of always snapping to the left edge; narrow characters still resolve to the left boundary. Adds unit coverage for the wide-char snapping and a focused test for the default-source suggestion hover/select routing and submit-on-click. * refactor(ui): extract resetSgrMouse helper and cover @folder mouse dismissal Address PR review feedback on the TUI mouse work: - KeypressContext: hoist the duplicated SGR-mouse reset block (swallow flag + buffer + timeout) into a single resetSgrMouse() helper, used by the bracketed-paste, ctrl+c, paste-start, and teardown paths. No behavior change. - InputPrompt.suggestionMouse.test: add coverage for clicking an @folder suggestion in @-mention mode, asserting the completion is dismissed so the dropdown stays closed. * fix(ui): keep combining marks attached when mapping a click to a cursor offset visualClickToOffset forced a minimum width of 1 per code point, so a zero-width combining mark (e.g. 'e' + U+0301) consumed a phantom column. Clicking the glyph after a decomposed grapheme landed the cursor between the base character and its combining mark instead of after the grapheme. Zero-width code points are now skipped without consuming a column, matching how the terminal renders them. Adds a regression test on 'e\u0301x'. * fix(ui): keep combining marks attached when snapping past a wide char When a click lands on the right half of a wide base character (e.g. a CJK glyph) that is followed by a zero-width combining mark, the cursor was placed between the base char and its mark. Step over following zero-width code points after snapping past the glyph so the cursor lands after the full grapheme. * fix(ui): cap SGR mouse reassembly buffer and simplify reset on paste Bound the SGR mouse reassembly buffer to the same 50-byte limit used by isIncompleteMouseSequence (now a shared MAX_SGR_MOUSE_SEQUENCE_LENGTH constant) so a malformed \x1b[< without a terminator no longer swallows keystrokes until the timeout fires. Also drop the redundant swallowingSgrMouse guard before the idempotent resetSgrMouse() call in the paste branch, matching the other call sites. * refactor(ui): extract layoutRowForEvent for mouse row mapping The frameAnchor(measureFrameHeight(node)) + terminalRowToLayoutRow(event.row) pair was duplicated in RowMouseController and TextInputMouseController. Extract a single layoutRowForEvent helper so the anchor->layout-row correction is single-sourced and can't drift between the two controllers. * test(ui): cover command-search mouse hover and click routing Adds coverage for the reverse/command-search branch of handleSuggestionHover and handleSuggestionSelect: a click while command search is active accepts via the search completion, resets it, and exits search mode (rather than leaving the UI stuck in search), and hover routes to the search source instead of the default completion. --------- Co-authored-by: Claude --- packages/cli/src/config/settingsSchema.ts | 2 +- .../cli/src/ui/components/BaseTextInput.tsx | 14 +- .../InputPrompt.suggestionMouse.test.tsx | 319 ++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 93 +++++ .../SuggestionsDisplay.mouse.test.tsx | 86 +++++ .../src/ui/components/SuggestionsDisplay.tsx | 35 +- .../shared/BaseSelectionList.mouse.test.tsx | 62 ++++ .../shared/BaseSelectionList.test.tsx | 3 + .../components/shared/BaseSelectionList.tsx | 40 ++- .../shared/RowMouseController.test.tsx | 167 +++++++++ .../components/shared/RowMouseController.tsx | 127 +++++++ .../shared/TextInputMouseController.test.tsx | 161 +++++++++ .../shared/TextInputMouseController.tsx | 83 +++++ .../src/ui/contexts/KeypressContext.test.tsx | 111 ++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 58 +++- .../cli/src/ui/hooks/useMouseEvents.test.tsx | 70 ++++ packages/cli/src/ui/hooks/useMouseEvents.ts | 97 ++++-- .../ui/hooks/useReverseSearchCompletion.tsx | 2 + .../cli/src/ui/hooks/useSelectionList.test.ts | 40 +++ packages/cli/src/ui/hooks/useSelectionList.ts | 15 + packages/cli/src/ui/utils/input-mouse.test.ts | 143 ++++++++ packages/cli/src/ui/utils/input-mouse.ts | 99 ++++++ packages/cli/src/ui/utils/list-mouse.test.ts | 87 +++++ packages/cli/src/ui/utils/list-mouse.ts | 70 ++++ .../utils/measure-element-position.test.tsx | 28 +- .../src/ui/utils/measure-element-position.ts | 43 +++ packages/cli/src/ui/utils/mouse.test.ts | 30 ++ packages/cli/src/ui/utils/mouse.ts | 60 +++- .../schemas/settings.schema.json | 2 +- 29 files changed, 2082 insertions(+), 65 deletions(-) create mode 100644 packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx create mode 100644 packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx create mode 100644 packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx create mode 100644 packages/cli/src/ui/components/shared/RowMouseController.test.tsx create mode 100644 packages/cli/src/ui/components/shared/RowMouseController.tsx create mode 100644 packages/cli/src/ui/components/shared/TextInputMouseController.test.tsx create mode 100644 packages/cli/src/ui/components/shared/TextInputMouseController.tsx create mode 100644 packages/cli/src/ui/utils/input-mouse.test.ts create mode 100644 packages/cli/src/ui/utils/input-mouse.ts create mode 100644 packages/cli/src/ui/utils/list-mouse.test.ts create mode 100644 packages/cli/src/ui/utils/list-mouse.ts 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 },