{"repo":"NousResearch/hermes-agent","public_id":"16019","display_label":"TUI clipboard copy fixes and feedback accuracy","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16035,"merged_at":"2026-04-26T12:46:46+00:00"},"resolution":{"state":"resolved","by":"pr-16035"},"freshness":{"state":"stale","last_verified_at":"2026-04-26T12:46:46+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16019,"pr_number":16035,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-26T12:46:46+00:00"}],"members":[{"kind":"issue","number":16019,"title":"[Bug]: Clipboard copy shows \"copied N chars\" but nothing is actually copied","url":null,"state":"closed"},{"kind":"pr","number":16020,"title":"fix(clipboard): dashboard Ctrl+C direct copy; TUI honest feedback; HERMES_TUI_FORCE_OSC52","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a56242038","number":0},{"kind":"commit","value":"0f3a6f0fb","number":0},{"kind":"commit","value":"2511207cb","number":0},{"kind":"pr","value":"#16035","number":16035},{"kind":"pr","value":"#16020","number":16020}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16035,"title":"fix(clipboard): salvage #16020 with native/tmux success signal + xterm keybinding","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts","hunks":["@@ -9,9 +9,9 @@ import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.j\n  * Returns no-op functions when fullscreen mode is disabled.\n  */\n export function useSelection(): {\n-  copySelection: () => string\n+  copySelection: () => Promise<string>\n   /** Copy without clearing the highlight (for copy-on-select). */\n-  copySelectionNoClear: () => string\n+  copySelectionNoClear: () => Promise<string>\n   clearSelection: () => void\n   hasSelection: () => boolean\n   /** Read the raw mutable selection state (for drag-to-scroll). */","@@ -48,8 +48,8 @@ export function useSelection(): {\n   return useMemo(() => {\n     if (!ink) {\n       return {\n-        copySelection: () => '',\n-        copySelectionNoClear: () => '',\n+        copySelection: async () => '',\n+        copySelectionNoClear: async () => '',\n         clearSelection: () => {},\n         hasSelection: () => false,\n         getState: () => null,"]},{"file":"ui-tui/packages/hermes-ink/src/ink/ink.tsx","hunks":["@@ -1297,11 +1297,13 @@ export default class Ink {\n   }\n \n   /**\n-   * Copy the current selection to the clipboard without clearing the\n-   * highlight. Matches iTerm2's copy-on-select behavior where the selected\n-   * region stays visible after the automatic copy.\n+   * Copy the current text selection to the system clipboard without clearing the\n+   * selection. Returns the copied text when a clipboard path succeeded (native\n+   * tool fired, tmux buffer loaded, or OSC 52 emitted), or '' when no path was\n+   * taken (e.g. headless Linux without tmux). Matches iTerm2's copy-on-select\n+   * behavior where the selected region stays visible after the automatic copy.\n    */\n-  copySelectionNoClear(): string {\n+  async copySelectionNoClear(): Promise<string> {\n     if (!hasSelection(this.selection)) {\n       return ''\n     }","@@ -1309,28 +1311,41 @@ export default class Ink {\n     const text = getSelectedText(this.selection, this.frontFrame.screen)\n \n     if (text) {\n-      // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux\n-      // drops it silently unless allow-passthrough is on — no regression).\n-      void setClipboard(text).then(raw => {\n-        if (raw) {\n-          this.options.stdout.write(raw)\n+      try {\n+        const { sequence, success } = await setClipboard(text)\n+\n+        if (sequence) {\n+          this.options.stdout.write(sequence)\n         }\n-      })\n+\n+        if (success) {\n+          return text\n+        }\n+\n+        if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {\n+          console.error('[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence')\n+        }\n+      } catch (err) {\n+        if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {\n+          console.error('[clipboard] error:', err)\n+        }\n+      }\n     }\n \n-    return text\n+    return ''\n   }\n \n   /**\n    * Copy the current text selection to the system clipboard via OSC 52\n-   * and clear the selection. Returns the copied text (empty if no selection).\n+   * and clear the selection. Returns the copied text (empty if no selection\n+   * or clipboard operation failed).\n    */\n-  copySelection(): string {\n+  async copySelection(): Promise<string> {\n     if (!hasSelection(this.selection)) {\n       return ''\n     }\n \n-    const text = this.copySelectionNoClear()\n+    const text = await this.copySelectionNoClear()\n     clearSelection(this.selection)\n     this.notifySelectionChange()\n "]},{"file":"ui-tui/packages/hermes-ink/src/ink/termio/osc.test.ts","hunks":["@@ -26,4 +26,26 @@ describe('shouldEmitClipboardSequence', () => {\n       shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)\n     ).toBe(false)\n   })\n+\n+  it('HERMES_TUI_FORCE_OSC52 takes precedence over TMUX suppression', () => {\n+    // Without the override, local-in-tmux suppresses the OSC 52 sequence\n+    // so the terminal multiplexer path wins. FORCE_OSC52=1 flips that\n+    // back on for users whose tmux config supports passthrough.\n+    expect(shouldEmitClipboardSequence({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv)).toBe(false)\n+    expect(\n+      shouldEmitClipboardSequence({\n+        HERMES_TUI_FORCE_OSC52: '1',\n+        TMUX: '/tmp/t,1,0'\n+      } as NodeJS.ProcessEnv)\n+    ).toBe(true)\n+  })\n+\n+  it('HERMES_TUI_FORCE_OSC52=0 suppresses OSC 52 even for remote or plain terminals', () => {\n+    expect(\n+      shouldEmitClipboardSequence({\n+        HERMES_TUI_FORCE_OSC52: '0',\n+        SSH_CONNECTION: '1'\n+      } as NodeJS.ProcessEnv)\n+    ).toBe(false)\n+  })\n })"]},{"file":"ui-tui/packages/hermes-ink/src/ink/termio/osc.ts","hunks":["@@ -84,7 +84,11 @@ export function getClipboardPath(): ClipboardPath {\n }\n \n export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env): boolean {\n-  const override = (env.HERMES_TUI_CLIPBOARD_OSC52 ?? env.HERMES_TUI_COPY_OSC52 ?? '').trim()\n+  const override = (\n+    env.HERMES_TUI_FORCE_OSC52 ??\n+    env.HERMES_TUI_CLIPBOARD_OSC52 ??\n+    env.HERMES_TUI_COPY_OSC52 ?? ''\n+  ).trim()\n \n   if (ENV_ON_RE.test(override)) {\n     return true","@@ -162,10 +166,23 @@ export async function tmuxLoadBuffer(text: string): Promise<boolean> {\n  * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over\n  * SSH these would write to the remote clipboard — OSC 52 is the right path there.\n  *\n- * Returns the sequence for the caller to write to stdout (raw OSC 52\n- * outside tmux, DCS-wrapped inside).\n+ * Returns { sequence, success }:\n+ *   - `sequence` is the bytes to write to stdout (raw OSC 52 outside tmux,\n+ *     DCS-wrapped inside; empty string when we shouldn't emit).\n+ *   - `success` is true when we believe SOME path reached the clipboard:\n+ *     native tool fired (local), tmux buffer loaded, or an OSC 52 sequence\n+ *     was emitted to the terminal. False only when no path was taken at\n+ *     all (headless Linux with no tmux + osc52 suppressed, effectively).\n+ *     This is best-effort — pbcopy/xclip are fire-and-forget, and OSC 52\n+ *     depends on the outer terminal honoring the sequence — but it lets\n+ *     callers distinguish \"nothing attempted\" from \"attempted\".\n  */\n-export async function setClipboard(text: string): Promise<string> {\n+export type ClipboardResult = {\n+  sequence: string\n+  success: boolean\n+}\n+\n+export async function setClipboard(text: string): Promise<ClipboardResult> {\n   const b64 = Buffer.from(text, 'utf8').toString('base64')\n   const raw = osc(OSC.CLIPBOARD, 'c', b64)\n   const emitSequence = shouldEmitClipboardSequence(process.env)","@@ -177,20 +194,28 @@ export async function setClipboard(text: string): Promise<string> {\n   // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).\n   // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY\n   // forever but SSH_CONNECTION is in tmux's default update-environment and\n-  // clears on local attach. Fire-and-forget.\n-  if (!process.env['SSH_CONNECTION']) {\n-    copyNative(text)\n-  }\n+  // clears on local attach. Fire-and-forget, but `copyNativeAttempted`\n+  // tells us whether ANY native path will be tried on this platform.\n+  const nativeAttempted =\n+    !process.env['SSH_CONNECTION'] && copyNative(text)\n \n   const tmuxBufferLoaded = await tmuxLoadBuffer(text)\n \n   // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling\n   // too, and BEL works everywhere for OSC 52.\n-  if (tmuxBufferLoaded) {\n-    return emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : ''\n-  }\n-\n-  return emitSequence ? raw : ''\n+  const sequence = tmuxBufferLoaded\n+    ? (emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '')\n+    : (emitSequence ? raw : '')\n+\n+  // Success if any path was taken. Native and tmux are fire-and-forget,\n+  // so we can't truly confirm the clipboard was written — but if native\n+  // was attempted OR tmux buffer loaded OR we emitted OSC 52, the user's\n+  // paste is likely to work. The only false case is \"we did literally\n+  // nothing\" (e.g. local-in-tmux with osc52 suppressed and tmux buffer\n+  // load failed), in which case reporting failure to the user is honest.\n+  const success = nativeAttempted || tmuxBufferLoaded || sequence.length > 0\n+\n+  return { sequence, success }\n }\n \n // Linux clipboard tool: undefined = not yet probed, null = none available.","@@ -198,65 +223,95 @@ export async function setClipboard(text: string): Promise<string> {\n // Cached after first attempt so repeated mouse-ups skip the probe chain.\n let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined\n \n+/** Internal: probe once and cache — wl-copy first, then xclip, then xsel. */\n+async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> {\n+  const opts = { useCwd: false, timeout: 500 }\n+\n+  const r = await execFileNoThrow('wl-copy', [], opts)\n+\n+  if (r.code === 0) {\n+    return 'wl-copy'\n+  }\n+\n+  const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)\n+\n+  if (r2.code === 0) {\n+    return 'xclip'\n+  }\n+\n+  const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts)\n+\n+  return r3.code === 0 ? 'xsel' : null\n+}\n+\n /**\n  * Shell out to a native clipboard utility as a safety net for OSC 52.\n  * Only called when not in an SSH session (over SSH, these would write to\n  * the remote machine's clipboard — OSC 52 is the right path there).\n  * Fire-and-forget: failures are silent since OSC 52 may have succeeded.\n+ *\n+ * Returns true when a native copy path was (or will be) attempted — i.e.\n+ * we'll spawn pbcopy on macOS, clip on Windows, or a known-working Linux\n+ * tool. Returns false only when we know no native tool is viable (Linux\n+ * without DISPLAY/WAYLAND_DISPLAY, or previously-probed-to-null). The\n+ * return value is used to decide whether to tell the user the copy\n+ * succeeded — spawning is best-effort but good enough to claim success.\n+ *\n+ * Linux behaviour: if DISPLAY and WAYLAND_DISPLAY are both unset, native\n+ * clipboard tools cannot work (they need a display server). In that case\n+ * we skip probing entirely and treat linuxCopy as permanently null.\n  */\n-function copyNative(text: string): void {\n+function copyNative(text: string): boolean {\n   const opts = { input: text, useCwd: false, timeout: 2000 }\n \n   switch (process.platform) {\n     case 'darwin':\n       void execFileNoThrow('pbcopy', [], opts)\n \n-      return\n+      return true\n     case 'linux': {\n-      if (linuxCopy === null) {\n-        return\n-      }\n+      // If we already probed (success or hard-fail), short-circuit.\n+      if (linuxCopy !== undefined) {\n+        if (linuxCopy === null) {\n+          // No working native tool — skip silently.\n+          return false\n+        }\n \n-      if (linuxCopy === 'wl-copy') {\n-        void execFileNoThrow('wl-copy', [], opts)\n+        // linuxCopy is a known-working tool; fire-and-forget.\n+        void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts)\n \n-        return\n+        return true\n       }\n \n-      if (linuxCopy === 'xclip') {\n-        void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)\n-\n-        return\n-      }\n+      // No display server → native tools will fail immediately. Cache null.\n+      if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {\n+        if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {\n+          console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable')\n+        }\n \n-      if (linuxCopy === 'xsel') {\n-        void execFileNoThrow('xsel', ['--clipboard', '--input'], opts)\n+        linuxCopy = null\n \n-        return\n+        return false\n       }\n-\n-      // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner.\n-      void execFileNoThrow('wl-copy', [], opts).then(r => {\n-        if (r.code === 0) {\n-          linuxCopy = 'wl-copy'\n-\n-          return\n+      // First call: probe in the background and cache the result for future copies.\n+      // We don't await — this is fire-and-forget. Treat as an attempt:\n+      // the probe will discover a tool and spawn it. If probing finds\n+      // nothing, the NEXT copy will short-circuit above.\n+      void (async () => {\n+        const winner = await probeLinuxCopy()\n+        linuxCopy = winner\n+\n+        if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {\n+          console.error(`[clipboard] [native] Linux: clipboard probe complete → ${winner ?? 'no tool available'}`)\n         }\n \n-        void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => {\n-          if (r2.code === 0) {\n-            linuxCopy = 'xclip'\n-\n-            return\n-          }\n-\n-          void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => {\n-            linuxCopy = r3.code === 0 ? 'xsel' : null\n-          })\n-        })\n-      })\n+        // Actually perform the copy with the discovered tool.\n+        if (winner) {\n+          void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts)\n+        }\n+      })()\n \n-      return\n+      return true\n     }\n \n     case 'win32':","@@ -264,8 +319,10 @@ function copyNative(text: string): void {\n       // imperfect (system locale encoding) but good enough for a fallback.\n       void execFileNoThrow('clip', [], opts)\n \n-      return\n+      return true\n   }\n+\n+  return false\n }\n \n /** @internal test-only */"]},{"file":"ui-tui/src/__tests__/createSlashHandler.test.ts","hunks":["@@ -363,7 +363,7 @@ const buildComposer = () => ({\n   hasSelection: false,\n   paste: vi.fn(),\n   queueRef: { current: [] as string[] },\n-  selection: { copySelection: vi.fn(() => '') },\n+  selection: { copySelection: vi.fn(async () => '') },\n   setInput: vi.fn()\n })\n "]},{"file":"ui-tui/src/app/interfaces.ts","hunks":["@@ -32,7 +32,7 @@ export type StatusBarMode = 'bottom' | 'off' | 'top'\n \n export interface SelectionApi {\n   clearSelection: () => void\n-  copySelection: () => string\n+  copySelection: () => Promise<string>\n }\n \n export interface CompletionItem {"]},{"file":"ui-tui/src/app/slash/commands/core.ts","hunks":["@@ -251,11 +251,17 @@ export const coreCommands: SlashCommand[] = [\n   {\n     help: 'copy selection or assistant message',\n     name: 'copy',\n-    run: (arg, ctx) => {\n+    run: async (arg, ctx) => {\n       const { sys } = ctx.transcript\n \n-      if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) {\n-        return sys('copied selection')\n+      if (!arg && ctx.composer.hasSelection) {\n+        const text = await ctx.composer.selection.copySelection()\n+\n+        if (text) {\n+          return sys(`copied ${text.length} characters`)\n+        } else {\n+          return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details')\n+        }\n       }\n \n       if (arg && Number.isNaN(parseInt(arg, 10))) {"]},{"file":"ui-tui/src/types/hermes-ink.d.ts","hunks":["@@ -83,8 +83,8 @@ declare module '@hermes/ink' {\n   export function withInkSuspended(run: RunExternalProcess): Promise<void>\n   export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void\n   export function useSelection(): {\n-    readonly copySelection: () => string\n-    readonly copySelectionNoClear: () => string\n+    readonly copySelection: () => Promise<string>\n+    readonly copySelectionNoClear: () => Promise<string>\n     readonly clearSelection: () => void\n     readonly hasSelection: () => boolean\n     readonly getState: () => unknown"]},{"file":"web/src/pages/ChatPage.tsx","hunks":["@@ -269,17 +269,17 @@ export default function ChatPage() {\n       const payload = data.slice(semi + 1);\n       if (payload === \"?\" || payload === \"\") return false; // read/clear — ignore\n       try {\n-        // atob returns a binary string (one byte per char); we need UTF-8\n-        // decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip\n-        // correctly.  Without this step, the three UTF-8 bytes of `≥`\n-        // would land in the clipboard as the three separate Latin-1\n-        // characters `â‰¥`.\n         const binary = atob(payload);\n         const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));\n         const text = new TextDecoder(\"utf-8\").decode(bytes);\n-        navigator.clipboard.writeText(text).catch(() => {});\n-      } catch {\n-        // Malformed base64 — silently drop.\n+        navigator.clipboard.writeText(text).catch((err) => {\n+          // Most common reason: the Clipboard API requires a user gesture.\n+          // This can fail when the OSC 52 response arrives outside the\n+          // original keydown event's activation. Log to aid debugging.\n+          console.warn(\"[dashboard clipboard] OSC 52 write failed:\", err.message);\n+        });\n+      } catch (e) {\n+        console.warn(\"[dashboard clipboard] malformed OSC 52 payload\");\n       }\n       return true;\n     });","@@ -290,16 +290,31 @@ export default function ChatPage() {\n     term.attachCustomKeyEventHandler((ev) => {\n       if (ev.type !== \"keydown\") return true;\n \n+      // Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C\n+      // is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal /\n+      // konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists;\n+      // without a selection it passes through to the TUI so agents can still\n+      // react to the keypress.\n+      // Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others.\n       const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;\n       const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;\n \n       if (copyModifier && ev.key.toLowerCase() === \"c\") {\n         const sel = term.getSelection();\n         if (sel) {\n-          navigator.clipboard.writeText(sel).catch(() => {});\n+          // Direct writeText inside the keydown handler preserves the user\n+          // gesture — async round-trips through OSC 52 can lose activation\n+          // and fail with \"Document is not focused\".\n+          navigator.clipboard.writeText(sel).catch((err) => {\n+            console.warn(\"[dashboard clipboard] direct copy failed:\", err.message);\n+          });\n+          // Clear xterm.js's highlight after copy (matches gnome-terminal).\n+          term.clearSelection();\n           ev.preventDefault();\n           return false;\n         }\n+        // No selection → fall through so the TUI receives Ctrl+Shift+C\n+        // (or the bare ev if the user used a different modifier).\n       }\n \n       if (pasteModifier && ev.key.toLowerCase() === \"v\") {","@@ -308,7 +323,9 @@ export default function ChatPage() {\n           .then((text) => {\n             if (text) term.paste(text);\n           })\n-          .catch(() => {});\n+          .catch((err) => {\n+            console.warn(\"[dashboard clipboard] paste failed:\", err.message);\n+          });\n         ev.preventDefault();\n         return false;\n       }\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}