diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 32bebcebb7d..8dab3805f07 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -6,6 +6,7 @@ import type { SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; /** * Checks if a query string potentially represents an '@' command. @@ -44,83 +45,172 @@ export const isSlashCommand = (query: string): boolean => { return true; }; -// Copies a string snippet to the clipboard for different platforms -export const copyToClipboard = async (text: string): Promise => { - const run = (cmd: string, args: string[], options?: SpawnOptions) => - new Promise((resolve, reject) => { - const child = options ? spawn(cmd, args, options) : spawn(cmd, args); - let stderr = ''; - if (child.stderr) { - child.stderr.on('data', (chunk) => (stderr += chunk.toString())); - } - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) return resolve(); - const errorMsg = stderr.trim(); - reject( - new Error( - `'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`, - ), - ); - }); - if (child.stdin) { - child.stdin.on('error', reject); - child.stdin.write(text); - child.stdin.end(); - } else { - reject(new Error('Child process has no stdin stream to write to.')); - } - }); +const MAX_OSC52_BASE64 = 99_992; // ~74 kB decoded +const ST = '\u001b\\'; // String Terminator + +const hasTty = (): boolean => process.stdout?.isTTY || process.stderr?.isTTY; + +const supportsOsc52 = (): boolean => { + if (!hasTty()) return false; + const term = (process.env['TERM'] || '').toLowerCase(); + if (term === 'dumb') return false; + + if ( + process.env['TMUX'] || + process.env['SSH_TTY'] || + process.env['SSH_CONNECTION'] + ) + return true; + + const program = (process.env['TERM_PROGRAM'] || '').toLowerCase(); + const known = [ + 'vscode', + 'wezterm', + 'kitty', + 'iterm', + 'alacritty', + 'windows terminal', + 'wt', + ]; + if (known.some((p) => program.includes(p))) return true; + + if (term.startsWith('xterm') || term.startsWith('screen')) return true; + + return false; +}; + +const preferOsc52 = (): boolean => { + if (!supportsOsc52()) return false; + + if (process.env['TMUX']) return true; + if (process.env['SSH_TTY'] || process.env['SSH_CONNECTION']) return true; + if ((process.env['TERM_PROGRAM'] || '').toLowerCase() === 'vscode') + return true; + + const headlessLinux = + process.platform === 'linux' && + !process.env['DISPLAY'] && + !process.env['WAYLAND_DISPLAY'] && + !process.env['MIR_SOCKET']; + return headlessLinux; +}; + +const buildOsc52 = (b64: string): string => { + const base = `\u001b]52;c;${b64}${ST}`; + + if (process.env['TMUX']) { + const esc = base.split('\u001b').join('\u001b\u001b'); + return `\u001bPtmux;${esc}${ST}`; + } + + if ((process.env['TERM'] || '').startsWith('screen')) { + const esc = base.split('\u001b').join('\u001b\u001b'); + return `\u001bP${esc}${ST}`; + } + + return base; +}; + +const writeTo = (stream: NodeJS.WriteStream, data: string) => + new Promise((resolve, reject) => { + stream.write(data, (err) => (err ? reject(err) : resolve())); + }); + +const tryOsc52Copy = async (text: string): Promise => { + if (!supportsOsc52()) throw new Error('OSC 52 unsupported'); + + const b64 = Buffer.from(text, 'utf8').toString('base64'); + if (b64.length > MAX_OSC52_BASE64) { + throw new Error( + `OSC 52 payload too large (${b64.length} > ${MAX_OSC52_BASE64} bytes)`, + ); + } - // Configure stdio for Linux clipboard commands. - // - stdin: 'pipe' to write the text that needs to be copied. - // - stdout: 'inherit' since we don't need to capture the command's output on success. - // - stderr: 'pipe' to capture error messages (e.g., "command not found") for better error handling. - const linuxOptions: SpawnOptions = { stdio: ['pipe', 'inherit', 'pipe'] }; + const seq = buildOsc52(b64); + + const streams: NodeJS.WriteStream[] = []; + if (process.stderr.isTTY) streams.push(process.stderr); + if ( + process.stdout.isTTY && + (process.stdout as unknown) !== (process.stderr as unknown) + ) + streams.push(process.stdout); + + for (const s of streams) { + try { + await writeTo(s, seq); + return; + } catch { + /* try next target */ + } + } + + if (process.platform !== 'win32') { + try { + await fs.writeFile('/dev/tty', seq, { encoding: 'utf8' }); + return; + } catch { + /* ignore */ + } + } + + throw new Error('No TTY accepted OSC 52 sequence'); +}; + +const runCmd = ( + text: string, + cmd: string, + args: string[], + opts?: SpawnOptions, +): Promise => + new Promise((resolve, reject) => { + const child = opts ? spawn(cmd, args, opts) : spawn(cmd, args); + let stderr = ''; + child.stderr?.on('data', (c) => (stderr += c)); + child.on('error', reject); + child.on('close', (code) => + code === 0 + ? resolve() + : reject(new Error(`${cmd} exited with ${code}: ${stderr.trim()}`)), + ); + if (child.stdin) { + child.stdin.on('error', reject); + child.stdin.write(text); + child.stdin.end(); + } else { + reject(new Error(`Child process for '${cmd}' has no stdin stream.`)); + } + }); + +const tryNativeClipboard = async (text: string): Promise => { + const linuxOpts: SpawnOptions = { stdio: ['pipe', 'ignore', 'pipe'] }; switch (process.platform) { case 'win32': - return run('clip', []); + return runCmd(text, 'clip', []); case 'darwin': - return run('pbcopy', []); + return runCmd(text, 'pbcopy', []); case 'linux': try { - await run('xclip', ['-selection', 'clipboard'], linuxOptions); - } catch (primaryError) { + await runCmd(text, 'xclip', ['-selection', 'clipboard'], linuxOpts); + } catch (xclipErr) { try { - // If xclip fails for any reason, try xsel as a fallback. - await run('xsel', ['--clipboard', '--input'], linuxOptions); - } catch (fallbackError) { - const xclipNotFound = - primaryError instanceof Error && - (primaryError as NodeJS.ErrnoException).code === 'ENOENT'; - const xselNotFound = - fallbackError instanceof Error && - (fallbackError as NodeJS.ErrnoException).code === 'ENOENT'; - if (xclipNotFound && xselNotFound) { - throw new Error( - 'Please ensure xclip or xsel is installed and configured.', - ); - } - - let primaryMsg = - primaryError instanceof Error - ? primaryError.message - : String(primaryError); - if (xclipNotFound) { - primaryMsg = `xclip not found`; - } - let fallbackMsg = - fallbackError instanceof Error - ? fallbackError.message - : String(fallbackError); - if (xselNotFound) { - fallbackMsg = `xsel not found`; + await runCmd(text, 'xsel', ['--clipboard', '--input'], linuxOpts); + } catch (xselErr) { + const xclipMissing = + xclipErr instanceof Error && + (xclipErr as NodeJS.ErrnoException).code === 'ENOENT'; + const xselMissing = + xselErr instanceof Error && + (xselErr as NodeJS.ErrnoException).code === 'ENOENT'; + if (xclipMissing && xselMissing) { + throw new Error('xclip/xsel not found'); } - - throw new Error( - `All copy commands failed. "${primaryMsg}", "${fallbackMsg}". `, - ); + const xclipMsg = + xclipErr instanceof Error ? xclipErr.message : String(xclipErr); + const xselMsg = + xselErr instanceof Error ? xselErr.message : String(xselErr); + throw new Error(`Native clipboard failed: ${xclipMsg}; ${xselMsg}`); } } return; @@ -129,6 +219,33 @@ export const copyToClipboard = async (text: string): Promise => { } }; +export const copyToClipboard = async (text: string): Promise => { + const steps: Array<() => Promise> = []; + + if (preferOsc52()) { + steps.push( + () => tryOsc52Copy(text), + () => tryNativeClipboard(text), + ); + } else { + steps.push(() => tryNativeClipboard(text)); + if (supportsOsc52()) steps.push(() => tryOsc52Copy(text)); + } + + const errors: Error[] = []; + for (const step of steps) { + try { + await step(); + return; + } catch (e) { + errors.push(e instanceof Error ? e : new Error(String(e))); + } + } + + if (errors.length === 1) throw errors[0]; + throw new Error(errors.map((e) => e.message).join(' | ')); +}; + export const getUrlOpenCommand = (): string => { // --- Determine the OS-specific command to open URLs --- let openCmd: string;