From 7f432fe87df28da4eb13bde4ae7a8a4be12f0f53 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Sun, 5 Apr 2026 16:59:06 +0530 Subject: [PATCH] fix: preserve unicode in Windows clipboard fallback --- src/ink/termio/osc.test.ts | 110 +++++++++++++++++++++++++++++++++++++ src/ink/termio/osc.ts | 17 ++++-- 2 files changed, 123 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..e9fbaf2c --- /dev/null +++ b/src/ink/termio/osc.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +const originalEnv = { ...process.env } +const originalPlatform = process.platform + +const execFileNoThrowMock = mock( + async () => ({ code: 0, stdout: '', stderr: '' }), +) + +mock.module('../../utils/execFileNoThrow.js', () => ({ + execFileNoThrow: execFileNoThrowMock, +})) + +async function importFreshOscModule() { + return import(`./osc.ts?ts=${Date.now()}-${Math.random()}`) +} + +describe('Windows clipboard fallback', () => { + beforeEach(() => { + execFileNoThrowMock.mockClear() + process.env = { ...originalEnv } + delete process.env['SSH_CONNECTION'] + delete process.env['TMUX'] + Object.defineProperty(process, 'platform', { value: 'win32' }) + }) + + afterEach(() => { + mock.restore() + 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('Привет мир') + + expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'clip')).toBe( + false, + ) + expect( + execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell'), + ).toBe(true) + }) + + test('passes the original Unicode text to the Windows copy command', async () => { + const { setClipboard } = await importFreshOscModule() + + await setClipboard('Привет мир') + + const windowsCall = execFileNoThrowMock.mock.calls.find( + ([cmd]) => cmd === 'powershell', + ) + + expect(windowsCall?.[2]).toMatchObject({ input: 'Привет мир' }) + }) +}) + +describe('clipboard path behavior remains stable', () => { + beforeEach(() => { + execFileNoThrowMock.mockClear() + process.env = { ...originalEnv } + delete process.env['SSH_CONNECTION'] + delete process.env['TMUX'] + }) + + afterEach(() => { + mock.restore() + 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..59905432 100644 --- a/src/ink/termio/osc.ts +++ b/src/ink/termio/osc.ts @@ -129,7 +129,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 +211,18 @@ 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) + // 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, + ) return } }