NousResearch/hermes-agent

TUI clipboard copy fallback fixes across terminals

Cluster 15664 · hermes cli. Data as of

1 open item. Fix merged in #20159 on 5 May 2026. Check it covers your case.

The short answer

1open issue
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #20159. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #20159. That is separate from the fix event.
Still open
1 issue and 0 PRs.

Multiple issues and fix attempts addressing the /copy command failing when terminals do not support OSC 52, with efforts to add native clipboard fallbacks for environments like GNOME Terminal, macOS Terminal.app, Wayland, and tmux.

Open issues1

  • Issue#15664/copy command does not work on macOS Terminal.app (OSC 52 not supported)

Open PRs0

No open pull requests.

Fixes on record

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

  • #20159 fix(tui): native clipboard fallback for /copy command (salvage #15963)

    merged to main

    files: scripts/release.py, ui-tui/src/__tests__/clipboard.test.ts, ui-tui/src/app/slash/commands/core.ts, ui-tui/src/lib/clipboard.ts

    Show the diff
    • scripts/release.py
      @@ -533,6 +533,7 @@
           "[redacted:email]": "memosr",
           "[redacted:email]": "perlowja",
           "[redacted:email]": "web-dev0521",
      +    "[redacted:email]": "bjianhang",
           "[redacted:email]": "tangyuanjc",
           "[redacted:email]": "harryplusplus",
           "[redacted:email]": "anthhub",
    • ui-tui/src/__tests__/clipboard.test.ts
      @@ -100,11 +100,22 @@ describe('isUsableClipboardText', () => {
       })
       
       describe('writeClipboardText', () => {
      -  it('does nothing off macOS', async () => {
      -    const start = vi.fn()
      +  it('does nothing off macOS when no tools are available', async () => {
      +    const child = {
      +      once: vi.fn((event: string, cb: (code?: number) => void) => {
      +        if (event === 'close') {
      +          cb(1) // non-zero exit = failure
      +        }
      +
      +        return child
      +      }),
      +      stdin: { end: vi.fn() }
      +    }
       
      -    await expect(writeClipboardText('hello', 'linux', start)).resolves.toBe(false)
      -    expect(start).not.toHaveBeenCalled()
      +    const start = vi.fn().mockReturnValue(child)
      +
      +    // Linux with no WAYLAND_DISPLAY / no WSL_INTEROP — falls through xclip then xsel, both fail
      +    await expect(writeClipboardText('hello', 'linux', start, {})).resolves.toBe(false)
         })
       
         it('writes text to pbcopy on macOS', async () => {
      @@ -148,4 +159,171 @@ describe('writeClipboardText', () => {
       
           await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false)
         })
      +
      +  it('uses wl-copy on Wayland Linux', async () => {
      +    const stdin = { end: vi.fn() }
      +
      +    const child = {
      +      once: vi.fn((event: string, cb: (code?: number) => void) => {
      +        if (event === 'close') {
      +          cb(0)
      +        }
      +
      +        return child
      +      }),
      +      stdin
      +    }
      +
      +    const start = vi.fn().mockReturnValue(child)
      +
      +    await expect(
      +      writeClipboardText('wayland text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
      +    ).resolves.toBe(true)
      +    expect(start).toHaveBeenCalledWith(
      +      'wl-copy',
      +      ['--type', 'text/plain'],
      +      expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
      +    )
      +    expect(stdin.end).toHaveBeenCalledWith('wayland text')
      +  })
      +
      +  it('falls back to xclip when wl-copy fails on Wayland', async () => {
      +    let callCount = 0
      +    const stdin = { end: vi.fn() }
      +
      +    const child = {
      +      once: vi.fn((event: string, cb: (code?: number) => void) => {
      +        if (event === 'close') {
      +          callCount++
      +          // wl-copy fails, xclip succeeds
      +          cb(callCount === 1 ? 1 : 0)
      +        }
      +
      +        return child
      +      }),
      +      stdin
      +    }
      +
      +    const start = vi.fn().mockReturnValue(child)
      +
      +    await expect(
      +      writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
      +    ).resolves.toBe(true)
      +    expect(start).toHaveBeenNthCalledWith(
      +      1,
      +      'wl-copy',
      +      ['--type', 'text/plain'],
      +      expect.anything()
      +    )
      +    expect(start).toHaveBeenNthCalledWith(
      +      2,
      +      'xclip',
      +      ['-selection', 'clipboard', '-in'],
      +      expect.anything()
      +    )
      +  })
      +
      +  it('falls back to xsel when both wl-copy and xclip fail', async () => {
      +    let callCount = 0
      +    const stdin = { end: vi.fn() }
      +
      +    const child = {
      +      once: vi.fn((event: string, cb: (code?: number) => void) => {
      +        if (event === 'close') {
      +          callCount++
      +          cb(callCount < 3 ? 1 : 0) // first two fail, third (xsel) succeeds
      +        }
      +
      +        return child
      +      }),
      +      stdin
      +    }
      +
      +    const start = vi.fn().mockReturnValue(child)
      +
      +    await expect(
      +      writeClipboardText('xsel text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
      +    ).resolves.toBe(true)
      +    expect(start).toHaveBeenNthCalledWith(3, 'xsel', ['--clipboard', '--input'], expect.anything())
      +  })
      +
      +  it('uses PowerShell on WSL2 when WSL_DISTRO_NAME is set', async () => {
      +    const stdin = { end: vi.fn() }
      +
      +    const child = {
      +      once: vi.fn((event: string, cb: (code?: number) => void) => {
      +        if (event === 'close') {
      +          cb(0)
      +        }
      +
      +        return child
      +      }),
      +      stdin
      +    }
      +
      +    const start = vi.fn().mockReturnValue(child)
      +
      +    await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(true)
      +    expect(start).toHaveBeenCalledWith(
      +      'powershell.exe',
      +      expect.arrayContaining(['-NoProfile', '-NonInteractive']),
      +      expect.anything()
      +    )
      +    expect(stdin.end).toHaveBeenCalledWith('wsl text')
      +  })
      +
      +  it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => {
      +    const stdin = { end: vi.fn() }
      +
      +    const child = {
      +      once: vi.fn((event: string, cb: (code?: number) => void) => {
      +        if (event === 'close') {
      +          cb(0)
      +        }
      +
      +        return child
      +      }),
      +      stdin
      +    }
      +
      +    const start = vi.fn().mockReturnValue(child)
      +
      +    await expect(
      +      writeClipboardText('wslg text', 'linux', start as any, {
      +        WAYLAND_DISPLAY: 'wayland-0',
      +        WSL_DISTRO_NAME: 'Ubuntu'
      +      })
      +    ).resolves.toBe(true)
      +    expect(start).toHaveBeenNthCalledWith(
      +      1,
      +      'powershell.exe',
      +      expect.arrayContaining(['-NoProfile', '-NonInteractive']),
      +      expect.anything()
      +    )
      +    expect(stdin.end).toHaveBeenCalledWith('wslg text')
      +  })
      +
      +  it('uses PowerShell on Windows', async () => {
      +    const stdin = { end: vi.fn() }
      +
      +    const child = {
      +      once: vi.fn((event: string, cb: (code?: number) => void) => {
      +        if (event === 'close') {
      +          cb(0)
      +        }
      +
      +        return child
      +      }),
      +      stdin
      +    }
      +
      +    const start = vi.fn().mockReturnValue(child)
      +
      +    await expect(writeClipboardText('windows text', 'win32', start as any)).resolves.toBe(true)
      +    expect(start).toHaveBeenCalledWith(
      +      'powershell',
      +      expect.arrayContaining(['-NoProfile', '-NonInteractive']),
      +      expect.anything()
      +    )
      +  })
       })
    • ui-tui/src/app/slash/commands/core.ts
      @@ -10,6 +10,7 @@ import type {
         SessionTitleResponse,
         SessionUndoResponse
       } from '../../../gatewayTypes.js'
      +import { writeClipboardText } from '../../../lib/clipboard.js'
       import { writeOsc52Clipboard } from '../../../lib/osc52.js'
       import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
       import type { Msg, PanelSection } from '../../../types.js'
      @@ -318,10 +319,27 @@ export const coreCommands: SlashCommand[] = [
             const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
       
             if (!target) {
      -        return sys('nothing to copy')
      +        return sys('nothing to copy — start a conversation first')
             }
       
      -      writeOsc52Clipboard(target.text)
      +      void writeClipboardText(target.text)
      +        .then(nativeOk => {
      +          if (ctx.stale()) {
      +            return
      +          }
      +
      +          if (nativeOk) {
      +            sys('copied to clipboard')
      +          } else {
      +            writeOsc52Clipboard(target.text)
      +            sys('sent OSC52 copy sequence (terminal support required)')
      +          }
      +        })
      +        .catch(error => {
      +          if (!ctx.stale()) {
      +            sys(`copy failed: ${String(error)}`)
      +          }
      +        })
           }
         },
       
    • ui-tui/src/lib/clipboard.ts
      @@ -44,7 +44,7 @@ function readClipboardCommands(
       
         const attempts: Array<{ args: readonly string[]; cmd: string }> = []
       
      -  if (env.WSL_INTEROP) {
      +  if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
           attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS })
         }
       
      @@ -91,32 +91,76 @@ export async function readClipboardText(
         return null
       }
       
      +function writeClipboardCommands(
      +  platform: NodeJS.Platform,
      +  env: NodeJS.ProcessEnv
      +): Array<{ args: readonly string[]; cmd: string }> {
      +  if (platform === 'darwin') {
      +    return [{ cmd: 'pbcopy', args: [] }]
      +  }
      +
      +  if (platform === 'win32') {
      +    return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }]
      +  }
      +
      +  const attempts: Array<{ args: readonly string[]; cmd: string }> = []
      +
      +  if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
      +    attempts.push({
      +      cmd: 'powershell.exe',
      +      args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input']
      +    })
      +  }
      +
      +  if (env.WAYLAND_DISPLAY) {
      +    attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] })
      +  }
      +
      +  attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] })
      +  attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] })
      +
      +  return attempts
      +}
      +
       /**
        * Write plain text to the system clipboard.
        *
      - * On macOS this uses `pbcopy`. On other platforms we intentionally return
      - * false for now; non-mac copy still falls back to OSC52.
      + * Tries native platform tools in fallback order:
      + * - macOS: pbcopy
      + * - Windows: PowerShell Set-Clipboard
      + * - WSL: powershell.exe Set-Clipboard
      + * - Linux Wayland: wl-copy --type text/plain
      + * - Linux X11: xclip -selection clipboard -in
      + * - Linux X11 alt: xsel --clipboard --input
      + *
      + * Returns true if at least one backend succeeded, false otherwise
      + * (callers should fall back to OSC52 on false).
        */
       export async function writeClipboardText(
         text: string,
         platform: NodeJS.Platform = process.platform,
      -  start: typeof spawn = spawn
      +  start: typeof spawn = spawn,
      +  env: NodeJS.ProcessEnv = process.env
       ): Promise<boolean> {
      -  if (platform !== 'darwin') {
      -    return false
      -  }
      +  const candidates = writeClipboardCommands(platform, env)
       
      -  try {
      -    const ok = await new Promise<boolean>(resolve => {
      -      const child = start('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
      +  for (const { cmd, args } of candidates) {
      +    try {
      +      const ok = await new Promise<boolean>(resolve => {
      +        const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
       
      -      child.once('error', () => resolve(false))
      -      child.once('close', code => resolve(code === 0))
      -      child.stdin.end(text)
      -    })
      +        child.once('error', () => resolve(false))
      +        child.once('close', code => resolve(code === 0))
      +        child.stdin?.end(text)
      +      })
       
      -    return ok
      -  } catch {
      -    return false
      +      if (ok) {
      +        return true
      +      }
      +    } catch {
      +      // Fall through to the next clipboard backend.
      +    }
         }
      +
      +  return false
       }

3 PRs closed without merging

From the close comments: merged via another change 1 · superseded by another PR 1 · other 1

  • #15856 fix(cli): /copy falls back to native clipboard on OSC 52-blind terminals · other
  • #15963 fix(tui): improve clipboard copy fallbacks · merged via another change → #20159, #18308
  • #15991 fix(clipboard): add native clipboard fallback for /copy command (#15664) · superseded by another PR → #20159, #15963, #20954, #29342
Closed issues · 1
  • Closed#18308TUI (--tui): Copy/paste from terminal does not work on GNOME Terminal
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.36: 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

Subsystem
hermes cli
Merged PRs
#20159
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
5 May 2026

For your agent

The same facts, typed.