From af46bef8d6ae06297b72f6360c88e6ba86e16fd0 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Mon, 8 Jun 2026 14:54:52 +0800 Subject: [PATCH] feat(i18n): localize status picker surface (7 MessageIds) --- crates/tui/src/localization.rs | 66 ++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 1 + crates/tui/src/tui/views/status_picker.rs | 69 ++++++++++++++++------- 3 files changed, 116 insertions(+), 20 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index e85990de..d5c23dad 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -228,6 +228,14 @@ pub enum MessageId { HistoryHintAccept, HistoryHintRestore, HistoryNoMatches, + // StatusPicker — `/statusline` multi-select footer-item picker. + StatusPickerTitle, + StatusPickerInstruction, + StatusPickerActionToggle, + StatusPickerActionAll, + StatusPickerActionNone, + StatusPickerActionSave, + StatusPickerActionCancel, ConfigTitle, ConfigModalTitle, ConfigSearchPlaceholder, @@ -559,6 +567,13 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::HistoryHintAccept, MessageId::HistoryHintRestore, MessageId::HistoryNoMatches, + MessageId::StatusPickerTitle, + MessageId::StatusPickerInstruction, + MessageId::StatusPickerActionToggle, + MessageId::StatusPickerActionAll, + MessageId::StatusPickerActionNone, + MessageId::StatusPickerActionSave, + MessageId::StatusPickerActionCancel, MessageId::ConfigTitle, MessageId::ConfigModalTitle, MessageId::ConfigSearchPlaceholder, @@ -1061,6 +1076,13 @@ fn english(id: MessageId) -> &'static str { MessageId::HistoryHintAccept => "Enter accept", MessageId::HistoryHintRestore => "Esc restore", MessageId::HistoryNoMatches => " No matches", + MessageId::StatusPickerTitle => " Status line ", + MessageId::StatusPickerInstruction => "Pick the chips you want in the footer:", + MessageId::StatusPickerActionToggle => "toggle ", + MessageId::StatusPickerActionAll => "all ", + MessageId::StatusPickerActionNone => "none ", + MessageId::StatusPickerActionSave => "save ", + MessageId::StatusPickerActionCancel => "cancel ", MessageId::ConfigTitle => "Session Configuration", MessageId::ConfigModalTitle => " Config ", MessageId::ConfigSearchPlaceholder => "type to filter", @@ -1527,6 +1549,15 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::HistoryHintAccept => "Enter để chấp nhận", MessageId::HistoryHintRestore => "Esc để khôi phục", MessageId::HistoryNoMatches => " Không tìm thấy kết quả", + MessageId::StatusPickerTitle => " Dòng trạng thái ", + MessageId::StatusPickerInstruction => { + "Chọn các thành phần bạn muốn hiển thị ở cuối màn hình:" + } + MessageId::StatusPickerActionToggle => "bật/tắt ", + MessageId::StatusPickerActionAll => "tất cả ", + MessageId::StatusPickerActionNone => "không ", + MessageId::StatusPickerActionSave => "lưu ", + MessageId::StatusPickerActionCancel => "huỷ ", MessageId::ConfigTitle => "Cấu hình phiên làm việc", MessageId::ConfigModalTitle => " Cấu hình ", MessageId::ConfigSearchPlaceholder => "Nhập để lọc kết quả", @@ -2065,6 +2096,13 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:穩定前綴區塊符合 DeepSeek V4 前綴快取條件。易變工作集的更改僅會破壞快取尾部。" } + MessageId::StatusPickerTitle => " 狀態列 ", + MessageId::StatusPickerInstruction => "選擇要在底部顯示的項目:", + MessageId::StatusPickerActionToggle => "切換 ", + MessageId::StatusPickerActionAll => "全部 ", + MessageId::StatusPickerActionNone => "無 ", + MessageId::StatusPickerActionSave => "儲存 ", + MessageId::StatusPickerActionCancel => "取消 ", other => chinese_simplified(other)?, }) } @@ -2078,6 +2116,13 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::HistoryHintAccept => "Enter 確定", MessageId::HistoryHintRestore => "Esc 復元", MessageId::HistoryNoMatches => " 一致なし", + MessageId::StatusPickerTitle => " ステータス行 ", + MessageId::StatusPickerInstruction => "フッターに表示する項目を選択:", + MessageId::StatusPickerActionToggle => "切替 ", + MessageId::StatusPickerActionAll => "すべて ", + MessageId::StatusPickerActionNone => "なし ", + MessageId::StatusPickerActionSave => "保存 ", + MessageId::StatusPickerActionCancel => "キャンセル ", MessageId::ConfigTitle => "セッション設定", MessageId::ConfigModalTitle => " 設定 ", MessageId::ConfigSearchPlaceholder => "入力して絞り込み", @@ -2538,6 +2583,13 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::HistoryHintAccept => "Enter 接受", MessageId::HistoryHintRestore => "Esc 还原", MessageId::HistoryNoMatches => " 无匹配", + MessageId::StatusPickerTitle => " 状态行 ", + MessageId::StatusPickerInstruction => "选择要在底部显示的项目:", + MessageId::StatusPickerActionToggle => "切换 ", + MessageId::StatusPickerActionAll => "全部 ", + MessageId::StatusPickerActionNone => "无 ", + MessageId::StatusPickerActionSave => "保存 ", + MessageId::StatusPickerActionCancel => "取消 ", MessageId::ConfigTitle => "会话配置", MessageId::ConfigModalTitle => " 配置 ", MessageId::ConfigSearchPlaceholder => "输入以筛选", @@ -2940,6 +2992,13 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::HistoryHintAccept => "Enter aceita", MessageId::HistoryHintRestore => "Esc restaura", MessageId::HistoryNoMatches => " Sem resultados", + MessageId::StatusPickerTitle => " Linha de status ", + MessageId::StatusPickerInstruction => "Escolha os itens que deseja no rodapé:", + MessageId::StatusPickerActionToggle => "alternar ", + MessageId::StatusPickerActionAll => "todos ", + MessageId::StatusPickerActionNone => "nenhum ", + MessageId::StatusPickerActionSave => "salvar ", + MessageId::StatusPickerActionCancel => "cancelar ", MessageId::ConfigTitle => "Configuração da sessão", MessageId::ConfigModalTitle => " Config ", MessageId::ConfigSearchPlaceholder => "digite para filtrar", @@ -3424,6 +3483,13 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::HistoryHintAccept => "Enter aceptar", MessageId::HistoryHintRestore => "Esc restaurar", MessageId::HistoryNoMatches => " Sin resultados", + MessageId::StatusPickerTitle => " Línea de estado ", + MessageId::StatusPickerInstruction => "Elige los elementos que quieres en el pie:", + MessageId::StatusPickerActionToggle => "alternar ", + MessageId::StatusPickerActionAll => "todos ", + MessageId::StatusPickerActionNone => "ninguno ", + MessageId::StatusPickerActionSave => "guardar ", + MessageId::StatusPickerActionCancel => "cancelar ", MessageId::ConfigTitle => "Configuración de la sesión", MessageId::ConfigModalTitle => " Config ", MessageId::ConfigSearchPlaceholder => "escribe para filtrar", diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b23f4fad..56c67e1a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5742,6 +5742,7 @@ async fn apply_command_result( .push(crate::tui::views::status_picker::StatusPickerView::new( &app.status_items, app.api_provider, + app.ui_locale, )); } } diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 6aa8a93e..aa44a458 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -19,7 +19,7 @@ use ratatui::{ }; use crate::config::{ApiProvider, StatusItem}; -use crate::localization::truncate_to_width; +use crate::localization::{Locale, MessageId, tr, truncate_to_width}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; use unicode_width::UnicodeWidthStr; @@ -39,11 +39,12 @@ pub struct StatusPickerView { cursor: usize, /// Snapshot of `app.status_items` at open time so Esc reverts cleanly. original: Vec, + locale: Locale, } impl StatusPickerView { #[must_use] - pub fn new(active: &[StatusItem], provider: ApiProvider) -> Self { + pub fn new(active: &[StatusItem], provider: ApiProvider, locale: Locale) -> Self { let rows: Vec = StatusItem::all() .iter() .filter(|item| item.is_available_for(provider)) @@ -55,6 +56,7 @@ impl StatusPickerView { selected, cursor: 0, original: active.to_vec(), + locale, } } @@ -185,22 +187,22 @@ impl ModalView for StatusPickerView { let block = Block::default() .title(Line::from(Span::styled( - " Status line ", + tr(self.locale, MessageId::StatusPickerTitle), Style::default() .fg(palette::DEEPSEEK_SKY) .add_modifier(Modifier::BOLD), ))) .title_bottom(Line::from(vec![ Span::styled(" Space ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("toggle "), + Span::raw(tr(self.locale, MessageId::StatusPickerActionToggle)), Span::styled(" a ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("all "), + Span::raw(tr(self.locale, MessageId::StatusPickerActionAll)), Span::styled(" n ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("none "), + Span::raw(tr(self.locale, MessageId::StatusPickerActionNone)), Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("save "), + Span::raw(tr(self.locale, MessageId::StatusPickerActionSave)), Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("cancel "), + Span::raw(tr(self.locale, MessageId::StatusPickerActionCancel)), ])) .borders(Borders::ALL) .border_style(Style::default().fg(palette::BORDER_COLOR)) @@ -215,7 +217,7 @@ impl ModalView for StatusPickerView { let mut lines: Vec = Vec::with_capacity(visible_rows + 2); lines.push(Line::from(Span::styled( - "Pick the chips you want in the footer:", + tr(self.locale, MessageId::StatusPickerInstruction), Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from("")); @@ -297,19 +299,19 @@ fn status_row_text(pointer: &str, mark: &str, item: &StatusItem, width: usize) - #[cfg(test)] mod tests { use super::*; + use crate::localization::Locale; #[test] fn opens_with_active_items_pre_selected() { let active = StatusItem::default_footer(); - let view = StatusPickerView::new(&active, ApiProvider::Deepseek); + let view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); assert_eq!(view.current_selection(), active); } #[test] fn space_toggles_current_row_and_emits_live_preview() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); - // Cursor starts at row 0 = StatusItem::Mode (currently checked). + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); match action { ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, final_save }) => { @@ -323,7 +325,7 @@ mod tests { #[test] fn enter_emits_final_save() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match action { ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => { @@ -336,8 +338,7 @@ mod tests { #[test] fn esc_reverts_to_snapshot() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); - // Toggle a few items off so the working set diverges from snapshot. + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); view.move_down(); view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); @@ -354,7 +355,7 @@ mod tests { #[test] fn select_all_and_select_none_keys_work() { let active: Vec = Vec::new(); - let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); match action { ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => { @@ -374,7 +375,7 @@ mod tests { #[test] fn arrow_keys_wrap_cursor_at_edges() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek); + let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); assert_eq!(view.cursor, 0); view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(view.cursor, StatusItem::all().len() - 1); @@ -404,10 +405,38 @@ mod tests { #[test] fn balance_excluded_for_non_deepseek_provider() { let active = StatusItem::default_footer(); - let view = StatusPickerView::new(&active, ApiProvider::Openrouter); - // Balance should not appear as a row for non-DeepSeek providers. + let view = StatusPickerView::new(&active, ApiProvider::Openrouter, Locale::En); assert!(!view.rows.contains(&StatusItem::Balance)); - // Mode should still be present. assert!(view.rows.contains(&StatusItem::Mode)); } + + #[test] + fn status_picker_displays_localized_title_for_zh_hans() { + assert_eq!(tr(Locale::ZhHans, MessageId::StatusPickerTitle), " 状态行 "); + } + + #[test] + fn status_picker_no_english_leak_in_non_en_locales() { + for locale in [ + Locale::Ja, + Locale::ZhHans, + Locale::ZhHant, + Locale::PtBr, + Locale::Es419, + Locale::Vi, + ] { + let title = tr(locale, MessageId::StatusPickerTitle); + assert!( + !title.contains("Status"), + "{} leaks English in title: {title}", + locale.tag() + ); + let instruction = tr(locale, MessageId::StatusPickerInstruction); + assert!( + !instruction.contains("footer"), + "{} leaks English in instruction: {instruction}", + locale.tag() + ); + } + } }