From 5c99107c1e5b3d2b7137d9b19b0958088537f75e Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:21:45 +0800 Subject: [PATCH 1/7] feat(ui): word-wise drag after double-click, line-wise after triple-click Double/triple click already selected a single word/line, but holding and dragging did not extend the selection by word/line. Enter a drag-capable word/line selection on multi-click and extend the range to the word/line boundary under the cursor on move/release, so double-click+drag grows by words and triple-click+drag by lines. Fixes #8738 --- .../ui/selection/use-text-selection.test.tsx | 41 +++++++++++ .../src/ui/selection/use-text-selection.tsx | 70 +++++++++++++++++-- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index ea4b7e04aaf..17cfd81d6f0 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -213,6 +213,47 @@ describe('TextSelectionController', () => { 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" + 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'); + }); + + 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-press', 2, 1)); + handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0 + handler(makeEvent('move', 6, 2)); // drag to line 1 + handler(makeEvent('left-release', 6, 2)); + nowSpy.mockRestore(); + + expect(setSelection).toHaveBeenLastCalledWith({ + sx: 0, + sy: 0, + ex: 5, + ey: 1, + }); + expect(copyToClipboard).toHaveBeenCalledWith('hello\nworld!'); + }); + it('snaps a wide-character spacer to the leading cell', () => { frame = makeWideFrame(); const handler = mount(); diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index d48354e7021..98e48e2719f 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -100,6 +100,10 @@ export function TextSelectionController( const baselineFrameRef = useRef(null); const baselineViewportRectRef = useRef(null); const lastClickRef = useRef(null); + const spanDragRef = useRef<{ + mode: 'word' | 'line'; + anchorSpan: { sx: number; sy: number; ex: number; ey: number }; + } | null>(null); const bufferRef = useRef(undefined); const propsRef = useRef(props); propsRef.current = props; @@ -113,6 +117,7 @@ export function TextSelectionController( const clearSelection = useCallback(() => { const selection = selectionRef.current; + spanDragRef.current = null; if (selection.isEmpty) { return; } @@ -183,6 +188,42 @@ export function TextSelectionController( [getBuffer, stdout], ); + // Extend an active word/line drag so the range spans from the original + // multi-click span to the word/line boundary at the current point (issue + // #8738). Falls back to a single cell when the cursor is over whitespace. + const extendSpanDrag = useCallback( + (point: { x: number; y: number }) => { + const spanDrag = spanDragRef.current; + if (!spanDrag) return; + const selection = selectionRef.current; + const frame = getBuffer()?.frame ?? null; + const current = + spanDrag.mode === 'word' + ? (wordSpanAt(frame, point.x, point.y) ?? { + sx: point.x, + sy: point.y, + ex: point.x, + ey: point.y, + }) + : (lineSpanAt(frame, point.y) ?? { + sx: point.x, + sy: point.y, + ex: point.x, + ey: point.y, + }); + const a = spanDrag.anchorSpan; + const cursorAfter = + point.y > a.ey || (point.y === a.ey && point.x >= a.ex); + selection.anchor = cursorAfter + ? { x: a.sx, y: a.sy } + : { x: a.ex, y: a.ey }; + selection.focus = cursorAfter + ? { x: current.ex, y: current.ey } + : { x: current.sx, y: current.sy }; + }, + [getBuffer], + ); + const handleMouse = useCallback( (event: MouseEvent) => { const selection = selectionRef.current; @@ -220,19 +261,29 @@ export function TextSelectionController( if (count >= 2) { const frame = getBuffer()?.frame ?? null; + const mode = count === 2 ? 'word' : 'line'; const span = count === 2 ? wordSpanAt(frame, point.x, point.y) : lineSpanAt(frame, point.y); if (span) { - selection.selectSpan(span, count === 2 ? 'word' : 'line'); + // Enter a drag-capable word/line selection so a held double/triple + // click can extend by word/line on move (issue #8738). Copy happens + // on release, matching char drags. + selection.mode = mode; + selection.anchor = { x: span.sx, y: span.sy }; + selection.focus = { x: span.ex, y: span.ey }; + selection.dragging = true; + spanDragRef.current = { mode, anchorSpan: span }; + dragScrollTopRef.current = + propsRef.current.getScrollState().scrollTop; recordBaseline(); applyHighlight(); - copySelection(); return; } } + spanDragRef.current = null; selection.start(point); dragScrollTopRef.current = propsRef.current.getScrollState().scrollTop; recordBaseline(); @@ -257,7 +308,11 @@ export function TextSelectionController( if (!mapped) { return; } - selection.extend(clampToViewport(mapped.point, mapped.rect)); + if (spanDragRef.current) { + extendSpanDrag(clampToViewport(mapped.point, mapped.rect)); + } else { + selection.extend(clampToViewport(mapped.point, mapped.rect)); + } applyHighlight(); return; } @@ -267,9 +322,15 @@ export function TextSelectionController( if (!selection.dragging) { return; } + const spanDrag = spanDragRef.current; + spanDragRef.current = null; const mapped = mapEvent(event); if (mapped) { - selection.extend(clampToViewport(mapped.point, mapped.rect)); + if (spanDrag) { + extendSpanDrag(clampToViewport(mapped.point, mapped.rect)); + } else { + selection.extend(clampToViewport(mapped.point, mapped.rect)); + } } selection.finish(); if (selection.isCollapsed || selection.isEmpty) { @@ -288,6 +349,7 @@ export function TextSelectionController( recordBaseline, mapEvent, getBuffer, + extendSpanDrag, ], ); From 99af051dd9c4be00d227a985dea2b220127b70da Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 8 Aug 2026 13:44:55 +0000 Subject: [PATCH 2/7] fix(ui): copy single-cell word/line selections on release Drag-capable word/line selections moved the copy from press to release, but the release path cleared any collapsed range before copying, so a plain double-click on a single-character word (or triple-click on a one-cell line) stopped copying. Only treat a collapsed release as a bare click in char mode; in word/line mode a collapsed range is a real single-cell span. Also drop the now-unused selectSpan helper and a stale release-branch comment. --- .../cli/src/ui/selection/selection-state.ts | 11 ---------- .../ui/selection/use-text-selection.test.tsx | 20 +++++++++++++++++++ .../src/ui/selection/use-text-selection.tsx | 8 ++++++-- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/selection/selection-state.ts b/packages/cli/src/ui/selection/selection-state.ts index b9880512bb9..cb796cce78d 100644 --- a/packages/cli/src/ui/selection/selection-state.ts +++ b/packages/cli/src/ui/selection/selection-state.ts @@ -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; } diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index 17cfd81d6f0..4455b504098 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -254,6 +254,26 @@ describe('TextSelectionController', () => { expect(copyToClipboard).toHaveBeenCalledWith('hello\nworld!'); }); + 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'); + }); + it('snaps a wide-character spacer to the leading cell', () => { frame = makeWideFrame(); const handler = mount(); diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index 98e48e2719f..6fe1cce344d 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -318,7 +318,6 @@ export function TextSelectionController( } if (event.name === 'left-release') { - // Word/line click-selects are not drags; leave them intact. if (!selection.dragging) { return; } @@ -333,7 +332,12 @@ export function TextSelectionController( } } selection.finish(); - if (selection.isCollapsed || selection.isEmpty) { + // A collapsed range is a real single-cell span in word/line mode, + // but only a bare click in char mode. + if ( + selection.isEmpty || + (selection.isCollapsed && selection.mode === 'char') + ) { clearSelection(); return; } From 24ae25c7a172a2c5f293588610ddea0979ac93ad Mon Sep 17 00:00:00 2001 From: Qwen Code Date: Sat, 8 Aug 2026 16:41:07 +0000 Subject: [PATCH 3/7] fix(ui): apply release cell to word/line drags, keep multi-click chain The release handler cleared the span-drag record before extending the selection, so the release cell never applied to a word/line drag when no move event covered it. Extend first and clear after, dispatch move/release extension through one shared helper, and keep the click record across a held multi-click so pointer drift cannot break the triple-click chain. Multi-click transitions now go through SelectionState start/extend instead of direct field writes. Adds backward-drag, release-without-move, whitespace-fallback, and release-only-copy test coverage. --- .../ui/selection/use-text-selection.test.tsx | 110 +++++++++++++++++- .../src/ui/selection/use-text-selection.tsx | 64 +++++----- 2 files changed, 140 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index 4455b504098..fee906865d3 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -231,6 +231,7 @@ describe('TextSelectionController', () => { ey: 0, }); expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz'); + expect(copyToClipboard).toHaveBeenCalledTimes(1); }); it('extends a triple-click line selection line-wise on drag', () => { @@ -241,8 +242,8 @@ describe('TextSelectionController', () => { handler(makeEvent('left-press', 2, 1)); handler(makeEvent('left-press', 2, 1)); handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0 - handler(makeEvent('move', 6, 2)); // drag to line 1 - handler(makeEvent('left-release', 6, 2)); + handler(makeEvent('move', 3, 2)); // drag into the middle of line 1 + handler(makeEvent('left-release', 3, 2)); nowSpy.mockRestore(); expect(setSelection).toHaveBeenLastCalledWith({ @@ -252,6 +253,7 @@ describe('TextSelectionController', () => { ey: 1, }); expect(copyToClipboard).toHaveBeenCalledWith('hello\nworld!'); + expect(copyToClipboard).toHaveBeenCalledTimes(1); }); it('copies a single-character word on a no-drag double-click', () => { @@ -272,6 +274,110 @@ describe('TextSelectionController', () => { ey: 0, }); expect(copyToClipboard).toHaveBeenCalledWith('a'); + expect(copyToClipboard).toHaveBeenCalledTimes(1); + }); + + 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-press', 2, 2)); + 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).toHaveBeenCalledWith('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', () => { diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index 6fe1cce344d..ed1c01126bd 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -197,20 +197,14 @@ export function TextSelectionController( if (!spanDrag) return; const selection = selectionRef.current; const frame = getBuffer()?.frame ?? null; - const current = - spanDrag.mode === 'word' - ? (wordSpanAt(frame, point.x, point.y) ?? { - sx: point.x, - sy: point.y, - ex: point.x, - ey: point.y, - }) - : (lineSpanAt(frame, point.y) ?? { - sx: point.x, - sy: point.y, - ex: point.x, - ey: point.y, - }); + const current = (spanDrag.mode === 'word' + ? wordSpanAt(frame, point.x, point.y) + : lineSpanAt(frame, point.y)) ?? { + sx: point.x, + sy: point.y, + ex: point.x, + ey: point.y, + }; const a = spanDrag.anchorSpan; const cursorAfter = point.y > a.ey || (point.y === a.ey && point.x >= a.ex); @@ -224,6 +218,17 @@ export function TextSelectionController( [getBuffer], ); + const extendActiveDrag = useCallback( + (point: { x: number; y: number }) => { + if (spanDragRef.current) { + extendSpanDrag(point); + } else { + selectionRef.current.extend(point); + } + }, + [extendSpanDrag], + ); + const handleMouse = useCallback( (event: MouseEvent) => { const selection = selectionRef.current; @@ -270,10 +275,8 @@ export function TextSelectionController( // Enter a drag-capable word/line selection so a held double/triple // click can extend by word/line on move (issue #8738). Copy happens // on release, matching char drags. - selection.mode = mode; - selection.anchor = { x: span.sx, y: span.sy }; - selection.focus = { x: span.ex, y: span.ey }; - selection.dragging = true; + selection.start({ x: span.sx, y: span.sy }, mode); + selection.extend({ x: span.ex, y: span.ey }); spanDragRef.current = { mode, anchorSpan: span }; dragScrollTopRef.current = propsRef.current.getScrollState().scrollTop; @@ -295,7 +298,11 @@ export function TextSelectionController( if (!selection.dragging) { return; } - lastClickRef.current = null; + // A held multi-click keeps its click record so pointer drift cannot + // break a triple-click; char drags still break the chain. + if (!spanDragRef.current) { + lastClickRef.current = null; + } // A scroll under the drag invalidates coordinates in B1. if ( propsRef.current.getScrollState().scrollTop !== @@ -308,11 +315,7 @@ export function TextSelectionController( if (!mapped) { return; } - if (spanDragRef.current) { - extendSpanDrag(clampToViewport(mapped.point, mapped.rect)); - } else { - selection.extend(clampToViewport(mapped.point, mapped.rect)); - } + extendActiveDrag(clampToViewport(mapped.point, mapped.rect)); applyHighlight(); return; } @@ -321,16 +324,13 @@ export function TextSelectionController( if (!selection.dragging) { return; } - const spanDrag = spanDragRef.current; - spanDragRef.current = null; const mapped = mapEvent(event); if (mapped) { - if (spanDrag) { - extendSpanDrag(clampToViewport(mapped.point, mapped.rect)); - } else { - selection.extend(clampToViewport(mapped.point, mapped.rect)); - } + extendActiveDrag(clampToViewport(mapped.point, mapped.rect)); } + // Clear the span drag only after extending, so the release cell still + // applies to a word/line drag when no move covered it. + spanDragRef.current = null; selection.finish(); // A collapsed range is a real single-cell span in word/line mode, // but only a bare click in char mode. @@ -353,7 +353,7 @@ export function TextSelectionController( recordBaseline, mapEvent, getBuffer, - extendSpanDrag, + extendActiveDrag, ], ); From a6c516ecd8fc4abd9f362f211624108686916678 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 8 Aug 2026 19:41:42 +0000 Subject: [PATCH 4/7] refactor(ui): dedupe word/line drag state and span dispatch --- .../cli/src/ui/selection/selection-span.ts | 17 ++++++- .../ui/selection/use-text-selection.test.tsx | 21 +++++++++ .../src/ui/selection/use-text-selection.tsx | 44 ++++++++----------- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/ui/selection/selection-span.ts b/packages/cli/src/ui/selection/selection-span.ts index 5a4b1a1fbbd..a850bdf6d0c 100644 --- a/packages/cli/src/ui/selection/selection-span.ts +++ b/packages/cli/src/ui/selection/selection-span.ts @@ -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 { @@ -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: SelectionMode, + point: Point, +): NormalizedSelection | null { + return mode === 'word' + ? wordSpanAt(frame, point.x, point.y) + : lineSpanAt(frame, point.y); +} diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index fee906865d3..cf4fb03b099 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -256,6 +256,27 @@ describe('TextSelectionController', () => { expect(copyToClipboard).toHaveBeenCalledTimes(1); }); + 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-press', 2, 1)); + 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).toHaveBeenCalledWith('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 }; diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index ed1c01126bd..87f7ac34d0b 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -11,9 +11,9 @@ import { useMouseEvents } from '../hooks/useMouseEvents.js'; import type { MouseEvent } from '../utils/mouse.js'; import { copyToClipboard } from '../utils/commandUtils.js'; import { getScreenBuffer, type ScreenBuffer } from './screen-buffer.js'; -import { SelectionState } from './selection-state.js'; +import { SelectionState, type NormalizedSelection } from './selection-state.js'; import { getSelectedText } from './selection-text.js'; -import { wordSpanAt, lineSpanAt } from './selection-span.js'; +import { spanAtForMode } from './selection-span.js'; import { terminalToGrid, pointInViewport, @@ -100,10 +100,9 @@ export function TextSelectionController( const baselineFrameRef = useRef(null); const baselineViewportRectRef = useRef(null); const lastClickRef = useRef(null); - const spanDragRef = useRef<{ - mode: 'word' | 'line'; - anchorSpan: { sx: number; sy: number; ex: number; ey: number }; - } | null>(null); + // Anchor span of an active word/line drag; null for char drags. While + // non-null, the selection mode is the matching 'word' | 'line'. + const anchorSpanRef = useRef(null); const bufferRef = useRef(undefined); const propsRef = useRef(props); propsRef.current = props; @@ -117,7 +116,7 @@ export function TextSelectionController( const clearSelection = useCallback(() => { const selection = selectionRef.current; - spanDragRef.current = null; + anchorSpanRef.current = null; if (selection.isEmpty) { return; } @@ -193,24 +192,22 @@ export function TextSelectionController( // #8738). Falls back to a single cell when the cursor is over whitespace. const extendSpanDrag = useCallback( (point: { x: number; y: number }) => { - const spanDrag = spanDragRef.current; - if (!spanDrag) return; + const anchorSpan = anchorSpanRef.current; + if (!anchorSpan) return; const selection = selectionRef.current; const frame = getBuffer()?.frame ?? null; - const current = (spanDrag.mode === 'word' - ? wordSpanAt(frame, point.x, point.y) - : lineSpanAt(frame, point.y)) ?? { + const current = spanAtForMode(frame, selection.mode, point) ?? { sx: point.x, sy: point.y, ex: point.x, ey: point.y, }; - const a = spanDrag.anchorSpan; const cursorAfter = - point.y > a.ey || (point.y === a.ey && point.x >= a.ex); + point.y > anchorSpan.ey || + (point.y === anchorSpan.ey && point.x >= anchorSpan.ex); selection.anchor = cursorAfter - ? { x: a.sx, y: a.sy } - : { x: a.ex, y: a.ey }; + ? { x: anchorSpan.sx, y: anchorSpan.sy } + : { x: anchorSpan.ex, y: anchorSpan.ey }; selection.focus = cursorAfter ? { x: current.ex, y: current.ey } : { x: current.sx, y: current.sy }; @@ -220,7 +217,7 @@ export function TextSelectionController( const extendActiveDrag = useCallback( (point: { x: number; y: number }) => { - if (spanDragRef.current) { + if (anchorSpanRef.current) { extendSpanDrag(point); } else { selectionRef.current.extend(point); @@ -267,17 +264,14 @@ export function TextSelectionController( if (count >= 2) { const frame = getBuffer()?.frame ?? null; const mode = count === 2 ? 'word' : 'line'; - const span = - count === 2 - ? wordSpanAt(frame, point.x, point.y) - : lineSpanAt(frame, point.y); + const span = spanAtForMode(frame, mode, point); if (span) { // Enter a drag-capable word/line selection so a held double/triple // click can extend by word/line on move (issue #8738). Copy happens // on release, matching char drags. selection.start({ x: span.sx, y: span.sy }, mode); selection.extend({ x: span.ex, y: span.ey }); - spanDragRef.current = { mode, anchorSpan: span }; + anchorSpanRef.current = span; dragScrollTopRef.current = propsRef.current.getScrollState().scrollTop; recordBaseline(); @@ -286,7 +280,7 @@ export function TextSelectionController( } } - spanDragRef.current = null; + anchorSpanRef.current = null; selection.start(point); dragScrollTopRef.current = propsRef.current.getScrollState().scrollTop; recordBaseline(); @@ -300,7 +294,7 @@ export function TextSelectionController( } // A held multi-click keeps its click record so pointer drift cannot // break a triple-click; char drags still break the chain. - if (!spanDragRef.current) { + if (!anchorSpanRef.current) { lastClickRef.current = null; } // A scroll under the drag invalidates coordinates in B1. @@ -330,7 +324,7 @@ export function TextSelectionController( } // Clear the span drag only after extending, so the release cell still // applies to a word/line drag when no move covered it. - spanDragRef.current = null; + anchorSpanRef.current = null; selection.finish(); // A collapsed range is a real single-cell span in word/line mode, // but only a bare click in char mode. From 7cf9520313428efd3da40c4d8031391bb990e60a Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 8 Aug 2026 21:58:54 +0000 Subject: [PATCH 5/7] refactor(ui): dedupe collapsed-range policy and pin multi-click tests --- .../cli/src/ui/selection/selection-span.ts | 2 +- .../cli/src/ui/selection/selection-state.ts | 8 ++++ .../ui/selection/use-text-selection.test.tsx | 42 +++++++++++++++-- .../src/ui/selection/use-text-selection.tsx | 45 +++++++++---------- 4 files changed, 69 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/selection/selection-span.ts b/packages/cli/src/ui/selection/selection-span.ts index a850bdf6d0c..2f797c11c71 100644 --- a/packages/cli/src/ui/selection/selection-span.ts +++ b/packages/cli/src/ui/selection/selection-span.ts @@ -80,7 +80,7 @@ export function lineSpanAt( /** Resolve the span at a point for a word/line selection mode. */ export function spanAtForMode( frame: ReadonlyFrame | null, - mode: SelectionMode, + mode: Exclude, point: Point, ): NormalizedSelection | null { return mode === 'word' diff --git a/packages/cli/src/ui/selection/selection-state.ts b/packages/cli/src/ui/selection/selection-state.ts index cb796cce78d..3ac0e775297 100644 --- a/packages/cli/src/ui/selection/selection-state.ts +++ b/packages/cli/src/ui/selection/selection-state.ts @@ -70,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) { diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index cf4fb03b099..4f49ac27fdd 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -208,6 +208,7 @@ describe('TextSelectionController', () => { selectHello(handler); handler(makeEvent('left-press', 1)); + handler(makeEvent('left-release', 1)); expect(copyToClipboard).toHaveBeenCalledTimes(1); expect(copyToClipboard).toHaveBeenLastCalledWith('hello'); @@ -220,6 +221,12 @@ describe('TextSelectionController', () => { 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(); @@ -240,7 +247,9 @@ describe('TextSelectionController', () => { 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)); @@ -252,8 +261,7 @@ describe('TextSelectionController', () => { ex: 5, ey: 1, }); - expect(copyToClipboard).toHaveBeenCalledWith('hello\nworld!'); - expect(copyToClipboard).toHaveBeenCalledTimes(1); + expect(copyToClipboard).toHaveBeenLastCalledWith('hello\nworld!'); }); it('extends a triple-click line selection across multi-word lines', () => { @@ -262,7 +270,9 @@ describe('TextSelectionController', () => { 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)); @@ -274,7 +284,7 @@ describe('TextSelectionController', () => { ex: 6, ey: 1, }); - expect(copyToClipboard).toHaveBeenCalledWith('foo bar\nbaz qux'); + expect(copyToClipboard).toHaveBeenLastCalledWith('foo bar\nbaz qux'); }); it('copies a single-character word on a no-drag double-click', () => { @@ -298,6 +308,28 @@ describe('TextSelectionController', () => { 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 }; @@ -343,7 +375,9 @@ describe('TextSelectionController', () => { 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)); @@ -355,7 +389,7 @@ describe('TextSelectionController', () => { ex: 5, ey: 1, }); - expect(copyToClipboard).toHaveBeenCalledWith('hello\nworld!'); + expect(copyToClipboard).toHaveBeenLastCalledWith('hello\nworld!'); }); it('falls back to the cursor cell when a word drag lands on whitespace', () => { diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index 87f7ac34d0b..73369ec56dd 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -11,7 +11,11 @@ import { useMouseEvents } from '../hooks/useMouseEvents.js'; import type { MouseEvent } from '../utils/mouse.js'; import { copyToClipboard } from '../utils/commandUtils.js'; import { getScreenBuffer, type ScreenBuffer } from './screen-buffer.js'; -import { SelectionState, type NormalizedSelection } from './selection-state.js'; +import { + SelectionState, + type NormalizedSelection, + type SelectionMode, +} from './selection-state.js'; import { getSelectedText } from './selection-text.js'; import { spanAtForMode } from './selection-span.js'; import { @@ -100,9 +104,11 @@ export function TextSelectionController( const baselineFrameRef = useRef(null); const baselineViewportRectRef = useRef(null); const lastClickRef = useRef(null); - // Anchor span of an active word/line drag; null for char drags. While - // non-null, the selection mode is the matching 'word' | 'line'. - const anchorSpanRef = useRef(null); + // Anchor span and mode of an active word/line drag; null for char drags. + const anchorSpanRef = useRef<{ + span: NormalizedSelection; + mode: Exclude; + } | null>(null); const bufferRef = useRef(undefined); const propsRef = useRef(props); propsRef.current = props; @@ -127,11 +133,9 @@ export function TextSelectionController( const applyHighlight = useCallback(() => { const selection = selectionRef.current; const normalized = selection.normalized(); - // Highlight whenever there is a real range; a word/line span of a single - // cell still highlights, but a bare char-mode click (collapsed) does not. - const shouldHighlight = - normalized && (!selection.isCollapsed || selection.mode !== 'char'); - getBuffer()?.setSelection(shouldHighlight ? normalized : null); + getBuffer()?.setSelection( + normalized && !selection.isBareClick ? normalized : null, + ); }, [getBuffer]); const recordBaseline = useCallback(() => { @@ -192,22 +196,22 @@ export function TextSelectionController( // #8738). Falls back to a single cell when the cursor is over whitespace. const extendSpanDrag = useCallback( (point: { x: number; y: number }) => { - const anchorSpan = anchorSpanRef.current; - if (!anchorSpan) return; + const anchor = anchorSpanRef.current; + if (!anchor) return; + const { span, mode } = anchor; const selection = selectionRef.current; const frame = getBuffer()?.frame ?? null; - const current = spanAtForMode(frame, selection.mode, point) ?? { + const current = spanAtForMode(frame, mode, point) ?? { sx: point.x, sy: point.y, ex: point.x, ey: point.y, }; const cursorAfter = - point.y > anchorSpan.ey || - (point.y === anchorSpan.ey && point.x >= anchorSpan.ex); + point.y > span.ey || (point.y === span.ey && point.x >= span.ex); selection.anchor = cursorAfter - ? { x: anchorSpan.sx, y: anchorSpan.sy } - : { x: anchorSpan.ex, y: anchorSpan.ey }; + ? { x: span.sx, y: span.sy } + : { x: span.ex, y: span.ey }; selection.focus = cursorAfter ? { x: current.ex, y: current.ey } : { x: current.sx, y: current.sy }; @@ -271,7 +275,7 @@ export function TextSelectionController( // on release, matching char drags. selection.start({ x: span.sx, y: span.sy }, mode); selection.extend({ x: span.ex, y: span.ey }); - anchorSpanRef.current = span; + anchorSpanRef.current = { span, mode }; dragScrollTopRef.current = propsRef.current.getScrollState().scrollTop; recordBaseline(); @@ -326,12 +330,7 @@ export function TextSelectionController( // applies to a word/line drag when no move covered it. anchorSpanRef.current = null; selection.finish(); - // A collapsed range is a real single-cell span in word/line mode, - // but only a bare click in char mode. - if ( - selection.isEmpty || - (selection.isCollapsed && selection.mode === 'char') - ) { + if (selection.isEmpty || selection.isBareClick) { clearSelection(); return; } From b5cbd2fd5d2bf302fbddfc9de4a19f3c87a5d352 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sun, 9 Aug 2026 00:18:35 +0000 Subject: [PATCH 6/7] test(ui): pin bare-click highlight and multi-row line-drag copy (#8739) --- .../ui/selection/use-text-selection.test.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index 4f49ac27fdd..b650baceed4 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -214,6 +214,20 @@ describe('TextSelectionController', () => { expect(copyToClipboard).toHaveBeenLastCalledWith('hello'); }); + it('does not highlight a bare char-mode click', () => { + const handler = mount(); + handler(makeEvent('left-press', 1)); + + // Assert on press: release clears the highlight either way, so only the + // press call pins the bare-click suppression. + expect(setSelection).toHaveBeenLastCalledWith(null); + + handler(makeEvent('left-release', 1)); + + expect(setSelection).toHaveBeenLastCalledWith(null); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); + 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 }; @@ -392,6 +406,31 @@ describe('TextSelectionController', () => { expect(copyToClipboard).toHaveBeenLastCalledWith('hello\nworld!'); }); + it('keeps covered-row trailing spaces in a multi-row line drag', () => { + frame = makeTwoLineFrame('aaa ', 'bbb'); + viewportRect = { x: 0, y: 0, width: 4, 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 "aaa" + handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0 + handler(makeEvent('move', 2, 2)); // drag onto line 1 + handler(makeEvent('left-release', 2, 2)); + nowSpy.mockRestore(); + + expect(setSelection).toHaveBeenLastCalledWith({ + sx: 0, + sy: 0, + ex: 2, + ey: 1, + }); + // Covered rows keep written trailing spaces (getSelectedText contract); + // only the final row ends at the line span's trimmed last content column. + expect(copyToClipboard).toHaveBeenLastCalledWith('aaa \nbbb'); + }); + 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 }; From de9c6a1d0c91e522ad792fae9380ed712e86fa2d Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 10 Aug 2026 12:51:43 +0000 Subject: [PATCH 7/7] fix(ui): restore press-time copy for multi-click selection (#8739) Co-authored-by: Qwen-Coder --- .../ui/selection/use-text-selection.test.tsx | 21 ++++++++++++++++++- .../src/ui/selection/use-text-selection.tsx | 7 +++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index b650baceed4..86c5ff2fa0d 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -251,8 +251,11 @@ describe('TextSelectionController', () => { ex: 10, ey: 0, }); + // The press-time copy survives so a repaint before release cannot lose + // the word; the release overwrites it with the grown range. + expect(copyToClipboard).toHaveBeenCalledWith('foo'); expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz'); - expect(copyToClipboard).toHaveBeenCalledTimes(1); + expect(copyToClipboard).toHaveBeenCalledTimes(2); }); it('extends a triple-click line selection line-wise on drag', () => { @@ -319,7 +322,23 @@ describe('TextSelectionController', () => { ey: 0, }); expect(copyToClipboard).toHaveBeenCalledWith('a'); + expect(copyToClipboard).toHaveBeenCalledTimes(2); + }); + + it('keeps the double-click copy when streaming clears the selection before release', () => { + frame = makeFrame('foo bar'); + viewportRect = { x: 0, y: 0, width: 7, 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 -> copies "foo" + listener!(makeFrame('foo baz')); // streaming repaint clears the selection + handler(makeEvent('left-release', 2)); // release arrives after the clear + nowSpy.mockRestore(); + expect(copyToClipboard).toHaveBeenCalledTimes(1); + expect(copyToClipboard).toHaveBeenCalledWith('foo'); }); it('copies a one-cell line on a no-drag triple-click', () => { diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index 73369ec56dd..586f60b9bb7 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -271,8 +271,10 @@ export function TextSelectionController( const span = spanAtForMode(frame, mode, point); if (span) { // Enter a drag-capable word/line selection so a held double/triple - // click can extend by word/line on move (issue #8738). Copy happens - // on release, matching char drags. + // click can extend by word/line on move (issue #8738). Copy at + // press too: a streaming repaint before release clears the + // selection, skipping the release copy. Release copies again when + // the drag grew the range. selection.start({ x: span.sx, y: span.sy }, mode); selection.extend({ x: span.ex, y: span.ey }); anchorSpanRef.current = { span, mode }; @@ -280,6 +282,7 @@ export function TextSelectionController( propsRef.current.getScrollState().scrollTop; recordBaseline(); applyHighlight(); + copySelection(); return; } }