diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9d0014c..3c8af99c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions - Live OTEL collector verify (Phoenix or equivalent) against the merged sink - Dogfood session migrate: new session under `~/.corbits/projects`, one legacy `.agent-state` migrate, write under state root still asks +### TUI + +- **Drag-select auto-copy.** With mouse capture on (the default), finishing a + drag selection in the transcript writes the selected text to the system + clipboard on mouse-up and flashes a short status line. Alt+M still hands the + mouse back for native terminal selection; Alt+C remains the keyboard copy + path for whole messages, tool outputs, and diffs. + ## [0.2.95] - 2026-08-09 Tool-only auto-pause that no longer stops healthy work, resume the last session diff --git a/docs/TUI.md b/docs/TUI.md index 0c75822f9..f65e28222 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -535,19 +535,24 @@ a stop does not reliably produce an idle event to drain against later. The main session shell owns the mouse. With DEC mouse reporting on (the default), the wheel scrolls the transcript, clicking a collapsed tool row or -diff arrow expands it in place, and dragging inside the transcript scrolls it -— none of that needs a modifier key. The cost of holding the mouse this way -is that native terminal drag-select is unavailable while reporting is on: -the terminal hands drag events to the app instead of running its own -selection. Two chords cover that gap without needing the mouse released -first: - +diff arrow expands it in place, and dragging across selectable text starts an +OpenTUI selection that **auto-copies to the system clipboard on mouse-up**. +Dragging on non-selectable chrome still scrolls. The cost of holding the +mouse this way is that *native* terminal drag-select is unavailable while +reporting is on: the terminal hands drag events to the app instead of +running its own selection. Two chords cover remaining copy needs: + +- **Drag-select (mouse captured)** uses OpenTUI's selection event + (`CliRenderEvents.SELECTION` → `copyFinishedSelection` in + `selection-copy.ts`). On mouse-up, non-empty selected text is written + through the system clipboard port and the highlight clears with a status + flash. Empty clicks do not copy. - **Alt+M** toggles DEC mouse reporting off and back on (`toggleMouseCapture`, `shell.ts`). Off, the terminal's own drag-select and copy work exactly as in any other terminal program; the status flash names the trade both ways ("Mouse released · drag to select and copy as - usual · Alt+M to click rows" / "Mouse captured · click to expand, drag to - scroll · Alt+M to select text again"). + usual · Alt+M to click rows" / "Mouse captured · drag text to copy · + click to expand · Alt+M for native select"). - **Alt+C** copies a message, tool output, or diff without touching the mouse at all: it opens a copy-selection surface over the transcript (`enterCopyMode`) that resolves through the system clipboard port @@ -621,7 +626,11 @@ terminal. It cannot observe: test round-trips through a real `pbcopy`/`xclip`/terminal clipboard. - **Terminal-owned text selection.** Native drag-select only exists once DEC mouse reporting is off and a real terminal emulator is running; there is - no terminal emulator in the test harness to select text in. + no terminal emulator in the test harness to select text in. OpenTUI + selection auto-copy is unit-tested (`selection-copy.test.ts`) and wired + through a synthetic `SELECTION` event (`copy-wire.test.ts`); a real + mouse-up path still needs a manual terminal check. + Concretely, whole defect classes — a DEC mouse-reporting toggle that silently no-ops, an Alt+key chord a given terminal never actually delivers, a diff --git a/src/tui/copy-wire.test.ts b/src/tui/copy-wire.test.ts index 9df224b42..1b4fa4e44 100644 --- a/src/tui/copy-wire.test.ts +++ b/src/tui/copy-wire.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { CliRenderEvents } from "@opentui/core" import { createHarness, type Harness } from "./harness" import { appendStreamRow, @@ -47,6 +48,44 @@ describe("Alt+C reaches the injected clipboard", () => { }) }) +describe("drag-select auto-copy", () => { + test("SELECTION event writes finished text and flashes", () => { + const clipboard = createRecordingClipboard() + const shell = createAppShell(harness.renderer, { clipboard }) + harness.renderer.emit(CliRenderEvents.SELECTION, { + isDragging: false, + getSelectedText: () => "dragged snippet", + }) + expect(clipboard.writes).toEqual(["dragged snippet"]) + expect(shell.statusFlash).toContain("Copied 15 chars") + expect(shell.statusFlash).toContain("dragged snippet") + shell.dispose() + }) + + test("SELECTION while dragging is a no-op", () => { + const clipboard = createRecordingClipboard() + const shell = createAppShell(harness.renderer, { clipboard }) + harness.renderer.emit(CliRenderEvents.SELECTION, { + isDragging: true, + getSelectedText: () => "partial", + }) + expect(clipboard.writes).toEqual([]) + expect(shell.statusFlash).toBeNull() + shell.dispose() + }) + + test("empty SELECTION is a no-op", () => { + const clipboard = createRecordingClipboard() + const shell = createAppShell(harness.renderer, { clipboard }) + harness.renderer.emit(CliRenderEvents.SELECTION, { + isDragging: false, + getSelectedText: () => "", + }) + expect(clipboard.writes).toEqual([]) + shell.dispose() + }) +}) + describe("Alt+M mouse capture", () => { test("toggles the host port and reports the new state", () => { let enabled = false @@ -60,6 +99,7 @@ describe("Alt+M mouse capture", () => { }) expect(toggleMouseCapture(shell)).toBe(true) expect(enabled).toBe(true) + expect(shell.statusFlash).toContain("drag text to copy") expect(toggleMouseCapture(shell)).toBe(false) expect(enabled).toBe(false) shell.dispose() diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 36c990ea9..97ac0adae 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -25,7 +25,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" }, { keys: "Ctrl+G", description: "cancel the most recently queued or steered message before it dispatches" }, { keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" }, - { 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" }, + { 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" }, { keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)" }, { keys: "Alt+T", description: "show or hide the task list above the prompt" }, { keys: "Alt+O", description: "observe a live subagent session; a system row says so when there is none" }, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 087a47423..0581fc81c 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -220,8 +220,9 @@ export type ProductHostConfig = { /** * Take DEC mouse reporting. Default true: wheel/trackpad scroll only * reaches OpenTUI when the terminal is told to report it, otherwise the - * terminal's own alternate-scroll mode resends it as arrow keys. Alt+M - * hands the mouse back to the terminal for native drag-select. + * terminal's own alternate-scroll mode resends it as arrow keys. With + * reporting on, drag-select is OpenTUI-owned and auto-copies on mouse-up; + * Alt+M hands the mouse back for native terminal selection. */ readonly useMouse?: boolean } @@ -317,8 +318,9 @@ export async function mountProductHost( // never reaches OpenTUI — the terminal's own alternate-scroll mode // swallows it and resends it as arrow keys, which the prompt then // reads as history navigation instead of the transcript scrolling. - // Cost accepted: this suppresses the terminal's native drag-select - // in the main shell. Alt+M hands the mouse back when that is wanted. + // Cost accepted: this suppresses the terminal's *native* drag-select + // in the main shell. OpenTUI selection still works and auto-copies + // on mouse-up; Alt+M hands the mouse back when native select is wanted. // enableMouseMovement stays off (no ?1003): only clicks and wheel // are needed. useMouse: config.useMouse ?? true, diff --git a/src/tui/selection-copy.test.ts b/src/tui/selection-copy.test.ts new file mode 100644 index 000000000..feca02007 --- /dev/null +++ b/src/tui/selection-copy.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { createRecordingClipboard } from "./copy-path.js" +import { + copyFinishedSelection, + type SelectionCopyHost, +} from "./selection-copy.js" + +function host(): SelectionCopyHost & { + readonly clipboard: ReturnType + flashes: string[] + cleared: number +} { + const clipboard = createRecordingClipboard() + const flashes: string[] = [] + let cleared = 0 + return { + clipboard, + flashes, + get cleared() { + return cleared + }, + flash: (text: string) => { + flashes.push(text) + }, + clearSelection: () => { + cleared += 1 + }, + } +} + +describe("copyFinishedSelection", () => { + test("writes selected text, flashes, and clears the highlight", () => { + const h = host() + const ok = copyFinishedSelection(h, { + isDragging: false, + getSelectedText: () => "hello world", + }) + expect(ok).toBe(true) + expect(h.clipboard.writes).toEqual(["hello world"]) + expect(h.flashes[0]).toContain("Copied 11 chars") + expect(h.flashes[0]).toContain("hello world") + expect(h.cleared).toBe(1) + }) + + test("skips while still dragging", () => { + const h = host() + const ok = copyFinishedSelection(h, { + isDragging: true, + getSelectedText: () => "partial", + }) + expect(ok).toBe(false) + expect(h.clipboard.writes).toEqual([]) + expect(h.flashes).toEqual([]) + expect(h.cleared).toBe(0) + }) + + test("skips empty selection", () => { + const h = host() + const ok = copyFinishedSelection(h, { + isDragging: false, + getSelectedText: () => "", + }) + expect(ok).toBe(false) + expect(h.clipboard.writes).toEqual([]) + expect(h.cleared).toBe(0) + }) + + test("truncates long flash previews", () => { + const h = host() + const long = "x".repeat(80) + copyFinishedSelection(h, { + isDragging: false, + getSelectedText: () => long, + }) + expect(h.clipboard.writes).toEqual([long]) + expect(h.flashes[0]).toContain("…") + expect(h.flashes[0]!.length).toBeLessThan(long.length + 40) + }) + + test("collapses multi-line selections in the flash preview", () => { + const h = host() + const ok = copyFinishedSelection(h, { + isDragging: false, + getSelectedText: () => "ok\ngo", + }) + expect(ok).toBe(true) + expect(h.clipboard.writes).toEqual(["ok\ngo"]) + expect(h.flashes[0]).toContain("ok go") + expect(h.flashes[0]).not.toContain("\n") + }) +}) + diff --git a/src/tui/selection-copy.ts b/src/tui/selection-copy.ts new file mode 100644 index 000000000..037af09bb --- /dev/null +++ b/src/tui/selection-copy.ts @@ -0,0 +1,46 @@ +/** + * Auto-copy finished OpenTUI mouse selections to the system clipboard. + * + * OpenTUI emits CliRenderEvents.SELECTION once on mouse-up + * (`finishSelection`). Mid-drag updates do not emit that event, so one + * handler is copy-on-release without per-pixel noise. + */ + +import type { Selection } from "@opentui/core" +import type { ClipboardPort } from "./copy-path.js" + +/** Minimal deps so unit tests do not need a full AppShell. */ +export type SelectionCopyHost = { + readonly clipboard: ClipboardPort + readonly flash: (text: string) => void + readonly clearSelection: () => void +} + +/** Slice of OpenTUI Selection used by the copy path. */ +export type FinishedSelection = { + readonly isDragging: boolean + getSelectedText(): string +} + +/** + * Copy a finished (non-dragging) selection. Returns true when text was + * written. Empty selections and still-dragging states are no-ops. + */ +export function copyFinishedSelection( + host: SelectionCopyHost, + selection: FinishedSelection | Selection, +): boolean { + if (selection.isDragging) return false + const text = selection.getSelectedText() + if (text.length === 0) return false + + void host.clipboard.writeText(text) + // Notice row is one line; always collapse whitespace so multi-line + // drag-selects do not inject raw newlines into chrome. + const oneLine = text.replace(/\s+/g, " ").trim() + const preview = + oneLine.length > 48 ? `${oneLine.slice(0, 45)}…` : oneLine + host.flash(`Copied ${text.length} chars: ${preview}`) + host.clearSelection() + return true +} diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 2d019c963..c92c03255 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -23,6 +23,7 @@ import { type CliRenderer, type KeyEvent, type MouseEvent, + type Selection, type TextChunk, } from "@opentui/core" @@ -151,6 +152,7 @@ import { type ClipboardPort, type CopyTarget, } from "./copy-path.js" +import { copyFinishedSelection } from "./selection-copy.js" import { badgeCount, cancelLast, @@ -473,7 +475,7 @@ function dispatchOverlayAccept( /** Renderer surface required by the shell (CliRenderer / createTestRenderer). */ export type ShellRenderer = Pick< CliRenderer, - "root" | "width" | "height" | "keyInput" | "on" | "off" | "isDestroyed" + "root" | "width" | "height" | "keyInput" | "on" | "off" | "isDestroyed" | "clearSelection" > export type AppShellOptions = { @@ -522,21 +524,24 @@ export type AppShellOptions = { */ readonly telemetryNotice?: string /** - * Clipboard port for Alt+C. Defaults to an in-memory recorder so tests and - * demos never shell out; the product host injects the system clipboard. + * Clipboard port for Alt+C and drag-select auto-copy. Defaults to an + * in-memory recorder so tests and demos never shell out; the product host + * injects the system clipboard. */ readonly clipboard?: ClipboardPort /** * Mouse-reporting switch behind Alt+M. Absent means the shell has no * renderer-level control (tests, demos) and reports the toggle unavailable. + * While reporting is on, OpenTUI owns drag-select and auto-copies on + * mouse-up; Alt+M hands the mouse back for native terminal selection. */ readonly mouseCapture?: MouseCapturePort } /** * Renderer-level DEC mouse reporting control. While reporting is on the - * terminal hands drags to us instead of selecting text, so the user needs a - * way to hand it back. + * terminal hands drags to OpenTUI (drag-to-copy on mouse-up); Alt+M hands + * reporting back so the terminal can run its own selection again. */ export type MouseCapturePort = { readonly get: () => boolean @@ -4512,7 +4517,7 @@ export function toggleMouseCapture(shell: AppShell): boolean | null { setStatusFlash( shell, next - ? "Mouse captured · click to expand, drag to scroll · Alt+M to select text again" + ? "Mouse captured · drag text to copy · click to expand · Alt+M for native select" : "Mouse released · drag to select and copy as usual · Alt+M to click rows", ) return next @@ -5762,6 +5767,23 @@ export function createAppShell( renderer.on(CliRenderEvents.FRAME, onFrame) renderer.on(CliRenderEvents.RESIZE, onResize) + // Declared before shell so dispose can off() the same function reference; + // body closes over shell after createAppShell finishes assigning it. + const onSelection = (selection: Selection): void => { + if (disposed) return + copyFinishedSelection( + { + clipboard: shell.clipboard, + flash: (text) => setStatusFlash(shell, text), + clearSelection: () => { + renderer.clearSelection() + }, + }, + selection, + ) + } + renderer.on(CliRenderEvents.SELECTION, onSelection) + const shell: AppShell = { renderer, root, @@ -5827,6 +5849,7 @@ export function createAppShell( } renderer.off(CliRenderEvents.FRAME, onFrame) renderer.off(CliRenderEvents.RESIZE, onResize) + renderer.off(CliRenderEvents.SELECTION, onSelection) internals.get(shell)?.landingIdleTimerCancel?.() flashTimers.get(shell)?.() flashTimers.delete(shell) diff --git a/src/tui/system-clipboard.ts b/src/tui/system-clipboard.ts index d0a296f3c..b3a705945 100644 --- a/src/tui/system-clipboard.ts +++ b/src/tui/system-clipboard.ts @@ -1,10 +1,11 @@ /** - * System clipboard port for the keyboard copy path. + * System clipboard port for app-owned copy paths. * - * Terminal drag-select is unavailable while DEC mouse reporting is on, so - * Alt+C must reach the OS clipboard itself rather than relying on the - * terminal's own selection. Native helpers are preferred; OSC 52 is the - * fallback for remote sessions where no helper binary exists. + * Used by drag-select auto-copy (OpenTUI selection on mouse-up), Alt+C copy + * mode, and related keyboard paths. Native terminal drag-select is still + * unavailable while DEC mouse reporting is on — Alt+M hands the mouse back + * when that is wanted. Native helpers are preferred; OSC 52 is the fallback + * for remote sessions where no helper binary exists. */ import type { ClipboardPort } from "./copy-path.js"