NousResearch/hermes-agent

TUI clipboard copy fixes and feedback accuracy

Cluster 16019. Data as of

Nothing open on GitHub. Fix merged in #16035 on 26 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #16035 fix(clipboard): salvage #16020 with native/tmux success signal + xterm keybinding

    merged to main

    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

    Show 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.00 means 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 Lab

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

For your agent

The same facts, typed.