diff --git a/CHANGELOG.md b/CHANGELOG.md index 4238f77b..153aef9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,24 @@ real world uses." ### Added +- **`/translate` opt-in: respond in the user's UI locale, with a + post-hoc fallback for English that leaks through** (harvested + from PR #1462 by **@YaYII**). Two-layer design: when the user + enables translation via the `/translate` slash command, a + `## Language Output Requirement` block is appended to the + system prompt instructing the model to reply in the resolved + session locale (Simplified Chinese, Traditional Chinese, + Japanese, or Brazilian Portuguese — code identifiers and + user-requested English code blocks are exempt). For replies + that still surface English despite the directive, a heuristic + in `tui::translation` (Latin-vs-CJK character ratio with + weighting for CJK information density) detects the leak and + invokes a focused per-message translation API call to render + the localised version before display. Both layers are off by + default and have no effect on installs that don't enable them. + Trust-boundary scope: opt-in only, system prompt addition is + conditional on the runtime flag, no model behaviour change for + English-locale users. - **AtlasCloud is now a first-class provider** (harvested from PR #1436 by **@lucaszhu-hue**). AtlasCloud hosts the V4 family (and other DeepSeek-compatible models) on its own endpoint at diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index c8119af9..b7ec2145 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -142,7 +142,6 @@ const ALLOW_INSECURE_HTTP_ENV: &str = "DEEPSEEK_ALLOW_INSECURE_HTTP"; pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 8 * 1024 * 1024; // 8 MB pub(super) const SSE_BACKPRESSURE_SLEEP_MS: u64 = 10; pub(super) const SSE_MAX_LINES_PER_CHUNK: usize = 256; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConnectionState { Healthy, @@ -573,6 +572,62 @@ fn build_default_headers( } impl DeepSeekClient { + /// Translate text to the requested target language using a focused + /// non-streaming chat completion call on the supplied model. + /// + /// This is a lightweight translation service — no tool calls, no + /// streaming, no conversation history. The dedicated translation agent + /// receives the source text and returns only the translated result. + pub async fn translate( + &self, + text: &str, + model: &str, + target_language: &str, + ) -> Result { + let url = api_url(&self.base_url, "chat/completions"); + let mut body = serde_json::json!({ + "model": model, + "messages": [ + { + "role": "system", + "content": format!( + "You are a professional translator. Your ONLY task is to translate text to {target_language}. \ + Rules:\n\ + 1. Output ONLY the translation, nothing else — no explanations, no notes, no quotes.\n\ + 2. Preserve all code blocks (```...```), URLs, file paths, command names, \ + and technical terms like API names, function names, and library names untranslated.\n\ + 3. Keep Markdown formatting (headings, lists, bold, italics, links) intact.\n\ + 4. Translate all natural-language prose naturally and professionally.\n\ + 5. Do NOT add any prefix, suffix, or commentary.\n\ + 6. If the input is already in {target_language} or contains no prose to translate, \ + return it as-is." + ) + }, + { + "role": "user", + "content": text + } + ], + "max_tokens": 4096, + "temperature": 0.1, + "stream": false + }); + apply_reasoning_effort(&mut body, Some("off"), self.api_provider); + + let response = self + .send_with_retry(|| self.http_client.post(&url).json(&body)) + .await?; + + let value: serde_json::Value = response.json().await?; + let translated = value["choices"][0]["message"]["content"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("translate: unexpected API response shape"))? + .trim() + .to_string(); + + Ok(translated) + } + /// List available models from the provider. pub async fn list_models(&self) -> Result> { let url = api_url(&self.base_url, "models"); diff --git a/crates/tui/src/commands/change.rs b/crates/tui/src/commands/change.rs index 93b71c9d..2b99ff3f 100644 --- a/crates/tui/src/commands/change.rs +++ b/crates/tui/src/commands/change.rs @@ -62,6 +62,7 @@ pub fn change(app: &mut App) -> CommandResult { ); let lang_name = match locale { Locale::ZhHans => "Simplified Chinese (中文)", + Locale::ZhHant => "Traditional Chinese (繁體中文)", Locale::Ja => "Japanese (日本語)", Locale::PtBr => "Brazilian Portuguese (Português)", // Fallback — should never reach here since we check En above. diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index a8b8964a..9a10b786 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -85,6 +85,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { match l { crate::localization::Locale::En => "en", crate::localization::Locale::ZhHans => "zh-Hans", + crate::localization::Locale::ZhHant => "zh-Hant", crate::localization::Locale::Ja => "ja", crate::localization::Locale::PtBr => "pt-BR", } diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 0f05fa6a..6a9088ec 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -307,6 +307,21 @@ pub fn home_dashboard(app: &mut App) -> CommandResult { CommandResult::message(stats) } +/// Toggle output translation to the current system language on/off. +/// +/// When enabled, the model is instructed to respond in the current locale and an +/// interception layer translates any remaining English output before it +/// reaches the user. +pub fn translate(app: &mut App) -> CommandResult { + app.translation_enabled = !app.translation_enabled; + let locale = app.ui_locale; + if app.translation_enabled { + CommandResult::message(tr(locale, MessageId::CmdTranslateOn)) + } else { + CommandResult::message(tr(locale, MessageId::CmdTranslateOff)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 46ef8482..a3616450 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -369,6 +369,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/tokens", description_id: MessageId::CmdTokensDescription, }, + CommandInfo { + name: "translate", + aliases: &["translation", "transale"], + usage: "/translate", + description_id: MessageId::CmdTranslateDescription, + }, CommandInfo { name: "system", aliases: &[], @@ -562,6 +568,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "logout" => config::logout(app), // Debug commands + "translate" | "translation" | "transale" => core::translate(app), "tokens" => debug::tokens(app), "cost" => debug::cost(app), "cache" => debug::cache(app, arg), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 83726767..d99d6192 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -96,6 +96,9 @@ pub struct EngineConfig { /// Resolved via `expand_path` so `~` works. pub instructions: Vec, pub project_context_pack_enabled: bool, + /// When true, the model is instructed to respond in the current locale + /// and a post-hoc translation layer replaces remaining English output. + pub translation_enabled: bool, /// Maximum number of assistant steps before stopping. pub max_steps: u32, /// Maximum number of concurrently active subagents. @@ -172,6 +175,7 @@ impl Default for EngineConfig { skills_dir: crate::skills::default_skills_dir(), instructions: Vec::new(), project_context_pack_enabled: true, + translation_enabled: false, max_steps: 100, max_subagents: DEFAULT_MAX_SUBAGENTS, features: Features::with_defaults(), @@ -453,6 +457,7 @@ impl Engine { goal_objective: config.goal_objective.as_deref(), project_context_pack_enabled: config.project_context_pack_enabled, locale_tag: &config.locale_tag, + translation_enabled: config.translation_enabled, }, session.approval_mode, ); @@ -593,6 +598,7 @@ impl Engine { trust_mode, auto_approve, approval_mode, + translation_enabled, } => { self.handle_send_message( content, @@ -606,6 +612,7 @@ impl Engine { trust_mode, auto_approve, approval_mode, + translation_enabled, ) .await; } @@ -810,6 +817,7 @@ impl Engine { self.session.trust_mode, self.session.auto_approve, self.session.approval_mode, + self.config.translation_enabled, ) .await; } @@ -897,6 +905,7 @@ impl Engine { trust_mode: bool, auto_approve: bool, approval_mode: crate::tui::approval::ApprovalMode, + translation_enabled: bool, ) { // Reset cancel token for fresh turn (in case previous was cancelled) self.reset_cancel_token(); @@ -976,6 +985,7 @@ impl Engine { self.config.allow_shell = allow_shell; self.session.trust_mode = trust_mode; self.config.trust_mode = trust_mode; + self.config.translation_enabled = translation_enabled; self.session.auto_approve = auto_approve; self.session.approval_mode = if auto_approve { crate::tui::approval::ApprovalMode::Auto @@ -1887,6 +1897,7 @@ impl Engine { goal_objective: self.config.goal_objective.as_deref(), project_context_pack_enabled: self.config.project_context_pack_enabled, locale_tag: &self.config.locale_tag, + translation_enabled: self.config.translation_enabled, }, self.session.approval_mode, ); diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index f3b127fa..b77385be 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -30,6 +30,7 @@ pub enum Op { trust_mode: bool, auto_approve: bool, approval_mode: ApprovalMode, + translation_enabled: bool, }, /// Cancel the current request diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 6e939215..941dd060 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -36,6 +36,7 @@ pub enum Locale { En, Ja, ZhHans, + ZhHant, PtBr, } @@ -45,10 +46,21 @@ impl Locale { Self::En => "en", Self::Ja => "ja", Self::ZhHans => "zh-Hans", + Self::ZhHant => "zh-Hant", Self::PtBr => "pt-BR", } } + pub fn translation_target_name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "Japanese (日本語)", + Self::ZhHans => "Simplified Chinese (简体中文)", + Self::ZhHant => "Traditional Chinese (繁體中文)", + Self::PtBr => "Brazilian Portuguese (Português do Brasil)", + } + } + #[allow(dead_code)] pub fn spec(self) -> LocaleSpec { match self { @@ -76,6 +88,14 @@ impl Locale { fallback: "en", coverage: LocaleCoverage::V076Core, }, + Self::ZhHant => LocaleSpec { + tag: "zh-Hant", + display_name: "Chinese Traditional", + script: "Hant", + direction: TextDirection::Ltr, + fallback: "zh-Hans", + coverage: LocaleCoverage::V076Core, + }, Self::PtBr => LocaleSpec { tag: "pt-BR", display_name: "Portuguese (Brazil)", @@ -89,7 +109,7 @@ impl Locale { #[allow(dead_code)] pub fn shipped() -> &'static [Self] { - &[Self::En, Self::Ja, Self::ZhHans, Self::PtBr] + &[Self::En, Self::Ja, Self::ZhHans, Self::ZhHant, Self::PtBr] } } @@ -271,6 +291,12 @@ pub enum MessageId { CmdSystemDescription, CmdTaskDescription, CmdTokensDescription, + CmdTranslateDescription, + CmdTranslateOff, + CmdTranslateOn, + TranslationInProgress, + TranslationComplete, + TranslationFailed, CmdTrustDescription, CmdLspDescription, CmdShareDescription, @@ -492,6 +518,12 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdSystemDescription, MessageId::CmdTaskDescription, MessageId::CmdTokensDescription, + MessageId::CmdTranslateDescription, + MessageId::CmdTranslateOff, + MessageId::CmdTranslateOn, + MessageId::TranslationInProgress, + MessageId::TranslationComplete, + MessageId::TranslationFailed, MessageId::CmdTrustDescription, MessageId::CmdLspDescription, MessageId::CmdShareDescription, @@ -637,6 +669,56 @@ pub fn tr(locale: Locale, id: MessageId) -> &'static str { fallback_translation(translation(locale, id), id) } +pub fn thinking_translation_placeholder(locale: Locale) -> &'static str { + match locale { + Locale::En => "Thinking; translating when complete...", + Locale::Ja => "思考中です。完了後に日本語へ翻訳します...", + Locale::ZhHans => "正在思考,完成后翻译为简体中文...", + Locale::ZhHant => "正在思考,完成後翻譯為繁體中文...", + Locale::PtBr => "Pensando; traduzindo ao concluir...", + } +} + +pub fn thinking_translation_in_progress(locale: Locale) -> &'static str { + match locale { + Locale::En => "Translating thinking content...", + Locale::Ja => "思考内容を翻訳中...", + Locale::ZhHans => "正在翻译思考内容...", + Locale::ZhHant => "正在翻譯思考內容...", + Locale::PtBr => "Traduzindo o conteúdo de raciocínio...", + } +} + +pub fn thinking_translation_complete(locale: Locale) -> &'static str { + match locale { + Locale::En => "Thinking translation complete", + Locale::Ja => "思考内容の翻訳が完了しました", + Locale::ZhHans => "思考内容翻译完成", + Locale::ZhHant => "思考內容翻譯完成", + Locale::PtBr => "Tradução do raciocínio concluída", + } +} + +pub fn thinking_translation_failed(locale: Locale) -> &'static str { + match locale { + Locale::En => "Thinking translation failed", + Locale::Ja => "思考内容の翻訳に失敗しました", + Locale::ZhHans => "思考内容翻译失败", + Locale::ZhHant => "思考內容翻譯失敗", + Locale::PtBr => "Falha ao traduzir o raciocínio", + } +} + +pub fn hidden_translation_failed(locale: Locale) -> &'static str { + match locale { + Locale::En => "Translation failed; original text is hidden.", + Locale::Ja => "翻訳に失敗しました。原文は非表示です。", + Locale::ZhHans => "翻译失败,原文已隐藏。", + Locale::ZhHant => "翻譯失敗,原文已隱藏。", + Locale::PtBr => "A tradução falhou; o texto original está oculto.", + } +} + #[allow(dead_code)] pub fn missing_message_ids(locale: Locale) -> Vec { ALL_MESSAGE_IDS @@ -733,7 +815,7 @@ fn parse_locale(value: &str) -> Option { || value.contains("-hk") || value.contains("-mo") { - return None; + return Some(Locale::ZhHant); } return Some(Locale::ZhHans); } @@ -869,6 +951,16 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdSystemDescription => "Show current system prompt", MessageId::CmdTaskDescription => "Manage background tasks", MessageId::CmdTokensDescription => "Show token usage for session", + MessageId::CmdTranslateDescription => { + "Toggle output translation to the current system language on/off" + } + MessageId::CmdTranslateOff => "Output translation disabled (original model output shown)", + MessageId::CmdTranslateOn => { + "Output translation enabled: model responses will be shown in your system language" + } + MessageId::TranslationInProgress => "Translating assistant output...", + MessageId::TranslationComplete => "Translation complete", + MessageId::TranslationFailed => "Translation failed", MessageId::CmdTrustDescription => { "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)" } @@ -1032,7 +1124,7 @@ fn english(id: MessageId) -> &'static str { "Pick the UI language. You can change it any time with `/settings set locale `." } MessageId::OnboardLanguageFooter => { - "Press 1-5 to choose, or Enter to keep the current setting" + "Press 1-6 to choose, or Enter to keep the current setting" } // Onboarding — API key entry. MessageId::OnboardApiKeyTitle => "Connect your DeepSeek API key", @@ -1086,10 +1178,23 @@ fn translation(locale: Locale, id: MessageId) -> Option<&'static str> { Locale::En => Some(english(id)), Locale::Ja => japanese(id), Locale::ZhHans => chinese_simplified(id), + Locale::ZhHant => traditional_chinese(id), Locale::PtBr => portuguese_brazil(id), } } +fn traditional_chinese(id: MessageId) -> Option<&'static str> { + Some(match id { + MessageId::CmdTranslateDescription => "切換輸出翻譯為目前系統語言的開關狀態", + MessageId::CmdTranslateOff => "輸出翻譯已關閉(顯示原始模型輸出)", + MessageId::CmdTranslateOn => "輸出翻譯已開啟:模型回覆將以繁體中文顯示", + MessageId::TranslationInProgress => "正在翻譯助理輸出...", + MessageId::TranslationComplete => "翻譯完成", + MessageId::TranslationFailed => "翻譯失敗", + other => chinese_simplified(other)?, + }) +} + fn japanese(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::ComposerPlaceholder => "タスクを書くか / を使う。", @@ -1218,6 +1323,14 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdSystemDescription => "現在のシステムプロンプトを表示", MessageId::CmdTaskDescription => "バックグラウンドタスクを管理", MessageId::CmdTokensDescription => "セッションのトークン使用量を表示", + MessageId::CmdTranslateDescription => "出力翻訳を現在のシステム言語に切り替え", + MessageId::CmdTranslateOff => "出力翻訳が無効になりました(元のモデル出力を表示)", + MessageId::CmdTranslateOn => { + "出力翻訳が有効になりました:モデル応答は現在のシステム言語で表示されます" + } + MessageId::TranslationInProgress => "アシスタント出力を翻訳中...", + MessageId::TranslationComplete => "翻訳が完了しました", + MessageId::TranslationFailed => "翻訳に失敗しました", MessageId::CmdTrustDescription => { "ワークスペースの信頼設定とパス別許可リストを管理(`/trust add `、`/trust list`、`/trust on|off`)" } @@ -1383,7 +1496,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::OnboardLanguageBlurb => { "UI 言語を選んでください。`/settings set locale ` でいつでも変更できます。" } - MessageId::OnboardLanguageFooter => "1〜5 で選択、または Enter で現在の設定を維持", + MessageId::OnboardLanguageFooter => "1〜6 で選択、または Enter で現在の設定を維持", // Onboarding — API key entry. MessageId::OnboardApiKeyTitle => "DeepSeek API キーを設定", MessageId::OnboardApiKeyStep1 => { @@ -1539,6 +1652,12 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdSystemDescription => "显示当前系统提示词", MessageId::CmdTaskDescription => "管理后台任务", MessageId::CmdTokensDescription => "显示本次会话的 token 用量", + MessageId::CmdTranslateDescription => "切换输出翻译为当前系统语言的开/关状态", + MessageId::CmdTranslateOff => "输出翻译已关闭(显示原始模型输出)", + MessageId::CmdTranslateOn => "输出翻译已开启:模型回复将以当前系统语言显示", + MessageId::TranslationInProgress => "正在翻译助手输出...", + MessageId::TranslationComplete => "翻译完成", + MessageId::TranslationFailed => "翻译失败", MessageId::CmdTrustDescription => { "管理工作区信任与按路径的白名单(`/trust add `、`/trust list`、`/trust on|off`)" } @@ -1686,7 +1805,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::OnboardLanguageBlurb => { "选择界面语言。可随时使用 `/settings set locale ` 修改。" } - MessageId::OnboardLanguageFooter => "按 1-5 选择,或按 Enter 保留当前设置", + MessageId::OnboardLanguageFooter => "按 1-6 选择,或按 Enter 保留当前设置", // Onboarding — API key entry. MessageId::OnboardApiKeyTitle => "连接你的 DeepSeek API 密钥", MessageId::OnboardApiKeyStep1 => { @@ -1860,6 +1979,18 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdSystemDescription => "Exibir o prompt de sistema atual", MessageId::CmdTaskDescription => "Gerenciar tarefas em segundo plano", MessageId::CmdTokensDescription => "Exibir o uso de tokens da sessão", + MessageId::CmdTranslateDescription => { + "Alternar tradução de saída para o idioma atual do sistema" + } + MessageId::CmdTranslateOff => { + "Tradução de saída desativada (saída original do modelo exibida)" + } + MessageId::CmdTranslateOn => { + "Tradução de saída ativada: as respostas serão exibidas no idioma do sistema" + } + MessageId::TranslationInProgress => "Traduzindo saída do assistente...", + MessageId::TranslationComplete => "Tradução concluída", + MessageId::TranslationFailed => "Falha na tradução", MessageId::CmdTrustDescription => { "Gerenciar a confiança do workspace e a allowlist por caminho (`/trust add `, `/trust list`, `/trust on|off`)" } @@ -2034,7 +2165,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Escolha o idioma da interface. Você pode mudá-lo a qualquer momento com `/settings set locale `." } MessageId::OnboardLanguageFooter => { - "Pressione 1-5 para escolher, ou Enter para manter a configuração atual" + "Pressione 1-6 para escolher, ou Enter para manter a configuração atual" } // Onboarding — API key entry. MessageId::OnboardApiKeyTitle => "Conecte sua chave de API DeepSeek", @@ -2097,9 +2228,10 @@ mod tests { assert_eq!(normalize_configured_locale("auto"), Some("auto")); assert_eq!(normalize_configured_locale("ja_JP.UTF-8"), Some("ja")); assert_eq!(normalize_configured_locale("zh-CN"), Some("zh-Hans")); + assert_eq!(normalize_configured_locale("zh-TW"), Some("zh-Hant")); + assert_eq!(normalize_configured_locale("zh_HK.UTF-8"), Some("zh-Hant")); assert_eq!(normalize_configured_locale("pt"), Some("pt-BR")); assert_eq!(normalize_configured_locale("pt-PT"), Some("pt-BR")); - assert_eq!(normalize_configured_locale("zh-TW"), None); } #[test] @@ -2114,6 +2246,12 @@ mod tests { }), Locale::ZhHans ); + assert_eq!( + resolve_locale_with_env("auto", |key| { + (key == "LANG").then(|| "zh_TW.UTF-8".to_string()) + }), + Locale::ZhHant + ); assert_eq!(resolve_locale_with_env("auto", |_| None), Locale::En); } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f1618462..3613fa79 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4354,6 +4354,7 @@ async fn run_exec_agent( skills_dir: config.skills_dir(), instructions: config.instructions_paths(), project_context_pack_enabled: config.project_context_pack_enabled(), + translation_enabled: false, max_steps: 100, max_subagents, features: config.features(), @@ -4405,6 +4406,7 @@ async fn run_exec_agent( allow_shell: auto_approve || config.allow_shell(), trust_mode, auto_approve, + translation_enabled: false, approval_mode: if auto_approve { crate::tui::approval::ApprovalMode::Auto } else { diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index b341a784..656e291a 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -24,6 +24,10 @@ pub struct PromptSessionContext<'a> { /// disk I/O happens inside the prompt builder, so the workspace- /// static portion of the system prompt stays cache-friendly. pub locale_tag: &'a str, + /// When true, a ## Language Output Requirement block is appended + /// to the system prompt instructing the model to respond in + /// the resolved session locale. + pub translation_enabled: bool, } /// Conventional location for the structured session-handoff artifact (#32). @@ -39,6 +43,48 @@ pub const HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md"; /// marker rather than skipped entirely so the model still sees the head. const INSTRUCTIONS_FILE_MAX_BYTES: usize = 100 * 1024; +/// System prompt block appended when `translation_enabled` is true. +/// Instructs the model to respond in the resolved session locale for all +/// natural-language output — explanations, summaries, conversation. +/// Code identifiers, untranslatable technical terms, and explicitly +/// requested English code blocks are exempt. +fn translation_output_instruction(locale_tag: &str) -> String { + let target_language = translation_target_language_for_tag(locale_tag); + format!( + "\ +## Language Output Requirement\n\ +\n\ +The user requires all responses in {target_language}. \ +Always respond in {target_language} — use natural, professional language for all \ +explanations, code comments, summaries, and conversational turns. \ +Only output English for:\n\ +- Code identifiers (variable names, function names, file paths)\n\ +- Technical terms that lack a standard translation in {target_language}\n\ +- Code blocks the user explicitly requests in English\n\n\ +This is a hard display requirement: the user does not read English, \ +so any English prose in your response will block their decision-making." + ) +} + +fn translation_target_language_for_tag(locale_tag: &str) -> &'static str { + let normalized = locale_tag.trim().to_ascii_lowercase(); + if normalized.starts_with("ja") { + "Japanese (日本語)" + } else if normalized.starts_with("zh-hant") + || normalized.contains("-tw") + || normalized.contains("-hk") + || normalized.contains("-mo") + { + "Traditional Chinese (繁體中文)" + } else if normalized.starts_with("zh") { + "Simplified Chinese (简体中文)" + } else if normalized.starts_with("pt") { + "Brazilian Portuguese (Português do Brasil)" + } else { + "English" + } +} + /// Render a `## Environment` block listing the resolved locale tag, /// runtime version, host platform, login shell, and current working directory. /// @@ -494,6 +540,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( goal_objective: None, project_context_pack_enabled: true, locale_tag: "en", + translation_enabled: false, }, ) } @@ -574,6 +621,15 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( render_environment_block(workspace, session_context.locale_tag), ); + // 2.3a. Translation output instruction — when enabled, instruct + // the model to respond in the resolved session locale. + if session_context.translation_enabled { + full_prompt = format!( + "{full_prompt}\n\n{}", + translation_output_instruction(session_context.locale_tag) + ); + } + // 2.5a. Configured `instructions = [...]` files (#454). Loaded // and concatenated in declared order. Lives above the skills // block so it's part of the workspace-static layer that the KV @@ -790,6 +846,7 @@ mod tests { goal_objective: None, project_context_pack_enabled: false, locale_tag: "zh-Hans", + translation_enabled: false, }, ApprovalMode::Suggest, ) { @@ -858,6 +915,7 @@ mod tests { goal_objective: None, project_context_pack_enabled: false, locale_tag: "zh-Hans", + translation_enabled: false, }, ApprovalMode::Suggest, ) { @@ -901,6 +959,7 @@ mod tests { goal_objective: None, project_context_pack_enabled: false, locale_tag: "en", + translation_enabled: false, }, ApprovalMode::Suggest, ) { @@ -989,6 +1048,7 @@ mod tests { goal_objective: None, project_context_pack_enabled: true, locale_tag: "ja", + translation_enabled: false, }, ) { SystemPrompt::Text(text) => text, @@ -1014,6 +1074,7 @@ mod tests { goal_objective: None, project_context_pack_enabled: false, locale_tag: "en", + translation_enabled: false, }, ) { SystemPrompt::Text(text) => text, @@ -1040,6 +1101,7 @@ mod tests { goal_objective: None, project_context_pack_enabled: true, locale_tag: "en", + translation_enabled: false, }, ) { SystemPrompt::Text(text) => text, @@ -1233,6 +1295,7 @@ mod tests { goal_objective: Some("Fix transcript corruption"), project_context_pack_enabled: true, locale_tag: "en", + translation_enabled: false, }, ) { SystemPrompt::Text(text) => text, @@ -1261,6 +1324,7 @@ mod tests { goal_objective: Some(" "), project_context_pack_enabled: true, locale_tag: "en", + translation_enabled: false, }, ) { SystemPrompt::Text(text) => text, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 33b15504..dc1e7c0e 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1615,6 +1615,7 @@ impl RuntimeThreadManager { allow_shell, trust_mode, auto_approve, + translation_enabled: false, approval_mode: if auto_approve { crate::tui::approval::ApprovalMode::Auto } else { @@ -1931,6 +1932,7 @@ impl RuntimeThreadManager { skills_dir: self.config.skills_dir(), instructions: self.config.instructions_paths(), project_context_pack_enabled: self.config.project_context_pack_enabled(), + translation_enabled: false, max_steps: 100, max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS), features: self.config.features(), diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index ed47ba03..880eba2c 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -897,6 +897,10 @@ pub struct App { pub session_artifacts: Vec, /// Trust mode - allow access outside workspace pub trust_mode: bool, + /// Translation mode — when enabled, the model is instructed to respond in + /// the current locale and a post-hoc translation layer replaces any + /// remaining English output before it reaches the user. + pub translation_enabled: bool, /// Ordered list of footer items the user wants visible. Sourced from /// `tui.status_items` in `~/.deepseek/config.toml` at startup; mutated /// live by `/statusline`. The renderer iterates this slice; no item is @@ -1485,6 +1489,7 @@ impl App { current_session_id: None, session_artifacts: Vec::new(), trust_mode: initial_mode == AppMode::Yolo, + translation_enabled: false, status_items: config .tui .as_ref() diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 60e83f86..0bebd9dd 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -55,6 +55,7 @@ mod subagent_routing; mod tool_routing; pub mod transcript; pub mod transcript_cache; +pub mod translation; pub mod ui; mod ui_text; pub mod user_input; diff --git a/crates/tui/src/tui/onboarding/language.rs b/crates/tui/src/tui/onboarding/language.rs index 4d2fb76d..f564a8af 100644 --- a/crates/tui/src/tui/onboarding/language.rs +++ b/crates/tui/src/tui/onboarding/language.rs @@ -12,8 +12,8 @@ use crate::localization::MessageId; use crate::palette; use crate::tui::app::App; -/// Locale options shown in the picker. Order matches the keyboard hotkeys -/// (1-5). Each entry is `(hotkey, settings_tag, native_name, english_label)`. +/// Locale options shown in the picker. Order matches the keyboard hotkeys. +/// Each entry is `(hotkey, settings_tag, native_name, english_label)`. /// `settings_tag` is what `Settings::set("locale", …)` accepts and what /// `localization::Locale` resolves on next read. pub const LANGUAGE_OPTIONS: &[(char, &str, &str, &str)] = &[ @@ -21,7 +21,8 @@ pub const LANGUAGE_OPTIONS: &[(char, &str, &str, &str)] = &[ ('2', "en", "English", ""), ('3', "ja", "日本語", "(Japanese)"), ('4', "zh-Hans", "简体中文", "(Simplified Chinese)"), - ('5', "pt-BR", "Português (Brasil)", "(Brazilian Portuguese)"), + ('5', "zh-Hant", "繁體中文", "(Traditional Chinese)"), + ('6', "pt-BR", "Português (Brasil)", "(Brazilian Portuguese)"), ]; pub fn lines(app: &App) -> Vec> { diff --git a/crates/tui/src/tui/translation.rs b/crates/tui/src/tui/translation.rs new file mode 100644 index 00000000..cf37cfe8 --- /dev/null +++ b/crates/tui/src/tui/translation.rs @@ -0,0 +1,174 @@ +//! Post-hoc translation interception layer. +//! +//! When output translation is enabled (`/translate`), this module provides +//! the interception logic that detects English model output and replaces it +//! with Chinese translations before display. The primary mechanism is the +//! system prompt instruction in `prompts.rs`; this module is the fallback +//! for model output that leaks English despite the instruction. +//! +//! ## Architecture +//! +//! - `needs_translation()` — heuristic to detect if text is predominantly +//! English and should be translated. +//! - `translate_text()` — calls the current session model through a +//! shared `DeepSeekClient` to translate text to the current locale. The dedicated +//! translation agent receives only the source text and returns only the +//! translation — no tool calls, no conversation history. +//! - `TranslationStatus` — tracks per-message translation status in the UI. + +use anyhow::Result; + +use crate::client::DeepSeekClient; + +/// Heuristic threshold: if more than this fraction of alphabetic characters +/// are Latin (A-Z / a-z), the text is considered English. +const ENGLISH_LATIN_RATIO_THRESHOLD: f64 = 0.6; + +/// Minimum number of alphabetic characters required before applying the +/// heuristic — avoids false positives on short mixed-language strings. +const MIN_ALPHA_CHARS_FOR_DETECTION: usize = 10; + +/// How many Latin-letter "information units" each CJK character is worth. +/// A single CJK character carries roughly the information of a short English +/// word (2–4 letters), so we weight CJK at 3× for fair comparison. +const CJK_CHAR_WEIGHT: usize = 3; + +/// Detect if text content is predominantly English and should be translated. +/// +/// The heuristic compares CJK characters (weighted) against Latin letters. +/// CJK characters carry much more information per glyph, so a string with +/// even a modest number of Chinese characters among English words will not +/// be flagged. +#[must_use] +pub fn needs_translation(text: &str) -> bool { + let mut latin_count = 0usize; + let mut cjk_count = 0usize; + + for ch in text.chars() { + if ch.is_ascii_alphabetic() { + latin_count += 1; + } else if is_cjk(ch) { + cjk_count += 1; + } + } + + let total_alpha = latin_count + (cjk_count * CJK_CHAR_WEIGHT); + + if total_alpha < MIN_ALPHA_CHARS_FOR_DETECTION { + return false; + } + + // If weighted CJK dominates, it's already Chinese — no translation needed. + if (cjk_count * CJK_CHAR_WEIGHT) > latin_count { + return false; + } + + let ratio = latin_count as f64 / total_alpha as f64; + ratio >= ENGLISH_LATIN_RATIO_THRESHOLD +} + +/// Check if a character is in the CJK Unified Ideographs block or is a +/// common Chinese/Japanese/Korean character. +fn is_cjk(ch: char) -> bool { + matches!( + ch, + '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs + | '\u{3400}'..='\u{4DBF}' // CJK Unified Ideographs Extension A + | '\u{2E80}'..='\u{2EFF}' // CJK Radicals Supplement + | '\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation + | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms + | '\u{3040}'..='\u{309F}' // Hiragana + | '\u{30A0}'..='\u{30FF}' // Katakana + ) +} + +/// Translate text to the requested target language using a dedicated +/// translation agent. +/// +/// This is a lightweight, focused API call — no streaming, no tool calls, +/// no conversation history. The agent's only role is translation. +/// +/// # Errors +/// +/// Returns an error if the API call fails or the response is malformed. +pub async fn translate_text( + text: &str, + client: &DeepSeekClient, + model: &str, + target_language: &str, +) -> Result { + client.translate(text, model, target_language).await +} + +/// Status of a translation operation for a single message. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub enum TranslationStatus { + /// No translation needed (already Chinese or not enough text). + NotNeeded, + /// Translation is pending — the original English is still displayed + /// with an indicator. + Pending, + /// Translation completed successfully. + Done, + /// Translation failed — original English displayed with fallback note. + Failed, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_text_avoids_false_positive() { + assert!(!needs_translation("hi")); + assert!(!needs_translation("ok")); + } + + #[test] + fn english_text_detected() { + assert!(needs_translation( + "This is a message from the assistant explaining how the code works." + )); + } + + #[test] + fn chinese_text_not_detected() { + assert!(!needs_translation( + "这是助手的一条中文回复,解释了代码的工作原理。" + )); + } + + #[test] + fn mixed_mostly_english_detected() { + assert!(needs_translation( + "The function handle_request takes a Request param and returns a Response." + )); + } + + #[test] + fn mixed_mostly_chinese_not_detected() { + assert!(!needs_translation( + "这个 handle_request 函数接收一个 Request 参数并返回 Response。" + )); + } + + #[test] + fn code_with_short_labels_not_falsely_detected() { + assert!(!needs_translation("let x = 1; let y = 2;")); + } + + #[test] + fn long_english_code_is_detected() { + assert!(needs_translation( + "function calculateTotalRevenueForQuarterlyReport() { return; }" + )); + } + + #[test] + fn js_comments_in_english_detected() { + assert!(needs_translation( + "// This is a JavaScript function that handles user authentication\nfunction login() {}" + )); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 87ef5b17..e73fc96e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; use std::io::{self, Stdout, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::Result; @@ -139,6 +140,23 @@ const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100; const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500; type AppTerminal = Terminal>; + +type PendingToolUses = Vec<(String, String, serde_json::Value)>; + +#[derive(Debug)] +enum TranslationEvent { + AssistantMessage { + history_index: Option, + original_text: String, + translated: anyhow::Result, + thinking: Option, + tool_uses: PendingToolUses, + }, + Thinking { + placeholder: String, + translated: anyhow::Result, + }, +} // Reset scroll region (`\x1b[r`), origin mode (`\x1b[?6l`), and home the cursor // (`\x1b[H`) before letting ratatui's diff renderer repaint. The destructive // `\x1b[2J\x1b[3J` pair was previously appended here to also wipe the visible @@ -409,6 +427,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { // Spawn the Engine - it will handle all API communication let engine_handle = spawn_engine(engine_config, config); + let translation_client = Arc::new(DeepSeekClient::new(config)?); if !app.api_messages.is_empty() { let _ = engine_handle @@ -443,6 +462,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { engine_handle, task_manager, &event_broker, + translation_client, ) .await; automation_cancel.cancel(); @@ -562,6 +582,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { skills_dir: app.skills_dir.clone(), instructions: config.instructions_paths(), project_context_pack_enabled: config.project_context_pack_enabled(), + translation_enabled: app.translation_enabled, // Effectively unlimited. V4 has a 1M context window and the user // wants the model running until it's actually done. The previous cap // of 100 hit the ceiling on long multi-step plans (wide refactors, @@ -674,9 +695,14 @@ async fn run_event_loop( mut engine_handle: EngineHandle, task_manager: SharedTaskManager, event_broker: &EventBroker, + translation_client: Arc, ) -> Result<()> { // Track streaming state let mut current_streaming_text = String::new(); + let (translation_tx, mut translation_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let mut pending_translations = 0usize; + let mut pending_thinking_translations = 0usize; let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone()); let mut last_task_refresh = Instant::now() .checked_sub(Duration::from_secs(2)) @@ -701,6 +727,93 @@ async fn run_event_loop( web_config_session = None; } + while let Ok(event) = translation_rx.try_recv() { + match event { + TranslationEvent::AssistantMessage { + history_index, + original_text, + translated, + thinking, + tool_uses, + } => { + pending_translations = pending_translations.saturating_sub(1); + pending_thinking_translations = pending_thinking_translations.saturating_sub(1); + let text = match translated { + Ok(text) => { + app.status_message = Some( + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::TranslationComplete, + ) + .to_string(), + ); + text + } + Err(err) => { + tracing::warn!("assistant translation failed: {err}"); + app.status_message = Some(format!( + "{}: {err}", + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::TranslationFailed, + ) + )); + crate::localization::hidden_translation_failed(app.ui_locale) + .to_string() + } + }; + + if let Some(index) = history_index + && let Some(HistoryCell::Assistant { content, .. }) = + app.history.get_mut(index) + { + *content = text.clone(); + app.bump_history_cell(index); + } + if !replace_matching_assistant_text(app, &original_text, text.clone()) { + push_assistant_message(app, text, thinking, tool_uses); + } + if pending_translations == 0 + && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) + { + app.is_loading = pending_translations > 0; + } + app.needs_redraw = true; + } + TranslationEvent::Thinking { + placeholder, + translated, + } => { + pending_translations = pending_translations.saturating_sub(1); + let text = match translated { + Ok(text) => { + app.status_message = Some( + crate::localization::thinking_translation_complete(app.ui_locale) + .to_string(), + ); + text + } + Err(err) => { + tracing::warn!("thinking translation failed: {err}"); + app.status_message = Some(format!( + "{}: {err}", + crate::localization::thinking_translation_failed(app.ui_locale) + )); + crate::localization::hidden_translation_failed(app.ui_locale) + .to_string() + } + }; + replace_pending_thinking_translation(app, &placeholder, text); + if pending_translations == 0 + && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) + { + app.is_loading = false; + } + app.needs_redraw = true; + } + } + } + if last_task_refresh.elapsed() >= Duration::from_millis(2500) { refresh_active_task_panel(app, &task_manager).await; last_task_refresh = Instant::now(); @@ -767,7 +880,9 @@ async fn run_event_loop( } stash_reasoning_buffer_into_last_reasoning(app); } + let mut completed_message_index = None; if let Some(index) = app.streaming_message_index.take() { + completed_message_index = Some(index); let remaining = app.streaming_state.finalize_block_text(0); if !remaining.is_empty() { append_streaming_text(app, index, &remaining); @@ -785,40 +900,55 @@ async fn run_event_loop( transcript_batch_updated = true; } - let mut blocks = Vec::new(); let thinking = app.last_reasoning.take(); - if let Some(thinking) = thinking { - blocks.push(ContentBlock::Thinking { thinking }); - } - if !current_streaming_text.is_empty() { - blocks.push(ContentBlock::Text { - text: current_streaming_text.clone(), - cache_control: None, - }); - } - for (id, name, input) in app.pending_tool_uses.drain(..) { - blocks.push(ContentBlock::ToolUse { - id, - name, - input, - caller: None, - }); - } + let tool_uses = app.pending_tool_uses.drain(..).collect::>(); + let history_index = completed_message_index; - // DeepSeek rejects assistant messages that contain only reasoning blocks. - // Keep reasoning in transcript cells, but only persist assistant turns that - // include visible text and/or tool calls. - let has_sendable_content = blocks.iter().any(|block| { - matches!( - block, - ContentBlock::Text { .. } | ContentBlock::ToolUse { .. } - ) - }); - if has_sendable_content { - app.api_messages.push(Message { - role: "assistant".to_string(), - content: blocks, + if app.translation_enabled + && !current_streaming_text.is_empty() + && crate::tui::translation::needs_translation(¤t_streaming_text) + { + app.status_message = Some( + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::TranslationInProgress, + ) + .to_string(), + ); + app.is_loading = true; + pending_translations = pending_translations.saturating_add(1); + let tx = translation_tx.clone(); + let client = translation_client.clone(); + let original_text = current_streaming_text.clone(); + let translation_model = app + .last_effective_model + .clone() + .unwrap_or_else(|| app.model.clone()); + let target_language = + app.ui_locale.translation_target_name().to_string(); + tokio::spawn(async move { + let translated = crate::tui::translation::translate_text( + &original_text, + &client, + &translation_model, + &target_language, + ) + .await; + let _ = tx.send(TranslationEvent::AssistantMessage { + history_index, + original_text, + translated, + thinking, + tool_uses, + }); }); + } else { + push_assistant_message( + app, + current_streaming_text.clone(), + thinking, + tool_uses, + ); } } EngineEvent::ThinkingStarted { .. } => { @@ -828,6 +958,11 @@ async fn run_event_loop( if start_streaming_thinking_block(app) { transcript_batch_updated = true; } + if app.translation_enabled { + let entry_idx = ensure_streaming_thinking_active_entry(app); + set_streaming_thinking_placeholder(app, entry_idx); + transcript_batch_updated = true; + } } EngineEvent::ThinkingDelta { content, .. } => { let sanitized = sanitize_stream_chunk(&content); @@ -843,12 +978,76 @@ async fn run_event_loop( app.streaming_state.push_content(0, &sanitized); let committed = app.streaming_state.commit_text(0); if !committed.is_empty() { - append_streaming_thinking(app, entry_idx, &committed); + if app.translation_enabled { + set_streaming_thinking_placeholder(app, entry_idx); + } else { + append_streaming_thinking(app, entry_idx, &committed); + } transcript_batch_updated = true; } } EngineEvent::ThinkingComplete { .. } => { - if finalize_current_streaming_thinking(app) { + if app.translation_enabled { + let original_thinking = app.reasoning_buffer.clone(); + let _ = app.streaming_state.finalize_block_text(0); + let duration = app + .thinking_started_at + .take() + .map(|t| t.elapsed().as_secs_f32()); + if finalize_streaming_thinking_active_entry(app, duration, "") { + transcript_batch_updated = true; + } + if !original_thinking.is_empty() + && crate::tui::translation::needs_translation(&original_thinking) + { + app.status_message = Some( + crate::localization::thinking_translation_in_progress( + app.ui_locale, + ) + .to_string(), + ); + app.is_loading = true; + pending_translations = pending_translations.saturating_add(1); + pending_thinking_translations = + pending_thinking_translations.saturating_add(1); + let tx = translation_tx.clone(); + let client = translation_client.clone(); + let translation_model = app + .last_effective_model + .clone() + .unwrap_or_else(|| app.model.clone()); + let placeholder = + crate::localization::thinking_translation_placeholder( + app.ui_locale, + ) + .to_string(); + let target_language = + app.ui_locale.translation_target_name().to_string(); + tokio::spawn(async move { + let translated = crate::tui::translation::translate_text( + &original_thinking, + &client, + &translation_model, + &target_language, + ) + .await; + let _ = tx.send(TranslationEvent::Thinking { + placeholder, + translated, + }); + }); + } else { + let placeholder = + crate::localization::thinking_translation_placeholder( + app.ui_locale, + ); + replace_pending_thinking_translation( + app, + placeholder, + original_thinking, + ); + } + } else if finalize_current_streaming_thinking(app) { transcript_batch_updated = true; } stash_reasoning_buffer_into_last_reasoning(app); @@ -1490,7 +1689,11 @@ async fn run_event_loop( } else if let Some(entry_idx) = app.streaming_thinking_active_entry { let committed = app.streaming_state.commit_text(0); if !committed.is_empty() { - append_streaming_thinking(app, entry_idx, &committed); + if app.translation_enabled { + set_streaming_thinking_placeholder(app, entry_idx); + } else { + append_streaming_thinking(app, entry_idx, &committed); + } transcript_batch_updated = true; } } @@ -1549,6 +1752,9 @@ async fn run_event_loop( && last_status_frame.elapsed() >= Duration::from_millis(status_animation_interval_ms(app)) { + if animate_pending_thinking_translation(app, pending_thinking_translations > 0) { + app.mark_history_updated(); + } if !app.low_motion && history_has_live_motion(&app.history) { app.mark_history_updated(); } @@ -1869,7 +2075,7 @@ async fn run_event_loop( app.onboarding = OnboardingState::Welcome; app.status_message = None; } - // Language picker hotkeys: 1-5 select + persist (#566). + // Language picker hotkeys select + persist (#566). // // Note: this used to be a single match-guard with `&& let`, // but `if_let_guard` is a nightly-only feature on Rust @@ -3546,6 +3752,66 @@ fn append_streaming_text(app: &mut App, index: usize, text: &str) { } } +fn push_assistant_message( + app: &mut App, + text: String, + thinking: Option, + tool_uses: PendingToolUses, +) { + let mut blocks = Vec::new(); + if let Some(thinking) = thinking { + blocks.push(ContentBlock::Thinking { thinking }); + } + if !text.is_empty() { + blocks.push(ContentBlock::Text { + text, + cache_control: None, + }); + } + for (id, name, input) in tool_uses { + blocks.push(ContentBlock::ToolUse { + id, + name, + input, + caller: None, + }); + } + + let has_sendable_content = blocks.iter().any(|block| { + matches!( + block, + ContentBlock::Text { .. } | ContentBlock::ToolUse { .. } + ) + }); + if has_sendable_content { + app.api_messages.push(Message { + role: "assistant".to_string(), + content: blocks, + }); + } +} + +fn replace_matching_assistant_text( + app: &mut App, + original_text: &str, + translated_text: String, +) -> bool { + for message in app.api_messages.iter_mut().rev() { + if message.role != "assistant" { + continue; + } + for block in &mut message.content { + if let ContentBlock::Text { text, .. } = block + && text == original_text + { + *text = translated_text; + return true; + } + } + } + false +} + /// Ensure an in-flight Thinking entry exists in `active_cell` and return its /// entry index. If no thinking entry is currently streaming, push a fresh one. /// P2.3: thinking shares the active cell with subsequent tool calls so the @@ -3587,6 +3853,93 @@ fn append_streaming_thinking(app: &mut App, entry_idx: usize, text: &str) { } } +fn thinking_translation_placeholder_frame(app: &App) -> String { + let base = crate::localization::thinking_translation_placeholder(app.ui_locale); + let elapsed = app + .thinking_started_at + .or(app.turn_started_at) + .map(|started| started.elapsed().as_secs_f32()) + .unwrap_or_default(); + let frame = match (elapsed.mul_add(2.0, 0.0) as usize) % 4 { + 0 => "|", + 1 => "/", + 2 => "-", + _ => "\\", + }; + format!("{base} ({elapsed:.1}s {frame})") +} + +fn set_streaming_thinking_placeholder(app: &mut App, entry_idx: usize) { + let base = crate::localization::thinking_translation_placeholder(app.ui_locale); + let next = thinking_translation_placeholder_frame(app); + let mutated = if let Some(active) = app.active_cell.as_mut() + && let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx) + && (content.is_empty() || content.starts_with(base)) + { + if *content != next { + *content = next; + true + } else { + false + } + } else { + false + }; + if mutated { + app.bump_active_cell_revision(); + } +} + +fn animate_pending_thinking_translation(app: &mut App, translation_pending: bool) -> bool { + if !app.translation_enabled { + return false; + } + let thinking_streaming = app.streaming_thinking_active_entry.is_some(); + if !translation_pending && !thinking_streaming { + return false; + } + let base = crate::localization::thinking_translation_placeholder(app.ui_locale); + let next = thinking_translation_placeholder_frame(app); + + if let Some(active) = app.active_cell.as_mut() { + for idx in (0..active.entry_count()).rev() { + if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx) + && content.starts_with(base) + && *content != next + { + *content = next.clone(); + app.bump_active_cell_revision(); + return true; + } + } + } + false +} + +fn replace_pending_thinking_translation(app: &mut App, placeholder: &str, translated_text: String) { + if let Some(active) = app.active_cell.as_mut() { + for idx in (0..active.entry_count()).rev() { + if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx) + && content.starts_with(placeholder) + { + *content = translated_text; + app.bump_active_cell_revision(); + return; + } + } + } + + for idx in (0..app.history.len()).rev() { + if let Some(HistoryCell::Thinking { content, .. }) = app.history.get_mut(idx) + && content.starts_with(placeholder) + { + *content = translated_text; + app.bump_history_cell(idx); + return; + } + } +} + /// Start a new streaming thinking block. If another thinking block is still /// active, first drain its pending UI tail so a late block boundary cannot /// discard content buffered inside `StreamingState`. @@ -3956,6 +4309,7 @@ async fn dispatch_user_message( goal_objective: app.goal.goal_objective.as_deref(), project_context_pack_enabled: config.project_context_pack_enabled(), locale_tag: app.ui_locale.tag(), + translation_enabled: app.translation_enabled, }, ), ); @@ -4048,6 +4402,7 @@ async fn dispatch_user_message( trust_mode: app.trust_mode, auto_approve: app.mode == AppMode::Yolo, approval_mode: app.approval_mode, + translation_enabled: app.translation_enabled, }) .await {