diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 722bcef18d..6133b33a38 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -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(); }); }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 68cabe2431..41519e0725 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -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 => + new Promise((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 => { + 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); };