diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index db9499b3e..2c802487a 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -222,6 +222,22 @@ pub const PLANNED_QA_LOCALES: &[LocaleSpec] = &[ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MessageId { ComposerPlaceholder, + ComposerTitle, + ComposerTitleDraft, + ComposerEmptyHeader, + ComposerEmptyModel, + ComposerEmptyDir, + ComposerSlashHintMove, + ComposerSlashHintAccept, + ComposerSlashHintClose, + ComposerSubmitSend, + ComposerSubmitOffline, + ComposerSubmitQueueWait, + ComposerSubmitQueueNext, + ComposerSubmitSteering, + ComposerSubmitQueued, + ComposerModelDesc, + ComposerUserCmdDesc, HistorySearchPlaceholder, HistorySearchTitle, HistoryHintMove, @@ -553,6 +569,22 @@ pub enum MessageId { #[allow(dead_code)] pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ComposerPlaceholder, + MessageId::ComposerTitle, + MessageId::ComposerTitleDraft, + MessageId::ComposerEmptyHeader, + MessageId::ComposerEmptyModel, + MessageId::ComposerEmptyDir, + MessageId::ComposerSlashHintMove, + MessageId::ComposerSlashHintAccept, + MessageId::ComposerSlashHintClose, + MessageId::ComposerSubmitSend, + MessageId::ComposerSubmitOffline, + MessageId::ComposerSubmitQueueWait, + MessageId::ComposerSubmitQueueNext, + MessageId::ComposerSubmitSteering, + MessageId::ComposerSubmitQueued, + MessageId::ComposerModelDesc, + MessageId::ComposerUserCmdDesc, MessageId::HistorySearchPlaceholder, MessageId::HistorySearchTitle, MessageId::HistoryHintMove, @@ -1055,6 +1087,22 @@ fn fallback_translation(candidate: Option<&'static str>, id: MessageId) -> &'sta fn english(id: MessageId) -> &'static str { match id { MessageId::ComposerPlaceholder => "Write a task or use /.", + MessageId::ComposerTitle => "Composer", + MessageId::ComposerTitleDraft => "Draft", + MessageId::ComposerEmptyHeader => ">_ codewhale (v{version})", + MessageId::ComposerEmptyModel => "model: {name} /model to switch", + MessageId::ComposerEmptyDir => "directory: {workspace}", + MessageId::ComposerSlashHintMove => "Up/Down move ", + MessageId::ComposerSlashHintAccept => "Tab accept ", + MessageId::ComposerSlashHintClose => "Esc close", + MessageId::ComposerSubmitSend => "\u{21b5} send ({count} queued)", + MessageId::ComposerSubmitOffline => "\u{21b5} offline queue", + MessageId::ComposerSubmitQueueWait => "\u{21b5} queue ({count} waiting)", + MessageId::ComposerSubmitQueueNext => "\u{21b5} queue for next turn", + MessageId::ComposerSubmitSteering => "\u{21b5} steering (Ctrl+Enter)", + MessageId::ComposerSubmitQueued => "\u{21b5} queued (Ctrl+Enter to steer)", + MessageId::ComposerModelDesc => "Switch to this model", + MessageId::ComposerUserCmdDesc => "User-defined command", MessageId::HistorySearchPlaceholder => "Search prompt history...", MessageId::HistorySearchTitle => "History Search", MessageId::HistoryHintMove => "Up/Down move", @@ -1523,6 +1571,22 @@ fn translation(locale: Locale, id: MessageId) -> Option<&'static str> { fn vietnamese(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::ComposerPlaceholder => "Nhập nhiệm vụ hoặc sử dụng /.", + MessageId::ComposerTitle => "Soạn thảo", + MessageId::ComposerTitleDraft => "Bản nháp", + MessageId::ComposerEmptyHeader => ">_ codewhale (v{version})", + MessageId::ComposerEmptyModel => "model: {name} /model để chuyển", + MessageId::ComposerEmptyDir => "thư mục: {workspace}", + MessageId::ComposerSlashHintMove => "Lên/Xuống di chuyển ", + MessageId::ComposerSlashHintAccept => "Tab chấp nhận ", + MessageId::ComposerSlashHintClose => "Esc đóng", + MessageId::ComposerSubmitSend => "\u{21b5} gửi ({count} đã xếp hàng)", + MessageId::ComposerSubmitOffline => "\u{21b5} hàng đợi ngoại tuyến", + MessageId::ComposerSubmitQueueWait => "\u{21b5} xếp hàng ({count} đang chờ)", + MessageId::ComposerSubmitQueueNext => "\u{21b5} xếp hàng cho lượt tiếp theo", + MessageId::ComposerSubmitSteering => "\u{21b5} điều hướng (Ctrl+Enter)", + MessageId::ComposerSubmitQueued => "\u{21b5} đã xếp hàng (Ctrl+Enter để điều hướng)", + MessageId::ComposerModelDesc => "Chuyển sang mô hình này", + MessageId::ComposerUserCmdDesc => "Lệnh do người dùng định nghĩa", MessageId::HistorySearchPlaceholder => "Tìm kiếm lịch sử câu lệnh...", MessageId::HistorySearchTitle => "Tìm kiếm lịch sử", MessageId::HistoryHintMove => "Lên/Xuống để di chuyển", @@ -2019,6 +2083,23 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::FanoutCounts => { "{done} 已完成 · {running} 運行中 · {failed} 失敗 · {pending} 等待中" } + MessageId::ComposerPlaceholder => "編寫任務或使用 /。", + MessageId::ComposerTitle => "編輯器", + MessageId::ComposerTitleDraft => "草稿", + MessageId::ComposerEmptyHeader => ">_ codewhale (v{version})", + MessageId::ComposerEmptyModel => "model: {name} /model 切換", + MessageId::ComposerEmptyDir => "目錄: {workspace}", + MessageId::ComposerSlashHintMove => "上/下移動 ", + MessageId::ComposerSlashHintAccept => "Tab 接受 ", + MessageId::ComposerSlashHintClose => "Esc 關閉", + MessageId::ComposerSubmitSend => "\u{21b5} 發送 ({count} 排隊中)", + MessageId::ComposerSubmitOffline => "\u{21b5} 離線佇列", + MessageId::ComposerSubmitQueueWait => "\u{21b5} 排隊 ({count} 等待中)", + MessageId::ComposerSubmitQueueNext => "\u{21b5} 排隊等待下一輪", + MessageId::ComposerSubmitSteering => "\u{21b5} 轉向 (Ctrl+Enter)", + MessageId::ComposerSubmitQueued => "\u{21b5} 已排隊 (Ctrl+Enter 轉向)", + MessageId::ComposerModelDesc => "切換到該模型", + MessageId::ComposerUserCmdDesc => "使用者自訂命令", MessageId::CtxInspTitle => "上下文檢查器", MessageId::CtxInspSessionContext => "會話上下文", @@ -2074,6 +2155,22 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { fn japanese(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::ComposerPlaceholder => "タスクを書くか / を使う。", + MessageId::ComposerTitle => "コンポーザー", + MessageId::ComposerTitleDraft => "下書き", + MessageId::ComposerEmptyHeader => ">_ codewhale (v{version})", + MessageId::ComposerEmptyModel => "model: {name} /model で切り替え", + MessageId::ComposerEmptyDir => "ディレクトリ: {workspace}", + MessageId::ComposerSlashHintMove => "上/下で移動 ", + MessageId::ComposerSlashHintAccept => "Tabで確定 ", + MessageId::ComposerSlashHintClose => "Escで閉じる", + MessageId::ComposerSubmitSend => "\u{21b5} 送信 ({count} キュー中)", + MessageId::ComposerSubmitOffline => "\u{21b5} オフラインキュー", + MessageId::ComposerSubmitQueueWait => "\u{21b5} キュー ({count} 待機中)", + MessageId::ComposerSubmitQueueNext => "\u{21b5} 次のターンにキュー", + MessageId::ComposerSubmitSteering => "\u{21b5} ステアリング (Ctrl+Enter)", + MessageId::ComposerSubmitQueued => "\u{21b5} キュー済み (Ctrl+Enterでステアリング)", + MessageId::ComposerModelDesc => "このモデルに切り替える", + MessageId::ComposerUserCmdDesc => "ユーザー定義コマンド", MessageId::HistorySearchPlaceholder => "プロンプト履歴を検索...", MessageId::HistorySearchTitle => "履歴検索", MessageId::HistoryHintMove => "Up/Down 移動", @@ -2534,6 +2631,22 @@ fn japanese(id: MessageId) -> Option<&'static str> { fn chinese_simplified(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::ComposerPlaceholder => "编写任务或使用 /。", + MessageId::ComposerTitle => "编辑器", + MessageId::ComposerTitleDraft => "草稿", + MessageId::ComposerEmptyHeader => ">_ codewhale (v{version})", + MessageId::ComposerEmptyModel => "model: {name} /model 切换", + MessageId::ComposerEmptyDir => "目录: {workspace}", + MessageId::ComposerSlashHintMove => "上/下移动 ", + MessageId::ComposerSlashHintAccept => "Tab 接受 ", + MessageId::ComposerSlashHintClose => "Esc 关闭", + MessageId::ComposerSubmitSend => "\u{21b5} 发送 ({count} 排队中)", + MessageId::ComposerSubmitOffline => "\u{21b5} 离线队列", + MessageId::ComposerSubmitQueueWait => "\u{21b5} 排队 ({count} 等待中)", + MessageId::ComposerSubmitQueueNext => "\u{21b5} 排队等待下一轮", + MessageId::ComposerSubmitSteering => "\u{21b5} 转向 (Ctrl+Enter)", + MessageId::ComposerSubmitQueued => "\u{21b5} 已排队 (Ctrl+Enter 转向)", + MessageId::ComposerModelDesc => "切换到该模型", + MessageId::ComposerUserCmdDesc => "用户自定义命令", MessageId::HistorySearchPlaceholder => "搜索提示历史...", MessageId::HistorySearchTitle => "历史搜索", MessageId::HistoryHintMove => "Up/Down 移动", @@ -2936,6 +3049,22 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { fn portuguese_brazil(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::ComposerPlaceholder => "Escreva uma tarefa ou use /.", + MessageId::ComposerTitle => "Compositor", + MessageId::ComposerTitleDraft => "Rascunho", + MessageId::ComposerEmptyHeader => ">_ codewhale (v{version})", + MessageId::ComposerEmptyModel => "model: {name} /model para trocar", + MessageId::ComposerEmptyDir => "diretório: {workspace}", + MessageId::ComposerSlashHintMove => "Cima/Baixo move ", + MessageId::ComposerSlashHintAccept => "Tab aceita ", + MessageId::ComposerSlashHintClose => "Esc fecha", + MessageId::ComposerSubmitSend => "\u{21b5} enviar ({count} na fila)", + MessageId::ComposerSubmitOffline => "\u{21b5} fila offline", + MessageId::ComposerSubmitQueueWait => "\u{21b5} fila ({count} aguardando)", + MessageId::ComposerSubmitQueueNext => "\u{21b5} fila para a próxima rodada", + MessageId::ComposerSubmitSteering => "\u{21b5} direcionar (Ctrl+Enter)", + MessageId::ComposerSubmitQueued => "\u{21b5} na fila (Ctrl+Enter para direcionar)", + MessageId::ComposerModelDesc => "Trocar para este modelo", + MessageId::ComposerUserCmdDesc => "Comando definido pelo usuário", MessageId::HistorySearchPlaceholder => "Pesquisar histórico de prompts...", MessageId::HistorySearchTitle => "Busca no histórico", MessageId::HistoryHintMove => "Up/Down move", @@ -3420,6 +3549,22 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { fn spanish_latin_america(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::ComposerPlaceholder => "Escribe una tarea o usa /.", + MessageId::ComposerTitle => "Compositor", + MessageId::ComposerTitleDraft => "Borrador", + MessageId::ComposerEmptyHeader => ">_ codewhale (v{version})", + MessageId::ComposerEmptyModel => "model: {name} /model para cambiar", + MessageId::ComposerEmptyDir => "directorio: {workspace}", + MessageId::ComposerSlashHintMove => "Arriba/Abajo mover ", + MessageId::ComposerSlashHintAccept => "Tab aceptar ", + MessageId::ComposerSlashHintClose => "Esc cerrar", + MessageId::ComposerSubmitSend => "\u{21b5} enviar ({count} en cola)", + MessageId::ComposerSubmitOffline => "\u{21b5} cola fuera de línea", + MessageId::ComposerSubmitQueueWait => "\u{21b5} cola ({count} esperando)", + MessageId::ComposerSubmitQueueNext => "\u{21b5} cola para la próxima ronda", + MessageId::ComposerSubmitSteering => "\u{21b5} dirigir (Ctrl+Enter)", + MessageId::ComposerSubmitQueued => "\u{21b5} en cola (Ctrl+Enter para dirigir)", + MessageId::ComposerModelDesc => "Cambiar a este modelo", + MessageId::ComposerUserCmdDesc => "Comando definido por el usuario", MessageId::HistorySearchPlaceholder => "Buscar en el historial de prompts...", MessageId::HistorySearchTitle => "Búsqueda en el historial", MessageId::HistoryHintMove => "Arriba/Abajo mover", diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 92deb9bed..2900f203b 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -573,9 +573,24 @@ impl Renderable for ComposerWidget<'_> { ])) } else if !self.slash_menu_entries.is_empty() { Some(Line::from(vec![ - Span::styled(" Up/Down move ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled("Tab accept ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + format!( + " {}", + self.app + .tr(crate::localization::MessageId::ComposerSlashHintMove) + ), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + self.app + .tr(crate::localization::MessageId::ComposerSlashHintAccept), + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + self.app + .tr(crate::localization::MessageId::ComposerSlashHintClose), + Style::default().fg(palette::TEXT_MUTED), + ), ])) } else if !input_text.trim().is_empty() { // Live disambiguation for #345: when there's content in the @@ -589,7 +604,11 @@ impl Renderable for ComposerWidget<'_> { SubmitDisposition::Immediate => { if queue_count > 0 { ( - Some(format!("↵ send ({queue_count} queued)")), + Some( + self.app + .tr(crate::localization::MessageId::ComposerSubmitSend) + .replace("{count}", &queue_count.to_string()), + ), palette::DEEPSEEK_SKY, ) } else { @@ -598,23 +617,42 @@ impl Renderable for ComposerWidget<'_> { } SubmitDisposition::Queue => { if self.app.offline_mode { - (Some("↵ offline queue".to_string()), palette::STATUS_WARNING) + ( + Some( + self.app + .tr(crate::localization::MessageId::ComposerSubmitOffline) + .to_string(), + ), + palette::STATUS_WARNING, + ) } else { let label = if queue_count > 0 { - format!("↵ queue ({} waiting)", queue_count.saturating_add(1)) + self.app + .tr(crate::localization::MessageId::ComposerSubmitQueueWait) + .replace("{count}", &queue_count.saturating_add(1).to_string()) } else { - "↵ queue for next turn".to_string() + self.app + .tr(crate::localization::MessageId::ComposerSubmitQueueNext) + .to_string() }; (Some(label), palette::TEXT_MUTED) } } // Steer and QueueFollowUp are now only reached via Ctrl+Enter override. SubmitDisposition::Steer => ( - Some("↵ steering (Ctrl+Enter)".to_string()), + Some( + self.app + .tr(crate::localization::MessageId::ComposerSubmitSteering) + .to_string(), + ), palette::DEEPSEEK_SKY, ), SubmitDisposition::QueueFollowUp => ( - Some("↵ queued (Ctrl+Enter to steer)".to_string()), + Some( + self.app + .tr(crate::localization::MessageId::ComposerSubmitQueued) + .to_string(), + ), palette::TEXT_MUTED, ), }; @@ -634,9 +672,10 @@ impl Renderable for ComposerWidget<'_> { self.app .tr(crate::localization::MessageId::HistorySearchTitle) } else if is_draft_mode { - "Draft" + self.app + .tr(crate::localization::MessageId::ComposerTitleDraft) } else { - "Composer" + self.app.tr(crate::localization::MessageId::ComposerTitle) }, Style::default().fg(palette::TEXT_MUTED), ))) @@ -1978,16 +2017,37 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { let body = vec![ Line::from(Span::styled( - format!("{inset}>_ codewhale (v{})", env!("CARGO_PKG_VERSION")), + format!( + "{inset}{}", + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::ComposerEmptyHeader + ) + .replace("{version}", env!("CARGO_PKG_VERSION")) + ), Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )), Line::from(""), Line::from(Span::styled( - format!("{inset}model: {} /model to switch", app.model), + format!( + "{inset}{}", + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::ComposerEmptyModel + ) + .replace("{name}", &app.model) + ), Style::default().fg(palette::TEXT_MUTED), )), Line::from(Span::styled( - format!("{inset}directory: {workspace}"), + format!( + "{inset}{}", + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::ComposerEmptyDir + ) + .replace("{workspace}", &workspace) + ), Style::default().fg(palette::TEXT_MUTED), )), ]; @@ -2251,7 +2311,11 @@ pub(crate) fn slash_completion_hints( for model_name in model_completion_names_for_provider(api_provider) { entries.push(SlashMenuEntry { name: format!("/model {model_name}"), - description: String::from("Switch to this model"), + description: crate::localization::tr( + locale, + crate::localization::MessageId::ComposerModelDesc, + ) + .to_string(), is_skill: false, alias_hint: None, }); @@ -2351,7 +2415,9 @@ fn push_command_entry( }; (desc, hint) } else { - let mut description = String::from("User-defined command"); + let mut description = + crate::localization::tr(locale, crate::localization::MessageId::ComposerUserCmdDesc) + .to_string(); let mut argument_hint = None; if let Some((_, content)) = user_commands.iter().find(|(key, _)| key == command_key) { let (metadata, _) = commands::user_commands::parse_frontmatter(content); @@ -3196,6 +3262,7 @@ mod tests { #[test] fn composer_border_renders_session_title() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::En; app.composer_density = ComposerDensity::Comfortable; app.session_title = Some("my-session".to_string()); let slash_menu_entries = Vec::::new(); @@ -3219,6 +3286,7 @@ mod tests { #[test] fn composer_border_renders_active_turn_receipt() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::En; app.composer_density = ComposerDensity::Comfortable; app.set_receipt_text("✓ turn completed · 2 tool(s) used"); let slash_menu_entries = Vec::::new(); @@ -3362,6 +3430,7 @@ mod tests { #[test] fn empty_state_shows_startup_context() { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::En; app.workspace = PathBuf::from("/tmp/codewhale-test-workspace"); app.model = "deepseek-v4-pro".to_string(); @@ -4039,4 +4108,86 @@ mod tests { ); } } + + #[test] + fn composer_no_english_leak_with_zh_hans_locale() { + let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::ZhHans; + app.composer_density = ComposerDensity::Comfortable; + let slash_menu_entries = vec![SlashMenuEntry { + name: "/test".to_string(), + description: "test command".to_string(), + is_skill: false, + alias_hint: None, + }]; + let mention_menu_entries = Vec::::new(); + let widget = ComposerWidget::new(&app, 20, &slash_menu_entries, &mention_menu_entries); + let area = Rect { + x: 0, + y: 0, + width: 96, + height: 8, + }; + let mut buf = Buffer::empty(area); + + widget.render(area, &mut buf); + let rendered = buffer_text(&buf, area); + + // Positive: Chinese translations are present in block title + assert!(rendered.contains("编"), "expected ZhHans title"); + assert!(rendered.contains("辑"), "expected ZhHans title"); + assert!(rendered.contains("器"), "expected ZhHans title"); + + // Positive: Chinese slash hints are present (menu is open) + // (ratatui spaces CJK characters in the buffer) + assert!(rendered.contains("上"), "expected ZhHans slash hint 'move'"); + assert!(rendered.contains("移"), "expected ZhHans slash hint 'move'"); + assert!(rendered.contains("动"), "expected ZhHans slash hint 'move'"); + assert!( + rendered.contains("Tab 接 受"), + "expected ZhHans slash hint 'accept'" + ); + assert!( + rendered.contains("Esc 关 闭"), + "expected ZhHans slash hint 'close'" + ); + + // Negative: English strings must not leak in composer + assert!(!rendered.contains("Composer"), "English 'Composer' leaked"); + assert!( + !rendered.contains("Up/Down move"), + "English 'Up/Down move' leaked" + ); + assert!( + !rendered.contains("Tab accept"), + "English 'Tab accept' leaked" + ); + assert!( + !rendered.contains("Esc close"), + "English 'Esc close' leaked" + ); + + // Also exercise the empty-state path where "directory:" leak would appear + let empty_lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20)); + let empty_rendered = empty_lines + .iter() + .flat_map(|line| line.spans.iter().map(|s| s.content.as_ref())) + .collect::(); + assert!( + !empty_rendered.contains("directory:"), + "English 'directory:' leaked in empty state" + ); + assert!( + empty_rendered.contains("目"), + "expected ZhHans directory label" + ); + assert!( + empty_rendered.contains("切换"), + "expected ZhHans 'switch' label" + ); + assert!( + empty_rendered.contains("model:"), + "expected 'model:' (technical term kept in English)" + ); + } } diff --git a/crates/tui/tests/qa_pty.rs b/crates/tui/tests/qa_pty.rs index 8e9a86445..9656cd1f8 100644 --- a/crates/tui/tests/qa_pty.rs +++ b/crates/tui/tests/qa_pty.rs @@ -57,6 +57,10 @@ fn spawn_minimal( // the box. 127.0.0.1:1 will refuse instantly. .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1") .env("RUST_LOG", "warn") + // Pin locale to English so PTY assertions that check English UI + // strings (e.g. "Composer") are deterministic regardless of the + // developer's system language. + .env("LANG", "C") .args([ "--workspace", ws.workspace().to_str().expect("utf-8 workspace path"), @@ -183,6 +187,7 @@ fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> { .env("DEEPSEEK_API_KEY", "ci-test-key-not-real") .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1") .env("RUST_LOG", "warn") + .env("LANG", "C") .args([ "--workspace", ws.workspace().to_str().expect("utf-8 workspace path"),