cctrans 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.hi.md +94 -36
- package/README.ja.md +94 -36
- package/README.ko.md +94 -36
- package/README.md +95 -37
- package/README.ru.md +95 -37
- package/README.zh-Hans.md +94 -36
- package/README.zh-Hant.md +94 -36
- package/ROADMAP.md +4 -1
- package/bin/cctrans.js +56 -13
- package/hook/message-display.js +76 -23
- package/hook/user-prompt-submit.js +8 -4
- package/package.json +2 -2
- package/src/config.js +23 -2
- package/src/interleave.js +160 -7
- package/src/langs.js +5 -5
- package/src/setup.js +40 -13
package/README.zh-Hant.md
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# cctrans
|
|
2
4
|
|
|
5
|
+
**用母語讀 Claude Code——token 按英文計費。**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/cctrans)
|
|
8
|
+
[](https://www.npmjs.com/package/cctrans)
|
|
9
|
+
[](https://github.com/roy-jiang-opus/cctrans)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](package.json)
|
|
12
|
+
|
|
3
13
|
[English](README.md) | [简体中文](README.zh-Hans.md) | **繁體中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Русский](README.ru.md) | [हिन्दी](README.hi.md)
|
|
4
14
|
|
|
5
|
-
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
6
18
|
|
|
7
19
|
```
|
|
8
20
|
● I will refactor the auth module to use async tokens.
|
|
@@ -11,11 +23,42 @@
|
|
|
11
23
|
↳ 這會影響 3 個檔案並加入重試層。
|
|
12
24
|
```
|
|
13
25
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
26
|
+
為 Claude Code 加上一層**雙語對照**:每行英文下方一行譯文(中/日/韓/俄/印地),**就在對話裡**——僅作顯示,轉錄、模型上下文和你的 token 帳單 100% 保持英文。
|
|
27
|
+
|
|
28
|
+
## ✨ 特性
|
|
29
|
+
|
|
30
|
+
- 🪞 **行內雙語顯示** —— 譯文隨回覆串流出現在每行英文下方,就在對話裡
|
|
31
|
+
- 🧩 **兩種排版** —— 逐行對照,或 `cctrans mode section`:整塊英文先出,再跟一段成組譯文
|
|
32
|
+
- 🧾 **非破壞性** —— 轉錄與模型上下文保持純英文;skills、文件、程式碼不受影響
|
|
33
|
+
- 🆓 **主對話零 token** —— 翻譯走獨立低成本後端(也有免費選項),完全在 Claude Code 工作階段之外
|
|
34
|
+
- ⌨️ **輸入翻譯(beta)** —— 用母語打字,模型按英文工作、按英文回覆(`cctrans input on`)
|
|
35
|
+
- 🌏 **6 種目標語言** —— `zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi`
|
|
36
|
+
- 🔌 **6 個後端自動降級** —— OpenAI / Anthropic / DeepL / Azure / 免費 Google / 你自己的 Claude 訂閱
|
|
37
|
+
- 🔒 **金鑰隔離** —— API key 只存在 chmod-600 的檔案裡,從不讀終端機環境變數
|
|
38
|
+
- 🛟 **故障安全** —— 任何錯誤或逾時都回退為純英文,絕不卡住工作階段
|
|
39
|
+
|
|
40
|
+
## 🚀 快速開始
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install -g cctrans && cctrans install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
安裝會註冊鉤子並引導你完成設定(語言 → 後端 → API key → 即時驗證)。然後**重新啟動 Claude Code**——回覆變成雙語。隨時在 Claude Code 輸入框輸入 `!cctrans off` / `!cctrans on` 開關(`!` 是 CC 內建 bash 模式,不呼叫模型、不花 token)。
|
|
47
|
+
|
|
48
|
+
<details>
|
|
49
|
+
<summary>從原始碼安裝</summary>
|
|
17
50
|
|
|
18
|
-
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/roy-jiang-opus/cctrans.git
|
|
53
|
+
cd cctrans
|
|
54
|
+
node bin/cctrans.js install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
需要 `~/.local/bin` 在 PATH 中,或使用別名:`alias cctrans='node /path/to/cctrans/bin/cctrans.js'`
|
|
58
|
+
|
|
59
|
+
</details>
|
|
60
|
+
|
|
61
|
+
## 🤔 為什麼做這個
|
|
19
62
|
|
|
20
63
|
兩個痛點,一個架構解決:
|
|
21
64
|
|
|
@@ -33,7 +76,7 @@ Anthropic 關於按語言調整額度的 issue([#26401](https://github.com/anthr
|
|
|
33
76
|
|
|
34
77
|
完整調研資料與來源:[MOTIVATION.md](MOTIVATION.md)。
|
|
35
78
|
|
|
36
|
-
## 運作原理
|
|
79
|
+
## ⚙️ 運作原理
|
|
37
80
|
|
|
38
81
|
利用 Claude Code 原生的 **`MessageDisplay` 鉤子**(v2.1.152+):它在每則助理訊息渲染時觸發,把完成的文字片段(`delta`)交給鉤子;鉤子回傳的 `displayContent` **只替換螢幕顯示**,不改變儲存的訊息。
|
|
39
82
|
|
|
@@ -50,41 +93,47 @@ Claude 串流輸出英文
|
|
|
50
93
|
|
|
51
94
|
> 已在 CC 2.1.169 實測:`delta` 是**互不重疊**的已完成片段(不是累積文字),普通 `\n` 即可讓兩種語言分行顯示,程式碼區塊/路徑/已是目標語言的行會自動跳過。
|
|
52
95
|
|
|
53
|
-
##
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
npm install -g cctrans && cctrans install
|
|
57
|
-
|
|
58
|
-
# from source:
|
|
59
|
-
git clone https://github.com/roy-jiang-opus/cctrans.git
|
|
60
|
-
cd cctrans
|
|
61
|
-
node bin/cctrans.js install # 註冊鉤子、連結 cctrans 到 ~/.local/bin,然後執行 setup 精靈
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
接著**重新啟動 Claude Code**(開新工作階段)讓鉤子生效。送出任意訊息,回覆就會雙語對照。
|
|
65
|
-
|
|
66
|
-
> 需要 `~/.local/bin` 在 PATH 中;否則使用別名:
|
|
67
|
-
> `alias cctrans='node /path/to/cctrans/bin/cctrans.js'`
|
|
68
|
-
|
|
69
|
-
## 使用
|
|
96
|
+
## 🎛 指令
|
|
70
97
|
|
|
71
98
|
| 指令 | 作用 |
|
|
72
99
|
|------|------|
|
|
73
100
|
| `cctrans on` / `cctrans off` / `cctrans toggle` | 開 / 關 / 切換翻譯 |
|
|
74
101
|
| `cctrans status` | 檢視狀態(開關、鉤子、後端、語言) |
|
|
75
102
|
| `cctrans lang [code]` | 檢視/切換目標語言:`zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
|
|
103
|
+
| `cctrans mode [line\|section]` | 排版:譯文跟在每行下方,或按區塊成組 |
|
|
76
104
|
| `cctrans backend <id>` | 切換翻譯引擎 |
|
|
77
105
|
| `cctrans backends` | 列出所有引擎及其可用性 |
|
|
78
|
-
| `cctrans setup` |
|
|
106
|
+
| `cctrans setup` | 互動式精靈:語言、顯示模式、後端、API key |
|
|
79
107
|
| `cctrans key [id] [value]` | 管理 `~/.cc-translate/keys.json` 中的 API key |
|
|
80
|
-
| `cctrans input on` / `cctrans input off` | 把非英文輸入翻譯成英文(作為上下文傳給模型) |
|
|
108
|
+
| `cctrans input on` / `cctrans input off` | **(beta)** 把非英文輸入翻譯成英文(作為上下文傳給模型) |
|
|
109
|
+
| `cctrans input threshold <n>` | 觸發輸入翻譯的非拉丁字元數(預設 4) |
|
|
81
110
|
| `cctrans last [N]` | 把最近(或往前第 N 則)回覆翻譯到終端機 |
|
|
82
111
|
| `cctrans test <文字>` | 翻譯一段文字,驗證引擎 |
|
|
83
112
|
| `cctrans install` / `cctrans uninstall` | 註冊 / 移除鉤子 |
|
|
84
113
|
|
|
85
|
-
|
|
114
|
+
## 🧩 顯示模式
|
|
115
|
+
|
|
116
|
+
`line`(預設)逐行對照:每行英文下方一行譯文,隨回覆串流出現。`section` 讓英文完全按 Claude 的串流輸出原樣呈現,在**一個區塊完成時**插入一段成組譯文——對列表很多的回覆要安靜得多:
|
|
86
117
|
|
|
87
|
-
|
|
118
|
+
```
|
|
119
|
+
Use these flags:
|
|
120
|
+
↳ 使用以下参数:
|
|
121
|
+
|
|
122
|
+
- Enable the cache
|
|
123
|
+
- Set a small timeout
|
|
124
|
+
- Prefer the batch API
|
|
125
|
+
↳ 启用缓存
|
|
126
|
+
↳ 设置较短的超时
|
|
127
|
+
↳ 优先使用批量 API
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
cctrans mode section # 隨時切回:cctrans mode line
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
> section 模式下,一個區塊的譯文在**該區塊完成時**才出現,而不是邊串流邊出——後端慢時(如 `claude-code`,3–6 秒/次)這個停頓會比較明顯,所以這裡 API 後端體驗最好。某個區塊翻譯失敗時,英文不受影響,該區塊只是保持未翻譯。
|
|
135
|
+
|
|
136
|
+
## 🌐 翻譯後端
|
|
88
137
|
|
|
89
138
|
| 後端 | 前提 | 速度 | 品質 | 說明 |
|
|
90
139
|
|------|------|------|------|------|
|
|
@@ -101,7 +150,7 @@ API key **只**存放在 `~/.cc-translate/keys.json`(chmod 600)——用 `cctran
|
|
|
101
150
|
|
|
102
151
|
其餘設定(後端、語言、標記、模型、Azure 端點)都在 `~/.cc-translate/state.json` 中——用 `cctrans` 指令修改或直接編輯檔案。
|
|
103
152
|
|
|
104
|
-
## 多語言
|
|
153
|
+
## 🗣 多語言
|
|
105
154
|
|
|
106
155
|
目標語言支援 **CJK + 俄語 + 印地語**(非拉丁文字,可按 Unicode 區間零成本判斷「該行已是目標語言」並跳過):
|
|
107
156
|
|
|
@@ -116,19 +165,28 @@ cctrans lang zh-Hans # 簡體中文(預設)
|
|
|
116
165
|
|
|
117
166
|
中文採用 BCP-47 **文字碼**(`zh-Hans`/`zh-Hant`)——繁體是文字系統而非地區;`zh-CN` / `zh-TW` 仍可作為別名使用,會自動正規化。切換語言立即生效(鉤子每次呼叫都讀取狀態),不同語言的快取相互獨立。
|
|
118
167
|
|
|
119
|
-
## 輸入翻譯
|
|
168
|
+
## ⌨️ 輸入翻譯(beta)
|
|
120
169
|
|
|
121
|
-
`cctrans input on` 啟用 `UserPromptSubmit`
|
|
170
|
+
`cctrans input on` 啟用 `UserPromptSubmit` 鉤子:當你的輸入包含足夠多的非拉丁字元時(預設 4 個以上——按絕對數量計,檔案路徑和識別字不會稀釋觸發條件;用 `cctrans input threshold <n>` 調整),英文譯文會作為上下文附給模型並被視為權威指令,同時要求模型**用英文回覆**——這樣雙語 overlay 持續生效,對話上下文全程保持英文。(已在 CC 2.1.169 核實:鉤子無法改寫 prompt 本身,所以原文仍在歷史中,英文隨附。)英文輸入原樣通過;任何錯誤都安全回退為原樣送出。
|
|
122
171
|
|
|
123
|
-
|
|
172
|
+
> **Beta**:翻譯呼叫會在每條非英文輸入送出前阻塞約 0.5–1.5 秒。預設關閉;setup 精靈會詢問一次。回饋 → [issues](https://github.com/roy-jiang-opus/cctrans/issues)。
|
|
173
|
+
|
|
174
|
+
## 📏 行為與限制(已核實)
|
|
124
175
|
|
|
125
176
|
- 鉤子在**串流輸出中**按片段觸發,每段單獨翻譯並就地替換——所以譯文會隨英文逐段出現。
|
|
126
177
|
- 鉤子有 **10 秒**逾時;本工具內部 9 秒保底。任何錯誤/逾時/超長(>9000 字元)都會**安全回退成原始英文**,絕不卡住工作階段。
|
|
127
|
-
- 每行譯文按內容雜湊**快取**(`~/.cc-translate/cache`)
|
|
178
|
+
- 每行譯文按內容雜湊**快取**(`~/.cc-translate/cache`),重繪與重複文字零成本。兩種模式共享同一快取。
|
|
179
|
+
- section 模式下,進行中區塊的文字會緩衝在 `~/.cc-translate/msgstate`(落盤暴露面與快取相同);訊息完成後該檔案即刪除,逾期殘留檔案 24 小時後清理。
|
|
128
180
|
- 用 `openai` 時每段約一次 API 呼叫(~$0.0001),串流輸出會比純英文多約 1 秒/段的延遲;`google` 較快但品質略低。
|
|
129
181
|
|
|
130
|
-
##
|
|
182
|
+
## 🔗 關注專案
|
|
131
183
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
184
|
+
- ⭐ **Star / Watch** [github.com/roy-jiang-opus/cctrans](https://github.com/roy-jiang-opus/cctrans),第一時間獲取版本更新
|
|
185
|
+
- 📦 **npm** —— [npmjs.com/package/cctrans](https://www.npmjs.com/package/cctrans) · 升級:`npm update -g cctrans`
|
|
186
|
+
- 🗺 **路線圖** —— [ROADMAP.md](ROADMAP.md):已完成與計劃中的功能
|
|
187
|
+
- 📚 **調研** —— [MOTIVATION.md](MOTIVATION.md):本專案背後的非英語 token 稅資料
|
|
188
|
+
- 🐛 **Issue / 新語言請求** —— [github.com/roy-jiang-opus/cctrans/issues](https://github.com/roy-jiang-opus/cctrans/issues)
|
|
189
|
+
|
|
190
|
+
## 📄 授權條款
|
|
191
|
+
|
|
192
|
+
[MIT](LICENSE) © Roy Jiang
|
package/ROADMAP.md
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Shipped
|
|
4
4
|
|
|
5
|
+
### ✅ Section display mode — grouped translation per block
|
|
6
|
+
`cctrans mode section` (default stays `line`): English streams untouched, and a block's translation is spliced in as one grouped `↳` block when the block completes (blank line / code fence / already-target line / end of message) — much quieter for list-heavy replies. Design notes: section boundaries are properties of the **text**, never of delta chunking (deltas batch arbitrarily — verified live), which makes repaint replay byte-identical; the open block buffers in `~/.cc-translate/msgstate` (atomic writes, removed on message end, 24h GC); state is committed **before** translation, so a crash/timeout can only drop a block's translation, never misplace it. Headings close their own section (a displaced `## ↳ 译` would render as a real heading). Translation stays per-line, so both modes share the sha1 cache and the backends are untouched. The boundary machinery trivially supports a future `message` granularity (flush at final only).
|
|
7
|
+
|
|
5
8
|
### ✅ Input translation — write in your language, send in English
|
|
6
9
|
`cctrans input on` enables a `UserPromptSubmit` hook: prompts that are mostly non-English get an English translation attached as `additionalContext`, which the model treats as the canonical instruction. Implementation note: Claude Code hooks provably cannot rewrite the prompt itself (verified against the 2.1.169 **and 2.1.170** binaries — the `UserPromptSubmit`/`UserPromptExpansion` output schemas only allow `additionalContext` and block), so attach-as-context is the strongest available form; the original prompt stays in history with the English alongside. If a future CC release adds a prompt-rewrite field, switching to true replacement is a one-line change in `hook/user-prompt-submit.js`.
|
|
7
10
|
|
|
8
11
|
### ✅ Interactive setup wizard
|
|
9
|
-
`cctrans install` registers both hooks and launches the wizard; `cctrans setup` re-runs it anytime. Walks through target language → backend selection → key entry for the chosen backend → live translation verification. Non-interactive flags: `--lang`, `--backend`, `--key`, `--yes`.
|
|
12
|
+
`cctrans install` registers both hooks and launches the wizard; `cctrans setup` re-runs it anytime. Walks through target language → display mode → backend selection → key entry for the chosen backend → live translation verification. Non-interactive flags: `--lang`, `--mode`, `--backend`, `--key`, `--yes`.
|
|
10
13
|
|
|
11
14
|
### ✅ Per-tool API-key config (no env cross-pollution)
|
|
12
15
|
Keys live **only** in `~/.cc-translate/keys.json` (chmod 600, atomic writes), managed via `cctrans key <id> [value|--clear]`, the setup wizard, or direct file edits. Shell environment variables are **never** consulted — no overrides, no opt-in. All non-secret settings (backend, language, marker, models, Azure endpoint) live in `~/.cc-translate/state.json`. The only env vars the tool reads are internal plumbing: `CCTRANS_HOME` / `CCTRANS_TRANSCRIPT` (tests) and `CCTRANS_DISABLE` / `CCTRANS_DEBUG_STDIN` (hook internals).
|
package/bin/cctrans.js
CHANGED
|
@@ -13,8 +13,8 @@ const fs = require('fs');
|
|
|
13
13
|
const os = require('os');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
|
|
16
|
-
const { getState, setState, STATE_FILE } = require('../src/config');
|
|
17
|
-
const { buildDisplayContent } = require('../src/interleave');
|
|
16
|
+
const { getState, setState, STATE_FILE, MODES, sweepMsgState } = require('../src/config');
|
|
17
|
+
const { buildDisplayContent, planSections, renderSections } = require('../src/interleave');
|
|
18
18
|
const { findTranscript, extractReply } = require('../src/transcript');
|
|
19
19
|
const { listBackends, getBackend } = require('../src/backends');
|
|
20
20
|
const { getLang, listLangs, normalizeLang } = require('../src/langs');
|
|
@@ -117,7 +117,10 @@ function status() {
|
|
|
117
117
|
console.log(' hook : ' + (installed ? C.green('installed') : C.red('not installed') + C.dim(' (run: cctrans install)')));
|
|
118
118
|
console.log(' backend : ' + st.backend + (b ? (b.available() ? C.green(' (ready)') : C.red(' (missing: ' + b.needs + ')')) : C.red(' (unknown backend)')));
|
|
119
119
|
console.log(' lang : ' + st.target + (lang ? C.dim(' (' + lang.name + ')') : C.red(' (unsupported — see: cctrans lang)')));
|
|
120
|
-
console.log('
|
|
120
|
+
console.log(' mode : ' + st.mode + C.dim(st.mode === 'section'
|
|
121
|
+
? ' (grouped per block; translation appears when the block completes)'
|
|
122
|
+
: ' (translation under each English line)'));
|
|
123
|
+
console.log(' input : ' + (st.inputEn ? C.green('ON') : 'off') + C.dim(' (beta; prompt -> English; toggle: cctrans input on|off; triggers at ' + st.inputMinChars + '+ non-Latin chars)'));
|
|
121
124
|
console.log(' keys : ' + Object.keys(keys.readKeys()).length + ' in ' + keys.KEYS_FILE + C.dim(' (manage: cctrans key)'));
|
|
122
125
|
console.log(' state : ' + STATE_FILE);
|
|
123
126
|
}
|
|
@@ -150,17 +153,30 @@ function backends() {
|
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
function colorizeForTerminal(displayContent, marker) {
|
|
156
|
+
// Match the marker after optional structure prefixes too ("## ↳", "> ↳",
|
|
157
|
+
// list-indent " ↳") so grouped section blocks color consistently.
|
|
158
|
+
const zhLine = new RegExp('^\\s*(?:#{1,6}\\s+|(?:>\\s*)+)?\\s*' + marker.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
153
159
|
return displayContent
|
|
154
160
|
.split('\n')
|
|
155
|
-
.map((l) => (
|
|
161
|
+
.map((l) => (zhLine.test(l) ? C.cyan(l) : l))
|
|
156
162
|
.join('\n');
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
async function renderText(text) {
|
|
160
166
|
const st = getState();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
let displayContent;
|
|
168
|
+
if (st.mode === 'section') {
|
|
169
|
+
// The whole text is one final delta, so `cctrans test`/`last` exercise
|
|
170
|
+
// section mode end-to-end.
|
|
171
|
+
const planned = planSections(text, { inFence: false, buf: [], target: st.target, final: true });
|
|
172
|
+
displayContent = await renderSections(planned, {
|
|
173
|
+
target: st.target, backend: st.backend, model: st.model, marker: st.marker, timeoutMs: 12000,
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
displayContent = (await buildDisplayContent(text, {
|
|
177
|
+
target: st.target, backend: st.backend, model: st.model, marker: st.marker, timeoutMs: 12000,
|
|
178
|
+
})).displayContent;
|
|
179
|
+
}
|
|
164
180
|
if (displayContent == null) { process.stdout.write(text + '\n'); return; }
|
|
165
181
|
process.stdout.write(colorizeForTerminal(displayContent, st.marker) + '\n');
|
|
166
182
|
}
|
|
@@ -179,16 +195,18 @@ function help() {
|
|
|
179
195
|
|
|
180
196
|
${C.bold('Control')}
|
|
181
197
|
cctrans on | off | toggle turn the inline translation on/off
|
|
182
|
-
cctrans input on | off translate non-English input to English (as context)
|
|
198
|
+
cctrans input on | off (beta) translate non-English input to English (as context)
|
|
199
|
+
cctrans input threshold <n> non-Latin chars that trigger input translation (default 4)
|
|
183
200
|
cctrans status show current state
|
|
184
201
|
cctrans lang [code] show/set target language (zh-Hans, zh-Hant, ja, ko, ru, hi)
|
|
202
|
+
cctrans mode [line|section] layout: translation under each line, or grouped per block
|
|
185
203
|
cctrans backend <id> choose translation engine
|
|
186
204
|
cctrans backends list engines + availability
|
|
187
205
|
|
|
188
206
|
${C.bold('Setup')}
|
|
189
207
|
cctrans install register hooks (+ link cctrans), then run setup
|
|
190
|
-
cctrans setup interactive wizard: language, backend, API keys
|
|
191
|
-
(flags: --lang --backend --key --yes)
|
|
208
|
+
cctrans setup interactive wizard: language, display mode, backend, API keys
|
|
209
|
+
(flags: --lang --mode --backend --key --input --yes)
|
|
192
210
|
cctrans key [id] [value] manage API keys in ~/.cc-translate/keys.json
|
|
193
211
|
(ids: openai, anthropic, deepl, azure, azure-region)
|
|
194
212
|
cctrans uninstall remove the hooks
|
|
@@ -203,8 +221,8 @@ ${C.dim('Tip: toggle from inside Claude Code by typing !cctrans off / !cctran
|
|
|
203
221
|
async function main() {
|
|
204
222
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
205
223
|
switch (cmd) {
|
|
206
|
-
case 'on': setState({ enabled: true }); console.log(C.green('✓ translation ON')); break;
|
|
207
|
-
case 'off': setState({ enabled: false }); console.log('✓ translation ' + C.red('OFF')); break;
|
|
224
|
+
case 'on': setState({ enabled: true }); sweepMsgState(24 * 60 * 60 * 1000); console.log(C.green('✓ translation ON')); break;
|
|
225
|
+
case 'off': setState({ enabled: false }); sweepMsgState(0); console.log('✓ translation ' + C.red('OFF')); break;
|
|
208
226
|
case 'toggle': { const s = getState(); const n = setState({ enabled: !s.enabled }); console.log('✓ translation ' + (n.enabled ? C.green('ON') : C.red('OFF'))); break; }
|
|
209
227
|
case 'backend': {
|
|
210
228
|
const id = rest[0];
|
|
@@ -218,6 +236,20 @@ async function main() {
|
|
|
218
236
|
break;
|
|
219
237
|
}
|
|
220
238
|
case 'backends': backends(); break;
|
|
239
|
+
case 'mode': {
|
|
240
|
+
const m = rest[0];
|
|
241
|
+
if (!m) {
|
|
242
|
+
const st = getState();
|
|
243
|
+
console.log('mode = ' + st.mode + C.dim(' (available: line — translation under each English line; section — grouped per block)'));
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
if (!MODES.includes(m)) { console.error('usage: cctrans mode <' + MODES.join('|') + '>'); process.exit(1); }
|
|
247
|
+
setState({ mode: m });
|
|
248
|
+
console.log(C.green('✓') + ' mode = ' + m + C.dim(m === 'section'
|
|
249
|
+
? ' (English streams as-is; each block\'s translation appears when the block completes)'
|
|
250
|
+
: ' (translation under each English line)'));
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
221
253
|
case 'lang': {
|
|
222
254
|
const code = rest[0];
|
|
223
255
|
if (!code) { const st = getState(); console.log('lang = ' + st.target + C.dim(' (available: ' + listLangs().join(', ') + '; aliases: zh-CN, zh-TW)')); break; }
|
|
@@ -240,8 +272,10 @@ async function main() {
|
|
|
240
272
|
const flag = (name) => { const i = rest.indexOf(name); return i > -1 ? rest[i + 1] : undefined; };
|
|
241
273
|
await require('../src/setup').runSetup({
|
|
242
274
|
lang: flag('--lang'),
|
|
275
|
+
mode: flag('--mode'),
|
|
243
276
|
backend: flag('--backend'),
|
|
244
277
|
key: flag('--key'),
|
|
278
|
+
input: flag('--input'),
|
|
245
279
|
yes: rest.includes('--yes'),
|
|
246
280
|
});
|
|
247
281
|
break;
|
|
@@ -251,8 +285,17 @@ async function main() {
|
|
|
251
285
|
const sub = rest[0];
|
|
252
286
|
if (sub === 'on' || sub === 'off') setState({ inputEn: sub === 'on' });
|
|
253
287
|
else if (sub === 'toggle') setState({ inputEn: !getState().inputEn });
|
|
288
|
+
else if (sub === 'threshold' && rest[1] !== undefined) {
|
|
289
|
+
const n = parseInt(rest[1], 10);
|
|
290
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
291
|
+
console.error('usage: cctrans input threshold <n> (non-Latin chars in a prompt that trigger translation; n >= 1)');
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
setState({ inputMinChars: n });
|
|
295
|
+
}
|
|
254
296
|
const st = getState();
|
|
255
|
-
console.log('input translation (prompt -> English): ' + (st.inputEn ? C.green('ON') : C.red('OFF')) +
|
|
297
|
+
console.log('input translation ' + C.dim('(beta)') + ' (prompt -> English): ' + (st.inputEn ? C.green('ON') : C.red('OFF')) +
|
|
298
|
+
C.dim(' threshold: ' + st.inputMinChars + ' non-Latin chars (set: cctrans input threshold <n>)') +
|
|
256
299
|
(inputHookInstalled() ? '' : C.red(' (hook not installed — run: cctrans install)')));
|
|
257
300
|
break;
|
|
258
301
|
}
|
package/hook/message-display.js
CHANGED
|
@@ -6,39 +6,67 @@
|
|
|
6
6
|
// delta = the newly completed lines of the streaming assistant message.
|
|
7
7
|
// Deltas are non-overlapping; a code fence (```) can span deltas.
|
|
8
8
|
// stdout (JSON, exit 0): { hookSpecificOutput: { hookEventName: "MessageDisplay",
|
|
9
|
-
// displayContent: "
|
|
9
|
+
// displayContent: "..." } }
|
|
10
10
|
// displayContent REPLACES the delta on screen. Display-only: the transcript
|
|
11
11
|
// and the model's context keep the original English.
|
|
12
12
|
//
|
|
13
|
+
// Two layouts (state.json "mode"):
|
|
14
|
+
// line — every prose line gets its "↳ 译" immediately (buildDisplayContent)
|
|
15
|
+
// section — English passes through untouched; prose lines buffer in msgstate
|
|
16
|
+
// and a grouped ZH block is spliced in when the section closes
|
|
17
|
+
// (planSections + renderSections)
|
|
18
|
+
//
|
|
13
19
|
// Safety contract: on disabled / empty / error / timeout, emit NOTHING and
|
|
14
20
|
// exit 0 so Claude Code renders the original English delta unchanged. This hook
|
|
15
21
|
// must never break or stall the user's session.
|
|
16
22
|
|
|
17
23
|
const fs = require('fs');
|
|
18
24
|
const path = require('path');
|
|
19
|
-
const { getState,
|
|
20
|
-
const { buildDisplayContent } = require('../src/interleave');
|
|
25
|
+
const { getState, MSGSTATE_DIR, sweepMsgState } = require('../src/config');
|
|
26
|
+
const { buildDisplayContent, planSections, renderSections } = require('../src/interleave');
|
|
21
27
|
|
|
22
28
|
function showOriginal() { process.exit(0); } // no stdout => CC keeps the original delta
|
|
23
29
|
|
|
24
|
-
// Per-message
|
|
25
|
-
// deltas of one message
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Per-message state, so "inside a ``` block?" and the open section's buffered
|
|
31
|
+
// lines carry across the deltas of one message (fresh process per delta).
|
|
32
|
+
// Reset at index 0 (also covers full repaints, which re-send the message from
|
|
33
|
+
// index 0); removed when the message completes. Schema {v, mode, index,
|
|
34
|
+
// inFence, buf}; a version/mode mismatch or unparseable file reads as fresh.
|
|
35
|
+
const STATE_V = 2;
|
|
36
|
+
function stateFile(id) { return path.join(MSGSTATE_DIR, String(id).replace(/[^\w.-]/g, '_') + '.json'); }
|
|
37
|
+
function loadMsgState(id, index, mode) {
|
|
38
|
+
const fresh = { inFence: false, buf: [] };
|
|
39
|
+
if (!id || index === 0) return fresh;
|
|
40
|
+
try {
|
|
41
|
+
const st = JSON.parse(fs.readFileSync(stateFile(id), 'utf8'));
|
|
42
|
+
if (st.v !== STATE_V || st.mode !== mode) return fresh;
|
|
43
|
+
// Index gap = an earlier delta crashed before saving. Drop the buffer so
|
|
44
|
+
// two sections can never be translated as one block at a later boundary;
|
|
45
|
+
// keep the fence flag best-effort (same exposure line mode has today).
|
|
46
|
+
if (st.index !== index - 1) return { inFence: !!st.inFence, buf: [] };
|
|
47
|
+
return { inFence: !!st.inFence, buf: Array.isArray(st.buf) ? st.buf : [] };
|
|
48
|
+
} catch (e) { return fresh; }
|
|
32
49
|
}
|
|
33
|
-
function
|
|
50
|
+
function saveMsgState(id, index, mode, inFence, buf, final) {
|
|
34
51
|
if (!id) return;
|
|
35
52
|
try {
|
|
36
|
-
if (final) { try { fs.unlinkSync(
|
|
37
|
-
fs.mkdirSync(
|
|
38
|
-
|
|
53
|
+
if (final) { try { fs.unlinkSync(stateFile(id)); } catch (e) {} return; }
|
|
54
|
+
fs.mkdirSync(MSGSTATE_DIR, { recursive: true });
|
|
55
|
+
const f = stateFile(id);
|
|
56
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: STATE_V, mode, index, inFence, buf }));
|
|
58
|
+
fs.renameSync(tmp, f); // atomic: buf files are big enough for torn writes to matter
|
|
59
|
+
if (index === 0) sweepMsgState(24 * 60 * 60 * 1000); // GC files leaked by killed sessions
|
|
39
60
|
} catch (e) {}
|
|
40
61
|
}
|
|
41
62
|
|
|
63
|
+
function emit(displayContent) {
|
|
64
|
+
process.stdout.write(JSON.stringify({
|
|
65
|
+
hookSpecificOutput: { hookEventName: 'MessageDisplay', displayContent: displayContent },
|
|
66
|
+
}));
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
let data = '';
|
|
43
71
|
process.stdin.on('data', (d) => (data += d));
|
|
44
72
|
process.stdin.on('end', async () => {
|
|
@@ -49,7 +77,6 @@ process.stdin.on('end', async () => {
|
|
|
49
77
|
try { inp = JSON.parse(data); } catch (e) { return showOriginal(); }
|
|
50
78
|
|
|
51
79
|
const delta = typeof inp.delta === 'string' ? inp.delta : '';
|
|
52
|
-
if (!delta) return showOriginal();
|
|
53
80
|
|
|
54
81
|
// Recursion guard: the claude-code backend spawns `claude -p` with
|
|
55
82
|
// CCTRANS_DISABLE=1 so a child Claude process can never re-enter this hook.
|
|
@@ -62,22 +89,48 @@ process.stdin.on('end', async () => {
|
|
|
62
89
|
const id = inp.message_id;
|
|
63
90
|
const index = typeof inp.index === 'number' ? inp.index : 0;
|
|
64
91
|
const final = inp.final === true;
|
|
65
|
-
|
|
92
|
+
|
|
93
|
+
if (st.mode === 'section') {
|
|
94
|
+
const ms = loadMsgState(id, index, 'section');
|
|
95
|
+
// An empty delta still flushes when it is the final one and lines are buffered.
|
|
96
|
+
if (!delta && !(final && ms.buf.length)) return showOriginal();
|
|
97
|
+
const guard = setTimeout(showOriginal, 9000);
|
|
98
|
+
try {
|
|
99
|
+
const planned = planSections(delta, { inFence: ms.inFence, buf: ms.buf, target: st.target, final: final });
|
|
100
|
+
// Commit state BEFORE translating (flushed sections already pruned from
|
|
101
|
+
// buf): a crash/timeout past this point can only drop a section's
|
|
102
|
+
// translation, never replay it at a wrong position.
|
|
103
|
+
saveMsgState(id, index, 'section', planned.inFence, planned.buf, final);
|
|
104
|
+
if (!planned.flushes.length) { clearTimeout(guard); return showOriginal(); }
|
|
105
|
+
const displayContent = await renderSections(planned, {
|
|
106
|
+
target: st.target, backend: st.backend, model: st.model, marker: st.marker,
|
|
107
|
+
timeoutMs: 5500, // smaller than line mode's 8000 so the google fallback keeps ~3s under the 9s guard
|
|
108
|
+
});
|
|
109
|
+
clearTimeout(guard);
|
|
110
|
+
if (displayContent == null) return showOriginal();
|
|
111
|
+
emit(displayContent);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
clearTimeout(guard);
|
|
114
|
+
return showOriginal();
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// line mode
|
|
120
|
+
if (!delta) return showOriginal();
|
|
121
|
+
const ms = loadMsgState(id, index, 'line');
|
|
66
122
|
|
|
67
123
|
// Guard below Claude Code's 10s MessageDisplay timeout so we always exit clean.
|
|
68
124
|
const guard = setTimeout(showOriginal, 9000);
|
|
69
125
|
try {
|
|
70
126
|
const { displayContent, inFence } = await buildDisplayContent(delta, {
|
|
71
127
|
target: st.target, backend: st.backend, model: st.model,
|
|
72
|
-
marker: st.marker, timeoutMs: 8000, inFence:
|
|
128
|
+
marker: st.marker, timeoutMs: 8000, inFence: ms.inFence,
|
|
73
129
|
});
|
|
74
130
|
clearTimeout(guard);
|
|
75
|
-
|
|
131
|
+
saveMsgState(id, index, 'line', inFence, [], final); // persist even when nothing was translated
|
|
76
132
|
if (displayContent == null) return showOriginal();
|
|
77
|
-
|
|
78
|
-
hookSpecificOutput: { hookEventName: 'MessageDisplay', displayContent: displayContent },
|
|
79
|
-
}));
|
|
80
|
-
process.exit(0);
|
|
133
|
+
emit(displayContent);
|
|
81
134
|
} catch (e) {
|
|
82
135
|
clearTimeout(guard);
|
|
83
136
|
return showOriginal();
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const { getState } = require('../src/config');
|
|
18
18
|
const { translateLines } = require('../src/translate');
|
|
19
|
-
const {
|
|
19
|
+
const { nonLatinCount } = require('../src/langs');
|
|
20
20
|
|
|
21
21
|
function passThrough() { process.exit(0); }
|
|
22
22
|
|
|
@@ -37,8 +37,11 @@ process.stdin.on('end', async () => {
|
|
|
37
37
|
try { st = getState(); } catch (e) { return passThrough(); }
|
|
38
38
|
if (!st.inputEn) return passThrough();
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
|
|
40
|
+
// Trigger on an absolute count of non-Latin chars (configurable:
|
|
41
|
+
// `cctrans input threshold <n>`), NOT a ratio — coding prompts are mostly
|
|
42
|
+
// Latin paths/identifiers, which would dilute a ratio below any threshold.
|
|
43
|
+
const min = Number.isInteger(st.inputMinChars) && st.inputMinChars > 0 ? st.inputMinChars : 4;
|
|
44
|
+
if (nonLatinCount(prompt) < min) return passThrough();
|
|
42
45
|
|
|
43
46
|
const guard = setTimeout(passThrough, 9000);
|
|
44
47
|
try {
|
|
@@ -52,7 +55,8 @@ process.stdin.on('end', async () => {
|
|
|
52
55
|
hookEventName: 'UserPromptSubmit',
|
|
53
56
|
additionalContext:
|
|
54
57
|
'English translation of the user\'s prompt (translated by a local tool; ' +
|
|
55
|
-
'treat it as the canonical instruction)
|
|
58
|
+
'treat it as the canonical instruction). Respond in English — a local ' +
|
|
59
|
+
'overlay renders your reply bilingually for the user:\n' + en,
|
|
56
60
|
},
|
|
57
61
|
}));
|
|
58
62
|
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctrans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Bilingual inline translation overlay for Claude Code: a translated line (zh/ja/ko/ru/hi) under each English line, right in the conversation — non-destructive, zero main-loop tokens.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Roy Jiang",
|
|
@@ -39,6 +39,6 @@
|
|
|
39
39
|
"node": ">=18"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"test": "node test/fence.js"
|
|
42
|
+
"test": "node test/fence.js && node test/markdown.js && node test/section.js"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/config.js
CHANGED
|
@@ -14,6 +14,23 @@ const HOME = os.homedir();
|
|
|
14
14
|
const BASE = process.env.CCTRANS_HOME || path.join(HOME, '.cc-translate');
|
|
15
15
|
const STATE_FILE = path.join(BASE, 'state.json');
|
|
16
16
|
const CACHE_DIR = path.join(BASE, 'cache');
|
|
17
|
+
const MSGSTATE_DIR = path.join(BASE, 'msgstate');
|
|
18
|
+
|
|
19
|
+
// Display layouts. Validate against this list everywhere (CLI, setup, hook) so
|
|
20
|
+
// a future granularity (e.g. 'message') is a one-line addition.
|
|
21
|
+
const MODES = ['line', 'section'];
|
|
22
|
+
|
|
23
|
+
// Remove per-message state files older than maxAgeMs (0 = all). Sessions
|
|
24
|
+
// killed mid-message leave their file behind; swept here and on index-0 saves.
|
|
25
|
+
function sweepMsgState(maxAgeMs) {
|
|
26
|
+
try {
|
|
27
|
+
const cutoff = Date.now() - (maxAgeMs || 0);
|
|
28
|
+
for (const f of fs.readdirSync(MSGSTATE_DIR)) {
|
|
29
|
+
const p = path.join(MSGSTATE_DIR, f);
|
|
30
|
+
try { if (fs.statSync(p).mtimeMs <= cutoff) fs.unlinkSync(p); } catch (e) {}
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {}
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
function ensureDirs() {
|
|
19
36
|
try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) {}
|
|
@@ -29,7 +46,9 @@ function defaults() {
|
|
|
29
46
|
anthropicModel: 'claude-haiku-4-5', // anthropic backend model
|
|
30
47
|
azureEndpoint: 'https://api.cognitive.microsofttranslator.com',
|
|
31
48
|
marker: '↳ ', // prefix on each translated line
|
|
32
|
-
|
|
49
|
+
mode: 'line', // display layout: 'line' (ZH under each line) or 'section' (grouped per block)
|
|
50
|
+
inputEn: false, // input translation (beta, prompt -> English) off until enabled
|
|
51
|
+
inputMinChars: 4, // non-Latin chars in a prompt that trigger input translation
|
|
33
52
|
};
|
|
34
53
|
}
|
|
35
54
|
|
|
@@ -51,7 +70,9 @@ function setState(patch) {
|
|
|
51
70
|
anthropicModel: next.anthropicModel,
|
|
52
71
|
azureEndpoint: next.azureEndpoint,
|
|
53
72
|
marker: next.marker,
|
|
73
|
+
mode: next.mode,
|
|
54
74
|
inputEn: next.inputEn,
|
|
75
|
+
inputMinChars: next.inputMinChars,
|
|
55
76
|
};
|
|
56
77
|
const tmp = STATE_FILE + '.' + process.pid + '.tmp';
|
|
57
78
|
fs.writeFileSync(tmp, JSON.stringify(persist, null, 2));
|
|
@@ -59,4 +80,4 @@ function setState(patch) {
|
|
|
59
80
|
return next;
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
module.exports = { HOME, BASE, STATE_FILE, CACHE_DIR, ensureDirs, getState, setState, defaults };
|
|
83
|
+
module.exports = { HOME, BASE, STATE_FILE, CACHE_DIR, MSGSTATE_DIR, MODES, ensureDirs, getState, setState, defaults, sweepMsgState };
|