Skip to content

Commit 642201e

Browse files
committed
feat(cli): support /copy in remote sessions using OSC52
1 parent 8d082a9 commit 642201e

File tree

2 files changed

+346
-9
lines changed

2 files changed

+346
-9
lines changed

packages/cli/src/ui/utils/commandUtils.test.ts

Lines changed: 205 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import {
1515
getUrlOpenCommand,
1616
} from './commandUtils.js';
1717

18+
// Constants used by OSC-52 tests
19+
const ESC = '\u001B';
20+
const BEL = '\u0007';
21+
const ST = '\u001B\\';
22+
1823
// Mock clipboardy
1924
vi.mock('clipboardy', () => ({
2025
default: {
@@ -25,6 +30,14 @@ vi.mock('clipboardy', () => ({
2530
// Mock child_process
2631
vi.mock('child_process');
2732

33+
// fs (for /dev/tty)
34+
const mockFs = vi.hoisted(() => ({
35+
createWriteStream: vi.fn(),
36+
}));
37+
vi.mock('node:fs', () => ({
38+
default: mockFs,
39+
}));
40+
2841
// Mock process.platform for platform-specific tests
2942
const mockProcess = vi.hoisted(() => ({
3043
platform: 'darwin',
@@ -40,6 +53,36 @@ vi.stubGlobal(
4053
}),
4154
);
4255

56+
const makeWritable = (opts?: { isTTY?: boolean; writeReturn?: boolean }) => {
57+
const { isTTY = false, writeReturn = true } = opts ?? {};
58+
const stream = Object.assign(new EventEmitter(), {
59+
write: vi.fn().mockReturnValue(writeReturn),
60+
end: vi.fn(),
61+
destroy: vi.fn(),
62+
isTTY,
63+
once: EventEmitter.prototype.once,
64+
on: EventEmitter.prototype.on,
65+
off: EventEmitter.prototype.off,
66+
}) as unknown as EventEmitter & {
67+
write: Mock;
68+
end: Mock;
69+
isTTY?: boolean;
70+
};
71+
return stream;
72+
};
73+
74+
const resetEnv = () => {
75+
delete process.env['TMUX'];
76+
delete process.env['STY'];
77+
delete process.env['SSH_TTY'];
78+
delete process.env['SSH_CONNECTION'];
79+
delete process.env['SSH_CLIENT'];
80+
delete process.env['WSL_DISTRO_NAME'];
81+
delete process.env['WSLENV'];
82+
delete process.env['WSL_INTEROP'];
83+
delete process.env['TERM'];
84+
};
85+
4386
interface MockChildProcess extends EventEmitter {
4487
stdin: EventEmitter & {
4588
write: Mock;
@@ -78,6 +121,23 @@ describe('commandUtils', () => {
78121

79122
// Setup clipboardy mock
80123
mockClipboardyWrite = clipboardy.write as Mock;
124+
125+
// default: no /dev/tty available
126+
mockFs.createWriteStream.mockImplementation(() => {
127+
throw new Error('ENOENT');
128+
});
129+
130+
// default: stdio are not TTY for tests unless explicitly set
131+
Object.defineProperty(process, 'stderr', {
132+
value: makeWritable({ isTTY: false }),
133+
configurable: true,
134+
});
135+
Object.defineProperty(process, 'stdout', {
136+
value: makeWritable({ isTTY: false }),
137+
configurable: true,
138+
});
139+
140+
resetEnv();
81141
});
82142

83143
describe('isAtCommand', () => {
@@ -139,23 +199,160 @@ describe('commandUtils', () => {
139199
});
140200

141201
describe('copyToClipboard', () => {
142-
it('should successfully copy text to clipboard using clipboardy', async () => {
202+
it('uses clipboardy when not in SSH/tmux/screen/WSL (even if TTYs exist)', async () => {
143203
const testText = 'Hello, world!';
144204
mockClipboardyWrite.mockResolvedValue(undefined);
145205

206+
// even if stderr/stdout are TTY, without the env signals we fallback
207+
Object.defineProperty(process, 'stderr', {
208+
value: makeWritable({ isTTY: true }),
209+
configurable: true,
210+
});
211+
Object.defineProperty(process, 'stdout', {
212+
value: makeWritable({ isTTY: true }),
213+
configurable: true,
214+
});
215+
146216
await copyToClipboard(testText);
147217

148218
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
149219
});
150220

151-
it('should propagate errors from clipboardy', async () => {
152-
const testText = 'Hello, world!';
153-
const error = new Error('Clipboard error');
154-
mockClipboardyWrite.mockRejectedValue(error);
221+
it('writes OSC-52 to /dev/tty when in SSH', async () => {
222+
const testText = 'abc';
223+
const tty = makeWritable({ isTTY: true });
224+
mockFs.createWriteStream.mockReturnValue(tty);
155225

156-
await expect(copyToClipboard(testText)).rejects.toThrow(
157-
'Clipboard error',
158-
);
226+
process.env['SSH_CONNECTION'] = '1';
227+
228+
await copyToClipboard(testText);
229+
230+
const b64 = Buffer.from(testText, 'utf8').toString('base64');
231+
const expected = `${ESC}]52;c;${b64}${BEL}`;
232+
233+
expect(tty.write).toHaveBeenCalledTimes(1);
234+
expect((tty.write as Mock).mock.calls[0][0]).toBe(expected);
235+
expect(tty.end).toHaveBeenCalledTimes(1); // /dev/tty closed after write
236+
expect(mockClipboardyWrite).not.toHaveBeenCalled();
237+
});
238+
239+
it('wraps OSC-52 for tmux', async () => {
240+
const testText = 'tmux-copy';
241+
const tty = makeWritable({ isTTY: true });
242+
mockFs.createWriteStream.mockReturnValue(tty);
243+
244+
process.env['TMUX'] = '1';
245+
246+
await copyToClipboard(testText);
247+
248+
const written = (tty.write as Mock).mock.calls[0][0] as string;
249+
// Starts with tmux DCS wrapper and ends with ST
250+
expect(written.startsWith(`${ESC}Ptmux;`)).toBe(true);
251+
expect(written.endsWith(ST)).toBe(true);
252+
// ESC bytes in payload are doubled
253+
expect(written).toContain(`${ESC}${ESC}]52;c;`);
254+
expect(mockClipboardyWrite).not.toHaveBeenCalled();
255+
});
256+
257+
it('wraps OSC-52 for GNU screen with chunked DCS', async () => {
258+
// ensure payload > chunk size (240) so there are multiple chunks
259+
const testText = 'x'.repeat(1200);
260+
const tty = makeWritable({ isTTY: true });
261+
mockFs.createWriteStream.mockReturnValue(tty);
262+
263+
process.env['STY'] = 'screen-session';
264+
265+
await copyToClipboard(testText);
266+
267+
const written = (tty.write as Mock).mock.calls[0][0] as string;
268+
const chunkStarts = (written.match(new RegExp(`${ESC}P`, 'g')) || [])
269+
.length;
270+
const chunkEnds = written.split(ST).length - 1;
271+
272+
expect(chunkStarts).toBeGreaterThan(1);
273+
expect(chunkStarts).toBe(chunkEnds);
274+
expect(written).toContain(']52;c;'); // contains base OSC-52 marker
275+
expect(mockClipboardyWrite).not.toHaveBeenCalled();
276+
});
277+
278+
it('falls back to stderr when /dev/tty unavailable and stderr is a TTY', async () => {
279+
const testText = 'stderr-tty';
280+
const stderrStream = makeWritable({ isTTY: true });
281+
Object.defineProperty(process, 'stderr', {
282+
value: stderrStream,
283+
configurable: true,
284+
});
285+
286+
process.env['SSH_TTY'] = '/dev/pts/1';
287+
288+
await copyToClipboard(testText);
289+
290+
const b64 = Buffer.from(testText, 'utf8').toString('base64');
291+
const expected = `${ESC}]52;c;${b64}${BEL}`;
292+
293+
expect(stderrStream.write).toHaveBeenCalledWith(expected);
294+
expect(mockClipboardyWrite).not.toHaveBeenCalled();
295+
});
296+
297+
it('falls back to clipboardy when no TTY is available', async () => {
298+
const testText = 'no-tty';
299+
mockClipboardyWrite.mockResolvedValue(undefined);
300+
301+
// /dev/tty throws; stderr/stdout are non-TTY by default
302+
process.env['SSH_CLIENT'] = 'client';
303+
304+
await copyToClipboard(testText);
305+
306+
expect(mockClipboardyWrite).toHaveBeenCalledWith(testText);
307+
});
308+
309+
it('resolves on drain when backpressure occurs', async () => {
310+
const tty = makeWritable({ isTTY: true, writeReturn: false });
311+
mockFs.createWriteStream.mockReturnValue(tty);
312+
process.env['SSH_CONNECTION'] = '1';
313+
314+
const p = copyToClipboard('drain-test');
315+
setTimeout(() => {
316+
tty.emit('drain');
317+
}, 0);
318+
await expect(p).resolves.toBeUndefined();
319+
});
320+
321+
it('propagates errors from OSC-52 write path', async () => {
322+
const tty = makeWritable({ isTTY: true, writeReturn: false });
323+
mockFs.createWriteStream.mockReturnValue(tty);
324+
process.env['SSH_CONNECTION'] = '1';
325+
326+
const p = copyToClipboard('err-test');
327+
setTimeout(() => {
328+
tty.emit('error', new Error('tty error'));
329+
}, 0);
330+
331+
await expect(p).rejects.toThrow('tty error');
332+
expect(mockClipboardyWrite).not.toHaveBeenCalled();
333+
});
334+
335+
it('does nothing for empty string', async () => {
336+
await copyToClipboard('');
337+
expect(mockClipboardyWrite).not.toHaveBeenCalled();
338+
// ensure no accidental writes to stdio either
339+
const stderrStream = process.stderr as unknown as { write: Mock };
340+
const stdoutStream = process.stdout as unknown as { write: Mock };
341+
expect(stderrStream.write).not.toHaveBeenCalled();
342+
expect(stdoutStream.write).not.toHaveBeenCalled();
343+
});
344+
345+
it('uses clipboardy when not in eligible env even if /dev/tty exists', async () => {
346+
const tty = makeWritable({ isTTY: true });
347+
mockFs.createWriteStream.mockReturnValue(tty);
348+
const text = 'local-terminal';
349+
mockClipboardyWrite.mockResolvedValue(undefined);
350+
351+
await copyToClipboard(text);
352+
353+
expect(mockClipboardyWrite).toHaveBeenCalledWith(text);
354+
expect(tty.write).not.toHaveBeenCalled();
355+
expect(tty.end).not.toHaveBeenCalled();
159356
});
160357
});
161358

0 commit comments

Comments
 (0)