Skip to content
Closed
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
253 changes: 185 additions & 68 deletions packages/cli/src/ui/utils/commandUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> => {
const run = (cmd: string, args: string[], options?: SpawnOptions) =>
new Promise<void>((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<void>((resolve, reject) => {
stream.write(data, (err) => (err ? reject(err) : resolve()));
});

const tryOsc52Copy = async (text: string): Promise<void> => {
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<void> =>
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<void> => {
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;
Expand All @@ -129,6 +219,33 @@ export const copyToClipboard = async (text: string): Promise<void> => {
}
};

export const copyToClipboard = async (text: string): Promise<void> => {
const steps: Array<() => Promise<void>> = [];

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;
Expand Down