Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,7 +1011,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.',
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
showInDialog: true,
},
shellOutputMaxLines: {
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/ui/components/BaseTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ──────────────────────────────────
Expand Down Expand Up @@ -197,6 +200,7 @@ export const BaseTextInput = ({
topRightLabel,
isActive = true,
renderLine = defaultRenderLine,
mouseEnabled = false,
}: BaseTextInputProps): ReactNode => {
// ── Keyboard handling ──

Expand Down Expand Up @@ -313,6 +317,7 @@ export const BaseTextInput = ({

// ── Physical cursor positioning for IME ──
const boxRef = useRef<DOMElement | null>(null);
const linesRef = useRef<DOMElement | null>(null);
const { hasMeasured } = useBoxMetrics(boxRef);
const { setCursorPosition } = useCursor();
const cursorPosition = getPhysicalCursorPosition(boxRef.current, {
Expand Down Expand Up @@ -346,6 +351,13 @@ export const BaseTextInput = ({

return (
<Box ref={boxRef} flexDirection="column">
{mouseEnabled && isActive && (
<TextInputMouseController
linesRef={linesRef}
buffer={buffer}
visibleLineCount={linesToRender.length}
/>
)}
<Text color={resolvedBorderColor} wrap="truncate-end">
{topBorderLine}
</Text>
Expand All @@ -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. */}
<Box flexGrow={1} flexDirection="column">
<Box flexGrow={1} flexDirection="column" ref={linesRef}>
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const uiState = useUIState();
const uiActions = useUIActions();
const settings = useSettings();
// Mouse interactions (suggestion list + click-to-position cursor) are enabled
// in alternate-screen mode (see RowMouseController's coordinate assumptions).
const mouseInteractionsEnabled = !!settings.merged.ui?.useTerminalBuffer;
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
Expand Down Expand Up @@ -1877,6 +1880,58 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
? activeCompletion.showSuggestions
: false);

// Mouse hover/select must target the SAME completion source that builds
// suggestionDisplayProps. For reverse/command search, selecting also resets
// that controller and exits search mode, mirroring keyboard acceptance.
// Export completion has no index-based handler, so mouse selection is left
// disabled for it (handlers are undefined when its suggestions are shown).
const suggestionsFromExport =
shouldUseExportSuggestions && !!exportCompletion.suggestionDisplayProps;
const handleSuggestionHover = useCallback(
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
(index: number) => {
if (commandSearchActive) {
commandSearchCompletion.setActiveSuggestionIndex(index);
} else if (reverseSearchActive) {
reverseSearchCompletion.setActiveSuggestionIndex(index);
} else {
completion.setActiveSuggestionIndex(index);
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
}
},
[
commandSearchActive,
reverseSearchActive,
commandSearchCompletion,
reverseSearchCompletion,
completion,
],
);
const handleSuggestionSelect = useCallback(
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
(index: number) => {
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
if (commandSearchActive || reverseSearchActive) {
const isCommandSearch = commandSearchActive;
const sc = isCommandSearch
? commandSearchCompletion
: reverseSearchCompletion;
sc.handleAutocomplete(index);
sc.resetCompletionState();
(isCommandSearch ? setCommandSearchActive : setReverseSearchActive)(
false,
);
} else {
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
completion.handleAutocomplete(index);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium / maintainability] handleSuggestionSelect re-implements the keyboard accept path. handleAutocomplete + navigatedRef reset + setExpandedSuggestionIndex(-1) + the @folder dismissCompletion gate + the submitOnAccept submit are copied inline instead of sharing acceptActiveCompletionSuggestion / the ACCEPT_SUGGESTION logic. Click-accept and Enter-accept will drift (the comment itself says it is 'mirroring'). Extract one acceptSuggestion(index) helper used by both paths.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point on the drift risk — the click path does re-do handleAutocomplete + navigatedRef = false + setExpandedSuggestionIndex(-1) + the @folder dismissCompletion gate + submitOnAccept that the keyboard path also performs.

I want to be careful unifying these because the two paths aren't identical, and a naive acceptSuggestion(index) helper would have to reconcile real differences:

  • Index source: keyboard uses activeSuggestionIndex with the -1 → 0 fallback; the click passes an explicit index.
  • @folder dismissal: the keyboard branch gates on key.name === 'return' (Enter only) before dismissing, whereas a click is always an explicit accept — so the helper needs a "treat as Enter-equivalent" flag rather than a key.
  • submitOnAccept: keyboard honors it only on Enter (not Tab); a click always honors it. Same flag concern.

So the right shape is probably acceptSuggestion(index, { submit }) shared by both, with the callers passing the submit/dismiss intent. That's a worthwhile cleanup but it touches the hot keyboard accept path, so I'd rather do it deliberately (with the keyboard accept tests green) than fold it into this PR's mouse work. Could you confirm you'd like that helper extracted here vs. a focused follow-up? Leaving open pending your call.


Generated by Claude Code

}
},
[
commandSearchActive,
reverseSearchActive,
commandSearchCompletion,
reverseSearchCompletion,
completion,
setCommandSearchActive,
setReverseSearchActive,
],
);

// Whether any input-side handler would consume a Tab keystroke. AppContainer
// feeds this into useAutoAcceptIndicator's `shouldBlockTab` so the
// Windows-only "bare Tab cycles approval mode" fallback doesn't double-fire
Expand Down Expand Up @@ -2025,6 +2080,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
topRightLabel={voiceStatusLabel ?? uiState.sessionName ?? undefined}
isActive={!isEmbeddedShellFocused}
renderLine={renderLineWithHighlighting}
mouseEnabled={mouseInteractionsEnabled}
/>
{shouldShowSuggestions && (
<Box marginLeft={2} marginRight={2}>
Expand All @@ -2043,6 +2099,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
: 'reverse'
}
expandedIndex={expandedSuggestionIndex}
mouseEnabled={mouseInteractionsEnabled}
onHoverIndex={
suggestionsFromExport ? undefined : handleSuggestionHover
}
onSelectIndex={
suggestionsFromExport ? undefined : handleSuggestionSelect
}
/>
</Box>
)}
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/ui/components/SuggestionsDisplay.mouse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

/** @vitest-environment jsdom */

import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SuggestionsDisplay, type Suggestion } from './SuggestionsDisplay.js';
import { RowMouseController } from './shared/RowMouseController.js';

vi.mock('./shared/RowMouseController.js', () => ({
RowMouseController: vi.fn(() => null),
}));

const suggestions: Suggestion[] = [
{ label: 'help', value: 'help' },
{ label: 'clear', value: 'clear' },
{ label: 'model', value: 'model' },
];

describe('SuggestionsDisplay mouse wiring', () => {
beforeEach(() => vi.clearAllMocks());

it('mounts RowMouseController with the scroll offset + callbacks when enabled', () => {
const onHoverIndex = vi.fn();
const onSelectIndex = vi.fn();
render(
<SuggestionsDisplay
suggestions={suggestions}
activeIndex={0}
isLoading={false}
width={60}
scrollOffset={2}
userInput=""
mode="slash"
mouseEnabled
onHoverIndex={onHoverIndex}
onSelectIndex={onSelectIndex}
/>,
);
expect(RowMouseController).toHaveBeenCalled();
const props = vi.mocked(RowMouseController).mock.calls[0][0];
// scrollOffset is the index of the first visible suggestion (startIndex),
// so RowMouseController maps visible position → original suggestion index.
expect(props.scrollOffset).toBe(2);
expect(props.onHoverIndex).toBe(onHoverIndex);
expect(props.onSelectIndex).toBe(onSelectIndex);
});

it('does not mount RowMouseController when mouse is disabled', () => {
render(
<SuggestionsDisplay
suggestions={suggestions}
activeIndex={0}
isLoading={false}
width={60}
scrollOffset={0}
userInput=""
mode="slash"
mouseEnabled={false}
onHoverIndex={vi.fn()}
onSelectIndex={vi.fn()}
/>,
);
expect(RowMouseController).not.toHaveBeenCalled();
});

it('does not mount RowMouseController when the callbacks are absent', () => {
render(
<SuggestionsDisplay
suggestions={suggestions}
activeIndex={0}
isLoading={false}
width={60}
scrollOffset={0}
userInput=""
mode="slash"
mouseEnabled
/>,
);
expect(RowMouseController).not.toHaveBeenCalled();
});
});
35 changes: 32 additions & 3 deletions packages/cli/src/ui/components/SuggestionsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -82,7 +90,13 @@ export function SuggestionsDisplay({
userInput,
mode,
expandedIndex,
onHoverIndex,
onSelectIndex,
mouseEnabled,
}: SuggestionsDisplayProps) {
const containerRef = useRef<DOMElement | null>(null);
const itemRefs = useRef<Array<DOMElement | null>>([]);

if (isLoading) {
return (
<Box width={width}>
Expand Down Expand Up @@ -131,7 +145,16 @@ export function SuggestionsDisplay({
: 0;

return (
<Box flexDirection="column" width={width}>
<Box flexDirection="column" width={width} ref={containerRef}>
{mouseEnabled && onHoverIndex && onSelectIndex && (
<RowMouseController
containerRef={containerRef}
itemRefs={itemRefs}
scrollOffset={startIndex}
onHoverIndex={onHoverIndex}
onSelectIndex={onSelectIndex}
Comment thread
DragonnZhang marked this conversation as resolved.
Resolved
/>
)}
{scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>}

{visibleSuggestions.map((suggestion, index) => {
Expand All @@ -157,7 +180,13 @@ export function SuggestionsDisplay({
);

return (
<Box key={`${suggestion.value}-${originalIndex}`} flexDirection="row">
<Box
key={`${suggestion.value}-${originalIndex}`}
flexDirection="row"
ref={(node) => {
itemRefs.current[index] = node;
}}
>
<Box width={ACTIVE_MARKER_WIDTH} flexShrink={0}>
<Text color={textColor}>{isActive ? '> ' : ' '}</Text>
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { LoadedSettings } from '../../../config/settings.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';

// Integration smoke test: with ui.useTerminalBuffer on, BaseSelectionList
// mounts the real RowMouseController (which subscribes via the real
// useMouseEvents/KeypressProvider). This guards the end-to-end gate + mount
// path — that turning mouse input on doesn't throw or break rendering.
// Coordinate accuracy is exercised by RowMouseController.test.tsx (unit) and
// validated in a real terminal.
function settingsWithMouse(enabled: boolean): LoadedSettings {
// Mouse input is enabled by alternate-screen mode.
const ui = { ui: { useTerminalBuffer: enabled } };
return new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: ui, originalSettings: ui },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
);
}

describe('BaseSelectionList with mouse enabled (integration)', () => {
const items = [
{ label: 'Alpha', value: 'a', key: 'a' },
{ label: 'Beta', value: 'b', key: 'b' },
];

// `?1003h` = any-event tracking; the mouse layer enables it for hover.
const ENABLE_ANY = '[?1003h';

it('mounts the mouse layer (enables any-event tracking) and still renders items', () => {
const { frames } = renderWithProviders(
<RadioButtonSelect items={items} onSelect={() => {}} />,
{ settings: settingsWithMouse(true) },
);
// `frames` captures both rendered frames and the raw enable escape that
// useMouseEvents writes to the same stdout — assert across all of them.
const output = frames.join('\n');
expect(output).toContain('Alpha');
expect(output).toContain('Beta');
expect(output).toContain(ENABLE_ANY);
});

it('does not mount the mouse layer when ui.useTerminalBuffer is off', () => {
const { lastFrame, frames } = renderWithProviders(
<RadioButtonSelect items={items} onSelect={() => {}} />,
{ settings: settingsWithMouse(false) },
);
expect(lastFrame()).toContain('Alpha');
expect(lastFrame()).toContain('Beta');
expect(frames.join('\n')).not.toContain(ENABLE_ANY);
});
});
Loading
Loading