Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@
"memosr_email@gmail.com": "memosr",
"jperlow@gmail.com": "perlowja",
"jasonpette1783@gmail.com": "web-dev0521",
"bjianhang@gmail.com": "bjianhang",
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
"harryplusplus@gmail.com": "harryplusplus",
"anthhub@163.com": "anthhub",
Expand Down
186 changes: 182 additions & 4 deletions ui-tui/src/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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()
)
})
})
22 changes: 20 additions & 2 deletions ui-tui/src/app/slash/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)}`)
}
})
}
},

Expand Down
78 changes: 61 additions & 17 deletions ui-tui/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

Expand Down Expand Up @@ -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
}
Loading