cctrans 0.3.0 → 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 CHANGED
@@ -28,6 +28,7 @@ Claude Code के लिए एक **द्विभाषी ओवरले**
28
28
  ## ✨ विशेषताएँ
29
29
 
30
30
  - 🪞 **इनलाइन द्विभाषी प्रदर्शन** — अनुवाद जवाब की स्ट्रीमिंग के साथ हर अंग्रेज़ी पंक्ति के नीचे दिखता है
31
+ - 🧩 **दो लेआउट** — पंक्ति-दर-पंक्ति अंतर्संयोजन, या `cctrans mode section`: पहले पूरा अंग्रेज़ी ब्लॉक, फिर उसका समूहीकृत अनुवाद
31
32
  - 🧾 **गैर-विनाशकारी** — ट्रांसक्रिप्ट और मॉडल कॉन्टेक्स्ट शुद्ध अंग्रेज़ी में रहते हैं; skills, दस्तावेज़, कोड अप्रभावित
32
33
  - 🆓 **मुख्य लूप के शून्य टोकन** — अनुवाद एक अलग सस्ते (मुफ़्त विकल्प सहित) बैकएंड से होता है, Claude Code सत्र से पूरी तरह बाहर
33
34
  - ⌨️ **इनपुट अनुवाद (beta)** — अपनी भाषा में लिखें, मॉडल अंग्रेज़ी में काम करता है — और अंग्रेज़ी में जवाब देता है (`cctrans input on`)
@@ -99,9 +100,10 @@ Claude अंग्रेज़ी स्ट्रीम करता है
99
100
  | `cctrans on` / `cctrans off` / `cctrans toggle` | अनुवाद चालू / बंद / टॉगल |
100
101
  | `cctrans status` | स्थिति देखें (टॉगल, हुक, बैकएंड, भाषा) |
101
102
  | `cctrans lang [code]` | लक्ष्य भाषा देखें/सेट करें: `zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
103
+ | `cctrans mode [line\|section]` | लेआउट: हर पंक्ति के नीचे अनुवाद, या ब्लॉक-वार समूहीकृत |
102
104
  | `cctrans backend <id>` | अनुवाद इंजन बदलें |
103
105
  | `cctrans backends` | सभी इंजन और उनकी उपलब्धता सूचीबद्ध करें |
104
- | `cctrans setup` | इंटरैक्टिव विज़ार्ड: भाषा, बैकएंड, API कुंजियाँ |
106
+ | `cctrans setup` | इंटरैक्टिव विज़ार्ड: भाषा, प्रदर्शन मोड, बैकएंड, API कुंजियाँ |
105
107
  | `cctrans key [id] [value]` | `~/.cc-translate/keys.json` में API कुंजियाँ प्रबंधित करें |
106
108
  | `cctrans input on` / `cctrans input off` | **(beta)** गैर-अंग्रेज़ी इनपुट का अंग्रेज़ी अनुवाद (संदर्भ के रूप में भेजा जाता है) |
107
109
  | `cctrans input threshold <n>` | इनपुट अनुवाद ट्रिगर करने वाले गैर-लैटिन वर्णों की संख्या (डिफ़ॉल्ट 4) |
@@ -109,6 +111,28 @@ Claude अंग्रेज़ी स्ट्रीम करता है
109
111
  | `cctrans test <टेक्स्ट>` | इंजन जाँचने के लिए कोई टेक्स्ट अनुवाद करें |
110
112
  | `cctrans install` / `cctrans uninstall` | हुक पंजीकृत / हटाएँ |
111
113
 
114
+ ## 🧩 प्रदर्शन मोड
115
+
116
+ `line` (डिफ़ॉल्ट) अंतर्संयोजन करता है: हर अंग्रेज़ी पंक्ति के नीचे अनुवाद की पंक्ति, जवाब की स्ट्रीमिंग के साथ। `section` अंग्रेज़ी को ठीक वैसे ही रखता है जैसे Claude स्ट्रीम करता है और **ब्लॉक पूरा होने पर एक समूहीकृत अनुवाद** जोड़ देता है — सूची-भारी जवाबों के लिए कहीं ज़्यादा शांत:
117
+
118
+ ```
119
+ Use these flags:
120
+ ↳ ये फ़्लैग इस्तेमाल करें:
121
+
122
+ - Enable the cache
123
+ - Set a small timeout
124
+ - Prefer the batch API
125
+ ↳ कैश सक्षम करें
126
+ ↳ छोटा टाइमआउट सेट करें
127
+ ↳ batch API को प्राथमिकता दें
128
+ ```
129
+
130
+ ```bash
131
+ cctrans mode section # कभी भी वापस जाएँ: cctrans mode line
132
+ ```
133
+
134
+ > section मोड में किसी ब्लॉक का अनुवाद **ब्लॉक पूरा होने पर** दिखता है, स्ट्रीमिंग के दौरान नहीं — धीमे बैकएंड (जैसे `claude-code`, 3–6s/कॉल) के साथ यह ठहराव महसूस होता है, इसलिए यहाँ API बैकएंड सबसे अच्छे रहते हैं। अगर किसी ब्लॉक का अनुवाद विफल हो जाए, तो अंग्रेज़ी अप्रभावित रहती है और वह ब्लॉक बस बिना अनुवाद के रह जाता है।
135
+
112
136
  ## 🌐 अनुवाद बैकएंड
113
137
 
114
138
  | बैकएंड | आवश्यक | गति | गुणवत्ता | टिप्पणी |
@@ -151,7 +175,8 @@ cctrans lang zh-Hans # सरलीकृत चीनी (डिफ़ॉल
151
175
 
152
176
  - हुक **स्ट्रीमिंग के दौरान** हर खंड पर सक्रिय होता है; हर खंड का अनुवाद होकर उसी जगह बदला जाता है — अनुवाद अंग्रेज़ी के साथ-साथ क्रमिक रूप से दिखता है।
153
177
  - हुक का टाइमआउट **10 सेकंड** है; यह टूल आंतरिक रूप से 9s पर गार्ड करता है। कोई भी त्रुटि / टाइमआउट / अधिक लंबाई (>9,000 वर्ण) **सुरक्षित रूप से मूल अंग्रेज़ी पर fallback** करती है — सत्र कभी नहीं अटकता।
154
- - हर अनुवादित पंक्ति सामग्री हैश से **कैश** होती है (`~/.cc-translate/cache`); री-पेंट और दोहराए टेक्स्ट की लागत शून्य।
178
+ - हर अनुवादित पंक्ति सामग्री हैश से **कैश** होती है (`~/.cc-translate/cache`); री-पेंट और दोहराए टेक्स्ट की लागत शून्य। दोनों मोड एक ही कैश साझा करते हैं।
179
+ - section मोड में चल रहे (अभी अधूरे) ब्लॉक का टेक्स्ट `~/.cc-translate/msgstate` में बफ़र होता है (डिस्क पर वही एक्सपोज़र जो कैश का है); संदेश पूरा होने पर फ़ाइल हटा दी जाती है और पुरानी फ़ाइलें 24 घंटे बाद साफ़ कर दी जाती हैं।
155
180
  - `openai` के साथ हर खंड लगभग एक API कॉल (~$0.0001) और शुद्ध अंग्रेज़ी की तुलना में ~1s/खंड की देरी; `google` तेज़ है पर गुणवत्ता थोड़ी कम।
156
181
 
157
182
  ## 🔗 जुड़े रहें
package/README.ja.md CHANGED
@@ -28,6 +28,7 @@ Claude Code の**バイリンガル表示オーバーレイ**:各英語行の下
28
28
  ## ✨ 特長
29
29
 
30
30
  - 🪞 **インラインのバイリンガル表示** —— 訳文は返信のストリーミングと共に各英語行の下に現れます
31
+ - 🧩 **2 つのレイアウト** —— 行ごとのインターリーブ、または `cctrans mode section`:英語ブロック全体を先に表示し、その後にまとめた訳文を表示
31
32
  - 🧾 **非破壊** —— トランスクリプトとモデルコンテキストは純粋な英語のまま;skills、ドキュメント、コードに影響なし
32
33
  - 🆓 **メインループのトークン消費ゼロ** —— 翻訳は独立した安価な(無料もある)バックエンドで実行、Claude Code セッションの完全に外側
33
34
  - ⌨️ **入力翻訳(beta)** —— 母語で入力し、モデルは英語で動き、英語で返信する(`cctrans input on`)
@@ -99,9 +100,10 @@ Claude が英語をストリーミング出力
99
100
  | `cctrans on` / `cctrans off` / `cctrans toggle` | 翻訳のオン / オフ / 切替 |
100
101
  | `cctrans status` | 状態表示(トグル、フック、バックエンド、言語) |
101
102
  | `cctrans lang [code]` | 目標言語の表示/設定:`zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
103
+ | `cctrans mode [line\|section]` | レイアウト:各行の下に訳文、またはブロックごとにまとめて |
102
104
  | `cctrans backend <id>` | 翻訳エンジンの切替 |
103
105
  | `cctrans backends` | 全エンジンと利用可否を一覧 |
104
- | `cctrans setup` | 対話式ウィザード:言語、バックエンド、API キー |
106
+ | `cctrans setup` | 対話式ウィザード:言語、表示モード、バックエンド、API キー |
105
107
  | `cctrans key [id] [value]` | `~/.cc-translate/keys.json` の API キーを管理 |
106
108
  | `cctrans input on` / `cctrans input off` | **(beta)** 非英語の入力を英語に翻訳(コンテキストとして送信) |
107
109
  | `cctrans input threshold <n>` | 入力翻訳を発火させる非ラテン文字数(デフォルト 4) |
@@ -109,6 +111,28 @@ Claude が英語をストリーミング出力
109
111
  | `cctrans test <テキスト>` | テキストを翻訳してエンジンを検証 |
110
112
  | `cctrans install` / `cctrans uninstall` | フックの登録 / 削除 |
111
113
 
114
+ ## 🧩 表示モード
115
+
116
+ `line`(デフォルト)はインターリーブ:各英語行の下に訳文行が一行ずつ、返信のストリーミングと共に表示されます。`section` は Claude のストリーミングどおりに英語をそのまま保ち、**ブロック完成時にまとめた訳文を 1 回**差し込みます——リストの多い返信ではずっと静かになります:
117
+
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
+
112
136
  ## 🌐 翻訳バックエンド
113
137
 
114
138
  | バックエンド | 前提 | 速度 | 品質 | 備考 |
@@ -151,7 +175,8 @@ cctrans lang zh-Hans # 簡体字中国語(デフォルト)
151
175
 
152
176
  - フックは**ストリーミング中**に断片ごとに発火し、断片ごとに翻訳してその場で置換——訳文は英語と並んで段階的に現れます。
153
177
  - フックのタイムアウトは **10 秒**;本ツールは内部で 9 秒のガードを持ちます。エラー/タイムアウト/超過(>9,000 文字)はすべて**元の英語に安全にフォールバック**——セッションを止めることはありません。
154
- - 訳文は内容ハッシュで**キャッシュ**(`~/.cc-translate/cache`);再描画や繰り返しテキストはコストゼロ。
178
+ - 訳文は内容ハッシュで**キャッシュ**(`~/.cc-translate/cache`);再描画や繰り返しテキストはコストゼロ。キャッシュは両モードで共有されます。
179
+ - section モードでは処理中のブロックのテキストが `~/.cc-translate/msgstate` にバッファされます(保存時の露出はキャッシュと同等);ファイルはメッセージ完了時に削除され、古いものは 24 時間後に掃除されます。
155
180
  - `openai` 使用時は断片ごとに約 1 回の API 呼び出し(~$0.0001)、純英語より約 1 秒/断片の遅延;`google` はより速いが品質はやや低め。
156
181
 
157
182
  ## 🔗 プロジェクトをフォロー
package/README.ko.md CHANGED
@@ -28,6 +28,7 @@ Claude Code의 **이중 언어 오버레이**: 각 영어 줄 아래에 번역(
28
28
  ## ✨ 특징
29
29
 
30
30
  - 🪞 **인라인 이중 언어 표시** — 번역이 응답 스트리밍과 함께 각 영어 줄 아래에 나타납니다
31
+ - 🧩 **두 가지 레이아웃** — 줄 단위 인터리브, 또는 `cctrans mode section`: 영어 블록 전체 먼저, 그다음 묶음 번역
31
32
  - 🧾 **비파괴적** — 트랜스크립트와 모델 컨텍스트는 순수 영어 유지; skills, 문서, 코드 영향 없음
32
33
  - 🆓 **메인 루프 토큰 0** — 번역은 독립적인 저비용(무료 옵션 포함) 백엔드에서 실행, Claude Code 세션 완전 외부
33
34
  - ⌨️ **입력 번역 (beta)** — 모국어로 입력하고 모델은 영어로 작동하고 영어로 응답(`cctrans input on`)
@@ -99,9 +100,10 @@ Claude가 영어를 스트리밍 출력
99
100
  | `cctrans on` / `cctrans off` / `cctrans toggle` | 번역 켜기 / 끄기 / 전환 |
100
101
  | `cctrans status` | 상태 표시 (토글, 훅, 백엔드, 언어) |
101
102
  | `cctrans lang [code]` | 대상 언어 표시/설정: `zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
103
+ | `cctrans mode [line\|section]` | 레이아웃: 각 줄 아래 번역, 또는 블록별 묶음 |
102
104
  | `cctrans backend <id>` | 번역 엔진 전환 |
103
105
  | `cctrans backends` | 모든 엔진과 가용성 나열 |
104
- | `cctrans setup` | 대화형 마법사: 언어, 백엔드, API 키 |
106
+ | `cctrans setup` | 대화형 마법사: 언어, 표시 모드, 백엔드, API 키 |
105
107
  | `cctrans key [id] [value]` | `~/.cc-translate/keys.json`의 API 키 관리 |
106
108
  | `cctrans input on` / `cctrans input off` | **(beta)** 비영어 입력을 영어로 번역 (컨텍스트로 전송) |
107
109
  | `cctrans input threshold <n>` | 입력 번역을 트리거하는 비라틴 문자 수 (기본 4) |
@@ -109,6 +111,28 @@ Claude가 영어를 스트리밍 출력
109
111
  | `cctrans test <텍스트>` | 텍스트를 번역하여 엔진 검증 |
110
112
  | `cctrans install` / `cctrans uninstall` | 훅 등록 / 제거 |
111
113
 
114
+ ## 🧩 표시 모드
115
+
116
+ `line`(기본)은 인터리브: 각 영어 줄 아래에 번역 한 줄씩, 응답 스트리밍과 함께 표시됩니다. `section`은 Claude가 스트리밍한 영어를 그대로 유지하고 **블록이 완성될 때 묶음 번역 하나**를 끼워 넣습니다 — 목록이 많은 응답에서 훨씬 차분합니다:
117
+
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
+
112
136
  ## 🌐 번역 백엔드
113
137
 
114
138
  | 백엔드 | 요구사항 | 속도 | 품질 | 비고 |
@@ -151,7 +175,8 @@ cctrans lang zh-Hans # 간체 중국어 (기본)
151
175
 
152
176
  - 훅은 **스트리밍 중** 조각마다 발화하며, 조각별로 번역하여 그 자리에서 교체 — 번역이 영어와 함께 점진적으로 나타납니다.
153
177
  - 훅 타임아웃은 **10초**; 이 도구는 내부적으로 9초 가드를 둡니다. 오류/타임아웃/초과(>9,000자)는 모두 **원본 영어로 안전하게 폴백** — 세션을 멈추지 않습니다.
154
- - 모든 번역 줄은 내용 해시로 **캐시**됩니다 (`~/.cc-translate/cache`); 다시 그리기와 반복 텍스트는 비용이 없습니다.
178
+ - 모든 번역 줄은 내용 해시로 **캐시**됩니다 (`~/.cc-translate/cache`); 다시 그리기와 반복 텍스트는 비용이 없습니다. 두 모드는 캐시를 공유합니다.
179
+ - section 모드에서는 진행 중인 블록의 텍스트가 `~/.cc-translate/msgstate`에 버퍼링됩니다(저장 시 노출 수준은 캐시와 동일); 파일은 메시지 완료 시 삭제되고 오래된 파일은 24시간 후 정리됩니다.
155
180
  - `openai` 사용 시 조각당 약 1회 API 호출(~$0.0001), 순수 영어 대비 조각당 약 1초 지연; `google`은 더 빠르지만 품질이 약간 낮습니다.
156
181
 
157
182
  ## 🔗 프로젝트 팔로우
package/README.md CHANGED
@@ -28,6 +28,7 @@ A **bilingual overlay** for Claude Code: a translated line (Chinese / Japanese /
28
28
  ## ✨ Features
29
29
 
30
30
  - 🪞 **Inline bilingual display** — the translation appears under each English line, in the conversation itself, streaming along with the reply
31
+ - 🧩 **Two layouts** — per-line interleave, or `cctrans mode section`: whole English block first, then its grouped translation
31
32
  - 🧾 **Non-destructive** — transcript and model context stay pure English; skills, docs, and code are untouched
32
33
  - 🆓 **Zero main-loop tokens** — translation runs through a separate cheap backend (or a free one), completely outside your Claude Code session
33
34
  - ⌨️ **Input translation (beta)** — type prompts in your language; the model works — and replies — in English (`cctrans input on`)
@@ -99,9 +100,10 @@ Claude streams English
99
100
  | `cctrans on` / `cctrans off` / `cctrans toggle` | turn translation on / off / toggle |
100
101
  | `cctrans status` | show state (toggle, hook, backend, language) |
101
102
  | `cctrans lang [code]` | show/set target language: `zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
103
+ | `cctrans mode [line\|section]` | layout: translation under each line, or grouped per block |
102
104
  | `cctrans backend <id>` | switch translation engine |
103
105
  | `cctrans backends` | list engines and their availability |
104
- | `cctrans setup` | interactive wizard: language, backend, API keys |
106
+ | `cctrans setup` | interactive wizard: language, display mode, backend, API keys |
105
107
  | `cctrans key [id] [value]` | manage API keys in `~/.cc-translate/keys.json` |
106
108
  | `cctrans input on` / `cctrans input off` | **(beta)** translate non-English input to English (sent as context) |
107
109
  | `cctrans input threshold <n>` | non-Latin characters that trigger input translation (default 4) |
@@ -109,6 +111,28 @@ Claude streams English
109
111
  | `cctrans test <text>` | translate ad-hoc text to verify the engine |
110
112
  | `cctrans install` / `cctrans uninstall` | register / remove the hooks |
111
113
 
114
+ ## 🧩 Display modes
115
+
116
+ `line` (default) interleaves: a translated line under each English line, streaming with the reply. `section` keeps English exactly as Claude streams it and splices in **one grouped translation when a block completes** — much quieter for list-heavy replies:
117
+
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 # switch back anytime: cctrans mode line
132
+ ```
133
+
134
+ > In section mode a block's translation appears **when the block completes**, not while it streams — with a slow backend (e.g. `claude-code`, 3–6 s/call) that pause is noticeable, so API backends feel best here. If a block's translation fails, the English is unaffected and that block simply stays untranslated.
135
+
112
136
  ## 🌐 Translation backends
113
137
 
114
138
  | Backend | Requires | Speed | Quality | Notes |
@@ -151,7 +175,8 @@ Chinese uses BCP-47 **script** codes (`zh-Hans`/`zh-Hant`) — Traditional Chine
151
175
 
152
176
  - The hook fires **per chunk during streaming**; each chunk is translated and replaced in place — translations appear progressively alongside the English.
153
177
  - The hook has a **10-second** timeout; this tool guards at 9s internally. Any error / timeout / oversized chunk (>9,000 chars) **falls back safely to the original English** — it never stalls the session.
154
- - Every translated line is **cached** by content hash (`~/.cc-translate/cache`); repaints and repeated text cost nothing.
178
+ - Every translated line is **cached** by content hash (`~/.cc-translate/cache`); repaints and repeated text cost nothing. Both modes share the cache.
179
+ - In section mode an in-flight block's text is buffered in `~/.cc-translate/msgstate` (same at-rest exposure as the cache); the file is removed when the message completes and stale ones are swept after 24h.
155
180
  - With `openai`, each chunk is roughly one API call (~$0.0001) and adds about 1s of latency vs. plain English; `google` is faster with slightly lower quality.
156
181
 
157
182
  ## 🔗 Stay in the loop
package/README.ru.md CHANGED
@@ -28,6 +28,7 @@
28
28
  ## ✨ Возможности
29
29
 
30
30
  - 🪞 **Встроенное двуязычное отображение** — перевод появляется под каждой английской строкой по мере стриминга ответа
31
+ - 🧩 **Два варианта компоновки** — построчное чередование или `cctrans mode section`: сначала весь английский блок, затем его сгруппированный перевод
31
32
  - 🧾 **Неразрушающий** — транскрипт и контекст модели остаются чисто английскими; skills, документация и код не затронуты
32
33
  - 🆓 **Ноль токенов основного цикла** — перевод идёт через отдельный дешёвый (или бесплатный) бэкенд, полностью вне сессии Claude Code
33
34
  - ⌨️ **Перевод ввода (beta)** — печатайте на родном языке, модель работает — и отвечает — на английском (`cctrans input on`)
@@ -100,9 +101,10 @@ Claude стримит английский текст
100
101
  | `cctrans on` / `cctrans off` / `cctrans toggle` | включить / выключить / переключить перевод |
101
102
  | `cctrans status` | показать состояние (переключатель, хук, бэкенд, язык) |
102
103
  | `cctrans lang [code]` | показать/задать целевой язык: `zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
104
+ | `cctrans mode [line\|section]` | компоновка: перевод под каждой строкой или сгруппированно по блокам |
103
105
  | `cctrans backend <id>` | сменить движок перевода |
104
106
  | `cctrans backends` | список движков и их доступность |
105
- | `cctrans setup` | интерактивный мастер: язык, бэкенд, API-ключи |
107
+ | `cctrans setup` | интерактивный мастер: язык, режим отображения, бэкенд, API-ключи |
106
108
  | `cctrans key [id] [value]` | управление ключами в `~/.cc-translate/keys.json` |
107
109
  | `cctrans input on` / `cctrans input off` | **(beta)** переводить неанглийский ввод на английский (отправляется как контекст) |
108
110
  | `cctrans input threshold <n>` | число нелатинских символов, запускающее перевод ввода (по умолчанию 4) |
@@ -110,6 +112,28 @@ Claude стримит английский текст
110
112
  | `cctrans test <текст>` | перевести произвольный текст для проверки движка |
111
113
  | `cctrans install` / `cctrans uninstall` | зарегистрировать / удалить хуки |
112
114
 
115
+ ## 🧩 Режимы отображения
116
+
117
+ `line` (по умолчанию) чередует строки: перевод под каждой английской строкой, в потоке вместе с ответом. `section` оставляет английский ровно таким, каким его стримит Claude, и вставляет **один сгруппированный перевод, когда блок завершён** — заметно спокойнее для ответов с большими списками:
118
+
119
+ ```
120
+ Use these flags:
121
+ ↳ Используйте эти флаги:
122
+
123
+ - Enable the cache
124
+ - Set a small timeout
125
+ - Prefer the batch API
126
+ ↳ Включите кэш
127
+ ↳ Задайте небольшой таймаут
128
+ ↳ Предпочитайте batch API
129
+ ```
130
+
131
+ ```bash
132
+ cctrans mode section # вернуться можно в любой момент: cctrans mode line
133
+ ```
134
+
135
+ > В режиме section перевод блока появляется **когда блок завершён**, а не во время стриминга — с медленным бэкендом (например `claude-code`, 3–6 с/вызов) эта пауза заметна, поэтому здесь лучше всего подходят API-бэкенды. Если перевод блока не удался, английский не затрагивается — блок просто остаётся непереведённым.
136
+
113
137
  ## 🌐 Бэкенды перевода
114
138
 
115
139
  | Бэкенд | Требует | Скорость | Качество | Примечание |
@@ -152,7 +176,8 @@ cctrans lang zh-Hans # упрощённый китайский (по умолч
152
176
 
153
177
  - Хук срабатывает **во время стриминга** на каждый фрагмент; каждый фрагмент переводится и заменяется на месте — перевод появляется постепенно вместе с английским.
154
178
  - Таймаут хука — **10 секунд**; внутренний гард инструмента — 9с. Любая ошибка / таймаут / превышение размера (>9000 символов) **безопасно откатывается к оригинальному английскому** — сессия никогда не зависает.
155
- - Каждая переведённая строка **кэшируется** по хэшу содержимого (`~/.cc-translate/cache`); перерисовки и повторяющийся текст бесплатны.
179
+ - Каждая переведённая строка **кэшируется** по хэшу содержимого (`~/.cc-translate/cache`); перерисовки и повторяющийся текст бесплатны. Оба режима используют общий кэш.
180
+ - В режиме section текст ещё не завершённого блока буферизуется в `~/.cc-translate/msgstate` (та же степень раскрытия данных на диске, что и у кэша); файл удаляется по завершении сообщения, а устаревшие файлы вычищаются через 24 ч.
156
181
  - С `openai` каждый фрагмент — примерно один вызов API (~$0.0001) и ~1с задержки по сравнению с чистым английским; `google` быстрее, но качество чуть ниже.
157
182
 
158
183
  ## 🔗 Следите за проектом
package/README.zh-Hans.md CHANGED
@@ -28,6 +28,7 @@
28
28
  ## ✨ 特性
29
29
 
30
30
  - 🪞 **行内双语显示** —— 译文随回复流式出现在每行英文下方,就在对话里
31
+ - 🧩 **两种排版** —— 逐行对照,或 `cctrans mode section`:整块英文先出,再跟一段成组译文
31
32
  - 🧾 **非破坏** —— 转录与模型上下文保持纯英文;skills、文档、代码不受影响
32
33
  - 🆓 **主对话零 token** —— 翻译走独立便宜后端(也有免费选项),完全在 Claude Code 会话之外
33
34
  - ⌨️ **输入翻译(beta)** —— 用母语打字,模型按英文工作、按英文回复(`cctrans input on`)
@@ -99,9 +100,10 @@ Claude 流式输出英文
99
100
  | `cctrans on` / `cctrans off` / `cctrans toggle` | 开 / 关 / 切换翻译 |
100
101
  | `cctrans status` | 查看状态(开关、钩子、后端、语言) |
101
102
  | `cctrans lang [code]` | 查看/切换目标语言:`zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
103
+ | `cctrans mode [line\|section]` | 排版:译文跟在每行下方,或按块成组 |
102
104
  | `cctrans backend <id>` | 切换翻译引擎 |
103
105
  | `cctrans backends` | 列出所有引擎及其可用性 |
104
- | `cctrans setup` | 交互式向导:语言、后端、API key |
106
+ | `cctrans setup` | 交互式向导:语言、显示模式、后端、API key |
105
107
  | `cctrans key [id] [value]` | 管理 `~/.cc-translate/keys.json` 里的 API key |
106
108
  | `cctrans input on` / `cctrans input off` | **(beta)** 把非英文输入翻译成英文(作为上下文发给模型) |
107
109
  | `cctrans input threshold <n>` | 触发输入翻译的非拉丁字符数(默认 4) |
@@ -109,6 +111,28 @@ Claude 流式输出英文
109
111
  | `cctrans test <文本>` | 翻译一段文本,验证引擎 |
110
112
  | `cctrans install` / `cctrans uninstall` | 注册 / 移除钩子 |
111
113
 
114
+ ## 🧩 显示模式
115
+
116
+ `line`(默认)逐行对照:每行英文下面一行译文,随回复流式出现。`section` 让英文完全按 Claude 的流式输出原样呈现,在**一个块完成时**插入一段成组译文——对列表很多的回复要安静得多:
117
+
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
+
112
136
  ## 🌐 翻译后端
113
137
 
114
138
  | 后端 | 前提 | 速度 | 质量 | 说明 |
@@ -151,7 +175,8 @@ cctrans lang zh-Hans # 简体中文(默认)
151
175
 
152
176
  - 钩子在**流式输出中**按片段触发,每段单独翻译并就地替换——所以译文会随英文逐段出现。
153
177
  - 钩子有 **10 秒**超时;本工具内部 9 秒兜底。任何错误/超时/超长(>9000 字符)都会**安全回退成原始英文**,绝不卡住会话。
154
- - 每行译文按内容哈希**缓存**(`~/.cc-translate/cache`),重绘和重复文本零成本。
178
+ - 每行译文按内容哈希**缓存**(`~/.cc-translate/cache`),重绘和重复文本零成本。两种模式共享同一缓存。
179
+ - section 模式下,进行中块的文本会缓冲在 `~/.cc-translate/msgstate`(落盘暴露面与缓存相同);消息完成后该文件即删除,过期残留文件 24 小时后清理。
155
180
  - 用 `openai` 时每段约一次 API 调用(~$0.0001),流式输出会比纯英文多约 1 秒/段的延迟;`google` 更快但质量略低。
156
181
 
157
182
  ## 🔗 关注项目
package/README.zh-Hant.md CHANGED
@@ -28,6 +28,7 @@
28
28
  ## ✨ 特性
29
29
 
30
30
  - 🪞 **行內雙語顯示** —— 譯文隨回覆串流出現在每行英文下方,就在對話裡
31
+ - 🧩 **兩種排版** —— 逐行對照,或 `cctrans mode section`:整塊英文先出,再跟一段成組譯文
31
32
  - 🧾 **非破壞性** —— 轉錄與模型上下文保持純英文;skills、文件、程式碼不受影響
32
33
  - 🆓 **主對話零 token** —— 翻譯走獨立低成本後端(也有免費選項),完全在 Claude Code 工作階段之外
33
34
  - ⌨️ **輸入翻譯(beta)** —— 用母語打字,模型按英文工作、按英文回覆(`cctrans input on`)
@@ -99,9 +100,10 @@ Claude 串流輸出英文
99
100
  | `cctrans on` / `cctrans off` / `cctrans toggle` | 開 / 關 / 切換翻譯 |
100
101
  | `cctrans status` | 檢視狀態(開關、鉤子、後端、語言) |
101
102
  | `cctrans lang [code]` | 檢視/切換目標語言:`zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
103
+ | `cctrans mode [line\|section]` | 排版:譯文跟在每行下方,或按區塊成組 |
102
104
  | `cctrans backend <id>` | 切換翻譯引擎 |
103
105
  | `cctrans backends` | 列出所有引擎及其可用性 |
104
- | `cctrans setup` | 互動式精靈:語言、後端、API key |
106
+ | `cctrans setup` | 互動式精靈:語言、顯示模式、後端、API key |
105
107
  | `cctrans key [id] [value]` | 管理 `~/.cc-translate/keys.json` 中的 API key |
106
108
  | `cctrans input on` / `cctrans input off` | **(beta)** 把非英文輸入翻譯成英文(作為上下文傳給模型) |
107
109
  | `cctrans input threshold <n>` | 觸發輸入翻譯的非拉丁字元數(預設 4) |
@@ -109,6 +111,28 @@ Claude 串流輸出英文
109
111
  | `cctrans test <文字>` | 翻譯一段文字,驗證引擎 |
110
112
  | `cctrans install` / `cctrans uninstall` | 註冊 / 移除鉤子 |
111
113
 
114
+ ## 🧩 顯示模式
115
+
116
+ `line`(預設)逐行對照:每行英文下方一行譯文,隨回覆串流出現。`section` 讓英文完全按 Claude 的串流輸出原樣呈現,在**一個區塊完成時**插入一段成組譯文——對列表很多的回覆要安靜得多:
117
+
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
+
112
136
  ## 🌐 翻譯後端
113
137
 
114
138
  | 後端 | 前提 | 速度 | 品質 | 說明 |
@@ -151,7 +175,8 @@ cctrans lang zh-Hans # 簡體中文(預設)
151
175
 
152
176
  - 鉤子在**串流輸出中**按片段觸發,每段單獨翻譯並就地替換——所以譯文會隨英文逐段出現。
153
177
  - 鉤子有 **10 秒**逾時;本工具內部 9 秒保底。任何錯誤/逾時/超長(>9000 字元)都會**安全回退成原始英文**,絕不卡住工作階段。
154
- - 每行譯文按內容雜湊**快取**(`~/.cc-translate/cache`),重繪與重複文字零成本。
178
+ - 每行譯文按內容雜湊**快取**(`~/.cc-translate/cache`),重繪與重複文字零成本。兩種模式共享同一快取。
179
+ - section 模式下,進行中區塊的文字會緩衝在 `~/.cc-translate/msgstate`(落盤暴露面與快取相同);訊息完成後該檔案即刪除,逾期殘留檔案 24 小時後清理。
155
180
  - 用 `openai` 時每段約一次 API 呼叫(~$0.0001),串流輸出會比純英文多約 1 秒/段的延遲;`google` 較快但品質略低。
156
181
 
157
182
  ## 🔗 關注專案
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,6 +117,9 @@ 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(' mode : ' + st.mode + C.dim(st.mode === 'section'
121
+ ? ' (grouped per block; translation appears when the block completes)'
122
+ : ' (translation under each English line)'));
120
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);
@@ -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
  }
@@ -183,13 +199,14 @@ ${C.bold('Control')}
183
199
  cctrans input threshold <n> non-Latin chars that trigger input translation (default 4)
184
200
  cctrans status show current state
185
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
186
203
  cctrans backend <id> choose translation engine
187
204
  cctrans backends list engines + availability
188
205
 
189
206
  ${C.bold('Setup')}
190
207
  cctrans install register hooks (+ link cctrans), then run setup
191
- cctrans setup interactive wizard: language, backend, API keys
192
- (flags: --lang --backend --key --input --yes)
208
+ cctrans setup interactive wizard: language, display mode, backend, API keys
209
+ (flags: --lang --mode --backend --key --input --yes)
193
210
  cctrans key [id] [value] manage API keys in ~/.cc-translate/keys.json
194
211
  (ids: openai, anthropic, deepl, azure, azure-region)
195
212
  cctrans uninstall remove the hooks
@@ -204,8 +221,8 @@ ${C.dim('Tip: toggle from inside Claude Code by typing !cctrans off / !cctran
204
221
  async function main() {
205
222
  const [cmd, ...rest] = process.argv.slice(2);
206
223
  switch (cmd) {
207
- case 'on': setState({ enabled: true }); console.log(C.green('✓ translation ON')); break;
208
- 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;
209
226
  case 'toggle': { const s = getState(); const n = setState({ enabled: !s.enabled }); console.log('✓ translation ' + (n.enabled ? C.green('ON') : C.red('OFF'))); break; }
210
227
  case 'backend': {
211
228
  const id = rest[0];
@@ -219,6 +236,20 @@ async function main() {
219
236
  break;
220
237
  }
221
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
+ }
222
253
  case 'lang': {
223
254
  const code = rest[0];
224
255
  if (!code) { const st = getState(); console.log('lang = ' + st.target + C.dim(' (available: ' + listLangs().join(', ') + '; aliases: zh-CN, zh-TW)')); break; }
@@ -241,6 +272,7 @@ async function main() {
241
272
  const flag = (name) => { const i = rest.indexOf(name); return i > -1 ? rest[i + 1] : undefined; };
242
273
  await require('../src/setup').runSetup({
243
274
  lang: flag('--lang'),
275
+ mode: flag('--mode'),
244
276
  backend: flag('--backend'),
245
277
  key: flag('--key'),
246
278
  input: flag('--input'),
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctrans",
3
- "version": "0.3.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,6 +46,7 @@ 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
49
+ mode: 'line', // display layout: 'line' (ZH under each line) or 'section' (grouped per block)
32
50
  inputEn: false, // input translation (beta, prompt -> English) off until enabled
33
51
  inputMinChars: 4, // non-Latin chars in a prompt that trigger input translation
34
52
  };
@@ -52,6 +70,7 @@ function setState(patch) {
52
70
  anthropicModel: next.anthropicModel,
53
71
  azureEndpoint: next.azureEndpoint,
54
72
  marker: next.marker,
73
+ mode: next.mode,
55
74
  inputEn: next.inputEn,
56
75
  inputMinChars: next.inputMinChars,
57
76
  };
@@ -61,4 +80,4 @@ function setState(patch) {
61
80
  return next;
62
81
  }
63
82
 
64
- 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 };
package/src/interleave.js CHANGED
@@ -16,6 +16,22 @@ function looksLikeCodeish(s) {
16
16
  return false;
17
17
  }
18
18
 
19
+ // Leading BLOCK markdown (heading / list marker / blockquote) must be split
20
+ // off before translation: fed to an MT backend it gets kept or mangled, and
21
+ // re-rendered mid-line after the ↳ marker it shows up as literal "##" / "-" /
22
+ // ">" (the translated line no longer starts with the prefix, so the renderer
23
+ // treats it as text). Translate the content only; re-apply structure when
24
+ // building the translated line.
25
+ function splitBlockPrefix(line) {
26
+ let m = line.match(/^(\s{0,3}#{1,6}\s+)(.*)$/); // heading
27
+ if (m) return { prefix: m[1], content: m[2], block: 'heading' };
28
+ m = line.match(/^(\s*(?:[-*+]|\d{1,3}[.)])\s+)(.*)$/); // list item
29
+ if (m) return { prefix: m[1], content: m[2], block: 'list' };
30
+ m = line.match(/^(\s*(?:>\s*)+)(.*)$/); // blockquote (possibly nested)
31
+ if (m) return { prefix: m[1], content: m[2], block: 'quote' };
32
+ return { prefix: '', content: line, block: null };
33
+ }
34
+
19
35
  // A code fence (```), and therefore "are we inside a code block?", can span
20
36
  // multiple MessageDisplay deltas. The caller threads the ending fence state of
21
37
  // one delta into the next (keyed by message_id), so classify takes an initial
@@ -30,17 +46,34 @@ function classify(lines, inFenceInit, target) {
30
46
  if (line.trim() === '') { plan.push({ line, kind: 'blank' }); continue; }
31
47
  if (isProbablyTarget(line, target)) { plan.push({ line, kind: 'target' }); continue; }
32
48
  if (looksLikeCodeish(line)) { plan.push({ line, kind: 'code' }); continue; }
33
- plan.push({ line, kind: 'prose' });
49
+ const { prefix, content, block } = splitBlockPrefix(line);
50
+ if (looksLikeCodeish(content)) { plan.push({ line, kind: 'code' }); continue; } // e.g. "- /path/to/file"
51
+ plan.push({ line, kind: 'prose', prefix, content, block });
34
52
  }
35
53
  return { plan, inFence };
36
54
  }
37
55
 
38
- // How to place the Chinese line under the English line.
56
+ // The translated line mirrors the English line's block structure:
57
+ // heading "## T" -> "## ↳ 译" (same heading style)
58
+ // quote "> T" -> "> ↳ 译" (stays inside the quote)
59
+ // list "- T" -> " ↳ 译" (same-width indent — a re-applied "- " would
60
+ // render a second bullet)
61
+ // plain "T" -> "↳ 译"
62
+ // demoteStructure drops the heading/quote prefix (plain "↳ 译"): in a grouped
63
+ // section block displaced from its English line, a re-applied "## "/"> " would
64
+ // render a REAL heading / fresh blockquote detached from what it translates.
65
+ function zhLineFor(p, zh, marker, demoteStructure) {
66
+ if (p.block === 'list') return ' '.repeat(p.prefix.length) + marker + zh;
67
+ if (demoteStructure && (p.block === 'heading' || p.block === 'quote')) return marker + zh;
68
+ return p.prefix + marker + zh;
69
+ }
70
+
71
+ // How to place the Chinese line under the English line (line mode).
39
72
  // hardBreak=true uses a CommonMark hard line break (two trailing spaces) so the
40
73
  // two lines stay separate even if displayContent is markdown-rendered.
41
- function pair(enLine, zhLine, marker, hardBreak) {
74
+ function pair(p, zh, marker, hardBreak) {
42
75
  const br = hardBreak ? ' \n' : '\n';
43
- return enLine + br + marker + zhLine;
76
+ return p.line + br + zhLineFor(p, zh, marker, false);
44
77
  }
45
78
 
46
79
  // Returns { displayContent, inFence }:
@@ -64,7 +97,7 @@ async function buildDisplayContent(rawDelta, opts) {
64
97
  const proseIdx = [];
65
98
  const proseLines = [];
66
99
  for (let i = 0; i < plan.length; i++) {
67
- if (plan[i].kind === 'prose') { proseIdx.push(i); proseLines.push(plan[i].line); }
100
+ if (plan[i].kind === 'prose') { proseIdx.push(i); proseLines.push(plan[i].content); }
68
101
  }
69
102
  if (proseLines.length === 0) return { displayContent: null, inFence }; // nothing to translate
70
103
 
@@ -79,7 +112,7 @@ async function buildDisplayContent(rawDelta, opts) {
79
112
  const p = plan[i];
80
113
  if (p.kind === 'prose') {
81
114
  const t = zhFor[i];
82
- if (t && t.trim() && t.trim() !== p.line.trim()) out.push(pair(p.line, t, marker, hardBreak));
115
+ if (t && t.trim() && t.trim() !== p.content.trim()) out.push(pair(p, t, marker, hardBreak));
83
116
  else out.push(p.line);
84
117
  } else {
85
118
  out.push(p.line);
@@ -90,4 +123,124 @@ async function buildDisplayContent(rawDelta, opts) {
90
123
  return { displayContent: dc, inFence };
91
124
  }
92
125
 
93
- module.exports = { buildDisplayContent, classify, looksLikeCodeish };
126
+ // ---------------------------------------------------------------------------
127
+ // Section mode: English streams untouched; a section's translation is spliced
128
+ // in as one grouped "↳" block when the section closes.
129
+ //
130
+ // A section = a maximal run of consecutive prose lines. Boundaries are
131
+ // properties of the TEXT, never of delta chunking (deltas batch arbitrarily —
132
+ // the same reply can arrive as 3 deltas in one run and 5 in another), which is
133
+ // what makes a full repaint (replay from index 0) reproduce identical output:
134
+ // - a real blank line (the last split element '' merely encodes the delta's
135
+ // trailing "\n" — a continuing block, not a blank);
136
+ // - any code/fence/target-language line;
137
+ // - a heading, which closes the run before it AND itself: a displaced
138
+ // "## ↳ 译" would render as a real heading below the block it titles;
139
+ // - the soft buffer cap (deferred past list items so a forced splice never
140
+ // lands mid-list, with a hard ceiling as backstop);
141
+ // - final:true.
142
+
143
+ const SECTION_CAP = 6000; // soft cap on buffered EN chars before a forced flush
144
+ const SECTION_HARD_CAP = 9000; // flush even mid-list past this
145
+
146
+ // Pure, synchronous segmentation — no I/O, no await, so the hook can persist
147
+ // the resulting buffer BEFORE translation starts (at-most-once flush: a crash
148
+ // or timeout after the save can only drop a section's translation, never
149
+ // replay it at a wrong position).
150
+ // opts: {inFence, buf (pending entries from prior deltas), target, final}
151
+ // Returns:
152
+ // out — the delta's own lines, verbatim (splice skeleton)
153
+ // flushes — [{pos, entries}]: closed sections and where in `out` their ZH
154
+ // block goes (right after the section's last English line)
155
+ // buf — entries still pending (the open section), to persist
156
+ // inFence — fence state at end of delta, to persist
157
+ function planSections(rawDelta, opts) {
158
+ opts = opts || {};
159
+ const lines = String(rawDelta).split('\n');
160
+ const { plan, inFence } = classify(lines, opts.inFence, opts.target || 'zh-Hans');
161
+ const out = [];
162
+ const flushes = [];
163
+ let pending = (opts.buf || []).slice();
164
+ let pendingChars = pending.reduce((n, e) => n + e.content.length, 0);
165
+ const flush = () => {
166
+ if (pending.length) { flushes.push({ pos: out.length, entries: pending }); pending = []; pendingChars = 0; }
167
+ };
168
+ for (let i = 0; i < plan.length; i++) {
169
+ const p = plan[i];
170
+ if (p.kind === 'prose') {
171
+ if (p.block === 'heading') flush(); // close the run before the heading
172
+ out.push(p.line);
173
+ pending.push({ line: p.line, prefix: p.prefix, content: p.content, block: p.block });
174
+ pendingChars += p.content.length;
175
+ if (p.block === 'heading') flush(); // ...and the heading itself
176
+ else if (pendingChars > SECTION_CAP && (p.block !== 'list' || pendingChars > SECTION_HARD_CAP)) flush();
177
+ continue;
178
+ }
179
+ if (p.kind === 'blank') {
180
+ const terminalArtifact = i === plan.length - 1 && p.line === '';
181
+ if (!terminalArtifact) flush();
182
+ out.push(p.line);
183
+ continue;
184
+ }
185
+ flush(); // code/fence/target line interrupts the section, then passes through
186
+ out.push(p.line);
187
+ }
188
+ if (opts.final) flush();
189
+ return { out, flushes, buf: pending, inFence };
190
+ }
191
+
192
+ // Translate all flushed sections (one batch call) and splice each grouped ZH
193
+ // block into the out-skeleton. Returns the displayContent string, or null for
194
+ // "leave this delta as the original English" (nothing survived translation, or
195
+ // over the cap). Translation is per-LINE (prefix-stripped), so the sha1 cache
196
+ // is shared with line mode and the backends' line contracts hold unchanged.
197
+ async function renderSections(planned, opts) {
198
+ opts = opts || {};
199
+ const marker = opts.marker || '↳ ';
200
+ const cap = opts.cap || 9000;
201
+ if (!planned.flushes.length) return null;
202
+
203
+ const contents = [];
204
+ for (const f of planned.flushes) for (const e of f.entries) contents.push(e.content);
205
+ const zh = await translateLines(contents, {
206
+ target: opts.target, backend: opts.backend, model: opts.model, timeoutMs: opts.timeoutMs,
207
+ });
208
+
209
+ let k = 0;
210
+ const blocks = [];
211
+ for (const f of planned.flushes) {
212
+ // Single-line sections keep line-mode structure; a uniform quote run keeps
213
+ // its "> " (the block continues the same blockquote). Only mixed grouped
214
+ // blocks demote structure prefixes.
215
+ const grouped = f.entries.length > 1;
216
+ const allQuote = grouped && f.entries.every((e) => e.block === 'quote');
217
+ const blockLines = [];
218
+ for (const e of f.entries) {
219
+ const t = zh[k++];
220
+ if (t && t.trim() && t.trim() !== e.content.trim()) blockLines.push(zhLineFor(e, t, marker, grouped && !allQuote));
221
+ }
222
+ if (blockLines.length) blocks.push({ pos: f.pos, lines: blockLines });
223
+ }
224
+ if (!blocks.length) return null;
225
+
226
+ const splice = () => {
227
+ const merged = planned.out.slice();
228
+ for (let i = blocks.length - 1; i >= 0; i--) merged.splice(blocks[i].pos, 0, ...blocks[i].lines);
229
+ return merged.join('\n');
230
+ };
231
+ let dc = splice();
232
+ // Over the cap: shed whole ZH blocks (largest first) instead of dropping the
233
+ // delta's entire translation — their sections are already committed out of
234
+ // the buffer, so a shed block is simply lost, never repositioned.
235
+ while (dc.length > cap && blocks.length) {
236
+ let big = 0;
237
+ for (let i = 1; i < blocks.length; i++) {
238
+ if (blocks[i].lines.join('\n').length > blocks[big].lines.join('\n').length) big = i;
239
+ }
240
+ blocks.splice(big, 1);
241
+ dc = blocks.length ? splice() : null;
242
+ }
243
+ return dc;
244
+ }
245
+
246
+ module.exports = { buildDisplayContent, classify, looksLikeCodeish, planSections, renderSections };
package/src/setup.js CHANGED
@@ -1,11 +1,12 @@
1
1
  'use strict';
2
- // Interactive setup wizard: language -> backend -> API-key entry -> input
3
- // translation (beta) -> live verification -> save. Re-runnable via `cctrans
4
- // setup`; non-interactive with flags (--lang, --backend, --key, --input,
5
- // --yes). Keys go to keys.json only — the shell environment is never read.
2
+ // Interactive setup wizard: language -> display mode -> backend -> API-key
3
+ // entry -> input translation (beta) -> live verification -> save. Re-runnable
4
+ // via `cctrans setup`; non-interactive with flags (--lang, --mode, --backend,
5
+ // --key, --input, --yes). Keys go to keys.json only — the shell environment is
6
+ // never read.
6
7
 
7
8
  const readline = require('node:readline/promises');
8
- const { getState, setState } = require('./config');
9
+ const { getState, setState, MODES } = require('./config');
9
10
  const { listLangs, getLang, normalizeLang } = require('./langs');
10
11
  const { listBackends, getBackend } = require('./backends');
11
12
  const keys = require('./keys');
@@ -38,7 +39,7 @@ async function runSetup(opts) {
38
39
  let lang = opts.lang;
39
40
  if (!lang) {
40
41
  const codes = listLangs();
41
- console.log('\n' + C.bold('Target language') + ' — translations appear under each English line:');
42
+ console.log('\n' + C.bold('Target language') + ' — replies show English + your language inline:');
42
43
  codes.forEach((c, i) => console.log(' ' + (i + 1) + '. ' + c.padEnd(8) + C.dim(getLang(c).name)));
43
44
  const cur = getState().target;
44
45
  const a = await ask('Pick a number or code', cur);
@@ -47,7 +48,18 @@ async function runSetup(opts) {
47
48
  if (!getLang(lang)) { console.error(C.red('unsupported language: ' + lang)); return false; }
48
49
  lang = normalizeLang(lang);
49
50
 
50
- // 2. Backend
51
+ // 2. Display mode
52
+ let mode = opts.mode;
53
+ if (!mode) {
54
+ console.log('\n' + C.bold('Display mode') + ':');
55
+ console.log(' 1. line ' + C.dim('translation under each English line, as it streams'));
56
+ console.log(' 2. section ' + C.dim('English block first, then its translation — appears when the block completes'));
57
+ const a = await ask('Pick a number or name', getState().mode);
58
+ mode = a === '1' ? 'line' : a === '2' ? 'section' : a;
59
+ }
60
+ if (!MODES.includes(mode)) { console.error(C.red('unknown mode: ' + mode + ' (available: ' + MODES.join(', ') + ')')); return false; }
61
+
62
+ // 3. Backend
51
63
  let backend = opts.backend;
52
64
  if (!backend) {
53
65
  console.log('\n' + C.bold('Translation backend') + ':');
@@ -62,7 +74,7 @@ async function runSetup(opts) {
62
74
  const b = getBackend(backend);
63
75
  if (!b) { console.error(C.red('unknown backend: ' + backend)); return false; }
64
76
 
65
- // 3. Key entry for the chosen backend, if missing (keys live ONLY in
77
+ // 4. Key entry for the chosen backend, if missing (keys live ONLY in
66
78
  // keys.json — shell env vars are never read)
67
79
  if (!b.available() && keys.KEY_IDS.includes(b.id)) {
68
80
  const v = opts.key || (await ask('Paste your ' + b.id + ' API key (enter to skip)', ''));
@@ -73,7 +85,7 @@ async function runSetup(opts) {
73
85
  }
74
86
  }
75
87
 
76
- // 4. Input translation (beta, opt-in): prompt -> English as context
88
+ // 5. Input translation (beta, opt-in): prompt -> English as context
77
89
  let inputEn = typeof opts.input === 'string' ? opts.input === 'on' : getState().inputEn;
78
90
  if (opts.input === undefined && rl) {
79
91
  console.log('\n' + C.bold('Input translation') + ' ' + C.dim('(beta)') +
@@ -84,13 +96,13 @@ async function runSetup(opts) {
84
96
  inputEn = /^y(es)?$/i.test(a);
85
97
  }
86
98
 
87
- // 5. Save config
88
- setState({ target: lang, backend, inputEn });
89
- console.log('\n' + C.green('✓') + ' saved: lang=' + lang + ' (' + getLang(lang).name + '), backend=' + backend +
90
- ', input=' + (inputEn ? 'on' : 'off') +
99
+ // 6. Save config
100
+ setState({ target: lang, mode, backend, inputEn });
101
+ console.log('\n' + C.green('✓') + ' saved: lang=' + lang + ' (' + getLang(lang).name + '), mode=' + mode +
102
+ ', backend=' + backend + ', input=' + (inputEn ? 'on' : 'off') +
91
103
  (b.available() ? '' : C.red(' (no key yet — will fall back to google)')));
92
104
 
93
- // 6. Live verification
105
+ // 7. Live verification
94
106
  process.stdout.write(C.dim('verifying… '));
95
107
  try {
96
108
  const { displayContent } = await buildDisplayContent('Setup verification: translation works.\n', {