diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index 93feb009d870..2bd6c859b74c 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -15,25 +15,27 @@ describe('readClipboardText', () => { }) it('reads text from PowerShell on Windows', async () => { - const run = vi.fn().mockResolvedValue({ stdout: 'from windows\r\n' }) + const b64 = Buffer.from('from windows\r\n', 'utf8').toString('base64') + const run = vi.fn().mockResolvedValue({ stdout: b64 }) await expect(readClipboardText('win32', run)).resolves.toBe('from windows\r\n') expect(run).toHaveBeenCalledWith( 'powershell', - ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], + ['-NoProfile', '-NonInteractive', '-Command', '[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'], expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true }) ) }) it('tries powershell.exe first on WSL', async () => { - const run = vi.fn().mockResolvedValue({ stdout: 'from wsl\n' }) + const b64 = Buffer.from('from wsl\n', 'utf8').toString('base64') + const run = vi.fn().mockResolvedValue({ stdout: b64 }) await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe( 'from wsl\n' ) expect(run).toHaveBeenCalledWith( 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], + ['-NoProfile', '-NonInteractive', '-Command', '[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'], expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true }) ) }) @@ -81,6 +83,16 @@ describe('readClipboardText', () => { readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv) ).resolves.toBeNull() }) + + it('preserves CJK text via base64 decoding from PowerShell on WSL', async () => { + const cjkText = '你好世界,测试中文 🎉' + const b64 = Buffer.from(cjkText, 'utf8').toString('base64') + const run = vi.fn().mockResolvedValue({ stdout: b64 }) + + await expect( + readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv) + ).resolves.toBe(cjkText) + }) }) describe('isUsableClipboardText', () => { @@ -109,6 +121,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin: { end: vi.fn() } } @@ -129,6 +142,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } @@ -152,6 +166,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin: { end: vi.fn() } } @@ -171,6 +186,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } @@ -201,6 +217,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } @@ -236,6 +253,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } @@ -258,6 +276,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } @@ -290,6 +309,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } @@ -327,6 +347,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } @@ -353,6 +374,7 @@ describe('writeClipboardText', () => { return child }), + unref: vi.fn(), stdin } diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 4a5387ae2d22..67fb5eb25fe8 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -3,7 +3,14 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) const CLIPBOARD_MAX_BUFFER = 4 * 1024 * 1024 -const POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'] as const +// PowerShell read: base64-encode the clipboard content to avoid ANSI codepage +// corruption (same problem as the write path — see comment at line 94). +const POWERSHELL_READ_ARGS = [ + '-NoProfile', + '-NonInteractive', + '-Command', + '[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))' +] as const type ClipboardRun = typeof execFileAsync @@ -33,19 +40,19 @@ export function isUsableClipboardText(text: null | string): text is string { function readClipboardCommands( platform: NodeJS.Platform, env: NodeJS.ProcessEnv -): Array<{ args: readonly string[]; cmd: string }> { +): Array<{ args: readonly string[]; cmd: string; base64?: boolean }> { if (platform === 'darwin') { return [{ cmd: 'pbpaste', args: [] }] } if (platform === 'win32') { - return [{ cmd: 'powershell', args: POWERSHELL_ARGS }] + return [{ cmd: 'powershell', args: POWERSHELL_READ_ARGS, base64: true }] } - const attempts: Array<{ args: readonly string[]; cmd: string }> = [] + const attempts: Array<{ args: readonly string[]; cmd: string; base64?: boolean }> = [] if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) { - attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS }) + attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_READ_ARGS, base64: true }) } if (env.WAYLAND_DISPLAY) { @@ -81,6 +88,10 @@ export async function readClipboardText( }) if (typeof result.stdout === 'string') { + if (attempt.base64) { + return Buffer.from(result.stdout.trim(), 'base64').toString('utf8') + } + return result.stdout } } catch { @@ -158,6 +169,7 @@ export async function writeClipboardText( const ok = await new Promise(resolve => { if (cmdEntry.stdin) { const child = start(cmdEntry.cmd, [...cmdEntry.args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + child.unref() child.once('error', () => resolve(false)) child.once('close', (code: number | null) => resolve(code === 0)) child.stdin?.end(text) @@ -165,6 +177,7 @@ export async function writeClipboardText( const b64 = Buffer.from(text, 'utf8').toString('base64') const script = _powershellWriteScript(b64) const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true }) + child.unref() child.once('error', () => resolve(false)) child.once('close', (code: number | null) => resolve(code === 0)) }