SLOPSHOPPER

zh-translate

按一下「中文」按鈕,Claude 的回覆和選擇題都會變成繁體中文

bandrowsguardcommandtoastprompt
v0.3.0no licenseupdated 2026-10-17justin20745413/claude-mods/zh-translate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zh-translate
› fix the failing auth test and add an audit log call ╭──────────────╮ │ zh-translate │ ⏺ 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. ✻ Worked for 42s · done 4:20 PM › /zh ⎿ zh-translate: 中文模式:開 [ 中文 開 ] 回覆與選擇題會顯示成繁體中文 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ 中文 開 ] 回覆與選擇題會顯示成繁體中文 ⟨Claude Code's own drawing⟩
README

claude-mods

Claude Code 的兩個 mod:

  • file-log:右側面板列出這次對話修改與新增的檔案,也能查看今天所有對話的紀錄(只保留當天)。內含貪食蛇和小恐龍小遊戲。
  • zh-translate:按一下「中文」按鈕或輸入 /zh,Claude 的回覆和選擇題都會變成繁體中文(只翻譯最新一則回覆)。

一次安裝全部

在終端機的 Claude Code 對話框輸入:

/plugin install all --marketplace justin20745413/claude-mods

第一次會問 Add marketplace?,按 y;接著選安裝範圍,直接按 Enter(user)即可。file-log 和 zh-translate 會一起裝好。

在一般終端機(shell)裡:

claude plugin install all --marketplace justin20745413/claude-mods

一次安裝全部工具,不要小遊戲

/plugin install no-game --marketplace justin20745413/claude-mods

會裝 file-log-nogame 和 zh-translate。file-log-nogame 跟 file-log 功能一樣,但不含貪食蛇和小恐龍,面板上也沒有 game 分頁。(file-log 和 file-log-nogame 選一個裝就好。)

只裝其中一個

/plugin install file-log --marketplace justin20745413/claude-mods
/plugin install file-log-nogame --marketplace justin20745413/claude-mods
/plugin install zh-translate --marketplace justin20745413/claude-mods

在桌面版 App(/plugin 不能用時):點對話框旁的 + → Plugins → Add marketplace,輸入 justin20745413/claude-mods,再按 Add plugin,選 all 或單一個 mod。

更新

裝過的人要拿到新版本:

claude plugin marketplace update my-mods
claude plugin update file-log@my-mods
claude plugin update zh-translate@my-mods

(裝的是無遊戲版的話,把 file-log@my-mods 換成 file-log-nogame@my-mods。)

給維護者

file-log-nogame 是由 file-log 自動產生的,不要直接改它。改完 file-log 後執行:

./scripts/make-nogame.sh
Source 2 files
hooks/register.tsx 225 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// 中文模式:
5// 1. Claude 之後的回覆直接用繁體中文寫(每個提示旁邊附一句只有模型看得到的指示)
6// 2. 最新一則英文回覆翻譯後顯示(只改顯示,對話紀錄不變);翻譯只留這一則,下一輪就清掉
7// 3. 選擇題(AskUserQuestion)在跳出前先翻成中文
8
9const isOn = atom({ plugin: 'zh-translate', key: 'isOn' } as const, false)
10const translations = atom({ plugin: 'zh-translate', key: 'translations' } as const, {} as Record<string, string>)
11const latest = atom({ plugin: 'zh-translate', key: 'latest' } as const, '')
12
13const STORE_KEY = 'isOn'
14// The cheapest Claude model ($0.10 / $0.50 per million tokens), thinking kept to a minimum.
15const MODEL = 'claude-haiku-5-5'
16const EFFORT = 'low' as const
17const MAX_PARALLEL = 3
18
19const REPLY_IN_CHINESE =
20  'The user has turned on Chinese mode: write every reply to the user in Traditional Chinese (繁體中文, Taiwan usage), ' +
21  'including AskUserQuestion questions, headers, option labels and descriptions. ' +
22  'Keep code, commands, file paths and identifiers as they are.'
23
24const TRANSLATE_SYSTEM =
25  'You translate text into Traditional Chinese (繁體中文, Taiwan usage). ' +
26  'Keep Markdown structure, code blocks, inline code, commands, file paths, URLs, numbers and product names unchanged. ' +
27  'Reply with the translation only, no preamble.'
28
29// Text that is already mostly Chinese, or has no letters to translate, is left alone.
30export function needsTranslation(text: string): boolean {
31  const prose = text.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
32  const cjk = (prose.match(/[㐀-鿿]/g) ?? []).length
33  const latinWords = (prose.match(/[A-Za-z]{2,}/g) ?? []).length
34  if (latinWords < 3) return false
35  return latinWords > cjk / 2
36}
37
38// A block belongs to the newest reply when its text is part of that reply's final text.
39export function isLatestReply(text: string, latestAnswer: string): boolean {
40  const t = text.trim()
41  return t !== '' && latestAnswer !== '' && latestAnswer.includes(t)
42}
43
44function hash(text: string): string {
45  let h = 5381
46  for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
47  return h.toString(36) + ':' + text.length
48}
49
50// ---- Reply blocks: translated in the background, a few at a time ----
51
52const pending = new Set<string>()
53const queue: { key: string; text: string }[] = []
54let running = 0
55
56function requestTranslation($: EngineInterface, key: string, text: string): void {
57  if (pending.has(key)) return
58  pending.add(key)
59  queue.push({ key, text })
60  pump($)
61}
62
63function pump($: EngineInterface): void {
64  while (running < MAX_PARALLEL && queue.length > 0) {
65    const job = queue.shift()!
66    running++
67    void translateBlock($, job.key, job.text).finally(() => {
68      running--
69      pending.delete(job.key)
70      pump($)
71    })
72  }
73}
74
75async function translateBlock($: EngineInterface, key: string, text: string): Promise<void> {
76  const reply = await $.model.complete({
77    model: MODEL,
78    effort: EFFORT,
79    system: TRANSLATE_SYSTEM,
80    prompt: text,
81    maxTokens: Math.min(16000, text.length * 2 + 256),
82  })
83  if (!reply.isAnswered || !reply.text.trim()) return
84  // A newer reply arrived while this one was translating: drop it.
85  if (!isLatestReply(text, await read($, latest))) return
86  await update($, translations, all => ({ ...all, [key]: reply.text.trim() }))
87}
88
89// ---- Questions: every shown string translated in one call, before the dialog opens ----
90
91type Option = { label?: unknown; description?: unknown; [k: string]: unknown }
92type Question = { question?: unknown; header?: unknown; options?: unknown; [k: string]: unknown }
93
94export async function translateQuestions($: EngineInterface, questions: readonly Question[]): Promise<Question[] | undefined> {
95  const strings: string[] = []
96  const take = (v: unknown) => {
97    if (typeof v === 'string') strings.push(v)
98  }
99  for (const q of questions) {
100    take(q.question)
101    take(q.header)
102    for (const o of Array.isArray(q.options) ? (q.options as Option[]) : []) {
103      take(o.label)
104      take(o.description)
105    }
106  }
107  if (!needsTranslation(strings.join('\n'))) return undefined
108
109  const reply = await $.model.complete({
110    model: MODEL,
111    effort: EFFORT,
112    system:
113      TRANSLATE_SYSTEM +
114      ' The input is a JSON array of strings; reply with a JSON array of the same length, each string translated in place. ' +
115      'Strings that are short headers must stay at most 12 characters.',
116    prompt: JSON.stringify(strings),
117    maxTokens: Math.min(8000, JSON.stringify(strings).length * 2 + 256),
118  })
119  if (!reply.isAnswered) return undefined
120  let out: unknown
121  try {
122    out = JSON.parse(reply.text.slice(reply.text.indexOf('['), reply.text.lastIndexOf(']') + 1))
123  } catch {
124    return undefined
125  }
126  if (!Array.isArray(out) || out.length !== strings.length || !out.every(s => typeof s === 'string')) return undefined
127
128  let i = 0
129  const next = (v: unknown) => (typeof v === 'string' ? (out as string[])[i++] : v)
130  return questions.map(q => {
131    const question = next(q.question)
132    const header = next(q.header)
133    const options = Array.isArray(q.options)
134      ? (q.options as Option[]).map(o => {
135          const label = next(o.label)
136          const description = next(o.description)
137          return { ...o, label, description }
138        })
139      : q.options
140    return {
141      ...q,
142      question,
143      header: typeof header === 'string' ? header.slice(0, 12) : header,
144      options,
145    }
146  })
147}
148
149async function setOn($: EngineInterface, on: boolean): Promise<void> {
150  await update($, isOn, () => on)
151  await $.store.set(STORE_KEY, on)
152  $.ui.toast(on ? '中文模式已開啟' : '中文模式已關閉')
153}
154
155export const register: Register = on => {
156  on('session.start', async ($, e, next) => {
157    const stored = (await $.store.get(STORE_KEY)) === true
158    await update($, isOn, () => stored)
159    await $.command.register({ name: 'zh', description: '切換中文模式:Claude 的回覆和選擇題都變成繁體中文' })
160    return next(e)
161  })
162
163  on('command.run', { command: 'zh' }, async $ => {
164    const now = !(await read($, isOn))
165    await setOn($, now)
166    return { text: now ? '中文模式:開' : '中文模式:關' }
167  })
168
169  // Future replies: ask the model itself to write in Chinese.
170  on('prompt.submit', async ($, e, next) => {
171    if (e.command || !(await read($, isOn))) return next(e)
172    return next({ ...e, context: [...(e.context ?? []), REPLY_IN_CHINESE] })
173  })
174
175  // Questions: translate before the dialog opens, so the choices are Chinese too.
176  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
177    if (!(await read($, isOn))) return next(e)
178    const translated = await translateQuestions($, e.questions as readonly Question[])
179    return next(translated ? ({ ...e, questions: translated } as typeof e) : e)
180  }).catch(($, e, next) => next(e))
181
182  // Remember the newest reply and forget older translations.
183  on('turn.complete', async ($, e, next) => {
184    const done = await next(e)
185    if (e.agentId === undefined) {
186      const answer = e.answer.trim()
187      await update($, latest, () => answer)
188      await update($, translations, () => ({}))
189    }
190    return done
191  })
192
193  // The newest reply on screen: show the translation once it is ready.
194  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
195    if (!(await read($, isOn))) return next(e)
196    const text = e.props.text
197    if (!needsTranslation(text)) return next(e)
198    if (!isLatestReply(text, await read($, latest))) return next(e)
199    const key = hash(text)
200    const done = (await read($, translations))[key]
201    if (done) return next({ ...e, props: { ...e.props, text: done } })
202    // Only rows in view are translated; scrolling to the others brings them in.
203    if (e.props.onScreen !== null) requestTranslation($, key, text)
204    return next(e)
205  })
206
207  // The button above the prompt.
208  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209    if (e.props.hasSurvey) return next(e)
210    const { Box, Button, Text } = $.ui.resolve(e)
211    const active = await read($, isOn)
212    // Other mods' bands are drawn below this one, not replaced.
213    const below = await next(e)
214    return (
215      <Box flexDirection="column">
216      <Box flexDirection="row" gap={1}>
217        <Button key="zh-toggle" variant={active ? 'primary' : 'secondary'} label={active ? '中文 開' : '中文 關'} onPress={() => setOn($, !active)} />
218        <Text dimColor>{active ? '回覆與選擇題會顯示成繁體中文' : '點一下或輸入 /zh 切換成中文'}</Text>
219      </Box>
220      {below}
221      </Box>
222    )
223  })
224}
225
types/index.d.ts 16 lines
1/** Whether Chinese mode is on. */
2export type ZhMode = boolean
3
4declare module 'claude-code' {
5  interface PluginState {
6    'zh-translate': {
7      /** Whether Chinese mode is on. */
8      isOn: ZhMode
9      /** Translations of the newest reply's blocks only, by a hash of the original text. */
10      translations: Record<string, string>
11      /** The newest reply's final text, so only it is translated. */
12      latest: string
13    }
14  }
15}
16