Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
230 changes: 230 additions & 0 deletions docs/design/vp-mouse-selection/design.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Settings are organized into categories. Most settings should be placed within th
| `ui.disableWorkflowKeywordTrigger` | boolean | When `true`, mentioning the word `workflow` in a prompt no longer softly steers the turn toward the Workflow tool (and the Footer `workflow active` indicator is suppressed). Only applies when workflows are enabled. | `false` |
| `ui.enableUserFeedback` | boolean | Show an optional feedback dialog after conversations to help improve Qwen performance. | `true` |
| `ui.compactInline` | boolean | Compact tool display within each group instead of merging across groups. Requires `ui.compactMode` to be enabled. Requires restart. | `false` |
| `ui.useTerminalBuffer` | boolean | 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. 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; hold `Shift` (or `Option` on macOS) while dragging for native text selection. | `false` |
| `ui.useTerminalBuffer` | boolean | 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. 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; drag to select text (double/triple click selects a word/line), or hold `Shift` (or `Option` on macOS) while dragging for the terminal's own selection. | `false` |
| `ui.hideBuiltinWorktreeIndicator` | boolean | Hide the built-in `⎇ worktree-<branch> (<slug>)` line in the Footer. The worktree state is still passed to custom statusline scripts via the stdin payload. Keep at the default unless your custom statusline renders the worktree itself. | `false` |

#### ide
Expand Down
2 changes: 1 addition & 1 deletion docs/users/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Active only when `ui.useTerminalBuffer` is enabled (Settings → UI → Virtuali
| `Ctrl+End` | Jump to the bottom (and re-engage live auto-follow). |
| **Mouse wheel** | Scroll history (3 lines per tick). |

When `ui.useTerminalBuffer` is on, the terminal forwards mouse events to qwen-code so the wheel can drive the in-app viewport. As a side effect, **native click-and-drag text selection is consumed by the program** — hold `Shift` (or `Option` on macOS Terminal / iTerm) while dragging to bypass mouse capture and select text the usual way.
When `ui.useTerminalBuffer` is on, the terminal forwards mouse events to qwen-code so the wheel can drive the in-app viewport. As a side effect, native click-and-drag text selection is consumed by the program, so qwen-code provides its own: **drag to select text in the history viewport, double-click to select a word, triple-click to select a line.** The selection is highlighted and copied to the clipboard when you release the mouse (works locally, over SSH via OSC 52, and inside tmux). A single click clears the selection; scrolling or new output clears it too. Selection is limited to the visible viewport for now. You can still fall back to the terminal's own selection by holding `Shift` (or `Option` on macOS Terminal / iTerm) while dragging.

### tmux trackpad scrolling

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,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. 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.',
'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. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging.',
showInDialog: true,
},
showScrollbar: {
Expand Down
67 changes: 67 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { LoadedSettings } from '../../config/settings.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { ThoughtExpandedProvider } from '../contexts/ThoughtExpandedContext.js';
import type { MouseEvent } from '../utils/mouse.js';
import {
layoutRowForEvent,
measureElementPosition,
} from '../utils/measure-element-position.js';

// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
Expand All @@ -28,6 +33,11 @@ vi.mock('../hooks/useMouseEvents.js', () => ({
useMouseEvents: vi.fn(),
}));

vi.mock('../utils/measure-element-position.js', () => ({
layoutRowForEvent: vi.fn(),
measureElementPosition: vi.fn(),
}));

import { useMouseEvents } from '../hooks/useMouseEvents.js';

import { toggleKeyHint } from './messages/ConversationMessages.js';
Expand Down Expand Up @@ -576,6 +586,42 @@ describe('<HistoryItemDisplay />', () => {
durationMs: 1200,
};

const mouseEvent = (name: MouseEvent['name'], col: number): MouseEvent => ({
name,
col,
row: 1,
shift: false,
meta: false,
ctrl: false,
button: 'left',
});

const renderThoughtWithToggle = (toggle: (headId: number) => void) => {
vi.mocked(measureElementPosition).mockReturnValue({
x: 0,
y: 0,
width: 80,
height: 3,
});
vi.mocked(layoutRowForEvent).mockImplementation((_node, row) => row - 1);
renderWithProviders(
<ThoughtExpandedProvider
value={{
allExpanded: false,
expandedHeadIds: new Set<number>(),
toggle,
}}
>
<HistoryItemDisplay
item={thoughtItem}
terminalWidth={100}
isPending={false}
/>
</ThoughtExpandedProvider>,
);
return vi.mocked(useMouseEvents).mock.calls.at(-1)?.[0];
};

it('subscribes the click handler without bypassVpGate (stays VP-gated)', () => {
vi.mocked(useMouseEvents).mockClear();
renderWithProviders(
Expand All @@ -592,5 +638,26 @@ describe('<HistoryItemDisplay />', () => {
expect(opts?.isActive).toBe(true);
expect(opts?.bypassVpGate ?? false).toBe(false);
});

it('toggles on a complete click', () => {
const toggle = vi.fn();
const handler = renderThoughtWithToggle(toggle);

handler?.(mouseEvent('left-press', 5));
expect(toggle).not.toHaveBeenCalled();
handler?.(mouseEvent('left-release', 5));
expect(toggle).toHaveBeenCalledWith(thoughtItem.id);
});

it('does not toggle when selecting text by dragging', () => {
const toggle = vi.fn();
const handler = renderThoughtWithToggle(toggle);

handler?.(mouseEvent('left-press', 5));
handler?.(mouseEvent('move', 20));
handler?.(mouseEvent('left-release', 20));

expect(toggle).not.toHaveBeenCalled();
});
});
});
31 changes: 27 additions & 4 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const ClickableThinkMessage: React.FC<{
onToggle,
}) => {
const ref = useRef<DOMElement>(null);
const pressRef = useRef<{ col: number; row: number } | null>(null);
const { rows: terminalHeight } = useTerminalSize();
const settings = useSettings();
const clickable = !!settings.merged.ui?.useTerminalBuffer;
Expand All @@ -129,16 +130,38 @@ const ClickableThinkMessage: React.FC<{
useMouseEvents(
useCallback(
(event: MouseEvent) => {
if (event.name !== 'left-press' || !ref.current) return;
if (!ref.current) return;
if (event.name === 'move') {
if (
pressRef.current &&
(event.col !== pressRef.current.col ||
event.row !== pressRef.current.row)
) {
pressRef.current = null;
}
return;
}
if (event.name !== 'left-press' && event.name !== 'left-release') {
pressRef.current = null;
return;
}
const metrics = measureElementPosition(ref.current);
const col = event.col - 1;
const row = layoutRowForEvent(ref.current, event.row, terminalHeight);
if (
const isInside =
col >= metrics.x &&
col < metrics.x + metrics.width &&
row >= metrics.y &&
row < metrics.y + metrics.height
) {
row < metrics.y + metrics.height;
if (event.name === 'left-press') {
pressRef.current = isInside
? { col: event.col, row: event.row }
: null;
return;
}
const press = pressRef.current;
pressRef.current = null;
if (isInside && press?.col === event.col && press.row === event.row) {
onToggle();
}
},
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/components/MainContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ vi.mock('./DebugModeNotification.js', () => ({
DebugModeNotification: () => <Text>DEBUG_NOTIFICATION</Text>,
}));

vi.mock('../selection/use-text-selection.js', () => ({
TextSelectionController: () => null,
}));

vi.mock('./shared/ScrollableList.js', async () => {
const actual = await vi.importActual<
typeof import('./shared/ScrollableList.js')
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import {
type MarkdownSourceCopyIndexOffsets,
} from '../utils/MarkdownDisplay.js';
import { buildThoughtHeadIdMap } from '../utils/historyUtils.js';
import { ScrollableList, SCROLL_TO_ITEM_END } from './shared/ScrollableList.js';
import {
ScrollableList,
SCROLL_TO_ITEM_END,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { TextSelectionController } from '../selection/use-text-selection.js';

// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
Expand Down Expand Up @@ -130,6 +135,7 @@ export const MainContent = () => {
// state still computes because it lives at the top of the component, but
// useMemo keeps it cheap when nothing changes.
const useVirtualScroll = uiState.useTerminalBuffer;
const scrollRef = useRef<ScrollableListRef<VpItem>>(null);

const { historyItemsWithSourceCopyOffsets, pendingStartSourceCopyOffsets } =
useMemo(() => {
Expand Down Expand Up @@ -409,6 +415,7 @@ export const MainContent = () => {
return (
<OverflowProvider>
<ScrollableList
ref={scrollRef}
hasFocus={!uiState.dialogsVisible}
data={allVirtualItems}
renderItem={renderVirtualItem}
Expand All @@ -421,6 +428,20 @@ export const MainContent = () => {
containerHeight={scrollContainerHeight}
showScrollbar={showScrollbar}
/>
<TextSelectionController
isActive={!uiState.dialogsVisible}
getViewportRect={() => scrollRef.current?.getViewportRect() ?? null}
getScrollState={() =>
scrollRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
}
}
hitTestScrollbar={(location) =>
scrollRef.current?.hitTestScrollbar(location) ?? false
}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</OverflowProvider>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/components/shared/ScrollableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ function ScrollableList<T>(
scrollHeight: 0,
innerHeight: 0,
},
getViewportRect: () =>
virtualizedListRef.current?.getViewportRect() ?? null,
}),
[],
);
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/ui/components/shared/VirtualizedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export type VirtualizedListRef<T> = {
scrollHeight: number;
innerHeight: number;
};
getViewportRect: () => {
x: number;
y: number;
width: number;
height: number;
} | null;
};

// Returns the smallest index i such that arr[i] > target. If every entry is
Expand Down Expand Up @@ -782,6 +788,8 @@ function VirtualizedList<T>(
innerHeight: scrollableContainerHeight,
};
},
getViewportRect: () =>
rootRef.current ? measureElementPosition(rootRef.current) : null,
}),
[
offsets,
Expand Down
103 changes: 103 additions & 0 deletions packages/cli/src/ui/selection/screen-buffer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, afterEach } from 'vitest';
import { Text } from 'ink';
import { render } from 'ink-testing-library';
import { getScreenBuffer } from './screen-buffer.js';

/** The background escape the patched renderer appends to selected cells. */
const SELECTION_BG = '';

type Rendered = ReturnType<typeof render>;

let current: Rendered | undefined;

afterEach(() => {
current?.unmount();
current = undefined;
});

describe('ScreenBuffer (Ink frame-controller M0)', () => {
it('exposes the composited frame as addressable cells', () => {
current = render(<Text>hello 中文</Text>);
const buffer = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
);

expect(buffer).toBeDefined();
expect(buffer!.dimensions.height).toBeGreaterThan(0);
expect(buffer!.lineText(0)).toBe('hello 中文');
expect(buffer!.getCellAt(0, 0)?.value).toBe('h');
});

it('handles wide characters with a leading cell and a spacer', () => {
current = render(<Text>hello 中文</Text>);
const buffer = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!;

// "hello " occupies columns 0-5; the first wide glyph starts at column 6.
const wide = buffer.getCellAt(6, 0);
expect(wide?.value).toBe('中');
expect(wide?.fullWidth).toBe(true);
// The trailing half of a wide glyph is an empty spacer cell.
expect(buffer.getCellAt(7, 0)?.value).toBe('');
});

it('highlights the selected range before serialization and clears it', () => {
current = render(<Text>hello world</Text>);
const buffer = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!;

expect(current.lastFrame()).not.toContain(SELECTION_BG);

buffer.setSelection({ sx: 0, sy: 0, ex: 4, ey: 0 });
expect(current.lastFrame()).toContain(SELECTION_BG);

buffer.setSelection(null);
expect(current.lastFrame()).not.toContain(SELECTION_BG);
});

it('does not leak the highlight onto identical text elsewhere on screen', () => {
// "abc" appears twice; selecting the first must not highlight the second.
current = render(<Text>abc{'\n'}abc</Text>);
const buffer = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!;

buffer.setSelection({ sx: 0, sy: 0, ex: 2, ey: 0 });
const frame = current.lastFrame() ?? '';
const [firstLine, secondLine] = frame.split('\n');

expect(firstLine).toContain(SELECTION_BG);
expect(secondLine).not.toContain(SELECTION_BG);
});

it('publishes exactly one frame per distinct selection change (no loop, deduped)', () => {
current = render(<Text>hello world</Text>);
const buffer = getScreenBuffer(
current.stdout as unknown as NodeJS.WriteStream,
)!;

let publishes = 0;
buffer.subscribe(() => {
publishes++;
});

buffer.setSelection({ sx: 0, sy: 0, ex: 2, ey: 0 });
expect(publishes).toBe(1);

// Identical selection is deduplicated: no extra render.
buffer.setSelection({ sx: 0, sy: 0, ex: 2, ey: 0 });
expect(publishes).toBe(1);

// A different selection renders once more.
buffer.setSelection({ sx: 0, sy: 0, ex: 4, ey: 0 });
expect(publishes).toBe(2);
});
});
Loading
Loading