diff --git a/CHANGELOG.md b/CHANGELOG.md index 81de968cd..9f91214cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Localized mode picker and composer indicators.** The `/mode` picker prompt, + mode names, and hints, plus the composer's Vim mode indicator, now render in + all seven shipped locales (model-facing mode labels stay English). Harvested + from #2239 by @gordonlu. + ## [0.8.64] - 2026-06-22 ### Added diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index ab29b73d9..2c4b02cd1 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Localized mode picker and composer indicators.** The `/mode` picker prompt, + mode names, and hints, plus the composer's Vim mode indicator, now render in + all seven shipped locales (model-facing mode labels stay English). Harvested + from #2239 by @gordonlu. + ## [0.8.64] - 2026-06-22 ### Added diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 09e781664..e04e2d9ff 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -547,6 +547,18 @@ pub enum MessageId { // Agent fanout card. FanoutCounts, + // App mode picker (prompt, names, hints) and composer vim indicator. + ModePickerPrompt, + AppModeAgent, + AppModeYolo, + AppModePlan, + AppModeAgentHint, + AppModePlanHint, + AppModeYoloHint, + VimModeNormal, + VimModeInsert, + VimModeVisual, + // Approval dialog — risk badges, category labels, field labels, options. ApprovalRiskReview, ApprovalRiskDestructive, @@ -985,6 +997,16 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxMenuHelp, MessageId::CtxMenuHelpDesc, MessageId::FanoutCounts, + MessageId::ModePickerPrompt, + MessageId::AppModeAgent, + MessageId::AppModeYolo, + MessageId::AppModePlan, + MessageId::AppModeAgentHint, + MessageId::AppModePlanHint, + MessageId::AppModeYoloHint, + MessageId::VimModeNormal, + MessageId::VimModeInsert, + MessageId::VimModeVisual, MessageId::ApprovalRiskReview, MessageId::ApprovalRiskDestructive, MessageId::ApprovalCategorySafe, @@ -1734,6 +1756,18 @@ fn english(id: MessageId) -> &'static str { "{done} done · {running} running · {failed} failed · {pending} pending" } + // App mode picker (prompt, names, hints) and composer vim indicator. + MessageId::ModePickerPrompt => "Choose how CodeWhale should operate:", + MessageId::AppModeAgent => "Agent", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "Plan", + MessageId::AppModeAgentHint => "Normal execution with approvals", + MessageId::AppModePlanHint => "Plan first before execution", + MessageId::AppModeYoloHint => "Auto-approve; shell enabled", + MessageId::VimModeNormal => "-- NORMAL --", + MessageId::VimModeInsert => "-- INSERT --", + MessageId::VimModeVisual => "-- VISUAL --", + // Approval dialog. MessageId::ApprovalRiskReview => "REVIEW", MessageId::ApprovalRiskDestructive => "DESTRUCTIVE", @@ -2376,6 +2410,18 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { "{done} hoàn thành · {running} đang chạy · {failed} thất bại · {pending} chờ" } + // App mode picker (prompt, names, hints) and composer vim indicator. + MessageId::ModePickerPrompt => "Chọn cách CodeWhale hoạt động:", + MessageId::AppModeAgent => "Tác nhân", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "Kế hoạch", + MessageId::AppModeAgentHint => "Thực thi bình thường, hỏi trước khi thay đổi", + MessageId::AppModePlanHint => "Lập kế hoạch trước khi thực thi", + MessageId::AppModeYoloHint => "Tự động phê duyệt; bật shell (toàn quyền)", + MessageId::VimModeNormal => "-- BÌNH THƯỜNG --", + MessageId::VimModeInsert => "-- CHÈN --", + MessageId::VimModeVisual => "-- TRỰC QUAN --", + // Approval dialog. MessageId::ApprovalRiskReview => "XEM XÉT", MessageId::ApprovalRiskDestructive => "NGUY HẠI", @@ -2541,6 +2587,18 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { "{done} 已完成 · {running} 運行中 · {failed} 失敗 · {pending} 等待中" } + // App mode picker (prompt, names, hints) and composer vim indicator. + MessageId::ModePickerPrompt => "選擇 CodeWhale 的運作方式:", + MessageId::AppModeAgent => "智能體", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "計畫", + MessageId::AppModeAgentHint => "正常執行,變更前需核准", + MessageId::AppModePlanHint => "先規劃,再執行", + MessageId::AppModeYoloHint => "自動核准;啟用 shell(完全存取)", + MessageId::VimModeNormal => "-- 一般 --", + MessageId::VimModeInsert => "-- 插入 --", + MessageId::VimModeVisual => "-- 可視 --", + // Approval dialog. MessageId::ApprovalRiskReview => "審查", MessageId::ApprovalRiskDestructive => "破壞性", @@ -3165,6 +3223,18 @@ fn japanese(id: MessageId) -> Option<&'static str> { "{done} 完了 · {running} 実行中 · {failed} 失敗 · {pending} 保留" } + // App mode picker (prompt, names, hints) and composer vim indicator. + MessageId::ModePickerPrompt => "CodeWhale の動作方法を選択してください:", + MessageId::AppModeAgent => "エージェント", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "プラン", + MessageId::AppModeAgentHint => "通常実行(変更前に承認を求めます)", + MessageId::AppModePlanHint => "実行前にまず計画します", + MessageId::AppModeYoloHint => "自動承認・シェル有効(フルアクセス)", + MessageId::VimModeNormal => "-- ノーマル --", + MessageId::VimModeInsert => "-- 挿入 --", + MessageId::VimModeVisual => "-- ビジュアル --", + // Approval dialog. MessageId::ApprovalRiskReview => "確認", MessageId::ApprovalRiskDestructive => "破壊的操作", @@ -3711,6 +3781,18 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { "{done} 已完成 · {running} 运行中 · {failed} 失败 · {pending} 等待中" } + // App mode picker (prompt, names, hints) and composer vim indicator. + MessageId::ModePickerPrompt => "选择 CodeWhale 的运行方式:", + MessageId::AppModeAgent => "智能体", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "计划", + MessageId::AppModeAgentHint => "正常执行,变更前需批准", + MessageId::AppModePlanHint => "先规划,再执行", + MessageId::AppModeYoloHint => "自动批准;启用 shell(完全访问)", + MessageId::VimModeNormal => "-- 普通 --", + MessageId::VimModeInsert => "-- 插入 --", + MessageId::VimModeVisual => "-- 可视 --", + // Approval dialog. MessageId::ApprovalRiskReview => "审查", MessageId::ApprovalRiskDestructive => "破坏性", @@ -4323,6 +4405,18 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "{done} concluído · {running} em execução · {failed} falhou · {pending} pendente" } + // App mode picker (prompt, names, hints) and composer vim indicator. + MessageId::ModePickerPrompt => "Escolha como o CodeWhale deve operar:", + MessageId::AppModeAgent => "Agente", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "Plano", + MessageId::AppModeAgentHint => "Execução normal com aprovações", + MessageId::AppModePlanHint => "Planeje antes de executar", + MessageId::AppModeYoloHint => "Aprovação automática; shell habilitado", + MessageId::VimModeNormal => "-- NORMAL --", + MessageId::VimModeInsert => "-- INSERIR --", + MessageId::VimModeVisual => "-- VISUAL --", + // Approval dialog. MessageId::ApprovalRiskReview => "REVISÃO", MessageId::ApprovalRiskDestructive => "DESTRUTIVO", @@ -4959,6 +5053,18 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { "{done} completado · {running} ejecutando · {failed} falló · {pending} pendiente" } + // App mode picker (prompt, names, hints) and composer vim indicator. + MessageId::ModePickerPrompt => "Elige cómo debe funcionar CodeWhale:", + MessageId::AppModeAgent => "Agente", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "Plan", + MessageId::AppModeAgentHint => "Ejecución normal con aprobaciones", + MessageId::AppModePlanHint => "Planifica antes de ejecutar", + MessageId::AppModeYoloHint => "Aprobación automática; shell habilitado", + MessageId::VimModeNormal => "-- NORMAL --", + MessageId::VimModeInsert => "-- INSERTAR --", + MessageId::VimModeVisual => "-- VISUAL --", + // Approval dialog. MessageId::ApprovalRiskReview => "REVISAR", MessageId::ApprovalRiskDestructive => "DESTRUCTIVO", @@ -5162,6 +5268,34 @@ mod tests { } } + #[test] + fn mode_picker_strings_are_translated_in_non_english_locales() { + // The picker prompt and the three mode hints are full sentences; every + // shipped non-English locale must provide a real translation rather than + // leaking the English string through the fallback chain. + let sentences = [ + MessageId::ModePickerPrompt, + MessageId::AppModeAgentHint, + MessageId::AppModePlanHint, + MessageId::AppModeYoloHint, + ]; + for locale in Locale::shipped() { + if *locale == Locale::En { + continue; + } + for id in sentences { + let localized = tr(*locale, id); + assert!(!localized.is_empty(), "{} empty for {id:?}", locale.tag()); + assert_ne!( + localized, + tr(Locale::En, id), + "{} should translate {id:?}", + locale.tag() + ); + } + } + } + #[test] fn unsupported_locale_falls_back_to_english() { assert_eq!( diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 952a98113..be17f500f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -958,13 +958,30 @@ impl AppMode { } } + /// Localized short name for the mode picker (user-facing surface only). #[must_use] - pub fn picker_hint(self) -> &'static str { - match self { - AppMode::Agent => "Normal execution with approvals", - AppMode::Plan => "Plan first before execution", - AppMode::Yolo => "Auto-approve; shell enabled", - } + pub fn display_name_localized(self, locale: Locale) -> &'static str { + tr( + locale, + match self { + AppMode::Agent => MessageId::AppModeAgent, + AppMode::Yolo => MessageId::AppModeYolo, + AppMode::Plan => MessageId::AppModePlan, + }, + ) + } + + /// Localized one-line hint for the mode picker (user-facing surface only). + #[must_use] + pub fn picker_hint_localized(self, locale: Locale) -> &'static str { + tr( + locale, + match self { + AppMode::Agent => MessageId::AppModeAgentHint, + AppMode::Plan => MessageId::AppModePlanHint, + AppMode::Yolo => MessageId::AppModeYoloHint, + }, + ) } #[allow(dead_code)] @@ -1087,14 +1104,17 @@ pub enum VimMode { } impl VimMode { - /// Short status-bar label shown in the composer border. + /// Localized status-bar label shown in the composer border (user-facing). #[must_use] - pub fn label(self) -> &'static str { - match self { - Self::Normal => "-- NORMAL --", - Self::Insert => "-- INSERT --", - Self::Visual => "-- VISUAL --", - } + pub fn label_localized(self, locale: Locale) -> &'static str { + tr( + locale, + match self { + Self::Normal => MessageId::VimModeNormal, + Self::Insert => MessageId::VimModeInsert, + Self::Visual => MessageId::VimModeVisual, + }, + ) } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 6fbc77a75..dad539fca 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -7303,6 +7303,7 @@ async fn apply_command_result( app.view_stack .push(crate::tui::views::mode_picker::ModePickerView::new( app.mode, + app.ui_locale, )); } } diff --git a/crates/tui/src/tui/views/mode_picker.rs b/crates/tui/src/tui/views/mode_picker.rs index b06167a1f..a0220793c 100644 --- a/crates/tui/src/tui/views/mode_picker.rs +++ b/crates/tui/src/tui/views/mode_picker.rs @@ -8,23 +8,26 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, }; +use unicode_width::UnicodeWidthStr; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tui::app::AppMode; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; pub struct ModePickerView { cursor: usize, + locale: Locale, } impl ModePickerView { #[must_use] - pub fn new(current: AppMode) -> Self { + pub fn new(current: AppMode, locale: Locale) -> Self { let cursor = AppMode::CHOICES .iter() .position(|mode| *mode == current) .unwrap_or(0); - Self { cursor } + Self { cursor, locale } } fn selected_mode(&self) -> AppMode { @@ -123,7 +126,7 @@ impl ModalView for ModePickerView { let mut lines = Vec::with_capacity(AppMode::CHOICES.len() + 1); lines.push(Line::from(Span::styled( - "Choose how CodeWhale should operate:", + tr(self.locale, MessageId::ModePickerPrompt), Style::default().fg(palette::TEXT_MUTED), ))); @@ -145,13 +148,17 @@ impl ModalView for ModePickerView { Style::default().fg(palette::TEXT_MUTED) }; let pointer = if is_cursor { ">" } else { " " }; + let name = mode.display_name_localized(self.locale); + // Pad by terminal columns, not scalar count, so wide (CJK) mode + // names keep the hint column aligned. + let pad = " ".repeat(7usize.saturating_sub(UnicodeWidthStr::width(name))); lines.push(Line::from(vec![ Span::styled( - format!("{pointer} {}. {:<7}", mode.number(), mode.display_name()), + format!("{pointer} {}. {name}{pad}", mode.number()), row_style, ), - Span::styled(mode.picker_hint(), hint_style), + Span::styled(mode.picker_hint_localized(self.locale), hint_style), ])); } @@ -166,13 +173,13 @@ mod tests { #[test] fn opens_on_current_mode() { - let view = ModePickerView::new(AppMode::Plan); + let view = ModePickerView::new(AppMode::Plan, Locale::En); assert_eq!(view.selected_mode(), AppMode::Plan); } #[test] fn enter_emits_selected_mode() { - let mut view = ModePickerView::new(AppMode::Agent); + let mut view = ModePickerView::new(AppMode::Agent, Locale::En); view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match action { @@ -185,7 +192,7 @@ mod tests { #[test] fn number_keys_select_modes() { - let mut view = ModePickerView::new(AppMode::Agent); + let mut view = ModePickerView::new(AppMode::Agent, Locale::En); let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); match action { ViewAction::EmitAndClose(ViewEvent::ModeSelected { mode }) => { diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 15fce7acc..a5099bf01 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -2140,7 +2140,7 @@ fn composer_top_right_chrome(app: &App, area_width: u16) -> Option if let Some(receipt) = receipt { let receipt_text = receipt.trim(); if app.composer.vim_enabled { - let vim_label = app.composer.vim_mode.label(); + let vim_label = app.composer.vim_mode.label_localized(app.ui_locale); let vim_width = UnicodeWidthStr::width(vim_label); let sep_width = UnicodeWidthStr::width(" · "); if vim_width + sep_width + 4 <= max_width { @@ -2165,7 +2165,10 @@ fn composer_top_right_chrome(app: &App, area_width: u16) -> Option let mut spans: Vec = Vec::new(); if app.composer.vim_enabled { spans.push(Span::styled( - truncate_display_width(app.composer.vim_mode.label(), max_width), + truncate_display_width( + app.composer.vim_mode.label_localized(app.ui_locale), + max_width, + ), vim_mode_style(app.composer.vim_mode), )); } diff --git a/docs/CONTRIBUTORS.md b/docs/CONTRIBUTORS.md index 195e68180..7e993da98 100644 --- a/docs/CONTRIBUTORS.md +++ b/docs/CONTRIBUTORS.md @@ -258,7 +258,7 @@ patches, and TUI fixes landed alongside first-time and returning contributor wor - **[wlon](https://github.com/wlon)** — NVIDIA NIM provider API-key preference diagnosis (#1081) - **[Horace Liu](https://github.com/liuhq)** — Nix package support and install documentation (#1173) - **[jieshu666](https://github.com/jieshu666)** — terminal repaint flicker reduction (#1563) -- **[gordonlu](https://github.com/gordonlu)** — Windows Enter / CSI-u input fix, status picker localization (7 MessageIds), and approval dialog localization across 7 locales (#1612, #2896, #2891) +- **[gordonlu](https://github.com/gordonlu)** — Windows Enter / CSI-u input fix, status picker localization (7 MessageIds), approval dialog localization across 7 locales, and mode picker + composer Vim indicator localization across 7 locales (#1612, #2896, #2891, #2239) - **[mdrkrg](https://github.com/mdrkrg)** — first-run onboarding crash fix when the API key is missing (#1598) - **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622) - **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645)