|
| 1 | +import { closeSync, openSync, writeSync } from 'fs' |
1 | 2 | import { createRequire } from 'module' |
2 | 3 |
|
3 | 4 | import { logger } from './logger' |
@@ -81,31 +82,22 @@ export async function copyTextToClipboard( |
81 | 82 | } |
82 | 83 |
|
83 | 84 | 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)) { |
89 | 87 | 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 | + |
96 | 90 | if (process.platform === 'darwin') { |
97 | | - execSync('pbcopy', execOptions) |
| 91 | + execSync('pbcopy', opts) |
98 | 92 | } else if (process.platform === 'linux') { |
99 | 93 | try { |
100 | | - execSync('xclip -selection clipboard', execOptions) |
| 94 | + execSync('xclip -selection clipboard', opts) |
101 | 95 | } catch { |
102 | | - execSync('xsel --clipboard --input', execOptions) |
| 96 | + execSync('xsel --clipboard --input', opts) |
103 | 97 | } |
104 | 98 | } else if (process.platform === 'win32') { |
105 | | - execSync('clip', execOptions) |
| 99 | + execSync('clip', opts) |
106 | 100 | } |
107 | | - } else { |
108 | | - return |
109 | 101 | } |
110 | 102 |
|
111 | 103 | if (!suppressGlobalMessage) { |
@@ -135,3 +127,52 @@ export function clearClipboardMessage() { |
135 | 127 | } |
136 | 128 | emitClipboardMessage(null) |
137 | 129 | } |
| 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