TUI clipboard CJK/UTF-8 encoding fixes for PowerShell/WSL2
Cluster 35107. Data as of
Nothing open on GitHub. Fix merged in #35222 on 30 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #35222. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #35222. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A bug report and multiple fix attempts addressing garbled CJK and emoji characters when copying text to the clipboard via PowerShell in TUI, particularly on WSL2. The fixes explore approaches such as base64 encoding to preserve UTF-8 integrity.
Open issues0
Open PRs0
Fixes on record
-
#35222 fix(tui): preserve UTF-8 in PowerShell clipboard writes (CJK/emoji)
files:
ui-tui/src/__tests__/clipboard.test.ts,ui-tui/src/lib/clipboard.tsShow the diff
-
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') }) }) -
ui-tui/src/lib/clipboard.ts@@ -91,33 +91,42 @@ export async function readClipboardText( return null } +// PowerShell on Windows/WSL decodes piped stdin with the system ANSI code +// page (e.g. CP936), not UTF-8, so $input-based writes mangle CJK/emoji. We +// instead base64-encode the UTF-8 bytes and pass them as a -Command argument, +// decoding with UTF8.GetString — this removes the stdin-encoding variable +// entirely (also immune to BOM injection on redirect). PowerShell entries set +// stdin=false; every other backend reads UTF-8 stdin natively. +type WriteCmd = { args: readonly string[]; cmd: string; stdin: boolean } + +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 }] } - 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'] - }) + attempts.push({ cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive'], stdin: false }) } 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 +153,21 @@ export async function writeClipboardText( ): Promise<boolean> { const candidates = writeClipboardCommands(platform, env) - for (const { cmd, args } of candidates) { + for (const cmdEntry of candidates) { try { const ok = await new Promise<boolean>(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 = _powershellWriteScript(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) {
-
2 PRs closed without merging
From the close comments: merged via another change 1 · superseded by another PR 1
Closed issues · 1
- Closed#35107/copy in TUI produces garbled CJK text on WSL2
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #35222
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 30 May 2026