From c1934974aaf64db460cc850a044bd13cc744cce7 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:42:10 +0530 Subject: [PATCH] fix: preserve unicode in Windows clipboard fallback (#388) * fix: preserve unicode in Windows clipboard fallback * fix: avoid Windows clipboard stdin codepage issues * test: fix Windows clipboard temp path fixture --- src/ink/termio/osc.test.ts | 134 +++++++++++++++++++++++++++++++++++++ src/ink/termio/osc.ts | 33 +++++++-- 2 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 src/ink/termio/osc.test.ts diff --git a/src/ink/termio/osc.test.ts b/src/ink/termio/osc.test.ts new file mode 100644 index 00000000..46e0375f --- /dev/null +++ b/src/ink/termio/osc.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { join } from 'node:path' + +const originalEnv = { ...process.env } +const originalPlatform = process.platform +const mockedClipboardPath = join(process.cwd(), 'openclaude-clipboard.txt') + +const generateTempFilePathMock = mock(() => mockedClipboardPath) + +const execFileNoThrowMock = mock( + async () => ({ code: 0, stdout: '', stderr: '' }), +) + +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'] + Object.defineProperty(process, 'platform', { value: 'win32' }) + }) + + afterEach(() => { + process.env = { ...originalEnv } + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + test('uses PowerShell instead of clip.exe for local Windows copy', async () => { + const { setClipboard } = await importFreshOscModule() + + await setClipboard('Привет мир') + await flushClipboardCopy() + + expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'clip')).toBe( + false, + ) + expect( + execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell'), + ).toBe(true) + }) + + 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({ + 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('${mockedClipboardPath.replace(/'/g, "''")}', [System.Text.Encoding]::UTF8); Set-Clipboard -Value $text`, + ) + }) +}) + +describe('clipboard path behavior remains stable', () => { + beforeEach(() => { + execFileNoThrowMock.mockClear() + process.env = { ...originalEnv } + delete process.env['SSH_CONNECTION'] + delete process.env['TMUX'] + }) + + afterEach(() => { + process.env = { ...originalEnv } + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + test('getClipboardPath stays native on local macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const { getClipboardPath } = await importFreshOscModule() + + expect(getClipboardPath()).toBe('native') + }) + + test('getClipboardPath stays tmux-buffer when TMUX is set', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + process.env['TMUX'] = '/tmp/tmux-1000/default,123,0' + const { getClipboardPath } = await importFreshOscModule() + + expect(getClipboardPath()).toBe('tmux-buffer') + }) + + test('Windows clipboard fallback is skipped over SSH', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + process.env['SSH_CONNECTION'] = '1 2 3 4' + const { setClipboard } = await importFreshOscModule() + + await setClipboard('Привет мир') + + expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell')).toBe( + false, + ) + }) + + test('local macOS clipboard fallback still uses pbcopy', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const { setClipboard } = await importFreshOscModule() + + await setClipboard('hello') + + expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'pbcopy')).toBe( + true, + ) + }) +}) diff --git a/src/ink/termio/osc.ts b/src/ink/termio/osc.ts index 9bef5153..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' @@ -129,7 +131,7 @@ export async function tmuxLoadBuffer(text: string): Promise { * Local (no SSH_CONNECTION): also shell out to a native clipboard utility. * OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables * OSC 52 by default, VS Code shows a permission prompt on first use. Native - * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over + * utilities (pbcopy/wl-copy/xclip/xsel/PowerShell Set-Clipboard) always work locally. Over * SSH these would write to the remote clipboard — OSC 52 is the right path there. * * Returns the sequence for the caller to write to stdout (raw OSC 52 @@ -211,9 +213,32 @@ function copyNative(text: string): void { return } case 'win32': - // clip.exe is always available on Windows. Unicode handling is - // imperfect (system locale encoding) but good enough for a fallback. - void execFileNoThrow('clip', [], 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 } }