Skip to content
Merged
2 changes: 2 additions & 0 deletions ui-tui/packages/hermes-ink/src/entry-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
10 changes: 6 additions & 4 deletions ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string>
copySelection: () => Promise<SelectionCopy>
/** Copy without clearing the highlight (for copy-on-select). */
copySelectionNoClear: () => Promise<string>
copySelectionNoClear: () => Promise<SelectionCopy>
clearSelection: () => void
hasSelection: () => boolean
/** Read the raw mutable selection state (for drag-to-scroll). */
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 40 additions & 19 deletions ui-tui/packages/hermes-ink/src/ink/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ import {
import {
CLEAR_ITERM2_PROGRESS,
CLEAR_TAB_STATUS,
clipboardDebugEnabled,
type ClipboardPath,
setClipboard,
supportsTabStatus,
wrapForMultiplexer
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string> {
async copySelectionNoClear(): Promise<SelectionCopy> {
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<string> {
async copySelection(): Promise<SelectionCopy> {
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. */
Expand Down
127 changes: 125 additions & 2 deletions ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 })
})
})
Loading
Loading