2025-04-29 08:29:09 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-21 16:35:22 -04:00
|
|
|
import { debugLogger } from '@google/gemini-cli-core';
|
2025-11-17 15:48:33 -08:00
|
|
|
import clipboardy from 'clipboardy';
|
2025-12-01 12:29:03 -05:00
|
|
|
import type { SlashCommand } from '../commands/types.js';
|
2025-12-03 22:27:33 -08:00
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import type { Writable } from 'node:stream';
|
2025-07-20 20:57:41 +02:00
|
|
|
|
2025-04-29 08:29:09 -07:00
|
|
|
/**
|
|
|
|
|
* Checks if a query string potentially represents an '@' command.
|
|
|
|
|
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
|
|
|
|
|
* and followed by a non-whitespace character.
|
|
|
|
|
*
|
|
|
|
|
* @param query The input query string.
|
|
|
|
|
* @returns True if the query looks like an '@' command, false otherwise.
|
|
|
|
|
*/
|
2025-04-29 15:39:36 -07:00
|
|
|
export const isAtCommand = (query: string): boolean =>
|
2025-05-07 12:30:32 -07:00
|
|
|
// Check if starts with @ OR has a space, then @
|
|
|
|
|
query.startsWith('@') || /\s@/.test(query);
|
2025-04-29 13:29:57 -07:00
|
|
|
|
2025-05-01 00:52:01 +00:00
|
|
|
/**
|
|
|
|
|
* Checks if a query string potentially represents an '/' command.
|
2025-08-26 11:51:27 +08:00
|
|
|
* It triggers if the query starts with '/' but excludes code comments like '//' and '/*'.
|
2025-05-01 00:52:01 +00:00
|
|
|
*
|
|
|
|
|
* @param query The input query string.
|
|
|
|
|
* @returns True if the query looks like an '/' command, false otherwise.
|
|
|
|
|
*/
|
2025-08-26 11:51:27 +08:00
|
|
|
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;
|
|
|
|
|
};
|
2025-07-20 20:57:41 +02:00
|
|
|
|
2025-12-03 22:27:33 -08:00
|
|
|
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;
|
|
|
|
|
|
2026-01-16 01:40:53 +05:30
|
|
|
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
|
|
|
|
|
}
|
2026-01-06 02:03:03 +05:30
|
|
|
}
|
|
|
|
|
|
2026-01-16 01:40:53 +05:30
|
|
|
resolve(getStdioTty());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const getStdioTty = (): TtyTarget => {
|
2026-01-19 20:22:42 +00:00
|
|
|
// 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.
|
2025-12-03 22:27:33 -08:00
|
|
|
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'],
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-19 20:22:42 +00:00
|
|
|
const isWindowsTerminal = (): boolean =>
|
|
|
|
|
process.platform === 'win32' && Boolean(process.env['WT_SESSION']);
|
|
|
|
|
|
2025-12-03 22:27:33 -08:00
|
|
|
const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb';
|
|
|
|
|
|
|
|
|
|
const shouldUseOsc52 = (tty: TtyTarget): boolean =>
|
2026-01-19 20:22:42 +00:00
|
|
|
Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL() || isWindowsTerminal());
|
2025-12-03 22:27:33 -08:00
|
|
|
|
|
|
|
|
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) => {
|
2026-01-19 20:22:42 +00:00
|
|
|
// 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.
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 22:27:33 -08:00
|
|
|
const onError = (err: unknown) => {
|
|
|
|
|
cleanup();
|
|
|
|
|
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.
|
2025-07-20 20:57:41 +02:00
|
|
|
export const copyToClipboard = async (text: string): Promise<void> => {
|
2025-12-03 22:27:33 -08:00
|
|
|
if (!text) return;
|
|
|
|
|
|
2026-01-16 01:40:53 +05:30
|
|
|
const tty = await pickTty();
|
2025-12-03 22:27:33 -08:00
|
|
|
|
|
|
|
|
if (shouldUseOsc52(tty)) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Local / non-TTY fallback
|
2025-11-17 15:48:33 -08:00
|
|
|
await clipboardy.write(text);
|
2025-07-20 20:57:41 +02:00
|
|
|
};
|
2025-08-07 12:00:46 -04:00
|
|
|
|
|
|
|
|
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';
|
2025-10-21 16:35:22 -04:00
|
|
|
debugLogger.warn(
|
2025-08-07 12:00:46 -04:00
|
|
|
`Unknown platform: ${process.platform}. Attempting to open URLs with: ${openCmd}.`,
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
return openCmd;
|
|
|
|
|
};
|
2025-12-01 12:29:03 -05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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(
|
2025-12-08 16:32:39 -05:00
|
|
|
command: SlashCommand | undefined | null,
|
2025-12-01 12:29:03 -05:00
|
|
|
): boolean {
|
|
|
|
|
if (!command) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Simply return the autoExecute flag value, defaulting to false if undefined
|
|
|
|
|
return command.autoExecute ?? false;
|
|
|
|
|
}
|