diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index b0646ee48..93feb009d 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -269,7 +269,14 @@ describe('writeClipboardText', () => { expect.arrayContaining(['-NoProfile', '-NonInteractive']), expect.anything() ) - expect(stdin.end).toHaveBeenCalledWith('wsl text') + // PowerShell uses base64-encoded UTF-8 via command argument, not stdin + expect(stdin.end).not.toHaveBeenCalled() + const calledArgs = start.mock.calls[0][1] as string[] + const commandIdx = calledArgs.indexOf('-Command') + expect(commandIdx).toBeGreaterThan(-1) + const script = calledArgs[commandIdx + 1] + expect(script).toContain('FromBase64String') + expect(script).toContain(Buffer.from('wsl text', 'utf8').toString('base64')) }) it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => { @@ -300,7 +307,13 @@ describe('writeClipboardText', () => { expect.arrayContaining(['-NoProfile', '-NonInteractive']), expect.anything() ) - expect(stdin.end).toHaveBeenCalledWith('wslg text') + // PowerShell uses base64-encoded UTF-8 via command argument, not stdin + expect(stdin.end).not.toHaveBeenCalled() + const calledArgs = start.mock.calls[0][1] as string[] + const commandIdx = calledArgs.indexOf('-Command') + const script = calledArgs[commandIdx + 1] + expect(script).toContain('FromBase64String') + expect(script).toContain(Buffer.from('wslg text', 'utf8').toString('base64')) }) it('uses PowerShell on Windows', async () => { @@ -325,5 +338,32 @@ describe('writeClipboardText', () => { expect.arrayContaining(['-NoProfile', '-NonInteractive']), expect.anything() ) + // PowerShell uses base64-encoded UTF-8 via command argument, not stdin + expect(stdin.end).not.toHaveBeenCalled() + }) + + it('preserves CJK text via base64 encoding in PowerShell on WSL', async () => { + const stdin = { end: vi.fn() } + + const child = { + once: vi.fn((event: string, cb: (code?: number) => void) => { + if (event === 'close') { + cb(0) + } + + return child + }), + stdin + } + + const start = vi.fn().mockReturnValue(child) + const cjkText = '你好世界,测试中文 🎉' + + await expect(writeClipboardText(cjkText, 'linux', start as any, { WSL_INTEROP: '/tmp/socket' })).resolves.toBe(true) + const calledArgs = start.mock.calls[0][1] as string[] + const commandIdx = calledArgs.indexOf('-Command') + const script = calledArgs[commandIdx + 1] + expect(script).toContain(Buffer.from(cjkText, 'utf8').toString('base64')) + expect(script).toContain('UTF8.GetString') }) }) diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 587e8986c..93472de7d 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -91,33 +91,44 @@ export async function readClipboardText( return null } +type WriteCmd = { args: readonly string[]; cmd: string } & ( + | { stdin: true } + | { stdin: false; psScript: (b64: string) => string } +) + +function _powershellWriteScript(b64: string): string { + return `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')))` +} + function writeClipboardCommands( platform: NodeJS.Platform, env: NodeJS.ProcessEnv -): Array<{ args: readonly string[]; cmd: string }> { +): WriteCmd[] { if (platform === 'darwin') { - return [{ cmd: 'pbcopy', args: [] }] + return [{ cmd: 'pbcopy', args: [], stdin: true }] } if (platform === 'win32') { - return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }] + return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive'], stdin: false, psScript: _powershellWriteScript }] } - const attempts: Array<{ args: readonly string[]; cmd: string }> = [] + const attempts: WriteCmd[] = [] if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) { attempts.push({ cmd: 'powershell.exe', - args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] + args: ['-NoProfile', '-NonInteractive'], + stdin: false, + psScript: _powershellWriteScript }) } if (env.WAYLAND_DISPLAY) { - attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] }) + attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'], stdin: true }) } - attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] }) - attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }) + attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'], stdin: true }) + attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'], stdin: true }) return attempts } @@ -144,14 +155,21 @@ export async function writeClipboardText( ): Promise { const candidates = writeClipboardCommands(platform, env) - for (const { cmd, args } of candidates) { + for (const cmdEntry of candidates) { try { const ok = await new Promise(resolve => { - const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) - - child.once('error', () => resolve(false)) - child.once('close', code => resolve(code === 0)) - child.stdin?.end(text) + if (cmdEntry.stdin) { + const child = start(cmdEntry.cmd, [...cmdEntry.args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + child.once('error', () => resolve(false)) + child.once('close', (code: number | null) => resolve(code === 0)) + child.stdin?.end(text) + } else { + const b64 = Buffer.from(text, 'utf8').toString('base64') + const script = cmdEntry.psScript(b64) + const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true }) + child.once('error', () => resolve(false)) + child.once('close', (code: number | null) => resolve(code === 0)) + } }) if (ok) {