mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
fix(tui): improve clipboard copy fallbacks
This commit is contained in:
parent
13a7cbcd64
commit
c3112adac5
3 changed files with 265 additions and 25 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
await expect(writeClipboardText('hello', 'linux', start)).resolves.toBe(false)
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
return child
|
||||
}),
|
||||
stdin: { end: vi.fn() }
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
if (ok) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the next clipboard backend.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await new Promise<boolean>(resolve => {
|
||||
const child = start('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
|
||||
|
||||
child.once('error', () => resolve(false))
|
||||
child.once('close', code => resolve(code === 0))
|
||||
child.stdin.end(text)
|
||||
})
|
||||
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue