Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 19 additions & 10 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/tui/copy-wire.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/tui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
10 changes: 6 additions & 4 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions src/tui/selection-copy.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createRecordingClipboard>
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")
})
})

46 changes: 46 additions & 0 deletions src/tui/selection-copy.ts
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 29 additions & 6 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type CliRenderer,
type KeyEvent,
type MouseEvent,
type Selection,
type TextChunk,
} from "@opentui/core"

Expand Down Expand Up @@ -151,6 +152,7 @@ import {
type ClipboardPort,
type CopyTarget,
} from "./copy-path.js"
import { copyFinishedSelection } from "./selection-copy.js"
import {
badgeCount,
cancelLast,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading