Skip to content

Commit b9a1c8b

Browse files
Auto-copy highlighted text to clipboard on mouse-up (#446)
* Auto-copy highlighted text to clipboard on mouse-up OpenTUI emits CliRenderEvents.SELECTION once when a drag-select finishes (mouse-up). A new copyFinishedSelection handler writes the selected text through the system clipboard port, flashes a status line with a char count and preview, then clears the highlight so the selection does not linger. Empty selections and mid-drag updates are no-ops. The Alt+M flash copy is updated to name the new behavior, and docs/TUI.md documents the drag-select-copy path alongside the existing Alt+M and Alt+C chords. * Polish drag-select auto-copy help text and wiring tests Align Alt+M copy/help strings and docs with mouse-up clipboard copy, and pin the SELECTION event path through a live shell. * Collapse multi-line copy previews and clarify Alt+M help Keep the status flash single-line for short drag-selects, and make the help row describe mouse capture as a toggle with capture as the default.
1 parent c7fae9d commit b9a1c8b

9 files changed

Lines changed: 247 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions
1919
- Live OTEL collector verify (Phoenix or equivalent) against the merged sink
2020
- Dogfood session migrate: new session under `~/.corbits/projects`, one legacy `.agent-state` migrate, write under state root still asks
2121

22+
### TUI
23+
24+
- **Drag-select auto-copy.** With mouse capture on (the default), finishing a
25+
drag selection in the transcript writes the selected text to the system
26+
clipboard on mouse-up and flashes a short status line. Alt+M still hands the
27+
mouse back for native terminal selection; Alt+C remains the keyboard copy
28+
path for whole messages, tool outputs, and diffs.
29+
2230
## [0.2.95] - 2026-08-09
2331

2432
Tool-only auto-pause that no longer stops healthy work, resume the last session

docs/TUI.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -535,19 +535,24 @@ a stop does not reliably produce an idle event to drain against later.
535535

536536
The main session shell owns the mouse. With DEC mouse reporting on (the
537537
default), the wheel scrolls the transcript, clicking a collapsed tool row or
538-
diff arrow expands it in place, and dragging inside the transcript scrolls it
539-
— none of that needs a modifier key. The cost of holding the mouse this way
540-
is that native terminal drag-select is unavailable while reporting is on:
541-
the terminal hands drag events to the app instead of running its own
542-
selection. Two chords cover that gap without needing the mouse released
543-
first:
544-
538+
diff arrow expands it in place, and dragging across selectable text starts an
539+
OpenTUI selection that **auto-copies to the system clipboard on mouse-up**.
540+
Dragging on non-selectable chrome still scrolls. The cost of holding the
541+
mouse this way is that *native* terminal drag-select is unavailable while
542+
reporting is on: the terminal hands drag events to the app instead of
543+
running its own selection. Two chords cover remaining copy needs:
544+
545+
- **Drag-select (mouse captured)** uses OpenTUI's selection event
546+
(`CliRenderEvents.SELECTION``copyFinishedSelection` in
547+
`selection-copy.ts`). On mouse-up, non-empty selected text is written
548+
through the system clipboard port and the highlight clears with a status
549+
flash. Empty clicks do not copy.
545550
- **Alt+M** toggles DEC mouse reporting off and back on
546551
(`toggleMouseCapture`, `shell.ts`). Off, the terminal's own drag-select
547552
and copy work exactly as in any other terminal program; the status flash
548553
names the trade both ways ("Mouse released · drag to select and copy as
549-
usual · Alt+M to click rows" / "Mouse captured · click to expand, drag to
550-
scroll · Alt+M to select text again").
554+
usual · Alt+M to click rows" / "Mouse captured · drag text to copy ·
555+
click to expand · Alt+M for native select").
551556
- **Alt+C** copies a message, tool output, or diff without touching the
552557
mouse at all: it opens a copy-selection surface over the transcript
553558
(`enterCopyMode`) that resolves through the system clipboard port
@@ -621,7 +626,11 @@ terminal. It cannot observe:
621626
test round-trips through a real `pbcopy`/`xclip`/terminal clipboard.
622627
- **Terminal-owned text selection.** Native drag-select only exists once DEC
623628
mouse reporting is off and a real terminal emulator is running; there is
624-
no terminal emulator in the test harness to select text in.
629+
no terminal emulator in the test harness to select text in. OpenTUI
630+
selection auto-copy is unit-tested (`selection-copy.test.ts`) and wired
631+
through a synthetic `SELECTION` event (`copy-wire.test.ts`); a real
632+
mouse-up path still needs a manual terminal check.
633+
625634

626635
Concretely, whole defect classes — a DEC mouse-reporting toggle that silently
627636
no-ops, an Alt+key chord a given terminal never actually delivers, a

src/tui/copy-wire.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
2+
import { CliRenderEvents } from "@opentui/core"
23
import { createHarness, type Harness } from "./harness"
34
import {
45
appendStreamRow,
@@ -47,6 +48,44 @@ describe("Alt+C reaches the injected clipboard", () => {
4748
})
4849
})
4950

51+
describe("drag-select auto-copy", () => {
52+
test("SELECTION event writes finished text and flashes", () => {
53+
const clipboard = createRecordingClipboard()
54+
const shell = createAppShell(harness.renderer, { clipboard })
55+
harness.renderer.emit(CliRenderEvents.SELECTION, {
56+
isDragging: false,
57+
getSelectedText: () => "dragged snippet",
58+
})
59+
expect(clipboard.writes).toEqual(["dragged snippet"])
60+
expect(shell.statusFlash).toContain("Copied 15 chars")
61+
expect(shell.statusFlash).toContain("dragged snippet")
62+
shell.dispose()
63+
})
64+
65+
test("SELECTION while dragging is a no-op", () => {
66+
const clipboard = createRecordingClipboard()
67+
const shell = createAppShell(harness.renderer, { clipboard })
68+
harness.renderer.emit(CliRenderEvents.SELECTION, {
69+
isDragging: true,
70+
getSelectedText: () => "partial",
71+
})
72+
expect(clipboard.writes).toEqual([])
73+
expect(shell.statusFlash).toBeNull()
74+
shell.dispose()
75+
})
76+
77+
test("empty SELECTION is a no-op", () => {
78+
const clipboard = createRecordingClipboard()
79+
const shell = createAppShell(harness.renderer, { clipboard })
80+
harness.renderer.emit(CliRenderEvents.SELECTION, {
81+
isDragging: false,
82+
getSelectedText: () => "",
83+
})
84+
expect(clipboard.writes).toEqual([])
85+
shell.dispose()
86+
})
87+
})
88+
5089
describe("Alt+M mouse capture", () => {
5190
test("toggles the host port and reports the new state", () => {
5291
let enabled = false
@@ -60,6 +99,7 @@ describe("Alt+M mouse capture", () => {
6099
})
61100
expect(toggleMouseCapture(shell)).toBe(true)
62101
expect(enabled).toBe(true)
102+
expect(shell.statusFlash).toContain("drag text to copy")
63103
expect(toggleMouseCapture(shell)).toBe(false)
64104
expect(enabled).toBe(false)
65105
shell.dispose()

src/tui/keybindings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
2525
{ keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" },
2626
{ keys: "Ctrl+G", description: "cancel the most recently queued or steered message before it dispatches" },
2727
{ keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" },
28-
{ keys: "Alt+M", description: "release the mouse to the terminal for native drag-select and copy; on by default for wheel scroll and click-to-expand" },
28+
{ keys: "Alt+M", description: "toggle DEC mouse capture (on by default: wheel scroll, click-to-expand, drag-to-copy); off restores native terminal drag-select" },
2929
{ keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)" },
3030
{ keys: "Alt+T", description: "show or hide the task list above the prompt" },
3131
{ keys: "Alt+O", description: "observe a live subagent session; a system row says so when there is none" },

src/tui/product-host.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,9 @@ export type ProductHostConfig = {
220220
/**
221221
* Take DEC mouse reporting. Default true: wheel/trackpad scroll only
222222
* reaches OpenTUI when the terminal is told to report it, otherwise the
223-
* terminal's own alternate-scroll mode resends it as arrow keys. Alt+M
224-
* hands the mouse back to the terminal for native drag-select.
223+
* terminal's own alternate-scroll mode resends it as arrow keys. With
224+
* reporting on, drag-select is OpenTUI-owned and auto-copies on mouse-up;
225+
* Alt+M hands the mouse back for native terminal selection.
225226
*/
226227
readonly useMouse?: boolean
227228
}
@@ -317,8 +318,9 @@ export async function mountProductHost(
317318
// never reaches OpenTUI — the terminal's own alternate-scroll mode
318319
// swallows it and resends it as arrow keys, which the prompt then
319320
// reads as history navigation instead of the transcript scrolling.
320-
// Cost accepted: this suppresses the terminal's native drag-select
321-
// in the main shell. Alt+M hands the mouse back when that is wanted.
321+
// Cost accepted: this suppresses the terminal's *native* drag-select
322+
// in the main shell. OpenTUI selection still works and auto-copies
323+
// on mouse-up; Alt+M hands the mouse back when native select is wanted.
322324
// enableMouseMovement stays off (no ?1003): only clicks and wheel
323325
// are needed.
324326
useMouse: config.useMouse ?? true,

src/tui/selection-copy.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { createRecordingClipboard } from "./copy-path.js"
3+
import {
4+
copyFinishedSelection,
5+
type SelectionCopyHost,
6+
} from "./selection-copy.js"
7+
8+
function host(): SelectionCopyHost & {
9+
readonly clipboard: ReturnType<typeof createRecordingClipboard>
10+
flashes: string[]
11+
cleared: number
12+
} {
13+
const clipboard = createRecordingClipboard()
14+
const flashes: string[] = []
15+
let cleared = 0
16+
return {
17+
clipboard,
18+
flashes,
19+
get cleared() {
20+
return cleared
21+
},
22+
flash: (text: string) => {
23+
flashes.push(text)
24+
},
25+
clearSelection: () => {
26+
cleared += 1
27+
},
28+
}
29+
}
30+
31+
describe("copyFinishedSelection", () => {
32+
test("writes selected text, flashes, and clears the highlight", () => {
33+
const h = host()
34+
const ok = copyFinishedSelection(h, {
35+
isDragging: false,
36+
getSelectedText: () => "hello world",
37+
})
38+
expect(ok).toBe(true)
39+
expect(h.clipboard.writes).toEqual(["hello world"])
40+
expect(h.flashes[0]).toContain("Copied 11 chars")
41+
expect(h.flashes[0]).toContain("hello world")
42+
expect(h.cleared).toBe(1)
43+
})
44+
45+
test("skips while still dragging", () => {
46+
const h = host()
47+
const ok = copyFinishedSelection(h, {
48+
isDragging: true,
49+
getSelectedText: () => "partial",
50+
})
51+
expect(ok).toBe(false)
52+
expect(h.clipboard.writes).toEqual([])
53+
expect(h.flashes).toEqual([])
54+
expect(h.cleared).toBe(0)
55+
})
56+
57+
test("skips empty selection", () => {
58+
const h = host()
59+
const ok = copyFinishedSelection(h, {
60+
isDragging: false,
61+
getSelectedText: () => "",
62+
})
63+
expect(ok).toBe(false)
64+
expect(h.clipboard.writes).toEqual([])
65+
expect(h.cleared).toBe(0)
66+
})
67+
68+
test("truncates long flash previews", () => {
69+
const h = host()
70+
const long = "x".repeat(80)
71+
copyFinishedSelection(h, {
72+
isDragging: false,
73+
getSelectedText: () => long,
74+
})
75+
expect(h.clipboard.writes).toEqual([long])
76+
expect(h.flashes[0]).toContain("…")
77+
expect(h.flashes[0]!.length).toBeLessThan(long.length + 40)
78+
})
79+
80+
test("collapses multi-line selections in the flash preview", () => {
81+
const h = host()
82+
const ok = copyFinishedSelection(h, {
83+
isDragging: false,
84+
getSelectedText: () => "ok\ngo",
85+
})
86+
expect(ok).toBe(true)
87+
expect(h.clipboard.writes).toEqual(["ok\ngo"])
88+
expect(h.flashes[0]).toContain("ok go")
89+
expect(h.flashes[0]).not.toContain("\n")
90+
})
91+
})
92+

src/tui/selection-copy.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Auto-copy finished OpenTUI mouse selections to the system clipboard.
3+
*
4+
* OpenTUI emits CliRenderEvents.SELECTION once on mouse-up
5+
* (`finishSelection`). Mid-drag updates do not emit that event, so one
6+
* handler is copy-on-release without per-pixel noise.
7+
*/
8+
9+
import type { Selection } from "@opentui/core"
10+
import type { ClipboardPort } from "./copy-path.js"
11+
12+
/** Minimal deps so unit tests do not need a full AppShell. */
13+
export type SelectionCopyHost = {
14+
readonly clipboard: ClipboardPort
15+
readonly flash: (text: string) => void
16+
readonly clearSelection: () => void
17+
}
18+
19+
/** Slice of OpenTUI Selection used by the copy path. */
20+
export type FinishedSelection = {
21+
readonly isDragging: boolean
22+
getSelectedText(): string
23+
}
24+
25+
/**
26+
* Copy a finished (non-dragging) selection. Returns true when text was
27+
* written. Empty selections and still-dragging states are no-ops.
28+
*/
29+
export function copyFinishedSelection(
30+
host: SelectionCopyHost,
31+
selection: FinishedSelection | Selection,
32+
): boolean {
33+
if (selection.isDragging) return false
34+
const text = selection.getSelectedText()
35+
if (text.length === 0) return false
36+
37+
void host.clipboard.writeText(text)
38+
// Notice row is one line; always collapse whitespace so multi-line
39+
// drag-selects do not inject raw newlines into chrome.
40+
const oneLine = text.replace(/\s+/g, " ").trim()
41+
const preview =
42+
oneLine.length > 48 ? `${oneLine.slice(0, 45)}…` : oneLine
43+
host.flash(`Copied ${text.length} chars: ${preview}`)
44+
host.clearSelection()
45+
return true
46+
}

src/tui/shell.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type CliRenderer,
2424
type KeyEvent,
2525
type MouseEvent,
26+
type Selection,
2627
type TextChunk,
2728
} from "@opentui/core"
2829

@@ -151,6 +152,7 @@ import {
151152
type ClipboardPort,
152153
type CopyTarget,
153154
} from "./copy-path.js"
155+
import { copyFinishedSelection } from "./selection-copy.js"
154156
import {
155157
badgeCount,
156158
cancelLast,
@@ -473,7 +475,7 @@ function dispatchOverlayAccept(
473475
/** Renderer surface required by the shell (CliRenderer / createTestRenderer). */
474476
export type ShellRenderer = Pick<
475477
CliRenderer,
476-
"root" | "width" | "height" | "keyInput" | "on" | "off" | "isDestroyed"
478+
"root" | "width" | "height" | "keyInput" | "on" | "off" | "isDestroyed" | "clearSelection"
477479
>
478480

479481
export type AppShellOptions = {
@@ -522,21 +524,24 @@ export type AppShellOptions = {
522524
*/
523525
readonly telemetryNotice?: string
524526
/**
525-
* Clipboard port for Alt+C. Defaults to an in-memory recorder so tests and
526-
* demos never shell out; the product host injects the system clipboard.
527+
* Clipboard port for Alt+C and drag-select auto-copy. Defaults to an
528+
* in-memory recorder so tests and demos never shell out; the product host
529+
* injects the system clipboard.
527530
*/
528531
readonly clipboard?: ClipboardPort
529532
/**
530533
* Mouse-reporting switch behind Alt+M. Absent means the shell has no
531534
* renderer-level control (tests, demos) and reports the toggle unavailable.
535+
* While reporting is on, OpenTUI owns drag-select and auto-copies on
536+
* mouse-up; Alt+M hands the mouse back for native terminal selection.
532537
*/
533538
readonly mouseCapture?: MouseCapturePort
534539
}
535540

536541
/**
537542
* Renderer-level DEC mouse reporting control. While reporting is on the
538-
* terminal hands drags to us instead of selecting text, so the user needs a
539-
* way to hand it back.
543+
* terminal hands drags to OpenTUI (drag-to-copy on mouse-up); Alt+M hands
544+
* reporting back so the terminal can run its own selection again.
540545
*/
541546
export type MouseCapturePort = {
542547
readonly get: () => boolean
@@ -4512,7 +4517,7 @@ export function toggleMouseCapture(shell: AppShell): boolean | null {
45124517
setStatusFlash(
45134518
shell,
45144519
next
4515-
? "Mouse captured · click to expand, drag to scroll · Alt+M to select text again"
4520+
? "Mouse captured · drag text to copy · click to expand · Alt+M for native select"
45164521
: "Mouse released · drag to select and copy as usual · Alt+M to click rows",
45174522
)
45184523
return next
@@ -5762,6 +5767,23 @@ export function createAppShell(
57625767
renderer.on(CliRenderEvents.FRAME, onFrame)
57635768
renderer.on(CliRenderEvents.RESIZE, onResize)
57645769

5770+
// Declared before shell so dispose can off() the same function reference;
5771+
// body closes over shell after createAppShell finishes assigning it.
5772+
const onSelection = (selection: Selection): void => {
5773+
if (disposed) return
5774+
copyFinishedSelection(
5775+
{
5776+
clipboard: shell.clipboard,
5777+
flash: (text) => setStatusFlash(shell, text),
5778+
clearSelection: () => {
5779+
renderer.clearSelection()
5780+
},
5781+
},
5782+
selection,
5783+
)
5784+
}
5785+
renderer.on(CliRenderEvents.SELECTION, onSelection)
5786+
57655787
const shell: AppShell = {
57665788
renderer,
57675789
root,
@@ -5827,6 +5849,7 @@ export function createAppShell(
58275849
}
58285850
renderer.off(CliRenderEvents.FRAME, onFrame)
58295851
renderer.off(CliRenderEvents.RESIZE, onResize)
5852+
renderer.off(CliRenderEvents.SELECTION, onSelection)
58305853
internals.get(shell)?.landingIdleTimerCancel?.()
58315854
flashTimers.get(shell)?.()
58325855
flashTimers.delete(shell)

0 commit comments

Comments
 (0)