Adding /copy command with clipboard support
Cluster 11835 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #20159 on 5 May 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 #20159. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members introduce a /copy command feature and address clipboard handling, including a native clipboard fallback mechanism. The work spans feature introduction and implementation details around clipboard integration.
Open issues0
Open PRs0
Fixes on record
-
#20159 fix(tui): native clipboard fallback for /copy command (salvage #15963)
files:
scripts/release.py,ui-tui/src/__tests__/clipboard.test.ts,ui-tui/src/app/slash/commands/core.ts,ui-tui/src/lib/clipboard.tsShow 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 }
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#12362 feat: support copy command · superseded by another PR →
#20159,#15963
Closed issues · 1
- Closed#11835[Feature]: Introduce /copy for hermes
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
- Subsystem
- hermes cli
- Merged PRs
- #20159
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 5 May 2026