【2026-04-09官方已修复,此贴终结】 ClaudeCode在Windows环境下/copy乱码问题

是这个帖子的延续

已经超过一个月,那个帖子不能再编辑了,所以开个新帖,分析下为什么又出现这个问题:

写在前面

如果是配置了"CLAUDE_CODE_NO_FLICKER": "1"导致出现复制的各种奇怪问题
我这边暂时还没研究明白,后面有空研究明白了再来编辑帖子

以下讨论的是原来的那种TUI模式下,/copy乱码的解决

测试升级2.1.92之后用补丁,且设置了NO_FLICKER,/copy正常,从屏幕ctrl C正常,鼠标默认复制这个就不往下深究了(我用的Windows Teminal)

/copy 剪贴板写入链路

当你按下 /copy 时,Claude Code 执行的是 setClipboard 函数(如果你有那份神秘源码的话,位置在 src/ink/termio/osc.ts),它会同时发射两条路径:

setClipboard(text) 被调用
│
├─ ① copyNative(text) [fire-and-forget,不等结果]
│ └─ switch(process.platform)
│ ├─ “darwin” → pbcopy (macOS,原生 UTF-8)
│ ├─ “linux” → wl-copy/xclip/xsel (Linux,原生 UTF-8)
│ └─ “win32” → clip.exe (Windows,使用系统代码页!)
│
├─ ② 构造 OSC 52 转义序列(base64 编码的 UTF-8)
│ └─ 写入 process.stdout → 到达终端
│ └─ 终端如果支持 OSC 52 → 正确设置剪贴板
│
└─ return OSC 52 序列

这两条路径是并行的,不是 fallback 关系。设计意图是"双保险"
OSC 52 依赖终端支持(有些终端默认关闭),native 工具作为本地保险。

问题出在哪

在中文 Windows 上(系统代码页 CP936/GBK,不一定会产生这个问题,这是情况之一):

1: copyNative 发射 clip.exe(异步,进程慢慢启动)
2: OSC 52 写入 stdout → 终端正确设置剪贴板 :white_check_mark:
3: clip.exe 启动完毕,用 CP936 解读 UTF-8 stdin → 乱码覆盖剪贴板 :cross_mark:
最终结果: 剪贴板 = clip.exe 写的乱码

OSC 52 先到且正确,但 clip.exe 后到用乱码覆盖了正确结果。如果你的终端不支持 OSC 52(部分环境下确实如此),那只有 clip.exe 一条路径生效,直接就是乱码。
说人话:是的,本质还是跟之前一样是clip没处理的问题

为什么之前好了现在又会了

官方修复与回归时间线


v2.1.77 时,官方为终端内鼠标选中文本的自动复制功能(useCopyOnSelect)写了一个新的 copyNative 函数。这个函数按 process.platform switch,直接 spawn clip,没有 UTF-8 处理。但此时/copy 命令还走旧的 map-based dispatcher(带 Set-Clipboard),所以用户无感。
两套代码并存了约 12 个版本。v2.1.不知道88还是89 时,旧的 dispatcher 被整个删除,/copy 被切换到新的 copyNative——也就是那个没有 UTF-8 处理的版本。

解决方案

原帖子里面的方案二、以及alias clip都可以(如果之前处理过的话应该也不会出现这个问题)
如果是采用方案一的,补丁脚本的代码替换成:

#!/usr/bin/env node
/**
 * Claude Code /copy UTF-8 修复补丁 (AST 版)
 * ============================================
 * 修复 Windows 上 /copy 命令中文乱码:clip.exe 使用系统代码页 (CP936 等)
 * 读取 Node.js 写入的 UTF-8 字节,导致非 ASCII 字符变成乱码。
 *
 * 用法:node fix-claude-copy.mjs [选项]
 *
 * 选项:
 *   (无)        应用补丁
 *   --check     检查补丁状态(exit 0=已打, exit 1=未打, exit 2=异常)
 *   --dry-run   预览补丁内容,不实际修改
 *   --revert    恢复到补丁前的备份
 *   --help      显示帮助
 *
 * 原理:copyNative 函数中 Windows 分支直接调用 clip.exe(AST 结构):
 *         SwitchStatement discriminant: process.platform
 *           SwitchCase test: "win32"
 *             CallExpression: fn("clip", [], opts)
 *       本补丁将其改为:
 *         fn("cmd", ["/d", "/s", "/c", "chcp 65001 >nul & clip"], opts)
 *       通过 cmd.exe 先切换代码页到 UTF-8 再调 clip,使 clip.exe 正确读取 UTF-8 stdin。
 *
 * 定位策略:
 *   1. 用 acorn 解析 cli.js 为 AST
 *   2. 查找 discriminant 为 process.platform 的 SwitchStatement
 *   3. 通过 case "darwin" 中包含 "pbcopy" 调用来确认是 copyNative 函数的 switch
 *   4. 在 case "win32" 中定位 fn("clip", [], opts) 调用
 *   5. 精确替换前两个参数:"clip",[] → "cmd",["/d","/s","/c","chcp 65001 >nul & clip"]
 *   不依赖任何混淆后的变量名,跨版本健壮。
 *
 * 回归背景:
 *   v2.1.70 官方修复(用 PowerShell Set-Clipboard),v2.1.89 重构时丢失修复。
 *   详见 https://github.com/anthropics/claude-code/issues/22346
 *        https://github.com/anthropics/claude-code/issues/41235
 */

import { readFileSync, writeFileSync, copyFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { createRequire } from 'module';
import { tmpdir } from 'os';

const BACKUP_SUFFIX = 'utf8-copy-patch-bak';
// 兼容旧版正则补丁的备份 suffix
const LEGACY_BACKUP_SUFFIX = 'utf8-patch-bak';

// ── 参数解析 ─────────────────────────────────────────────────

const isRevert = process.argv.includes('--revert');
const isCheck = process.argv.includes('--check');
const isDryRun = process.argv.includes('--dry-run');
const isHelp = process.argv.includes('--help') || process.argv.includes('-h');

if (isHelp) {
  console.log(`Claude Code /copy UTF-8 修复补丁 (AST 版)

用法:node fix-claude-copy.mjs [选项]

选项:
  (无)        应用补丁
  --check     检查补丁状态 (exit 0=已打, 1=未打, 2=异常)
  --dry-run   预览补丁变更,不实际修改文件
  --revert    恢复到补丁前的备份
  --help      显示此帮助

每次 Claude Code 更新后需重新运行。`);
  process.exit(0);
}

// ── 定位 cli.js ──────────────────────────────────────────────

function findCliJs() {
  const candidates = [];

  try {
    const prefix = execSync('npm config get prefix', { encoding: 'utf-8' }).trim();
    candidates.push(join(prefix, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'));
  } catch {}
  try {
    const root = execSync('npm root -g', { encoding: 'utf-8' }).trim();
    candidates.push(join(root, '@anthropic-ai', 'claude-code', 'cli.js'));
  } catch {}
  try {
    const bin = execSync(
      process.platform === 'win32' ? 'where.exe claude' : 'which claude',
      { encoding: 'utf-8' },
    ).trim().split(/\r?\n/)[0];
    candidates.push(join(dirname(bin), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'));
  } catch {}

  const home = process.env.USERPROFILE || process.env.HOME;
  candidates.push(
    join(home, '.claude', 'local', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
    join(home, 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
    '/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js',
    '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js',
  );

  for (const p of candidates) {
    if (existsSync(p)) return p;
  }
  return null;
}

// ── 获取 acorn ───────────────────────────────────────────────

function loadAcorn() {
  const require = createRequire(import.meta.url);

  // 1) 尝试本地已安装的 acorn
  try { return require('acorn'); } catch {}

  // 2) 尝试从 temp 缓存加载
  const cached = join(tmpdir(), 'acorn-claude-patch.js');
  if (existsSync(cached)) {
    try { return require(cached); } catch {}
  }

  // 3) 下载到 temp
  console.log('📦 正在下载 acorn parser...');
  try {
    execSync(`node -e "const r=require('https');const f=require('fs');r.get('https://unpkg.com/acorn@8.14.0/dist/acorn.js',res=>{let d='';res.on('data',c=>d+=c);res.on('end',()=>f.writeFileSync('${cached.replace(/\\/g, '\\\\')}',d))})"`, { stdio: 'pipe' });
  } catch {
    try {
      execSync(`node -e "fetch('https://unpkg.com/acorn@8.14.0/dist/acorn.js').then(r=>r.text()).then(t=>require('fs').writeFileSync('${cached.replace(/\\/g, '\\\\')}',t))"`, { stdio: 'pipe' });
    } catch {
      console.error('❌ 无法下载 acorn parser,请手动安装: npm i -g acorn');
      process.exit(2);
    }
  }
  return require(cached);
}

// ── AST 工具 ─────────────────────────────────────────────────

/** 递归遍历 AST,收集满足 predicate 的节点 */
function findNodes(node, predicate, results = []) {
  if (!node || typeof node !== 'object') return results;
  if (predicate(node)) results.push(node);
  for (const key in node) {
    if (key === 'type') continue;
    const child = node[key];
    if (child && typeof child === 'object') {
      if (Array.isArray(child)) {
        child.forEach(c => findNodes(c, predicate, results));
      } else {
        findNodes(child, predicate, results);
      }
    }
  }
  return results;
}

/** 判断节点是否为 process.platform 的 MemberExpression */
function isProcessPlatform(node) {
  return (
    node.type === 'MemberExpression' &&
    !node.computed &&
    node.object.type === 'Identifier' &&
    node.object.name === 'process' &&
    node.property.type === 'Identifier' &&
    node.property.name === 'platform'
  );
}

/** 在节点树中查找第一个 CallExpression,其第一个参数为指定字符串字面量 */
function findCallWithFirstArg(root, targetStr) {
  const calls = findNodes(root, n =>
    n.type === 'CallExpression' &&
    n.arguments.length >= 1 &&
    n.arguments[0].type === 'Literal' &&
    n.arguments[0].value === targetStr
  );
  return calls[0] || null;
}

/** 在 SwitchStatement 中按 test 值查找 SwitchCase */
function findCase(switchStmt, testValue) {
  return switchStmt.cases.find(c =>
    c.test && c.test.type === 'Literal' && c.test.value === testValue
  ) || null;
}

// ── 主流程 ───────────────────────────────────────────────────

const cliPath = findCliJs();
if (!cliPath) {
  console.error('❌ 找不到 Claude Code cli.js,请确认已全局安装 @anthropic-ai/claude-code');
  process.exit(2);
}
console.log(`📂 cli.js: ${cliPath}`);

// 读取版本
let version = 'unknown';
try {
  const pkg = JSON.parse(readFileSync(join(cliPath, '..', 'package.json'), 'utf-8'));
  version = pkg.version;
} catch {}
console.log(`📦 版本: ${version}`);

// 平台提示
if (process.platform !== 'win32') {
  console.log('⚠️  当前不是 Windows 环境。补丁仅影响 case "win32" 分支,在其他平台上无实际效果。');
}

// ── 恢复 ──

if (isRevert) {
  const dir = dirname(cliPath);
  // 同时搜索新旧两种备份 suffix
  const files = readdirSync(dir)
    .filter(f =>
      f.startsWith('cli.js.' + BACKUP_SUFFIX) ||
      f.startsWith('cli.js.' + LEGACY_BACKUP_SUFFIX)
    )
    .sort()
    .reverse();
  if (files.length === 0) {
    console.error('❌ 备份文件不存在,无法恢复');
    process.exit(2);
  }
  const bakPath = join(dir, files[0]);
  console.log(`🔍 找到备份: ${files[0]}`);
  if (isDryRun) {
    console.log('📝 [DRY-RUN] 将恢复此备份并删除,但实际未执行');
    process.exit(0);
  }
  copyFileSync(bakPath, cliPath);
  unlinkSync(bakPath);
  console.log(`✅ 已恢复并删除备份。请重启 Claude Code 生效。`);
  if (files.length > 1) {
    console.log(`💡 还有 ${files.length - 1} 个历史备份,如需清理请手动删除`);
  }
  process.exit(0);
}

// ── 读取 & 解析 ──

let code = readFileSync(cliPath, 'utf-8');

// 处理 shebang
let shebang = '';
if (code.startsWith('#!')) {
  const idx = code.indexOf('\n');
  shebang = code.slice(0, idx + 1);
  code = code.slice(idx + 1);
}

console.log('📦 正在加载 AST parser...');
const acorn = loadAcorn();

console.log('🔍 正在解析 cli.js(可能需要几秒)...');
let ast;
try {
  ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: 'module' });
} catch (e) {
  console.error('❌ AST 解析失败:', e.message);
  process.exit(2);
}

const src = (node) => code.slice(node.start, node.end);

// ── Step 1: 查找 switch(process.platform) 语句 ──

const platformSwitches = findNodes(ast, n =>
  n.type === 'SwitchStatement' && isProcessPlatform(n.discriminant)
);

if (platformSwitches.length === 0) {
  console.error('❌ 未找到 switch(process.platform) 语句');
  process.exit(2);
}
console.log(`🔍 找到 switch(process.platform) 语句 (${platformSwitches.length} 处)`);

// ── Step 2: 筛选出 copyNative 的 switch ──
//
// copyNative 的 switch 特征:
//   - 有 case "darwin",其中包含对 "pbcopy" 的调用
//   - 有 case "win32",其中包含对 "clip" 或 "cmd"(已打补丁) 的调用

/** @type {'unpatched' | 'patched' | null} */
let patchState = null;
let targetSwitch = null;
let win32Case = null;
let clipCall = null;

for (const sw of platformSwitches) {
  const darwinCase = findCase(sw, 'darwin');
  const winCase = findCase(sw, 'win32');

  if (!darwinCase || !winCase) continue;

  // 确认 darwin case 包含 "pbcopy" 调用(copyNative 的指纹)
  if (!findCallWithFirstArg(darwinCase, 'pbcopy')) continue;

  // 检查 win32 case 的状态
  const clip = findCallWithFirstArg(winCase, 'clip');
  if (clip) {
    patchState = 'unpatched';
    targetSwitch = sw;
    win32Case = winCase;
    clipCall = clip;
    break;
  }

  // 已打补丁?检查 cmd + chcp 65001
  const cmdCall = findCallWithFirstArg(winCase, 'cmd');
  if (cmdCall) {
    const hasChcp = findNodes(cmdCall, n =>
      n.type === 'Literal' && typeof n.value === 'string' && n.value.includes('chcp 65001')
    );
    if (hasChcp.length > 0) {
      patchState = 'patched';
      targetSwitch = sw;
      win32Case = winCase;
      break;
    }
  }
}

if (!targetSwitch) {
  console.error('❌ 未找到 copyNative 函数的 switch(process.platform)');
  console.error('   要求:case "darwin" 中含 "pbcopy" 调用,case "win32" 中含 "clip" 或 "cmd" 调用');
  console.error(`   已检查 ${platformSwitches.length} 个 switch(process.platform) 语句`);
  process.exit(2);
}

console.log(`🔍 定位 copyNative switch at offset ${targetSwitch.start}-${targetSwitch.end}`);

// ── 已打补丁的处理 ──

if (patchState === 'patched') {
  const patchedCall = findCallWithFirstArg(win32Case, 'cmd');
  console.log(`✅ 补丁已生效 (win32 case 已使用 cmd + chcp 65001)`);
  if (patchedCall) {
    console.log(`   当前代码: ${src(patchedCall)}`);
  }
  if (isCheck) {
    // --check: exit 0 表示补丁已就位
    process.exit(0);
  }
  if (isDryRun) {
    console.log('📝 [DRY-RUN] 补丁已存在,无需任何操作');
    process.exit(0);
  }
  // 正常运行模式
  console.log('💡 无需重复操作');
  process.exit(0);
}

// ── Step 3: 验证 clip 调用结构 ──
//
// 期望的 AST 结构:fn("clip", [], opts)
//   arguments[0]: Literal "clip"
//   arguments[1]: ArrayExpression (empty)
//   arguments[2]: Identifier (opts 变量)

const args = clipCall.arguments;
if (args.length < 2) {
  console.error(`❌ clip 调用参数数量异常: 期望 >= 2, 实际 ${args.length}`);
  process.exit(2);
}

const arg0 = args[0]; // "clip"
const arg1 = args[1]; // []

if (arg0.type !== 'Literal' || arg0.value !== 'clip') {
  console.error(`❌ 第一个参数不是字符串 "clip": ${src(arg0)}`);
  process.exit(2);
}
if (arg1.type !== 'ArrayExpression' || arg1.elements.length !== 0) {
  console.error(`❌ 第二个参数不是空数组: ${src(arg1)}`);
  process.exit(2);
}

const fnName = clipCall.callee.type === 'Identifier'
  ? clipCall.callee.name
  : src(clipCall.callee);
const optsName = args.length >= 3 ? src(args[2]) : '(none)';

console.log(`🔍 定位 clip 调用:`);
console.log(`   位置: offset ${clipCall.start}-${clipCall.end}`);
console.log(`   源码: ${src(clipCall)}`);
console.log(`   结构: ${fnName}("clip", [], ${optsName})`);

// ── --check 模式 ──

if (isCheck) {
  console.log('');
  console.log('⚠️  补丁未应用 — 不带 --check 参数重新运行以应用');
  process.exit(1);
}

// ── Step 4: 构造替换 ──
//
// 替换 arguments[0] 和 arguments[1] 的源码范围:
//   "clip",[]  →  "cmd",["/d","/s","/c","chcp 65001 >nul & clip"]
//
// 只修改这两个参数,保留函数名和 opts 参数不变。

const replaceStart = arg0.start;
const replaceEnd = arg1.end;
const originalText = code.slice(replaceStart, replaceEnd);
const patchedText = '"cmd",["/d","/s","/c","chcp 65001 >nul & clip"]';

console.log(`\n🔧 替换计划:`);
console.log(`   范围: offset ${replaceStart}-${replaceEnd} (${replaceEnd - replaceStart} bytes)`);
console.log(`   原始: ${originalText}`);
console.log(`   替换: ${patchedText}`);

// ── --dry-run 模式 ──

if (isDryRun) {
  // 展示完整的替换前后对比
  const ctxStart = Math.max(0, clipCall.start - 30);
  const ctxEnd = Math.min(code.length, clipCall.end + 30);
  const before = code.slice(ctxStart, ctxEnd);
  const after = code.slice(ctxStart, replaceStart) + patchedText + code.slice(replaceEnd, ctxEnd);
  console.log(`\n   替换前上下文: ...${before}...`);
  console.log(`   替换后上下文: ...${after}...`);
  console.log('\n📝 [DRY-RUN] 以上为预览,未实际修改文件');
  process.exit(0);
}

// ── Step 5: 执行替换 ──

let newCode = code.slice(0, replaceStart) + patchedText + code.slice(replaceEnd);

// ── Step 6: 验证 ──

// 验证补丁内容存在
if (!newCode.includes('chcp 65001 >nul & clip')) {
  console.error('❌ 验证失败:补丁内容未出现在输出中');
  process.exit(2);
}

// 验证 "pbcopy" 仍存在(确认没有误伤其他平台代码)
if (!newCode.includes('"pbcopy"')) {
  console.error('❌ 验证失败:补丁意外影响了其他平台代码');
  process.exit(2);
}

// 验证补丁后的代码可解析(最强验证)
try {
  acorn.parse(newCode, { ecmaVersion: 2022, sourceType: 'module' });
} catch (e) {
  console.error('❌ 验证失败:补丁后代码无法通过 AST 解析');
  console.error(`   ${e.message}`);
  process.exit(2);
}
console.log('✔  补丁后代码 AST 解析验证通过');

// ── Step 7: 备份 & 写入 ──

const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const bakPath = cliPath + '.' + BACKUP_SUFFIX + '-' + timestamp;
copyFileSync(cliPath, bakPath);
console.log(`💾 已备份至: ${bakPath}`);

writeFileSync(cliPath, shebang + newCode, 'utf-8');

console.log('');
console.log(`🔧 补丁已应用:`);
console.log(`   ${fnName}("clip", [], ${optsName})`);
console.log(`   → ${fnName}("cmd", ["/d", "/s", "/c", "chcp 65001 >nul & clip"], ${optsName})`);
console.log('');
console.log('✅ /copy 将使用 UTF-8 代码页写入剪贴板。请重启 Claude Code 生效。');
console.log('💡 恢复原版: node fix-claude-copy.mjs --revert');
console.log('💡 检查状态: node fix-claude-copy.mjs --check');

9 个赞

@dd_hh @zhice @OpenAI @baker_hongrunj

2 个赞

原来如此 长知识了!
回去就测试看看:star_struck::star_struck::star_struck:

1 个赞

有佬友找到一个配置解决乱码

佬友实力恐怖如斯,待我细细阅读一番。:face_savoring_food::face_savoring_food:

佬友可以尝试在最新版搞个patch开启Windows下的流式输出吗?line by line那个特性:folded_hands:

之前都没注意过这个命令,我刚试了下,copy命令复制出来的中文不乱码,版本是:v2.1.86,升级到v2.1.92就乱码了 :joy:

❯ /copy
  ⎿  Copied to clipboard (44 characters, 1 lines)
     Also written to C:\Users\admin\AppData\Local\Temp\claude\response.md

缓存文件里面的内容,是不乱码的。

Also written to xxx文件不乱码倒是正常的
调用入口都不一样,详见神秘源码的src\commands\copy\copy.tsx
不一定是最新版的,但原理一样,写文件走的fs.writeFile(‘utf-8’) ,文件系统直接写 UTF-8 字节.

那个做法,是挺方便,但是我持保留意见哈
原因1:原理是通过设置SSH_CONNECTION骗claudecode“我现在正在远程ssh环境下,所以不要用那个copyNative函数”,要生效但是需要保证自己的使用终端完全支持OSC 52(源码注释里面有提到OSC 52不可靠的场景)
原因2:改这个变量可能不止影响OSC 52,这个变量在env.ts里面会被设置成env.isSSH(),保不准会被别的地方使用到造成连锁反应,这个我没深究,我觉得这毕竟只是个小bug,顾此失彼就不好了

1 个赞

佬我歪楼了(不是乱码问题),是这个特性:joy:
有没有办法搞个patch强制启用它呢

先说好,我个人很不建议很不建议开 :cold_face:
从时间线和源码推测,很像是官方控不住这个特性带来的面向不同环境的渲染问题,所以一起通过门控直接强判Windows环境,是的话就关掉,具体会导致什么渲染问题我没深究。
如果一定要用,可以看这个补丁,用法注释里面有,用之前先丢给AI看看
以及就算用了补丁想把那个特性恢复回来,这个配置项不能开。


fix-claude-line-streaming-windows.zip (5.1 KB)

1 个赞

佬友太强了,非常感谢!
我之前在2179没遇到什么异常,要是有问题我自己再恢复:face_blowing_a_kiss:
待会吃完饭马上试试看:+1:

2.1.97 修了 NO_FLICKER 模式下的复制乱码问题:grinning_face:

Fixed Korean/Japanese/Unicode text becoming garbled when copied in no-flicker mode on Windows

1 个赞

看了一下,现在是涉及复制的都共享一个处理:
转base64之后,通过powershell命令行执行
Set-Clipboard -Value ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(‘${K}’)))
K就是转base64之后的字符串
剩下的他那里面有个判断上述的K长度是否超过30000的动作,超过3万走OSC 52序列,没看懂为啥。
这个帖子可以关了,已修改标题,感谢提醒

1 个赞