SLOPSHOPPER

cc-zh

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

newspinnerrowscommandtoasttimer
v0.1.0MITupdated 2026-10-13jiangnanquan/claude-code-zh/mods/cc-zh
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-zh
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ 耗时 42 秒 › /cc-zh-dump ⎿ cc-zh: 未译项:命令 0,配置 0,屏幕文案 1 ⎿ cc-zh: ⎿ cc-zh: [屏幕文案] ⎿ cc-zh: - [spinner-mode] thinking ⎿ cc-zh: ⎿ cc-zh: 把上面的英文原样作为 key 加进 dict.json 的 exact 即可(值填中文)。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
✻ 耗时 42 秒
README

cc-zh — Claude Code 简体中文 UI mod(0.1.0)

B 线:2.1.287+ 的官方扩展点(mods)。不碰官方产物——不改二进制、不改字节码; 失效时退回英文原文,不会把 CLI 改崩。零 token、零网络。

本项目前身是二进制补丁路线(A 线),因官方 2.1.24x bytecode 化已清退;其翻译表资产 已并入本插件的 dict.json,旧文件与过程保留在仓库 Git 历史中。

当前实测状态(CC 2.1.296,2026-10-12)

项值
词典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 · TurnDurationAssistantMessage · 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 是只读的 —— 两者不对称。是否要汉化工具名本身,属产品判断,未做。

不可汉化(官方设计,非本 mod 缺陷)

mods 的 ui.render 只覆盖 15 个渲染点,且权限确认对话框由引擎独占绘制 (官方注释:*"The permission dialog is drawn by the engine alone, since its answer authorises an action"*)。 以下均为覆盖范围外:

  • 权限确认窗口 · 欢迎屏幕 · /config、/model 的子选择屏与选项值(auto/dark/plan)
  • 错误信息 · tips · 提示行下方的 pills(auto mode on)
  • 工具调用行本身(Bash(ls)、Interrupted)与运行中的 (12s, 300 tokens)
  • claude --help(CLI 参数层输出,非会话内渲染)

设计要点:以「引擎英文原文」为键

与社区先例 ko-ui(韩文)的关键差异——不需要按命令名/配置键枚举:

引擎的英文描述  ──(command.describe 的 next(e) 返回)──►  查 dict.json 的 exact 表  ──►  中文
                                                            ↓ 未命中
                                                         patterns 正则兜底

好处:

  1. 历史翻译表(1905 条,源自 claude-code-zh-cn,MIT)已一次性吸收为 dict.json 的 exact 底子,无需重编结构;
  2. 版本升级把英文改了 ⇒ 只是「命不中、放行原文」,不会译错;
  3. 未命中的字符串由 /cc-zh-dump 汇总 ⇒ 词典增量攒,不靠猜。

dict.json 是唯一词典,不再自动跟随上游翻译表;上游若有值得吸收的新词,人工甄别后补入。

patterns:静态表结构性覆盖不到的那一类

带运行时变量的描述无法进 exact(每次值都不同),改走 patterns 正则:

命令引擎原文形态正则
/modelSet the AI model for Claude Code (currently <模型名>)^Set the AI model for Claude Code \(currently (.+)\)$
/fastToggle 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                ← 补词条/验收前**必读**

用法

日常使用:已装进全局配置(2026-10-12 起)

// ~/.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)。

单会话试装(改配置前先验证、或做 A/B 对比)

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 提示因此可以忽略。

补词条的方法

  1. 会话里跑 /cc-zh-dump,拿到仍为英文的清单;
  2. 用 tools/runtime-sampler/ 取权威英文原文当 key,值填中文,写进 dict.json 的 exact;
  3. 新会话生效(词典每次会话读一次)。

⛔ 不要拿 /cc-zh-dump 的屏幕输出当 key —— 渲染器会折行并吞掉反引号 (实测 ` claude doctor 、 anthropic 的反引号界面上不显示), exact 表逐字匹配,错一个字符就永远命中不了且不报错。详见 tools/README.md`。

红线:命不中一律放行原文,绝不写「大概意思」的中文;CommandOutput.command/args、 ToolResult.tool 等官方只读字段不得改写。

已知限制

  • 需 Claude Code ≥ 2.1.287;mods API 官方明示「can change between releases」⇒ 跟随版本维护。
  • modes 段恒空(SessionMode.props.modes 实测为 []),页脚 pills 由引擎独占绘制。
  • 翻译只改绘制,不改存储:ctrl+o 展开视图下部分内容可能仍为原文(引擎语义如此)。
  • config.describe 仍走裸 exact 查表(未接 patterns);实测 /config 的 label 不含 ANSI 转义,故当前无影响。若将来出现带变量或带 ANSI 的配置 label,需同样改造。
Source 1 files
hooks/register.tsx 347 lines
1import 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