SLOPSHOPPER

translate-view

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

newpanerowscommandpromptprocess
★ 58v0.1.6MITupdated 2026-10-06Yrzhe/claude-skills/plugins/translate-view
A shopper browsing a rack in a slop shop
README

Translate View

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」。打开设置会回到语言检测首页,并显示当前生效的检测方式和翻译模型。填好接口后开启自动翻译并保存。

语言与检测

  • 本地脚本:不发送检测请求。根据文字与常用词判断,适合简单中英文;混合语言、繁简体、短句与部分语种可能判断不准。不确定时仍会交给翻译模型。
  • LLM(如 Gemini):使用「模型接口」页的地址和密钥先判断语言,再按需翻译。「检测模型」可单独填写型号,留空复用翻译模型。选择 Gemini 时需要提供支持该型号的 Chat Completions 兼容接口;不能仅凭服务商列出的模型名判断协议兼容性。
  • Jev:使用独立的 TypeSafe System One 接口判断语言,实际翻译仍由 「模型接口」页的模型完成。支持兼容该请求/响应格式的网关。低置信度判断按不确定处理。

可选常见语言,也可在「自定义接收/阅读代码」填写 en、zh、ja 等语言代码。

模型接口与环境变量

使用兼容 Chat Completions 的接口:

  • Base URL:包含服务商要求的版本前缀,例如 https://provider.example/v1。仅自动追加 /chat/completions。
  • POST URL:完整地址,填写时优先于 Base URL。
  • 模型名:填写该服务实际支持 Chat Completions 的模型/别名。能列出模型不代表其支持该协议。
  • API Key:使用 Authorization: Bearer ...。本地无认证接口可留空,并清空 Key 环境变量。
  • 环境变量:可分别填写 URL、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

Jev 只判断语言,不生成译文;选择其他检测方式时不会调用它。其配置来源独立于翻译模型:

  • 「本机填写优先」:填写 POST URL、Jev 模型名和 API Key。默认公开接口为 TypeSafe 的 /v1/systemone,模型示例为 jev-latest;自建网关可以更换。
  • 「读取环境变量」:填写变量名,默认推荐 TYPESAFE_POST_URL、TYPESAFE_MODEL、TYPESAFE_API_KEY,已保存的手填值会保留但不参与这一路请求。

使用 TypeSafe System One 请求格式,请求包含 state、model、questions,读取 answers.language.choice 和 confidence。

看懂配置来源

设置会标出当前生效模型与字段来源:本机填写、环境变量或未配置。模型/地址输入框里的实值来自这台机器的私有配置;公开插件不包含你的私有地址、密钥或已选择的翻译型号。

环境变量页填写的是名字,不是变量值。界面逐项显示「已读取」「当前进程未设置」或「尚未绑定」,不显示环境变量的完整内容;密钥身份仅显示末四位。灰色示例不代表已经绑定;「填入推荐变量名」只填写名字,不创建变量、不写入密钥,也不自动切换配置来源。新的变量名保存后检查。

用途推荐变量名
翻译 Base URLOPENAI_BASE_URL
翻译 API KeyOPENAI_API_KEY
翻译模型名OPENAI_MODEL
Jev POST URLTYPESAFE_POST_URL
Jev API KeyTYPESAFE_API_KEY
Jev 模型名TYPESAFE_MODEL

可以改用自己的变量名。变量需要由启动 Claude 的终端导出;给变量名并不会让插件自动扫描电脑上的其他密钥文件。

自定义 Prompt

「翻译 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 保存的原始会话记录包含译后的输入。重启/恢复旧会话后不保证恢复译前显示。显示原文的内存缓存最多 100 条、约 200000 字符。
  • 右侧显示最新一轮的回复。生成中的文字可提前翻译,后续工具步骤/最终答案改变时刷新;只显示主 Agent 文本。
  • 译文使用 Claude 原生 Markdown 渲染。受原生组件长度限制,超过 9000 字符的单个连续块(如超长代码块)会分片按纯文本显示,避免整个面板失效。
  • 单次输入/翻译片段最多 100000 字符;接口响应最多约 2 MB。非常大的单个代码块/段落应拆分。
  • 本地语言检测是启发式;模型翻译和检测也可能出错。
  • 新终端、不同字体及窗口尺寸的最终视觉效果仍取决于 Claude 的渲染器。CLI 是支持目标;VS Code 聊天面板、Claude Desktop 和 headless 模式不提供此右侧 UI。

开发与验证

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,实际拖动侧栏验证宽表、窄屏记录和恢复表格,使用模拟接口,不调用真实模型。

Source 4 files
hooks/register.tsx 367 lines
1import { 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}
367
hooks/core.ts 119 lines
1import { 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}
119
hooks/tables.ts 79 lines
1// 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}
79
types/index.d.ts 7 lines
1export type OriginalPrompts = Record<string, string>
2declare module 'claude-code' {
3  interface PluginState {
4    'translate-view': { originals: OriginalPrompts }
5  }
6}
7