mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
feat(cli): support /copy in remote sessions using OSC52 (#13471)
This commit is contained in:
@@ -15,6 +15,11 @@ import {
|
||||
getUrlOpenCommand,
|
||||
} from './commandUtils.js';
|
||||
|
||||
// Constants used by OSC-52 tests
|
||||
const ESC = '\u001B';
|
||||
const BEL = '\u0007';
|
||||
const ST = '\u001B\\';
|
||||
|
||||
// Mock clipboardy
|
||||
vi.mock('clipboardy', () => ({
|
||||
default: {
|
||||
@@ -25,6 +30,14 @@ vi.mock('clipboardy', () => ({
|
||||
// Mock child_process
|
||||
vi.mock('child_process');
|
||||
|
||||
// fs (for /dev/tty)
|
||||
const mockFs = vi.hoisted(() => ({
|
||||
createWriteStream: vi.fn(),
|
||||
}));
|
||||
vi.mock('node:fs', () => ({
|
||||
default: mockFs,
|
||||
}));
|
||||
|
||||
// Mock process.platform for platform-specific tests
|
||||
const mockProcess = vi.hoisted(() => ({
|
||||
platform: 'darwin',
|
||||
@@ -40,6 +53,36 @@ vi.stubGlobal(
|
||||
}),
|
||||
);
|
||||
|
||||
const makeWritable = (opts?: { isTTY?: boolean; writeReturn?: boolean }) => {
|
||||
const { isTTY = false, writeReturn = true } = opts ?? {};
|
||||
const stream = Object.assign(new EventEmitter(), {
|
||||
write: vi.fn().mockReturnValue(writeReturn),
|
||||
end: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
isTTY,
|
||||
once: EventEmitter.prototype.once,
|
||||
on: EventEmitter.prototype.on,
|
||||
off: EventEmitter.prototype.off,
|
||||
}) as unknown as EventEmitter & {
|
||||
write: Mock;
|
||||
end: Mock;
|
||||
isTTY?: boolean;
|
||||
};
|
||||
return stream;
|
||||
};
|
||||
|
||||
const resetEnv = () => {
|
||||
delete process.env['TMUX'];
|
||||
delete process.env['STY'];
|
||||
delete process.env['SSH_TTY'];
|
||||
delete process.env['SSH_CONNECTION'];
|
||||
delete process.env['SSH_CLIENT'];
|
||||
delete process.env['WSL_DISTRO_NAME'];
|
||||
delete process.env['WSLENV'];
|
||||
delete process.env['WSL_INTEROP'];
|
||||
delete process.env['TERM'];
|
||||
};
|
||||
|
||||
interface MockChildProcess extends EventEmitter {
|
||||
stdin: EventEmitter & {
|
||||
write: Mock;
|
||||
@@ -78,6 +121,23 @@ describe('commandUtils', () => {
|
||||
|
||||
// Setup clipboardy mock
|
||||
mockClipboardyWrite = clipboardy.write as Mock;
|
||||
|
||||
// default: no /dev/tty available
|
||||
mockFs.createWriteStream.mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
// default: stdio are not TTY for tests unless explicitly set
|
||||
Object.defineProperty(process, 'stderr', {
|
||||
value: makeWritable({ isTTY: false }),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, 'stdout', {
|
||||
value: makeWritable({ isTTY: false }),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
resetEnv();
|
||||
});
|
||||
|
||||
describe('isAtCommand', () => {
|
||||
@@ -139,23 +199,160 @@ describe('commandUtils', () => {
|
||||
});
|
||||
|
||||
describe('copyToClipboard', () => {
|
||||
it('should successfully copy text to clipboard using clipboardy', async () => {
|
||||
it('uses clipboardy when not in SSH/tmux/screen/WSL (even if TTYs exist)', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
// even if stderr/stdout are TTY, without the env signals we fallback
|
||||
Object.defineProperty(process, 'stderr', {
|
||||
value: makeWritable({ isTTY: true }),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, 'stdout', {
|
||||
value: makeWritable({ isTTY: true }),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
|
||||
});
|
||||
|
||||
it('should propagate errors from clipboardy', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
const error = new Error('Clipboard error');
|
||||
mockClipboardyWrite.mockRejectedValue(error);
|
||||
it('writes OSC-52 to /dev/tty when in SSH', async () => {
|
||||
const testText = 'abc';
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow(
|
||||
'Clipboard error',
|
||||
);
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
const b64 = Buffer.from(testText, 'utf8').toString('base64');
|
||||
const expected = `${ESC}]52;c;${b64}${BEL}`;
|
||||
|
||||
expect(tty.write).toHaveBeenCalledTimes(1);
|
||||
expect((tty.write as Mock).mock.calls[0][0]).toBe(expected);
|
||||
expect(tty.end).toHaveBeenCalledTimes(1); // /dev/tty closed after write
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps OSC-52 for tmux', async () => {
|
||||
const testText = 'tmux-copy';
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
|
||||
process.env['TMUX'] = '1';
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
const written = (tty.write as Mock).mock.calls[0][0] as string;
|
||||
// Starts with tmux DCS wrapper and ends with ST
|
||||
expect(written.startsWith(`${ESC}Ptmux;`)).toBe(true);
|
||||
expect(written.endsWith(ST)).toBe(true);
|
||||
// ESC bytes in payload are doubled
|
||||
expect(written).toContain(`${ESC}${ESC}]52;c;`);
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps OSC-52 for GNU screen with chunked DCS', async () => {
|
||||
// ensure payload > chunk size (240) so there are multiple chunks
|
||||
const testText = 'x'.repeat(1200);
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
|
||||
process.env['STY'] = 'screen-session';
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
const written = (tty.write as Mock).mock.calls[0][0] as string;
|
||||
const chunkStarts = (written.match(new RegExp(`${ESC}P`, 'g')) || [])
|
||||
.length;
|
||||
const chunkEnds = written.split(ST).length - 1;
|
||||
|
||||
expect(chunkStarts).toBeGreaterThan(1);
|
||||
expect(chunkStarts).toBe(chunkEnds);
|
||||
expect(written).toContain(']52;c;'); // contains base OSC-52 marker
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to stderr when /dev/tty unavailable and stderr is a TTY', async () => {
|
||||
const testText = 'stderr-tty';
|
||||
const stderrStream = makeWritable({ isTTY: true });
|
||||
Object.defineProperty(process, 'stderr', {
|
||||
value: stderrStream,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
process.env['SSH_TTY'] = '/dev/pts/1';
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
const b64 = Buffer.from(testText, 'utf8').toString('base64');
|
||||
const expected = `${ESC}]52;c;${b64}${BEL}`;
|
||||
|
||||
expect(stderrStream.write).toHaveBeenCalledWith(expected);
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to clipboardy when no TTY is available', async () => {
|
||||
const testText = 'no-tty';
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
// /dev/tty throws; stderr/stdout are non-TTY by default
|
||||
process.env['SSH_CLIENT'] = 'client';
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
|
||||
});
|
||||
|
||||
it('resolves on drain when backpressure occurs', async () => {
|
||||
const tty = makeWritable({ isTTY: true, writeReturn: false });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
|
||||
const p = copyToClipboard('drain-test');
|
||||
setTimeout(() => {
|
||||
tty.emit('drain');
|
||||
}, 0);
|
||||
await expect(p).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('propagates errors from OSC-52 write path', async () => {
|
||||
const tty = makeWritable({ isTTY: true, writeReturn: false });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
process.env['SSH_CONNECTION'] = '1';
|
||||
|
||||
const p = copyToClipboard('err-test');
|
||||
setTimeout(() => {
|
||||
tty.emit('error', new Error('tty error'));
|
||||
}, 0);
|
||||
|
||||
await expect(p).rejects.toThrow('tty error');
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for empty string', async () => {
|
||||
await copyToClipboard('');
|
||||
expect(mockClipboardyWrite).not.toHaveBeenCalled();
|
||||
// ensure no accidental writes to stdio either
|
||||
const stderrStream = process.stderr as unknown as { write: Mock };
|
||||
const stdoutStream = process.stdout as unknown as { write: Mock };
|
||||
expect(stderrStream.write).not.toHaveBeenCalled();
|
||||
expect(stdoutStream.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses clipboardy when not in eligible env even if /dev/tty exists', async () => {
|
||||
const tty = makeWritable({ isTTY: true });
|
||||
mockFs.createWriteStream.mockReturnValue(tty);
|
||||
const text = 'local-terminal';
|
||||
mockClipboardyWrite.mockResolvedValue(undefined);
|
||||
|
||||
await copyToClipboard(text);
|
||||
|
||||
expect(mockClipboardyWrite).toHaveBeenCalledWith(text);
|
||||
expect(tty.write).not.toHaveBeenCalled();
|
||||
expect(tty.end).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Checks if a query string potentially represents an '@' command.
|
||||
@@ -45,8 +47,146 @@ export const isSlashCommand = (query: string): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Copies a string snippet to the clipboard
|
||||
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 = (): TtyTarget => {
|
||||
// Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout.
|
||||
try {
|
||||
const devTty = fs.createWriteStream('/dev/tty');
|
||||
return { stream: devTty, closeAfter: true };
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
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 isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb';
|
||||
|
||||
const shouldUseOsc52 = (tty: TtyTarget): boolean =>
|
||||
Boolean(tty) &&
|
||||
!isDumbTerm() &&
|
||||
(isSSH() || inTmux() || inScreen() || isWSL());
|
||||
|
||||
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) => {
|
||||
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.
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
if (!text) return;
|
||||
|
||||
const tty = pickTty();
|
||||
|
||||
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
|
||||
await clipboardy.write(text);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user