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

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ 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.textSelection.enabled` | boolean | Select text with the mouse in the virtualized-history viewport (drag, double-click a word, triple-click a line). Only applies when `ui.useTerminalBuffer` is enabled. Disable to leave the mouse to the terminal. | `true` |
| `ui.textSelection.copyOnSelect` | boolean | Copy the selection to the clipboard automatically when the mouse is released. | `true` |
| `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, by default, 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. Toggle this under Settings → UI (`ui.textSelection.enabled` and `ui.textSelection.copyOnSelect`). 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, or by disabling `ui.textSelection.enabled`.

### tmux trackpad scrolling

Expand Down
34 changes: 33 additions & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,9 +1035,41 @@ 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; see ui.textSelection. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging.',
showInDialog: true,
},
textSelection: {
type: 'object',
label: 'Mouse Text Selection (Virtualized History)',
category: 'UI',
requiresRestart: false,
default: {},
description:
'Mouse text selection in the virtualized-history viewport: drag to select, double/triple click to select a word/line, and copy. Only applies when Virtualized History is enabled.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Mouse Text Selection',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Select text with the mouse in the virtualized-history viewport. Disable to leave the mouse to the terminal.',
Comment thread
chiga0 marked this conversation as resolved.
Resolved
showInDialog: true,
},
copyOnSelect: {
type: 'boolean',
label: 'Copy on Select',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Copy the selection to the clipboard automatically when the mouse is released.',
showInDialog: true,
},
},
},
showScrollbar: {
type: 'boolean',
label: 'Show Scrollbar (Virtualized History)',
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
40 changes: 38 additions & 2 deletions packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@
*/

import { Box, Static } from 'ink';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { isHistoryItemVisibleAfterRestore } from '../types.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { Notifications } from './Notifications.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { DebugModeNotification } from './DebugModeNotification.js';
Expand All @@ -21,7 +30,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 +144,12 @@ 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 settings = useContext(SettingsContext);
const textSelectionEnabled =
settings?.merged.ui?.textSelection?.enabled ?? true;
const textSelectionCopyOnSelect =
settings?.merged.ui?.textSelection?.copyOnSelect ?? true;

const { historyItemsWithSourceCopyOffsets, pendingStartSourceCopyOffsets } =
useMemo(() => {
Expand Down Expand Up @@ -409,6 +429,7 @@ export const MainContent = () => {
return (
<OverflowProvider>
<ScrollableList
ref={scrollRef}
hasFocus={!uiState.dialogsVisible}
data={allVirtualItems}
renderItem={renderVirtualItem}
Expand All @@ -421,6 +442,21 @@ export const MainContent = () => {
containerHeight={scrollContainerHeight}
showScrollbar={showScrollbar}
/>
<TextSelectionController
isActive={!uiState.dialogsVisible && textSelectionEnabled}
copyOnSelect={textSelectionCopyOnSelect}
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);
});
});
77 changes: 77 additions & 0 deletions packages/cli/src/ui/selection/screen-buffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import {
getFrameController,
type FrameController,
type ReadonlyFrame,
type ScreenSelection,
} from 'ink';

/** A single addressable cell of the composited screen. */
export interface ScreenCell {
/** The character in this cell; empty string for a wide-character spacer. */
value: string;
/** Whether this cell holds the leading half of a wide (2-column) character. */
fullWidth: boolean;
}

/**
* Read side of the frame-buffer bridge. Wraps Ink's {@link FrameController} to
* answer "what character is at (x, y)" against the latest composited frame and
* to push a selection range that the renderer highlights before serialization.
*
* This is the foundation the selection state machine (M1) builds on; it does
* not itself own any mouse or keyboard handling.
*/
export class ScreenBuffer {
constructor(private readonly controller: FrameController) {}

get frame(): ReadonlyFrame | null {
return this.controller.getFrame();
}

get dimensions(): { width: number; height: number } {
const frame = this.frame;
return { width: frame?.width ?? 0, height: frame?.height ?? 0 };
}

getCellAt(x: number, y: number): ScreenCell | null {
const cell = this.frame?.cells[y]?.[x];
return cell ? { value: cell.value, fullWidth: cell.fullWidth } : null;
}

/** Visual text of a row, with trailing padding trimmed. */
lineText(y: number): string {
const row = this.frame?.cells[y];
if (!row) {
return '';
}
return row
.map((cell) => cell.value)
.join('')
.replace(/\s+$/u, '');
}

setSelection(selection: ScreenSelection | null): void {
this.controller.setSelection(selection);
}

subscribe(listener: (frame: ReadonlyFrame) => void): () => void {
return this.controller.subscribe(listener);
}
}

/**
* Returns the screen buffer for the given output stream, or `undefined` if no
* Ink instance is rendering to it (e.g. non-TTY or before render).
*/
export function getScreenBuffer(
stdout: NodeJS.WriteStream,
): ScreenBuffer | undefined {
const controller = getFrameController(stdout);
return controller ? new ScreenBuffer(controller) : undefined;
}
Loading
Loading