Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 141 additions & 1 deletion ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'

import { shouldEmitClipboardSequence } from './osc.js'
import { env, supportsOsc52Clipboard } from '../../utils/env.js'

import { shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js'

describe('shouldEmitClipboardSequence', () => {
it('suppresses local multiplexer clipboard OSC by default', () => {
Expand Down Expand Up @@ -49,3 +51,141 @@ describe('shouldEmitClipboardSequence', () => {
).toBe(false)
})
})

describe('supportsOsc52Clipboard', () => {
// Terminals known to correctly implement OSC 52. On these, setClipboard()
// skips the native-tool safety net (wl-copy/xclip/pbcopy) to avoid racing
// the terminal's own clipboard write. Values must match what
// detectTerminal() in utils/env.ts returns — TERM=xterm-ghostty normalises
// to 'ghostty', TERM_PROGRAM=WezTerm stays 'WezTerm', etc.
it.each(['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode'])(
'returns true for allowlisted terminal %s',
terminal => {
expect(supportsOsc52Clipboard(terminal)).toBe(true)
}
)
Comment thread
benbarclay marked this conversation as resolved.

// Intentionally conservative — iTerm2 disables OSC 52 by default; Alacritty
// and GNOME Terminal detection is unreliable; xterm/Terminal.app lack
// reliable OSC 52. These keep the existing native-safety-net behaviour.
it.each(['iTerm.app', 'alacritty', 'Apple_Terminal', 'xterm', 'tmux', 'screen', 'cursor', 'WarpTerminal', ''])(
'returns false for non-allowlisted terminal %s',
terminal => {
expect(supportsOsc52Clipboard(terminal)).toBe(false)
}
)

it('returns false when terminal is null (detection failed)', () => {
expect(supportsOsc52Clipboard(null)).toBe(false)
})

it('defaults to the module-level detected terminal when no argument is passed', () => {
// With no argument, uses env.terminal detected at module load. We don't
// know what that is in CI, but the call must return a boolean (not throw)
// and the result must match calling with env.terminal explicitly.
expect(typeof supportsOsc52Clipboard()).toBe('boolean')
expect(supportsOsc52Clipboard()).toBe(supportsOsc52Clipboard(env.terminal))
})
Comment thread
benbarclay marked this conversation as resolved.
})

// shouldUseNativeClipboard() encodes the gating logic that setClipboard()
// uses to decide whether to fire copyNative(). Testing it directly (rather
// 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('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;
// pbcopy/wl-copy/xclip on the remote machine would write to the wrong
// clipboard. OSC 52 is the right path. Existing behaviour, preserved.
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'xterm')).toBe(false)
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, null)).toBe(false)
})

it('returns true on plain local terminals (existing behaviour)', () => {
// Non-allowlisted terminals — xterm, GNOME Terminal, Apple_Terminal,
// alacritty (detection unreliable), iTerm2 (OSC 52 off by default).
// These keep the native safety net firing. This is the bulk of the
// existing user base; behaviour must not regress.
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'xterm')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'iTerm.app')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'Apple_Terminal')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'alacritty')).toBe(true)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, null)).toBe(true)
})

it('returns false on allowlisted local terminals (the race-fix case)', () => {
// Ghostty / kitty / WezTerm / Windows Terminal / VS Code — OSC 52
// alone is reliable, native fallback racing it can corrupt the
// clipboard (the wl-copy on Wayland symptom this PR fixes).
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'kitty')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'WezTerm')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'windows-terminal')).toBe(false)
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'vscode')).toBe(false)
})

it('returns true inside tmux even on allowlisted outer terminal', () => {
// detectTerminal() prefers TERM_PROGRAM over TMUX, so a tmux session
// inside Ghostty reports terminal='ghostty'. But setClipboard() goes
// through tmux load-buffer there, not raw OSC 52 — the wl-copy race
// doesn't apply. Native is still useful since tmux's outer-terminal
// forwarding depends on `set -g set-clipboard` + `allow-passthrough`.
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true)
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'vscode')).toBe(true)
})

it('returns true inside GNU screen even on allowlisted outer terminal', () => {
// Same reasoning as TMUX — STY indicates we're inside screen, which
// has its own escape-sequence handling and we don't emit raw OSC 52.
expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
})

it('returns true when OSC 52 emission is disabled via HERMES_TUI_FORCE_OSC52=0', () => {
// If we suppress OSC 52 (user override) AND skip native, the clipboard
// write becomes a no-op. So when OSC 52 is off, native is the only
// remaining path — keep it on regardless of terminal allowlist.
expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
expect(shouldUseNativeClipboard({ HERMES_TUI_CLIPBOARD_OSC52: '0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true)
expect(shouldUseNativeClipboard({ HERMES_TUI_COPY_OSC52: 'no' } as NodeJS.ProcessEnv, 'vscode')).toBe(true)
})

it('returns true under TMUX even with HERMES_TUI_FORCE_OSC52=1 on an allowlisted terminal (tmux load-buffer path)', () => {
// FORCE_OSC52=1 is the user explicitly opting INTO OSC 52 (e.g. they
// have tmux set up for passthrough). On an allowlisted terminal the
// race-avoidance still applies.
expect(
shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty')
// TMUX guard wins — native still fires because we're going through
// tmux load-buffer, not raw OSC 52 to the terminal.
).toBe(true)
})

it('SSH_CONNECTION takes precedence over allowlisted terminal', () => {
// Even on Ghostty, if we're SSH'd in we shouldn't run pbcopy on the
// remote machine — the user's clipboard is on the other end.
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
})

it('SSH_CONNECTION takes precedence over TMUX', () => {
// Combined: SSH'd in and inside tmux on the remote. SSH_CONNECTION
// gate fires first, native stays off (we use OSC 52 to reach the
// local terminal).
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'xterm')).toBe(
false
)
})

it('defaults env to process.env and terminal to the module-detected terminal when no args passed', () => {
// Smoke test: no args is a valid call shape for the convenience seam.
// shouldUseNativeClipboard() defaults `terminal` to envModule.terminal
// (the module-level detected terminal), not null. Returns a boolean
// without throwing.
expect(typeof shouldUseNativeClipboard()).toBe('boolean')
})
})
101 changes: 94 additions & 7 deletions ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { Buffer } from 'buffer'

import { env } from '../../utils/env.js'
import { env as envModule, supportsOsc52Clipboard } from '../../utils/env.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'

import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
Expand All @@ -20,7 +20,7 @@ export const ST = ESC + '\\'
/** Generate an OSC sequence: ESC ] p1;p2;...;pN <terminator>
* Uses ST terminator for Kitty (avoids beeps), BEL for others */
export function osc(...parts: (string | number)[]): string {
const terminator = env.terminal === 'kitty' ? ST : BEL
const terminator = envModule.terminal === 'kitty' ? ST : BEL

return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
}
Expand Down Expand Up @@ -102,6 +102,77 @@ export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env
return !!env['SSH_CONNECTION'] || (!env['TMUX'] && !env['STY'])
}

/**
* Decide whether setClipboard() should also fire the native clipboard tool
* (pbcopy / wl-copy / xclip / xsel / clip.exe) as a safety net alongside
* OSC 52 / tmux load-buffer.
*
* The default is "yes, native fires" — it's the historical safety net for
* terminals where OSC 52 may not work (iTerm2 disables OSC 52 by default,
* Apple_Terminal / GNOME Terminal / xterm coverage is patchy). The two
* cases where we suppress it:
*
* 1. SSH session: native tools would write to the *remote* machine's
* clipboard. OSC 52 (which travels back over the pty to the user's
* local terminal) is the right path. Existing behaviour.
*
* 2. Allowlisted OSC-52-capable terminal AND we're actually going to
* emit an OSC 52 sequence AND we're not inside tmux/screen. On these
* terminals (Ghostty / kitty / WezTerm / Windows Terminal / VS Code)
* the OSC 52 write is reliable on its own, and racing it with a
* native tool is destructive — wl-copy on Wayland in particular
* wipes the clipboard during its existence-probe and forks a daemon
* that races the terminal's own write (~30% empty-clipboard rate
* reported on Ghostty + Wayland; symptom: ctrl+shift+c works on the
* 3rd attempt).
*
* The TMUX/STY guard is important: detectTerminal() in utils/env.ts
* prefers TERM_PROGRAM over TMUX, so a tmux session inside Ghostty
* reports terminal='ghostty'. But inside tmux setClipboard() doesn't
* emit raw OSC 52 — it goes through tmux load-buffer (which loads
* the tmux paste buffer and, with -w, asks tmux to forward an OSC 52
* to the OUTER terminal via its own emission path). The native
* safety net is still useful there because tmux load-buffer's
* outer-terminal forwarding depends on `set -g set-clipboard` and
* `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
* we ALSO skip native, the clipboard write becomes a no-op. So skip
* native only when OSC 52 will actually carry the data.
*/
export function shouldUseNativeClipboard(
env: NodeJS.ProcessEnv = process.env,
terminal: string | null = envModule.terminal
): boolean {
Comment thread
benbarclay marked this conversation as resolved.
// Over SSH the native tools would write to the wrong machine's clipboard.
if (env.SSH_CONNECTION) {
return false
}

// Inside tmux/screen, OSC 52 is normally suppressed and we rely on
// tmux load-buffer instead — so the wl-copy/OSC-52 race usually doesn't
// apply. Even when HERMES_TUI_FORCE_OSC52=1 forces a tmux-passthrough
// 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
// host terminal. Native (pbcopy/wl-copy/xclip) covers that gap.
if (env.TMUX || env.STY) {
return true
}

// If OSC 52 won't actually emit (user override or env state), the
// native tool is the only path left — keep it on.
if (!shouldEmitClipboardSequence(env)) {
return true
}

// OSC 52 is going to emit AND the terminal is in the allowlist of
// terminals where OSC 52 alone is reliable: skip native to avoid the
// wl-copy race documented above.
return !supportsOsc52Clipboard(terminal)
}

/**
* Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \
* tmux forwards the payload to the outer terminal, bypassing its own parser.
Expand Down Expand Up @@ -193,11 +264,27 @@ export async function setClipboard(text: string): Promise<ClipboardResult> {
// AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency
// before pbcopy even started — fast cmd+tab → paste would beat it
// (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
// Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY
// forever but SSH_CONNECTION is in tmux's default update-environment and
// clears on local attach. Fire-and-forget, but `copyNativeAttempted`
// tells us whether ANY native path will be tried on this platform.
const nativeAttempted = !process.env['SSH_CONNECTION'] && copyNative(text)
// Skipped entirely on terminals with first-class OSC 52 support (see
// `shouldUseNativeClipboard()` above): running wl-copy/xclip/pbcopy in
// parallel with OSC 52 on those terminals can corrupt the clipboard.
// wl-copy on Wayland is the worst offender — `probeLinuxCopy()` runs it
// with empty stdin to check if the binary exists (which destructively
// wipes the clipboard), and the subsequent real invocation forks a
// background daemon that races the terminal's own OSC 52 write plus its
// own prior daemon's SIGTERM. On Ghostty + Wayland this produced a ~30%
// clipboard-empty rate (symptom: user had to press ctrl+shift+c three
// times before the selection landed). Native still fires inside
// tmux/screen — we primarily rely on tmux load-buffer there rather
// 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
// OSC 52, but it can be silently dropped without that setting).
// Native also fires when the user has disabled OSC 52 emission via
// HERMES_TUI_FORCE_OSC52=0 (otherwise the clipboard write becomes a
// complete no-op). Fire-and-forget, but `nativeAttempted` tells us
// whether ANY native path will be tried.
const nativeAttempted = shouldUseNativeClipboard(process.env, envModule.terminal) && copyNative(text)

const tmuxBufferLoaded = await tmuxLoadBuffer(text)

Expand Down
25 changes: 25 additions & 0 deletions ui-tui/packages/hermes-ink/src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,28 @@ function detectTerminal(): TerminalName {
export const env = {
terminal: detectTerminal()
}

// Terminals known to correctly implement OSC 52 clipboard writes
// (ESC ] 52 ; c ; <b64> BEL/ST — osc() in ink/termio/osc.ts emits BEL
// for most terminals and ST for kitty). When detected, setClipboard() skips the
// native-tool safety net entirely — running wl-copy/xclip/pbcopy in
// parallel with OSC 52 races the terminal's own clipboard write and can
// corrupt it (e.g. wl-copy on Wayland holds the selection in a background
// daemon; stacking two writes within ~30ms triggers a SIGTERM race).
// Intentionally conservative: terminals with known flaky or disabled-by-
// default OSC 52 (iTerm2 disables OSC 52 by default; Alacritty detection
// is unreliable) are not on this list. Users on those terminals keep the
// existing behaviour (native safety net fires alongside OSC 52).
//
// Lives here in utils/env.ts (rather than ink/terminal.ts) so that
// ink/termio/osc.ts can import it without creating a circular dependency:
// ink/terminal.ts already imports `link` from ink/termio/osc.ts.
const OSC52_CAPABLE_TERMINALS = ['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode']
Comment thread
benbarclay marked this conversation as resolved.

/** True if this terminal is known to correctly handle OSC 52 clipboard
* writes, so setClipboard() can skip the native-tool safety net.
* Accepts an optional terminal name for testability; defaults to the
* module-level `env.terminal` detected at startup. */
export function supportsOsc52Clipboard(terminal: string | null = env.terminal): boolean {
return OSC52_CAPABLE_TERMINALS.includes(terminal ?? '')
}
Loading