Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions crates/tui/src/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 => "破壞性",
Expand Down Expand Up @@ -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 => "破壊的操作",
Expand Down Expand Up @@ -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 => "破坏性",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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!(
Expand Down
46 changes: 33 additions & 13 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
},
)
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
));
}
}
Expand Down
23 changes: 15 additions & 8 deletions crates/tui/src/tui/views/mode_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
)));

Expand All @@ -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),
]));
}

Expand All @@ -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 {
Expand All @@ -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 }) => {
Expand Down
Loading
Loading