diff --git a/.gitignore b/.gitignore index ab1cf906..4e037e3c 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ docs/*_PLAN.md .envrc .direnv scripts/run_deep_swe.py +.claude/ # Benchmark artifacts and caches re-included by !scripts/** results/ diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 736abcba..bc4eecc8 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -610,6 +610,17 @@ pub enum MessageId { CtxInspChangesByTurn, CtxInspStablePrefixOnly, CtxInspCacheTip, + // Tool family labels (card headers, sidebar, footer). + ToolFamilyRead, + ToolFamilyPatch, + ToolFamilyRun, + ToolFamilyFind, + ToolFamilyDelegate, + ToolFamilyFanout, + ToolFamilyRlm, + ToolFamilyVerify, + ToolFamilyThink, + ToolFamilyGeneric, } #[allow(dead_code)] @@ -990,6 +1001,16 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxInspChangesByTurn, MessageId::CtxInspStablePrefixOnly, MessageId::CtxInspCacheTip, + MessageId::ToolFamilyRead, + MessageId::ToolFamilyPatch, + MessageId::ToolFamilyRun, + MessageId::ToolFamilyFind, + MessageId::ToolFamilyDelegate, + MessageId::ToolFamilyFanout, + MessageId::ToolFamilyRlm, + MessageId::ToolFamilyVerify, + MessageId::ToolFamilyThink, + MessageId::ToolFamilyGeneric, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1696,6 +1717,16 @@ fn english(id: MessageId) -> &'static str { "Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. \ Volatile working-set changes break the cache only for the tail." } + MessageId::ToolFamilyRead => "read", + MessageId::ToolFamilyPatch => "patch", + MessageId::ToolFamilyRun => "run", + MessageId::ToolFamilyFind => "find", + MessageId::ToolFamilyDelegate => "delegate", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "verify", + MessageId::ToolFamilyThink => "think", + MessageId::ToolFamilyGeneric => "tool", } } @@ -2270,6 +2301,16 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Gợi ý: Các khối ổn định đủ điều kiện cho bộ nhớ đệm tiền tố DeepSeek V4. Thay đổi vùng làm việc chỉ phá vỡ bộ nhớ đệm ở phần cuối." } + MessageId::ToolFamilyRead => "đọc", + MessageId::ToolFamilyPatch => "vá", + MessageId::ToolFamilyRun => "chạy", + MessageId::ToolFamilyFind => "tìm", + MessageId::ToolFamilyDelegate => "ủy quyền", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "xác minh", + MessageId::ToolFamilyThink => "suy nghĩ", + MessageId::ToolFamilyGeneric => "công cụ", }) } @@ -2398,6 +2439,16 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::StatusPickerActionNone => "無 ", MessageId::StatusPickerActionSave => "儲存 ", MessageId::StatusPickerActionCancel => "取消 ", + MessageId::ToolFamilyRead => "讀取", + MessageId::ToolFamilyPatch => "修補", + MessageId::ToolFamilyRun => "執行", + MessageId::ToolFamilyFind => "搜尋", + MessageId::ToolFamilyDelegate => "委派", + MessageId::ToolFamilyFanout => "扇出", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "驗證", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "工具", other => chinese_simplified(other)?, }) } @@ -2931,6 +2982,16 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "ヒント:安定プレフィックスブロックはDeepSeek V4プレフィックスキャッシュの対象です。揮発性ワーキングセットの変更は末尾のキャッシュのみを破壊します。" } + MessageId::ToolFamilyRead => "読込", + MessageId::ToolFamilyPatch => "パッチ", + MessageId::ToolFamilyRun => "実行", + MessageId::ToolFamilyFind => "検索", + MessageId::ToolFamilyDelegate => "委任", + MessageId::ToolFamilyFanout => "ファンアウト", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "検証", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "ツール", }) } @@ -3399,6 +3460,16 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:稳定前缀区块符合 DeepSeek V4 前缀缓存条件。易变工作集的更改仅会破坏缓存尾部。" } + MessageId::ToolFamilyRead => "读取", + MessageId::ToolFamilyPatch => "修补", + MessageId::ToolFamilyRun => "运行", + MessageId::ToolFamilyFind => "搜索", + MessageId::ToolFamilyDelegate => "委派", + MessageId::ToolFamilyFanout => "扇出", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "验证", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "工具", }) } @@ -3957,6 +4028,16 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Dica: Blocos de prefixo estável são elegíveis para cache de prefixo DeepSeek V4. Alterações no conjunto de trabalho volátil quebram o cache apenas no final." } + MessageId::ToolFamilyRead => "ler", + MessageId::ToolFamilyPatch => "corrigir", + MessageId::ToolFamilyRun => "executar", + MessageId::ToolFamilyFind => "buscar", + MessageId::ToolFamilyDelegate => "delegar", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "verificar", + MessageId::ToolFamilyThink => "pensar", + MessageId::ToolFamilyGeneric => "ferramenta", }) } @@ -4525,6 +4606,16 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Consejo: Los bloques de prefijo estable son elegibles para caché de prefijo DeepSeek V4. Los cambios en el conjunto de trabajo volátil solo rompen la caché al final." } + MessageId::ToolFamilyRead => "leer", + MessageId::ToolFamilyPatch => "parchear", + MessageId::ToolFamilyRun => "ejecutar", + MessageId::ToolFamilyFind => "buscar", + MessageId::ToolFamilyDelegate => "delegar", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "verificar", + MessageId::ToolFamilyThink => "pensar", + MessageId::ToolFamilyGeneric => "herramienta", }) } diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 02dc8ce5..463dec13 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -4,7 +4,7 @@ use std::time::Instant; use unicode_width::UnicodeWidthStr; use crate::core::coherence::CoherenceState; -use crate::localization::MessageId; +use crate::localization::{Locale, MessageId}; use crate::palette; use crate::tools::subagent::SubAgentStatus; use crate::tui::app::App; @@ -314,7 +314,7 @@ pub(crate) fn active_tool_status_label(app: &App) -> Option { let mut snapshot = ActiveToolStatusSnapshot::default(); for cell in active.entries() { - collect_active_tool_status(cell, &mut snapshot); + collect_active_tool_status(cell, &mut snapshot, app.ui_locale); } if snapshot.total() == 0 { return None; @@ -345,7 +345,11 @@ pub(crate) fn active_tool_status_label(app: &App) -> Option { Some(parts.join(" \u{00B7} ")) } -fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) { +fn collect_active_tool_status( + cell: &HistoryCell, + snapshot: &mut ActiveToolStatusSnapshot, + locale: Locale, +) { let HistoryCell::Tool(tool) = cell else { return; }; @@ -401,7 +405,7 @@ fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatu return; } snapshot.record( - tool_activity_label_for_name(&generic.name), + tool_activity_label_for_name(&generic.name, locale), generic.status, None, ); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fde39c34..350265b4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -9523,7 +9523,10 @@ fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> Stri HistoryCell::Error { .. } => "error".to_string(), HistoryCell::SubAgent(_) => "sub-agent".to_string(), HistoryCell::Tool(ToolCell::Generic(generic)) => { - crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name) + crate::tui::widgets::tool_card::tool_activity_label_for_name( + &generic.name, + app.ui_locale, + ) } HistoryCell::Tool(_) => { detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string()) @@ -9958,9 +9961,12 @@ pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option Some(format!("search {}", search.query)), - HistoryCell::Tool(ToolCell::Generic(generic)) => { - Some(crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name)) - } + HistoryCell::Tool(ToolCell::Generic(generic)) => Some( + crate::tui::widgets::tool_card::tool_activity_label_for_name( + &generic.name, + app.ui_locale, + ), + ), HistoryCell::SubAgent(_) => Some("sub-agent".to_string()), _ => None, } diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index d525551b..5e4c2e6a 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -23,6 +23,8 @@ //! module is the vocabulary, not the layout engine. Keeping it small means //! a future visual refresh only has to touch the constants here. +use crate::localization::Locale; + /// Tool family — the verb the agent is performing. Used to pick a glyph /// and label for the card header. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -97,8 +99,9 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily { /// User-facing label for an arbitrary tool name. Known tools collapse to the /// semantic verb; unknown tools keep their exact name for debugging. +#[cfg(test)] #[must_use] -pub fn tool_display_label_for_name(name: &str) -> String { +fn tool_display_label_for_name(name: &str) -> String { let family = tool_family_for_name(name); if matches!(family, ToolFamily::Generic) { name.to_string() @@ -107,15 +110,31 @@ pub fn tool_display_label_for_name(name: &str) -> String { } } +fn family_message_id(family: ToolFamily) -> crate::localization::MessageId { + match family { + ToolFamily::Read => crate::localization::MessageId::ToolFamilyRead, + ToolFamily::Patch => crate::localization::MessageId::ToolFamilyPatch, + ToolFamily::Run => crate::localization::MessageId::ToolFamilyRun, + ToolFamily::Find => crate::localization::MessageId::ToolFamilyFind, + ToolFamily::Delegate => crate::localization::MessageId::ToolFamilyDelegate, + ToolFamily::Fanout => crate::localization::MessageId::ToolFamilyFanout, + ToolFamily::Rlm => crate::localization::MessageId::ToolFamilyRlm, + ToolFamily::Verify => crate::localization::MessageId::ToolFamilyVerify, + ToolFamily::Think => crate::localization::MessageId::ToolFamilyThink, + ToolFamily::Generic => crate::localization::MessageId::ToolFamilyGeneric, + } +} + /// Compact activity/status label for arbitrary tool names. Known built-ins use /// the semantic verb; unknown tools keep the `tool NAME` form. #[must_use] -pub fn tool_activity_label_for_name(name: &str) -> String { +pub fn tool_activity_label_for_name(name: &str, locale: Locale) -> String { let family = tool_family_for_name(name); + let mid = family_message_id(family); if matches!(family, ToolFamily::Generic) { - format!("tool {name}") + format!("{} {name}", crate::localization::tr(locale, mid)) } else { - tool_display_label_for_name(name) + crate::localization::tr(locale, mid).to_string() } } @@ -237,6 +256,7 @@ mod tests { tool_display_label_for_name, tool_family_for_name, tool_family_for_title, tool_header_summary_for_name, }; + use crate::localization::{Locale, MessageId, tr}; #[test] fn legacy_titles_route_to_expected_families() { @@ -275,10 +295,16 @@ mod tests { "future_private_tool" ); - assert_eq!(tool_activity_label_for_name("exec_shell"), "run"); - assert_eq!(tool_activity_label_for_name("run_verifiers"), "verify"); assert_eq!( - tool_activity_label_for_name("future_private_tool"), + tool_activity_label_for_name("exec_shell", Locale::En), + "run" + ); + assert_eq!( + tool_activity_label_for_name("run_verifiers", Locale::En), + "verify" + ); + assert_eq!( + tool_activity_label_for_name("future_private_tool", Locale::En), "tool future_private_tool" ); } @@ -344,4 +370,80 @@ mod tests { assert_eq!(rail_glyph(CardRail::Bottom), "\u{2570}"); assert!(rail_glyph(CardRail::Single).is_empty()); } + + #[test] + fn tool_family_labels_localized_no_english_leak() { + let checks: &[(MessageId, &str, &str)] = &[ + (MessageId::ToolFamilyRead, "read", "đọc,读,読,读取,ler,leer"), + ( + MessageId::ToolFamilyPatch, + "patch", + "vá,補,パ,修补,corrigir,parchear", + ), + ( + MessageId::ToolFamilyRun, + "run", + "chạy,執,実,运行,executar,ejecutar", + ), + ( + MessageId::ToolFamilyFind, + "find", + "tìm,搜,検,搜索,buscar,buscar", + ), + ( + MessageId::ToolFamilyVerify, + "verify", + "xác minh,驗,検,验,verificar,verificar", + ), + ]; + for locale in [ + Locale::Ja, + Locale::ZhHans, + Locale::ZhHant, + Locale::PtBr, + Locale::Es419, + Locale::Vi, + ] { + for (id, eng, _) in checks { + let msg = tr(locale, *id); + assert!( + !msg.eq_ignore_ascii_case(eng), + "{} leaked exact English '{}' for '{:?}': {msg}", + locale.tag(), + eng, + id + ); + } + } + } + + #[test] + fn tool_family_activity_label_localized_no_english_leak() { + let known = [ + "exec_shell", + "read_file", + "apply_patch", + "grep_files", + "run_verifiers", + ]; + let english_labels = ["run", "read", "patch", "find", "verify"]; + for locale in [ + Locale::Ja, + Locale::ZhHans, + Locale::ZhHant, + Locale::PtBr, + Locale::Es419, + Locale::Vi, + ] { + for (tool, eng) in known.iter().zip(english_labels.iter()) { + let label = tool_activity_label_for_name(tool, locale); + assert!( + !label.eq_ignore_ascii_case(eng), + "{} leaked English '{}' for tool '{tool}': {label}", + locale.tag(), + eng, + ); + } + } + } }