NousResearch/hermes-agent

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

0open issues
0open PRs
4items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35222 fix(tui): preserve UTF-8 in PowerShell clipboard writes (CJK/emoji)

    merged to main

    files: ui-tui/src/__tests__/clipboard.test.ts, ui-tui/src/lib/clipboard.ts

    Show 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

  • #35122 fix(tui): preserve utf8 when copying via powershell · superseded by another PR → #35124
  • #35124 fix(tui): use base64 encoding for PowerShell clipboard writes to preserve UTF-8 · merged via another change → #35222
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.00 means 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 Lab

At a glance

Merged PRs
#35222
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
30 May 2026

For your agent

The same facts, typed.