mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(cli): use OSC-52 clipboard copy in Windows Terminal (#16920)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
@@ -33,6 +33,7 @@ vi.mock('child_process');
|
|||||||
// fs (for /dev/tty)
|
// fs (for /dev/tty)
|
||||||
const mockFs = vi.hoisted(() => ({
|
const mockFs = vi.hoisted(() => ({
|
||||||
createWriteStream: vi.fn(),
|
createWriteStream: vi.fn(),
|
||||||
|
writeSync: vi.fn(),
|
||||||
constants: { W_OK: 2 },
|
constants: { W_OK: 2 },
|
||||||
}));
|
}));
|
||||||
vi.mock('node:fs', () => ({
|
vi.mock('node:fs', () => ({
|
||||||
@@ -84,6 +85,7 @@ const resetEnv = () => {
|
|||||||
delete process.env['WSLENV'];
|
delete process.env['WSLENV'];
|
||||||
delete process.env['WSL_INTEROP'];
|
delete process.env['WSL_INTEROP'];
|
||||||
delete process.env['TERM'];
|
delete process.env['TERM'];
|
||||||
|
delete process.env['WT_SESSION'];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MockChildProcess extends EventEmitter {
|
interface MockChildProcess extends EventEmitter {
|
||||||
@@ -477,6 +479,85 @@ describe('commandUtils', () => {
|
|||||||
expect(mockClipboardyWrite).toHaveBeenCalledWith('windows-native-test');
|
expect(mockClipboardyWrite).toHaveBeenCalledWith('windows-native-test');
|
||||||
expect(mockFs.createWriteStream).not.toHaveBeenCalled();
|
expect(mockFs.createWriteStream).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses OSC-52 on Windows Terminal (WT_SESSION) and prioritizes stdout', async () => {
|
||||||
|
mockProcess.platform = 'win32';
|
||||||
|
const stdoutStream = makeWritable({ isTTY: true });
|
||||||
|
const stderrStream = makeWritable({ isTTY: true });
|
||||||
|
Object.defineProperty(process, 'stdout', {
|
||||||
|
value: stdoutStream,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(process, 'stderr', {
|
||||||
|
value: stderrStream,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['WT_SESSION'] = 'some-uuid';
|
||||||
|
|
||||||
|
const testText = 'windows-terminal-test';
|
||||||
|
await copyToClipboard(testText);
|
||||||
|
|
||||||
|
const b64 = Buffer.from(testText, 'utf8').toString('base64');
|
||||||
|
const expected = `${ESC}]52;c;${b64}${BEL}`;
|
||||||
|
|
||||||
|
expect(stdoutStream.write).toHaveBeenCalledWith(expected);
|
||||||
|
expect(stderrStream.write).not.toHaveBeenCalled();
|
||||||
|
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fs.writeSync on Windows when stdout has an fd (bypassing Ink)', async () => {
|
||||||
|
mockProcess.platform = 'win32';
|
||||||
|
const stdoutStream = makeWritable({ isTTY: true });
|
||||||
|
// Simulate FD
|
||||||
|
(stdoutStream as unknown as { fd: number }).fd = 1;
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'stdout', {
|
||||||
|
value: stdoutStream,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['WT_SESSION'] = 'some-uuid';
|
||||||
|
|
||||||
|
const testText = 'direct-write-test';
|
||||||
|
await copyToClipboard(testText);
|
||||||
|
|
||||||
|
const b64 = Buffer.from(testText, 'utf8').toString('base64');
|
||||||
|
const expected = `${ESC}]52;c;${b64}${BEL}`;
|
||||||
|
|
||||||
|
expect(mockFs.writeSync).toHaveBeenCalledWith(1, expected);
|
||||||
|
expect(stdoutStream.write).not.toHaveBeenCalled();
|
||||||
|
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fs.writeSync on Windows when stderr has an fd and stdout is not a TTY', async () => {
|
||||||
|
mockProcess.platform = 'win32';
|
||||||
|
const stdoutStream = makeWritable({ isTTY: false });
|
||||||
|
const stderrStream = makeWritable({ isTTY: true });
|
||||||
|
// Simulate FD
|
||||||
|
(stderrStream as unknown as { fd: number }).fd = 2;
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'stdout', {
|
||||||
|
value: stdoutStream,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(process, 'stderr', {
|
||||||
|
value: stderrStream,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['WT_SESSION'] = 'some-uuid';
|
||||||
|
|
||||||
|
const testText = 'direct-write-stderr-test';
|
||||||
|
await copyToClipboard(testText);
|
||||||
|
|
||||||
|
const b64 = Buffer.from(testText, 'utf8').toString('base64');
|
||||||
|
const expected = `${ESC}]52;c;${b64}${BEL}`;
|
||||||
|
|
||||||
|
expect(mockFs.writeSync).toHaveBeenCalledWith(2, expected);
|
||||||
|
expect(stderrStream.write).not.toHaveBeenCalled();
|
||||||
|
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getUrlOpenCommand', () => {
|
describe('getUrlOpenCommand', () => {
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ const pickTty = (): Promise<TtyTarget> =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getStdioTty = (): TtyTarget => {
|
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)
|
if (process.stderr?.isTTY)
|
||||||
return { stream: process.stderr, closeAfter: false };
|
return { stream: process.stderr, closeAfter: false };
|
||||||
if (process.stdout?.isTTY)
|
if (process.stdout?.isTTY)
|
||||||
@@ -140,10 +152,13 @@ const isWSL = (): boolean =>
|
|||||||
process.env['WSL_INTEROP'],
|
process.env['WSL_INTEROP'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isWindowsTerminal = (): boolean =>
|
||||||
|
process.platform === 'win32' && Boolean(process.env['WT_SESSION']);
|
||||||
|
|
||||||
const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb';
|
const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb';
|
||||||
|
|
||||||
const shouldUseOsc52 = (tty: TtyTarget): boolean =>
|
const shouldUseOsc52 = (tty: TtyTarget): boolean =>
|
||||||
Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL());
|
Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL() || isWindowsTerminal());
|
||||||
|
|
||||||
const safeUtf8Truncate = (buf: Buffer, maxBytes: number): Buffer => {
|
const safeUtf8Truncate = (buf: Buffer, maxBytes: number): Buffer => {
|
||||||
if (buf.length <= maxBytes) return buf;
|
if (buf.length <= maxBytes) return buf;
|
||||||
@@ -176,6 +191,27 @@ const wrapForScreen = (seq: string): string => {
|
|||||||
|
|
||||||
const writeAll = (stream: Writable, data: string): Promise<void> =>
|
const writeAll = (stream: Writable, data: string): Promise<void> =>
|
||||||
new Promise<void>((resolve, reject) => {
|
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.
|
||||||
|
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) => {
|
const onError = (err: unknown) => {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(err as Error);
|
reject(err as Error);
|
||||||
|
|||||||
Reference in New Issue
Block a user