Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5c99107
feat(ui): word-wise drag after double-click, line-wise after triple-c…
qwen-code-dev-bot Aug 8, 2026
01404d5
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 8, 2026
99af051
fix(ui): copy single-cell word/line selections on release
qwen-code-dev-bot Aug 8, 2026
24ae25c
fix(ui): apply release cell to word/line drags, keep multi-click chain
qwen-code-dev-bot Aug 8, 2026
797f345
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 8, 2026
a6c516e
refactor(ui): dedupe word/line drag state and span dispatch
qwen-code-dev-bot Aug 8, 2026
7cf9520
refactor(ui): dedupe collapsed-range policy and pin multi-click tests
qwen-code-dev-bot Aug 8, 2026
b5cbd2f
test(ui): pin bare-click highlight and multi-row line-drag copy (#8739)
qwen-code-dev-bot Aug 9, 2026
4fd9868
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 9, 2026
a03e911
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 10, 2026
de9c6a1
fix(ui): restore press-time copy for multi-click selection (#8739)
qwen-code-dev-bot Aug 10, 2026
133e7ff
Merge branch 'feat/word-line-drag-selection' of https://github.com/Qw…
qwen-code-dev-bot Aug 10, 2026
b32dbbe
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
d5999ed
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
aaaae0d
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
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
17 changes: 16 additions & 1 deletion packages/cli/src/ui/selection/selection-span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import type { ReadonlyFrame } from 'ink';
import type { NormalizedSelection } from './selection-state.js';
import type {
NormalizedSelection,
Point,
SelectionMode,
} from './selection-state.js';

/** A cell counts as part of a word when it is non-empty and not whitespace. */
function isWordCell(value: string): boolean {
Expand Down Expand Up @@ -72,3 +76,14 @@ export function lineSpanAt(
}
return { sx: 0, sy: y, ex: end, ey: y };
}

/** Resolve the span at a point for a word/line selection mode. */
export function spanAtForMode(
frame: ReadonlyFrame | null,
mode: Exclude<SelectionMode, 'char'>,
point: Point,
): NormalizedSelection | null {
return mode === 'word'
? wordSpanAt(frame, point.x, point.y)
: lineSpanAt(frame, point.y);
}
19 changes: 8 additions & 11 deletions packages/cli/src/ui/selection/selection-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,6 @@ export class SelectionState {
}
}

/** Select a resolved word/line span from a multi-click (not a drag). */
selectSpan(
span: { sx: number; sy: number; ex: number; ey: number },
mode: SelectionMode,
): void {
this.anchor = { x: span.sx, y: span.sy };
this.focus = { x: span.ex, y: span.ey };
this.dragging = false;
this.mode = mode;
}

finish(): void {
this.dragging = false;
}
Expand All @@ -81,6 +70,14 @@ export class SelectionState {
);
}

/**
* A collapsed range is a real single-cell span in word/line mode, but only a
* bare click in char mode.
*/
get isBareClick(): boolean {
return this.isCollapsed && this.mode === 'char';
}

/** Anchor/focus ordered into reading order, or null when empty. */
normalized(): NormalizedSelection | null {
if (!this.anchor || !this.focus) {
Expand Down
222 changes: 222 additions & 0 deletions packages/cli/src/ui/selection/use-text-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,233 @@ describe('TextSelectionController', () => {
selectHello(handler);

handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1));

expect(copyToClipboard).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenLastCalledWith('hello');
});

it('extends a double-click word selection word-wise on drag', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2)); // first click on "foo"
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 2,
ey: 0,
});
handler(makeEvent('move', 10)); // drag to "baz"
handler(makeEvent('left-release', 10));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
expect(copyToClipboard).toHaveBeenCalledTimes(1);
});

it('extends a triple-click line selection line-wise on drag', () => {
frame = makeTwoLineFrame('hello', 'world!');
viewportRect = { x: 0, y: 0, width: 6, height: 2 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1));
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1)); // double-click -> word "hello"
handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0
handler(makeEvent('move', 3, 2)); // drag into the middle of line 1
handler(makeEvent('left-release', 3, 2));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 5,
ey: 1,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('hello\nworld!');
});

it('extends a triple-click line selection across multi-word lines', () => {
frame = makeTwoLineFrame('foo bar', 'baz qux');
viewportRect = { x: 0, y: 0, width: 7, height: 2 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1));
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1)); // double-click -> word "foo"
handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0
handler(makeEvent('move', 2, 2)); // drag into 'baz' on line 1
handler(makeEvent('left-release', 2, 2));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 6,
ey: 1,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('foo bar\nbaz qux');
});

it('copies a single-character word on a no-drag double-click', () => {
frame = makeFrame('a b');
viewportRect = { x: 0, y: 0, width: 3, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1));
handler(makeEvent('left-press', 1)); // double-click -> selects "a"
handler(makeEvent('left-release', 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 0,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('a');
expect(copyToClipboard).toHaveBeenCalledTimes(1);
});

it('copies a one-cell line on a no-drag triple-click', () => {
frame = makeFrame('x');
viewportRect = { x: 0, y: 0, width: 1, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1));
handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1)); // double-click -> word "x"
handler(makeEvent('left-press', 1)); // triple-click -> line "x"
handler(makeEvent('left-release', 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 0,
ey: 0,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('x');
});

it('extends a word drag to the release cell when no move event is emitted', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2));
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
handler(makeEvent('left-release', 10)); // release over "baz" with no move
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
});

it('extends a double-click word selection backward when dragging left', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 9)); // first click on "baz"
handler(makeEvent('left-press', 9)); // double-click -> selects "baz"
handler(makeEvent('move', 1)); // drag back onto "foo"
handler(makeEvent('left-release', 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
});

it('extends a triple-click line selection backward when dragging up', () => {
frame = makeTwoLineFrame('hello', 'world!');
viewportRect = { x: 0, y: 0, width: 6, height: 2 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 2));
handler(makeEvent('left-release', 2, 2));
handler(makeEvent('left-press', 2, 2));
handler(makeEvent('left-release', 2, 2)); // double-click -> word "world!"
handler(makeEvent('left-press', 2, 2)); // triple-click -> line 1
handler(makeEvent('move', 2, 1)); // drag up onto line 0
handler(makeEvent('left-release', 2, 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 5,
ey: 1,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('hello\nworld!');
});

it('falls back to the cursor cell when a word drag lands on whitespace', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2));
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
handler(makeEvent('move', 4)); // drag onto the gap after "foo"
handler(makeEvent('left-release', 4));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 3,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo ');
});

it('keeps the triple-click chain across drift during a held double-click', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2));
handler(makeEvent('left-release', 2));
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
handler(makeEvent('move', 4)); // drift off the word while held
handler(makeEvent('left-release', 4));
handler(makeEvent('left-press', 2)); // third click -> selects the line
handler(makeEvent('left-release', 2));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('foo bar baz');
});

it('snaps a wide-character spacer to the leading cell', () => {
frame = makeWideFrame();
const handler = mount();
Expand Down
Loading
Loading