diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 28c532681..42b43cfba 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -12,7 +12,7 @@ use crate::config_persistence::{ persist_tui_integer_key, }; use crate::config_ui::{ConfigUiMode, parse_mode}; -use crate::localization::resolve_locale; +use crate::localization::{MessageId, resolve_locale, tr}; use crate::settings::Settings; use crate::tui::app::{ App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus, VimMode, @@ -867,14 +867,15 @@ pub fn switch_mode(app: &mut App, mode: AppMode) -> String { } fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) { + let locale = app.ui_locale; if app.set_mode(mode) { ( - format!("Switched to {} mode.", mode_display_name(mode)), + tr(locale, MessageId::AppModeSwitched).replace("{mode}", mode_display_name(mode)), true, ) } else { ( - format!("Already in {} mode.", mode_display_name(mode)), + tr(locale, MessageId::AppModeAlreadyIn).replace("{mode}", mode_display_name(mode)), false, ) } @@ -1251,6 +1252,7 @@ mod tests { app.auto_model = false; app.api_provider = crate::config::ApiProvider::Deepseek; app.model_ids_passthrough = false; + app.ui_locale = crate::localization::Locale::En; app } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 28132c50d..faa42800c 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -550,6 +550,16 @@ pub enum MessageId { CtxInspChangesByTurn, CtxInspStablePrefixOnly, CtxInspCacheTip, + // Onboarding screens — welcome screen. + OnboardWelcomeVersion, + OnboardWelcomeDesc, + OnboardWelcomeDesc2, + OnboardWelcomeDesc3, + OnboardWelcomeEnter, + OnboardWelcomeExit, + // App mode status messages. + AppModeSwitched, + AppModeAlreadyIn, } #[allow(dead_code)] @@ -874,6 +884,14 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxInspChangesByTurn, MessageId::CtxInspStablePrefixOnly, MessageId::CtxInspCacheTip, + MessageId::OnboardWelcomeVersion, + MessageId::OnboardWelcomeDesc, + MessageId::OnboardWelcomeDesc2, + MessageId::OnboardWelcomeDesc3, + MessageId::OnboardWelcomeEnter, + MessageId::OnboardWelcomeExit, + MessageId::AppModeSwitched, + MessageId::AppModeAlreadyIn, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1509,6 +1527,18 @@ 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::OnboardWelcomeVersion => "Version {version}", + MessageId::OnboardWelcomeDesc => "A focused terminal workspace for longer model sessions.", + MessageId::OnboardWelcomeDesc2 => { + "You'll add an API key, review trust for this directory, and then land in the chat." + } + MessageId::OnboardWelcomeDesc3 => { + "The main composer is multi-line, so you can write full prompts instead of squeezing everything into one line." + } + MessageId::OnboardWelcomeEnter => "Press Enter to continue.", + MessageId::OnboardWelcomeExit => "Ctrl+C exits at any point.", + MessageId::AppModeSwitched => "Switched to {mode} mode", + MessageId::AppModeAlreadyIn => "Already in {mode} mode", } } @@ -2009,6 +2039,20 @@ 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::OnboardWelcomeVersion => "Phiên bản {version}", + MessageId::OnboardWelcomeDesc => { + "Một không gian làm việc đầu cuối tập trung cho các phiên làm việc với mô hình dài hơn." + } + MessageId::OnboardWelcomeDesc2 => { + "Bạn sẽ thêm khóa API, xem xét quyền truy cập cho thư mục này, và sau đó vào cuộc trò chuyện." + } + MessageId::OnboardWelcomeDesc3 => { + "Trình soạn thảo chính hỗ trợ nhiều dòng, vì vậy bạn có thể viết đầy đủ nội dung thay vì nhồi nhét mọi thứ vào một dòng." + } + MessageId::OnboardWelcomeEnter => "Nhấn Enter để tiếp tục.", + MessageId::OnboardWelcomeExit => "Ctrl+C thoát bất kỳ lúc nào.", + MessageId::AppModeSwitched => "Đã chuyển sang chế độ {mode}", + MessageId::AppModeAlreadyIn => "Đã ở chế độ {mode} rồi", }) } @@ -2073,6 +2117,16 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:穩定前綴區塊符合 DeepSeek V4 前綴快取條件。易變工作集的更改僅會破壞快取尾部。" } + MessageId::OnboardWelcomeVersion => "版本 {version}", + MessageId::OnboardWelcomeDesc => "專注於更長模型會話的終端工作區。", + MessageId::OnboardWelcomeDesc2 => "您將添加 API 金鑰、審閱此目錄的信任設定,然後進入對話。", + MessageId::OnboardWelcomeDesc3 => { + "主編輯器支援多行輸入,因此您可以撰寫完整的提示,而不是將所有內容擠在一行中。" + } + MessageId::OnboardWelcomeEnter => "按 Enter 繼續。", + MessageId::OnboardWelcomeExit => "Ctrl+C 隨時退出。", + MessageId::AppModeSwitched => "已切換至 {mode} 模式", + MessageId::AppModeAlreadyIn => "已在 {mode} 模式", other => chinese_simplified(other)?, }) } @@ -2536,6 +2590,20 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "ヒント:安定プレフィックスブロックはDeepSeek V4プレフィックスキャッシュの対象です。揮発性ワーキングセットの変更は末尾のキャッシュのみを破壊します。" } + MessageId::OnboardWelcomeVersion => "バージョン {version}", + MessageId::OnboardWelcomeDesc => { + "長時間のモデルセッションに最適化された端末ワークスペース。" + } + MessageId::OnboardWelcomeDesc2 => { + "APIキーを追加し、このディレクトリの信頼設定を確認してから、チャットを開始します。" + } + MessageId::OnboardWelcomeDesc3 => { + "メインコンポーザーはマルチライン対応なので、すべてを1行に詰め込む代わりに完全なプロンプトを記述できます。" + } + MessageId::OnboardWelcomeEnter => "Enter を押して続行。", + MessageId::OnboardWelcomeExit => "Ctrl+C でいつでも終了。", + MessageId::AppModeSwitched => "{mode} モードに切り替えました", + MessageId::AppModeAlreadyIn => "既に {mode} モードです", }) } @@ -2940,6 +3008,16 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:稳定前缀区块符合 DeepSeek V4 前缀缓存条件。易变工作集的更改仅会破坏缓存尾部。" } + MessageId::OnboardWelcomeVersion => "版本 {version}", + MessageId::OnboardWelcomeDesc => "专注于更长模型会话的终端工作区。", + MessageId::OnboardWelcomeDesc2 => "您将添加 API 密钥、审阅此目录的信任设置,然后进入对话。", + MessageId::OnboardWelcomeDesc3 => { + "主编辑器支持多行输入,因此您可以编写完整的提示,而不是将所有内容挤在一行中。" + } + MessageId::OnboardWelcomeEnter => "按 Enter 继续。", + MessageId::OnboardWelcomeExit => "Ctrl+C 随时退出。", + MessageId::AppModeSwitched => "已切换至 {mode} 模式", + MessageId::AppModeAlreadyIn => "已在 {mode} 模式", }) } @@ -3426,6 +3504,20 @@ 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::OnboardWelcomeVersion => "Versão {version}", + MessageId::OnboardWelcomeDesc => { + "Um espaço de trabalho terminal focado para sessões de modelo mais longas." + } + MessageId::OnboardWelcomeDesc2 => { + "Você adicionará uma chave de API, revisará a confiança para este diretório e então entrará no chat." + } + MessageId::OnboardWelcomeDesc3 => { + "O compositor principal é multi-linha, permitindo prompts completos em vez de comprimir tudo em uma única linha." + } + MessageId::OnboardWelcomeEnter => "Pressione Enter para continuar.", + MessageId::OnboardWelcomeExit => "Ctrl+C sai a qualquer momento.", + MessageId::AppModeSwitched => "Alternado para o modo {mode}", + MessageId::AppModeAlreadyIn => "Já está no modo {mode}", }) } @@ -3922,6 +4014,20 @@ 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::OnboardWelcomeVersion => "Versión {version}", + MessageId::OnboardWelcomeDesc => { + "Un espacio de trabajo terminal enfocado para sesiones de modelo más largas." + } + MessageId::OnboardWelcomeDesc2 => { + "Agregarás una clave de API, revisarás la confianza para este directorio y luego ingresarás al chat." + } + MessageId::OnboardWelcomeDesc3 => { + "El compositor principal es multilínea, permitiendo prompts completos en lugar de comprimir todo en una línea." + } + MessageId::OnboardWelcomeEnter => "Presiona Enter para continuar.", + MessageId::OnboardWelcomeExit => "Ctrl+C sale en cualquier momento.", + MessageId::AppModeSwitched => "Cambiado al modo {mode}", + MessageId::AppModeAlreadyIn => "Ya está en modo {mode}", }) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 76aba38c7..7c2215752 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -376,12 +376,18 @@ pub enum StatusToastLevel { Error, } +#[derive(Debug, Clone)] +pub enum StatusToastKind { + ModeSwitch, +} + #[derive(Debug, Clone)] pub struct StatusToast { pub text: String, pub level: StatusToastLevel, pub created_at: Instant, pub ttl_ms: Option, + pub is_mode_switch: bool, } impl StatusToast { @@ -392,6 +398,7 @@ impl StatusToast { level, created_at: Instant::now(), ttl_ms, + is_mode_switch: false, } } @@ -1282,6 +1289,10 @@ pub struct App { pub sticky_status: Option, /// Last status text already promoted from `status_message` into toast state. pub last_status_message_seen: Option, + /// When the next status message is promoted, it will be tagged with this + /// variant so downstream checks (mode-switch dedup) work regardless of + /// locale. + pub last_status_toast_kind: Option, pub model: String, /// Persisted model selections by provider name. Loaded from settings so /// `/model` and the picker can surface saved provider-specific choices. @@ -2106,6 +2117,7 @@ impl App { status_toasts: VecDeque::new(), sticky_status: None, last_status_message_seen: None, + last_status_toast_kind: None, model, provider_models, auto_model, @@ -2381,7 +2393,9 @@ impl App { let entering_yolo = mode == AppMode::Yolo && previous_mode != AppMode::Yolo; let leaving_yolo = previous_mode == AppMode::Yolo && mode != AppMode::Yolo; self.mode = mode; - self.status_message = Some(format!("Switched to {} mode", mode.label())); + self.status_message = + Some(tr(self.ui_locale, MessageId::AppModeSwitched).replace("{mode}", mode.label())); + self.last_status_toast_kind = Some(StatusToastKind::ModeSwitch); if entering_yolo { self.yolo_restore = Some(YoloRestoreState { @@ -3179,7 +3193,23 @@ impl App { level: StatusToastLevel, ttl_ms: Option, ) { - let toast = StatusToast::new(text, level, ttl_ms); + self.push_status_toast_with_flags(text, level, ttl_ms, false); + } + + fn push_status_toast_with_flags( + &mut self, + text: impl Into, + level: StatusToastLevel, + ttl_ms: Option, + is_mode_switch: bool, + ) { + let toast = StatusToast { + text: text.into(), + level, + created_at: Instant::now(), + ttl_ms, + is_mode_switch, + }; self.status_toasts.push_back(toast); while self.status_toasts.len() > 24 { self.status_toasts.pop_front(); @@ -3317,10 +3347,6 @@ impl App { (StatusToastLevel::Info, Some(4_000), false) } - fn is_mode_switch_status_message(message: &str) -> bool { - message.starts_with("Switched to ") && message.ends_with(" mode") - } - pub fn sync_status_message_to_toasts(&mut self) { let current = self.status_message.clone(); if self.last_status_message_seen == current { @@ -3335,6 +3361,12 @@ impl App { return; } + let is_mode_switch = matches!( + self.last_status_toast_kind, + Some(StatusToastKind::ModeSwitch) + ); + self.last_status_toast_kind = None; + let (level, ttl_ms, sticky) = Self::classify_status_text(&message); if sticky { self.set_sticky_status(message, level, ttl_ms); @@ -3347,11 +3379,10 @@ impl App { { self.clear_sticky_status(); } - if Self::is_mode_switch_status_message(&message) { - self.status_toasts - .retain(|toast| !Self::is_mode_switch_status_message(&toast.text)); + if is_mode_switch { + self.status_toasts.retain(|toast| !toast.is_mode_switch); } - self.push_status_toast(message, level, ttl_ms); + self.push_status_toast_with_flags(message, level, ttl_ms, is_mode_switch); } } @@ -6135,6 +6166,7 @@ mod tests { #[test] fn test_mode_switch_toasts_replace_previous_mode_switch_toast() { let mut app = App::new(test_options(false), &Config::default()); + app.ui_locale = Locale::En; let first_mode = match app.mode { AppMode::Plan => AppMode::Agent, AppMode::Agent => AppMode::Yolo, @@ -6179,6 +6211,7 @@ mod tests { #[test] fn test_mode_switch_toasts_do_not_disrupt_non_mode_toasts() { let mut app = App::new(test_options(false), &Config::default()); + app.ui_locale = Locale::En; app.status_message = Some("Task queued".to_string()); app.sync_status_message_to_toasts(); diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index a1cce682a..0ba444789 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -33,7 +33,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; let lines = match app.onboarding { - OnboardingState::Welcome => welcome::lines(), + OnboardingState::Welcome => welcome::lines(app.ui_locale), OnboardingState::Language => language::lines(app), OnboardingState::ApiKey => api_key::lines(app), OnboardingState::TrustDirectory => trust_directory::lines(app), diff --git a/crates/tui/src/tui/onboarding/welcome.rs b/crates/tui/src/tui/onboarding/welcome.rs index 46d710fe2..c232a233f 100644 --- a/crates/tui/src/tui/onboarding/welcome.rs +++ b/crates/tui/src/tui/onboarding/welcome.rs @@ -1,11 +1,12 @@ -//! Welcome screen content for onboarding. - use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; -pub fn lines() -> Vec> { +pub fn lines(locale: Locale) -> Vec> { + let version = tr(locale, MessageId::OnboardWelcomeVersion) + .replace("{version}", env!("CARGO_PKG_VERSION")); vec![ Line::from(Span::styled( "codewhale", @@ -14,29 +15,29 @@ pub fn lines() -> Vec> { .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - format!("Version {}", env!("CARGO_PKG_VERSION")), + version, Style::default().fg(palette::TEXT_MUTED), )), Line::from(""), Line::from(Span::styled( - "A focused terminal workspace for longer model sessions.", + tr(locale, MessageId::OnboardWelcomeDesc), Style::default().fg(palette::TEXT_PRIMARY), )), Line::from(Span::styled( - "You'll add an API key, review trust for this directory, and then land in the chat.", + tr(locale, MessageId::OnboardWelcomeDesc2), Style::default().fg(palette::TEXT_MUTED), )), Line::from(Span::styled( - "The main composer is multi-line, so you can write full prompts instead of squeezing everything into one line.", + tr(locale, MessageId::OnboardWelcomeDesc3), Style::default().fg(palette::TEXT_MUTED), )), Line::from(""), Line::from(Span::styled( - "Press Enter to continue.", + tr(locale, MessageId::OnboardWelcomeEnter), Style::default().fg(palette::TEXT_PRIMARY), )), Line::from(Span::styled( - "Ctrl+C exits at any point.", + tr(locale, MessageId::OnboardWelcomeExit), Style::default().fg(palette::TEXT_MUTED), )), ]