Nothing open on GitHub. Fix merged in #16035 on 26 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #16035. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #16035. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple fixes addressing clipboard copy failures on Linux/Wayland and tmux environments, plus improvements to user feedback so 'copied N chars' messages reflect actual copy success. Includes OSC52 terminal support, native/tmux success signaling, and a force-override environment variable.
Open issues0
Open PRs0
Fixes on record
-
#16035 fix(clipboard): salvage #16020 with native/tmux success signal + xterm keybinding
files:
ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts,ui-tui/packages/hermes-ink/src/ink/ink.tsx,ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts,ui-tui/packages/hermes-ink/src/ink/termio/osc.ts,ui-tui/src/__tests__/createSlashHandler.test.ts,ui-tui/src/app/interfaces.ts,ui-tui/src/app/slash/commands/core.ts,ui-tui/src/types/hermes-ink.d.ts,web/src/pages/ChatPage.tsxShow the diff
-
ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts@@ -9,9 +9,9 @@ import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.j * Returns no-op functions when fullscreen mode is disabled. */ export function useSelection(): { - copySelection: () => string + copySelection: () => Promise<string> /** Copy without clearing the highlight (for copy-on-select). */ - copySelectionNoClear: () => string + copySelectionNoClear: () => Promise<string> clearSelection: () => void hasSelection: () => boolean /** Read the raw mutable selection state (for drag-to-scroll). */ @@ -48,8 +48,8 @@ export function useSelection(): { return useMemo(() => { if (!ink) { return { - copySelection: () => '', - copySelectionNoClear: () => '', + copySelection: async () => '', + copySelectionNoClear: async () => '', clearSelection: () => {}, hasSelection: () => false, getState: () => null, -
ui-tui/packages/hermes-ink/src/ink/ink.tsx@@ -1297,11 +1297,13 @@ export default class Ink { } /** - * Copy the current selection to the clipboard without clearing the - * highlight. Matches iTerm2's copy-on-select behavior where the selected - * region stays visible after the automatic copy. + * 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. */ - copySelectionNoClear(): string { + async copySelectionNoClear(): Promise<string> { if (!hasSelection(this.selection)) { return '' } @@ -1309,28 +1311,41 @@ export default class Ink { const text = getSelectedText(this.selection, this.frontFrame.screen) if (text) { - // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux - // drops it silently unless allow-passthrough is on — no regression). - void setClipboard(text).then(raw => { - if (raw) { - this.options.stdout.write(raw) + try { + const { sequence, success } = await setClipboard(text) + + if (sequence) { + this.options.stdout.write(sequence) } - }) + + if (success) { + return text + } + + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence') + } + } catch (err) { + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error('[clipboard] error:', err) + } + } } - return text + return '' } /** * Copy the current text selection to the system clipboard via OSC 52 - * and clear the selection. Returns the copied text (empty if no selection). + * and clear the selection. Returns the copied text (empty if no selection + * or clipboard operation failed). */ - copySelection(): string { + async copySelection(): Promise<string> { if (!hasSelection(this.selection)) { return '' } - const text = this.copySelectionNoClear() + const text = await this.copySelectionNoClear() clearSelection(this.selection) this.notifySelectionChange() -
ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts@@ -26,4 +26,26 @@ describe('shouldEmitClipboardSequence', () => { shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv) ).toBe(false) }) + + it('HERMES_TUI_FORCE_OSC52 takes precedence over TMUX suppression', () => { + // Without the override, local-in-tmux suppresses the OSC 52 sequence + // so the terminal multiplexer path wins. FORCE_OSC52=1 flips that + // back on for users whose tmux config supports passthrough. + expect(shouldEmitClipboardSequence({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv)).toBe(false) + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_FORCE_OSC52: '1', + TMUX: '/tmp/t,1,0' + } as NodeJS.ProcessEnv) + ).toBe(true) + }) + + it('HERMES_TUI_FORCE_OSC52=0 suppresses OSC 52 even for remote or plain terminals', () => { + expect( + shouldEmitClipboardSequence({ + HERMES_TUI_FORCE_OSC52: '0', + SSH_CONNECTION: '1' + } as NodeJS.ProcessEnv) + ).toBe(false) + }) }) -
ui-tui/packages/hermes-ink/src/ink/termio/osc.ts@@ -84,7 +84,11 @@ export function getClipboardPath(): ClipboardPath { } export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env): boolean { - const override = (env.HERMES_TUI_CLIPBOARD_OSC52 ?? env.HERMES_TUI_COPY_OSC52 ?? '').trim() + const override = ( + env.HERMES_TUI_FORCE_OSC52 ?? + env.HERMES_TUI_CLIPBOARD_OSC52 ?? + env.HERMES_TUI_COPY_OSC52 ?? '' + ).trim() if (ENV_ON_RE.test(override)) { return true @@ -162,10 +166,23 @@ export async function tmuxLoadBuffer(text: string): Promise<boolean> { * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over * SSH these would write to the remote clipboard — OSC 52 is the right path there. * - * Returns the sequence for the caller to write to stdout (raw OSC 52 - * outside tmux, DCS-wrapped inside). + * Returns { sequence, success }: + * - `sequence` is the bytes to write to stdout (raw OSC 52 outside tmux, + * DCS-wrapped inside; empty string when we shouldn't emit). + * - `success` is true when we believe SOME path reached the clipboard: + * native tool fired (local), tmux buffer loaded, or an OSC 52 sequence + * was emitted to the terminal. False only when no path was taken at + * all (headless Linux with no tmux + osc52 suppressed, effectively). + * This is best-effort — pbcopy/xclip are fire-and-forget, and OSC 52 + * depends on the outer terminal honoring the sequence — but it lets + * callers distinguish "nothing attempted" from "attempted". */ -export async function setClipboard(text: string): Promise<string> { +export type ClipboardResult = { + sequence: string + success: boolean +} + +export async function setClipboard(text: string): Promise<ClipboardResult> { const b64 = Buffer.from(text, 'utf8').toString('base64') const raw = osc(OSC.CLIPBOARD, 'c', b64) const emitSequence = shouldEmitClipboardSequence(process.env) @@ -177,20 +194,28 @@ export async function setClipboard(text: string): Promise<string> { // (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. - if (!process.env['SSH_CONNECTION']) { - copyNative(text) - } + // 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) const tmuxBufferLoaded = await tmuxLoadBuffer(text) // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling // too, and BEL works everywhere for OSC 52. - if (tmuxBufferLoaded) { - return emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '' - } - - return emitSequence ? raw : '' + const sequence = tmuxBufferLoaded + ? (emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '') + : (emitSequence ? raw : '') + + // Success if any path was taken. Native and tmux are fire-and-forget, + // so we can't truly confirm the clipboard was written — but if native + // was attempted OR tmux buffer loaded OR we emitted OSC 52, the user's + // paste is likely to work. The only false case is "we did literally + // nothing" (e.g. local-in-tmux with osc52 suppressed and tmux buffer + // load failed), in which case reporting failure to the user is honest. + const success = nativeAttempted || tmuxBufferLoaded || sequence.length > 0 + + return { sequence, success } } // Linux clipboard tool: undefined = not yet probed, null = none available. @@ -198,65 +223,95 @@ export async function setClipboard(text: string): Promise<string> { // Cached after first attempt so repeated mouse-ups skip the probe chain. let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined +/** Internal: probe once and cache — wl-copy first, then xclip, then xsel. */ +async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> { + const opts = { useCwd: false, timeout: 500 } + + const r = await execFileNoThrow('wl-copy', [], opts) + + if (r.code === 0) { + return 'wl-copy' + } + + const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) + + if (r2.code === 0) { + return 'xclip' + } + + const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts) + + return r3.code === 0 ? 'xsel' : null +} + /** * Shell out to a native clipboard utility as a safety net for OSC 52. * Only called when not in an SSH session (over SSH, these would write to * the remote machine's clipboard — OSC 52 is the right path there). * Fire-and-forget: failures are silent since OSC 52 may have succeeded. + * + * Returns true when a native copy path was (or will be) attempted — i.e. + * we'll spawn pbcopy on macOS, clip on Windows, or a known-working Linux + * tool. Returns false only when we know no native tool is viable (Linux + * without DISPLAY/WAYLAND_DISPLAY, or previously-probed-to-null). The + * return value is used to decide whether to tell the user the copy + * succeeded — spawning is best-effort but good enough to claim success. + * + * 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. */ -function copyNative(text: string): void { +function copyNative(text: string): boolean { const opts = { input: text, useCwd: false, timeout: 2000 } switch (process.platform) { case 'darwin': void execFileNoThrow('pbcopy', [], opts) - return + return true case 'linux': { - if (linuxCopy === null) { - return - } + // If we already probed (success or hard-fail), short-circuit. + if (linuxCopy !== undefined) { + if (linuxCopy === null) { + // No working native tool — skip silently. + return false + } - if (linuxCopy === 'wl-copy') { - void execFileNoThrow('wl-copy', [], opts) + // linuxCopy is a known-working tool; fire-and-forget. + void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) - return + return true } - if (linuxCopy === 'xclip') { - void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) - - return - } + // 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) { + console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable') + } - if (linuxCopy === 'xsel') { - void execFileNoThrow('xsel', ['--clipboard', '--input'], opts) + linuxCopy = null - return + return false } - - // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner. - void execFileNoThrow('wl-copy', [], opts).then(r => { - if (r.code === 0) { - linuxCopy = 'wl-copy' - - return + // 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 () => { + const winner = await probeLinuxCopy() + linuxCopy = winner + + if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) { + console.error(`[clipboard] [native] Linux: clipboard probe complete → ${winner ?? 'no tool available'}`) } - void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => { - if (r2.code === 0) { - linuxCopy = 'xclip' - - return - } - - void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => { - linuxCopy = r3.code === 0 ? 'xsel' : null - }) - }) - }) + // Actually perform the copy with the discovered tool. + if (winner) { + void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts) + } + })() - return + return true } case 'win32': @@ -264,8 +319,10 @@ function copyNative(text: string): void { // imperfect (system locale encoding) but good enough for a fallback. void execFileNoThrow('clip', [], opts) - return + return true } + + return false } /** @internal test-only */ -
ui-tui/src/__tests__/createSlashHandler.test.ts@@ -363,7 +363,7 @@ const buildComposer = () => ({ hasSelection: false, paste: vi.fn(), queueRef: { current: [] as string[] }, - selection: { copySelection: vi.fn(() => '') }, + selection: { copySelection: vi.fn(async () => '') }, setInput: vi.fn() }) -
ui-tui/src/app/interfaces.ts@@ -32,7 +32,7 @@ export type StatusBarMode = 'bottom' | 'off' | 'top' export interface SelectionApi { clearSelection: () => void - copySelection: () => string + copySelection: () => Promise<string> } export interface CompletionItem { -
ui-tui/src/app/slash/commands/core.ts@@ -251,11 +251,17 @@ export const coreCommands: SlashCommand[] = [ { help: 'copy selection or assistant message', name: 'copy', - run: (arg, ctx) => { + run: async (arg, ctx) => { const { sys } = ctx.transcript - if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) { - return sys('copied selection') + if (!arg && ctx.composer.hasSelection) { + const text = await ctx.composer.selection.copySelection() + + if (text) { + return sys(`copied ${text.length} characters`) + } else { + return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details') + } } if (arg && Number.isNaN(parseInt(arg, 10))) { -
ui-tui/src/types/hermes-ink.d.ts@@ -83,8 +83,8 @@ declare module '@hermes/ink' { export function withInkSuspended(run: RunExternalProcess): Promise<void> export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void export function useSelection(): { - readonly copySelection: () => string - readonly copySelectionNoClear: () => string + readonly copySelection: () => Promise<string> + readonly copySelectionNoClear: () => Promise<string> readonly clearSelection: () => void readonly hasSelection: () => boolean readonly getState: () => unknown -
web/src/pages/ChatPage.tsx@@ -269,17 +269,17 @@ export default function ChatPage() { const payload = data.slice(semi + 1); if (payload === "?" || payload === "") return false; // read/clear — ignore try { - // atob returns a binary string (one byte per char); we need UTF-8 - // decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip - // correctly. Without this step, the three UTF-8 bytes of `≥` - // would land in the clipboard as the three separate Latin-1 - // characters `≥`. const binary = atob(payload); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const text = new TextDecoder("utf-8").decode(bytes); - navigator.clipboard.writeText(text).catch(() => {}); - } catch { - // Malformed base64 — silently drop. + navigator.clipboard.writeText(text).catch((err) => { + // Most common reason: the Clipboard API requires a user gesture. + // This can fail when the OSC 52 response arrives outside the + // original keydown event's activation. Log to aid debugging. + console.warn("[dashboard clipboard] OSC 52 write failed:", err.message); + }); + } catch (e) { + console.warn("[dashboard clipboard] malformed OSC 52 payload"); } return true; }); @@ -290,16 +290,31 @@ export default function ChatPage() { term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; + // Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C + // is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal / + // konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists; + // without a selection it passes through to the TUI so agents can still + // react to the keypress. + // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others. const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; if (copyModifier && ev.key.toLowerCase() === "c") { const sel = term.getSelection(); if (sel) { - navigator.clipboard.writeText(sel).catch(() => {}); + // Direct writeText inside the keydown handler preserves the user + // gesture — async round-trips through OSC 52 can lose activation + // and fail with "Document is not focused". + navigator.clipboard.writeText(sel).catch((err) => { + console.warn("[dashboard clipboard] direct copy failed:", err.message); + }); + // Clear xterm.js's highlight after copy (matches gnome-terminal). + term.clearSelection(); ev.preventDefault(); return false; } + // No selection → fall through so the TUI receives Ctrl+Shift+C + // (or the bare ev if the user used a different modifier). } if (pasteModifier && ev.key.toLowerCase() === "v") { @@ -308,7 +323,9 @@ export default function ChatPage() { .then((text) => { if (text) term.paste(text); }) - .catch(() => {}); + .catch((err) => { + console.warn("[dashboard clipboard] paste failed:", err.message); + }); ev.preventDefault(); return false; }
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#16020 fix(clipboard): dashboard Ctrl+C direct copy; TUI honest feedback; HERMES_TUI_FORCE_OSC52 · merged via another change →
a562420,0f3a6f0,2511207,#16035,#16020
Closed issues · 1
- Closed#16019[Bug]: Clipboard copy shows "copied N chars" but nothing is actually copied
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #16035
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 26 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.