| |
| |
| |
| |
| |
|
|
| import { debugLogger } from '@google/gemini-cli-core'; |
| import clipboardy from 'clipboardy'; |
| import type { SlashCommand } from '../commands/types.js'; |
| import fs from 'node:fs'; |
| import type { Writable } from 'node:stream'; |
| import type { Settings } from '../../config/settingsSchema.js'; |
| import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js'; |
|
|
| |
| |
| |
| const AT_COMMAND_DETECT_REGEX = new RegExp( |
| `(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}`, |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const isAtCommand = (query: string): boolean => |
| AT_COMMAND_DETECT_REGEX.test(query); |
|
|
| |
| |
| |
| |
| |
| |
| |
| export const isSlashCommand = (query: string): boolean => { |
| if (!query.startsWith('/')) { |
| return false; |
| } |
|
|
| |
| if (query.startsWith('//')) { |
| return false; |
| } |
|
|
| |
| if (query.startsWith('/*')) { |
| return false; |
| } |
|
|
| return true; |
| }; |
|
|
| const ESC = '\u001B'; |
| const BEL = '\u0007'; |
| const ST = '\u001B\\'; |
|
|
| const MAX_OSC52_SEQUENCE_BYTES = 100_000; |
| const OSC52_HEADER = `${ESC}]52;c;`; |
| const OSC52_FOOTER = BEL; |
| const MAX_OSC52_BODY_B64_BYTES = |
| MAX_OSC52_SEQUENCE_BYTES - |
| Buffer.byteLength(OSC52_HEADER) - |
| Buffer.byteLength(OSC52_FOOTER); |
| const MAX_OSC52_DATA_BYTES = Math.floor(MAX_OSC52_BODY_B64_BYTES / 4) * 3; |
|
|
| |
| const SCREEN_DCS_CHUNK_SIZE = 240; |
|
|
| type TtyTarget = { stream: Writable; closeAfter: boolean } | null; |
|
|
| const pickTty = (): Promise<TtyTarget> => |
| new Promise((resolve) => { |
| |
| if (process.platform !== 'win32') { |
| |
| try { |
| const devTty = fs.createWriteStream('/dev/tty'); |
|
|
| |
| const timeout = setTimeout(() => { |
| |
| devTty.removeAllListeners('open'); |
| devTty.removeAllListeners('error'); |
| devTty.destroy(); |
| resolve(getStdioTty()); |
| }, 100); |
|
|
| |
| |
| |
| devTty.once('open', () => { |
| clearTimeout(timeout); |
| devTty.removeAllListeners('error'); |
| |
| devTty.on('error', () => {}); |
| resolve({ stream: devTty, closeAfter: true }); |
| }); |
|
|
| |
| devTty.once('error', () => { |
| clearTimeout(timeout); |
| devTty.removeAllListeners('open'); |
| resolve(getStdioTty()); |
| }); |
| return; |
| } catch { |
| |
| } |
| } |
|
|
| resolve(getStdioTty()); |
| }); |
|
|
| const getStdioTty = (): TtyTarget => { |
| |
| |
| if (process.platform === 'win32') { |
| if (process.stdout?.isTTY) |
| return { stream: process.stdout, closeAfter: false }; |
| if (process.stderr?.isTTY) |
| return { stream: process.stderr, closeAfter: false }; |
| return null; |
| } |
|
|
| |
| |
| if (process.stderr?.isTTY) |
| return { stream: process.stderr, closeAfter: false }; |
| if (process.stdout?.isTTY) |
| return { stream: process.stdout, closeAfter: false }; |
| return null; |
| }; |
|
|
| const inTmux = (): boolean => |
| Boolean( |
| process.env['TMUX'] || (process.env['TERM'] ?? '').startsWith('tmux'), |
| ); |
|
|
| const inScreen = (): boolean => |
| Boolean( |
| process.env['STY'] || (process.env['TERM'] ?? '').startsWith('screen'), |
| ); |
|
|
| const isSSH = (): boolean => |
| Boolean( |
| process.env['SSH_TTY'] || |
| process.env['SSH_CONNECTION'] || |
| process.env['SSH_CLIENT'], |
| ); |
|
|
| const isWSL = (): boolean => |
| Boolean( |
| process.env['WSL_DISTRO_NAME'] || |
| process.env['WSLENV'] || |
| process.env['WSL_INTEROP'], |
| ); |
|
|
| const isWindowsTerminal = (): boolean => |
| process.platform === 'win32' && Boolean(process.env['WT_SESSION']); |
|
|
| const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb'; |
|
|
| const shouldUseOsc52 = (tty: TtyTarget, settings?: Settings): boolean => |
| Boolean(tty) && |
| !isDumbTerm() && |
| (settings?.experimental?.useOSC52Copy || |
| isSSH() || |
| isWSL() || |
| isWindowsTerminal()); |
|
|
| const safeUtf8Truncate = (buf: Buffer, maxBytes: number): Buffer => { |
| if (buf.length <= maxBytes) return buf; |
| let end = maxBytes; |
| |
| while (end > 0 && (buf[end - 1] & 0b1100_0000) === 0b1000_0000) end--; |
| return buf.subarray(0, end); |
| }; |
|
|
| const buildOsc52 = (text: string): string => { |
| const raw = Buffer.from(text, 'utf8'); |
| const safe = safeUtf8Truncate(raw, MAX_OSC52_DATA_BYTES); |
| const b64 = safe.toString('base64'); |
| return `${OSC52_HEADER}${b64}${OSC52_FOOTER}`; |
| }; |
|
|
| const wrapForTmux = (seq: string): string => { |
| |
| const doubledEsc = seq.split(ESC).join(ESC + ESC); |
| return `${ESC}Ptmux;${doubledEsc}${ST}`; |
| }; |
|
|
| const wrapForScreen = (seq: string): string => { |
| let out = ''; |
| for (let i = 0; i < seq.length; i += SCREEN_DCS_CHUNK_SIZE) { |
| out += `${ESC}P${seq.slice(i, i + SCREEN_DCS_CHUNK_SIZE)}${ST}`; |
| } |
| return out; |
| }; |
|
|
| const writeAll = (stream: Writable, data: string): Promise<void> => |
| new Promise<void>((resolve, reject) => { |
| |
| |
| |
| |
| const fd = (stream as unknown as { fd?: number }).fd; |
| if ( |
| process.platform === 'win32' && |
| typeof fd === 'number' && |
| (stream === process.stdout || stream === process.stderr) |
| ) { |
| try { |
| fs.writeSync(fd, data); |
| resolve(); |
| return; |
| } catch (e) { |
| debugLogger.warn( |
| 'Direct write to TTY failed, falling back to stream write', |
| e, |
| ); |
| } |
| } |
|
|
| const onError = (err: unknown) => { |
| cleanup(); |
| |
| reject(err as Error); |
| }; |
| const onDrain = () => { |
| cleanup(); |
| resolve(); |
| }; |
| const cleanup = () => { |
| stream.off('error', onError); |
| stream.off('drain', onDrain); |
| |
| }; |
| stream.once('error', onError); |
| if (stream.write(data)) { |
| cleanup(); |
| resolve(); |
| } else { |
| stream.once('drain', onDrain); |
| } |
| }); |
|
|
| |
| export const copyToClipboard = async ( |
| text: string, |
| settings?: Settings, |
| ): Promise<void> => { |
| if (!text) return; |
|
|
| const tty = await pickTty(); |
|
|
| if (shouldUseOsc52(tty, settings)) { |
| const osc = buildOsc52(text); |
| const payload = inTmux() |
| ? wrapForTmux(osc) |
| : inScreen() |
| ? wrapForScreen(osc) |
| : osc; |
|
|
| await writeAll(tty!.stream, payload); |
|
|
| if (tty!.closeAfter) { |
| |
| (tty!.stream as fs.WriteStream).end(); |
| } |
| return; |
| } |
|
|
| |
| await clipboardy.write(text); |
| }; |
|
|
| export const getUrlOpenCommand = (): string => { |
| |
| let openCmd: string; |
| switch (process.platform) { |
| case 'darwin': |
| openCmd = 'open'; |
| break; |
| case 'win32': |
| openCmd = 'start'; |
| break; |
| case 'linux': |
| openCmd = 'xdg-open'; |
| break; |
| default: |
| |
| openCmd = 'xdg-open'; |
| debugLogger.warn( |
| `Unknown platform: ${process.platform}. Attempting to open URLs with: ${openCmd}.`, |
| ); |
| break; |
| } |
| return openCmd; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function isAutoExecutableCommand( |
| command: SlashCommand | undefined | null, |
| ): boolean { |
| if (!command) { |
| return false; |
| } |
|
|
| |
| return command.autoExecute ?? false; |
| } |
|
|