Skip to content

Commit 29cc16a

Browse files
committed
Update copy strategy to be more cross-platform
1 parent fd2b560 commit 29cc16a

File tree

1 file changed

+58
-17
lines changed

1 file changed

+58
-17
lines changed

cli/src/utils/clipboard.ts

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { closeSync, openSync, writeSync } from 'fs'
12
import { createRequire } from 'module'
23

34
import { logger } from './logger'
@@ -81,31 +82,22 @@ export async function copyTextToClipboard(
8182
}
8283

8384
try {
84-
if (typeof navigator !== 'undefined' && navigator.clipboard) {
85-
await navigator.clipboard.writeText(text)
86-
} else if (typeof process !== 'undefined' && process.platform) {
87-
// NOTE: Inline require() is used because this code path only runs in Node.js
88-
// environments, and we need to check process.platform at runtime first
85+
// Try OSC52 first (works over SSH/headless), then fallback to platform tools
86+
if (!tryCopyViaOsc52(text)) {
8987
const { execSync } = require('child_process') as typeof import('child_process')
90-
// Use stdio: ['pipe', 'ignore', 'ignore'] to prevent stderr from corrupting the TUI on headless servers
91-
// stdin needs 'pipe' for input, stdout/stderr use 'ignore' to discard any output
92-
const execOptions: { input: string; stdio: ('pipe' | 'ignore')[] } = {
93-
input: text,
94-
stdio: ['pipe', 'ignore', 'ignore'],
95-
}
88+
const opts = { input: text, stdio: ['pipe', 'ignore', 'ignore'] as ('pipe' | 'ignore')[] }
89+
9690
if (process.platform === 'darwin') {
97-
execSync('pbcopy', execOptions)
91+
execSync('pbcopy', opts)
9892
} else if (process.platform === 'linux') {
9993
try {
100-
execSync('xclip -selection clipboard', execOptions)
94+
execSync('xclip -selection clipboard', opts)
10195
} catch {
102-
execSync('xsel --clipboard --input', execOptions)
96+
execSync('xsel --clipboard --input', opts)
10397
}
10498
} else if (process.platform === 'win32') {
105-
execSync('clip', execOptions)
99+
execSync('clip', opts)
106100
}
107-
} else {
108-
return
109101
}
110102

111103
if (!suppressGlobalMessage) {
@@ -135,3 +127,52 @@ export function clearClipboardMessage() {
135127
}
136128
emitClipboardMessage(null)
137129
}
130+
131+
132+
// =============================================================================
133+
// OSC52 Clipboard Support
134+
// =============================================================================
135+
// OSC52 writes to clipboard via terminal escape sequences - works over SSH
136+
// because the client terminal handles clipboard. Format: ESC ] 52 ; c ; <base64> BEL
137+
// tmux/screen require passthrough wrapping to forward the sequence.
138+
139+
// 32KB is safe for all environments (tmux is the strictest)
140+
const OSC52_MAX_PAYLOAD = 32_000
141+
142+
function buildOsc52Sequence(text: string): string | null {
143+
if (process.env.TERM === 'dumb') return null
144+
145+
const base64 = Buffer.from(text, 'utf8').toString('base64')
146+
if (base64.length > OSC52_MAX_PAYLOAD) return null
147+
148+
const osc = `\x1b]52;c;${base64}\x07`
149+
150+
// tmux: wrap in DCS passthrough with doubled ESC
151+
if (process.env.TMUX) {
152+
return `\x1bPtmux;${osc.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`
153+
}
154+
155+
// GNU screen: wrap in DCS passthrough
156+
if (process.env.STY) {
157+
return `\x1bP${osc}\x1b\\`
158+
}
159+
160+
return osc
161+
}
162+
163+
function tryCopyViaOsc52(text: string): boolean {
164+
const sequence = buildOsc52Sequence(text)
165+
if (!sequence) return false
166+
167+
const ttyPath = process.platform === 'win32' ? 'CON' : '/dev/tty'
168+
let fd: number | null = null
169+
try {
170+
fd = openSync(ttyPath, 'w')
171+
writeSync(fd, sequence)
172+
return true
173+
} catch {
174+
return false
175+
} finally {
176+
if (fd !== null) closeSync(fd)
177+
}
178+
}

0 commit comments

Comments
 (0)