File size: 10,398 Bytes
9332ccf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 | /**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
// Pre-compiled regex for detecting @<path> patterns consistent with parseAllAtCommands.
// Uses the same AT_COMMAND_PATH_REGEX_SOURCE so that isAtCommand is true whenever
// parseAllAtCommands would find at least one atPath part.
const AT_COMMAND_DETECT_REGEX = new RegExp(
`(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}`,
);
/**
* Checks if a query string potentially represents an '@' command.
* Returns true if the query contains any '@<path>' pattern that would be
* recognised by the @ command processor, regardless of what character
* precedes the '@' sign. This ensures that prompts written in an external
* editor (where '@' may follow punctuation like ':' or '(') are correctly
* identified and their referenced files pre-loaded before the query is sent
* to the model.
*
* @param query The input query string.
* @returns True if the query looks like an '@' command, false otherwise.
*/
export const isAtCommand = (query: string): boolean =>
AT_COMMAND_DETECT_REGEX.test(query);
/**
* Checks if a query string potentially represents an '/' command.
* It triggers if the query starts with '/' but excludes code comments like '//' and '/*'.
*
* @param query The input query string.
* @returns True if the query looks like an '/' command, false otherwise.
*/
export const isSlashCommand = (query: string): boolean => {
if (!query.startsWith('/')) {
return false;
}
// Exclude line comments that start with '//'
if (query.startsWith('//')) {
return false;
}
// Exclude block comments that start with '/*'
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;
// Conservative chunk size for GNU screen DCS passthrough.
const SCREEN_DCS_CHUNK_SIZE = 240;
type TtyTarget = { stream: Writable; closeAfter: boolean } | null;
const pickTty = (): Promise<TtyTarget> =>
new Promise((resolve) => {
// /dev/tty is only available on Unix-like systems (Linux, macOS, BSD, etc.)
if (process.platform !== 'win32') {
// Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout.
try {
const devTty = fs.createWriteStream('/dev/tty');
// Safety timeout: if /dev/tty doesn't respond quickly, fallback to avoid hanging.
const timeout = setTimeout(() => {
// Remove listeners to prevent them from firing after timeout.
devTty.removeAllListeners('open');
devTty.removeAllListeners('error');
devTty.destroy();
resolve(getStdioTty());
}, 100);
// If we can't open it (e.g. sandbox), we'll get an error.
// We wait for 'open' to confirm it's usable, or 'error' to fallback.
// If it opens, we resolve with the stream.
devTty.once('open', () => {
clearTimeout(timeout);
devTty.removeAllListeners('error');
// Prevent future unhandled 'error' events from crashing the process
devTty.on('error', () => {});
resolve({ stream: devTty, closeAfter: true });
});
// If it errors immediately (or quickly), we fallback.
devTty.once('error', () => {
clearTimeout(timeout);
devTty.removeAllListeners('open');
resolve(getStdioTty());
});
return;
} catch {
// fall through - synchronous failure
}
}
resolve(getStdioTty());
});
const getStdioTty = (): TtyTarget => {
// On Windows, prioritize stdout to prevent shell-specific formatting (e.g., PowerShell's
// red stderr) from corrupting the raw escape sequence payload.
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;
}
// On non-Windows platforms, prioritize stderr to avoid polluting stdout,
// preserving it for potential redirection or piping.
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;
// Back up to the start of a UTF-8 code point if we cut through a continuation byte (10xxxxxx).
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 => {
// Double ESC bytes in payload without a control-character regex.
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) => {
// On Windows, writing directly to the underlying file descriptor bypasses
// application-level stream interception (e.g., by the Ink UI framework).
// This ensures the raw OSC-52 escape sequence reaches the terminal host uncorrupted.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
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();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
reject(err as Error);
};
const onDrain = () => {
cleanup();
resolve();
};
const cleanup = () => {
stream.off('error', onError);
stream.off('drain', onDrain);
// Writable.write() handlers may not emit 'drain' if the first write succeeded.
};
stream.once('error', onError);
if (stream.write(data)) {
cleanup();
resolve();
} else {
stream.once('drain', onDrain);
}
});
// Copies a string snippet to the clipboard with robust OSC-52 support.
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) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(tty!.stream as fs.WriteStream).end();
}
return;
}
// Local / non-TTY fallback
await clipboardy.write(text);
};
export const getUrlOpenCommand = (): string => {
// --- Determine the OS-specific command to open URLs ---
let openCmd: string;
switch (process.platform) {
case 'darwin':
openCmd = 'open';
break;
case 'win32':
openCmd = 'start';
break;
case 'linux':
openCmd = 'xdg-open';
break;
default:
// Default to xdg-open, which appears to be supported for the less popular operating systems.
openCmd = 'xdg-open';
debugLogger.warn(
`Unknown platform: ${process.platform}. Attempting to open URLs with: ${openCmd}.`,
);
break;
}
return openCmd;
};
/**
* Determines if a slash command should auto-execute when selected.
*
* All built-in commands have autoExecute explicitly set to true or false.
* Custom commands (.toml files) and extension commands without this flag
* will default to false (safe default - won't auto-execute).
*
* @param command The slash command to check
* @returns true if the command should auto-execute on Enter
*/
export function isAutoExecutableCommand(
command: SlashCommand | undefined | null,
): boolean {
if (!command) {
return false;
}
// Simply return the autoExecute flag value, defaulting to false if undefined
return command.autoExecute ?? false;
}
|