# NousResearch/hermes-agent · TUI clipboard copy fixes and feedback accuracy

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #16035. Verify it addresses your case.
- **Recommended:** read PR #16035 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-16035`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #16019: \[Bug\]: Clipboard copy shows "copied N chars" but nothing is actually copied

## Prior & competing PRs

- **#16035: fix(clipboard): salvage #16020 with native/tmux success signal + xterm keybinding**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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.tsx`
  - fix hunks:
  - `ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts`

    ```diff
    @@ -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). */
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 ''
         }
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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':
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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;
         });
    ```

    ```diff
    @@ -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") {
    ```

    ```diff
    @@ -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;
           }
    ```
- **#16020: fix(clipboard): dashboard Ctrl+C direct copy; TUI honest feedback; HERMES_TUI_FORCE_OSC52**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `a56242038`, `0f3a6f0fb`, `2511207cb`, `#16035`, `#16020`

## Fix status

- Merged to main in PR #16035 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-26`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
