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.zh-Hant.md CHANGED
@@ -1,8 +1,20 @@
1
+ <div align="center">
2
+
1
3
  # cctrans
2
4
 
5
+ **用母語讀 Claude Code——token 按英文計費。**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/cctrans?color=cb3837&logo=npm)](https://www.npmjs.com/package/cctrans)
8
+ [![npm downloads](https://img.shields.io/npm/dm/cctrans?color=blue)](https://www.npmjs.com/package/cctrans)
9
+ [![GitHub stars](https://img.shields.io/github/stars/roy-jiang-opus/cctrans?style=flat&logo=github)](https://github.com/roy-jiang-opus/cctrans)
10
+ [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
11
+ [![node](https://img.shields.io/node/v/cctrans)](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
- 為 Claude Code 加上一層**雙語對照**:每則回覆會在原始英文行下方自動補上一行譯文(中/日/韓/俄/印地),**就在對話裡**,一行英文一行譯文。
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
- - **非破壞性**:畫面上多了譯文,但轉錄檔與模型看到的上下文**仍是純英文**——技術文件、skills、程式碼都不受影響。
15
- - **不污染歷史、不耗主對話 token**:翻譯由一個**獨立的低成本後端**完成,與你的 Claude Code 工作階段完全無關。
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` | 互動式精靈:語言、後端、API key |
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
- **最快的開關方式**:在 Claude Code 輸入框直接輸入 `!cctrans off` 或 `!cctrans on`(`!` 是 CC 內建的 bash 模式,不呼叫模型、不花 token)。
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` 鉤子:當你的輸入大多是非英文時,英文譯文會作為上下文附給模型並被視為權威指令——你繼續用母語打字,模型按英文工作。(已在 CC 2.1.169 核實:鉤子無法改寫 prompt 本身,所以原文仍在歷史中,英文隨附。)英文輸入原樣通過;任何錯誤都安全回退為原樣送出。
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
- ```bash
133
- node bin/cctrans.js uninstall # 移除鉤子;重新啟動 Claude Code 生效
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(' input : ' + (st.inputEn ? C.green('ON') : 'off') + C.dim(' (prompt -> English; toggle: cctrans input on|off)'));
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) => (l.startsWith(marker) ? C.cyan(l) : 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
- const { displayContent } = await buildDisplayContent(text, {
162
- target: st.target, backend: st.backend, model: st.model, marker: st.marker, timeoutMs: 12000,
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
  }
@@ -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: "<EN line>\n↳ <ZH line> ..." } }
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, BASE } = require('../src/config');
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 code-fence state, so "inside a ``` block?" carries across the
25
- // deltas of one message. Reset at index 0 (also covers full repaints, which
26
- // re-send the message from index 0); removed when the message completes.
27
- const MSGDIR = path.join(BASE, 'msgstate');
28
- function fenceFile(id) { return path.join(MSGDIR, String(id).replace(/[^\w.-]/g, '_') + '.json'); }
29
- function loadFence(id, index) {
30
- if (!id || index === 0) return false;
31
- try { return !!JSON.parse(fs.readFileSync(fenceFile(id), 'utf8')).inFence; } catch (e) { return false; }
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 saveFence(id, index, inFence, final) {
50
+ function saveMsgState(id, index, mode, inFence, buf, final) {
34
51
  if (!id) return;
35
52
  try {
36
- if (final) { try { fs.unlinkSync(fenceFile(id)); } catch (e) {} return; }
37
- fs.mkdirSync(MSGDIR, { recursive: true });
38
- fs.writeFileSync(fenceFile(id), JSON.stringify({ index, inFence }));
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
- const inFence0 = loadFence(id, index);
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: inFence0,
128
+ marker: st.marker, timeoutMs: 8000, inFence: ms.inFence,
73
129
  });
74
130
  clearTimeout(guard);
75
- saveFence(id, index, inFence, final); // persist even when nothing was translated
131
+ saveMsgState(id, index, 'line', inFence, [], final); // persist even when nothing was translated
76
132
  if (displayContent == null) return showOriginal();
77
- process.stdout.write(JSON.stringify({
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 { nonLatinRatio } = require('../src/langs');
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
- // Only act on prompts that are substantially non-English.
41
- if (nonLatinRatio(prompt) < 0.2) return passThrough();
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):\n' + en,
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.2.1",
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
- inputEn: false, // input translation (prompt -> English) off until enabled
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 };