diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 9a423e6abc..7686a0ab97 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -33,6 +33,7 @@ vi.mock('child_process'); // fs (for /dev/tty) const mockFs = vi.hoisted(() => ({ createWriteStream: vi.fn(), + writeSync: vi.fn(), constants: { W_OK: 2 }, })); vi.mock('node:fs', () => ({ @@ -84,6 +85,7 @@ const resetEnv = () => { delete process.env['WSLENV']; delete process.env['WSL_INTEROP']; delete process.env['TERM']; + delete process.env['WT_SESSION']; }; interface MockChildProcess extends EventEmitter { @@ -477,6 +479,85 @@ describe('commandUtils', () => { expect(mockClipboardyWrite).toHaveBeenCalledWith('windows-native-test'); 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', () => { diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index d8894503e3..1f6d6f86bb 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -109,6 +109,18 @@ const pickTty = (): Promise => }); 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) return { stream: process.stderr, closeAfter: false }; if (process.stdout?.isTTY) @@ -140,10 +152,13 @@ const isWSL = (): boolean => process.env['WSL_INTEROP'], ); +const isWindowsTerminal = (): boolean => + process.platform === 'win32' && Boolean(process.env['WT_SESSION']); + const isDumbTerm = (): boolean => (process.env['TERM'] ?? '') === 'dumb'; const shouldUseOsc52 = (tty: TtyTarget): boolean => - Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL()); + Boolean(tty) && !isDumbTerm() && (isSSH() || isWSL() || isWindowsTerminal()); const safeUtf8Truncate = (buf: Buffer, maxBytes: number): Buffer => { if (buf.length <= maxBytes) return buf; @@ -176,6 +191,27 @@ const wrapForScreen = (seq: string): string => { const writeAll = (stream: Writable, data: string): Promise => new Promise((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) => { cleanup(); reject(err as Error);