Skip to content

Commit 8e84d7b

Browse files
committed
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.
1 parent c7fae9d commit 8e84d7b

4 files changed

Lines changed: 158 additions & 11 deletions

File tree

‎docs/TUI.md‎

Lines changed: 14 additions & 9 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

‎src/tui/selection-copy.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
})

‎src/tui/selection-copy.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
const preview =
39+
text.length > 48 ? `${text.slice(0, 45).replace(/\s+/g, " ")}…` : text
40+
host.flash(`Copied ${text.length} chars: ${preview}`)
41+
host.clearSelection()
42+
return true
43+
}

‎src/tui/shell.ts‎

Lines changed: 22 additions & 2 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 = {
@@ -4512,7 +4514,7 @@ export function toggleMouseCapture(shell: AppShell): boolean | null {
45124514
setStatusFlash(
45134515
shell,
45144516
next
4515-
? "Mouse captured · click to expand, drag to scroll · Alt+M to select text again"
4517+
? "Mouse captured · drag text to copy · click to expand · Alt+M for native select"
45164518
: "Mouse released · drag to select and copy as usual · Alt+M to click rows",
45174519
)
45184520
return next
@@ -5762,6 +5764,23 @@ export function createAppShell(
57625764
renderer.on(CliRenderEvents.FRAME, onFrame)
57635765
renderer.on(CliRenderEvents.RESIZE, onResize)
57645766

5767+
// Declared before shell so dispose can off() the same function reference;
5768+
// body closes over shell after createAppShell finishes assigning it.
5769+
const onSelection = (selection: Selection): void => {
5770+
if (disposed) return
5771+
copyFinishedSelection(
5772+
{
5773+
clipboard: shell.clipboard,
5774+
flash: (text) => setStatusFlash(shell, text),
5775+
clearSelection: () => {
5776+
renderer.clearSelection()
5777+
},
5778+
},
5779+
selection,
5780+
)
5781+
}
5782+
renderer.on(CliRenderEvents.SELECTION, onSelection)
5783+
57655784
const shell: AppShell = {
57665785
renderer,
57675786
root,
@@ -5827,6 +5846,7 @@ export function createAppShell(
58275846
}
58285847
renderer.off(CliRenderEvents.FRAME, onFrame)
58295848
renderer.off(CliRenderEvents.RESIZE, onResize)
5849+
renderer.off(CliRenderEvents.SELECTION, onSelection)
58305850
internals.get(shell)?.landingIdleTimerCancel?.()
58315851
flashTimers.get(shell)?.()
58325852
flashTimers.delete(shell)

0 commit comments

Comments
 (0)