From 6701288fe07a53af873e1ef53855a9618d733327 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 16:44:05 -0500 Subject: [PATCH] feat(tui): auto copy-on-select for transcript text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tui_gateway/server.py | 29 +++++++++++++ ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/slash/commands/core.ts | 21 +++++++++ ui-tui/src/app/uiStore.ts | 3 +- ui-tui/src/app/useConfigSync.ts | 1 + ui-tui/src/app/useMainApp.ts | 62 +++++++++++++++++++++++++++ ui-tui/src/config/env.ts | 3 ++ ui-tui/src/content/hotkeys.ts | 4 ++ ui-tui/src/gatewayTypes.ts | 1 + ui-tui/src/types/hermes-ink.d.ts | 7 ++- 10 files changed, 130 insertions(+), 2 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2a1456e41be4..462a774ea418 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1d7cdaead038..27fca574c0b9 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -86,6 +86,7 @@ export interface UiState { bgTasks: Set busy: boolean compact: boolean + copyOnSelect: boolean detailsMode: DetailsMode detailsModeCommandOverride: boolean info: null | SessionInfo diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 2cad70b9a5d9..0461b2f92dc2 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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('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', diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 1b3a841e18c7..ad7a01fd33a7 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -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, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 26d02d620465..8e89e02ab8fd 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -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, diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 9f2cae78d64d..cc258892e89b 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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), diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 8fb9cf69a6e0..f3616541b467 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -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 diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index b79d08061bfb..bc3cb6406d5b 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -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'], diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 605d51213f7c..96760a77f38a 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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 } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 5c3d85697d32..4d79544eb7ad 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -139,12 +139,17 @@ declare module '@hermes/ink' { export function useExternalProcess(): (run: RunExternalProcess) => Promise export function withInkSuspended(run: RunExternalProcess): Promise 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 readonly copySelectionNoClear: () => Promise 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