From 5a3b59bebda8073ba883eafdcaf57620f61b74de Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:25:12 +0000 Subject: [PATCH 1/8] fix(tui): make the clipboard path and its env knobs tell the truth getClipboardPath() returned 'native' only on darwin, while setClipboard() gates the native tool on shouldUseNativeClipboard() plus copyNative()'s own per-platform check. A local Linux desktop with a display server therefore had xclip or wl-copy write the clipboard successfully and was still told the copy had only left as an escape sequence; Windows, where clip.exe always exists, read the same way. The function now asks the same two questions setClipboard() asks, in the same order, and takes env, platform and terminal as arguments so every branch is covered by a case rather than only the one CI runs on. It had no callers before, so nothing depended on the old answer. The OSC 52 override and the clipboard debug switch were readable only under the upstream HERMES_TUI_ names, while every knob this project documents uses RAVEN_TUI_ and /copy's own failure hint names RAVEN_TUI_FORCE_OSC52 and RAVEN_TUI_DEBUG_CLIPBOARD. Following that hint changed nothing. Both spellings are now read, RAVEN_TUI_ first, with the HERMES_TUI_ names kept as aliases so an environment that worked before still works. The debug switch moves behind clipboardDebugEnabled() so its four call sites cannot drift apart. Co-authored-by: Claude (claude-opus-5) --- .../packages/hermes-ink/src/entry-exports.ts | 1 + ui-tui/packages/hermes-ink/src/ink/ink.tsx | 7 +- .../hermes-ink/src/ink/termio/osc.test.ts | 99 ++++++++++++++++++- .../packages/hermes-ink/src/ink/termio/osc.ts | 64 +++++++++--- ui-tui/src/types/hermes-ink.d.ts | 2 + 5 files changed, 158 insertions(+), 15 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 3b502cac..d6418434 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -26,6 +26,7 @@ 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, getClipboardPath } from './ink/termio/osc.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/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 55ce9be7..c314d1a3 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -109,6 +109,7 @@ import { import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, + clipboardDebugEnabled, setClipboard, supportsTabStatus, wrapForMultiplexer @@ -1395,13 +1396,13 @@ export default class Ink { return text } - 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) } } 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..6abd0cf8 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 @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' import { env, supportsOsc52Clipboard } from '../../utils/env.js' -import { shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js' +import { clipboardDebugEnabled, getClipboardPath, shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js' describe('shouldEmitClipboardSequence', () => { it('suppresses local multiplexer clipboard OSC by default', () => { @@ -99,6 +99,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 +246,49 @@ describe('shouldUseNativeClipboard', () => { expect(typeof shouldUseNativeClipboard()).toBe('boolean') }) }) + +describe('getClipboardPath', () => { + // setClipboard() decides native via shouldUseNativeClipboard() and then + // copyNative()'s own per-platform availability. A predictor that disagrees + // with either sends the user to fix the wrong thing, so every branch of both + // gets a case here rather than only the one this CI box runs on. + + it('names native on a plain local macOS terminal', () => { + expect(getClipboardPath({} as NodeJS.ProcessEnv, 'darwin', null)).toBe('native') + }) + + it('names native on a local Linux desktop with a display server', () => { + // The defect this closes: the predictor keyed native off darwin alone, so + // wl-copy/xclip really wrote the clipboard while the user was told the copy + // only left as an escape sequence and to go change a terminal setting. + expect(getClipboardPath({ DISPLAY: ':0' } as NodeJS.ProcessEnv, 'linux', null)).toBe('native') + expect(getClipboardPath({ WAYLAND_DISPLAY: 'wayland-0' } as NodeJS.ProcessEnv, 'linux', null)).toBe('native') + }) + + it('names native on Windows, where clip.exe always exists', () => { + expect(getClipboardPath({} as NodeJS.ProcessEnv, 'win32', null)).toBe('native') + }) + + it('names osc52 on a terminal where setClipboard deliberately skips native', () => { + // On an allowlisted terminal setClipboard suppresses the native tool to + // avoid racing the terminal's own OSC 52 write, so OSC 52 is the only path + // taken -- the old predictor claimed 'native' here. + expect(getClipboardPath({} as NodeJS.ProcessEnv, 'darwin', 'ghostty')).toBe('osc52') + }) + + it('names osc52 over SSH, where native would write the wrong machine', () => { + expect(getClipboardPath({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'linux', null)).toBe('osc52') + expect(getClipboardPath({ DISPLAY: ':0', SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'darwin', null)).toBe('osc52') + }) + + it('names osc52 on headless Linux, where no native tool can run', () => { + expect(getClipboardPath({} as NodeJS.ProcessEnv, 'linux', null)).toBe('osc52') + }) + + it('names the tmux buffer only when no native tool is available', () => { + // Inside tmux with a native tool present, setClipboard fires both and the + // native write is the higher-confidence one, so that is what gets named. + expect(getClipboardPath({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'darwin', null)).toBe('native') + expect(getClipboardPath({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'linux', null)).toBe('tmux-buffer') + }) +}) 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..7ffd2b13 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -69,28 +69,69 @@ export function wrapForMultiplexer(sequence: string): string { * - 'osc52': only the raw OSC 52 sequence will be written to stdout. * Best-effort; iTerm2 disables OSC 52 by default. * - * pbcopy gating uses SSH_CONNECTION specifically, not SSH_TTY — tmux panes + * Decided by asking the same two questions setClipboard() asks, in the same + * order: shouldUseNativeClipboard() for whether the native tool is wanted, and + * copyNative()'s own platform gate for whether one exists. Keeping this in step + * with them is the whole point -- callers put the answer in front of the user, + * and a path named wrongly sends them to fix something that was never broken. + * + * The native gate 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. */ export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52' -export function getClipboardPath(): ClipboardPath { - const nativeAvailable = process.platform === 'darwin' && !process.env['SSH_CONNECTION'] +/** Whether copyNative() has a tool it can actually run on this platform. + * Mirrors its own switch: pbcopy and clip.exe ship with the OS, while the + * Linux tools need a display server to talk to. */ +function nativeToolAvailable(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): boolean { + switch (platform) { + case 'darwin': + case 'win32': + return true + + case 'linux': + return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY) + + default: + return false + } +} - if (nativeAvailable) { +export function getClipboardPath( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + terminal: string | null = envModule.terminal +): ClipboardPath { + // Native first: when it fires alongside tmux or OSC 52 it is the one write + // that is not contingent on a terminal or multiplexer setting. + if (shouldUseNativeClipboard(env, terminal) && nativeToolAvailable(env, platform)) { return 'native' } - if (process.env['TMUX']) { + if (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 +184,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. */ @@ -158,7 +200,7 @@ export function shouldUseNativeClipboard( // 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 @@ -284,10 +326,10 @@ 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 + // RAVEN_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) @@ -376,7 +418,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') } @@ -392,7 +434,7 @@ function copyNative(text: string): boolean { 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'}`) } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index bc860a81..1c45da1c 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -169,6 +169,8 @@ 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 function getClipboardPath(): ClipboardPath export function useHasSelection(): boolean export function useStdout(): { readonly stdout?: NodeJS.WriteStream } export function useTerminalFocus(): boolean From 8bd8e54bddf7308470135b5aaa10268a77d483ca Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:25:27 +0000 Subject: [PATCH 2/8] feat(tui): copy a settled selection on every platform, not just macos 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. Copy-on- select is what makes a transcript selection copyable at all, but the subscription bailed out on !isMac, so on Linux and Windows a drag highlighted text and copied nothing. The subscription moves out of useMainApp into subscribeCopyOnSelect(). Inlined in a hook that needs a live gateway to start, none of its four guards could be tested; each now has a case, and each was checked by removing only that guard and watching the suite go red. The read of the bus state stops being an unchecked cast: the ambient useSelection() declaration types it as unknown, so the module narrows it instead. Nothing on screen changes when a drag ends, so the copy is reported to the transcript once a clipboard path has actually taken the text. The callback fires on a non-empty result only, since copySelectionNoClear() resolves to '' when nothing reached the clipboard, and announcing a copy there would be the same false success this branch removes from /copy. The first report of a session carries the resolved path, later ones stay terse: OSC 52 is the one path a terminal can still refuse, and the first copy is when a user is looking for the reason a paste came up empty. The effect sits below sys() because a dependency array is built during render, where sys is still in its temporal dead zone. Co-authored-by: Claude (claude-opus-5) --- ui-tui/src/__tests__/clipboard.test.ts | 58 +++++- ui-tui/src/__tests__/copyOnSelect.test.ts | 222 ++++++++++++++++++++++ ui-tui/src/app/slash/commands/core.ts | 14 +- ui-tui/src/app/useMainApp.ts | 69 +++---- ui-tui/src/lib/clipboard.ts | 40 ++++ ui-tui/src/lib/copyOnSelect.ts | 68 +++++++ 6 files changed, 425 insertions(+), 46 deletions(-) create mode 100644 ui-tui/src/__tests__/copyOnSelect.test.ts create mode 100644 ui-tui/src/lib/copyOnSelect.ts diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index 34edce53..bf34a1f6 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -5,7 +5,13 @@ import { describe, expect, it, vi } from 'vitest' -import { isUsableClipboardText, readClipboardText, writeClipboardText } from '../lib/clipboard.js' +import { + copyOnSelectNotice, + copyResultNotice, + isUsableClipboardText, + readClipboardText, + writeClipboardText +} from '../lib/clipboard.js' describe('readClipboardText', () => { it('reads text from pbpaste on macOS', async () => { @@ -324,3 +330,53 @@ 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('copied 42 characters') + expect(copyOnSelectNotice(1, 'tmux-buffer', false)).toBe('copied 1 character') + }) +}) diff --git a/ui-tui/src/__tests__/copyOnSelect.test.ts b/ui-tui/src/__tests__/copyOnSelect.test.ts new file mode 100644 index 00000000..43ba2251 --- /dev/null +++ b/ui-tui/src/__tests__/copyOnSelect.test.ts @@ -0,0 +1,222 @@ +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 state: { + dragging: boolean + present: boolean + rawState?: unknown + text: string + version: number + writeSucceeded: boolean + } = { + dragging: false, + present: true, + text: 'selected text', + version: 1, + writeSucceeded: true + } + + return { + copied, + onCopied: (text: string) => reported.push(text), + notify: () => { + for (const cb of listeners) { + cb() + } + }, + selection: { + copySelectionNoClear: async () => { + copied.push(state.text) + + return state.writeSucceeded ? state.text : '' + }, + getState: (): unknown => state.rawState ?? { isDragging: state.dragging }, + hasSelection: () => state.present, + subscribe: (cb: () => void) => { + listeners.add(cb) + + return () => listeners.delete(cb) + }, + version: () => state.version + }, + reported, + state + } +} + +describe('subscribeCopyOnSelect', () => { + it('copies a settled selection on a non-macOS platform', () => { + // The defect this closes: the subscription used to bail out on + // `!isMac`, so a drag on Linux or Windows highlighted text and copied + // nothing. This suite runs on whatever the host is -- on Linux (this + // box, and CI) reaching the copy IS the proof the platform gate is gone. + 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('stays silent when no clipboard path took the text', async () => { + // `copySelectionNoClear()` resolves to '' when nothing reached the + // clipboard. Announcing a copy there would be the exact lie this change + // set out to remove from `/copy`. + 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/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index f9bb861f..a5295d32 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -3,7 +3,7 @@ // Modifications Copyright (c) 2026 EverMind. // See NOTICES.md and LICENSES/MIT-hermes-agent.txt. -import { forceRedraw } from '@hermes/ink' +import { forceRedraw, getClipboardPath } from '@hermes/ink' import type { ConfigGetValueResponse, @@ -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, writeClipboardText } from '../../../lib/clipboard.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' import { patchOverlayState } from '../../overlayStore.js' @@ -358,12 +358,12 @@ export const coreCommands: SlashCommand[] = [ const text = 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' - ) + return sys(copyResultNotice(text.length, getClipboardPath())) } + + 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..afa949ee 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -3,7 +3,15 @@ // Modifications Copyright (c) 2026 EverMind. // See NOTICES.md and LICENSES/MIT-hermes-agent.txt. -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' +import { + getClipboardPath, + type ScrollBoxHandle, + useApp, + useHasSelection, + useSelection, + useStdout, + useTerminalTitle +} from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -25,10 +33,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 { copyOnSelectNotice } 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 +187,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 +330,25 @@ 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. + const copiedOnSelectRef = useRef(false) + + useEffect( + () => + subscribeCopyOnSelect(selection, text => { + sys(copyOnSelectNotice(text.length, getClipboardPath(), !copiedOnSelectRef.current)) + copiedOnSelectRef.current = true + }), + [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..99182aa7 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,42 @@ 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. + */ +function copiedCount(charCount: number): string { + return `copied ${charCount} character${charCount === 1 ? '' : 's'}` +} + +export function copyResultNotice(charCount: number, path: ClipboardPath): string { + const copied = copiedCount(charCount) + + switch (path) { + case 'native': + return copied + + case 'osc52': + return `${copied} via OSC 52 — if the paste comes up empty, allow clipboard access in your terminal` + + case 'tmux-buffer': + return `${copied} 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) : copiedCount(charCount) +} diff --git a/ui-tui/src/lib/copyOnSelect.ts b/ui-tui/src/lib/copyOnSelect.ts new file mode 100644 index 00000000..76b31627 --- /dev/null +++ b/ui-tui/src/lib/copyOnSelect.ts @@ -0,0 +1,68 @@ +/** + * 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. + */ + +/** 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. + * Returns the bus unsubscribe, so a React effect can return it directly. + * + * `copySelectionNoClear()` resolves to '' when no path reached the clipboard, + * so the callback fires on a real write only. + * + * 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) => 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 => { + if (text) { + onCopied?.(text) + } + }) + }) +} From 4ff9ce01246a24c73c9251707324a930b82315bd Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:49:33 +0000 Subject: [PATCH 3/8] fix(tui): stop the copy report overclaiming what reached the clipboard Measured on the real code: one 200x50 viewport of CJK is a 40 KB OSC 52 sequence, and a selection dragged through 2000 rows is 536 KB in a single escape sequence. Nothing in the write path caps or chunks it, and terminals drop an oversized sequence without a word, yet setClipboard() reports success for all three because bytes were written to stdout. So the osc52 path now reports what it sent rather than claiming a copy; native and tmux, which really did write a clipboard, keep saying copied. The count was UTF-16 code units, which reads three emoji as six characters and a combining accent as two. graphemeCount() counts what is on screen, via Intl.Segmenter where it exists and a code-point spread otherwise. CJK was already correct, being one code unit per character. An existing assertion pinned the old verb for the terse form and is flipped here, since it encoded the overclaim rather than the behaviour worth keeping. getClipboardPath() gains the one thing its callers rely on and it did not say: the answer is only meaningful after a copy succeeded. Headless with no tmux and OSC 52 suppressed takes no path at all, and the type has no word for that; both callers ask only after a non-empty result, so they never see it. Co-authored-by: Claude (claude-opus-5) --- .../packages/hermes-ink/src/ink/termio/osc.ts | 6 +++ ui-tui/src/__tests__/clipboard.test.ts | 32 +++++++++++++- ui-tui/src/app/slash/commands/core.ts | 4 +- ui-tui/src/app/useMainApp.ts | 4 +- ui-tui/src/lib/clipboard.ts | 43 ++++++++++++++++--- 5 files changed, 77 insertions(+), 12 deletions(-) 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 7ffd2b13..402c8ea0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -69,6 +69,12 @@ export function wrapForMultiplexer(sequence: string): string { * - 'osc52': only the raw OSC 52 sequence will be written to stdout. * Best-effort; iTerm2 disables OSC 52 by default. * + * Only meaningful once a copy has succeeded. setClipboard() can take no path + * at all -- headless, no tmux, and OSC 52 suppressed by the override -- and + * reports success false for it; this returns 'osc52' there, because the type + * has no word for "nothing happened". Both callers ask only after a non-empty + * result, so that combination is unreachable through them. + * * Decided by asking the same two questions setClipboard() asks, in the same * order: shouldUseNativeClipboard() for whether the native tool is wanted, and * copyNative()'s own platform gate for whether one exists. Keeping this in step diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index bf34a1f6..0d081e52 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import { copyOnSelectNotice, + graphemeCount, copyResultNotice, isUsableClipboardText, readClipboardText, @@ -376,7 +377,36 @@ describe('copyOnSelectNotice', () => { 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('copied 42 characters') + expect(copyOnSelectNotice(42, 'osc52', false)).toBe('sent 42 characters') expect(copyOnSelectNotice(1, 'tmux-buffer', false)).toBe('copied 1 character') }) }) + +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/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index a5295d32..d1e180d2 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 { copyResultNotice, 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' @@ -358,7 +358,7 @@ export const coreCommands: SlashCommand[] = [ const text = await ctx.composer.selection.copySelection() if (text) { - return sys(copyResultNotice(text.length, getClipboardPath())) + return sys(copyResultNotice(graphemeCount(text), getClipboardPath())) } return sys( diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index afa949ee..5e464324 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -33,7 +33,7 @@ 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 { copyOnSelectNotice } from '../lib/clipboard.js' +import { copyOnSelectNotice, graphemeCount } from '../lib/clipboard.js' import { buildConfirmRespond } from '../lib/confirmCountdown.js' import { subscribeCopyOnSelect } from '../lib/copyOnSelect.js' import { composerPromptWidth } from '../lib/inputMetrics.js' @@ -343,7 +343,7 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { useEffect( () => subscribeCopyOnSelect(selection, text => { - sys(copyOnSelectNotice(text.length, getClipboardPath(), !copiedOnSelectRef.current)) + sys(copyOnSelectNotice(graphemeCount(text), getClipboardPath(), !copiedOnSelectRef.current)) copiedOnSelectRef.current = true }), [selection, sys] diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 99182aa7..6aca27c3 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -179,22 +179,51 @@ export async function writeClipboardText( * owns. Saying so there -- and not saying it where a native tool really did * write the clipboard -- is what keeps the wording worth reading. */ -function copiedCount(charCount: number): string { - return `copied ${charCount} character${charCount === 1 ? '' : 's'}` +/** + * 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 copied = copiedCount(charCount) + const head = counted(verbFor(path), charCount) switch (path) { case 'native': - return copied + return head case 'osc52': - return `${copied} via OSC 52 — if the paste comes up empty, allow clipboard access in your terminal` + return `${head} via OSC 52 — if the paste comes up empty, allow clipboard access in your terminal` case 'tmux-buffer': - return `${copied} to the tmux buffer — reaching the system clipboard needs tmux set-clipboard` + return `${head} to the tmux buffer — reaching the system clipboard needs tmux set-clipboard` } } @@ -207,5 +236,5 @@ export function copyResultNotice(charCount: number, path: ClipboardPath): string * for the reason a paste came up empty. */ export function copyOnSelectNotice(charCount: number, path: ClipboardPath, firstOfSession: boolean): string { - return firstOfSession ? copyResultNotice(charCount, path) : copiedCount(charCount) + return firstOfSession ? copyResultNotice(charCount, path) : counted(verbFor(path), charCount) } From f031e1af5ef773d2d2190f339c8428be84ea831f Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:51:28 +0000 Subject: [PATCH 4/8] docs(ui): give the copy-on-select suite a purpose header AGENTS.md section 1.1 requires a file-level purpose on every new code file, and this one opened straight into its imports. Three comments also recorded why the change was made rather than what the code must hold to, which the same rule rejects as transient task context. The narrative goes; the constraints behind it stay. The platform case keeps the note that it can only mean something on a non-macOS runner, since there is no platform to inject. Co-authored-by: Claude (claude-opus-5) --- ui-tui/src/__tests__/copyOnSelect.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/__tests__/copyOnSelect.test.ts b/ui-tui/src/__tests__/copyOnSelect.test.ts index 43ba2251..fb7b49ed 100644 --- a/ui-tui/src/__tests__/copyOnSelect.test.ts +++ b/ui-tui/src/__tests__/copyOnSelect.test.ts @@ -1,3 +1,11 @@ +/** + * 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 { describe, expect, it } from 'vitest' import { subscribeCopyOnSelect } from '../lib/copyOnSelect.js' @@ -56,10 +64,9 @@ function fakeSelection() { describe('subscribeCopyOnSelect', () => { it('copies a settled selection on a non-macOS platform', () => { - // The defect this closes: the subscription used to bail out on - // `!isMac`, so a drag on Linux or Windows highlighted text and copied - // nothing. This suite runs on whatever the host is -- on Linux (this - // box, and CI) reaching the copy IS the proof the platform gate is gone. + // 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) @@ -185,8 +192,8 @@ describe('subscribeCopyOnSelect reporting', () => { it('stays silent when no clipboard path took the text', async () => { // `copySelectionNoClear()` resolves to '' when nothing reached the - // clipboard. Announcing a copy there would be the exact lie this change - // set out to remove from `/copy`. + // 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 From d314729a2c089c08b4646e39d2e6043cfb8a29ad Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:53:44 +0000 Subject: [PATCH 5/8] fix(tui): scope the copy-on-select caveat to the session it was shown in The first copy of a session carries the clipboard-path caveat and later ones stay terse, but the flag holding that lived in a hook that outlives the session. `newSession()` and `resumeById()` replace `ui.sid` without remounting `useMainApp`, so every session after the first opened with the terse line and the user never learned an OSC 52 paste can come up empty. Move the tally into a reporter that keys on a session identifier, so the state and the thing it is scoped to live together and the boundary can be asserted. The hook reads the sid through `getUiState()`, which keeps a session change from tearing down the bus subscription. Co-authored-by: Claude (claude-opus-5) --- ui-tui/src/__tests__/clipboard.test.ts | 32 ++++++++++++++++++++++++++ ui-tui/src/app/useMainApp.ts | 14 +++++++---- ui-tui/src/lib/clipboard.ts | 25 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index 0d081e52..03e093fe 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import { copyOnSelectNotice, + createCopyOnSelectReporter, graphemeCount, copyResultNotice, isUsableClipboardText, @@ -382,6 +383,37 @@ describe('copyOnSelectNotice', () => { }) }) +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 diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 5e464324..33c97342 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -33,7 +33,7 @@ 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 { copyOnSelectNotice, graphemeCount } from '../lib/clipboard.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' @@ -338,13 +338,19 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { // 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. - const copiedOnSelectRef = useRef(false) + // + // 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 => { - sys(copyOnSelectNotice(graphemeCount(text), getClipboardPath(), !copiedOnSelectRef.current)) - copiedOnSelectRef.current = true + sys(reportCopyOnSelect.current(graphemeCount(text), getClipboardPath(), getUiState().sid ?? 'draft')) }), [selection, sys] ) diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 6aca27c3..71d909be 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -238,3 +238,28 @@ export function copyResultNotice(charCount: number, path: ClipboardPath): string 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) + } +} From a7245ca86d51b2ac3dbc35d3f750769098a8bc3d Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:02:41 +0000 Subject: [PATCH 6/8] fix(tui): report the clipboard path the write took, not one guessed from env getClipboardPath() re-derived the path from environment state after the fact, and inside tmux the environment cannot tell a load-buffer that worked from one that did not: both have TMUX set. With a stale socket the data went out as raw OSC 52 while both callers said "copied to the tmux buffer", sending the user to a tmux set-clipboard setting that had nothing to do with it. setClipboard() already knew. It computes nativeAttempted, tmuxBufferLoaded and the emitted sequence to decide success, so it now reports which of them took the text and returns null when none did. The path travels with the copied text out of copySelectionNoClear()/copySelection() to the two callers that show it, and the predictor is deleted rather than left in the package for the next caller to trust. The env-matrix cases the predictor's suite covered were assertions about the predictor itself; what replaces them drives the real setClipboard() with tmux stubbed, including the failed-load fallback that was wrong. Co-authored-by: Claude (claude-opus-5) --- .../packages/hermes-ink/src/entry-exports.ts | 3 +- .../hermes-ink/src/ink/hooks/use-selection.ts | 10 +- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 52 ++++++---- .../hermes-ink/src/ink/termio/osc.test.ts | 77 ++++++++------- .../packages/hermes-ink/src/ink/termio/osc.ts | 94 +++++++------------ ui-tui/src/__tests__/copyOnSelect.test.ts | 24 ++++- ui-tui/src/app/interfaces.ts | 6 +- ui-tui/src/app/slash/commands/core.ts | 8 +- ui-tui/src/app/useMainApp.ts | 5 +- ui-tui/src/lib/copyOnSelect.ts | 27 ++++-- ui-tui/src/types/hermes-ink.d.ts | 9 +- 11 files changed, 175 insertions(+), 140 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index d6418434..b5715d5f 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -26,7 +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, getClipboardPath } from './ink/termio/osc.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 c314d1a3..431dbc5d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -110,6 +110,7 @@ import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, clipboardDebugEnabled, + type ClipboardPath, setClipboard, supportsTabStatus, wrapForMultiplexer @@ -149,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 @@ -1372,28 +1388,31 @@ 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 (clipboardDebugEnabled()) { @@ -1408,24 +1427,25 @@ export default class Ink { } } - 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 6abd0cf8..fa2bc39f 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 { clipboardDebugEnabled, getClipboardPath, 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', () => { @@ -247,48 +251,51 @@ describe('shouldUseNativeClipboard', () => { }) }) -describe('getClipboardPath', () => { - // setClipboard() decides native via shouldUseNativeClipboard() and then - // copyNative()'s own per-platform availability. A predictor that disagrees - // with either sends the user to fix the wrong thing, so every branch of both - // gets a case here rather than only the one this CI box runs on. - - it('names native on a plain local macOS terminal', () => { - expect(getClipboardPath({} as NodeJS.ProcessEnv, 'darwin', null)).toBe('native') +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) + + 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') }) - it('names native on a local Linux desktop with a display server', () => { - // The defect this closes: the predictor keyed native off darwin alone, so - // wl-copy/xclip really wrote the clipboard while the user was told the copy - // only left as an escape sequence and to go change a terminal setting. - expect(getClipboardPath({ DISPLAY: ':0' } as NodeJS.ProcessEnv, 'linux', null)).toBe('native') - expect(getClipboardPath({ WAYLAND_DISPLAY: 'wayland-0' } as NodeJS.ProcessEnv, 'linux', null)).toBe('native') + afterEach(() => { + vi.unstubAllEnvs() }) - it('names native on Windows, where clip.exe always exists', () => { - expect(getClipboardPath({} as NodeJS.ProcessEnv, 'win32', null)).toBe('native') - }) + it('names the tmux buffer when load-buffer succeeds', async () => { + run.mockResolvedValue({ stdout: '', stderr: '', code: 0 }) - it('names osc52 on a terminal where setClipboard deliberately skips native', () => { - // On an allowlisted terminal setClipboard suppresses the native tool to - // avoid racing the terminal's own OSC 52 write, so OSC 52 is the only path - // taken -- the old predictor claimed 'native' here. - expect(getClipboardPath({} as NodeJS.ProcessEnv, 'darwin', 'ghostty')).toBe('osc52') + await expect(setClipboard('probe')).resolves.toMatchObject({ success: true, path: 'tmux-buffer' }) }) - it('names osc52 over SSH, where native would write the wrong machine', () => { - expect(getClipboardPath({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'linux', null)).toBe('osc52') - expect(getClipboardPath({ DISPLAY: ':0', SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'darwin', null)).toBe('osc52') - }) + 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') - it('names osc52 on headless Linux, where no native tool can run', () => { - expect(getClipboardPath({} as NodeJS.ProcessEnv, 'linux', null)).toBe('osc52') + expect(result.success).toBe(true) + expect(result.path).toBe('osc52') + expect(result.sequence).toContain(']52;c;') }) - it('names the tmux buffer only when no native tool is available', () => { - // Inside tmux with a native tool present, setClipboard fires both and the - // native write is the higher-confidence one, so that is what gets named. - expect(getClipboardPath({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'darwin', null)).toBe('native') - expect(getClipboardPath({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'linux', null)).toBe('tmux-buffer') + 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 402c8ea0..9364dad9 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -58,70 +58,26 @@ 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. * - * Only meaningful once a copy has succeeded. setClipboard() can take no path - * at all -- headless, no tmux, and OSC 52 suppressed by the override -- and - * reports success false for it; this returns 'osc52' there, because the type - * has no word for "nothing happened". Both callers ask only after a non-empty - * result, so that combination is unreachable through them. - * - * Decided by asking the same two questions setClipboard() asks, in the same - * order: shouldUseNativeClipboard() for whether the native tool is wanted, and - * copyNative()'s own platform gate for whether one exists. Keeping this in step - * with them is the whole point -- callers put the answer in front of the user, - * and a path named wrongly sends them to fix something that was never broken. - * - * The native gate 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' -/** Whether copyNative() has a tool it can actually run on this platform. - * Mirrors its own switch: pbcopy and clip.exe ship with the OS, while the - * Linux tools need a display server to talk to. */ -function nativeToolAvailable(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): boolean { - switch (platform) { - case 'darwin': - case 'win32': - return true - - case 'linux': - return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY) - - default: - return false - } -} - -export function getClipboardPath( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, - terminal: string | null = envModule.terminal -): ClipboardPath { - // Native first: when it fires alongside tmux or OSC 52 it is the one write - // that is not contingent on a terminal or multiplexer setting. - if (shouldUseNativeClipboard(env, terminal) && nativeToolAvailable(env, platform)) { - return 'native' - } - - if (env.TMUX) { - return 'tmux-buffer' - } - - return 'osc52' -} - /** * Whether to log why a clipboard write took the path it did. * @@ -200,6 +156,10 @@ 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 } @@ -306,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 { @@ -354,7 +316,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. diff --git a/ui-tui/src/__tests__/copyOnSelect.test.ts b/ui-tui/src/__tests__/copyOnSelect.test.ts index fb7b49ed..d72e016d 100644 --- a/ui-tui/src/__tests__/copyOnSelect.test.ts +++ b/ui-tui/src/__tests__/copyOnSelect.test.ts @@ -6,6 +6,8 @@ * 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' @@ -19,8 +21,10 @@ 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 @@ -28,6 +32,7 @@ function fakeSelection() { writeSucceeded: boolean } = { dragging: false, + path: 'native', present: true, text: 'selected text', version: 1, @@ -36,7 +41,10 @@ function fakeSelection() { return { copied, - onCopied: (text: string) => reported.push(text), + onCopied: (text: string, path: ClipboardPath) => { + reported.push(text) + reportedPaths.push(path) + }, notify: () => { for (const cb of listeners) { cb() @@ -46,7 +54,7 @@ function fakeSelection() { copySelectionNoClear: async () => { copied.push(state.text) - return state.writeSucceeded ? state.text : '' + return state.writeSucceeded ? { text: state.text, path: state.path } : { text: '', path: null } }, getState: (): unknown => state.rawState ?? { isDragging: state.dragging }, hasSelection: () => state.present, @@ -58,6 +66,7 @@ function fakeSelection() { version: () => state.version }, reported, + reportedPaths, state } } @@ -190,6 +199,17 @@ describe('subscribeCopyOnSelect reporting', () => { 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 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 d1e180d2..1bd12c5a 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -3,7 +3,7 @@ // Modifications Copyright (c) 2026 EverMind. // See NOTICES.md and LICENSES/MIT-hermes-agent.txt. -import { forceRedraw, getClipboardPath } from '@hermes/ink' +import { forceRedraw } from '@hermes/ink' import type { ConfigGetValueResponse, @@ -355,10 +355,10 @@ 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(copyResultNotice(graphemeCount(text), getClipboardPath())) + if (text && path) { + return sys(copyResultNotice(graphemeCount(text), path)) } return sys( diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 33c97342..2735025e 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -4,7 +4,6 @@ // See NOTICES.md and LICENSES/MIT-hermes-agent.txt. import { - getClipboardPath, type ScrollBoxHandle, useApp, useHasSelection, @@ -349,8 +348,8 @@ export function useMainApp(gw: GatewayClient, rpcClient?: ChatStreamRpcClient) { useEffect( () => - subscribeCopyOnSelect(selection, text => { - sys(reportCopyOnSelect.current(graphemeCount(text), getClipboardPath(), getUiState().sid ?? 'draft')) + subscribeCopyOnSelect(selection, (text, path) => { + sys(reportCopyOnSelect.current(graphemeCount(text), path, getUiState().sid ?? 'draft')) }), [selection, sys] ) diff --git a/ui-tui/src/lib/copyOnSelect.ts b/ui-tui/src/lib/copyOnSelect.ts index 76b31627..5af09831 100644 --- a/ui-tui/src/lib/copyOnSelect.ts +++ b/ui-tui/src/lib/copyOnSelect.ts @@ -7,11 +7,13 @@ * 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 + copySelectionNoClear: () => Promise getState: () => unknown hasSelection: () => boolean subscribe: (cb: () => void) => () => void @@ -20,11 +22,15 @@ export type CopyOnSelectSelection = { /** * Copy each settled selection to the clipboard, keeping the highlight, and - * hand the copied text to `onCopied` once a clipboard path actually took it. - * Returns the bus unsubscribe, so a React effect can return it directly. + * 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 '' when no path reached the clipboard, - * so the callback fires on a real write only. + * `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 @@ -40,7 +46,10 @@ 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) => void): () => void { +export function subscribeCopyOnSelect( + selection: CopyOnSelectSelection, + onCopied?: (text: string, path: ClipboardPath) => void +): () => void { let lastCopiedVersion = -1 return selection.subscribe(() => { @@ -59,9 +68,9 @@ export function subscribeCopyOnSelect(selection: CopyOnSelectSelection, onCopied } lastCopiedVersion = version - void selection.copySelectionNoClear().then(text => { - if (text) { - onCopied?.(text) + 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 1c45da1c..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 @@ -170,7 +170,10 @@ declare module '@hermes/ink' { readonly setSelectionBgColor: (color: string) => void } export type ClipboardPath = 'native' | 'osc52' | 'tmux-buffer' - export function getClipboardPath(): ClipboardPath + 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 From 4550a4425efac9d93ce9c22055eb205f4cdd3d79 Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:22:42 +0000 Subject: [PATCH 7/8] fix(tui): wait for the linux clipboard probe before naming a native path copyNative() answers the first Linux call before its probe has found a tool, because a display server means one could exist, not that one does. Reporting the observed path promoted that optimism into a claimed native write: a machine with DISPLAY and no wl-copy, xclip or xsel was told its first copy landed while nothing had been written, and the same call reported failure once the probe settled a moment later. The first call now answers with the probe's own result. It is still invoked before the tmux await, so the probe runs alongside load-buffer rather than ahead of the report, and every later call still answers synchronously from the cache. The copy itself never ran ahead of this probe -- the tool is spawned inside it -- only the report did. Co-authored-by: Claude (claude-opus-5) --- .../hermes-ink/src/ink/termio/osc.test.ts | 19 +++++++++++ .../packages/hermes-ink/src/ink/termio/osc.ts | 34 +++++++++++++------ 2 files changed, 42 insertions(+), 11 deletions(-) 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 fa2bc39f..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 @@ -257,6 +257,7 @@ describe('setClipboard path reporting', () => { // 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() @@ -269,6 +270,9 @@ describe('setClipboard path reporting', () => { 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 () => { @@ -290,6 +294,21 @@ describe('setClipboard path reporting', () => { 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. 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 9364dad9..96373b83 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -298,11 +298,17 @@ export async function setClipboard(text: string): Promise { // OSC 52, but it can be silently dropped without that setting). // Native also fires when the user has disabled OSC 52 emission via // RAVEN_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) + // 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. @@ -373,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) { @@ -406,11 +417,12 @@ 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 @@ -422,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': From f025c32b0c9b1b877bc4b0138bd5d00659ec7bd8 Mon Sep 17 00:00:00 2001 From: Dizhan Xue <48319803+LivXue@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:22:42 +0000 Subject: [PATCH 8/8] chore(tui): format the two lines this branch left wrapped Dropping a name from the @hermes/ink import and adding a wide function signature left both inside Prettier's print width while still wrapped, so this branch was the reason those two files reported unformatted. Every file it touches now passes prettier --check. Co-authored-by: Claude (claude-opus-5) --- ui-tui/src/app/useMainApp.ts | 9 +-------- ui-tui/src/lib/clipboard.ts | 6 +----- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 2735025e..7f8e43ca 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -3,14 +3,7 @@ // Modifications Copyright (c) 2026 EverMind. // See NOTICES.md and LICENSES/MIT-hermes-agent.txt. -import { - type ScrollBoxHandle, - useApp, - useHasSelection, - useSelection, - useStdout, - useTerminalTitle -} from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 71d909be..7382aaec 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -248,11 +248,7 @@ export function copyOnSelectNotice(charCount: number, path: ClipboardPath, first * 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 { +export function createCopyOnSelectReporter(): (charCount: number, path: ClipboardPath, sessionKey: string) => string { const told = new Set() return (charCount, path, sessionKey) => {