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 4c54f8d18a6..b3d73709783 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 @@ -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', () => { @@ -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) + } + ) + + // 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)) + }) +}) + +// 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') + }) +}) 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 99dce2df346..3f680b6dec2 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -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' @@ -20,7 +20,7 @@ export const ST = ESC + '\\' /** Generate an OSC sequence: ESC ] p1;p2;...;pN * 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}` } @@ -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 { + // 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 ; ESC \ * tmux forwards the payload to the outer terminal, bypassing its own parser. @@ -193,11 +264,27 @@ export async function setClipboard(text: string): Promise { // 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) diff --git a/ui-tui/packages/hermes-ink/src/utils/env.ts b/ui-tui/packages/hermes-ink/src/utils/env.ts index 7393f1baa76..f66ad2cf8b0 100644 --- a/ui-tui/packages/hermes-ink/src/utils/env.ts +++ b/ui-tui/packages/hermes-ink/src/utils/env.ts @@ -39,3 +39,28 @@ function detectTerminal(): TerminalName { export const env = { terminal: detectTerminal() } + +// Terminals known to correctly implement OSC 52 clipboard writes +// (ESC ] 52 ; c ; 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'] + +/** 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 ?? '') +}