diff --git a/src/ink/termio/osc.test.ts b/src/ink/termio/osc.test.ts index e9fbaf2c..d7a9cfd4 100644 --- a/src/ink/termio/osc.test.ts +++ b/src/ink/termio/osc.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' const originalEnv = { ...process.env } const originalPlatform = process.platform +const generateTempFilePathMock = mock(() => '/tmp/openclaude-clipboard.txt') + const execFileNoThrowMock = mock( async () => ({ code: 0, stdout: '', stderr: '' }), ) @@ -11,13 +13,22 @@ mock.module('../../utils/execFileNoThrow.js', () => ({ execFileNoThrow: execFileNoThrowMock, })) +mock.module('../../utils/tempfile.js', () => ({ + generateTempFilePath: generateTempFilePathMock, +})) + async function importFreshOscModule() { return import(`./osc.ts?ts=${Date.now()}-${Math.random()}`) } +async function flushClipboardCopy(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + describe('Windows clipboard fallback', () => { beforeEach(() => { execFileNoThrowMock.mockClear() + generateTempFilePathMock.mockClear() process.env = { ...originalEnv } delete process.env['SSH_CONNECTION'] delete process.env['TMUX'] @@ -34,6 +45,7 @@ describe('Windows clipboard fallback', () => { const { setClipboard } = await importFreshOscModule() await setClipboard('Привет мир') + await flushClipboardCopy() expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'clip')).toBe( false, @@ -43,16 +55,28 @@ describe('Windows clipboard fallback', () => { ).toBe(true) }) - test('passes the original Unicode text to the Windows copy command', async () => { + test('passes Windows clipboard text through a UTF-8 temp file instead of stdin', async () => { const { setClipboard } = await importFreshOscModule() await setClipboard('Привет мир') + await flushClipboardCopy() const windowsCall = execFileNoThrowMock.mock.calls.find( ([cmd]) => cmd === 'powershell', ) - expect(windowsCall?.[2]).toMatchObject({ input: 'Привет мир' }) + expect(windowsCall?.[2]).toMatchObject({ + stdin: 'ignore', + }) + expect(windowsCall?.[2]).not.toMatchObject({ input: 'Привет мир' }) + expect(windowsCall?.[2]).not.toMatchObject({ + env: expect.objectContaining({ + OPENCLAUDE_CLIPBOARD_TEXT_B64: expect.any(String), + }), + }) + expect(windowsCall?.[1]).toContain( + "$text = [System.IO.File]::ReadAllText('/tmp/openclaude-clipboard.txt', [System.Text.Encoding]::UTF8); Set-Clipboard -Value $text", + ) }) }) diff --git a/src/ink/termio/osc.ts b/src/ink/termio/osc.ts index 59905432..5732b481 100644 --- a/src/ink/termio/osc.ts +++ b/src/ink/termio/osc.ts @@ -3,8 +3,10 @@ */ import { Buffer } from 'buffer' +import { unlink, writeFile } from 'node:fs/promises' import { env } from '../../utils/env.js' import { execFileNoThrow } from '../../utils/execFileNoThrow.js' +import { generateTempFilePath } from '../../utils/tempfile.js' import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js' import type { Action, Color, TabStatusAction } from './types.js' @@ -211,18 +213,32 @@ function copyNative(text: string): void { return } case 'win32': - // PowerShell's Set-Clipboard preserves Unicode text more reliably than - // clip.exe, which can mangle non-ASCII text under some Windows locales. - void execFileNoThrow( - 'powershell', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - '$text = [Console]::In.ReadToEnd(); Set-Clipboard -Value $text', - ], - opts, - ) + // Avoid piping non-ASCII text through the Windows stdin/codepage + // boundary. Write UTF-8 text to a temp file and let PowerShell read it + // directly as UTF-8 before calling Set-Clipboard. + void (async () => { + const tempPath = generateTempFilePath('openclaude-clipboard', '.txt') + const escapedTempPath = tempPath.replace(/'/g, "''") + try { + await writeFile(tempPath, text, { encoding: 'utf8' }) + await execFileNoThrow( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$text = [System.IO.File]::ReadAllText('${escapedTempPath}', [System.Text.Encoding]::UTF8); Set-Clipboard -Value $text`, + ], + { + useCwd: false, + timeout: opts.timeout, + stdin: 'ignore', + }, + ) + } finally { + await unlink(tempPath).catch(() => {}) + } + })().catch(() => {}) return } }