feat(tui): auto copy-on-select for transcript text

Drag in the transcript already highlighted but you had to press Cmd+C to
land it on the clipboard, and the highlight cleared on copy — most users
never realised selection existed. Now drag-release fires copySelectionNoClear
so the text is on the clipboard immediately while the highlight stays put,
matching iTerm2's "Copy to pasteboard on selection" default. Esc clears.

Behaviour:
- Single click in the input still positions the cursor (TextInput onClick).
- Single click in the transcript still does nothing destructive.
- Double / triple click select word / line, then drag extends.
- /copyselect [on|off|toggle] (alias /cos) flips the setting at runtime,
  HERMES_TUI_DISABLE_COPY_ON_SELECT=1 disables at startup, persists via
  display.tui_copy_on_select in config.yaml.

Help overlay now lists drag-select, multi-click, and click-to-position
so the gestures are discoverable.

Made-with: Cursor
This commit is contained in:
Brooklyn Nicholson
2026-04-27 16:44:05 -05:00
parent 46b4cf8d21
commit 6701288fe0
10 changed files with 130 additions and 2 deletions

View File

@@ -3134,6 +3134,27 @@ def _(rid, params: dict) -> dict:
_write_config_key("display.tui_mouse", nv)
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
if key == "copy_on_select":
raw = str(value or "").strip().lower()
display = (
_load_cfg().get("display")
if isinstance(_load_cfg().get("display"), dict)
else {}
)
current = bool(display.get("tui_copy_on_select", True))
if raw in ("", "toggle"):
nv = not current
elif raw == "on":
nv = True
elif raw == "off":
nv = False
else:
return _err(rid, 4002, f"unknown copy_on_select value: {value}")
_write_config_key("display.tui_copy_on_select", nv)
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
if key in ("prompt", "personality", "skin"):
try:
cfg = _load_cfg()
@@ -3281,6 +3302,14 @@ def _(rid, params: dict) -> dict:
display = _load_cfg().get("display")
on = display.get("tui_mouse", True) if isinstance(display, dict) else True
return _ok(rid, {"value": "on" if on else "off"})
if key == "copy_on_select":
display = _load_cfg().get("display")
on = (
display.get("tui_copy_on_select", True)
if isinstance(display, dict)
else True
)
return _ok(rid, {"value": "on" if on else "off"})
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
try:

View File

@@ -86,6 +86,7 @@ export interface UiState {
bgTasks: Set<string>
busy: boolean
compact: boolean
copyOnSelect: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
info: null | SessionInfo

View File

@@ -107,6 +107,27 @@ export const coreCommands: SlashCommand[] = [
}
},
{
aliases: ['cos'],
help: 'toggle auto copy-on-drag-release [on|off|toggle]',
name: 'copyselect',
run: (arg, ctx) => {
const current = ctx.ui.copyOnSelect
const next = flagFromArg(arg, current)
if (next === null) {
return ctx.transcript.sys('usage: /copyselect [on|off|toggle]')
}
patchUiState({ copyOnSelect: next })
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'copy_on_select', value: next ? 'on' : 'off' })
.catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`copy-on-select ${next ? 'on' : 'off'}`))
}
},
{
aliases: ['new'],
help: 'start a new session',

View File

@@ -1,6 +1,6 @@
import { atom } from 'nanostores'
import { MOUSE_TRACKING } from '../config/env.js'
import { COPY_ON_SELECT, MOUSE_TRACKING } from '../config/env.js'
import { ZERO } from '../domain/usage.js'
import { DEFAULT_THEME } from '../theme.js'
@@ -10,6 +10,7 @@ const buildUiState = (): UiState => ({
bgTasks: new Set(),
busy: false,
compact: false,
copyOnSelect: COPY_ON_SELECT,
detailsMode: 'collapsed',
detailsModeCommandOverride: false,
info: null,

View File

@@ -44,6 +44,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
setBell(!!d.bell_on_complete)
patchUiState({
compact: !!d.tui_compact,
copyOnSelect: d.tui_copy_on_select !== false,
detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false,
inlineDiffs: d.inline_diffs !== false,

View File

@@ -142,11 +142,73 @@ export function useMainApp(gw: GatewayClient) {
const hasSelection = useHasSelection()
const selection = useSelection()
const copyOnSelect = ui.copyOnSelect
useEffect(() => {
selection.setSelectionBgColor(ui.theme.color.selectionBg)
}, [selection, ui.theme.color.selectionBg])
// Auto copy-on-select: when a drag completes with a real selection
// (anchor + focus, not a bare click), push the text to the clipboard
// without clearing the highlight. Matches iTerm2's "Copy to pasteboard
// on selection" — drag → release → already on clipboard, paste anywhere.
// Subscribes once and tracks the previous isDragging on a ref so the
// effect doesn't re-attach on every selection tick.
const wasDraggingRef = useRef(false)
const lastCopiedRef = useRef<{ anchor: string; focus: string } | null>(null)
useEffect(() => {
if (!copyOnSelect) {
wasDraggingRef.current = false
return
}
const fingerprint = (s: { anchor: { col: number; row: number } | null; focus: { col: number; row: number } | null }) => ({
anchor: s.anchor ? `${s.anchor.row}:${s.anchor.col}` : '',
focus: s.focus ? `${s.focus.row}:${s.focus.col}` : ''
})
return selection.subscribe(() => {
const state = selection.getState()
if (!state) {
return
}
const dragging = state.isDragging
const prev = wasDraggingRef.current
wasDraggingRef.current = dragging
if (!state.anchor) {
lastCopiedRef.current = null
return
}
if (!prev || dragging || !state.focus) {
return
}
const fp = fingerprint(state)
const last = lastCopiedRef.current
if (last && last.anchor === fp.anchor && last.focus === fp.focus) {
return
}
lastCopiedRef.current = fp
void selection.copySelectionNoClear().then(text => {
if (text) {
const len = text.length
turnController.pushActivity(`copied ${len} char${len === 1 ? '' : 's'} · Esc to clear`, 'info')
}
})
})
}, [copyOnSelect, selection])
const composer = useComposerState({
gw,
onClipboardPaste: quiet => clipboardPasteRef.current(quiet),

View File

@@ -2,6 +2,9 @@ const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
// Drag-release auto-copies the selected text. Off by default for terminals
// that do clipboard their own way; opt out with the env var or /copyselect.
export const COPY_ON_SELECT = !truthy(process.env.HERMES_TUI_DISABLE_COPY_ON_SELECT)
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
// Skip AlternateScreen — TUI renders into the primary buffer so the host

View File

@@ -16,7 +16,11 @@ const copyHotkeys: [string, string][] = isMac
: [['Ctrl+C', 'copy selection / interrupt / clear draft / exit']]
export const HOTKEYS: [string, string][] = [
['drag', 'select text in transcript (auto-copies on release)'],
['double / triple click', 'select word / line'],
['click input', 'position cursor inside the prompt'],
...copyHotkeys,
['Esc', 'clear selection'],
[action + '+D', 'exit'],
[action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'],
[action + '+L', 'redraw / repaint'],

View File

@@ -61,6 +61,7 @@ export interface ConfigDisplayConfig {
streaming?: boolean
thinking_mode?: string
tui_compact?: boolean
tui_copy_on_select?: boolean
tui_mouse?: boolean
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
}

View File

@@ -139,12 +139,17 @@ declare module '@hermes/ink' {
export function useExternalProcess(): (run: RunExternalProcess) => Promise<void>
export function withInkSuspended(run: RunExternalProcess): Promise<void>
export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void
export type InkSelectionState = {
readonly anchor: { readonly col: number; readonly row: number } | null
readonly focus: { readonly col: number; readonly row: number } | null
readonly isDragging: boolean
}
export function useSelection(): {
readonly copySelection: () => Promise<string>
readonly copySelectionNoClear: () => Promise<string>
readonly clearSelection: () => void
readonly hasSelection: () => boolean
readonly getState: () => unknown
readonly getState: () => InkSelectionState | null
readonly subscribe: (cb: () => void) => () => void
readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void