feat(translate): opt-in /translate command localises model replies
Two-layer design for users whose UI locale is not English: 1. **System-prompt directive (primary)**: when the user enables translation via `/translate`, 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, technical terms without an established translation, and code blocks the user explicitly requests in English are exempt. The block is gated on `PromptSessionContext.translation_ enabled`, so it adds zero tokens for installs that don't opt in. 2. **Post-hoc heuristic (fallback)**: a lightweight detector in `tui::translation` compares Latin-letter count against weighted CJK characters (CJK chars carry ~3× the information per glyph, so the ratio comparison stays fair across mixed code+prose). When a reply still surfaces English despite the directive, the detector flags it and a focused per-message `client.translate()` call renders the localised version before display. The dedicated translation request runs without conversation history, tool calls, or streaming — the only role is translate-and-return. Adds the `/translate` slash command, locale strings for the new UI states, the post-hoc fallback module, the per-message `TranslationStatus`, and threading through `core::ops`, `core::engine`, `runtime_threads`, and the TUI app/UI surface. Trust-boundary check: opt-in only — `translation_enabled` defaults to false everywhere, so English-locale installs see zero behaviour change. The system prompt addition is conditional on the runtime flag, not the contributor's earlier always-on form. Threaded the new `Locale::ZhHant` arm through the v0.8.32 `/change` slash command match to keep the pattern exhaustiveness check passing. Harvested from PR #1462 by @YaYII Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
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<Vec<AvailableModel>> {
|
||||
let url = api_url(&self.base_url, "models");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -96,6 +96,9 @@ pub struct EngineConfig {
|
||||
/// Resolved via `expand_path` so `~` works.
|
||||
pub instructions: Vec<PathBuf>,
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ pub enum Op {
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
approval_mode: ApprovalMode,
|
||||
translation_enabled: bool,
|
||||
},
|
||||
|
||||
/// Cancel the current request
|
||||
|
||||
@@ -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<MessageId> {
|
||||
ALL_MESSAGE_IDS
|
||||
@@ -733,7 +815,7 @@ fn parse_locale(value: &str) -> Option<Locale> {
|
||||
|| 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 <path>`, `/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 <tag>`."
|
||||
}
|
||||
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 <path>`、`/trust list`、`/trust on|off`)"
|
||||
}
|
||||
@@ -1383,7 +1496,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::OnboardLanguageBlurb => {
|
||||
"UI 言語を選んでください。`/settings set locale <tag>` でいつでも変更できます。"
|
||||
}
|
||||
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 <path>`、`/trust list`、`/trust on|off`)"
|
||||
}
|
||||
@@ -1686,7 +1805,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::OnboardLanguageBlurb => {
|
||||
"选择界面语言。可随时使用 `/settings set locale <tag>` 修改。"
|
||||
}
|
||||
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 <path>`, `/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 <tag>`."
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -897,6 +897,10 @@ pub struct App {
|
||||
pub session_artifacts: Vec<ArtifactRecord>,
|
||||
/// 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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Line<'static>> {
|
||||
|
||||
@@ -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<String> {
|
||||
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() {}"
|
||||
));
|
||||
}
|
||||
}
|
||||
+390
-35
@@ -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<ColorCompatBackend<Stdout>>;
|
||||
|
||||
type PendingToolUses = Vec<(String, String, serde_json::Value)>;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum TranslationEvent {
|
||||
AssistantMessage {
|
||||
history_index: Option<usize>,
|
||||
original_text: String,
|
||||
translated: anyhow::Result<String>,
|
||||
thinking: Option<String>,
|
||||
tool_uses: PendingToolUses,
|
||||
},
|
||||
Thinking {
|
||||
placeholder: String,
|
||||
translated: anyhow::Result<String>,
|
||||
},
|
||||
}
|
||||
// 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<DeepSeekClient>,
|
||||
) -> Result<()> {
|
||||
// Track streaming state
|
||||
let mut current_streaming_text = String::new();
|
||||
let (translation_tx, mut translation_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<TranslationEvent>();
|
||||
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::<Vec<_>>();
|
||||
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<String>,
|
||||
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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user