diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 3b502cac..b5715d5f 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -26,6 +26,8 @@ export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' +export { type ClipboardPath } from './ink/termio/osc.js' +export { type SelectionCopy } from './ink/ink.js' export { isXtermJs } from './ink/terminal.js' export { oscColor } from './ink/terminal-querier.js' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts index 747279ab..b5a18b0b 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts @@ -6,6 +6,8 @@ import { useContext, useMemo, useSyncExternalStore } from 'react' +import type { SelectionCopy } from '../ink.js' + import StdinContext from '../components/StdinContext.js' import instances from '../instances.js' import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.js' @@ -15,9 +17,9 @@ import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.j * Returns no-op functions when fullscreen mode is disabled. */ export function useSelection(): { - copySelection: () => Promise + copySelection: () => Promise /** Copy without clearing the highlight (for copy-on-select). */ - copySelectionNoClear: () => Promise + copySelectionNoClear: () => Promise clearSelection: () => void hasSelection: () => boolean /** Read the raw mutable selection state (for drag-to-scroll). */ @@ -56,8 +58,8 @@ export function useSelection(): { return useMemo(() => { if (!ink) { return { - copySelection: async () => '', - copySelectionNoClear: async () => '', + copySelection: async () => ({ text: '', path: null }), + copySelectionNoClear: async () => ({ text: '', path: null }), clearSelection: () => {}, hasSelection: () => false, getState: () => null, diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 55ce9be7..431dbc5d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -109,6 +109,8 @@ import { import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, + clipboardDebugEnabled, + type ClipboardPath, setClipboard, supportsTabStatus, wrapForMultiplexer @@ -148,6 +150,21 @@ function makeAltScreenParkPatch(terminalRows: number) { }) } +/** + * The outcome of a selection copy: the text that was copied and the path that + * took it, or '' and null when no path did. + * + * The path travels with the text because only `setClipboard()` can see which + * path ran -- inside tmux a failed load-buffer falls through to raw OSC 52 and + * leaves the environment looking exactly like the case that worked. + */ +export type SelectionCopy = { + text: string + path: ClipboardPath | null +} + +const NOTHING_COPIED: SelectionCopy = { text: '', path: null } + export type Options = { stdout: NodeJS.WriteStream stdin: NodeJS.ReadStream @@ -1371,60 +1388,64 @@ export default class Ink { /** * Copy the current text selection to the system clipboard without clearing the - * selection. Returns the copied text when a clipboard path succeeded (native - * tool fired, tmux buffer loaded, or OSC 52 emitted), or '' when no path was - * taken (e.g. headless Linux without tmux). Matches iTerm2's copy-on-select - * behavior where the selected region stays visible after the automatic copy. + * selection. Returns the copied text plus the path that took it when a + * clipboard path succeeded (native tool fired, tmux buffer loaded, or OSC 52 + * emitted), or an empty text and a null path when none did (e.g. headless + * Linux without tmux). The path comes from what `setClipboard()` observed -- + * callers report it to the user, and it cannot be re-derived from the + * environment afterwards. Matches iTerm2's copy-on-select behavior where the + * selected region stays visible after the automatic copy. */ - async copySelectionNoClear(): Promise { + async copySelectionNoClear(): Promise { if (!hasSelection(this.selection)) { - return '' + return NOTHING_COPIED } const text = getSelectedText(this.selection, this.frontFrame.screen) if (text) { try { - const { sequence, success } = await setClipboard(text) + const { sequence, success, path } = await setClipboard(text) if (sequence) { this.options.stdout.write(sequence) } - if (success) { - return text + if (success && path) { + return { text, path } } - if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + if (clipboardDebugEnabled()) { console.error( - '[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence' + '[clipboard] no path reached the clipboard (headless + no tmux?) — set RAVEN_TUI_FORCE_OSC52=1 to force the escape sequence' ) } } catch (err) { - if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + if (clipboardDebugEnabled()) { console.error('[clipboard] error:', err) } } } - return '' + return NOTHING_COPIED } /** * Copy the current text selection to the system clipboard via OSC 52 - * and clear the selection. Returns the copied text (empty if no selection - * or clipboard operation failed). + * and clear the selection. Returns what `copySelectionNoClear()` reports: + * the copied text and the path that took it, or an empty text and a null + * path when no selection existed or no path took it. */ - async copySelection(): Promise { + async copySelection(): Promise { if (!hasSelection(this.selection)) { - return '' + return NOTHING_COPIED } - const text = await this.copySelectionNoClear() + const copied = await this.copySelectionNoClear() clearSelection(this.selection) this.notifySelectionChange() - return text + return copied } /** Clear the current text selection without copying. */ diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts index 7cb3d4f9..4a4539cd 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts @@ -4,11 +4,15 @@ // Modifications Copyright (c) 2026 EverMind. // See NOTICES.md and LICENSES/MIT-{hermes-agent,ink}.txt. -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { env, supportsOsc52Clipboard } from '../../utils/env.js' -import { shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js' +import { execFileNoThrow } from '../../utils/execFileNoThrow.js' + +import { clipboardDebugEnabled, setClipboard, shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js' + +vi.mock('../../utils/execFileNoThrow.js', () => ({ execFileNoThrow: vi.fn() })) describe('shouldEmitClipboardSequence', () => { it('suppresses local multiplexer clipboard OSC by default', () => { @@ -99,6 +103,57 @@ describe('supportsOsc52Clipboard', () => { // than mocking copyNative inside setClipboard) matches the package's // existing style — tests pass env/terminal as arguments instead of using // vi.mock — and gives broader coverage of the env x terminal matrix. +describe('RAVEN_TUI clipboard env aliases', () => { + it('honours the RAVEN_TUI spelling of the OSC 52 override', () => { + // The defect this closes: `/copy`'s own failure hint tells the user to + // set RAVEN_TUI_FORCE_OSC52, and every other env knob in this repo uses + // that prefix -- but only the upstream HERMES_TUI names were ever read, + // so following the hint changed nothing. + expect( + shouldEmitClipboardSequence({ RAVEN_TUI_FORCE_OSC52: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv) + ).toBe(true) + expect( + shouldEmitClipboardSequence({ RAVEN_TUI_FORCE_OSC52: '0', SSH_CONNECTION: '1' } as NodeJS.ProcessEnv) + ).toBe(false) + }) + + it('keeps reading the upstream HERMES_TUI names', () => { + // The vendored fork is still upstream code; an env var that worked + // before this change has to keep working after it. + expect( + shouldEmitClipboardSequence({ HERMES_TUI_FORCE_OSC52: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv) + ).toBe(true) + }) + + it('lets the RAVEN_TUI spelling win when both are set', () => { + // Pinning the precedence rather than leaving it to `??` ordering: this + // repo documents the RAVEN_TUI name, so that is the one a user who set + // both most recently meant. + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_FORCE_OSC52: '0', + RAVEN_TUI_FORCE_OSC52: '1', + TMUX: '/tmp/t,1,0' + } as NodeJS.ProcessEnv) + ).toBe(true) + }) +}) + +describe('clipboardDebugEnabled', () => { + it('accepts either env prefix', () => { + // The same hint promises RAVEN_TUI_DEBUG_CLIPBOARD=1 explains a failed + // copy. It read HERMES_TUI_DEBUG_CLIPBOARD only, so the diagnostic the + // user was told to turn on stayed silent. + expect(clipboardDebugEnabled({ RAVEN_TUI_DEBUG_CLIPBOARD: '1' } as NodeJS.ProcessEnv)).toBe(true) + expect(clipboardDebugEnabled({ HERMES_TUI_DEBUG_CLIPBOARD: '1' } as NodeJS.ProcessEnv)).toBe(true) + }) + + it('stays off when neither is set', () => { + expect(clipboardDebugEnabled({} as NodeJS.ProcessEnv)).toBe(false) + expect(clipboardDebugEnabled({ RAVEN_TUI_DEBUG_CLIPBOARD: '' } as NodeJS.ProcessEnv)).toBe(false) + }) +}) + describe('shouldUseNativeClipboard', () => { it('returns false over SSH (native would write to remote clipboard)', () => { // Over SSH the user's terminal is on the local end of the pty; @@ -195,3 +250,71 @@ describe('shouldUseNativeClipboard', () => { expect(typeof shouldUseNativeClipboard()).toBe('boolean') }) }) + +describe('setClipboard path reporting', () => { + // The path is what a caller puts in front of the user, and inside tmux the + // environment cannot tell a load-buffer that worked from one that did not: + // both have TMUX set. So it has to come from the call, which means driving + // the real one with `tmux` stubbed rather than asserting on a predictor. + const run = vi.mocked(execFileNoThrow) + const realPlatform = process.platform + + beforeEach(() => { + run.mockReset() + // SSH suppresses the native tool, so tmux and OSC 52 are the only paths + // left and the case is not decided by whatever this runner has installed. + vi.stubEnv('SSH_CONNECTION', '1') + vi.stubEnv('TMUX', '/tmp/tmux-1/default,1,0') + vi.stubEnv('RAVEN_TUI_FORCE_OSC52', '1') + }) + + afterEach(() => { + vi.unstubAllEnvs() + // One case forces the platform; restore it so the rest of the file, and any + // runner that is not Linux, are not left with it. + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) + }) + + it('names the tmux buffer when load-buffer succeeds', async () => { + run.mockResolvedValue({ stdout: '', stderr: '', code: 0 }) + + await expect(setClipboard('probe')).resolves.toMatchObject({ success: true, path: 'tmux-buffer' }) + }) + + it('names osc52 when load-buffer failed and the sequence carried the text', async () => { + // A stale TMUX socket: the variable is set, the server is gone. The bytes + // went out as raw OSC 52, so telling the user to check tmux set-clipboard + // sends them to a setting that had nothing to do with it. + run.mockResolvedValue({ stdout: '', stderr: 'no server running', code: 1 }) + + const result = await setClipboard('probe') + + expect(result.success).toBe(true) + expect(result.path).toBe('osc52') + expect(result.sequence).toContain(']52;c;') + }) + + it('waits for the first Linux probe before claiming a native path', async () => { + // A display server says a native tool could exist, not that one does. The + // first call used to report `native` while the probe was still running, so + // a machine with DISPLAY and no wl-copy/xclip/xsel was told its very first + // copy landed. Nothing had been written. + vi.stubEnv('DISPLAY', ':0') + vi.stubEnv('TMUX', '') + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('RAVEN_TUI_FORCE_OSC52', '0') + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + run.mockResolvedValue({ stdout: '', stderr: 'not found', code: 1 }) + + await expect(setClipboard('probe')).resolves.toMatchObject({ success: false, path: null }) + }) + + it('reports no path when nothing took the text', async () => { + // Suppressing the sequence with the same override leaves a failed + // load-buffer as the only attempt, and success false has to agree. + vi.stubEnv('RAVEN_TUI_FORCE_OSC52', '0') + run.mockResolvedValue({ stdout: '', stderr: 'no server running', code: 1 }) + + await expect(setClipboard('probe')).resolves.toMatchObject({ success: false, path: null }) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts index 0949ab75..96373b83 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -58,39 +58,42 @@ export function wrapForMultiplexer(sequence: string): string { } /** - * Which path setClipboard() will take, based on env state. Synchronous so - * callers can show an honest toast without awaiting the copy itself. + * Which path a clipboard write actually took. * - * - 'native': pbcopy (or equivalent) will run — high-confidence system - * clipboard write. tmux buffer may also be loaded as a bonus. - * - 'tmux-buffer': tmux load-buffer will run, but no native tool — paste - * with prefix+] works. System clipboard depends on tmux's set-clipboard - * option + outer terminal OSC 52 support; can't know from here. - * - 'osc52': only the raw OSC 52 sequence will be written to stdout. - * Best-effort; iTerm2 disables OSC 52 by default. + * - 'native': pbcopy (or equivalent) ran -- high-confidence system clipboard + * write. The tmux buffer may have been loaded as a bonus. + * - 'tmux-buffer': tmux load-buffer succeeded but no native tool ran -- paste + * with prefix+] works. Reaching the system clipboard from there depends on + * tmux's set-clipboard option plus the outer terminal's OSC 52 support, + * which cannot be known from here. + * - 'osc52': only the raw OSC 52 sequence went to stdout. Best-effort; iTerm2 + * disables OSC 52 by default. * - * pbcopy gating uses SSH_CONNECTION specifically, not SSH_TTY — tmux panes - * inherit SSH_TTY forever even after local reattach, but SSH_CONNECTION is - * in tmux's default update-environment set and gets cleared. + * Reported by `setClipboard()` from what it observed, not derived from the + * environment afterwards. The difference is load-bearing: inside tmux, a + * load-buffer that fails falls through to raw OSC 52, and the environment + * still looks exactly like the tmux case that did work. Callers put this + * value in front of the user, and a path named wrongly sends them to fix + * something that was never broken. */ export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52' -export function getClipboardPath(): ClipboardPath { - const nativeAvailable = process.platform === 'darwin' && !process.env['SSH_CONNECTION'] - - if (nativeAvailable) { - return 'native' - } - - if (process.env['TMUX']) { - return 'tmux-buffer' - } - - return 'osc52' +/** + * Whether to log why a clipboard write took the path it did. + * + * Reads the RAVEN_TUI spelling as well as the upstream HERMES_TUI one: this + * repo documents the former (it is what `/copy` tells the user to set) and + * vendors the latter. + */ +export function clipboardDebugEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.RAVEN_TUI_DEBUG_CLIPBOARD || env.HERMES_TUI_DEBUG_CLIPBOARD) } export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env): boolean { const override = ( + env.RAVEN_TUI_FORCE_OSC52 ?? + env.RAVEN_TUI_CLIPBOARD_OSC52 ?? + env.RAVEN_TUI_COPY_OSC52 ?? env.HERMES_TUI_FORCE_OSC52 ?? env.HERMES_TUI_CLIPBOARD_OSC52 ?? env.HERMES_TUI_COPY_OSC52 ?? @@ -143,7 +146,8 @@ export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env * `allow-passthrough`, which many users don't have configured. * * The OSC-52-will-emit guard matters too: if the user has set - * HERMES_TUI_FORCE_OSC52=0, no OSC 52 sequence will be written. If + * RAVEN_TUI_FORCE_OSC52=0 (or the HERMES_TUI_ alias), no OSC 52 + * sequence will be written. If * we ALSO skip native, the clipboard write becomes a no-op. So skip * native only when OSC 52 will actually carry the data. */ @@ -152,13 +156,17 @@ export function shouldUseNativeClipboard( terminal: string | null = envModule.terminal ): boolean { // Over SSH the native tools would write to the wrong machine's clipboard. + // SSH_CONNECTION specifically, not SSH_TTY: a tmux pane inherits SSH_TTY + // forever, even after the client detaches and reattaches locally, while + // SSH_CONNECTION is in tmux's default update-environment set and gets + // cleared. if (env.SSH_CONNECTION) { return false } // Inside tmux/screen, OSC 52 is normally suppressed and we rely on // tmux load-buffer instead — so the wl-copy/OSC-52 race usually doesn't - // apply. Even when HERMES_TUI_FORCE_OSC52=1 forces a tmux-passthrough + // apply. Even when RAVEN_TUI_FORCE_OSC52=1 forces a tmux-passthrough // OSC 52 emission, we keep native enabled as a safety net: tmux's // outer-terminal forwarding depends on `allow-passthrough` in the // user's tmux config, so a forced OSC 52 may silently never reach the @@ -258,6 +266,8 @@ export async function tmuxLoadBuffer(text: string): Promise { export type ClipboardResult = { sequence: string success: boolean + /** The path that took the text, or null when no path did (success false). */ + path: ClipboardPath | null } export async function setClipboard(text: string): Promise { @@ -284,15 +294,21 @@ export async function setClipboard(text: string): Promise { // than raw OSC 52, so the wl-copy race usually doesn't apply, and // native is kept as a safety net because tmux passthrough forwarding // depends on the user's `allow-passthrough` config (note: when - // HERMES_TUI_FORCE_OSC52=1 we DO additionally emit a tmux-passthrough + // RAVEN_TUI_FORCE_OSC52=1 we DO additionally emit a tmux-passthrough // OSC 52, but it can be silently dropped without that setting). // Native also fires when the user has disabled OSC 52 emission via - // HERMES_TUI_FORCE_OSC52=0 (otherwise the clipboard write becomes a - // complete no-op). Fire-and-forget, but `nativeAttempted` tells us - // whether ANY native path will be tried. - const nativeAttempted = shouldUseNativeClipboard(process.env, envModule.terminal) && copyNative(text) + // RAVEN_TUI_FORCE_OSC52=0 (otherwise the clipboard write becomes a + // complete no-op). The spawn stays fire-and-forget; what is awaited is + // only whether a tool exists to spawn, which on Linux's first copy is + // not known until the probe settles. + // Started before the await, resolved after it: on Linux's first copy the tool + // is not known yet, and reporting a native write before the probe settles + // claims a copy that no tool performed. The probe now runs alongside + // load-buffer instead of ahead of the report. + const nativePending = shouldUseNativeClipboard(process.env, envModule.terminal) && copyNative(text) const tmuxBufferLoaded = await tmuxLoadBuffer(text) + const nativeAttempted = await nativePending // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling // too, and BEL works everywhere for OSC 52. @@ -306,7 +322,19 @@ export async function setClipboard(text: string): Promise { // load failed), in which case reporting failure to the user is honest. const success = nativeAttempted || tmuxBufferLoaded || sequence.length > 0 - return { sequence, success } + // Same precedence the doc above describes, read off what happened rather + // than off the environment: native outranks tmux because it is the write + // that is not contingent on a terminal or multiplexer setting, and a failed + // load-buffer has to fall through to osc52 here exactly as the data did. + const path: ClipboardPath | null = nativeAttempted + ? 'native' + : tmuxBufferLoaded + ? 'tmux-buffer' + : sequence.length > 0 + ? 'osc52' + : null + + return { sequence, success, path } } // Linux clipboard tool: undefined = not yet probed, null = none available. @@ -351,8 +379,13 @@ async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> { * Linux behaviour: if DISPLAY and WAYLAND_DISPLAY are both unset, native * clipboard tools cannot work (they need a display server). In that case * we skip probing entirely and treat linuxCopy as permanently null. + * + * The first Linux call answers with a promise, because until the probe settles + * there is no answer to give: a display server says a tool could exist, not + * that one does. Every other call answers synchronously, so only that first + * copy pays for it, and the caller starts this before its own await. */ -function copyNative(text: string): boolean { +function copyNative(text: string): boolean | Promise { const opts = { input: text, useCwd: false, timeout: 2000 } switch (process.platform) { @@ -376,7 +409,7 @@ function copyNative(text: string): boolean { // No display server → native tools will fail immediately. Cache null. if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { - if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + if (clipboardDebugEnabled()) { console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable') } @@ -384,15 +417,16 @@ function copyNative(text: string): boolean { return false } - // First call: probe in the background and cache the result for future copies. - // We don't await — this is fire-and-forget. Treat as an attempt: - // the probe will discover a tool and spawn it. If probing finds - // nothing, the NEXT copy will short-circuit above. - void (async () => { + // First call: probe, cache the result for future copies, and answer with + // whether a tool was actually found. A display server means a tool could + // exist, not that one does, so answering true here would report a copy + // that nothing performed. The copy itself already waited on this probe -- + // only the report used to run ahead of it. + return (async () => { const winner = await probeLinuxCopy() linuxCopy = winner - if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + if (clipboardDebugEnabled()) { console.error(`[clipboard] [native] Linux: clipboard probe complete → ${winner ?? 'no tool available'}`) } @@ -400,9 +434,9 @@ function copyNative(text: string): boolean { if (winner) { void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) } - })() - return true + return winner !== null + })() } case 'win32': diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index 34edce53..03e093fe 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -5,7 +5,15 @@ import { describe, expect, it, vi } from 'vitest' -import { isUsableClipboardText, readClipboardText, writeClipboardText } from '../lib/clipboard.js' +import { + copyOnSelectNotice, + createCopyOnSelectReporter, + graphemeCount, + copyResultNotice, + isUsableClipboardText, + readClipboardText, + writeClipboardText +} from '../lib/clipboard.js' describe('readClipboardText', () => { it('reads text from pbpaste on macOS', async () => { @@ -324,3 +332,113 @@ describe('writeClipboardText', () => { ) }) }) + +describe('copyResultNotice', () => { + it('says the copy only left as an escape sequence', () => { + // The defect this closes: over SSH with no native clipboard tool, a copy + // that never reached the user's terminal still reported a flat "copied 42 + // characters". The one case where the user has something to fix is the + // one case the message has to name. + const notice = copyResultNotice(42, 'osc52') + + expect(notice).toContain('42') + expect(notice).toContain('OSC 52') + expect(notice.toLowerCase()).toContain('terminal') + }) + + it('reports a native copy without a caveat', () => { + // pbcopy/wl-copy actually wrote the clipboard, so hedging here would + // train the user to ignore the wording in the case that matters. + const notice = copyResultNotice(7, 'native') + + expect(notice).toBe('copied 7 characters') + }) + + it('names the tmux buffer as the thing that was written', () => { + // tmux load-buffer succeeded; whether that reaches the system clipboard + // is the user's set-clipboard setting, not something we can claim. + const notice = copyResultNotice(9, 'tmux-buffer') + + expect(notice).toContain('9') + expect(notice).toContain('tmux') + }) + + it('counts one character as one, not as a plural', () => { + expect(copyResultNotice(1, 'native')).toBe('copied 1 character') + }) +}) + +describe('copyOnSelectNotice', () => { + it('carries the path caveat on the first copy of a session', () => { + // The user has to learn once that OSC 52 is best-effort and where the + // switch lives. The first drag is the only moment that lands. + expect(copyOnSelectNotice(42, 'osc52', true)).toBe(copyResultNotice(42, 'osc52')) + }) + + it('goes terse after that', () => { + // This fires on every drag. Repeating a full sentence about terminal + // settings would bury the transcript the feature exists to let you read. + expect(copyOnSelectNotice(42, 'osc52', false)).toBe('sent 42 characters') + expect(copyOnSelectNotice(1, 'tmux-buffer', false)).toBe('copied 1 character') + }) +}) + +describe('createCopyOnSelectReporter', () => { + it('spends the caveat once per session, not once per process', () => { + // A new or resumed session replaces the sid under a component that stays + // mounted. Carrying one flag across that boundary loses the caveat for + // every session after the first, which is where a user meets an OSC 52 + // paste that silently came up empty. + const report = createCopyOnSelectReporter() + + expect(report(42, 'osc52', 's1')).toBe(copyResultNotice(42, 'osc52')) + expect(report(42, 'osc52', 's1')).toBe('sent 42 characters') + expect(report(42, 'osc52', 's2')).toBe(copyResultNotice(42, 'osc52')) + }) + + it('does not repeat the caveat when a session is returned to', () => { + // The caveat is about this session having been told, so resuming one that + // already heard it has nothing to add. + const report = createCopyOnSelectReporter() + + report(42, 'osc52', 's1') + report(42, 'osc52', 's2') + + expect(report(42, 'osc52', 's1')).toBe('sent 42 characters') + }) + + it('keeps its own tally per reporter', () => { + // Two TUI processes must not share the fact that one of them has reported. + expect(createCopyOnSelectReporter()(42, 'osc52', 's1')).toBe(copyResultNotice(42, 'osc52')) + expect(createCopyOnSelectReporter()(42, 'osc52', 's1')).toBe(copyResultNotice(42, 'osc52')) + }) +}) + +describe('copyResultNotice honesty', () => { + it('does not claim a copy on the one path whose outcome it cannot see', () => { + // OSC 52 writes bytes to the terminal and the terminal decides whether to + // honour them -- and silently drops an oversized sequence. A 2000-row + // drag-scroll selection is a single half-megabyte escape sequence that no + // terminal accepts, and setClipboard() still reports success because bytes + // were written. "copied" is a claim about the outcome; "sent" is what we + // actually know. + expect(copyResultNotice(42, 'osc52')).toContain('sent 42 characters') + expect(copyResultNotice(42, 'osc52')).not.toContain('copied') + }) + + it('still says copied where a native tool really wrote the clipboard', () => { + expect(copyResultNotice(42, 'native')).toBe('copied 42 characters') + }) + + it('counts what a reader would call a character, not utf-16 code units', () => { + // Three emoji are six code units. Reporting "6 characters" for a + // three-character selection is a small lie in the one line whose whole + // job is telling the truth about the copy. + expect(copyResultNotice([...'\u{1f389}\u{1f389}\u{1f389}'].length, 'native')).toBe('copied 3 characters') + expect(graphemeCount('\u{1f389}\u{1f389}\u{1f389}')).toBe(3) + expect(graphemeCount('\u4f60\u597d\u4e16\u754c')).toBe(4) + expect(graphemeCount('hello')).toBe(5) + // e + combining acute is one character on screen and two code points. + expect(graphemeCount('e\u0301cole')).toBe(5) + }) +}) diff --git a/ui-tui/src/__tests__/copyOnSelect.test.ts b/ui-tui/src/__tests__/copyOnSelect.test.ts new file mode 100644 index 00000000..d72e016d --- /dev/null +++ b/ui-tui/src/__tests__/copyOnSelect.test.ts @@ -0,0 +1,249 @@ +/** + * Behaviour of the copy-on-select subscription: which settled selections reach + * the clipboard, which bus notifications must not, and what the caller is told. + * + * The clipboard write itself belongs to ink, so the bus is faked and every + * assertion is about what this module decides to do with it. + */ + +import type { ClipboardPath } from '@hermes/ink' + +import { describe, expect, it } from 'vitest' + +import { subscribeCopyOnSelect } from '../lib/copyOnSelect.js' + +const flush = () => new Promise(resolve => setImmediate(resolve)) + +/** Stand-in for the ink selection bus. The real one needs a live Ink + * instance bound to a TTY, so the bus is faked and the assertions are on + * what this module decides to copy. */ +function fakeSelection() { + const listeners = new Set<() => void>() + const copied: string[] = [] + const reported: string[] = [] + const reportedPaths: (ClipboardPath | null)[] = [] + const state: { + dragging: boolean + path: ClipboardPath + present: boolean + rawState?: unknown + text: string + version: number + writeSucceeded: boolean + } = { + dragging: false, + path: 'native', + present: true, + text: 'selected text', + version: 1, + writeSucceeded: true + } + + return { + copied, + onCopied: (text: string, path: ClipboardPath) => { + reported.push(text) + reportedPaths.push(path) + }, + notify: () => { + for (const cb of listeners) { + cb() + } + }, + selection: { + copySelectionNoClear: async () => { + copied.push(state.text) + + return state.writeSucceeded ? { text: state.text, path: state.path } : { text: '', path: null } + }, + getState: (): unknown => state.rawState ?? { isDragging: state.dragging }, + hasSelection: () => state.present, + subscribe: (cb: () => void) => { + listeners.add(cb) + + return () => listeners.delete(cb) + }, + version: () => state.version + }, + reported, + reportedPaths, + state + } +} + +describe('subscribeCopyOnSelect', () => { + it('copies a settled selection on a non-macOS platform', () => { + // The case is the host platform's own: there is no platform to inject, so + // reaching the copy at all is what says no platform gate remains. That + // makes the assertion meaningful only on a non-macOS runner. + const bus = fakeSelection() + + subscribeCopyOnSelect(bus.selection) + bus.notify() + + expect(bus.copied).toEqual(['selected text']) + }) + + it('leaves the clipboard alone while the drag is still moving', () => { + // Copying every drag-move tick would overwrite the clipboard dozens of + // times per selection and hand the user whatever partial span the mouse + // happened to be crossing. + // + // Paired with the settled case on the same subscription: an assertion that + // nothing was copied passes just as well when the bus was never wired up, + // so the second half is what makes the first half mean anything. + const bus = fakeSelection() + + bus.state.dragging = true + subscribeCopyOnSelect(bus.selection) + bus.notify() + + expect(bus.copied).toEqual([]) + + bus.state.dragging = false + bus.notify() + + expect(bus.copied).toEqual(['selected text']) + }) + + it('copies one selection version only once', () => { + // The bus re-notifies on mutations that do not change the span, so + // without version de-duping a single drag produced repeat clipboard + // writes -- each one a fresh OSC 52 burst at the terminal. + const bus = fakeSelection() + + subscribeCopyOnSelect(bus.selection) + bus.notify() + bus.notify() + bus.notify() + + expect(bus.copied).toEqual(['selected text']) + }) + + it('copies again once the selection actually changes', () => { + // The flip side of de-duping: a second drag must still reach the + // clipboard, or copy-on-select works exactly once per session. + const bus = fakeSelection() + + subscribeCopyOnSelect(bus.selection) + bus.notify() + + bus.state.text = 'a later selection' + bus.state.version = 2 + bus.notify() + + expect(bus.copied).toEqual(['selected text', 'a later selection']) + }) + + it('ignores a notification that carries no selection', () => { + // Clearing the selection also notifies. Copying there would push an + // empty string over whatever the user had on their clipboard. + const bus = fakeSelection() + + bus.state.present = false + subscribeCopyOnSelect(bus.selection) + bus.notify() + + expect(bus.copied).toEqual([]) + + bus.state.present = true + bus.notify() + + expect(bus.copied).toEqual(['selected text']) + }) + + it('treats a bus with no readable state as not dragging', () => { + // `useSelection().getState()` is typed `unknown` by the ambient + // declaration, so this module has to narrow rather than assume. A bus + // that reports nothing must not strand the selection uncopied. + const bus = fakeSelection() + + bus.state.rawState = null + subscribeCopyOnSelect(bus.selection) + bus.notify() + + expect(bus.copied).toEqual(['selected text']) + }) + + it('stops copying once the subscription is disposed', () => { + // The React effect returns this for cleanup; if it did not unsubscribe, + // a remounted transcript would copy once per stale listener. Copy first so + // the silence afterwards is attributable to the disposal. + const bus = fakeSelection() + + const unsubscribe = subscribeCopyOnSelect(bus.selection) + + bus.notify() + + expect(bus.copied).toEqual(['selected text']) + + unsubscribe() + bus.state.version = 2 + bus.notify() + + expect(bus.copied).toEqual(['selected text']) + }) +}) + +describe('subscribeCopyOnSelect reporting', () => { + it('hands the copied text to the caller once the write lands', async () => { + // Copy-on-select is silent by nature -- nothing on screen changes when a + // drag ends. Without a report there is no way for the user to tell a + // working copy from a dead one. + const bus = fakeSelection() + + subscribeCopyOnSelect(bus.selection, bus.onCopied) + bus.notify() + await flush() + + expect(bus.reported).toEqual(['selected text']) + }) + + it('reports the path the write took, not one re-derived afterwards', async () => { + const bus = fakeSelection() + + bus.state.path = 'osc52' + subscribeCopyOnSelect(bus.selection, bus.onCopied) + bus.notify() + await flush() + + expect(bus.reportedPaths).toEqual(['osc52']) + }) + + it('stays silent when no clipboard path took the text', async () => { + // `copySelectionNoClear()` resolves to '' when nothing reached the + // clipboard, so the empty string is the whole signal -- reporting a copy + // on it would claim a write that never happened. + const bus = fakeSelection() + + bus.state.writeSucceeded = false + subscribeCopyOnSelect(bus.selection, bus.onCopied) + bus.notify() + await flush() + + expect(bus.copied).toEqual(['selected text']) + expect(bus.reported).toEqual([]) + }) + + it('reports once per selection, not once per notification', async () => { + const bus = fakeSelection() + + subscribeCopyOnSelect(bus.selection, bus.onCopied) + bus.notify() + bus.notify() + await flush() + + expect(bus.reported).toEqual(['selected text']) + }) + + it('works with no reporter attached', async () => { + // The callback is optional; dropping it must not turn a copy into a crash. + const bus = fakeSelection() + + subscribeCopyOnSelect(bus.selection) + bus.notify() + await flush() + + expect(bus.copied).toEqual(['selected text']) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9afe26cb..c58fa45d 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -3,7 +3,7 @@ // Modifications Copyright (c) 2026 EverMind. // See NOTICES.md and LICENSES/MIT-hermes-agent.txt. -import type { ScrollBoxHandle } from '@hermes/ink' +import type { ScrollBoxHandle, SelectionCopy } from '@hermes/ink' import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react' import type { PasteEvent } from '../components/textInput.js' @@ -47,8 +47,8 @@ export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji' export interface SelectionApi { captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void clearSelection: () => void - copySelection: () => Promise - copySelectionNoClear: () => Promise + copySelection: () => Promise + copySelectionNoClear: () => Promise getState: () => unknown version: () => number shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index f9bb861f..1bd12c5a 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -22,7 +22,7 @@ import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' -import { writeClipboardText } from '../../../lib/clipboard.js' +import { copyResultNotice, graphemeCount, writeClipboardText } from '../../../lib/clipboard.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' import { patchOverlayState } from '../../overlayStore.js' @@ -355,15 +355,15 @@ export const coreCommands: SlashCommand[] = [ const { sys } = ctx.transcript if (!arg && ctx.composer.hasSelection) { - const text = await ctx.composer.selection.copySelection() + const { text, path } = await ctx.composer.selection.copySelection() - if (text) { - return sys(`copied ${text.length} characters`) - } else { - return sys( - 'clipboard copy failed — try RAVEN_TUI_FORCE_OSC52=1 to force the escape sequence; RAVEN_TUI_DEBUG_CLIPBOARD=1 for details' - ) + if (text && path) { + return sys(copyResultNotice(graphemeCount(text), path)) } + + return sys( + 'clipboard copy failed — try RAVEN_TUI_FORCE_OSC52=1 to force the escape sequence; RAVEN_TUI_DEBUG_CLIPBOARD=1 for details' + ) } if (arg && Number.isNaN(parseInt(arg, 10))) { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index cbf5e2e5..7f8e43ca 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -25,10 +25,12 @@ import { type GatewayClient } from '../gatewayClientStub.js' import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { approvalResponseAccepted, buildApprovalRespond } from '../lib/approval.js' +import { createCopyOnSelectReporter, graphemeCount } from '../lib/clipboard.js' import { buildConfirmRespond } from '../lib/confirmCountdown.js' +import { subscribeCopyOnSelect } from '../lib/copyOnSelect.js' import { composerPromptWidth } from '../lib/inputMetrics.js' import { appendTranscriptMessage } from '../lib/messages.js' -import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' +import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' @@ -177,47 +179,11 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { const hasSelection = useHasSelection() const selection = useSelection() - const lastCopiedVersionRef = useRef(-1) useEffect(() => { selection.setSelectionBgColor(ui.theme.color.selectionBg) }, [selection, ui.theme.color.selectionBg]) - // macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable - // mouse tracking, so the only reliable native-feeling path is iTerm-style - // copy-on-select: once a drag creates a stable TUI selection, write it to - // the system clipboard while keeping the highlight visible. - // - // Subscribe directly via the ink selection bus (not useSyncExternalStore) - // so React doesn't re-render MainApp on every drag-move tick. The version - // ref de-dupes against re-entrant notifications. - useEffect(() => { - if (!isMac) { - return - } - - return selection.subscribe(() => { - if (!selection.hasSelection()) { - return - } - - const state = selection.getState() as { isDragging?: boolean } | null - - if (state?.isDragging) { - return - } - - const version = selection.version() - - if (version === lastCopiedVersionRef.current) { - return - } - - lastCopiedVersionRef.current = version - void selection.copySelectionNoClear() - }) - }, [selection]) - const clearSelection = useCallback(() => { selection.clearSelection() getInputSelection()?.collapseToEnd() @@ -356,6 +322,31 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage]) + // Terminals do not forward their own copy shortcut to a TUI that enables + // mouse tracking, so copy-on-select is what makes a transcript selection + // copyable at all. That holds on every platform, not just macOS. + // + // Nothing on screen changes when a drag ends, so the transcript line is the + // only confirmation the clipboard was written. Lives below `sys` because the + // dependency array is evaluated during render, while `sys` is still in its + // temporal dead zone further up. + // + // The path caveat is per session while this hook outlives any one session: + // `newSession()` and `resumeById()` replace `ui.sid` without remounting it, + // so which sessions have been told belongs to the reporter rather than to a + // flag here, which would stay set and drop the caveat from the next + // session's first copy. The sid is read through `getUiState()` so a session + // change does not tear down and rebuild the bus subscription. + const reportCopyOnSelect = useRef(createCopyOnSelectReporter()) + + useEffect( + () => + subscribeCopyOnSelect(selection, (text, path) => { + sys(reportCopyOnSelect.current(graphemeCount(text), path, getUiState().sid ?? 'draft')) + }), + [selection, sys] + ) + const page = useCallback( (text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }), [] diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index f74bd8d2..7382aaec 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -3,6 +3,7 @@ // Modifications Copyright (c) 2026 EverMind. // See NOTICES.md and LICENSES/MIT-hermes-agent.txt. +import { type ClipboardPath } from '@hermes/ink' import { execFile, spawn } from 'node:child_process' import { promisify } from 'node:util' @@ -169,3 +170,92 @@ export async function writeClipboardText( return false } + +/** + * Transcript line for a completed copy, naming the channel it actually took. + * + * Only the OSC 52 path can silently fail: the bytes reach the terminal and + * the terminal decides whether to honour them, which is a setting the user + * owns. Saying so there -- and not saying it where a native tool really did + * write the clipboard -- is what keeps the wording worth reading. + */ +/** + * How many characters a reader would say the text has. + * + * `String.length` counts UTF-16 code units, so it reports three emoji as six + * and a combining accent as two. Segmenter is the only built-in that counts + * what is on screen; the spread fallback at least collapses surrogate pairs. + */ +export function graphemeCount(text: string): number { + if (typeof Intl.Segmenter === 'function') { + let count = 0 + + for (const _ of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text)) { + count++ + } + + return count + } + + return [...text].length +} + +/** 'copied'/'sent' plus a correctly pluralised count. The verb is the caller's + * because only the native and tmux paths actually wrote anything. */ +function counted(verb: string, charCount: number): string { + return `${verb} ${charCount} character${charCount === 1 ? '' : 's'}` +} + +/** OSC 52 hands bytes to the terminal and the terminal decides whether to keep + * them -- an oversized sequence is dropped without a word, and a 2000-row + * drag-scroll selection is one half-megabyte escape sequence. So that path + * reports what was sent; the paths that really wrote a clipboard say copied. */ +const verbFor = (path: ClipboardPath): string => (path === 'osc52' ? 'sent' : 'copied') + +export function copyResultNotice(charCount: number, path: ClipboardPath): string { + const head = counted(verbFor(path), charCount) + + switch (path) { + case 'native': + return head + + case 'osc52': + return `${head} via OSC 52 — if the paste comes up empty, allow clipboard access in your terminal` + + case 'tmux-buffer': + return `${head} to the tmux buffer — reaching the system clipboard needs tmux set-clipboard` + } +} + +/** + * Transcript line for an automatic copy-on-select write. + * + * This fires on every drag, so it stays terse -- except the first one of a + * session, which carries the path caveat. OSC 52 is the one path the terminal + * can still refuse, and the first copy is the only moment a user is looking + * for the reason a paste came up empty. + */ +export function copyOnSelectNotice(charCount: number, path: ClipboardPath, firstOfSession: boolean): string { + return firstOfSession ? copyResultNotice(charCount, path) : counted(verbFor(path), charCount) +} + +/** + * Report copies for a TUI process, spending the path caveat once per session. + * + * The caller keeps one of these for as long as its component lives, which + * outlasts any single session -- so which sessions have already been told is + * state this has to own, rather than a boolean the caller flips. `sessionKey` + * is whatever identifies the current session to the caller; a resumed session + * reaching the same key has already had its caveat and does not repeat it. + */ +export function createCopyOnSelectReporter(): (charCount: number, path: ClipboardPath, sessionKey: string) => string { + const told = new Set() + + return (charCount, path, sessionKey) => { + const firstOfSession = !told.has(sessionKey) + + told.add(sessionKey) + + return copyOnSelectNotice(charCount, path, firstOfSession) + } +} diff --git a/ui-tui/src/lib/copyOnSelect.ts b/ui-tui/src/lib/copyOnSelect.ts new file mode 100644 index 00000000..5af09831 --- /dev/null +++ b/ui-tui/src/lib/copyOnSelect.ts @@ -0,0 +1,77 @@ +/** + * iTerm-style copy-on-select for the TUI transcript. + * + * A TUI that enables mouse tracking owns the drag, so the terminal never + * builds a native selection and its own copy shortcut has nothing to copy. + * Writing the span to the clipboard as soon as the drag settles is what makes + * a TUI selection copyable at all, on every platform. + */ + +import type { ClipboardPath, SelectionCopy } from '@hermes/ink' + +/** The slice of the ink selection bus this needs. Structural rather than the + * full `useSelection()` return so the module stays testable without a live + * Ink instance. */ +export type CopyOnSelectSelection = { + copySelectionNoClear: () => Promise + getState: () => unknown + hasSelection: () => boolean + subscribe: (cb: () => void) => () => void + version: () => number +} + +/** + * Copy each settled selection to the clipboard, keeping the highlight, and + * hand the copied text to `onCopied` once a clipboard path actually took it, + * together with the path that took it. Returns the bus unsubscribe, so a React + * effect can return it directly. + * + * `copySelectionNoClear()` resolves to an empty text when no path reached the + * clipboard, so the callback fires on a real write only. The path comes from + * the write itself: it cannot be re-derived from the environment, because a + * tmux load-buffer that failed leaves the environment looking like one that + * worked. + * + * Subscribes to the bus rather than going through `useSyncExternalStore` so + * the transcript does not re-render on every drag-move tick, and de-dupes on + * the selection version because the bus also notifies for mutations that + * leave the span unchanged. + */ +/** The ambient `useSelection()` declaration types the bus state as + * `unknown`, so read the one field this needs instead of asserting a shape. + * Anything unreadable counts as "not dragging" -- the drag is over far more + * often than the state is missing, and guessing the other way would drop the + * copy entirely. */ +function isDragging(state: unknown): boolean { + return typeof state === 'object' && state !== null && (state as { isDragging?: unknown }).isDragging === true +} + +export function subscribeCopyOnSelect( + selection: CopyOnSelectSelection, + onCopied?: (text: string, path: ClipboardPath) => void +): () => void { + let lastCopiedVersion = -1 + + return selection.subscribe(() => { + if (!selection.hasSelection()) { + return + } + + if (isDragging(selection.getState())) { + return + } + + const version = selection.version() + + if (version === lastCopiedVersion) { + return + } + + lastCopiedVersion = version + void selection.copySelectionNoClear().then(({ text, path }) => { + if (text && path) { + onCopied?.(text, path) + } + }) + }) +} diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index bc860a81..f2cd3d4e 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -156,8 +156,8 @@ declare module '@hermes/ink' { export function withInkSuspended(run: RunExternalProcess): Promise export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void export function useSelection(): { - readonly copySelection: () => Promise - readonly copySelectionNoClear: () => Promise + readonly copySelection: () => Promise + readonly copySelectionNoClear: () => Promise readonly clearSelection: () => void readonly hasSelection: () => boolean readonly getState: () => unknown @@ -169,6 +169,11 @@ declare module '@hermes/ink' { readonly captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void readonly setSelectionBgColor: (color: string) => void } + export type ClipboardPath = 'native' | 'osc52' | 'tmux-buffer' + export type SelectionCopy = { + readonly text: string + readonly path: ClipboardPath | null + } export function useHasSelection(): boolean export function useStdout(): { readonly stdout?: NodeJS.WriteStream } export function useTerminalFocus(): boolean