Claude Code 简体中文 UI mod —— 以静态词典把斜杠命令说明、/config 项、Spinner 词、提示与通知显示为中文。零 token、零网络、不碰官方产物。

B 线:2.1.287+ 的官方扩展点(mods)。不碰官方产物——不改二进制、不改字节码; 失效时退回英文原文,不会把 CLI 改崩。零 token、零网络。
本项目前身是二进制补丁路线(A 线),因官方 2.1.24x bytecode 化已清退;其翻译表资产 已并入本插件的 dict.json,旧文件与过程保留在仓库 Git 历史中。
| 项 | 值 |
|---|---|
| 词典 | exact 1950 条 · patterns 4 条 · spinner 5 条 · modes 空(见下) |
/cc-zh-dump | 命令 0,配置 0,屏幕文案 0 —— 本轮采集到的未译项已全部补齐 |
| 装配校验 | claude plugin validate . → Validation passed(13 钩子 + 8 个 $ 调用) |
| 运行时测试 | claude plugin test . → 11 pass / 0 fail |
| 排版实机验证 | 100 列 × 300 行快照:溢出 0 行;长中文描述正常折行 |
| UTF-8 完整性 | 解码替换字符 0(探针自身缺陷已排除,见 tools/README.md) |
modes(页脚模式标签)确认为空是终态:采样显示 SessionMode.props.modes 恒为 [], 页脚的 auto mode on 是引擎独占绘制的 pill,按设计不可汉化。
以静态词典把下列界面显示为中文。全部走「props 透传改写」——由引擎用自己的组件绘制, 因此布局、配色、键位、交互一律不变。
| # | 界面 | 实现钩子 | 状态 |
|---|---|---|---|
| 1 | / 弹出的斜杠命令说明(内置命令、skills、插件) | command.describe | 已实现(含 patterns 兜底) |
| 2 | /config 行的 label 与说明 | config.describe | 已实现 |
| 3 | 工作态 Spinner 词与附带文案 | ui.render Spinner | 已实现(仅 terminal surface;只在仍为英文时接替,见下) |
| 4 | 提示行(整句译到才替换) | ui.render PromptHint | 已实现 |
| 5 | 通知条 | ui.render InfoNotice | 已实现 |
| 6 | 页脚模式标签 | ui.render SessionMode | 已接线;modes 恒空,无对象可填 |
| 7 | (ctrl+b to run in background) | ui.render ToolProgress | 已实现 |
| 8 | 命令输出行 | ui.render CommandOutput | 已实现(仅精确命中/正则,不猜) |
| 9 | 后台任务通知 | ui.render UserMessage{origin=task-notification} | 已实现 |
| 10 | 折叠的工具调用摘要(Read 3 files) | ui.render ToolGroup | 已实现(自绘) |
| 11 | 轮次结束行(Baked for 3s) | ui.render TurnDuration | 已实现(自绘,中文时长) |
settings.json 已提供若干官方本地化开关,本 mod 必须让路:
| 官方设置 | 作用 | 本 mod 的纪律 |
|---|---|---|
spinnerVerbs | 自定义 Spinner 动词(用户已配「烘焙中」「焯水ing」等) | 只在 props.word 仍为英文时才接替;含中文一律原样放行 |
spinnerTipsOverride | 自定义 tips 文本 | 本 mod 不碰 tips(不在覆盖范围内) |
language | 官方语言偏好 | 与本 mod 正交,不冲突 |
这不是纸面顾虑:2.1.296 实测
Spinner.props.word就是用户spinnerVerbs里的随机中文词 (如「哈希中」「联网中」)。早期版本无条件用固定词表覆盖,会把它换成「应答中」—— 属体验退化。现由spinnerWord()负责判定,tests/register.test.ts有回归用例。
ui.render 共 15 个渲染点,本 mod 目前钩了 9 个:
| 已钩 | 未钩 |
|---|---|
| Spinner · PromptHint · InfoNotice · SessionMode · ToolProgress · CommandOutput · UserMessage(task-notification) · ToolGroup · TurnDuration | AssistantMessage · ToolUse · ToolResult · AskUserQuestion · AbovePrompt · Pane |
未钩的这几处仍会显示英文,实测可见的有:
Thought for 1s (ctrl+o to expand) —— 思考块标题(属 AssistantMessage)(0s · thinking with max effort) —— Spinner 附注,不在 props.message 里 (实测 message 为 null),故现有 Spinner 钩子取不到
ToolUse.tool按官方声明是可写字段(Bash/Read这类标签可汉化),但ToolResult.tool是只读的 —— 两者不对称。是否要汉化工具名本身,属产品判断,未做。
mods 的 ui.render 只覆盖 15 个渲染点,且权限确认对话框由引擎独占绘制 (官方注释:*"The permission dialog is drawn by the engine alone, since its answer authorises an action"*)。 以下均为覆盖范围外:
/config、/model 的子选择屏与选项值(auto/dark/plan)auto mode on)Bash(ls)、Interrupted)与运行中的 (12s, 300 tokens)claude --help(CLI 参数层输出,非会话内渲染)与社区先例 ko-ui(韩文)的关键差异——不需要按命令名/配置键枚举:
引擎的英文描述 ──(command.describe 的 next(e) 返回)──► 查 dict.json 的 exact 表 ──► 中文
↓ 未命中
patterns 正则兜底
好处:
dict.json 的 exact 底子,无需重编结构;/cc-zh-dump 汇总 ⇒ 词典增量攒,不靠猜。dict.json 是唯一词典,不再自动跟随上游翻译表;上游若有值得吸收的新词,人工甄别后补入。
patterns:静态表结构性覆盖不到的那一类带运行时变量的描述无法进 exact(每次值都不同),改走 patterns 正则:
| 命令 | 引擎原文形态 | 正则 |
|---|---|---|
/model | Set the AI model for Claude Code (currently <模型名>) | ^Set the AI model for Claude Code \(currently (.+)\)$ |
/fast | Toggle fast mode (<模式>) | ^Toggle fast mode \((.+)\)$ |
/sandbox | ◯ sandbox disabled (⏎ to configure) | ^◯ sandbox disabled \(⏎ to configure\)$ |
⚠️
command.describe走的是trDesc()(exact→patterns),与早期的裸exact查表不同。 若patterns突然不生效,先确认trDesc还在(tests/register.test.ts有回归用例)。
mods/cc-zh/
├── .claude-plugin/plugin.json 清单(types/ 由 CLI 生成,已 gitignore)
├── hooks/hooks.json 指向 ./register.tsx
├── hooks/register.tsx 全部 13 个钩子(含 /cc-zh-dump)
├── dict.json 词典:exact(1950) + spinner + modes + patterns(4)
├── tests/register.test.ts 11 项运行时测试
└── tools/ 验收与采样工具(非发布物)
├── tty-probe.py TTY 排版验收(可复算证据)
├── runtime-sampler/ 取权威英文原文的采样插件
└── README.md ← 补词条/验收前**必读**
// ~/.claude/settings.json
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/jnq/Dev/Private/claude-code-zh/mods/cc-zh"
}
CLAUDE_CODE_PLUGIN_DIRS 是官方支持的变量,CLI 启动时按 --plugin-dir 的同一条路径内联加载 (二进制内记作 preAction: CLAUDE_CODE_PLUGIN_DIRS inline plugins)。 ⇒ 直接敲 claude 即带汉化;仓库里改完立即生效,无需重装。删掉这一行即完全还原。
与
--plugin-dir的差别只在生命周期:命令行参数是 session-only,变量是每次启动都读。 两者都已实测生效(2026-10-12,CC 2.1.296)。
claude --plugin-dir /Users/jnq/Dev/Private/claude-code-zh/mods/cc-zh
# 会话内:列出词典里还没有中文的命令/配置项/屏幕文案
/cc-zh-dump
# 校验与测试
claude plugin validate .
claude plugin test .
尚未发布到 Anthropic 官方目录;本仓自带 marketplace,可直接:
/plugin install cc-zh --marketplace jiangnanquan/claude-code-zh(或先claude plugin marketplace add jiangnanquan/claude-code-zh再claude plugin install cc-zh@claude-code-zh)。validate给出的 marketplace 提示因此可以忽略。
/cc-zh-dump,拿到仍为英文的清单;tools/runtime-sampler/ 取权威英文原文当 key,值填中文,写进 dict.json 的 exact;⛔ 不要拿
/cc-zh-dump的屏幕输出当 key —— 渲染器会折行并吞掉反引号 (实测 `claude doctor、anthropic的反引号界面上不显示), exact 表逐字匹配,错一个字符就永远命中不了且不报错。详见tools/README.md`。
红线:命不中一律放行原文,绝不写「大概意思」的中文;CommandOutput.command/args、 ToolResult.tool 等官方只读字段不得改写。
modes 段恒空(SessionMode.props.modes 实测为 []),页脚 pills 由引擎独占绘制。ctrl+o 展开视图下部分内容可能仍为原文(引擎语义如此)。config.describe 仍走裸 exact 查表(未接 patterns);实测 /config 的 label 不含 ANSI 转义,故当前无影响。若将来出现带变量或带 ANSI 的配置 label,需同样改造。hooks/register.tsx 347 lines1import type { Register } from 'claude-code'
2
3// ── cc-zh:Claude Code 简体中文 UI mod ─────────────────────────────────────────
4// 设计要点(与社区先例 ko-ui 的差异):
5// 1. **以「引擎给的英文原文」为查表键**,而不是按命令名/配置键枚举。
6// `command.describe` 的 `next(e)` 本就返回引擎的英文描述 ⇒ 直接查 exact 表。
7// 好处:① 存量扁平表(1894 条 en→zh)可原样复用;② 版本升级后英文一变,
8// 只是「命不中、放行原文」,不会译错。
9// 2. 全部走 **props 透传改写**(`next({ ...e, props })`),由引擎用自己的组件绘制
10// ⇒ 布局/配色/键位/交互不变;props 不合法时引擎退回原文绘制,不会崩。
11// 3. 命不中的字符串写进 `$.store`,由 `/cc-zh-dump` 列出 ⇒ 词典可增量攒。
12// 4. 零 token、零网络:只有一次 `$.fs.read` 读静态词典。
13
14type Pattern = { re: string; to: string }
15
16type Dict = {
17 exact: Record<string, string>
18 spinner: Record<string, string>
19 modes: Record<string, string>
20 patterns: Pattern[]
21}
22
23const EMPTY: Dict = { exact: {}, spinner: {}, modes: {}, patterns: [] }
24
25const MISSING_MAX = 400
26const ANSI = new RegExp('\\u001b\\[[0-9;]*m', 'g')
27const CJK = /[\u4e00-\u9fff]/
28// 键位类词不算「未译完」:ctrl / shift / alt / esc / tab / enter / cmd / opt / fn
29const KEYS = /\b(ctrl|shift|alt|esc|tab|enter|cmd|opt|fn)\b[+\w-]*/gi
30// ⚠️ 实测坑(2026-10-12,本仓测试发现):守卫若只放行键位词,会把 CLI / API / MCP
31// 这类**全大写缩写**与 Git / GitHub 这类**专有名词**误判为「未译完」,导致含它们
32// 的提示行永远不替换。故须一并豁免。
33const ACRONYM = /\b[A-Z][A-Z0-9]{1,}\b/g
34const PROPER = /\b(Claude|Git|GitHub|Bash|Read|Write|Edit|Grep|Glob|Opus|Sonnet|Haiku|Fable|Mythos|Astra|Vim|Emacs)\b/g
35
36let dict: Promise<Dict> | undefined
37let queue: Promise<unknown> = Promise.resolve()
38const seen = new Set<string>()
39
40function load($: any): Promise<Dict> {
41 dict ??= $.fs
42 .read(`${$.plugin.root}/dict.json`)
43 .then((t: string) => ({ ...EMPTY, ...JSON.parse(t) }))
44 .catch(() => EMPTY)
45 return dict
46}
47
48/** 记录「仍是英文」的文案,供 /cc-zh-dump 列出。不翻译、不猜测。 */
49function record($: any, kind: string, text: string) {
50 const t = (text ?? '').replace(ANSI, '').trim()
51 if (!t || CJK.test(t) || t.length > 2000) return
52 const line = `[${kind}] ${t}`
53 if (seen.has(line)) return
54 seen.add(line)
55 queue = queue.then(async () => {
56 const old = ((await $.store.get('missing')) as string[] | undefined) ?? []
57 if (old.includes(line) || old.length >= MISSING_MAX) return
58 await $.store.set('missing', [...old, line])
59 })
60}
61
62/** exact 命中 → patterns 兜底;都不中则原样返回。 */
63export function tr(d: Dict, text: string): string {
64 const bare = text.replace(ANSI, '')
65 const hit = d.exact[bare]
66 if (hit) return hit
67 let out = bare
68 for (const p of d.patterns) {
69 try {
70 out = out.replace(new RegExp(p.re, 'gm'), p.to)
71 } catch {
72 // 词典里的正则写错,跳过该条
73 }
74 }
75 return out
76}
77
78/**
79 * 与 `tr` 同源,但**区分「译到了」与「原样放行」**:没译到一律返回 undefined。
80 *
81 * 为什么需要它:`command.describe` 的 key 是引擎给的整句描述,调用方必须能分辨
82 * 「命中」与「未命中」。而该处原先只做裸 exact 查表、不过 patterns ⇒
83 * 带**运行时变量**的描述(`/model` 的 `(currently …)`、`/fast` 的 `(Opus)`、
84 * `/sandbox` 的状态)结构性无法汉化。此函数让 patterns 在这条路径上生效。
85 */
86export function trDesc(d: Dict, text: string): string | undefined {
87 const bare = text.replace(ANSI, '')
88 const out = tr(d, text)
89 return out === bare ? undefined : out
90}
91
92/**
93 * Spinner 用词:**只在当前词仍是英文时**才接替;已含中文一律放行。
94 *
95 * 为什么必须这样:用户很可能已经用官方 `settings.json` 的 `spinnerVerbs`
96 * 配了自定义中文动词(例如「烘焙中」「焯水ing」),此时 `props.word` **本来就是中文**。
97 * 若无条件用固定词表覆盖,等于把别人的趣味动词换成「思考中」,是体验退化。
98 *
99 * @returns `word` 最终采用的词(原样返回时保持 `undefined` 语义);`missed` 表示
100 * 「当前仍是英文,但词典里没有对应词」—— 供 `/cc-zh-dump` 收集。
101 */
102export function spinnerWord(
103 d: Dict,
104 mode: string,
105 current?: string,
106): { word?: string; missed: boolean } {
107 if (CJK.test(String(current ?? ''))) return { word: current, missed: false }
108 const zh = d.spinner[mode]
109 if (!zh) return { word: current, missed: true }
110 return { word: zh, missed: false }
111}
112
113/**
114 * 提示行文案:只有「整句都译到」才接受,否则放行原文。
115 * 理由:提示行是短句,半英半中的观感比全英文更差(ko-ui 同判据)。
116 */
117export function trWhole(d: Dict, text: string): string | undefined {
118 const out = tr(d, text)
119 if (out === text.replace(ANSI, '')) return undefined
120 const rest = out.replace(KEYS, '').replace(ACRONYM, '').replace(PROPER, '')
121 return /[A-Za-z]{3,}/.test(rest) ? undefined : out
122}
123
124const GROUP_ORDER = ['search', 'read', 'list', 'shell']
125const GROUP_OF: Record<string, string> = {
126 Grep: 'search', Glob: 'search', Read: 'read', LS: 'list', Bash: 'shell', PowerShell: 'shell',
127}
128const GROUP_TEXT: Record<string, (n: number, active: boolean) => string> = {
129 search: (n, a) => `检索 ${n} 次${a ? '进行中' : '已完成'}`,
130 read: (n, a) => `读取 ${n} 个文件${a ? '中' : ''}`,
131 list: (n, a) => `列出 ${n} 个目录${a ? '中' : ''}`,
132 shell: (n, a) => `执行 ${n} 条命令${a ? '中' : ''}`,
133}
134
135export function groupLine(calls: ReadonlyArray<{ tool: string }>, active: boolean): string | undefined {
136 const count: Record<string, number> = {}
137 for (const c of calls) {
138 const g = GROUP_OF[c.tool]
139 if (!g) return undefined // 含未知工具 ⇒ 交给引擎原文
140 count[g] = (count[g] ?? 0) + 1
141 }
142 const parts = GROUP_ORDER.filter(g => count[g]).map(g => GROUP_TEXT[g](count[g], active))
143 return parts.length > 0 ? parts.join(',') : undefined
144}
145
146export function duration(ms: number): string {
147 const s = Math.max(0, Math.round(ms / 1000))
148 const h = Math.floor(s / 3600)
149 const m = Math.floor((s % 3600) / 60)
150 const r = s % 60
151 if (h > 0) return `${h} 小时 ${m} 分`
152 if (m > 0) return `${m} 分 ${r} 秒`
153 return `${r} 秒`
154}
155
156/** 列出「仍是英文且不在词典里」的命令、配置项与屏幕文案。 */
157async function scan($: any) {
158 const d = await load($)
159 const need = (t?: string) => !!t && t.trim() !== '' && !CJK.test(t) && !d.exact[t.trim()]
160 const todo: { commands: Record<string, string>; config: Record<string, string>; texts: string[] } =
161 { commands: {}, config: {}, texts: [] }
162
163 const commands: any[] = await $.command.list()
164 for (const c of commands) {
165 if (c.plugin !== 'cc-zh' && need(c.description)) todo.commands[c.name] = c.description
166 }
167 const config: any[] = await $.config.list()
168 for (const r of config) {
169 if (need(r.label)) todo.config[r.key] = r.label
170 }
171 todo.texts = ((await $.store.get('missing')) as string[] | undefined) ?? []
172 return todo
173}
174
175export const register: Register = on => {
176 on('session.start', async ($, e, next) => {
177 await $.command.register({
178 name: 'cc-zh-dump',
179 description: '列出词典里还没有中文的命令、配置项与屏幕文案',
180 })
181 // 词典是异步读的,读完让这两处重算一次
182 await $.ui.invalidate('command.describe')
183 await $.ui.invalidate('config.describe')
184 $.clock.after(8000, async () => {
185 const t = await scan($)
186 const n = Object.keys(t.commands).length + Object.keys(t.config).length
187 if (n > 0) {
188 await $.ui.toast(`cc-zh:发现 ${n} 处未译项,输入 /cc-zh-dump 查看`)
189 }
190 })
191 return next(e)
192 })
193
194 on('command.run', { command: 'cc-zh-dump' }, async $ => {
195 const t = await scan($)
196 const cmds = Object.entries(t.commands).map(([k, v]) => `- /${k}: ${v}`)
197 const cfgs = Object.entries(t.config).map(([k, v]) => `- ${k}: ${v}`)
198 const lines = [
199 `未译项:命令 ${cmds.length},配置 ${cfgs.length},屏幕文案 ${t.texts.length}`,
200 ...(cmds.length ? ['', '[命令说明]', ...cmds] : []),
201 ...(cfgs.length ? ['', '[配置项]', ...cfgs] : []),
202 ...(t.texts.length ? ['', '[屏幕文案]', ...t.texts.slice(0, 80).map(x => `- ${x}`)] : []),
203 '',
204 '把上面的英文原样作为 key 加进 dict.json 的 exact 即可(值填中文)。',
205 ]
206 return { text: lines.join('\n') }
207 })
208
209 // ── 斜杠命令说明(`/` 弹出的那一栏)──
210 on('command.describe', async ($, e, next) => {
211 const d = await load($)
212 const r = await next(e)
213 const zh = trDesc(d, r.description ?? '')
214 if (zh === undefined) {
215 record($, `cmd:${e.command}`, r.description ?? '')
216 return r
217 }
218 const hint = r.argumentHint ? trDesc(d, String(r.argumentHint)) : undefined
219 return { ...r, description: zh, argumentHint: hint ?? r.argumentHint }
220 })
221
222 // ── /config 行 ──
223 on('config.describe', async ($, e, next) => {
224 const d = await load($)
225 const r = await next(e)
226 const label = d.exact[(r.label ?? '').trim()]
227 const desc = d.exact[(r.description ?? '').trim()]
228 if (!label) record($, `config:${e.key}`, r.label ?? '')
229 return { ...r, label: label ?? r.label, description: desc ?? r.description }
230 })
231
232 // ── 工作态 Spinner ──
233 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
234 if (e.surface !== 'terminal') return next(e)
235 const d = await load($)
236 const p = e.props
237 let message = p.message
238 if (message !== null && message !== undefined) {
239 const out = tr(d, message)
240 if (out === String(message).replace(ANSI, '')) record($, 'spinner', message)
241 else message = out
242 }
243 const { word, missed } = spinnerWord(d, p.mode, p.word)
244 if (missed) record($, `spinner-mode`, p.mode)
245 return next({ ...e, props: { ...p, word, message } })
246 })
247
248 // ── 提示行(整句译到才替换)──
249 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
250 const d = await load($)
251 const out = trWhole(d, e.props.hint ?? '')
252 if (out === undefined) {
253 record($, 'hint', e.props.hint ?? '')
254 return next(e)
255 }
256 return next({ ...e, props: { ...e.props, hint: out } })
257 })
258
259 // ── 通知条 ──
260 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
261 const d = await load($)
262 const text = e.props.text ?? ''
263 const out = tr(d, text)
264 if (out === text.replace(ANSI, '')) {
265 record($, 'notice', text)
266 return next(e)
267 }
268 return next({ ...e, props: { ...e.props, text: out } })
269 })
270
271 // ── 页脚模式标签 ──
272 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
273 const d = await load($)
274 const modes = e.props.modes.map((m: string) => {
275 const zh = d.modes[m]
276 if (!zh) record($, 'mode', m)
277 return zh ?? m
278 })
279 return next({ ...e, props: { ...e.props, modes } })
280 })
281
282 // ── `(ctrl+b to run in background)` ──
283 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
284 const hint = e.props.hint ?? ''
285 const m = /^\((.+) to run in background\)$/.exec(hint)
286 if (!m) {
287 record($, 'progress', hint)
288 return next(e)
289 }
290 return next({ ...e, props: { ...e.props, hint: `(${m[1]} 转后台运行)` } })
291 })
292
293 // ── 命令输出行 ──
294 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
295 const d = await load($)
296 const text = e.props.text ?? ''
297 const out = tr(d, text)
298 if (out === text.replace(ANSI, '')) {
299 record($, `out:${e.props.command}`, text)
300 return next(e)
301 }
302 return next({ ...e, props: { ...e.props, text: out } })
303 })
304
305 // ── 后台任务通知(引擎自绘,透传改写)──
306 on(
307 'ui.render',
308 { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } },
309 async ($, e, next) => {
310 const d = await load($)
311 const text = e.props.text ?? ''
312 const out = tr(d, text)
313 if (out === text.replace(ANSI, '')) {
314 record($, 'task-notification', text)
315 return next(e)
316 }
317 return next({ ...e, props: { ...e.props, text: out } })
318 },
319 )
320
321 // ── 折叠的工具调用摘要(自绘,因为译文长度与原文不同)──
322 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
323 if (e.props.isExpanded) return next(e)
324 const line = groupLine(e.props.calls, e.props.isActive)
325 if (line === undefined) {
326 record($, 'toolgroup', e.props.calls.map((c: any) => c.tool).join(','))
327 return next(e)
328 }
329 const { Box, Text } = $.ui.resolve(e)
330 return (
331 <Box flexDirection="row">
332 <Text dimColor>
333 ● {line}
334 {e.props.isActive ? '…' : ''}
335 </Text>
336 <Text dimColor>(ctrl+o 展开)</Text>
337 </Box>
338 )
339 })
340
341 // ── 轮次结束行(自绘,中文时长)──
342 on('ui.render', { component: 'TurnDuration' }, async ($, e) => {
343 const { Text } = $.ui.resolve(e)
344 return <Text dimColor>✻ 耗时 {duration(e.props.durationMs)}</Text>
345 })
346}
347