Translate outgoing prompts and stream reply translations into a side pane with configurable LLM and Jev detection.

Claude Code 双向翻译 mod。输入按「Agent 接收」语言自动翻译;回复按「我的阅读」语言翻译到右侧,原回复保留在左侧。
在 Claude Code 中运行:
/plugin marketplace add Yrzhe/claude-skills
/plugin marketplace update yrzhe-skills
/plugin install translate-view@yrzhe-skills
/reload-plugins
/translate settings
需要 Claude Code 2.1.287+(在 2.1.291 验证)、Python 3 和 macOS/Linux。右侧停靠面板需要 Claude 的全屏渲染模式;必要时退出后重新启动:
CLAUDE_CODE_NO_FLICKER=1 claude --continue
窄窗口或非全屏布局可能由 Claude 将面板放到输入框上方。面板的高度、停靠位置由 Claude 管理。与 reply-view 可以同时使用。
顶部固定显示两个目标语言和「设置」按钮;下方是用 Claude 原生 Markdown 渲染的可滚动译文,支持标题、加粗、列表、链接和代码块。表格按侧栏实际宽度显示:放得下时保留带边框的表格;放不下时按记录纵向排列「列名: 内容」,记录之间加分隔线。拉宽侧栏后自动恢复表格,不重新调用翻译接口。超长表格在记录之间分段,并保留表头。往上滚动时停止自动跟随,点「回到底部」恢复。生成中的译文合并完整句子/段落后分批翻译;Agent 完成后,会把积压内容合并请求,避免逐段重复检测和等待。支持接口的 SSE 流式输出;短回复在完成后翻译。代码块等完整后再处理。检测与翻译服务自身的响应速度仍会影响延迟。
| 设置 | 示例 | 效果 |
|---|---|---|
| Agent 接收 | English | 中文输入先译成英文,再发送给 Agent |
| 我的阅读 | 简体中文 | 英文回复在右侧显示中文译文 |
识别为目标语言时跳过翻译调用;右侧会标注「已是目标语言」。语言不确定时交给翻译模型处理。语言设置独立,不会强制 Agent 改变原回复语言。
常用命令:
/translate
/translate settings
/translate off
/translate on
关闭设置页返回译文;关闭面板后可用 /translate 重新打开。/translate off 会关闭两个方向的翻译。
首次安装默认关闭。设置顶部固定显示五个分类,按实际宽度排布,空间足够时五项在同一行:「语言检测 / 模型接口 / 环境变量 / Jev 接口 / 翻译 Prompt」。打开设置会回到语言检测首页,并显示当前生效的检测方式和翻译模型。填好接口后开启自动翻译并保存。
可选常见语言,也可在「自定义接收/阅读代码」填写 en、zh、ja 等语言代码。
使用兼容 Chat Completions 的接口:
Base URL:包含服务商要求的版本前缀,例如 https://provider.example/v1。仅自动追加 /chat/completions。POST URL:完整地址,填写时优先于 Base URL。Authorization: Bearer ...。本地无认证接口可留空,并清空 Key 环境变量。OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_MODEL。在「环境变量」分类填写变量名,不带 $。默认「本机填写优先」模式下手填值优先、留空读环境变量;选择「读取环境变量」后,LLM 的 Base URL、Key、模型全部从环境读取,手填值和 POST URL 被忽略但不会被删除。环境变量需要在启动 Claude 前导出,修改 shell 环境后要重启 Claude。Jev 有独立的配置来源、地址、模型和密钥绑定。非流式响应读取 choices[0].message.content;流式响应读取 choices[0].delta.content。返回普通 JSON 的服务也可使用,但译文在该批完成后显示。不支持直接使用 Anthropic Messages、Gemini 原生接口或 Responses API 的 URL。
Jev 只判断语言,不生成译文;选择其他检测方式时不会调用它。其配置来源独立于翻译模型:
/v1/systemone,模型示例为 jev-latest;自建网关可以更换。TYPESAFE_POST_URL、TYPESAFE_MODEL、TYPESAFE_API_KEY,已保存的手填值会保留但不参与这一路请求。使用 TypeSafe System One 请求格式,请求包含 state、model、questions,读取 answers.language.choice 和 confidence。
设置会标出当前生效模型与字段来源:本机填写、环境变量或未配置。模型/地址输入框里的实值来自这台机器的私有配置;公开插件不包含你的私有地址、密钥或已选择的翻译型号。
环境变量页填写的是名字,不是变量值。界面逐项显示「已读取」「当前进程未设置」或「尚未绑定」,不显示环境变量的完整内容;密钥身份仅显示末四位。灰色示例不代表已经绑定;「填入推荐变量名」只填写名字,不创建变量、不写入密钥,也不自动切换配置来源。新的变量名保存后检查。
| 用途 | 推荐变量名 |
|---|---|
| 翻译 Base URL | OPENAI_BASE_URL |
| 翻译 API Key | OPENAI_API_KEY |
| 翻译模型名 | OPENAI_MODEL |
| Jev POST URL | TYPESAFE_POST_URL |
| Jev API Key | TYPESAFE_API_KEY |
| Jev 模型名 | TYPESAFE_MODEL |
可以改用自己的变量名。变量需要由启动 Claude 的终端导出;给变量名并不会让插件自动扫描电脑上的其他密钥文件。
「翻译 Prompt」页可分别编辑发送前翻译、回复翻译的系统提示词。支持:
{target_language}:当前方向的目标语言。{source_language}:检测出的源语言。示例:
Translate into {target_language}. Preserve the original intent and tone. Use concise, natural language. Return only the translation, without explanations.
插件会额外要求保留代码、链接、路径和附件标记占位符,避免模型改变这些内容。默认 Prompt 要求保留原意和 Markdown 结构,不执行待翻译文本中的指令。
设置保存在本机 ~/.config/claude-translate/config.json,文件权限 0600,不在插件目录或公开仓库。可用 TRANSLATE_VIEW_CONFIG 指定另一个私有配置文件。所有 Claude 会话默认共享这份设置;其他已打开会话需重新加载插件以刷新开关/语言显示。
模型接口和 Jev 页会显示「当前生效密钥」的来源及末四位(短密钥完全隐藏):来自本机时显示配置文件路径和 apiKey / jevKey 字段,来自环境时显示确切变量名。未启用的本机备用密钥会另标为「当前未使用」。这些信息对应已保存的设置,修改后点保存才更新。完整密钥不回显;密钥输入留空只是保留本机已有值,不表示自动寻找其他密钥,「清除已保存密钥」在保存时删除。原生终端输入框不支持密码遮罩,新输入的密钥在保存前可见;也可只填写环境变量名。
启用后,用户提示词和主 Agent 的可见回复会发送到所配置的翻译服务。选用 LLM/Jev 检测时,自然语言部分也会发送到检测服务。图片/音频/文档附件的二进制、思考内容、工具输出、其他 Agent 的回复不会被送去翻译。插件不会读取集中密钥库,也不会上传到作者的服务。发布包不含任何个人地址、模型配置或密钥。
发送前翻译失败时暂停该次发送并提示。空输入框会恢复原文;若已经开始写下一条,则保留新输入,侧栏提供「恢复输入」按钮追加未发送的文字。可以重试或关闭翻译后发送。回复翻译失败时原回复仍可阅读,侧栏可重试。请求有超时和大小上限,不自动无限重试。
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
python3 -m unittest discover -s tests -p 'test_*.py' -v
tsc -p .
Claude 首次加载 mod 时会生成本机版本对应的类型声明与 tsconfig.json,它们不随包发布。
tests/terminal_smoke.py 用真实 Claude Code PTY、本地模拟翻译服务和替代 Agent 响应验证 Markdown 样式、长文滚动与固定页头、消息原文、设置以及失败行为;不调用真实 Agent 模型。需要测试环境安装 pyte,例如:
uv run --with pyte python tests/terminal_smoke.py
测试脚本默认调用 claude,可通过 CLAUDE_BIN 环境变量指定其他启动命令。
Gemini 官方提供 OpenAI 兼容接口。第三方网关是否支持该接口格式,需要实际验证;插件不会自动改用其他模型或服务商。
参考:Claude mod UI、TypeSafe Jev API。MIT License。
表格布局专项验证:python3 tests/terminal_smoke.py --tables,实际拖动侧栏验证宽表、窄屏记录和恢复表格,使用模拟接口,不调用真实模型。
hooks/register.tsx 367 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register } from 'claude-code'
3import { isControlPrompt, languageOptions, nextSegment, markdownParts, settingsGroups } from './core'
4import type { Config } from './core'
5
6const PANE = 'translate-view'
7let cfg: Config | undefined
8let draft: Record<string, unknown> = {}
9let page = ''
10let tab = 'language'
11let notice = ''
12let interactive = false
13let activeTurn = ''
14let generation = 0
15let busy = false
16let follow = true
17let needsFollow = true
18let settingsStart = false
19let currentStream: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | undefined
20const displayOriginals = atom({ plugin: 'translate-view', key: 'originals' } as const, {} as Record<string, string>)
21let originals = new Map<string, { original: string }[]>()
22let failedPrompts: string[] = []
23let job: { source: string; cursor: number; done: boolean; pieces: string[]; pending: string; error: string; skipped: boolean } | undefined
24
25function redraw($: EngineInterface) { $.ui.invalidate('ui.render') }
26function reset(source = '', done = false) {
27 generation++
28 const stream = currentStream
29 currentStream = undefined
30 if (stream) { void stream.result.catch(() => {}); void stream.return({ code: null, signal: 'SIGTERM' }).catch(() => {}) }
31 job = { source, cursor: 0, done, pieces: [], pending: '', error: '', skipped: true }
32 follow = true; needsFollow = true
33}
34async function recover($: EngineInterface, text: string) {
35 const current = await $.prompt.read().catch(() => null)
36 if (current && !current.text) {
37 const result = await $.prompt.fill({ text, mode: 'append' }).catch(() => null)
38 if (result?.isFilled) return
39 }
40 failedPrompts.push(text)
41}
42async function helper($: EngineInterface, data: object): Promise<Record<string, any>> {
43 const result = await $.process.run(['python3', `${$.plugin.root}/scripts/translate.py`], { stdin: JSON.stringify(data), timeoutMs: 95000 })
44 let value
45 try { value = JSON.parse(result.stdout) } catch { throw new Error('翻译服务未返回有效结果。') }
46 if (result.exitCode !== 0 || value.error) throw new Error(value.error || '翻译失败。')
47 return value
48}
49async function load($: EngineInterface) {
50 cfg = await helper($, { action: 'load' }) as Config
51}
52async function open($: EngineInterface, settings = false) {
53 if (!cfg) await load($)
54 if (settings || !cfg?.enabled) { page = 'settings'; draft = { ...cfg }; tab = 'language'; settingsStart = true }
55 await $.ui.open({ id: PANE, title: 'Translation', columns: 52, rows: 24 })
56 needsFollow = true
57 redraw($)
58}
59async function save($: EngineInterface, patch: Record<string, unknown>, close = true) {
60 try {
61 cfg = await helper($, { action: 'save', patch }) as Config
62 draft = { ...cfg }; notice = '已保存'; if (close) page = ''
63 const source = job?.source ?? ''; const done = job?.done ?? true
64 reset(source, done)
65 } catch (e) { notice = e instanceof Error ? e.message : '保存失败。' }
66 redraw($)
67}
68async function pump($: EngineInterface) {
69 if (busy || !cfg?.enabled || !job || job.error) return
70 const segment = nextSegment(job.source, job.cursor, job.done)
71 if (!segment) return
72 busy = true
73 const token = generation, target = job
74 let buffer = '', final: { text: string; skipped?: boolean } | undefined
75 try {
76 const stream = $.process.spawn({ argv: ['python3', `${$.plugin.root}/scripts/translate.py`], input: JSON.stringify({ action: 'stream', direction: 'outgoing', text: segment }) })
77 currentStream = stream
78 for await (const chunk of stream) {
79 if (token !== generation) break
80 if (chunk.stream !== 'stdout') continue
81 buffer += chunk.text
82 if (buffer.length > 2_000_000) throw new Error('译文过长。')
83 let at: number
84 while ((at = buffer.indexOf('\n')) >= 0) {
85 const line = buffer.slice(0, at); buffer = buffer.slice(at + 1)
86 if (!line.trim()) continue
87 const data = JSON.parse(line)
88 if (data.type === 'error') throw new Error(data.error)
89 if (data.type === 'partial' && typeof data.text === 'string') target.pending = data.text
90 if (data.type === 'done' && typeof data.text === 'string') final = data
91 }
92 redraw($)
93 }
94 if (token !== generation) return
95 const result = await stream.result
96 if (result.code !== 0 || !final) throw new Error('翻译流未完成,请重试。')
97 target.pieces.push(final.text); target.pending = ''; target.cursor += segment.length
98 target.skipped = target.skipped && final.skipped === true
99 } catch (e) {
100 if (token === generation) { target.pending = ''; target.error = e instanceof Error ? e.message : '翻译失败。' }
101 } finally {
102 currentStream = undefined; busy = false; redraw($)
103 }
104}
105
106export const register: Register = on => {
107 on('session.start', async ($, e, next) => {
108 interactive = e.isInteractive && e.surface === 'terminal'
109 await $.command.register({ name: 'translate', description: 'Open translation pane or settings', argumentHint: '[settings|on|off]', immediate: true })
110 try { await load($) } catch (e) { notice = e instanceof Error ? e.message : '配置读取失败。' }
111 if (interactive && cfg) await open($)
112 $.clock.every(250, async () => {
113 await pump($)
114 if (interactive && page === 'settings' && settingsStart) {
115 const result = await $.ui.scroll({ in: PANE, to: 'start' }).catch(() => ({ deny: 'pane unavailable' }))
116 if (!result.deny) { settingsStart = false; redraw($) }
117 }
118 if (interactive && page !== 'settings' && needsFollow && follow) {
119 const result = await $.ui.scroll({ in: PANE, to: 'end' }).catch(() => ({ deny: 'pane unavailable' }))
120 if (!result.deny) needsFollow = false
121 }
122 })
123 return next(e)
124 })
125 on('command.run', { command: 'translate' }, async ($, e) => {
126 if (e.args.trim() === 'off' || e.args.trim() === 'on') await save($, { enabled: e.args.trim() === 'on' })
127 await open($, e.args.trim() === 'settings')
128 return { text: cfg?.enabled ? `翻译已开启 · Agent 接收 ${cfg.incomingLanguage} · 我的阅读 ${cfg.outgoingLanguage}` : '翻译已关闭;可在设置中开启。' }
129 })
130 on('session.end', ($, e, next) => { reset(); originals.clear(); return next(e) })
131 on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => { reset(); originals.clear(); failedPrompts = []; await update($, displayOriginals, () => ({})); redraw($); return next(e) })
132 on('prompt.submit', async ($, e, next) => {
133 if (!cfg?.enabled || !['composer', 'bridge', 'sdk'].includes(e.origin.kind) || !e.text.trim() || isControlPrompt(e.text)) return next(e)
134 let pending: { original: string } | undefined
135 let pendingKey = ''
136 let entered = false
137 try {
138 const translated = await helper($, { action: 'translate', direction: 'incoming', text: e.text })
139 if (typeof translated.text !== 'string' || !translated.text.trim()) throw new Error('未收到有效译文。')
140 if (translated.text !== e.text) {
141 pending = { original: e.text }; pendingKey = translated.text
142 originals.set(translated.text, [...(originals.get(translated.text) ?? []), pending])
143 while (originals.size > 100) originals.delete(originals.keys().next().value!)
144 }
145 // Attachments/context/wait/origin are passed through, never sent to the translator.
146 const result = await next({ ...e, text: translated.text })
147 entered = !result.drop
148 if (result.drop) await recover($, e.text)
149 return result
150 } catch (error) {
151 notice = error instanceof Error ? error.message : '发送前翻译失败。'
152 await recover($, e.text)
153 redraw($)
154 // Keep the user's draft available instead of silently sending a mistranslation.
155 return { drop: `发送前翻译失败:${notice} 原文未发送;可重试,或 /translate off 后发送。` }
156 } finally {
157 if (pending && !entered) {
158 const queue = originals.get(pendingKey)?.filter(item => item !== pending)
159 if (queue?.length) originals.set(pendingKey, queue)
160 else originals.delete(pendingKey)
161 }
162 }
163 })
164 on('session.append', { door: 'prompt' }, async ($, e, next) => {
165 if (!e.agentId && e.message.type === 'user') {
166 const text = e.message.content.filter(block => block.type === 'text').map(block => typeof block.text === 'string' ? block.text : '').join('')
167 const queue = originals.get(text)
168 const original = queue?.shift()?.original
169 if (queue?.length === 0) originals.delete(text)
170 if (original !== undefined) await update($, displayOriginals, previous => {
171 const entries = [...Object.entries(previous), [e.uuid, original] as [string, string]]
172 let size = entries.reduce((sum, [, value]) => sum + value.length, 0)
173 while (entries.length > 100 || size > 200_000 && entries.length > 1) size -= entries.shift()![1].length
174 return Object.fromEntries(entries)
175 })
176 }
177 return next(e)
178 })
179 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
180 const original = (await read($, displayOriginals))[e.requestId]
181 return next(original !== undefined ? { ...e, props: { ...e.props, text: original } } : e)
182 })
183 on('turn.start', ($, e, next) => { activeTurn = e.turnId; reset(); notice = ''; redraw($); return next(e) })
184 on('turn.step', async function* ($, e, next) {
185 if (e.agentId || !interactive || !cfg?.enabled) return yield* next(e)
186 const stream = next(e)
187 let source = '', began = false
188 for await (const chunk of stream) {
189 if (chunk.kind === 'text' && e.turnId === activeTurn) {
190 if (!began) { reset(); began = true }
191 source += chunk.text
192 if (job) job.source = source
193 redraw($)
194 }
195 yield chunk
196 }
197 const result = await stream.result
198 if (e.turnId === activeTurn && result.answer) {
199 if (job?.source !== result.answer) reset(result.answer, result.stopReason === 'end_turn')
200 else if (job) job.done = result.stopReason === 'end_turn'
201 redraw($)
202 }
203 return result
204 })
205 on('turn.complete', ($, e, next) => {
206 if (!e.agentId && interactive && e.turnId === activeTurn) {
207 if (e.isAborted) { reset(); notice = '本轮已停止'; }
208 else if (job?.source !== e.answer) reset(e.answer, true)
209 else if (job) job.done = true
210 redraw($)
211 }
212 return next(e)
213 })
214 on('ui.scroll', { component: 'Pane' }, async ($, e, next) => {
215 if (e.requestId !== PANE) return next(e)
216 if (page !== 'settings' && e.origin.kind === 'person') {
217 follow = e.offset >= Math.max(0, e.contentRows - e.bodyRows)
218 needsFollow = follow
219 }
220 const result = await next(e)
221 redraw($)
222 return result
223 })
224 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
225 if (e.requestId !== PANE || e.surface !== 'terminal') return next(e)
226 const { Box, Text, Button, Input, Select, Markdown } = $.ui.resolve(e)
227 if (!cfg) return <Text>{notice || '正在读取设置…'}</Text>
228 const settings = () => { draft = { ...cfg }; page = 'settings'; tab = 'language'; settingsStart = true; notice = ''; redraw($) }
229 const set = (key: string, value: unknown) => { draft[key] = value }
230 const field = (key: string, label: string, placeholder = '') => <Box flexDirection="column"><Text dimColor>{label + ':'}</Text><Input key={`input-${key}`} label="" value={String(draft[key] ?? '')} placeholder={placeholder} onInput={value => set(key, value)} onSubmit={value => set(key, value)} /></Box>
231 const envNames: Record<string, string> = { baseUrlEnv: 'OPENAI_BASE_URL', apiKeyEnv: 'OPENAI_API_KEY', modelEnv: 'OPENAI_MODEL', jevUrlEnv: 'TYPESAFE_POST_URL', jevKeyEnv: 'TYPESAFE_API_KEY', jevModelEnv: 'TYPESAFE_MODEL' }
232 const sourceName = (value?: string) => value === 'manual' ? '本机填写' : value === 'env' ? '环境变量' : '未配置'
233 const keySummary = (key: 'apiKey' | 'jevKey', env: 'apiKeyEnv' | 'jevKeyEnv') => {
234 const source = cfg!.sources?.[key]
235 const info = cfg!.keyInfo?.[key]
236 const location = source === 'manual' ? `本机配置 · ${key} 字段` : source === 'env' ? `环境变量 · ${cfg![env]}` : '未配置密钥'
237 return <Box key={`key-summary-${key}`} flexDirection="column">
238 <Text>{'当前生效密钥:' + (info?.active || '无') + ' · ' + location}</Text>
239 {source === 'manual' && <Text dimColor>{cfg!.configPath || '~/.config/claude-translate/config.json'}</Text>}
240 {source === 'manual' && <Text dimColor>{`已保存的本机密钥优先;${cfg![env] || '环境变量'} 当前未用于此密钥。`}</Text>}
241 {source !== 'manual' && info?.saved && <Text dimColor>{'本机另存:' + info.saved + '(当前未使用)'}</Text>}
242 <Text dimColor>显示已保存配置的生效结果;修改后点「保存」更新。仅显示密钥末四位,短密钥完全隐藏。</Text>
243 </Box>
244 }
245 const sourcePicker = (key: string) => <Select key={key} label="配置来源" value={String(draft[key] || 'manual')} options={[{ value: 'manual', label: '本机填写优先' }, { value: 'env', label: '读取环境变量' }]} onSelect={value => { set(key, value); settingsStart = true; redraw($) }} />
246 const environmentFields = (provider: 'llm' | 'jev') => <Box flexDirection="column">
247 {(provider === 'llm' ? [['baseUrlEnv', '地址变量名'], ['apiKeyEnv', '密钥变量名'], ['modelEnv', '模型变量名']] : [['jevUrlEnv', 'Jev 地址变量名'], ['jevKeyEnv', 'Jev 密钥变量名'], ['jevModelEnv', 'Jev 模型变量名']]).map(([key, label]) => <Box key={`binding-${key}`} flexDirection="column">
248 {field(key!, label!, envNames[key!] + '(推荐名称)')}
249 <Text dimColor>{!draft[key!] ? '尚未绑定;上方灰字仅是示例。' : draft[key!] !== cfg?.[key as keyof Config] ? '保存后检查此变量。' : `${String(draft[key!])}:${cfg?.envStatus?.[key!] ? '已读取(不显示变量值)' : '当前进程未设置'}`}</Text>
250 </Box>)}
251 </Box>
252 const sections = [
253 { value: 'language', label: '语言检测' }, { value: 'llm', label: '模型接口' },
254 { value: 'env', label: '环境变量' }, { value: 'jev', label: 'Jev 接口' }, { value: 'prompt', label: '翻译 Prompt' },
255 ]
256 const narrowSettings = e.props.bodyColumns < 42
257 const groups = settingsGroups(sections, e.props.bodyColumns)
258 const settingsRows = (narrowSettings ? 2 : 1) + groups.length + 1
259 const settingsHeader = <Box key="settings-header" position="absolute" top={e.props.scroll.offset} left={0} width={e.props.bodyColumns} height={settingsRows} flexDirection="column" backgroundColor="background">
260 <Box flexDirection={narrowSettings ? 'column' : 'row'} justifyContent="space-between" width={Math.max(1, e.props.bodyColumns - 4)}>
261 <Text bold>翻译设置</Text>
262 <Box flexDirection="row" columnGap={1}>
263 <Button key="cancel-settings" label="取消" onPress={() => { draft = {}; page = ''; notice = ''; needsFollow = true; redraw($) }} />
264 <Button key="save-settings" label="保存" onPress={() => save($, draft)} />
265 </Box>
266 </Box>
267 {groups.map((group, index) => <Box key={`settings-nav-${index}`} flexDirection="row" columnGap={1} height={1}>
268 {group.map(section => <Button key={`tab-${section.value}`} label={(tab === section.value ? '● ' : ' ') + section.label} onPress={() => { tab = section.value; settingsStart = true; notice = ''; redraw($) }} />)}
269 </Box>)}
270 <Text dimColor wrap="truncate">{'当前检测:' + ({ script: '本地脚本', llm: 'LLM', jev: 'Jev' }[cfg.detection] || cfg.detection) + ' · 模型:' + (cfg.effectiveModel || cfg.model || '未配置')}</Text>
271 </Box>
272 if (page === 'settings') return <Box key="settings" flexDirection="column" width={e.props.bodyColumns} minHeight={e.props.scroll.bodyRows}>
273 <Box key="settings-body" flexDirection="column" paddingTop={settingsRows}>
274 {notice && <Text>{notice}</Text>}
275 {tab === 'language' && <Box flexDirection="column">
276 <Select key="enabled" label="自动翻译" value={draft.enabled ? 'on' : 'off'} options={[{ value: 'on', label: '开启' }, { value: 'off', label: '关闭' }]} onSelect={value => { set('enabled', value === 'on'); redraw($) }} />
277 <Select key="incomingLanguage" label="Agent 接收" value={String(draft.incomingLanguage)} options={languageOptions(String(draft.incomingLanguage))} onSelect={value => { set('incomingLanguage', value); redraw($) }} />
278 <Select key="outgoingLanguage" label="我的阅读" value={String(draft.outgoingLanguage)} options={languageOptions(String(draft.outgoingLanguage))} onSelect={value => { set('outgoingLanguage', value); redraw($) }} />
279 {field('incomingLanguage', '自定义接收代码', 'en / zh / ja')}
280 {field('outgoingLanguage', '自定义阅读代码', 'en / zh / ja')}
281 <Select key="detection" label="语言检测" value={String(draft.detection)} options={[{ value: 'script', label: '本地脚本' }, { value: 'llm', label: 'LLM(如 Gemini)' }, { value: 'jev', label: 'Jev' }]} onSelect={value => { set('detection', value); redraw($) }} />
282 {draft.detection === 'llm' && field('detectionModel', '检测模型', '留空复用翻译模型;也可填写 Gemini 型号')}
283 <Text dimColor>脚本在本机判断,无检测接口等待。LLM 检测使用「模型接口」的地址和密钥,检测模型可以单独填写;Jev 有独立接口。</Text>
284 <Text dimColor>发送前译文不会另占一行;右侧译文保留左侧原回复。</Text>
285 </Box>}
286 {tab === 'llm' && <Box flexDirection="column">
287 <Text bold>翻译模型 · Chat Completions</Text>
288 <Text dimColor>这些值保存在本机,不在插件代码中;新安装不预填你的接口和模型。</Text>
289 {sourcePicker('llmConfigSource')}
290 <Text>{'当前生效:' + (cfg.effectiveModel || '未配置') + ' · ' + sourceName(cfg.sources?.model)}</Text>
291 {keySummary('apiKey', 'apiKeyEnv')}
292 {draft.llmConfigSource === 'env' ? environmentFields('llm') : <Box flexDirection="column">
293 {field('baseUrl', '接口 Base URL', 'https://provider.example/v1')}
294 {field('model', '翻译模型名', '填写你的服务商提供的型号')}
295 {field('apiKey', '新的 API Key', cfg.hasApiKey ? '留空不更换本机已保存的密钥' : '填入密钥')}
296 <Button key="clear-api-key" plain label="清除已保存密钥" onPress={() => { set('clearApiKey', true); set('apiKey', ''); notice = '保存后清除密钥'; redraw($) }} />
297 {field('postUrl', '完整 POST URL(可选)', '填写时替代 Base URL')}
298 <Text dimColor>本机没有保存密钥时才读取绑定变量。输入框留空不会清除已存密钥。新输入的密钥会显示;保存后仅显示末四位。</Text>
299 </Box>}
300 </Box>}
301 {tab === 'env' && <Box flexDirection="column">
302 <Text bold>环境变量绑定</Text>
303 <Text dimColor>这里填写变量的名字,不是 URL 或密钥本身。变量由启动 Claude 的终端提供。</Text>
304 <Text bold>{'翻译接口 · ' + (draft.llmConfigSource === 'env' ? '读取环境变量' : '本机填写优先')}</Text>
305 {environmentFields('llm')}
306 <Text bold>{'Jev 检测 · ' + (draft.jevConfigSource === 'env' ? '读取环境变量' : '本机填写优先')}</Text>
307 {environmentFields('jev')}
308 <Button key="suggest-env-names" label="填入推荐变量名" onPress={() => { for (const [key, name] of Object.entries(envNames)) if (!draft[key]) set(key, name); redraw($) }} />
309 <Text dimColor>绑定名字不会自动创建变量,也不会切换配置来源。请在模型接口或 Jev 页选择「读取环境变量」。</Text>
310 <Text dimColor>例:export OPENAI_MODEL="服务商模型名"。修改终端环境后,需重启 Claude。</Text>
311 </Box>}
312 {tab === 'jev' && <Box flexDirection="column">
313 <Text bold>Jev · 只判断语言,不生成译文</Text>
314 <Text>{draft.detection === 'jev' ? '当前启用 Jev 检测。' : '当前未启用;在「语言检测」中选 Jev 后才会调用。'}</Text>
315 {sourcePicker('jevConfigSource')}
316 <Text dimColor>{'当前模型来源:' + sourceName(cfg.sources?.jevModel)}</Text>
317 {keySummary('jevKey', 'jevKeyEnv')}
318 {draft.jevConfigSource === 'env' ? environmentFields('jev') : <Box flexDirection="column">
319 {field('jevUrl', 'Jev POST URL', 'https://api.typesafe.ai/v1/systemone')}
320 {field('jevModel', 'Jev 模型名', 'jev-latest(官方示例)')}
321 {field('jevKey', '新的 Jev API Key', cfg.hasJevKey ? '留空不更换本机已保存的密钥' : '填入密钥')}
322 <Button key="clear-jev-key" plain label="清除已保存 Jev 密钥" onPress={() => { set('clearJevKey', true); set('jevKey', ''); notice = '保存后清除密钥'; redraw($) }} />
323 <Text dimColor>默认地址和 jev-latest 是官方公开示例;你的网关配置只保存在本机。环境变量请在「环境变量」页绑定。</Text>
324 </Box>}
325 </Box>}
326 {tab === 'prompt' && <Box flexDirection="column">
327 <Text dimColor>分别设置两个方向的系统提示词。变量:{'{target_language}'}、{'{source_language}'}。</Text>
328 {field('incomingPrompt', '发送前')}
329 {field('outgoingPrompt', '回复')}
330 <Text dimColor>代码与链接占位符必须保留;不要在 Prompt 中填写密钥。</Text>
331 </Box>}
332 </Box>
333 {settingsHeader}
334 </Box>
335 const translating = !!job && (job.cursor < job.source.length || !job.done) && cfg.enabled
336 const text = job ? job.pieces.join('') + job.pending : ''
337 const content = text || (cfg.enabled ? '等待 Agent 回复…' : '翻译已关闭,点击设置配置并开启。')
338 // Let Claude measure and scroll its own Markdown. A header placed at the
339 // native offset stays pinned without guessing the rendered line heights.
340 return <Box key="translation-pane" flexDirection="column" width={e.props.bodyColumns} minHeight={e.props.scroll.bodyRows}>
341 <Box key="translation-body" flexDirection="column" paddingTop={4}>
342 {markdownParts(content, e.props.bodyColumns).map((part, i) => part.plain
343 ? <Text key={`markdown-${i}`}>{part.text}</Text>
344 : <Markdown key={`markdown-${i}`} text={part.text} />)}
345 <Text dimColor>{job?.error || notice || '滚轮翻阅'}</Text>
346 </Box>
347 <Box key="sticky-header" position="absolute" top={e.props.scroll.offset} left={0} width={e.props.bodyColumns} height={4} flexDirection="column" backgroundColor="background">
348 <Box key="header" flexDirection="row" justifyContent="space-between" height={1} width={Math.max(1, e.props.bodyColumns - 4)}>
349 <Text bold>Translation</Text><Button key="settings-button" plain label="设置" onPress={settings} />
350 </Box>
351 <Select key="agent-language" label="Agent 接收" value={cfg.incomingLanguage} options={languageOptions(cfg.incomingLanguage)} onSelect={value => save($, { incomingLanguage: value })} />
352 <Select key="reader-language" label="我的阅读" value={cfg.outgoingLanguage} options={languageOptions(cfg.outgoingLanguage)} onSelect={value => save($, { outgoingLanguage: value })} />
353 <Box flexDirection="row" justifyContent="space-between" height={1}>
354 <Text dimColor>{job?.error ? '翻译失败' : !cfg.enabled ? '已关闭' : translating ? '翻译中…' : job?.source && job.skipped ? '已是目标语言' : '译文'}</Text>
355 {failedPrompts.length > 0 && <Button key="recover-input" plain label="恢复输入" onPress={async () => {
356 const current = await $.prompt.read()
357 const result = await $.prompt.fill({ text: (current.text ? '\n\n' : '') + failedPrompts.join('\n\n'), mode: 'append' })
358 if (result.isFilled) failedPrompts = []
359 redraw($)
360 }} />}
361 <Button key="retry" plain label={job?.error ? '重试' : '回到底部'} onPress={() => { if (job?.error) reset(job.source, job.done); follow = true; needsFollow = true; redraw($) }} />
362 </Box>
363 </Box>
364 </Box>
365 })
366}
367hooks/core.ts 119 lines1import { responsiveTables } from './tables'
2
3export type Config = {
4 configPath?: string; keyInfo?: Record<string, { active: string; saved: string }>;
5 sources?: Record<string, string>; envStatus?: Record<string, boolean>; effectiveJevModel?: string;
6 jevConfigSource?: string; jevUrlEnv?: string; jevModelEnv?: string;
7 llmConfigSource?: string; detectionModel?: string; effectiveModel?: string;
8 enabled: boolean; incomingLanguage: string; outgoingLanguage: string; detection: string;
9 baseUrl: string; postUrl: string; model: string; baseUrlEnv: string; apiKeyEnv: string; modelEnv: string;
10 jevUrl: string; jevKeyEnv: string; jevModel: string; incomingPrompt: string; outgoingPrompt: string;
11 hasApiKey: boolean; hasJevKey: boolean; ready: boolean;
12}
13export const languages = [
14 ['en', 'English'], ['zh', '简体中文'], ['zh-TW', '繁體中文'], ['ja', '日本語'], ['ko', '한국어'],
15 ['fr', 'Français'], ['de', 'Deutsch'], ['es', 'Español'], ['pt', 'Português'], ['it', 'Italiano'],
16 ['ru', 'Русский'], ['ar', 'العربية'], ['vi', 'Tiếng Việt'],
17].map(([value, label]) => ({ value: value!, label: label! }))
18export function languageOptions(value: string) {
19 return languages.some(item => item.value === value) ? languages : [...languages, { value, label: value }]
20}
21export function cleanText(text: string): string {
22 return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
23}
24function cells(char: string): number {
25 if (/\p{Mark}/u.test(char)) return 0
26 const n = char.codePointAt(0)!
27 return n >= 0x1100 && (n <= 0x115f || n >= 0x2e80 && n <= 0xa4cf || n >= 0xac00 && n <= 0xd7a3 || n >= 0xf900 && n <= 0xfaff || n >= 0xfe10 && n <= 0xfe6f || n >= 0xff00 && n <= 0xff60 || n >= 0x1f000) ? 2 : 1
28}
29export function wrapLines(text: string, columns: number): string[] {
30 const lines: string[] = []
31 for (const raw of cleanText(text).replace(/\t/g, ' ').split('\n')) {
32 let line = '', width = 0
33 for (const char of raw) {
34 const size = cells(char)
35 if (width + size > Math.max(2, columns) && line) { lines.push(line); line = ''; width = 0 }
36 line += char; width += size
37 }
38 lines.push(line)
39 }
40 return lines
41}
42// Batch the backlog, rather than issuing detection + translation per paragraph.
43// Live batches end at a complete line/sentence outside a fence; a finished
44// ordinary reply is translated in one streaming request.
45export function nextSegment(source: string, cursor: number, done: boolean): string | undefined {
46 const tail = source.slice(cursor)
47 if (!tail) return undefined
48 const limit = done ? 12000 : 1600
49 if (done && tail.length <= limit) return tail
50 let fence = '', candidate = 0, index = 0
51 for (const line of tail.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
52 if (index + line.length > limit && candidate >= 160) break
53 const match = /^ {0,3}(`{3,}|~{3,})/.exec(line)
54 if (match) {
55 if (!fence) fence = match[1]!
56 else if (match[1]![0] === fence[0] && match[1]!.length >= fence.length && !line.trim().slice(match[1]!.length).trim()) fence = ''
57 }
58 index += line.length
59 if (!fence && line.endsWith('\n')) candidate = index
60 if (index >= limit && candidate >= 160) break
61 }
62 if (candidate >= 160) return tail.slice(0, candidate)
63 if (done) return tail
64 if (!fence && tail.length >= 160) {
65 const sentence = /^([\s\S]{160,}[.!?。!?](?:\s+|(?=[\u3400-\u9fff])))/.exec(tail.slice(0, limit))
66 if (sentence) return sentence[1]
67 }
68 return undefined
69}
70
71export function isControlPrompt(text: string): boolean {
72 return /^\s*\/[A-Za-z][\w:-]*(?:\s|$)/.test(text) || /^\s*!(?!\[)/.test(text)
73}
74
75// Native Markdown leaves accept at most 10,000 characters. Keep ordinary
76// paragraphs, lists and fenced blocks intact. Oversized blocks fall back to
77// bounded Text leaves so a long reply cannot invalidate the whole pane.
78export function markdownParts(source: string, columns?: number): { text: string; plain: boolean }[] {
79 const clean = cleanText(source)
80 const text = columns === undefined ? clean : responsiveTables(clean, columns, value => Array.from(value).reduce((sum, char) => sum + cells(char), 0))
81 const blocks: string[] = []
82 let block = '', fence = ''
83 for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
84 const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
85 if (marker) {
86 if (!fence) fence = marker
87 else if (marker[0] === fence[0] && marker.length >= fence.length && !line.trim().slice(marker.length).trim()) fence = ''
88 }
89 block += line
90 if (!fence && !line.trim()) { blocks.push(block); block = '' }
91 }
92 if (block) blocks.push(block)
93 const parts: { text: string; plain: boolean }[] = []
94 for (const value of blocks) {
95 const previous = parts[parts.length - 1]
96 if (previous && !previous.plain && previous.text.length + value.length <= 9000) previous.text += value
97 else if (value.length <= 9000) parts.push({ text: value, plain: false })
98 else for (let index = 0; index < value.length;) {
99 let end = Math.min(index + 9000, value.length)
100 if (end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1]!)) end--
101 parts.push({ text: value.slice(index, end), plain: true }); index = end
102 }
103 }
104 return parts
105}
106
107export function settingsGroups<T extends { label: string }>(items: T[], columns: number): T[][] {
108 const groups: T[][] = []
109 let group: T[] = [], used = 0
110 for (const item of items) {
111 // Four cells for the native button brackets/padding, two for its active marker.
112 const width = Array.from(item.label).reduce((sum, char) => sum + cells(char), 6)
113 if (group.length && used + 1 + width > columns) { groups.push(group); group = []; used = 0 }
114 used += (group.length ? 1 : 0) + width; group.push(item)
115 }
116 if (group.length) groups.push(group)
117 return groups
118}
119hooks/tables.ts 79 lines1// The terminal's native Markdown table measures the transcript, not its containing
2// mod pane. Keep grids that fit, and turn wider tables into labelled records.
3// Work on display text only: model prompts, translations and code stay unchanged.
4export function tableCells(line: string): string[] | undefined {
5 if (/^(?: {4}|\t)/.test(line)) return undefined
6 const source = line.trim()
7 const cells: string[] = []
8 let cell = '', pipes = 0
9 for (let i = 0; i < source.length; i++) {
10 const char = source[i]!
11 if (char === '\\' && i + 1 < source.length) { cell += char + source[++i]; continue }
12 if (char === '|') { cells.push(cell.trim()); cell = ''; pipes++ }
13 else cell += char
14 }
15 if (!pipes) return undefined
16 cells.push(cell.trim())
17 if (source.startsWith('|')) cells.shift()
18 // An escaped final pipe belongs to the last cell, not the outside border.
19 if (cell === '' && source.endsWith('|')) cells.pop()
20 return cells
21}
22
23export function responsiveTables(source: string, columns: number, width: (text: string) => number): string {
24 const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? []
25 const result: string[] = []
26 let fence = ''
27 for (let i = 0; i < lines.length; i++) {
28 const line = lines[i]!
29 const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
30 if (marker) {
31 if (!fence) fence = marker
32 else if (marker[0] === fence[0] && marker.length >= fence.length && !line.trim().slice(marker.length).trim()) fence = ''
33 result.push(line); continue
34 }
35 const headers = !fence && !/^\s*(?:>|#{1,6}\s|[-+*]\s|\d+[.)]\s)/.test(line) ? tableCells(line) : undefined
36 const delimiters = headers && tableCells(lines[i + 1] ?? '')
37 if (!headers?.length || !delimiters || delimiters.length !== headers.length || !delimiters.every(cell => /^:?-+:?$/.test(cell))) {
38 result.push(line); continue
39 }
40 const rows: string[][] = []
41 let end = i + 2
42 while (end < lines.length) {
43 const row = tableCells(lines[end]!)
44 // Do not mistake a code fence, list or block quote for a data row.
45 if (!row || /^\s*(?:`{3}|~{3}|>|#{1,6}\s|[-+*]\s|\d+[.)]\s)/.test(lines[end]!)) break
46 rows.push(row); end++
47 }
48 // Counting raw inline Markdown is conservative: never underestimate a cell
49 // because of code, link destinations, CJK characters or escaped delimiters.
50 const count = Math.max(headers.length, ...rows.map(row => row.length))
51 const widths = Array.from({ length: count }, (_, col) => Math.max(3, width(headers[col] ?? ''), ...rows.map(row => width(row[col] ?? ''))))
52 const fits = widths.reduce((sum, value) => sum + value + 3, 1) <= columns
53 if (fits) {
54 // Keep large tables under the native Markdown leaf limit by repeating
55 // their header at row boundaries, instead of falling back to raw pipes.
56 const heading = line + lines[i + 1]!
57 let chunk = heading
58 for (let j = i + 2; j < end; j++) {
59 if (chunk.length + lines[j]!.length > 8000) { result.push(chunk + '\n'); chunk = heading }
60 chunk += lines[j]!
61 }
62 result.push(chunk)
63 } else {
64 const separator = '─'.repeat(Math.max(1, Math.floor(columns)))
65 const records = rows.length ? rows : [headers.map(() => '')]
66 result.push('\n' + records.map(row => {
67 const fields = Array.from({ length: Math.max(headers.length, row.length) }, (_, col) => {
68 const label = headers[col] || `第 ${col + 1} 列`
69 return `${label}: ${row[col] || '—'}`
70 })
71 // Hard line breaks keep one field per line; blank lines separate records.
72 return fields.join(' \n')
73 }).join(`\n\n${separator}\n\n`) + '\n\n')
74 }
75 i = end - 1
76 }
77 return result.join('')
78}
79types/index.d.ts 7 lines1export type OriginalPrompts = Record<string, string>
2declare module 'claude-code' {
3 interface PluginState {
4 'translate-view': { originals: OriginalPrompts }
5 }
6}
7