From a809bf1387d49ca6491966e207cf19e80755f497 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Thu, 14 May 2026 14:07:16 +0800 Subject: [PATCH 01/15] fix(input): write \033[>0u instead of \033[>1u on Windows to fix Enter (#1599) \033[>1u enables the kitty keyboard protocol with DISAMBIGUATE_ESCAPE_CODES. On Windows Terminal, this causes Enter to emit \033[57414u, but crossterm does not decode CSI u sequences on Windows (issue #1599). The raw string [57414u appears as text input instead of sending the message. \033[>0u advertises protocol awareness without enabling any flags. Enter stays as \r\n and crossterm processes it normally. Changes: - push_keyboard_enhancement_flags: write \033[>0u instead of \033[>1u on Windows - Gate KeyboardEnhancementFlags import behind #[cfg(not(windows))] - Update Windows test to expect \033[>0u - Non-Windows test unchanged (still expects \033[>1u) - Remove unused KeyEvent, MouseButton, MouseEvent, MouseEventKind imports introduced by upstream during rebase --- crates/tui/src/tui/ui.rs | 12 +++++++----- crates/tui/src/tui/ui/tests.rs | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index bd13f7078..9ae5e9de4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -11,7 +11,6 @@ use crossterm::{ event::{ self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, - KeyboardEnhancementFlags, }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, @@ -20,7 +19,9 @@ use crossterm::{ // PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are // never referenced, so the imports are gated to avoid -D warnings failures. #[cfg(not(windows))] -use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}; +use crossterm::event::{ + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; use ratatui::{ Frame, Terminal, layout::{Constraint, Direction, Layout, Rect, Size}, @@ -6314,11 +6315,12 @@ fn push_keyboard_enhancement_flags(writer: &mut W) { // returns Unsupported on Windows (is_ansi_code_supported() == false), so // the ANSI escape is written directly on that platform. Modern Windows // terminals (VSCode integrated terminal, Windows Terminal ≥1.17) honour - // the kitty keyboard protocol; terminals that do not silently discard it. + // the kitty keyboard protocol but crossterm's event reader does not + // decode CSI u sequences on Windows (issue #1599). Write \033[>0u to + // probe the protocol without enabling any flags — Enter stays as \n. #[cfg(windows)] { - let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES.bits(); - if let Err(err) = write!(writer, "\x1b[>{}u", flags).and_then(|()| writer.flush()) { + if let Err(err) = write!(writer, "\x1b[>0u").and_then(|()| writer.flush()) { tracing::debug!( target: "kitty_keyboard", ?err, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index deccfe346..518331d5b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -153,8 +153,8 @@ fn push_keyboard_flags_writes_kitty_push_sequence_on_windows() { push_keyboard_enhancement_flags(&mut buf); let seq = String::from_utf8_lossy(&buf); assert!( - seq.contains("\x1b[>1u"), - "push_keyboard_enhancement_flags must write kitty push (\\x1b[>1u) on Windows (#1359); got: {seq:?}" + seq.contains("\x1b[>0u"), + "push_keyboard_enhancement_flags must write kitty probe (\\x1b[>0u) on Windows (#1599); got: {seq:?}" ); } From 567d824d1cecaf61cd5fa476b959e2d1a44f7bf6 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 6 May 2026 14:54:13 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20i18n=20Phase=201=20=E2=80=94=20lo?= =?UTF-8?q?calize=20composer,=20config=20modal,=20status=20picker,=20and?= =?UTF-8?q?=20pending=20input=20preview=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 23 new MessageId variants with translations for all 4 shipped locales (en, ja, zh-Hans, pt-BR) covering high-frequency TUI chrome: - Composer submit/queue/steer/offline hints and queue count - Config modal section labels - Status line picker title, instruction, and action hints - Pending input preview headers and edit hint Co-Authored-By: DeepSeek V4 Flash --- crates/tui/src/commands/core.rs | 9 +- crates/tui/src/commands/debug.rs | 8 +- crates/tui/src/commands/provider.rs | 13 +- crates/tui/src/localization.rs | 142 ++++++++++++++++++ crates/tui/src/tui/ui.rs | 3 +- crates/tui/src/tui/views/mod.rs | 48 +++--- crates/tui/src/tui/views/status_picker.rs | 48 ++++-- crates/tui/src/tui/widgets/mod.rs | 27 +++- .../src/tui/widgets/pending_input_preview.rs | 34 ++++- 9 files changed, 275 insertions(+), 57 deletions(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 0a50f1d8e..b91ca89ea 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -3,8 +3,8 @@ use std::fmt::Write; use std::path::PathBuf; -use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name_for_provider}; -use crate::localization::{MessageId, tr}; +use crate::config::{ApiProvider, COMMON_DEEPSEEK_MODELS, normalize_model_name_for_provider}; +use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort}; use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents}; @@ -380,6 +380,7 @@ mod tests { use super::*; use crate::client::PromptInspection; use crate::config::Config; + use crate::localization::Locale; use crate::models::Message; use crate::tui::app::{App, AppMode, TuiOptions, TurnCacheRecord}; use crate::tui::history::HistoryCell; @@ -410,8 +411,8 @@ mod tests { initial_input: None, }; let mut app = App::new(options, &Config::default()); - app.ui_locale = crate::localization::Locale::En; - app.api_provider = crate::config::ApiProvider::Deepseek; + app.ui_locale = Locale::En; + app.api_provider = ApiProvider::Deepseek; app } diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index a89bd1744..45e6286d2 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -7,8 +7,10 @@ use std::time::Instant; use super::CommandResult; use crate::client::{PromptInspection, inspect_prompt_for_request}; use crate::compaction::estimate_input_tokens_conservative; +use crate::config::ApiProvider; use crate::localization::{Locale, MessageId, tr}; use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model}; +use crate::pricing::CostCurrency; use crate::tui::app::{App, AppAction, TurnCacheRecord}; use crate::tui::history::HistoryCell; @@ -450,9 +452,9 @@ mod tests { initial_input: None, }; let mut app = App::new(options, &Config::default()); - app.ui_locale = crate::localization::Locale::En; - app.cost_currency = crate::pricing::CostCurrency::Usd; - app.api_provider = crate::config::ApiProvider::Deepseek; + app.ui_locale = Locale::En; + app.cost_currency = CostCurrency::Usd; + app.api_provider = ApiProvider::Deepseek; app } diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 915cce8c5..12f755493 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -4,7 +4,8 @@ //! `/provider` with no args opens the picker modal (#52). `/provider ` //! keeps the v0.6.6 CLI form for muscle-memory + scripted use. -use crate::config::{ApiProvider, normalize_model_name, provider_passes_model_through}; +use crate::config::{ApiProvider, Config, normalize_model_name, provider_passes_model_through}; +use crate::localization::Locale; use crate::tui::app::{App, AppAction}; use super::CommandResult; @@ -66,6 +67,7 @@ fn expand_model_alias(name: &str) -> String { mod tests { use super::*; use crate::config::Config; + use crate::localization::Locale; use crate::tui::app::TuiOptions; use std::path::PathBuf; @@ -91,9 +93,12 @@ mod tests { resume_session_id: None, initial_input: None, }; - let mut app = App::new(options, &Config::default()); - app.ui_locale = crate::localization::Locale::En; - app.api_provider = crate::config::ApiProvider::Deepseek; + let cfg = Config { + provider: Some("deepseek".to_string()), + ..Default::default() + }; + let mut app = App::new(options, &cfg); + app.ui_locale = Locale::En; app } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 874bb2ec8..fb65da366 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -454,6 +454,28 @@ pub enum MessageId { OnboardTipsLine4, OnboardTipsFooterEnter, OnboardTipsFooterAction, + ComposerTitle, + ComposerDraftTitle, + ComposerQueueForNextTurn, + ComposerQueueCount, + ComposerSteerHint, + ComposerQueuedHint, + ComposerOfflineQueueHint, + PendingInputsHeader, + PendingInputsContextHeader, + PendingInputsEditHint, + StatusPickerTitle, + StatusPickerToggle, + StatusPickerAll, + StatusPickerNone, + StatusPickerSave, + StatusPickerCancel, + StatusPickerInstruction, + ConfigSectionModel, + ConfigSectionPermissions, + ConfigSectionDisplay, + ConfigSectionComposer, + ConfigSectionSidebar, } #[allow(dead_code)] @@ -688,6 +710,30 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::OnboardTipsLine4, MessageId::OnboardTipsFooterEnter, MessageId::OnboardTipsFooterAction, + MessageId::ComposerTitle, + MessageId::ComposerDraftTitle, + MessageId::ComposerQueueForNextTurn, + MessageId::ComposerQueueCount, + MessageId::ComposerSteerHint, + MessageId::ComposerQueuedHint, + MessageId::ComposerOfflineQueueHint, + MessageId::PendingInputsHeader, + MessageId::PendingInputsContextHeader, + MessageId::PendingInputsEditHint, + MessageId::StatusPickerTitle, + MessageId::StatusPickerToggle, + MessageId::StatusPickerAll, + MessageId::StatusPickerNone, + MessageId::StatusPickerSave, + MessageId::StatusPickerCancel, + MessageId::StatusPickerInstruction, + MessageId::ConfigSectionModel, + MessageId::ConfigSectionPermissions, + MessageId::ConfigSectionDisplay, + MessageId::ConfigSectionComposer, + MessageId::ConfigSectionSidebar, + MessageId::ConfigSectionHistory, + MessageId::ConfigSectionMcp, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1136,6 +1182,30 @@ fn english(id: MessageId) -> &'static str { MessageId::LinksTip => "Tip: API keys are available in the dashboard console.", MessageId::SubagentsFetching => "Fetching sub-agent status...", MessageId::HelpUnknownCommand => "Unknown command: {topic}", + MessageId::ComposerTitle => "Composer", + MessageId::ComposerDraftTitle => "Draft", + MessageId::ComposerQueueForNextTurn => "↵ queue for next turn", + MessageId::ComposerQueueCount => "↵ queue ({} waiting)", + MessageId::ComposerSteerHint => "↵ steering (Ctrl+Enter)", + MessageId::ComposerQueuedHint => "↵ queued (Ctrl+Enter to steer)", + MessageId::ComposerOfflineQueueHint => "↵ offline queue", + MessageId::PendingInputsHeader => "Pending inputs", + MessageId::PendingInputsContextHeader => "Context for next send", + MessageId::PendingInputsEditHint => "{} edit last queued message", + MessageId::StatusPickerTitle => " Status line ", + MessageId::StatusPickerToggle => "toggle", + MessageId::StatusPickerAll => "all", + MessageId::StatusPickerNone => "none", + MessageId::StatusPickerSave => "save", + MessageId::StatusPickerCancel => "cancel", + MessageId::StatusPickerInstruction => "Pick the chips you want in the footer:", + MessageId::ConfigSectionModel => "Model", + MessageId::ConfigSectionPermissions => "Permissions", + MessageId::ConfigSectionDisplay => "Display", + MessageId::ConfigSectionComposer => "Composer", + MessageId::ConfigSectionSidebar => "Sidebar", + MessageId::ConfigSectionHistory => "History", + MessageId::ConfigSectionMcp => "MCP", MessageId::HomeDashboardTitle => "codewhale Home Dashboard", MessageId::HomeModel => "Model:", MessageId::HomeMode => "Mode:", @@ -1523,6 +1593,30 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::LinksTip => "ヒント: API キーはダッシュボードコンソールで取得できます。", MessageId::SubagentsFetching => "サブエージェントの状態を取得中...", MessageId::HelpUnknownCommand => "不明なコマンド: {topic}", + MessageId::ComposerTitle => "コンポーザー", + MessageId::ComposerDraftTitle => "下書き", + MessageId::ComposerQueueForNextTurn => "↵ 次のターンにキュー", + MessageId::ComposerQueueCount => "↵ キュー({} 待機中)", + MessageId::ComposerSteerHint => "↵ ステアリング(Ctrl+Enter)", + MessageId::ComposerQueuedHint => "↵ キュー済み(Ctrl+Enterでステアリング)", + MessageId::ComposerOfflineQueueHint => "↵ オフラインキュー", + MessageId::PendingInputsHeader => "保留中の入力", + MessageId::PendingInputsContextHeader => "次回送信のコンテキスト", + MessageId::PendingInputsEditHint => "{} 最後のキュー済みメッセージを編集", + MessageId::StatusPickerTitle => " ステータスライン ", + MessageId::StatusPickerToggle => "切替", + MessageId::StatusPickerAll => "すべて", + MessageId::StatusPickerNone => "なし", + MessageId::StatusPickerSave => "保存", + MessageId::StatusPickerCancel => "キャンセル", + MessageId::StatusPickerInstruction => "フッターに表示するチップを選択してください:", + MessageId::ConfigSectionModel => "モデル", + MessageId::ConfigSectionPermissions => "権限", + MessageId::ConfigSectionDisplay => "表示", + MessageId::ConfigSectionComposer => "コンポーザー", + MessageId::ConfigSectionSidebar => "サイドバー", + MessageId::ConfigSectionHistory => "履歴", + MessageId::ConfigSectionMcp => "MCP", MessageId::HomeDashboardTitle => "codewhale ホームダッシュボード", MessageId::HomeModel => "モデル:", MessageId::HomeMode => "モード:", @@ -1842,6 +1936,30 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::LinksTip => "提示:API 密钥可在控制台中获取。", MessageId::SubagentsFetching => "正在获取子代理状态...", MessageId::HelpUnknownCommand => "未知命令:{topic}", + MessageId::ComposerTitle => "编辑器", + MessageId::ComposerDraftTitle => "草稿", + MessageId::ComposerQueueForNextTurn => "↵ 排队等待下一轮", + MessageId::ComposerQueueCount => "↵ 排队({} 等待中)", + MessageId::ComposerSteerHint => "↵ 引导回复(Ctrl+Enter)", + MessageId::ComposerQueuedHint => "↵ 已排队(Ctrl+Enter 转向)", + MessageId::ComposerOfflineQueueHint => "↵ 离线排队", + MessageId::PendingInputsHeader => "待处理输入", + MessageId::PendingInputsContextHeader => "下次发送的上下文", + MessageId::PendingInputsEditHint => "{} 编辑最后一条已排队消息", + MessageId::StatusPickerTitle => " 状态栏 ", + MessageId::StatusPickerToggle => "切换", + MessageId::StatusPickerAll => "全选", + MessageId::StatusPickerNone => "全无", + MessageId::StatusPickerSave => "保存", + MessageId::StatusPickerCancel => "取消", + MessageId::StatusPickerInstruction => "选择要在底栏中显示的模块:", + MessageId::ConfigSectionModel => "模型", + MessageId::ConfigSectionPermissions => "权限", + MessageId::ConfigSectionDisplay => "显示", + MessageId::ConfigSectionComposer => "编辑器", + MessageId::ConfigSectionSidebar => "侧栏", + MessageId::ConfigSectionHistory => "历史", + MessageId::ConfigSectionMcp => "MCP", MessageId::HomeDashboardTitle => "codewhale 主面板", MessageId::HomeModel => "模型:", MessageId::HomeMode => "模式:", @@ -2209,6 +2327,30 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::LinksTip => "Dica: chaves de API estão disponíveis no console do painel.", MessageId::SubagentsFetching => "Buscando status dos sub-agentes...", MessageId::HelpUnknownCommand => "Comando desconhecido: {topic}", + MessageId::ComposerTitle => "Compositor", + MessageId::ComposerDraftTitle => "Rascunho", + MessageId::ComposerQueueForNextTurn => "↵ fila para próximo turno", + MessageId::ComposerQueueCount => "↵ fila ({} aguardando)", + MessageId::ComposerSteerHint => "↵ direcionar (Ctrl+Enter)", + MessageId::ComposerQueuedHint => "↵ na fila (Ctrl+Enter para direcionar)", + MessageId::ComposerOfflineQueueHint => "↵ fila offline", + MessageId::PendingInputsHeader => "Entradas pendentes", + MessageId::PendingInputsContextHeader => "Contexto para próximo envio", + MessageId::PendingInputsEditHint => "{} editar última mensagem na fila", + MessageId::StatusPickerTitle => " Linha de status ", + MessageId::StatusPickerToggle => "alternar", + MessageId::StatusPickerAll => "todos", + MessageId::StatusPickerNone => "nenhum", + MessageId::StatusPickerSave => "salvar", + MessageId::StatusPickerCancel => "cancelar", + MessageId::StatusPickerInstruction => "Escolha os itens para exibir no rodapé:", + MessageId::ConfigSectionModel => "Modelo", + MessageId::ConfigSectionPermissions => "Permissões", + MessageId::ConfigSectionDisplay => "Exibição", + MessageId::ConfigSectionComposer => "Compositor", + MessageId::ConfigSectionSidebar => "Barra lateral", + MessageId::ConfigSectionHistory => "Histórico", + MessageId::ConfigSectionMcp => "MCP", MessageId::HomeDashboardTitle => "Painel Inicial do codewhale", MessageId::HomeModel => "Modelo:", MessageId::HomeMode => "Modo:", diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de619..5585e69d3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4864,6 +4864,7 @@ async fn apply_command_result( app.view_stack .push(crate::tui::views::status_picker::StatusPickerView::new( &app.status_items, + app.ui_locale, )); } } @@ -5628,7 +5629,7 @@ async fn handle_plan_choice( /// - `queued_messages` — Enter while busy (offline-mode FIFO); drained at /// end-of-turn. fn build_pending_input_preview(app: &App) -> PendingInputPreview { - let mut preview = PendingInputPreview::new(); + let mut preview = PendingInputPreview::with_locale(app.ui_locale); let selected_attachment = app.selected_composer_attachment_index(); let mut attachment_index = 0usize; preview.context_items = crate::tui::file_mention::pending_context_previews( diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 68ce1ac7a..28acaa527 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -538,16 +538,20 @@ enum ConfigSection { } impl ConfigSection { - fn label(self) -> &'static str { - match self { - ConfigSection::Model => "Model", - ConfigSection::Permissions => "Permissions", - ConfigSection::Display => "Display", - ConfigSection::Composer => "Composer", - ConfigSection::Sidebar => "Sidebar", - ConfigSection::History => "History", - ConfigSection::Mcp => "MCP", - } + fn label(self, locale: crate::localization::Locale) -> &'static str { + use crate::localization::MessageId; + crate::localization::tr( + locale, + match self { + ConfigSection::Model => MessageId::ConfigSectionModel, + ConfigSection::Permissions => MessageId::ConfigSectionPermissions, + ConfigSection::Display => MessageId::ConfigSectionDisplay, + ConfigSection::Composer => MessageId::ConfigSectionComposer, + ConfigSection::Sidebar => MessageId::ConfigSectionSidebar, + ConfigSection::History => MessageId::ConfigSectionHistory, + ConfigSection::Mcp => MessageId::ConfigSectionMcp, + }, + ) } } @@ -839,7 +843,7 @@ impl ConfigView { return true; } - let section = row.section.label().to_lowercase(); + let section = row.section.label(self.locale).to_lowercase(); let key = row.key.to_lowercase(); let value = row.value.to_lowercase(); let scope = row.scope.label().to_lowercase(); @@ -1425,7 +1429,7 @@ impl ModalView for ConfigView { match item { ConfigListItem::Section(section) => { lines.push(Line::from(Span::styled( - format!(" {}", section.label()), + format!(" {}", section.label(self.locale)), Style::default().fg(palette::DEEPSEEK_SKY).bold(), ))); } @@ -2048,7 +2052,9 @@ mod tests { resume_session_id: None, initial_input: None, }; - App::new(options, &Config::default()) + let mut app = App::new(options, &Config::default()); + app.ui_locale = Locale::En; + app } fn type_filter(view: &mut ConfigView, text: &str) { @@ -2131,7 +2137,7 @@ mod tests { view.visible_items() .into_iter() .filter_map(|item| match item { - ConfigListItem::Section(section) => Some(section.label()), + ConfigListItem::Section(section) => Some(section.label(view.locale)), ConfigListItem::Row(_) => None, }) .collect() @@ -2164,13 +2170,13 @@ mod tests { assert_eq!( visible_section_labels(&view), vec![ - ConfigSection::Model.label(), - ConfigSection::Permissions.label(), - ConfigSection::Display.label(), - ConfigSection::Composer.label(), - ConfigSection::Sidebar.label(), - ConfigSection::History.label(), - ConfigSection::Mcp.label(), + ConfigSection::Model.label(view.locale), + ConfigSection::Permissions.label(view.locale), + ConfigSection::Display.label(view.locale), + ConfigSection::Composer.label(view.locale), + ConfigSection::Sidebar.label(view.locale), + ConfigSection::History.label(view.locale), + ConfigSection::Mcp.label(view.locale), ] ); } diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 17b6173a6..57943d478 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -19,6 +19,7 @@ use ratatui::{ }; use crate::config::StatusItem; +use crate::localization::{self, Locale}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; @@ -35,11 +36,13 @@ pub struct StatusPickerView { cursor: usize, /// Snapshot of `app.status_items` at open time so Esc reverts cleanly. original: Vec, + /// Current UI locale for translatable strings. + locale: Locale, } impl StatusPickerView { #[must_use] - pub fn new(active: &[StatusItem]) -> Self { + pub fn new(active: &[StatusItem], locale: Locale) -> Self { let rows: Vec = StatusItem::all().to_vec(); let selected: Vec = rows.iter().map(|item| active.contains(item)).collect(); Self { @@ -47,6 +50,7 @@ impl StatusPickerView { selected, cursor: 0, original: active.to_vec(), + locale, } } @@ -167,24 +171,40 @@ impl ModalView for StatusPickerView { Clear.render(popup_area, buf); + let sp_t = |id| localization::tr(self.locale, id); let block = Block::default() .title(Line::from(Span::styled( - " Status line ", + sp_t(localization::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(format!( + "{} ", + sp_t(localization::MessageId::StatusPickerToggle) + )), Span::styled(" a ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("all "), + Span::raw(format!( + "{} ", + sp_t(localization::MessageId::StatusPickerAll) + )), Span::styled(" n ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("none "), + Span::raw(format!( + "{} ", + sp_t(localization::MessageId::StatusPickerNone) + )), Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("save "), + Span::raw(format!( + "{} ", + sp_t(localization::MessageId::StatusPickerSave) + )), Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw("cancel "), + Span::raw(format!( + "{} ", + sp_t(localization::MessageId::StatusPickerCancel) + )), ])) .borders(Borders::ALL) .border_style(Style::default().fg(palette::BORDER_COLOR)) @@ -196,7 +216,7 @@ impl ModalView for StatusPickerView { let mut lines: Vec = Vec::with_capacity(self.rows.len() + 2); lines.push(Line::from(Span::styled( - "Pick the chips you want in the footer:", + sp_t(localization::MessageId::StatusPickerInstruction), Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from("")); @@ -246,14 +266,14 @@ mod tests { #[test] fn opens_with_active_items_pre_selected() { let active = StatusItem::default_footer(); - let view = StatusPickerView::new(&active); + let view = StatusPickerView::new(&active, 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); + let mut view = StatusPickerView::new(&active, Locale::En); // Cursor starts at row 0 = StatusItem::Mode (currently checked). let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); match action { @@ -268,7 +288,7 @@ mod tests { #[test] fn enter_emits_final_save() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, Locale::En); let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match action { ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => { @@ -281,7 +301,7 @@ mod tests { #[test] fn esc_reverts_to_snapshot() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, Locale::En); // Toggle a few items off so the working set diverges from snapshot. view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); view.move_down(); @@ -299,7 +319,7 @@ mod tests { #[test] fn select_all_and_select_none_keys_work() { let active: Vec = Vec::new(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, Locale::En); let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); match action { ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => { @@ -319,7 +339,7 @@ mod tests { #[test] fn arrow_keys_move_cursor_within_bounds() { let active = StatusItem::default_footer(); - let mut view = StatusPickerView::new(&active); + let mut view = StatusPickerView::new(&active, Locale::En); assert_eq!(view.cursor, 0); view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); assert_eq!(view.cursor, 1); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 2c478a29e..4cc7cbf34 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -594,23 +594,35 @@ 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::ComposerOfflineQueueHint).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::ComposerQueueCount) + .replace("{}", &(queue_count.saturating_add(1).to_string())) } else { - "↵ queue for next turn".to_string() + self.app + .tr(crate::localization::MessageId::ComposerQueueForNextTurn) + .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::ComposerSteerHint) + .to_string(), + ), palette::DEEPSEEK_SKY, ), SubmitDisposition::QueueFollowUp => ( - Some("↵ queued (Ctrl+Enter to steer)".to_string()), + Some( + self.app + .tr(crate::localization::MessageId::ComposerQueuedHint) + .to_string(), + ), palette::TEXT_MUTED, ), }; @@ -630,9 +642,10 @@ impl Renderable for ComposerWidget<'_> { self.app .tr(crate::localization::MessageId::HistorySearchTitle) } else if is_draft_mode { - "Draft" + self.app + .tr(crate::localization::MessageId::ComposerDraftTitle) } else { - "Composer" + self.app.tr(crate::localization::MessageId::ComposerTitle) }, Style::default().fg(palette::TEXT_MUTED), ))) diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index cc3829dbc..9e46f5c20 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -19,6 +19,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; use unicode_width::UnicodeWidthChar; +use crate::localization::{self, Locale}; use crate::palette; use crate::tui::widgets::Renderable; @@ -44,6 +45,7 @@ pub struct PendingInputPreview { pub rejected_steers: Vec, pub queued_messages: Vec, pub edit_binding: EditBinding, + pub locale: Locale, } /// Compact pre-send context row shown above the composer. `included=false` @@ -67,6 +69,18 @@ impl PendingInputPreview { rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: EditBinding::UP, + locale: Locale::En, + } + } + + pub fn with_locale(locale: Locale) -> Self { + Self { + context_items: Vec::new(), + pending_steers: Vec::new(), + rejected_steers: Vec::new(), + queued_messages: Vec::new(), + edit_binding: EditBinding::UP, + locale, } } @@ -94,7 +108,13 @@ impl PendingInputPreview { if !self.context_items.is_empty() { push_section_header( &mut lines, - Line::from(vec![Span::raw("• "), Span::raw("Context for next send")]), + Line::from(vec![ + Span::raw("• "), + Span::raw(localization::tr( + self.locale, + localization::MessageId::PendingInputsContextHeader, + )), + ]), ); for item in &self.context_items { push_context_item(&mut lines, item, width); @@ -107,7 +127,13 @@ impl PendingInputPreview { } push_section_header( &mut lines, - Line::from(vec![Span::raw("• "), Span::raw("Pending inputs")]), + Line::from(vec![ + Span::raw("• "), + Span::raw(localization::tr( + self.locale, + localization::MessageId::PendingInputsHeader, + )), + ]), ); for steer in &self.pending_steers { push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " "); @@ -119,8 +145,10 @@ impl PendingInputPreview { push_truncated_item(&mut lines, message, width, dim_italic, " ↳ ", " "); } if !self.queued_messages.is_empty() { + let hint = + localization::tr(self.locale, localization::MessageId::PendingInputsEditHint); lines.push(Line::from(vec![Span::styled( - format!(" {} edit last queued message", self.edit_binding.label), + hint.replace("{}", self.edit_binding.label), dim, )])); } From 6b8500fb81243b74d4460960345d694543761547 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 6 May 2026 14:58:18 +0800 Subject: [PATCH 03/15] chore: ignore .claude/settings.json in gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 50c41e5ad..96693e165 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ apps/ # Claude Code runtime artifacts .claude/scheduled_tasks.lock .claude/worktrees/ +.claude/settings.json .worktrees/ .ace-tool/ From 8582c53c134d4dccb2c3d9405d87bbe5e55ebe16 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 6 May 2026 15:34:21 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20i18n=20Phase=202=20=E2=80=94=20lo?= =?UTF-8?q?calize=20approval=20and=20sandbox=20elevation=20UI=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 41 new MessageId variants with translations for all 4 shipped locales (en, ja, zh-Hans, pt-BR) covering: - Approval field labels (Type, About, Impact, Params) - Risk badges (REVIEW, DESTRUCTIVE) and category labels - Approval option labels and destructive confirmation footer - Sandbox elevation title, option labels, descriptions, and impact text Co-Authored-By: DeepSeek V4 Flash --- crates/tui/src/localization.rs | 307 +++++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 3 +- 2 files changed, 309 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index fb65da366..bdbef8d4a 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -476,6 +476,52 @@ pub enum MessageId { ConfigSectionDisplay, ConfigSectionComposer, ConfigSectionSidebar, + ConfigSectionHistory, + ConfigSectionMcp, + // Phase 2: Approval & Sandbox Elevation + ApprovalRiskReview, + ApprovalRiskDestructive, + ApprovalCategorySafe, + ApprovalCategoryFileWrite, + ApprovalCategoryShell, + ApprovalCategoryNetwork, + ApprovalCategoryMcpRead, + ApprovalCategoryMcpAction, + ApprovalCategoryUnknown, + ApprovalFieldType, + ApprovalFieldAbout, + ApprovalFieldImpact, + ApprovalFieldParams, + ApprovalOptionApproveOnce, + ApprovalOptionApproveAlways, + ApprovalOptionDeny, + ApprovalOptionAbortTurn, + ApprovalStaged, + ApprovalBlockTitle, + ApprovalFooterBenignPrefix, + ApprovalFooterBenignSuffix, + ApprovalFooterDestructiveConfirmPrefix, + ApprovalFooterDestructiveConfirmSuffix, + ApprovalFooterDestructivePrefix, + ApprovalFooterDestructiveSuffix, + ElevationTitleSandboxDenied, + ElevationTitleRequired, + ElevationFieldTool, + ElevationFieldCmd, + ElevationFieldReason, + ElevationImpactHeader, + ElevationImpactNetwork, + ElevationImpactWrite, + ElevationImpactFullAccess, + ElevationPromptProceed, + ElevationOptionNetwork, + ElevationOptionWrite, + ElevationOptionFullAccess, + ElevationOptionAbort, + ElevationOptionNetworkDesc, + ElevationOptionWriteDesc, + ElevationOptionFullAccessDesc, + ElevationOptionAbortDesc, } #[allow(dead_code)] @@ -734,6 +780,49 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ConfigSectionSidebar, MessageId::ConfigSectionHistory, MessageId::ConfigSectionMcp, + MessageId::ApprovalRiskReview, + MessageId::ApprovalRiskDestructive, + MessageId::ApprovalCategorySafe, + MessageId::ApprovalCategoryFileWrite, + MessageId::ApprovalCategoryShell, + MessageId::ApprovalCategoryNetwork, + MessageId::ApprovalCategoryMcpRead, + MessageId::ApprovalCategoryMcpAction, + MessageId::ApprovalCategoryUnknown, + MessageId::ApprovalFieldType, + MessageId::ApprovalFieldAbout, + MessageId::ApprovalFieldImpact, + MessageId::ApprovalFieldParams, + MessageId::ApprovalOptionApproveOnce, + MessageId::ApprovalOptionApproveAlways, + MessageId::ApprovalOptionDeny, + MessageId::ApprovalOptionAbortTurn, + MessageId::ApprovalStaged, + MessageId::ApprovalBlockTitle, + MessageId::ApprovalFooterBenignPrefix, + MessageId::ApprovalFooterBenignSuffix, + MessageId::ApprovalFooterDestructiveConfirmPrefix, + MessageId::ApprovalFooterDestructiveConfirmSuffix, + MessageId::ApprovalFooterDestructivePrefix, + MessageId::ApprovalFooterDestructiveSuffix, + MessageId::ElevationTitleSandboxDenied, + MessageId::ElevationTitleRequired, + MessageId::ElevationFieldTool, + MessageId::ElevationFieldCmd, + MessageId::ElevationFieldReason, + MessageId::ElevationImpactHeader, + MessageId::ElevationImpactNetwork, + MessageId::ElevationImpactWrite, + MessageId::ElevationImpactFullAccess, + MessageId::ElevationPromptProceed, + MessageId::ElevationOptionNetwork, + MessageId::ElevationOptionWrite, + MessageId::ElevationOptionFullAccess, + MessageId::ElevationOptionAbort, + MessageId::ElevationOptionNetworkDesc, + MessageId::ElevationOptionWriteDesc, + MessageId::ElevationOptionFullAccessDesc, + MessageId::ElevationOptionAbortDesc, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1285,6 +1374,64 @@ fn english(id: MessageId) -> &'static str { } MessageId::OnboardTipsFooterEnter => "Press Enter", MessageId::OnboardTipsFooterAction => " to open the workspace", + // Phase 2: Approval & Sandbox Elevation + MessageId::ApprovalRiskReview => "REVIEW", + MessageId::ApprovalRiskDestructive => "DESTRUCTIVE", + MessageId::ApprovalCategorySafe => "Safe", + MessageId::ApprovalCategoryFileWrite => "File Write", + MessageId::ApprovalCategoryShell => "Shell Command", + MessageId::ApprovalCategoryNetwork => "Network", + MessageId::ApprovalCategoryMcpRead => "MCP Read", + MessageId::ApprovalCategoryMcpAction => "MCP Action", + MessageId::ApprovalCategoryUnknown => "Unknown", + MessageId::ApprovalFieldType => "Type:", + MessageId::ApprovalFieldAbout => "About:", + MessageId::ApprovalFieldImpact => "Impact:", + MessageId::ApprovalFieldParams => "Params:", + MessageId::ApprovalOptionApproveOnce => "Approve once", + MessageId::ApprovalOptionApproveAlways => "Approve always for this kind", + MessageId::ApprovalOptionDeny => "Deny this call", + MessageId::ApprovalOptionAbortTurn => "Abort the turn", + MessageId::ApprovalStaged => "(staged)", + MessageId::ApprovalBlockTitle => "approval", + MessageId::ApprovalFooterBenignPrefix => "Single key approves: ", + MessageId::ApprovalFooterBenignSuffix => " · v: full params · Esc: abort", + MessageId::ApprovalFooterDestructiveConfirmPrefix => "Confirm destructive action — press ", + MessageId::ApprovalFooterDestructiveConfirmSuffix => { + " again to commit, anything else cancels." + } + MessageId::ApprovalFooterDestructivePrefix => "Two keys to approve: ", + MessageId::ApprovalFooterDestructiveSuffix => " · v: full params · Esc: abort", + MessageId::ElevationTitleSandboxDenied => " ⚠ Sandbox Denied ", + MessageId::ElevationTitleRequired => " Sandbox Elevation Required ", + MessageId::ElevationFieldTool => "Tool:", + MessageId::ElevationFieldCmd => "Cmd:", + MessageId::ElevationFieldReason => "Reason:", + MessageId::ElevationImpactHeader => "Impact if approved:", + MessageId::ElevationImpactNetwork => { + "network retry enables outbound downloads and HTTP requests" + } + MessageId::ElevationImpactWrite => { + "write retry expands writable filesystem scope for this tool call" + } + MessageId::ElevationImpactFullAccess => { + "full access removes sandbox restrictions entirely for this retry" + } + MessageId::ElevationPromptProceed => "Choose how to proceed:", + MessageId::ElevationOptionNetwork => "Allow outbound network", + MessageId::ElevationOptionWrite => "Allow extra write access", + MessageId::ElevationOptionFullAccess => "Full access (filesystem + network)", + MessageId::ElevationOptionAbort => "Abort", + MessageId::ElevationOptionNetworkDesc => { + "Retry this tool call with outbound network access for downloads and HTTP requests" + } + MessageId::ElevationOptionWriteDesc => { + "Retry this tool call with additional writable filesystem scope" + } + MessageId::ElevationOptionFullAccessDesc => { + "Retry without sandbox limits; grants unrestricted filesystem and network access" + } + MessageId::ElevationOptionAbortDesc => "Cancel this tool execution", } } @@ -1696,6 +1843,58 @@ fn japanese(id: MessageId) -> Option<&'static str> { } MessageId::OnboardTipsFooterEnter => "Enter を押す", MessageId::OnboardTipsFooterAction => " とワークスペースが開きます", + // Phase 2: Approval & Sandbox Elevation + MessageId::ApprovalRiskReview => "確認", + MessageId::ApprovalRiskDestructive => "破壊的操作", + MessageId::ApprovalCategorySafe => "安全", + MessageId::ApprovalCategoryFileWrite => "ファイル書き込み", + MessageId::ApprovalCategoryShell => "シェルコマンド", + MessageId::ApprovalCategoryNetwork => "ネットワーク", + MessageId::ApprovalCategoryMcpRead => "MCP読み取り", + MessageId::ApprovalCategoryMcpAction => "MCPアクション", + MessageId::ApprovalCategoryUnknown => "未分類", + MessageId::ApprovalFieldType => "種類:", + MessageId::ApprovalFieldAbout => "詳細:", + MessageId::ApprovalFieldImpact => "影響:", + MessageId::ApprovalFieldParams => "パラメータ:", + MessageId::ApprovalOptionApproveOnce => "1回だけ承認", + MessageId::ApprovalOptionApproveAlways => "常に承認(この種類)", + MessageId::ApprovalOptionDeny => "拒否", + MessageId::ApprovalOptionAbortTurn => "中断", + MessageId::ApprovalStaged => "(ステージング中)", + MessageId::ApprovalBlockTitle => "承認", + MessageId::ApprovalFooterBenignPrefix => "ワンキー承認:", + MessageId::ApprovalFooterBenignSuffix => " · v: パラメータ表示 · Esc: 中止", + MessageId::ApprovalFooterDestructiveConfirmPrefix => "破壊的操作の確認 — ", + MessageId::ApprovalFooterDestructiveConfirmSuffix => { + " をもう一度押して確定、他のキーでキャンセル" + } + MessageId::ApprovalFooterDestructivePrefix => "2キー承認が必要:", + MessageId::ApprovalFooterDestructiveSuffix => " · v: パラメータ表示 · Esc: 中止", + MessageId::ElevationTitleSandboxDenied => " ⚠ サンドボックス拒否 ", + MessageId::ElevationTitleRequired => " サンドボックス昇格 ", + MessageId::ElevationFieldTool => "ツール:", + MessageId::ElevationFieldCmd => "コマンド:", + MessageId::ElevationFieldReason => "理由:", + MessageId::ElevationImpactHeader => "承認された場合の影響:", + MessageId::ElevationImpactNetwork => { + "ネットワーク再試行で外部ダウンロード・HTTPリクエストが可能" + } + MessageId::ElevationImpactWrite => "書き込み再試行でファイルシステムの書き込み範囲が拡大", + MessageId::ElevationImpactFullAccess => "フルアクセスでサンドボックス制限を完全に解除", + MessageId::ElevationPromptProceed => "方法を選択:", + MessageId::ElevationOptionNetwork => "外部ネットワークを許可", + MessageId::ElevationOptionWrite => "追加の書き込みアクセスを許可", + MessageId::ElevationOptionFullAccess => "フルアクセス(ファイルシステム + ネットワーク)", + MessageId::ElevationOptionAbort => "中止", + MessageId::ElevationOptionNetworkDesc => { + "ダウンロードとHTTPリクエストのため外部ネットワークアクセスを許可して再試行" + } + MessageId::ElevationOptionWriteDesc => "追加の書き込み可能ファイルシステム範囲で再試行", + MessageId::ElevationOptionFullAccessDesc => { + "サンドボックス制限なしで再試行:制限なしのファイルシステム・ネットワークアクセス" + } + MessageId::ElevationOptionAbortDesc => "このツール実行をキャンセル", }) } @@ -2027,6 +2226,54 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::OnboardTipsLine4 => "Ctrl+R 恢复历史会话,Esc 退出当前输入或弹层。", MessageId::OnboardTipsFooterEnter => "按 Enter", MessageId::OnboardTipsFooterAction => " 进入工作区", + // Phase 2: 审批与沙箱弹窗 + MessageId::ApprovalRiskReview => "审核", + MessageId::ApprovalRiskDestructive => "危险操作", + MessageId::ApprovalCategorySafe => "安全操作", + MessageId::ApprovalCategoryFileWrite => "文件写入", + MessageId::ApprovalCategoryShell => "Shell 命令", + MessageId::ApprovalCategoryNetwork => "网络", + MessageId::ApprovalCategoryMcpRead => "MCP 读取", + MessageId::ApprovalCategoryMcpAction => "MCP 操作", + MessageId::ApprovalCategoryUnknown => "未分类", + MessageId::ApprovalFieldType => "类型:", + MessageId::ApprovalFieldAbout => "说明:", + MessageId::ApprovalFieldImpact => "影响:", + MessageId::ApprovalFieldParams => "参数:", + MessageId::ApprovalOptionApproveOnce => "仅批准一次", + MessageId::ApprovalOptionApproveAlways => "同类操作始终批准", + MessageId::ApprovalOptionDeny => "拒绝此次调用", + MessageId::ApprovalOptionAbortTurn => "中断本轮", + MessageId::ApprovalStaged => "(待确认)", + MessageId::ApprovalBlockTitle => "审批", + MessageId::ApprovalFooterBenignPrefix => "一键批准:", + MessageId::ApprovalFooterBenignSuffix => " · v: 查看参数 · Esc: 中止", + MessageId::ApprovalFooterDestructiveConfirmPrefix => "确认危险操作 — 再次按下 ", + MessageId::ApprovalFooterDestructiveConfirmSuffix => " 以提交,其他按键取消", + MessageId::ApprovalFooterDestructivePrefix => "需要二次确认:", + MessageId::ApprovalFooterDestructiveSuffix => " · v: 查看参数 · Esc: 中止", + MessageId::ElevationTitleSandboxDenied => " ⚠ 沙箱拒绝 ", + MessageId::ElevationTitleRequired => " 沙箱提权 ", + MessageId::ElevationFieldTool => "工具:", + MessageId::ElevationFieldCmd => "命令:", + MessageId::ElevationFieldReason => "原因:", + MessageId::ElevationImpactHeader => "批准后的影响:", + MessageId::ElevationImpactNetwork => "网络重试 - 允许外部下载和 HTTP 请求", + MessageId::ElevationImpactWrite => "写入重试 - 扩大此工具调用的文件系统写入范围", + MessageId::ElevationImpactFullAccess => "完全访问 - 解除沙箱限制", + MessageId::ElevationPromptProceed => "请选择处理方式:", + MessageId::ElevationOptionNetwork => "允许外部网络访问", + MessageId::ElevationOptionWrite => "允许额外写入权限", + MessageId::ElevationOptionFullAccess => "完全访问(文件系统 + 网络)", + MessageId::ElevationOptionAbort => "中止", + MessageId::ElevationOptionNetworkDesc => { + "重试此工具调用,允许外部网络访问进行下载和 HTTP 请求" + } + MessageId::ElevationOptionWriteDesc => "重试此工具调用,扩大可写入的文件系统范围", + MessageId::ElevationOptionFullAccessDesc => { + "无沙箱限制重试,授予不受限的文件系统和网络访问权限" + } + MessageId::ElevationOptionAbortDesc => "取消此工具调用", }) } @@ -2438,6 +2685,66 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { } MessageId::OnboardTipsFooterEnter => "Pressione Enter", MessageId::OnboardTipsFooterAction => " para abrir o workspace", + // Phase 2: Approval & Sandbox Elevation + MessageId::ApprovalRiskReview => "REVISÃO", + MessageId::ApprovalRiskDestructive => "DESTRUTIVO", + MessageId::ApprovalCategorySafe => "Seguro", + MessageId::ApprovalCategoryFileWrite => "Escrita de Arquivo", + MessageId::ApprovalCategoryShell => "Comando Shell", + MessageId::ApprovalCategoryNetwork => "Rede", + MessageId::ApprovalCategoryMcpRead => "Leitura MCP", + MessageId::ApprovalCategoryMcpAction => "Ação MCP", + MessageId::ApprovalCategoryUnknown => "Desconhecido", + MessageId::ApprovalFieldType => "Tipo:", + MessageId::ApprovalFieldAbout => "Sobre:", + MessageId::ApprovalFieldImpact => "Impacto:", + MessageId::ApprovalFieldParams => "Parâmetros:", + MessageId::ApprovalOptionApproveOnce => "Aprovar uma vez", + MessageId::ApprovalOptionApproveAlways => "Aprovar sempre para este tipo", + MessageId::ApprovalOptionDeny => "Negar esta chamada", + MessageId::ApprovalOptionAbortTurn => "Abortar turno", + MessageId::ApprovalStaged => "(em espera)", + MessageId::ApprovalBlockTitle => "aprovação", + MessageId::ApprovalFooterBenignPrefix => "Tecla única aprova: ", + MessageId::ApprovalFooterBenignSuffix => " · v: parâmetros · Esc: abortar", + MessageId::ApprovalFooterDestructiveConfirmPrefix => { + "Confirme ação destrutiva — pressione " + } + MessageId::ApprovalFooterDestructiveConfirmSuffix => { + " novamente para confirmar, qualquer outra tecla cancela." + } + MessageId::ApprovalFooterDestructivePrefix => "Duas teclas para aprovar: ", + MessageId::ApprovalFooterDestructiveSuffix => " · v: parâmetros · Esc: abortar", + MessageId::ElevationTitleSandboxDenied => " ⚠ Sandbox Negado ", + MessageId::ElevationTitleRequired => " Elevação de Sandbox Necessária ", + MessageId::ElevationFieldTool => "Ferramenta:", + MessageId::ElevationFieldCmd => "Comando:", + MessageId::ElevationFieldReason => "Motivo:", + MessageId::ElevationImpactHeader => "Impacto se aprovado:", + MessageId::ElevationImpactNetwork => { + "retentativa de rede permite downloads externos e requisições HTTP" + } + MessageId::ElevationImpactWrite => { + "retentativa de escrita expande escopo gravável do sistema de arquivos" + } + MessageId::ElevationImpactFullAccess => { + "acesso total remove restrições de sandbox completamente" + } + MessageId::ElevationPromptProceed => "Escolha como prosseguir:", + MessageId::ElevationOptionNetwork => "Permitir rede externa", + MessageId::ElevationOptionWrite => "Permitir acesso extra de escrita", + MessageId::ElevationOptionFullAccess => "Acesso total (sistema de arquivos + rede)", + MessageId::ElevationOptionAbort => "Abortar", + MessageId::ElevationOptionNetworkDesc => { + "Tentar novamente com acesso de rede externa para downloads e requisições HTTP" + } + MessageId::ElevationOptionWriteDesc => { + "Tentar novamente com escopo adicional gravável no sistema de arquivos" + } + MessageId::ElevationOptionFullAccessDesc => { + "Tentar sem limites de sandbox; concede acesso irrestrito ao sistema de arquivos e rede" + } + MessageId::ElevationOptionAbortDesc => "Cancelar esta execução de ferramenta", }) } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5585e69d3..59fce4442 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2028,7 +2028,8 @@ async fn run_event_loop( blocked_network, blocked_write, ); - app.view_stack.push(ElevationView::new(request)); + app.view_stack + .push(ElevationView::new(request, app.ui_locale)); app.status_message = Some(format!("Sandbox blocked {tool_name}: {denial_reason}")); } From faf4d3b5ae2fd63c669d07e56f5b41cd2f3744b8 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 6 May 2026 15:53:09 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20i18n=20Phase=203=20=E2=80=94=20lo?= =?UTF-8?q?calize=20queue,=20task,=20trust,=20LSP,=20and=20logout=20comman?= =?UTF-8?q?d=20output=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: DeepSeek V4 Flash --- crates/tui/src/commands/config.rs | 49 +++--- crates/tui/src/commands/mod.rs | 13 ++ crates/tui/src/commands/queue.rs | 68 ++++---- crates/tui/src/commands/task.rs | 22 ++- crates/tui/src/localization.rs | 253 ++++++++++++++++++++++++++++++ 5 files changed, 348 insertions(+), 57 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 651b4d5d4..82b11880f 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -10,7 +10,7 @@ use crate::config::{ }; use crate::config_ui::{ConfigUiMode, parse_mode}; use crate::llm_client::LlmClient; -use crate::localization::resolve_locale; +use crate::localization::{self, MessageId, resolve_locale}; use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; use crate::settings::Settings; use crate::tui::app::{ @@ -759,24 +759,23 @@ pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult { let rest = parts.next().map(str::trim).unwrap_or(""); let workspace = app.workspace.clone(); + let t = |id| localization::tr(app.ui_locale, id); match sub.as_str() { "" | "status" | "list" => trust_status(&workspace, app, sub == "list"), "on" | "enable" | "yes" | "y" => { app.trust_mode = true; - CommandResult::message( - "Workspace trust mode enabled — agent file tools can now read/write any path. \ - Use `/trust off` to revert; prefer `/trust add ` for a narrower opt-in.", - ) + CommandResult::message(t(MessageId::CmdTrustEnabled)) } "off" | "disable" | "no" | "n" => { app.trust_mode = false; - CommandResult::message("Workspace trust mode disabled.") + CommandResult::message(t(MessageId::CmdTrustDisabled)) } "add" => trust_add(&workspace, rest), "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest), - other => CommandResult::error(format!( - "Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add `, or `/trust remove `." - )), + other => CommandResult::error_locale( + t(MessageId::CmdTrustUnknownAction).replace("{action}", other), + app.ui_locale, + ), } } @@ -1249,6 +1248,7 @@ fn truncate_for_auto_router(text: &str, max_chars: usize) -> String { /// - `/lsp off` — disable inline LSP diagnostics /// - `/lsp status` — show whether diagnostics are currently enabled pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let t = |id| localization::tr(app.ui_locale, id); let raw = arg.map(str::trim).unwrap_or(""); // Access lsp_manager config through the App's engine handle let current_enabled = app.lsp_enabled; @@ -1256,38 +1256,38 @@ pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { match raw { "" | "status" => { let status = if current_enabled { "on" } else { "off" }; - CommandResult::message(format!( - "LSP diagnostics are currently **{status}**.\n\n\ - Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits." - )) + CommandResult::message(t(MessageId::CmdLspStatus).replace("{status}", status)) } "on" | "enable" | "1" | "true" => { app.lsp_enabled = true; - CommandResult::message( - "LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.", - ) + CommandResult::message(t(MessageId::CmdLspEnabled)) } "off" | "disable" | "0" | "false" => { app.lsp_enabled = false; - CommandResult::message("LSP diagnostics disabled.") + CommandResult::message(t(MessageId::CmdLspDisabled)) } - other => CommandResult::error(format!( - "Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`." - )), + other => CommandResult::error_locale( + t(MessageId::CmdLspUnknownArg).replace("{arg}", other), + app.ui_locale, + ), } } /// Logout - clear API key and return to onboarding pub fn logout(app: &mut App) -> CommandResult { + let t = |id| localization::tr(app.ui_locale, id); match clear_api_key() { Ok(()) => { app.onboarding = OnboardingState::ApiKey; app.onboarding_needs_api_key = true; app.api_key_input.clear(); app.api_key_cursor = 0; - CommandResult::message("Logged out. Enter a new API key to continue.") + CommandResult::message(t(MessageId::CmdLogoutSuccess)) } - Err(e) => CommandResult::error(format!("Failed to clear API key: {e}")), + Err(e) => CommandResult::error_locale( + t(MessageId::CmdLogoutFailed).replace("{reason}", &e.to_string()), + app.ui_locale, + ), } } @@ -1295,6 +1295,7 @@ pub fn logout(app: &mut App) -> CommandResult { mod tests { use super::*; use crate::config::Config; + use crate::localization::Locale; use crate::test_support::lock_test_env; use crate::tui::app::{App, TuiOptions}; use crate::tui::approval::ApprovalMode; @@ -1400,7 +1401,9 @@ mod tests { resume_session_id: None, initial_input: None, }; - App::new(options, &Config::default()) + let mut app = App::new(options, &Config::default()); + app.ui_locale = Locale::En; + app } #[test] diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index f21df395f..6fcc80ebb 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -95,6 +95,19 @@ impl CommandResult { is_error: true, } } + + /// Create an error message result with a localized "Error:" prefix + pub fn error_locale(msg: impl Into, locale: Locale) -> Self { + Self { + message: Some(format!( + "{} {}", + tr(locale, MessageId::CmdErrorPrefix), + msg.into() + )), + action: None, + is_error: true, + } + } } /// Command metadata for help and autocomplete. diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index b1c76b8b6..fbb99d5e8 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -1,5 +1,6 @@ //! Queue commands: queue list/edit/drop/clear +use crate::localization::{self, Locale, MessageId}; use crate::tui::app::App; use super::CommandResult; @@ -19,27 +20,32 @@ pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { "edit" => edit_queue(app, parts.next()), "drop" | "remove" | "rm" => drop_queue(app, parts.next()), "clear" => clear_queue(app), - _ => CommandResult::error("Usage: /queue [list|edit |drop |clear]"), + _ => CommandResult::error_locale( + localization::tr(app.ui_locale, MessageId::CmdQueueUsage), + app.ui_locale, + ), } } fn list_queue(app: &mut App) -> CommandResult { + let locale = app.ui_locale; + let t = |id| localization::tr(locale, id); let mut lines = Vec::new(); let queued = app.queued_message_count(); if let Some(draft) = app.queued_draft.as_ref() { - lines.push("Editing queued message:".to_string()); + lines.push(format!("{}:", t(MessageId::CmdEditingQueuedDraft))); lines.push(format!("- {}", truncate_preview(&draft.display))); } if queued == 0 { if lines.is_empty() { - return CommandResult::message("No queued messages"); + return CommandResult::message(t(MessageId::CmdQueueNoMessages)); } return CommandResult::message(lines.join("\n")); } - lines.push(format!("Queued messages ({queued}):")); + lines.push(t(MessageId::CmdQueueListHeader).replace("{queued}", &queued.to_string())); for (idx, message) in app.queued_messages.iter().enumerate() { lines.push(format!( "{}. {}", @@ -48,70 +54,75 @@ fn list_queue(app: &mut App) -> CommandResult { )); } - lines.push("Tip: /queue edit to edit, /queue drop to remove".to_string()); + lines.push(t(MessageId::CmdQueueListTip).to_string()); CommandResult::message(lines.join("\n")) } fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult { + let locale = app.ui_locale; + let t = |id| localization::tr(locale, id); if app.queued_draft.is_some() { - return CommandResult::error( - "Already editing a queued message. Send it or /queue clear to discard.", - ); + return CommandResult::error_locale(t(MessageId::CmdQueueAlreadyEditing), locale); } - let index = match parse_index(index) { + let index = match parse_index(index, locale) { Ok(index) => index, - Err(err) => return CommandResult::error(err), + Err(err) => return CommandResult::error_locale(err, locale), }; let Some(message) = app.remove_queued_message(index) else { - return CommandResult::error("Queued message not found"); + return CommandResult::error_locale(t(MessageId::CmdQueueNotFound), locale); }; app.input = message.display.clone(); app.cursor_position = app.input.len(); app.queued_draft = Some(message); - app.status_message = Some(format!("Editing queued message {}", index + 1)); + app.status_message = + Some(t(MessageId::CmdEditingQueuedDraft).replace("{n}", &(index + 1).to_string())); - CommandResult::message(format!( - "Editing queued message {} (press Enter to re-queue/send)", - index + 1 - )) + CommandResult::message( + t(MessageId::CmdEditingQueuedDraft).replace("{n}", &(index + 1).to_string()), + ) } fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult { - let index = match parse_index(index) { + let locale = app.ui_locale; + let t = |id| localization::tr(locale, id); + let index = match parse_index(index, locale) { Ok(index) => index, - Err(err) => return CommandResult::error(err), + Err(err) => return CommandResult::error_locale(err, locale), }; if app.remove_queued_message(index).is_none() { - return CommandResult::error("Queued message not found"); + return CommandResult::error_locale(t(MessageId::CmdQueueNotFound), locale); } - CommandResult::message(format!("Dropped queued message {}", index + 1)) + CommandResult::message(t(MessageId::CmdQueueDropped).replace("{n}", &(index + 1).to_string())) } fn clear_queue(app: &mut App) -> CommandResult { + let locale = app.ui_locale; + let t = |id| localization::tr(locale, id); let queued = app.queued_message_count(); let had_draft = app.queued_draft.take().is_some(); app.queued_messages.clear(); if queued == 0 && !had_draft { - return CommandResult::message("Queue already empty"); + return CommandResult::message(t(MessageId::CmdQueueAlreadyEmpty)); } - CommandResult::message("Queue cleared") + CommandResult::message(t(MessageId::CmdQueueCleared)) } -fn parse_index(input: Option<&str>) -> Result { +fn parse_index(input: Option<&str>, locale: Locale) -> Result { + let t = |id| localization::tr(locale, id); let Some(input) = input else { - return Err("Missing index. Usage: /queue edit or /queue drop "); + return Err(t(MessageId::CmdQueueMissingIndex).to_string()); }; let raw = input .parse::() - .map_err(|_| "Index must be a positive number")?; + .map_err(|_| t(MessageId::CmdQueueIndexPositive).to_string())?; if raw == 0 { - return Err("Index must be >= 1"); + return Err(t(MessageId::CmdQueueIndexMin).replace("{min}", "1")); } Ok(raw - 1) } @@ -132,6 +143,7 @@ fn truncate_preview(text: &str) -> String { mod tests { use super::*; use crate::config::Config; + use crate::localization::Locale; use crate::tui::app::{App, QueuedMessage, TuiOptions}; use tempfile::TempDir; @@ -157,7 +169,9 @@ mod tests { resume_session_id: None, initial_input: None, }; - App::new(options, &Config::default()) + let mut app = App::new(options, &Config::default()); + app.ui_locale = Locale::En; + app } #[test] diff --git a/crates/tui/src/commands/task.rs b/crates/tui/src/commands/task.rs index c96fe29a1..f1cb30afd 100644 --- a/crates/tui/src/commands/task.rs +++ b/crates/tui/src/commands/task.rs @@ -1,10 +1,11 @@ //! Task commands: add/list/show/cancel +use crate::localization::{self, MessageId}; use crate::tui::app::{App, AppAction}; use super::CommandResult; -pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { +pub fn task(app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); if raw.is_empty() || raw.eq_ignore_ascii_case("list") { return CommandResult::action(AppAction::TaskList); @@ -14,10 +15,11 @@ pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { let action = parts.next().unwrap_or("").to_ascii_lowercase(); let remainder = parts.next().map(str::trim).filter(|s| !s.is_empty()); + let t = |id| localization::tr(app.ui_locale, id); match action.as_str() { "add" => { let Some(prompt) = remainder else { - return CommandResult::error("Usage: /task add "); + return CommandResult::error_locale(t(MessageId::CmdTaskUsageAdd), app.ui_locale); }; CommandResult::action(AppAction::TaskAdd { prompt: prompt.to_string(), @@ -26,17 +28,20 @@ pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { "list" => CommandResult::action(AppAction::TaskList), "show" => { let Some(id) = remainder else { - return CommandResult::error("Usage: /task show "); + return CommandResult::error_locale(t(MessageId::CmdTaskUsageShow), app.ui_locale); }; CommandResult::action(AppAction::TaskShow { id: id.to_string() }) } "cancel" | "stop" => { let Some(id) = remainder else { - return CommandResult::error("Usage: /task cancel "); + return CommandResult::error_locale( + t(MessageId::CmdTaskUsageCancel), + app.ui_locale, + ); }; CommandResult::action(AppAction::TaskCancel { id: id.to_string() }) } - _ => CommandResult::error("Usage: /task [add |list|show |cancel ]"), + _ => CommandResult::error_locale(t(MessageId::CmdTaskUsageGeneral), app.ui_locale), } } @@ -44,11 +49,12 @@ pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { mod tests { use super::*; use crate::config::Config; + use crate::localization::Locale; use crate::tui::app::TuiOptions; use std::path::PathBuf; fn app() -> App { - App::new( + let mut app = App::new( TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("."), @@ -71,7 +77,9 @@ mod tests { initial_input: None, }, &Config::default(), - ) + ); + app.ui_locale = Locale::En; + app } #[test] diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index bdbef8d4a..a80a6eeb9 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -522,6 +522,34 @@ pub enum MessageId { ElevationOptionWriteDesc, ElevationOptionFullAccessDesc, ElevationOptionAbortDesc, + // Phase 3: Common command output + CmdErrorPrefix, + CmdQueueUsage, + CmdQueueNoMessages, + CmdQueueListHeader, + CmdQueueListTip, + CmdQueueAlreadyEditing, + CmdQueueMissingIndex, + CmdQueueIndexPositive, + CmdQueueIndexMin, + CmdQueueNotFound, + CmdQueueDropped, + CmdQueueAlreadyEmpty, + CmdQueueCleared, + CmdTaskUsageAdd, + CmdTaskUsageShow, + CmdTaskUsageCancel, + CmdTaskUsageGeneral, + CmdTrustEnabled, + CmdTrustDisabled, + CmdTrustUnknownAction, + CmdLspStatus, + CmdLspEnabled, + CmdLspDisabled, + CmdLspUnknownArg, + CmdLogoutSuccess, + CmdLogoutFailed, + CmdEditingQueuedDraft, } #[allow(dead_code)] @@ -823,6 +851,33 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ElevationOptionWriteDesc, MessageId::ElevationOptionFullAccessDesc, MessageId::ElevationOptionAbortDesc, + MessageId::CmdErrorPrefix, + MessageId::CmdQueueUsage, + MessageId::CmdQueueNoMessages, + MessageId::CmdQueueListHeader, + MessageId::CmdQueueListTip, + MessageId::CmdQueueAlreadyEditing, + MessageId::CmdQueueMissingIndex, + MessageId::CmdQueueIndexPositive, + MessageId::CmdQueueIndexMin, + MessageId::CmdQueueNotFound, + MessageId::CmdQueueDropped, + MessageId::CmdQueueAlreadyEmpty, + MessageId::CmdQueueCleared, + MessageId::CmdTaskUsageAdd, + MessageId::CmdTaskUsageShow, + MessageId::CmdTaskUsageCancel, + MessageId::CmdTaskUsageGeneral, + MessageId::CmdTrustEnabled, + MessageId::CmdTrustDisabled, + MessageId::CmdTrustUnknownAction, + MessageId::CmdLspStatus, + MessageId::CmdLspEnabled, + MessageId::CmdLspDisabled, + MessageId::CmdLspUnknownArg, + MessageId::CmdLogoutSuccess, + MessageId::CmdLogoutFailed, + MessageId::CmdEditingQueuedDraft, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1432,6 +1487,57 @@ fn english(id: MessageId) -> &'static str { "Retry without sandbox limits; grants unrestricted filesystem and network access" } MessageId::ElevationOptionAbortDesc => "Cancel this tool execution", + // ── Phase 3: common command output ── + MessageId::CmdErrorPrefix => "Error:", + MessageId::CmdQueueUsage => "Usage: /queue [list|edit |drop |clear]", + MessageId::CmdQueueNoMessages => "No queued messages", + MessageId::CmdQueueListHeader => "Queued messages ({queued}):", + MessageId::CmdQueueListTip => "Tip: /queue edit to edit, /queue drop to remove", + MessageId::CmdQueueAlreadyEditing => { + "Already editing a queued message. Send it or /queue clear to discard." + } + MessageId::CmdQueueMissingIndex => { + "Missing index. Usage: /queue edit or /queue drop " + } + MessageId::CmdQueueIndexPositive => "Index must be a positive number", + MessageId::CmdQueueIndexMin => "Index must be >= {min}", + MessageId::CmdQueueNotFound => "Queued message not found", + MessageId::CmdQueueDropped => "Dropped queued message {n}", + MessageId::CmdQueueAlreadyEmpty => "Queue already empty", + MessageId::CmdQueueCleared => "Queue cleared", + MessageId::CmdTaskUsageAdd => "Usage: /task add ", + MessageId::CmdTaskUsageShow => "Usage: /task show ", + MessageId::CmdTaskUsageCancel => "Usage: /task cancel ", + MessageId::CmdTaskUsageGeneral => "Usage: /task [add |list|show |cancel ]", + MessageId::CmdTrustEnabled => { + "Workspace trust mode enabled — agent file tools can now read/write \ + any path. Use `/trust off` to revert; prefer `/trust add ` \ + for a narrower opt-in." + } + MessageId::CmdTrustDisabled => "Workspace trust mode disabled.", + MessageId::CmdTrustUnknownAction => { + "Unknown /trust action `{action}`. Use `/trust`, `/trust on|off`, \ + `/trust add `, or `/trust remove `." + } + MessageId::CmdLspStatus => { + "LSP diagnostics are currently **{status}**.\n\n\ + Use `/lsp on` to enable or `/lsp off` to disable inline \ + diagnostics after file edits." + } + MessageId::CmdLspEnabled => { + "LSP diagnostics enabled — file edit results will include compiler \ + errors and warnings when available." + } + MessageId::CmdLspDisabled => "LSP diagnostics disabled.", + MessageId::CmdLspUnknownArg => { + "Unknown /lsp argument `{arg}`. Use `/lsp on`, `/lsp off`, or \ + `/lsp status`." + } + MessageId::CmdLogoutSuccess => "Logged out. Enter a new API key to continue.", + MessageId::CmdLogoutFailed => "Failed to clear API key: {reason}", + MessageId::CmdEditingQueuedDraft => { + "Editing queued message {n} (press Enter to re-queue/send)" + } } } @@ -1895,6 +2001,59 @@ fn japanese(id: MessageId) -> Option<&'static str> { "サンドボックス制限なしで再試行:制限なしのファイルシステム・ネットワークアクセス" } MessageId::ElevationOptionAbortDesc => "このツール実行をキャンセル", + // ── Phase 3: common command output ── + MessageId::CmdErrorPrefix => "エラー:", + MessageId::CmdQueueUsage => "使用法: /queue [list|edit |drop |clear]", + MessageId::CmdQueueNoMessages => "キューされたメッセージはありません", + MessageId::CmdQueueListHeader => "キューされたメッセージ ({queued}):", + MessageId::CmdQueueListTip => "ヒント: /queue edit で編集、/queue drop で削除", + MessageId::CmdQueueAlreadyEditing => { + "既にキューされたメッセージを編集中です。送信するか /queue clear で破棄してください。" + } + MessageId::CmdQueueMissingIndex => { + "インデックスがありません。使用法: /queue edit または /queue drop " + } + MessageId::CmdQueueIndexPositive => "インデックスは正の数である必要があります", + MessageId::CmdQueueIndexMin => "インデックスは {min} 以上である必要があります", + MessageId::CmdQueueNotFound => "キューされたメッセージが見つかりません", + MessageId::CmdQueueDropped => "キューされたメッセージ {n} を削除しました", + MessageId::CmdQueueAlreadyEmpty => "キューは既に空です", + MessageId::CmdQueueCleared => "キューをクリアしました", + MessageId::CmdTaskUsageAdd => "使用法: /task add ", + MessageId::CmdTaskUsageShow => "使用法: /task show ", + MessageId::CmdTaskUsageCancel => "使用法: /task cancel ", + MessageId::CmdTaskUsageGeneral => "使用法: /task [add |list|show |cancel ]", + MessageId::CmdTrustEnabled => { + "ワークスペース信頼モードが有効になりました — エージェントのファイルツールは \ + 任意のパスを読み書きできます。`/trust off` で戻すか、`/trust add ` で \ + より狭い範囲の許可を推奨します。" + } + MessageId::CmdTrustDisabled => "ワークスペース信頼モードが無効になりました。", + MessageId::CmdTrustUnknownAction => { + "不明な /trust アクション `{action}` です。`/trust`、`/trust on|off`、\ + `/trust add `、または `/trust remove ` を使用してください。" + } + MessageId::CmdLspStatus => { + "LSP 診断は現在 **{status}** です。\n\n\ + `/lsp on` で有効、`/lsp off` で無効にできます。ファイル編集後に \ + インライン診断を表示します。" + } + MessageId::CmdLspEnabled => { + "LSP 診断が有効になりました — ファイル編集結果にコンパイラのエラーや \ + 警告が含まれるようになります。" + } + MessageId::CmdLspDisabled => "LSP 診断が無効になりました。", + MessageId::CmdLspUnknownArg => { + "不明な /lsp 引数 `{arg}` です。`/lsp on`、`/lsp off`、または `/lsp status` \ + を使用してください。" + } + MessageId::CmdLogoutSuccess => { + "ログアウトしました。新しい API キーを入力して続行してください。" + } + MessageId::CmdLogoutFailed => "API キーの消去に失敗しました: {reason}", + MessageId::CmdEditingQueuedDraft => { + "キューされたメッセージ {n} を編集中 (Enter で再キュー/送信)" + } }) } @@ -2274,6 +2433,47 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { "无沙箱限制重试,授予不受限的文件系统和网络访问权限" } MessageId::ElevationOptionAbortDesc => "取消此工具调用", + // ── Phase 3: common command output ── + MessageId::CmdErrorPrefix => "错误:", + MessageId::CmdQueueUsage => "用法:/queue [list|edit |drop |clear]", + MessageId::CmdQueueNoMessages => "没有已排队的消息", + MessageId::CmdQueueListHeader => "已排队的消息 ({queued}):", + MessageId::CmdQueueListTip => "提示:用 /queue edit 编辑,用 /queue drop 删除", + MessageId::CmdQueueAlreadyEditing => { + "已在编辑一条已排队的消息。请发送或用 /queue clear 放弃。" + } + MessageId::CmdQueueMissingIndex => "缺少索引。用法:/queue edit 或 /queue drop ", + MessageId::CmdQueueIndexPositive => "索引必须为正数", + MessageId::CmdQueueIndexMin => "索引必须 ≥ {min}", + MessageId::CmdQueueNotFound => "未找到已排队的消息", + MessageId::CmdQueueDropped => "已从队列中删除消息 {n}", + MessageId::CmdQueueAlreadyEmpty => "队列已为空", + MessageId::CmdQueueCleared => "队列已清空", + MessageId::CmdTaskUsageAdd => "用法:/task add ", + MessageId::CmdTaskUsageShow => "用法:/task show ", + MessageId::CmdTaskUsageCancel => "用法:/task cancel ", + MessageId::CmdTaskUsageGeneral => "用法:/task [add |list|show |cancel ]", + MessageId::CmdTrustEnabled => { + "工作区信任模式已启用 — 代理文件工具现在可以读写任何路径。\n\ + 使用 `/trust off` 恢复;建议用 `/trust add ` 进行更精确的授权。" + } + MessageId::CmdTrustDisabled => "工作区信任模式已禁用。", + MessageId::CmdTrustUnknownAction => { + "未知的 /trust 操作 `{action}`。请使用 `/trust`、`/trust on|off`、\ + `/trust add ` 或 `/trust remove `。" + } + MessageId::CmdLspStatus => { + "LSP 诊断当前为 **{status}**。\n\n\ + 使用 `/lsp on` 启用或 `/lsp off` 禁用文件编辑后的内联诊断。" + } + MessageId::CmdLspEnabled => "LSP 诊断已启用 — 文件编辑结果将包含编译器错误和警告(如有)。", + MessageId::CmdLspDisabled => "LSP 诊断已禁用。", + MessageId::CmdLspUnknownArg => { + "未知的 /lsp 参数 `{arg}`。请使用 `/lsp on`、`/lsp off` 或 `/lsp status`。" + } + MessageId::CmdLogoutSuccess => "已登出。请输入新的 API 密钥以继续。", + MessageId::CmdLogoutFailed => "清除 API 密钥失败:{reason}", + MessageId::CmdEditingQueuedDraft => "正在编辑已排队的消息 {n}(按 Enter 重新排队/发送)", }) } @@ -2745,6 +2945,59 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Tentar sem limites de sandbox; concede acesso irrestrito ao sistema de arquivos e rede" } MessageId::ElevationOptionAbortDesc => "Cancelar esta execução de ferramenta", + // ── Phase 3: common command output ── + MessageId::CmdErrorPrefix => "Erro:", + MessageId::CmdQueueUsage => "Uso: /queue [list|edit |drop |clear]", + MessageId::CmdQueueNoMessages => "Nenhuma mensagem enfileirada", + MessageId::CmdQueueListHeader => "Mensagens enfileiradas ({queued}):", + MessageId::CmdQueueListTip => { + "Dica: use /queue edit para editar, /queue drop para remover" + } + MessageId::CmdQueueAlreadyEditing => { + "Já está editando uma mensagem enfileirada. Envie-a ou use /queue clear para descartá-la." + } + MessageId::CmdQueueMissingIndex => { + "Índice ausente. Uso: /queue edit ou /queue drop " + } + MessageId::CmdQueueIndexPositive => "O índice deve ser um número positivo", + MessageId::CmdQueueIndexMin => "O índice deve ser >= {min}", + MessageId::CmdQueueNotFound => "Mensagem enfileirada não encontrada", + MessageId::CmdQueueDropped => "Mensagem enfileirada {n} removida", + MessageId::CmdQueueAlreadyEmpty => "A fila já está vazia", + MessageId::CmdQueueCleared => "Fila limpa", + MessageId::CmdTaskUsageAdd => "Uso: /task add ", + MessageId::CmdTaskUsageShow => "Uso: /task show ", + MessageId::CmdTaskUsageCancel => "Uso: /task cancel ", + MessageId::CmdTaskUsageGeneral => "Uso: /task [add |list|show |cancel ]", + MessageId::CmdTrustEnabled => { + "Modo de confiança do workspace ativado — as ferramentas de arquivo do \ + agente podem agora ler/escrever qualquer caminho. Use `/trust off` para \ + reverter; prefira `/trust add ` para uma permissão mais restrita." + } + MessageId::CmdTrustDisabled => "Modo de confiança do workspace desativado.", + MessageId::CmdTrustUnknownAction => { + "Ação /trust desconhecida `{action}`. Use `/trust`, `/trust on|off`, \ + `/trust add `, ou `/trust remove `." + } + MessageId::CmdLspStatus => { + "O diagnóstico LSP está atualmente **{status}**.\n\n\ + Use `/lsp on` para ativar ou `/lsp off` para desativar o diagnóstico \ + inline após edições de arquivo." + } + MessageId::CmdLspEnabled => { + "Diagnóstico LSP ativado — os resultados de edição de arquivo incluirão \ + erros e avisos do compilador quando disponíveis." + } + MessageId::CmdLspDisabled => "Diagnóstico LSP desativado.", + MessageId::CmdLspUnknownArg => { + "Argumento /lsp desconhecido `{arg}`. Use `/lsp on`, `/lsp off`, ou \ + `/lsp status`." + } + MessageId::CmdLogoutSuccess => "Desconectado. Insira uma nova chave de API para continuar.", + MessageId::CmdLogoutFailed => "Falha ao limpar a chave de API: {reason}", + MessageId::CmdEditingQueuedDraft => { + "Editando mensagem enfileirada {n} (pressione Enter para re-enfileirar/enviar)" + } }) } From 3412528ce9e2eb257e9c2db1f2d7f956983c3b00 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 6 May 2026 16:09:58 +0800 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20i18n=20Phase=204=20=E2=80=94=20mi?= =?UTF-8?q?grate=20all=20CommandResult::error()=20calls=20to=20localized?= =?UTF-8?q?=20prefix=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 111+ CommandResult::error() callers across 21 command files now use the localized `error(msg, locale)` signature, replacing the hardcoded "Error:" prefix with locale-aware output via CmdErrorPrefix. Breaking helper API: parse_index, clear, parse_add, trust_add, trust_remove now take a locale: Locale parameter threaded from their callers. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/commands/attachment.rs | 13 +++++-- crates/tui/src/commands/cycle.rs | 23 ++++++------ crates/tui/src/commands/debug.rs | 14 ++++---- crates/tui/src/commands/hooks.rs | 7 ++-- crates/tui/src/commands/mcp.rs | 16 +++++---- crates/tui/src/commands/memory.rs | 17 ++++++--- crates/tui/src/commands/mod.rs | 30 ++++++++-------- crates/tui/src/commands/queue.rs | 12 +++---- crates/tui/src/commands/restore.rs | 32 ++++++++++------- crates/tui/src/commands/session.rs | 51 ++++++++++++++++++--------- crates/tui/src/commands/share.rs | 14 +++++--- crates/tui/src/commands/stash.rs | 16 +++++---- crates/tui/src/commands/task.rs | 11 +++--- 13 files changed, 154 insertions(+), 102 deletions(-) diff --git a/crates/tui/src/commands/attachment.rs b/crates/tui/src/commands/attachment.rs index 2f205381c..b7c355ed8 100644 --- a/crates/tui/src/commands/attachment.rs +++ b/crates/tui/src/commands/attachment.rs @@ -7,20 +7,27 @@ use crate::tui::app::App; pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult { let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else { - return CommandResult::error("Usage: /attach "); + return CommandResult::error("Usage: /attach ", app.ui_locale); }; let path = resolve_attachment_path(raw_path, &app.workspace); let Ok(path) = path.canonicalize() else { - return CommandResult::error(format!("Attachment not found: {}", path.display())); + return CommandResult::error( + format!("Attachment not found: {}", path.display()), + app.ui_locale, + ); }; if !path.is_file() { - return CommandResult::error(format!("Attachment is not a file: {}", path.display())); + return CommandResult::error( + format!("Attachment is not a file: {}", path.display()), + app.ui_locale, + ); } let Some(kind) = media_kind(&path) else { return CommandResult::error( "Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.", + app.ui_locale, ); }; diff --git a/crates/tui/src/commands/cycle.rs b/crates/tui/src/commands/cycle.rs index 7a1c9c651..daa05d388 100644 --- a/crates/tui/src/commands/cycle.rs +++ b/crates/tui/src/commands/cycle.rs @@ -47,15 +47,17 @@ pub fn show_cycle(app: &App, arg: Option<&str>) -> CommandResult { let Some(raw) = arg.map(str::trim) else { return CommandResult::error( "Usage: /cycle — n is the cycle number from /cycles".to_string(), + app.ui_locale, ); }; if raw.is_empty() { - return CommandResult::error("Usage: /cycle ".to_string()); + return CommandResult::error("Usage: /cycle ".to_string(), app.ui_locale); } let Ok(n) = raw.parse::() else { - return CommandResult::error(format!( - "Cycle number must be a positive integer (got '{raw}')." - )); + return CommandResult::error( + format!("Cycle number must be a positive integer (got '{raw}')."), + app.ui_locale, + ); }; let Some(brief) = app.cycle_briefings.iter().find(|b| b.cycle == n) else { @@ -69,9 +71,10 @@ pub fn show_cycle(app: &App, arg: Option<&str>) -> CommandResult { } else { known.join(", ") }; - return CommandResult::error(format!( - "Cycle {n} not found in this session. Known cycles: {known_str}." - )); + return CommandResult::error( + format!("Cycle {n} not found in this session. Known cycles: {known_str}."), + app.ui_locale, + ); }; let mut out = String::new(); @@ -99,10 +102,10 @@ pub fn recall_archive(app: &App, arg: Option<&str>) -> CommandResult { use crate::tools::spec::{ToolContext, ToolSpec}; let Some(raw) = arg.map(str::trim) else { - return CommandResult::error("Usage: /recall ".to_string()); + return CommandResult::error("Usage: /recall ".to_string(), app.ui_locale); }; if raw.is_empty() { - return CommandResult::error("Usage: /recall ".to_string()); + return CommandResult::error("Usage: /recall ".to_string(), app.ui_locale); } let session_id = app @@ -120,7 +123,7 @@ pub fn recall_archive(app: &App, arg: Option<&str>) -> CommandResult { match result { Ok(res) => CommandResult::message(res.content), - Err(err) => CommandResult::error(format!("recall_archive failed: {err}")), + Err(err) => CommandResult::error(format!("recall_archive failed: {err}"), app.ui_locale), } } diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 45e6286d2..5a74adc18 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -1545,17 +1545,17 @@ pub fn patch_undo(app: &mut App) -> CommandResult { let repo = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) { Ok(r) => r, Err(e) => { - return CommandResult::error(format!( - "Snapshot repo unavailable for {}: {e}", - workspace.display(), - )); + return CommandResult::error( + format!("Snapshot repo unavailable for {}: {e}", workspace.display(),), + app.ui_locale, + ); } }; let snapshots = match repo.list(20) { Ok(s) => s, Err(e) => { - return CommandResult::error(format!("Failed to list snapshots: {e}")); + return CommandResult::error(format!("Failed to list snapshots: {e}"), app.ui_locale); } }; @@ -1582,7 +1582,7 @@ pub fn patch_undo(app: &mut App) -> CommandResult { }; if let Err(e) = repo.restore(&target.id) { - return CommandResult::error(format!("Restore failed: {e}")); + return CommandResult::error(format!("Restore failed: {e}"), app.ui_locale); } if let Some(tool_id) = target.label.strip_prefix("tool:") { @@ -1742,6 +1742,6 @@ pub fn retry(app: &mut App) -> CommandResult { AppAction::SendMessage(input), ) } - None => CommandResult::error("No previous request to retry"), + None => CommandResult::error("No previous request to retry", app.ui_locale), } } diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index fbf7d760b..52b7452d1 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -26,9 +26,10 @@ pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult { match sub.as_str() { "" | "list" | "ls" | "show" => list(app), "events" | "event" | "list-events" => events(), - other => CommandResult::error(format!( - "unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`." - )), + other => CommandResult::error( + format!("unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`."), + app.ui_locale, + ), } } diff --git a/crates/tui/src/commands/mcp.rs b/crates/tui/src/commands/mcp.rs index 2a29f7293..0b722b174 100644 --- a/crates/tui/src/commands/mcp.rs +++ b/crates/tui/src/commands/mcp.rs @@ -3,8 +3,9 @@ use crate::tui::app::{App, AppAction, McpUiAction}; use super::CommandResult; +use crate::localization::Locale; -pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { +pub fn mcp(app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); if raw.is_empty() || raw.eq_ignore_ascii_case("status") || raw.eq_ignore_ascii_case("list") { return CommandResult::action(AppAction::Mcp(McpUiAction::Show)); @@ -16,23 +17,24 @@ pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { "init" => CommandResult::action(AppAction::Mcp(McpUiAction::Init { force: parts.any(|part| part == "--force" || part == "-f"), })), - "add" => parse_add(parts.collect()), + "add" => parse_add(parts.collect(), app.ui_locale), "enable" => match parse_name(parts.next(), "Usage: /mcp enable ") { Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Enable { name })), - Err(msg) => CommandResult::error(msg), + Err(msg) => CommandResult::error(msg, app.ui_locale), }, "disable" => match parse_name(parts.next(), "Usage: /mcp disable ") { Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Disable { name })), - Err(msg) => CommandResult::error(msg), + Err(msg) => CommandResult::error(msg, app.ui_locale), }, "remove" | "rm" => match parse_name(parts.next(), "Usage: /mcp remove ") { Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Remove { name })), - Err(msg) => CommandResult::error(msg), + Err(msg) => CommandResult::error(msg, app.ui_locale), }, "validate" => CommandResult::action(AppAction::Mcp(McpUiAction::Validate)), "reload" | "reconnect" => CommandResult::action(AppAction::Mcp(McpUiAction::Reload)), _ => CommandResult::error( "Usage: /mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", + app.ui_locale, ), } } @@ -44,10 +46,11 @@ fn parse_name(name: Option<&str>, usage: &str) -> Result { } } -fn parse_add(parts: Vec<&str>) -> CommandResult { +fn parse_add(parts: Vec<&str>, locale: Locale) -> CommandResult { if parts.len() < 3 { return CommandResult::error( "Usage: /mcp add stdio [args...] OR /mcp add http ", + locale, ); } match parts[0].to_ascii_lowercase().as_str() { @@ -62,6 +65,7 @@ fn parse_add(parts: Vec<&str>) -> CommandResult { })), _ => CommandResult::error( "Usage: /mcp add stdio [args...] OR /mcp add http ", + locale, ), } } diff --git a/crates/tui/src/commands/memory.rs b/crates/tui/src/commands/memory.rs index 78bd4480a..5fa71c1c3 100644 --- a/crates/tui/src/commands/memory.rs +++ b/crates/tui/src/commands/memory.rs @@ -47,6 +47,7 @@ pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { if !app.use_memory { return CommandResult::error( "user memory is disabled. Enable with `[memory] enabled = true` in `~/.deepseek/config.toml` or `DEEPSEEK_MEMORY=on` in your environment, then restart the TUI.", + app.ui_locale, ); } @@ -71,17 +72,23 @@ pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { "path" => CommandResult::message(path.display().to_string()), "clear" => match fs::write(&path, "") { Ok(()) => CommandResult::message(format!("memory cleared: {}", path.display())), - Err(err) => CommandResult::error(format!("failed to clear {}: {err}", path.display())), + Err(err) => CommandResult::error( + format!("failed to clear {}: {err}", path.display()), + app.ui_locale, + ), }, "edit" => CommandResult::message(format!( "to edit your memory file, run:\n\n ${{VISUAL:-${{EDITOR:-vi}}}} {}", path.display() )), "help" => CommandResult::message(memory_help(&path)), - _ => CommandResult::error(format!( - "unknown subcommand `{sub}`. Try `/memory help`.\n\n{}", - memory_help(&path) - )), + _ => CommandResult::error( + format!( + "unknown subcommand `{sub}`. Try `/memory help`.\n\n{}", + memory_help(&path) + ), + app.ui_locale, + ), } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 6fcc80ebb..ab8a7439b 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -87,17 +87,8 @@ impl CommandResult { } } - /// Create an error message result - pub fn error(msg: impl Into) -> Self { - Self { - message: Some(format!("Error: {}", msg.into())), - action: None, - is_error: true, - } - } - /// Create an error message result with a localized "Error:" prefix - pub fn error_locale(msg: impl Into, locale: Locale) -> Self { + pub fn error(msg: impl Into, locale: Locale) -> Self { Self { message: Some(format!( "{} {}", @@ -671,9 +662,11 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Legacy command migrations (kept out of registry/autocomplete intentionally). "set" => CommandResult::error( "The /set command was retired. Use /config to edit settings and /settings to inspect current values.", + app.ui_locale, ), "deepseek" => CommandResult::error( "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).", + app.ui_locale, ), _ => { @@ -684,18 +677,22 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { } let suggestions = suggest_command_names(command, 3); if suggestions.is_empty() { - CommandResult::error(format!( - "Unknown command: /{command}. Type /help for available commands." - )) + CommandResult::error( + format!("Unknown command: /{command}. Type /help for available commands."), + app.ui_locale, + ) } else { let list = suggestions .into_iter() .map(|name| format!("/{name}")) .collect::>() .join(", "); - CommandResult::error(format!( - "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." - )) + CommandResult::error( + format!( + "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." + ), + app.ui_locale, + ) } } } @@ -755,6 +752,7 @@ pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { "Usage: /rlm [N] \n\n\ Opens a persistent RLM context with sub_rlm depth N (0-3, default 1)." .to_string(), + app.ui_locale, ); } }; diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index fbb99d5e8..0d8aa521c 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -20,7 +20,7 @@ pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { "edit" => edit_queue(app, parts.next()), "drop" | "remove" | "rm" => drop_queue(app, parts.next()), "clear" => clear_queue(app), - _ => CommandResult::error_locale( + _ => CommandResult::error( localization::tr(app.ui_locale, MessageId::CmdQueueUsage), app.ui_locale, ), @@ -63,15 +63,15 @@ fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult { let locale = app.ui_locale; let t = |id| localization::tr(locale, id); if app.queued_draft.is_some() { - return CommandResult::error_locale(t(MessageId::CmdQueueAlreadyEditing), locale); + return CommandResult::error(t(MessageId::CmdQueueAlreadyEditing), locale); } let index = match parse_index(index, locale) { Ok(index) => index, - Err(err) => return CommandResult::error_locale(err, locale), + Err(err) => return CommandResult::error(err, locale), }; let Some(message) = app.remove_queued_message(index) else { - return CommandResult::error_locale(t(MessageId::CmdQueueNotFound), locale); + return CommandResult::error(t(MessageId::CmdQueueNotFound), locale); }; app.input = message.display.clone(); @@ -90,11 +90,11 @@ fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult { let t = |id| localization::tr(locale, id); let index = match parse_index(index, locale) { Ok(index) => index, - Err(err) => return CommandResult::error_locale(err, locale), + Err(err) => return CommandResult::error(err, locale), }; if app.remove_queued_message(index).is_none() { - return CommandResult::error_locale(t(MessageId::CmdQueueNotFound), locale); + return CommandResult::error(t(MessageId::CmdQueueNotFound), locale); } CommandResult::message(t(MessageId::CmdQueueDropped).replace("{n}", &(index + 1).to_string())) diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/restore.rs index 8ea3540e5..02e8f0296 100644 --- a/crates/tui/src/commands/restore.rs +++ b/crates/tui/src/commands/restore.rs @@ -19,16 +19,18 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let repo = match SnapshotRepo::open_or_init(&workspace) { Ok(r) => r, Err(e) => { - return CommandResult::error(format!( - "Snapshot repo unavailable for {}: {e}", - workspace.display(), - )); + return CommandResult::error( + format!("Snapshot repo unavailable for {}: {e}", workspace.display(),), + app.ui_locale, + ); } }; let snapshots = match repo.list(LIST_LIMIT) { Ok(s) => s, - Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + Err(e) => { + return CommandResult::error(format!("Failed to list snapshots: {e}"), app.ui_locale); + } }; if snapshots.is_empty() { @@ -44,17 +46,21 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let n: usize = match arg.parse() { Ok(n) if n >= 1 => n, _ => { - return CommandResult::error(format!( - "Usage: /restore (N is 1-based; got '{arg}')", - )); + return CommandResult::error( + format!("Usage: /restore (N is 1-based; got '{arg}')",), + app.ui_locale, + ); } }; if n > snapshots.len() { - return CommandResult::error(format!( - "Only {} snapshot(s) available; asked for #{n}.", - snapshots.len(), - )); + return CommandResult::error( + format!( + "Only {} snapshot(s) available; asked for #{n}.", + snapshots.len(), + ), + app.ui_locale, + ); } // Non-YOLO sessions get a confirmation gate. We don't have a true @@ -71,7 +77,7 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let target = &snapshots[n - 1]; if let Err(e) = repo.restore(&target.id) { - return CommandResult::error(format!("Restore failed: {e}")); + return CommandResult::error(format!("Restore failed: {e}"), app.ui_locale); } CommandResult::message(format!( diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index a54c44034..c4a7af92c 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -52,7 +52,12 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { crate::session_manager::compact_session_tool_outputs(&mut persisted); let json = match serde_json::to_string_pretty(&persisted) { Ok(j) => j, - Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")), + Err(e) => { + return CommandResult::error( + format!("Failed to serialize session: {e}"), + app.ui_locale, + ); + } }; match std::fs::write(&save_path, json) { Ok(()) => { @@ -63,10 +68,12 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { crate::session_manager::truncate_id(&session.metadata.id) )) } - Err(e) => CommandResult::error(format!("Failed to save session: {e}")), + Err(e) => { + CommandResult::error(format!("Failed to save session: {e}"), app.ui_locale) + } } } - Err(e) => CommandResult::error(format!("Failed to create directory: {e}")), + Err(e) => CommandResult::error(format!("Failed to create directory: {e}"), app.ui_locale), } } @@ -144,20 +151,26 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { app.workspace.join(p) } } else { - return CommandResult::error("Usage: /load "); + return CommandResult::error("Usage: /load ", app.ui_locale); }; let content = match std::fs::read_to_string(&load_path) { Ok(c) => c, Err(e) => { - return CommandResult::error(format!("Failed to read session file: {e}")); + return CommandResult::error( + format!("Failed to read session file: {e}"), + app.ui_locale, + ); } }; let mut session: crate::session_manager::SavedSession = match serde_json::from_str(&content) { Ok(s) => s, Err(e) => { - return CommandResult::error(format!("Failed to parse session file: {e}")); + return CommandResult::error( + format!("Failed to parse session file: {e}"), + app.ui_locale, + ); } }; crate::session_manager::compact_session_tool_outputs(&mut session); @@ -274,7 +287,7 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult { match std::fs::write(&export_path, content) { Ok(()) => CommandResult::message(format!("Exported to {}", export_path.display())), - Err(e) => CommandResult::error(format!("Failed to export: {e}")), + Err(e) => CommandResult::error(format!("Failed to export: {e}"), app.ui_locale), } } @@ -295,9 +308,10 @@ pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { app.view_stack.push(SessionPickerView::new(&app.workspace)); CommandResult::ok() } - _ => CommandResult::error(format!( - "unknown subcommand `{action}`. usage: /sessions [show|prune ]" - )), + _ => CommandResult::error( + format!("unknown subcommand `{action}`. usage: /sessions [show|prune ]"), + app.ui_locale, + ), } } @@ -306,28 +320,33 @@ pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { /// [`crate::session_manager::SessionManager::prune_sessions_older_than`] /// so users can run a safe cleanup without leaving the TUI. Skips /// the checkpoint subdirectory (the helper guarantees that already). -fn prune(_app: &mut App, days_arg: Option<&str>) -> CommandResult { +fn prune(app: &mut App, days_arg: Option<&str>) -> CommandResult { let days_str = match days_arg { Some(s) => s, None => { return CommandResult::error( "usage: /sessions prune (e.g. `/sessions prune 30` to drop sessions older than 30 days)", + app.ui_locale, ); } }; let days: u64 = match days_str.parse() { Ok(n) if n > 0 => n, _ => { - return CommandResult::error(format!( - "expected a positive integer number of days, got `{days_str}`" - )); + return CommandResult::error( + format!("expected a positive integer number of days, got `{days_str}`"), + app.ui_locale, + ); } }; let manager = match crate::session_manager::SessionManager::default_location() { Ok(m) => m, Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); + return CommandResult::error( + format!("could not open sessions directory: {err}"), + app.ui_locale, + ); } }; @@ -338,7 +357,7 @@ fn prune(_app: &mut App, days_arg: Option<&str>) -> CommandResult { "pruned {n} session{} older than {days}d", if n == 1 { "" } else { "s" } )), - Err(err) => CommandResult::error(format!("prune failed: {err}")), + Err(err) => CommandResult::error(format!("prune failed: {err}"), app.ui_locale), } } diff --git a/crates/tui/src/commands/share.rs b/crates/tui/src/commands/share.rs index 9923af0b5..1d559f2b0 100644 --- a/crates/tui/src/commands/share.rs +++ b/crates/tui/src/commands/share.rs @@ -31,9 +31,12 @@ pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { so you can paste it into Slack, GitHub, Twitter, etc." .to_string(), ), - _ => CommandResult::error(format!( - "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." - )), + _ => CommandResult::error( + format!( + "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." + ), + app.ui_locale, + ), } } @@ -41,7 +44,10 @@ pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { fn do_share(app: &mut App) -> CommandResult { // Check if there's any session content to share if app.history.is_empty() { - return CommandResult::error("Nothing to share. The current session is empty."); + return CommandResult::error( + "Nothing to share. The current session is empty.", + app.ui_locale, + ); } // Sanity-check: the extra info block is optional; the session itself diff --git a/crates/tui/src/commands/stash.rs b/crates/tui/src/commands/stash.rs index 1723e4403..683790409 100644 --- a/crates/tui/src/commands/stash.rs +++ b/crates/tui/src/commands/stash.rs @@ -6,6 +6,7 @@ //! point. use crate::composer_stash; +use crate::localization::Locale; use crate::tui::app::App; use super::CommandResult; @@ -23,10 +24,13 @@ pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult { match sub.as_str() { "" | "list" | "ls" | "show" => list(), "pop" | "restore" => pop(app), - "clear" | "wipe" | "drop" => clear(), - other => CommandResult::error(format!( - "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`." - )), + "clear" | "wipe" | "drop" => clear(app.ui_locale), + other => CommandResult::error( + format!( + "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`." + ), + app.ui_locale, + ), } } @@ -52,11 +56,11 @@ fn list() -> CommandResult { CommandResult::message(out) } -fn clear() -> CommandResult { +fn clear(locale: Locale) -> CommandResult { match composer_stash::clear_stash() { Ok(0) => CommandResult::message("Stash already empty — nothing to clear."), Ok(n) => CommandResult::message(format!("Cleared {n} parked draft(s) from the stash.")), - Err(err) => CommandResult::error(format!("Failed to clear stash: {err}")), + Err(err) => CommandResult::error(format!("Failed to clear stash: {err}"), locale), } } diff --git a/crates/tui/src/commands/task.rs b/crates/tui/src/commands/task.rs index f1cb30afd..e2dce26d4 100644 --- a/crates/tui/src/commands/task.rs +++ b/crates/tui/src/commands/task.rs @@ -19,7 +19,7 @@ pub fn task(app: &mut App, args: Option<&str>) -> CommandResult { match action.as_str() { "add" => { let Some(prompt) = remainder else { - return CommandResult::error_locale(t(MessageId::CmdTaskUsageAdd), app.ui_locale); + return CommandResult::error(t(MessageId::CmdTaskUsageAdd), app.ui_locale); }; CommandResult::action(AppAction::TaskAdd { prompt: prompt.to_string(), @@ -28,20 +28,17 @@ pub fn task(app: &mut App, args: Option<&str>) -> CommandResult { "list" => CommandResult::action(AppAction::TaskList), "show" => { let Some(id) = remainder else { - return CommandResult::error_locale(t(MessageId::CmdTaskUsageShow), app.ui_locale); + return CommandResult::error(t(MessageId::CmdTaskUsageShow), app.ui_locale); }; CommandResult::action(AppAction::TaskShow { id: id.to_string() }) } "cancel" | "stop" => { let Some(id) = remainder else { - return CommandResult::error_locale( - t(MessageId::CmdTaskUsageCancel), - app.ui_locale, - ); + return CommandResult::error(t(MessageId::CmdTaskUsageCancel), app.ui_locale); }; CommandResult::action(AppAction::TaskCancel { id: id.to_string() }) } - _ => CommandResult::error_locale(t(MessageId::CmdTaskUsageGeneral), app.ui_locale), + _ => CommandResult::error(t(MessageId::CmdTaskUsageGeneral), app.ui_locale), } } From 339952ef0f32157938a966dae058e09ffa299e17 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 6 May 2026 16:31:49 +0800 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20i18n=20Phase=204b=20=E2=80=94=20l?= =?UTF-8?q?ocalize=20tool=20family=20labels,=20agent=20lifecycle,=20fanout?= =?UTF-8?q?=20summaries,=20and=20sub-agent=20modal=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Deepseek V4 Flash --- crates/tui/src/localization.rs | 186 ++++++++++++++++++++++++ crates/tui/src/tui/app.rs | 1 + crates/tui/src/tui/history.rs | 15 +- crates/tui/src/tui/sidebar.rs | 1 + crates/tui/src/tui/views/mod.rs | 97 ++++++++---- crates/tui/src/tui/widgets/tool_card.rs | 20 +++ 6 files changed, 289 insertions(+), 31 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index a80a6eeb9..3eeeb7e20 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -550,6 +550,44 @@ pub enum MessageId { CmdLogoutSuccess, CmdLogoutFailed, CmdEditingQueuedDraft, + + // ── Tool family labels (card headers) ──────────────── + ToolFamilyRead, + ToolFamilyPatch, + ToolFamilyRun, + ToolFamilyFind, + ToolFamilyDelegate, + ToolFamilyFanout, + ToolFamilyRlm, + ToolFamilyThink, + ToolFamilyGeneric, + + // ── Agent lifecycle labels (status badges) ────────── + AgentLifecyclePending, + AgentLifecycleRunning, + AgentLifecycleDone, + AgentLifecycleFailed, + AgentLifecycleCancelled, + + // ── Fanout summary counts ──────────────────────────── + FanoutCounts, + + // ── Sub-agents modal ───────────────────────────────── + SubAgentsTitle, + SubAgentsNoAgents, + SubAgentsRunning, + SubAgentsCompleted, + SubAgentsInterrupted, + SubAgentsFailed, + SubAgentsCancelled, + AgentStatusRunning, + AgentStatusCompleted, + AgentStatusInterrupted, + AgentStatusCancelled, + AgentStatusFailed, + + // ── Sidebar ────────────────────────────────────────── + SidebarNoAgents, } #[allow(dead_code)] @@ -878,6 +916,34 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdLogoutSuccess, MessageId::CmdLogoutFailed, MessageId::CmdEditingQueuedDraft, + MessageId::ToolFamilyRead, + MessageId::ToolFamilyPatch, + MessageId::ToolFamilyRun, + MessageId::ToolFamilyFind, + MessageId::ToolFamilyDelegate, + MessageId::ToolFamilyFanout, + MessageId::ToolFamilyRlm, + MessageId::ToolFamilyThink, + MessageId::ToolFamilyGeneric, + MessageId::AgentLifecyclePending, + MessageId::AgentLifecycleRunning, + MessageId::AgentLifecycleDone, + MessageId::AgentLifecycleFailed, + MessageId::AgentLifecycleCancelled, + MessageId::FanoutCounts, + MessageId::SubAgentsTitle, + MessageId::SubAgentsNoAgents, + MessageId::SubAgentsRunning, + MessageId::SubAgentsCompleted, + MessageId::SubAgentsInterrupted, + MessageId::SubAgentsFailed, + MessageId::SubAgentsCancelled, + MessageId::AgentStatusRunning, + MessageId::AgentStatusCompleted, + MessageId::AgentStatusInterrupted, + MessageId::AgentStatusCancelled, + MessageId::AgentStatusFailed, + MessageId::SidebarNoAgents, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1538,6 +1604,36 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdEditingQueuedDraft => { "Editing queued message {n} (press Enter to re-queue/send)" } + MessageId::ToolFamilyRead => "read", + MessageId::ToolFamilyPatch => "patch", + MessageId::ToolFamilyRun => "run", + MessageId::ToolFamilyFind => "find", + MessageId::ToolFamilyDelegate => "delegate", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyThink => "think", + MessageId::ToolFamilyGeneric => "tool", + MessageId::AgentLifecyclePending => "pending", + MessageId::AgentLifecycleRunning => "running", + MessageId::AgentLifecycleDone => "done", + MessageId::AgentLifecycleFailed => "failed", + MessageId::AgentLifecycleCancelled => "cancelled", + MessageId::FanoutCounts => { + "{done} done · {running} running · {failed} failed · {pending} pending" + } + MessageId::SubAgentsTitle => "Sub-agents", + MessageId::SubAgentsNoAgents => "No agents running.", + MessageId::SubAgentsRunning => "Running", + MessageId::SubAgentsCompleted => "Completed", + MessageId::SubAgentsInterrupted => "Interrupted", + MessageId::SubAgentsFailed => "Failed", + MessageId::SubAgentsCancelled => "Cancelled", + MessageId::AgentStatusRunning => "running", + MessageId::AgentStatusCompleted => "completed", + MessageId::AgentStatusInterrupted => "interrupted", + MessageId::AgentStatusCancelled => "cancelled", + MessageId::AgentStatusFailed => "failed", + MessageId::SidebarNoAgents => "No agents", } } @@ -2054,6 +2150,36 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdEditingQueuedDraft => { "キューされたメッセージ {n} を編集中 (Enter で再キュー/送信)" } + MessageId::ToolFamilyRead => "読み取り", + MessageId::ToolFamilyPatch => "パッチ", + MessageId::ToolFamilyRun => "実行", + MessageId::ToolFamilyFind => "検索", + MessageId::ToolFamilyDelegate => "委任", + MessageId::ToolFamilyFanout => "ファンアウト", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "ツール", + MessageId::AgentLifecyclePending => "待機中", + MessageId::AgentLifecycleRunning => "実行中", + MessageId::AgentLifecycleDone => "完了", + MessageId::AgentLifecycleFailed => "失敗", + MessageId::AgentLifecycleCancelled => "キャンセル済み", + MessageId::FanoutCounts => { + "{done} 完了 · {running} 実行中 · {failed} 失敗 · {pending} 待機" + } + MessageId::SubAgentsTitle => "サブエージェント", + MessageId::SubAgentsNoAgents => "実行中のエージェントはありません。", + MessageId::SubAgentsRunning => "実行中", + MessageId::SubAgentsCompleted => "完了", + MessageId::SubAgentsInterrupted => "中断", + MessageId::SubAgentsFailed => "失敗", + MessageId::SubAgentsCancelled => "キャンセル済み", + MessageId::AgentStatusRunning => "実行中", + MessageId::AgentStatusCompleted => "完了", + MessageId::AgentStatusInterrupted => "中断", + MessageId::AgentStatusCancelled => "キャンセル済み", + MessageId::AgentStatusFailed => "失敗", + MessageId::SidebarNoAgents => "エージェントなし", }) } @@ -2474,6 +2600,36 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdLogoutSuccess => "已登出。请输入新的 API 密钥以继续。", MessageId::CmdLogoutFailed => "清除 API 密钥失败:{reason}", MessageId::CmdEditingQueuedDraft => "正在编辑已排队的消息 {n}(按 Enter 重新排队/发送)", + MessageId::ToolFamilyRead => "读取", + MessageId::ToolFamilyPatch => "补丁", + MessageId::ToolFamilyRun => "运行", + MessageId::ToolFamilyFind => "查找", + MessageId::ToolFamilyDelegate => "委托", + MessageId::ToolFamilyFanout => "扇出", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "工具", + MessageId::AgentLifecyclePending => "等待中", + MessageId::AgentLifecycleRunning => "运行中", + MessageId::AgentLifecycleDone => "完成", + MessageId::AgentLifecycleFailed => "失败", + MessageId::AgentLifecycleCancelled => "已取消", + MessageId::FanoutCounts => { + "{done} 完成 · {running} 运行中 · {failed} 失败 · {pending} 等待中" + } + MessageId::SubAgentsTitle => "子 Agent", + MessageId::SubAgentsNoAgents => "没有运行中的 Agent。", + MessageId::SubAgentsRunning => "运行中", + MessageId::SubAgentsCompleted => "已完成", + MessageId::SubAgentsInterrupted => "已中断", + MessageId::SubAgentsFailed => "失败", + MessageId::SubAgentsCancelled => "已取消", + MessageId::AgentStatusRunning => "运行中", + MessageId::AgentStatusCompleted => "已完成", + MessageId::AgentStatusInterrupted => "已中断", + MessageId::AgentStatusCancelled => "已取消", + MessageId::AgentStatusFailed => "失败", + MessageId::SidebarNoAgents => "无 Agent", }) } @@ -2998,6 +3154,36 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdEditingQueuedDraft => { "Editando mensagem enfileirada {n} (pressione Enter para re-enfileirar/enviar)" } + MessageId::ToolFamilyRead => "ler", + MessageId::ToolFamilyPatch => "patch", + MessageId::ToolFamilyRun => "executar", + MessageId::ToolFamilyFind => "buscar", + MessageId::ToolFamilyDelegate => "delegar", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyThink => "pensar", + MessageId::ToolFamilyGeneric => "ferramenta", + MessageId::AgentLifecyclePending => "pendente", + MessageId::AgentLifecycleRunning => "executando", + MessageId::AgentLifecycleDone => "concluído", + MessageId::AgentLifecycleFailed => "falhou", + MessageId::AgentLifecycleCancelled => "cancelado", + MessageId::FanoutCounts => { + "{done} concluído · {running} executando · {failed} falhou · {pending} pendente" + } + MessageId::SubAgentsTitle => "Sub-agentes", + MessageId::SubAgentsNoAgents => "Nenhum agente em execução.", + MessageId::SubAgentsRunning => "Executando", + MessageId::SubAgentsCompleted => "Concluído", + MessageId::SubAgentsInterrupted => "Interrompido", + MessageId::SubAgentsFailed => "Falhou", + MessageId::SubAgentsCancelled => "Cancelado", + MessageId::AgentStatusRunning => "executando", + MessageId::AgentStatusCompleted => "concluído", + MessageId::AgentStatusInterrupted => "interrompido", + MessageId::AgentStatusCancelled => "cancelado", + MessageId::AgentStatusFailed => "falhou", + MessageId::SidebarNoAgents => "Sem agentes", }) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3c49df0ed..f7cd2e115 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3108,6 +3108,7 @@ impl App { calm_mode: self.calm_mode, low_motion: self.low_motion, spacing: self.transcript_spacing, + locale: self.ui_locale, } } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index f2ce686e6..868bfe671 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -9,6 +9,7 @@ use serde_json::Value; use unicode_width::UnicodeWidthStr; use crate::deepseek_theme::active_theme; +use crate::localization::Locale; use crate::models::{ContentBlock, Message}; use crate::palette; use crate::tools::review::ReviewOutput; @@ -142,9 +143,15 @@ pub enum SubAgentCell { impl SubAgentCell { pub fn lines(&self, width: u16) -> Vec> { + // No-locale overload — falls back to English. Used by `lines()` + // and `transcript_lines()` on `HistoryCell` (test paths). + self.lines_with_locale(width, Locale::En) + } + + pub fn lines_with_locale(&self, width: u16, locale: Locale) -> Vec> { match self { - SubAgentCell::Delegate(card) => card.render_lines(width), - SubAgentCell::Fanout(card) => card.render_lines(width), + SubAgentCell::Delegate(card) => card.render_lines(width, locale), + SubAgentCell::Fanout(card) => card.render_lines(width, locale), } } } @@ -157,6 +164,7 @@ pub struct TranscriptRenderOptions { pub calm_mode: bool, pub low_motion: bool, pub spacing: TranscriptSpacing, + pub locale: Locale, } pub(crate) struct RenderedTranscriptLine { @@ -174,6 +182,7 @@ impl Default for TranscriptRenderOptions { calm_mode: false, low_motion: false, spacing: TranscriptSpacing::Comfortable, + locale: Locale::En, } } } @@ -296,7 +305,7 @@ impl HistoryCell { width, ), HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width), - HistoryCell::SubAgent(cell) => cell.lines(width), + HistoryCell::SubAgent(cell) => cell.lines_with_locale(width, options.locale), HistoryCell::ArchivedContext { .. } => { render_archived_context(self, width, options.low_motion) } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 2ebd58dd6..332e72c19 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -17,6 +17,7 @@ use ratatui::{ }; use crate::deepseek_theme::Theme; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tools::plan::StepStatus; use crate::tools::subagent::SubAgentStatus; diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 28acaa527..357e340c5 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1543,6 +1543,7 @@ pub use help::HelpView; pub struct SubAgentsView { agents: Vec, scroll: usize, + locale: Locale, } /// Build the agent rows shown by `/subagents`. @@ -1652,8 +1653,12 @@ fn live_subagent_result( } impl SubAgentsView { - pub fn new(agents: Vec) -> Self { - Self { agents, scroll: 0 } + pub fn new(agents: Vec, locale: Locale) -> Self { + Self { + agents, + scroll: 0, + locale, + } } } @@ -1716,7 +1721,7 @@ impl ModalView for SubAgentsView { if self.agents.is_empty() { lines.push(Line::from(Span::styled( - "No agents running.", + tr(self.locale, MessageId::SubAgentsNoAgents), Style::default().fg(palette::TEXT_MUTED), ))); } else { @@ -1737,15 +1742,35 @@ impl ModalView for SubAgentsView { } let status_summary = [ - ("Running", running.len(), palette::STATUS_WARNING), - ("Completed", completed.len(), palette::STATUS_SUCCESS), - ("Interrupted", interrupted.len(), palette::STATUS_WARNING), - ("Failed", failed.len(), palette::DEEPSEEK_RED), - ("Cancelled", cancelled.len(), palette::TEXT_MUTED), + ( + tr(self.locale, MessageId::SubAgentsRunning), + running.len(), + palette::STATUS_WARNING, + ), + ( + tr(self.locale, MessageId::SubAgentsCompleted), + completed.len(), + palette::STATUS_SUCCESS, + ), + ( + tr(self.locale, MessageId::SubAgentsInterrupted), + interrupted.len(), + palette::STATUS_WARNING, + ), + ( + tr(self.locale, MessageId::SubAgentsFailed), + failed.len(), + palette::DEEPSEEK_RED, + ), + ( + tr(self.locale, MessageId::SubAgentsCancelled), + cancelled.len(), + palette::TEXT_MUTED, + ), ]; lines.push(Line::from(Span::styled( - "Sub-agents", + tr(self.locale, MessageId::SubAgentsTitle), Style::default().fg(palette::DEEPSEEK_SKY).bold(), ))); @@ -1793,38 +1818,43 @@ impl ModalView for SubAgentsView { append_subagent_group( &mut lines, - "Running", + tr(self.locale, MessageId::SubAgentsRunning), palette::STATUS_WARNING.into(), &running, content_width, + self.locale, ); append_subagent_group( &mut lines, - "Completed", + tr(self.locale, MessageId::SubAgentsCompleted), palette::STATUS_SUCCESS.into(), &completed, content_width, + self.locale, ); append_subagent_group( &mut lines, - "Interrupted", + tr(self.locale, MessageId::SubAgentsInterrupted), palette::STATUS_WARNING.into(), &interrupted, content_width, + self.locale, ); append_subagent_group( &mut lines, - "Failed", + tr(self.locale, MessageId::SubAgentsFailed), palette::DEEPSEEK_RED.into(), &failed, content_width, + self.locale, ); append_subagent_group( &mut lines, - "Cancelled", + tr(self.locale, MessageId::SubAgentsCancelled), palette::TEXT_MUTED.into(), &cancelled, content_width, + self.locale, ); } @@ -1843,11 +1873,11 @@ impl ModalView for SubAgentsView { .block( Block::default() .title(Line::from(vec![Span::styled( - " Sub-agents ", + format!(" {} ", tr(self.locale, MessageId::SubAgentsTitle)), Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )])) .title_bottom(Line::from(vec![ - Span::styled(" Esc to close ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled(" Esc/q to close ", Style::default().fg(palette::TEXT_MUTED)), Span::styled(" R to refresh ", Style::default().fg(palette::TEXT_MUTED)), Span::styled(scroll_indicator, Style::default().fg(palette::DEEPSEEK_SKY)), ])) @@ -1868,6 +1898,7 @@ fn append_subagent_group( section_style: ratatui::style::Style, agents: &[&SubAgentResult], content_width: usize, + locale: Locale, ) { use ratatui::{ style::Style, @@ -1890,7 +1921,7 @@ fn append_subagent_group( .map(|nick| format!("{nick:<12}")) .unwrap_or_else(|| format!("{id:<12}")); let kind = format_agent_type(&agent.agent_type); - let (status, status_style, status_detail) = format_agent_status(&agent.status); + let (status, status_style, status_detail) = format_agent_status(&agent.status, locale); lines.push(Line::from(vec![ Span::raw(" "), @@ -1917,7 +1948,7 @@ fn append_subagent_group( if let Some(detail) = status_detail { let max_len = content_width.saturating_sub(10); - let detail = truncate_view_text(detail, max_len); + let detail = truncate_view_text(&detail, max_len); lines.push(Line::from(vec![ Span::styled(" reason: ", Style::default().fg(palette::TEXT_MUTED)), Span::styled(detail, Style::default().fg(palette::DEEPSEEK_RED)), @@ -1974,28 +2005,38 @@ fn format_agent_type(agent_type: &SubAgentType) -> &'static str { fn format_agent_status( status: &SubAgentStatus, -) -> (&'static str, ratatui::style::Style, Option<&str>) { + locale: Locale, +) -> (String, ratatui::style::Style, Option) { use ratatui::style::Style; - match status { - SubAgentStatus::Running => ("running", Style::default().fg(palette::DEEPSEEK_SKY), None), + let (id, style, detail) = match status { + SubAgentStatus::Running => ( + MessageId::AgentStatusRunning, + Style::default().fg(palette::DEEPSEEK_SKY), + None, + ), SubAgentStatus::Completed => ( - "completed", + MessageId::AgentStatusCompleted, Style::default().fg(palette::DEEPSEEK_BLUE), None, ), SubAgentStatus::Interrupted(reason) => ( - "interrupted", + MessageId::AgentStatusInterrupted, Style::default().fg(palette::STATUS_WARNING), - Some(reason.as_str()), + Some(reason.clone()), + ), + SubAgentStatus::Cancelled => ( + MessageId::AgentStatusCancelled, + Style::default().fg(palette::TEXT_MUTED), + None, ), - SubAgentStatus::Cancelled => ("cancelled", Style::default().fg(palette::TEXT_MUTED), None), SubAgentStatus::Failed(reason) => ( - "failed", + MessageId::AgentStatusFailed, Style::default().fg(palette::DEEPSEEK_RED), - Some(reason.as_str()), + Some(reason.clone()), ), - } + }; + (tr(locale, id).to_string(), style, detail) } fn truncate_view_text(text: &str, max_chars: usize) -> String { diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index 6020069b1..de472967d 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -23,6 +23,8 @@ //! module is the vocabulary, not the layout engine. Keeping it small means //! a future visual refresh only has to touch the constants here. +use crate::localization::{Locale, MessageId, tr}; + /// Tool family — the verb the agent is performing. Used to pick a glyph /// and label for the card header. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -167,6 +169,24 @@ pub fn family_label(family: ToolFamily) -> &'static str { } } +/// Locale-aware version of [`family_label`]. Returns an owned string so the +/// caller can use it directly in a `Span` without extra `.to_string()`. +#[must_use] +pub fn family_label_locale(family: ToolFamily, locale: Locale) -> String { + let id = match family { + ToolFamily::Read => MessageId::ToolFamilyRead, + ToolFamily::Patch => MessageId::ToolFamilyPatch, + ToolFamily::Run => MessageId::ToolFamilyRun, + ToolFamily::Find => MessageId::ToolFamilyFind, + ToolFamily::Delegate => MessageId::ToolFamilyDelegate, + ToolFamily::Fanout => MessageId::ToolFamilyFanout, + ToolFamily::Rlm => MessageId::ToolFamilyRlm, + ToolFamily::Think => MessageId::ToolFamilyThink, + ToolFamily::Generic => MessageId::ToolFamilyGeneric, + }; + tr(locale, id).to_string() +} + /// Position of a line within a multi-line card — drives the left-rail /// glyph so the box reads as a contiguous group from top to bottom. #[derive(Debug, Clone, Copy, PartialEq, Eq)] From c3d64639e346fa549ba4e5d556ac51c12ddaa232 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 26 May 2026 17:45:28 +0800 Subject: [PATCH 08/15] feat: i18n wiring + rebase compile fixes --- crates/tui/src/commands/anchor.rs | 16 +-- crates/tui/src/commands/change.rs | 2 +- crates/tui/src/commands/config.rs | 46 +++--- crates/tui/src/commands/core.rs | 16 +-- crates/tui/src/commands/debug.rs | 2 +- crates/tui/src/commands/feedback.rs | 2 +- crates/tui/src/commands/goal.rs | 2 +- crates/tui/src/commands/init.rs | 2 +- crates/tui/src/commands/jobs.rs | 12 +- crates/tui/src/commands/mod.rs | 18 ++- crates/tui/src/commands/network.rs | 2 +- crates/tui/src/commands/note.rs | 32 ++--- crates/tui/src/commands/provider.rs | 4 +- crates/tui/src/commands/rename.rs | 12 +- crates/tui/src/commands/review.rs | 4 +- crates/tui/src/commands/session.rs | 14 +- crates/tui/src/commands/share.rs | 2 +- crates/tui/src/commands/skills.rs | 46 +++--- crates/tui/src/commands/status.rs | 2 +- crates/tui/src/core/engine.rs | 7 +- crates/tui/src/localization.rs | 171 +++++++++++++++++++++++ crates/tui/src/tui/app.rs | 38 ++--- crates/tui/src/tui/approval.rs | 69 ++++----- crates/tui/src/tui/context_inspector.rs | 166 ++++++++++++---------- crates/tui/src/tui/history.rs | 149 ++++++++++++-------- crates/tui/src/tui/onboarding/mod.rs | 2 +- crates/tui/src/tui/onboarding/welcome.rs | 13 +- crates/tui/src/tui/sidebar.rs | 32 +++-- crates/tui/src/tui/ui.rs | 9 +- crates/tui/src/tui/ui/tests.rs | 2 +- crates/tui/src/tui/views/mod.rs | 26 ++-- crates/tui/src/tui/widgets/agent_card.rs | 41 +++--- crates/tui/src/tui/widgets/header.rs | 42 ++++-- crates/tui/src/tui/widgets/mod.rs | 15 +- 34 files changed, 643 insertions(+), 375 deletions(-) diff --git a/crates/tui/src/commands/anchor.rs b/crates/tui/src/commands/anchor.rs index 7ba66d7a1..0b9f7ffa0 100644 --- a/crates/tui/src/commands/anchor.rs +++ b/crates/tui/src/commands/anchor.rs @@ -21,12 +21,12 @@ pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult { let input = match content { Some(c) => c.trim(), None => { - return CommandResult::error(format!("Usage: {USAGE}")); + return CommandResult::error_msg(format!("Usage: {USAGE}")); } }; if input.is_empty() { - return CommandResult::error(format!("Usage: {USAGE}")); + return CommandResult::error_msg(format!("Usage: {USAGE}")); } // Parse subcommands. @@ -89,20 +89,20 @@ fn add_anchor(app: &mut App, text: &str) -> CommandResult { if let Some(parent) = path.parent() && let Err(e) = fs::create_dir_all(parent) { - return CommandResult::error(format!("Failed to create anchors directory: {e}")); + return CommandResult::error_msg(format!("Failed to create anchors directory: {e}")); } // Append to anchors file. let mut file = match fs::OpenOptions::new().create(true).append(true).open(&path) { Ok(f) => f, Err(e) => { - return CommandResult::error(format!("Failed to open anchors file: {e}")); + return CommandResult::error_msg(format!("Failed to open anchors file: {e}")); } }; // Write separator and anchor content. if let Err(e) = writeln!(file, "\n---\n{text}") { - return CommandResult::error(format!("Failed to write anchor: {e}")); + return CommandResult::error_msg(format!("Failed to write anchor: {e}")); } CommandResult::message(format!( @@ -134,7 +134,7 @@ fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult { let index: usize = match index_str.parse() { Ok(n) if n >= 1 => n, _ => { - return CommandResult::error( + return CommandResult::error_msg( "Invalid index. Use /anchor list to see anchor numbers, then /anchor remove .", ); } @@ -143,7 +143,7 @@ fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult { let mut anchors = read_anchors(app); if index > anchors.len() { - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Anchor #{index} does not exist. You have {} anchor(s). Use /anchor list to see them.", anchors.len() )); @@ -151,7 +151,7 @@ fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult { let removed = anchors.remove(index - 1); if let Err(e) = write_anchors(app, &anchors) { - return CommandResult::error(e); + return CommandResult::error_msg(e); } CommandResult::message(format!("Removed anchor #{index}: {removed}")) diff --git a/crates/tui/src/commands/change.rs b/crates/tui/src/commands/change.rs index e8448a489..46d6eec3a 100644 --- a/crates/tui/src/commands/change.rs +++ b/crates/tui/src/commands/change.rs @@ -56,7 +56,7 @@ pub fn change(app: &mut App, version: Option<&str>) -> CommandResult { Expected a line starting with `## [`." .to_string() }; - return CommandResult::error(msg); + return CommandResult::error_msg(msg); } }; diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 82b11880f..6d93e3503 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -28,10 +28,10 @@ use anyhow::Result; pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult { let mode = match parse_mode(arg) { Ok(mode) => mode, - Err(err) => return CommandResult::error(err), + Err(err) => return CommandResult::error_msg(err), }; if mode == ConfigUiMode::Web && !cfg!(feature = "web") { - return CommandResult::error( + return CommandResult::error_msg( "This build does not include the web config UI. Rebuild with the `web` feature.", ); } @@ -242,7 +242,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { }; match value { Some(v) => CommandResult::message(format!("{key} = {v}")), - None => CommandResult::error(format!( + None => CommandResult::error_msg(format!( "Unknown setting '{key}'. See `/help config` for available settings." )), } @@ -252,7 +252,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { pub fn show_settings(app: &mut App) -> CommandResult { match Settings::load() { Ok(settings) => CommandResult::message(settings.display(app.ui_locale)), - Err(e) => CommandResult::error(format!("Failed to load settings: {e}")), + Err(e) => CommandResult::error_msg(format!("Failed to load settings: {e}")), } } @@ -270,7 +270,7 @@ pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult { "off" | "false" | "0" | "no" => false, "toggle" => !app.verbose_transcript, _ => { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /verbose [on|off]. Compact thinking remains available when verbose is off.", ); } @@ -407,7 +407,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> } // Clear auto mode when a specific model is set let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else { - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Invalid model '{value}'. Expected a DeepSeek model ID. Common models: {}", COMMON_DEEPSEEK_MODELS.join(", ") )); @@ -428,14 +428,14 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.approval_mode = m; CommandResult::message(format!("approval_mode = {}", m.label())) } - None => CommandResult::error( + None => CommandResult::error_msg( "Invalid approval_mode. Use: auto, suggest/on-request/untrusted, never/deny", ), }; } "mcp_config_path" | "mcp" => { if value.trim().is_empty() { - return CommandResult::error("mcp_config_path cannot be empty"); + return CommandResult::error_msg("mcp_config_path cannot be empty"); } app.mcp_config_path = PathBuf::from(expand_tilde(value)); app.mcp_restart_required = true; @@ -447,7 +447,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.mcp_config_path.display(), path.display() ), - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + Err(err) => return CommandResult::error_msg(format!("Failed to save: {err}")), } } else { format!( @@ -488,11 +488,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> )); Settings::default() } - Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")), + Err(e) => return CommandResult::error_msg(format!("Failed to load settings: {e}")), }; if let Err(e) = settings.set(&key, value) { - return CommandResult::error(format!("{e}")); + return CommandResult::error_msg(format!("{e}")); } let mut action = None; @@ -648,7 +648,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> let message = if persist { if let Err(e) = settings.save() { - return CommandResult::error(format!("Failed to save: {e}")); + return CommandResult::error_msg(format!("Failed to save: {e}")); } format!("{key} = {display_value} (saved)") } else { @@ -683,7 +683,7 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { let parts: Vec<&str> = args.splitn(2, ' ').collect(); if parts.len() < 2 { - return CommandResult::error("Usage: /set "); + return CommandResult::error_msg("Usage: /set "); } let key = parts[0].to_lowercase(); @@ -703,7 +703,7 @@ pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult { }; match parse_mode_arg(arg) { Some(mode) => CommandResult::message(switch_mode(app, mode)), - None => CommandResult::error("Usage: /mode [agent|plan|yolo|1|2|3]"), + None => CommandResult::error_msg("Usage: /mode [agent|plan|yolo|1|2|3]"), } } @@ -772,7 +772,7 @@ pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult { } "add" => trust_add(&workspace, rest), "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest), - other => CommandResult::error_locale( + other => CommandResult::error( t(MessageId::CmdTrustUnknownAction).replace("{action}", other), app.ui_locale, ), @@ -810,13 +810,13 @@ fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult fn trust_add(workspace: &Path, raw: &str) -> CommandResult { if raw.is_empty() { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /trust add . Supply an absolute path or a path relative to the workspace.", ); } let path = PathBuf::from(expand_tilde(raw)); if !path.exists() { - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Path not found: {} — supply an existing directory or file.", path.display() )); @@ -826,19 +826,19 @@ fn trust_add(workspace: &Path, raw: &str) -> CommandResult { "Added to trust list for this workspace: {}", stored.display() )), - Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")), + Err(err) => CommandResult::error_msg(format!("Failed to update trust list: {err}")), } } fn trust_remove(workspace: &Path, raw: &str) -> CommandResult { if raw.is_empty() { - return CommandResult::error("Usage: /trust remove "); + return CommandResult::error_msg("Usage: /trust remove "); } let path = PathBuf::from(expand_tilde(raw)); match crate::workspace_trust::remove(workspace, &path) { Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())), Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())), - Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")), + Err(err) => CommandResult::error_msg(format!("Failed to update trust list: {err}")), } } @@ -1266,7 +1266,7 @@ pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { app.lsp_enabled = false; CommandResult::message(t(MessageId::CmdLspDisabled)) } - other => CommandResult::error_locale( + other => CommandResult::error( t(MessageId::CmdLspUnknownArg).replace("{arg}", other), app.ui_locale, ), @@ -1284,7 +1284,7 @@ pub fn logout(app: &mut App) -> CommandResult { app.api_key_cursor = 0; CommandResult::message(t(MessageId::CmdLogoutSuccess)) } - Err(e) => CommandResult::error_locale( + Err(e) => CommandResult::error( t(MessageId::CmdLogoutFailed).replace("{reason}", &e.to_string()), app.ui_locale, ), @@ -2049,7 +2049,7 @@ mod tests { let mut app = create_test_app(); let result = trust(&mut app, Some("add")); let msg = result.message.expect("error message"); - assert!(msg.starts_with("Error:"), "got {msg:?}"); + assert!(msg.starts_with("Usage:"), "got {msg:?}"); } #[test] diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index b91ca89ea..d71a9b289 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -32,7 +32,7 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { } return CommandResult::message(help); } - return CommandResult::error( + return CommandResult::error_msg( tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic), ); } @@ -130,7 +130,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { ); } let Some(model_id) = normalize_model_name_for_provider(app.api_provider, name) else { - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Invalid model '{name}'. Expected auto or a DeepSeek model ID. Common models: {}", COMMON_DEEPSEEK_MODELS.join(", ") )); @@ -167,7 +167,7 @@ pub fn models(_app: &mut App) -> CommandResult { pub fn subagents(app: &mut App) -> CommandResult { if app.view_stack.top_kind() != Some(ModalKind::SubAgents) { let agents = subagent_view_agents(app, &app.subagent_cache); - app.view_stack.push(SubAgentsView::new(agents)); + app.view_stack.push(SubAgentsView::new(agents, app.ui_locale)); } app.status_message = Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string()); CommandResult::action(AppAction::ListSubAgents) @@ -178,7 +178,7 @@ pub fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult { let profile_name = match arg { Some(name) if !name.trim().is_empty() => name.trim().to_string(), _ => { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /profile \n\nSwitch to a named config profile. Profiles are defined in ~/.deepseek/config.toml under [profiles] sections.", ); } @@ -198,7 +198,7 @@ pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { let expanded = match expand_workspace_path(raw_path) { Ok(path) => path, - Err(message) => return CommandResult::error(message), + Err(message) => return CommandResult::error_msg(message), }; let candidate = if expanded.is_absolute() { expanded @@ -207,10 +207,10 @@ pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { }; if !candidate.exists() { - return CommandResult::error(format!("Workspace does not exist: {}", candidate.display())); + return CommandResult::error_msg(format!("Workspace does not exist: {}", candidate.display())); } if !candidate.is_dir() { - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Workspace is not a directory: {}", candidate.display() )); @@ -271,7 +271,7 @@ pub fn home_dashboard(app: &mut App) -> CommandResult { stats, "{} {}", tr(locale, MessageId::HomeMode), - app.mode.label() + app.mode.label(locale) ); let _ = writeln!( stats, diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 5a74adc18..87746632e 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -125,7 +125,7 @@ pub fn system_prompt(app: &mut App) -> CommandResult { CommandResult::message(format!( "System Prompt ({} mode):\n─────────────────────────────\n{}", - app.mode.label(), + app.mode.label(app.ui_locale), display )) } diff --git a/crates/tui/src/commands/feedback.rs b/crates/tui/src/commands/feedback.rs index 9849c9a20..a49841cb9 100644 --- a/crates/tui/src/commands/feedback.rs +++ b/crates/tui/src/commands/feedback.rs @@ -15,7 +15,7 @@ pub fn feedback(_app: &mut App, arg: Option<&str>) -> CommandResult { let kind = match parse_feedback_kind(raw) { Some(parsed) => parsed, None => { - return CommandResult::error( + return CommandResult::error_msg( "Unknown feedback type. Use `/feedback` to list feedback options.", ); } diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 83248e334..251a32717 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -28,7 +28,7 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { let (objective, budget) = parse_goal_budget(text); let objective = objective.trim().to_string(); if objective.is_empty() || objective.chars().all(|c| c == '|') { - return CommandResult::error("Usage: /goal [budget: N]"); + return CommandResult::error_msg("Usage: /goal [budget: N]"); } app.goal.goal_objective = Some(objective.clone()); app.goal.goal_token_budget = budget; diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/init.rs index 7e3027460..a04e7b475 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/init.rs @@ -31,7 +31,7 @@ pub fn init(app: &mut App) -> CommandResult { agents_path.display() )) } - Err(e) => CommandResult::error(format!("Failed to write AGENTS.md: {e}")), + Err(e) => CommandResult::error_msg(format!("Failed to write AGENTS.md: {e}")), } } diff --git a/crates/tui/src/commands/jobs.rs b/crates/tui/src/commands/jobs.rs index fa31dc31a..26eb7a3c3 100644 --- a/crates/tui/src/commands/jobs.rs +++ b/crates/tui/src/commands/jobs.rs @@ -21,14 +21,14 @@ pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show { id: id.to_string(), })), - None => CommandResult::error("Usage: /jobs show "), + None => CommandResult::error_msg("Usage: /jobs show "), }, "poll" | "wait" => match id { Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Poll { id: id.to_string(), wait: action == "wait", })), - None => CommandResult::error("Usage: /jobs poll "), + None => CommandResult::error_msg("Usage: /jobs poll "), }, "stdin" | "send" => match id { Some(id) if !rest.is_empty() => { @@ -38,7 +38,7 @@ pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { close: false, })) } - _ => CommandResult::error("Usage: /jobs stdin "), + _ => CommandResult::error_msg("Usage: /jobs stdin "), }, "close-stdin" | "eof" => match id { Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin { @@ -46,18 +46,18 @@ pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { input: String::new(), close: true, })), - None => CommandResult::error("Usage: /jobs close-stdin "), + None => CommandResult::error_msg("Usage: /jobs close-stdin "), }, "cancel" | "kill" | "stop" => match id { Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Cancel { id: id.to_string(), })), - None => CommandResult::error("Usage: /jobs cancel "), + None => CommandResult::error_msg("Usage: /jobs cancel "), }, "cancel-all" | "kill-all" | "stop-all" => { CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll)) } - _ => CommandResult::error( + _ => CommandResult::error_msg( "Usage: /jobs [list|show |poll |wait |stdin |close-stdin |cancel |cancel-all]", ), } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index ab8a7439b..5f20dced6 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -87,6 +87,16 @@ impl CommandResult { } } + /// Create a simple error message (English prefix) + #[allow(dead_code)] + pub fn error_msg(msg: impl Into) -> Self { + Self { + message: Some(msg.into()), + action: None, + is_error: true, + } + } + /// Create an error message result with a localized "Error:" prefix pub fn error(msg: impl Into, locale: Locale) -> Self { Self { @@ -743,7 +753,7 @@ pub use config::{ pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { let (max_depth, target) = match parse_depth_prefixed_arg(arg, 1) { Ok(parsed) => parsed, - Err(message) => return CommandResult::error(message), + Err(message) => return CommandResult::error_msg(message), }; let target = match target { Some(p) if !p.trim().is_empty() => p.trim().to_string(), @@ -776,12 +786,12 @@ pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { pub fn agent(_app: &mut App, arg: Option<&str>) -> CommandResult { let (max_depth, task) = match parse_depth_prefixed_arg(arg, 1) { Ok(parsed) => parsed, - Err(message) => return CommandResult::error(message), + Err(message) => return CommandResult::error_msg(message), }; let task = match task { Some(task) if !task.trim().is_empty() => task.trim().to_string(), _ => { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /agent [N] \n\n\ Opens a persistent sub-agent session with recursive agent depth N (0-3, default 1).", ); @@ -825,7 +835,7 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { let _ = writeln!(out); let _ = writeln!(out, "Current session snapshot:"); let _ = writeln!(out, "- Workspace: {}", app.workspace.display()); - let _ = writeln!(out, "- Mode: {}", app.mode.label()); + let _ = writeln!(out, "- Mode: {}", app.mode.label(app.ui_locale)); let _ = writeln!(out, "- Model: {}", app.model_display_label()); if let Some(focus) = focus { let _ = writeln!(out, "- Requested relay focus: {focus}"); diff --git a/crates/tui/src/commands/network.rs b/crates/tui/src/commands/network.rs index dbe0e7afe..5d43032ff 100644 --- a/crates/tui/src/commands/network.rs +++ b/crates/tui/src/commands/network.rs @@ -13,7 +13,7 @@ use crate::tui::app::App; pub fn network(_app: &mut App, arg: Option<&str>) -> CommandResult { match network_inner(arg) { Ok(message) => CommandResult::message(message), - Err(err) => CommandResult::error(err.to_string()), + Err(err) => CommandResult::error_msg(err.to_string()), } } diff --git a/crates/tui/src/commands/note.rs b/crates/tui/src/commands/note.rs index 6efe44134..c033b5137 100644 --- a/crates/tui/src/commands/note.rs +++ b/crates/tui/src/commands/note.rs @@ -14,12 +14,12 @@ pub fn note(app: &mut App, content: Option<&str>) -> CommandResult { let input = match content { Some(c) => c.trim(), None => { - return CommandResult::error(format!("Usage: {USAGE}")); + return CommandResult::error_msg(format!("Usage: {USAGE}")); } }; if input.is_empty() { - return CommandResult::error("Note content cannot be empty"); + return CommandResult::error_msg("Note content cannot be empty"); } let notes_path = notes_path(app); @@ -55,19 +55,19 @@ fn split_command(input: &str) -> (&str, Option<&str>) { fn append_note_command(notes_path: &Path, content: Option<&str>) -> CommandResult { let Some(note_content) = content.map(str::trim).filter(|content| !content.is_empty()) else { - return CommandResult::error("Usage: /note add "); + return CommandResult::error_msg("Usage: /note add "); }; match append_note(notes_path, note_content) { Ok(()) => CommandResult::message(format!("Note appended to {}", notes_path.display())), - Err(e) => CommandResult::error(e), + Err(e) => CommandResult::error_msg(e), } } fn list_notes_command(notes_path: &Path) -> CommandResult { let notes = match read_notes(notes_path) { Ok(notes) => notes, - Err(e) => return CommandResult::error(e), + Err(e) => return CommandResult::error_msg(e), }; if notes.is_empty() { @@ -84,11 +84,11 @@ fn list_notes_command(notes_path: &Path) -> CommandResult { fn show_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { let notes = match read_notes(notes_path) { Ok(notes) => notes, - Err(e) => return CommandResult::error(e), + Err(e) => return CommandResult::error_msg(e), }; let index = match parse_note_index(rest, notes.len(), "/note show ") { Ok(index) => index, - Err(e) => return CommandResult::error(e), + Err(e) => return CommandResult::error_msg(e), }; CommandResult::message(format!("Note {}:\n\n{}", index + 1, notes[index])) @@ -96,22 +96,22 @@ fn show_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { fn edit_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { let Some(rest) = rest else { - return CommandResult::error("Usage: /note edit "); + return CommandResult::error_msg("Usage: /note edit "); }; let (index_text, new_content) = match split_command(rest) { (index_text, Some(new_content)) if !new_content.trim().is_empty() => { (index_text, new_content.trim()) } - _ => return CommandResult::error("Usage: /note edit "), + _ => return CommandResult::error_msg("Usage: /note edit "), }; let mut notes = match read_notes(notes_path) { Ok(notes) => notes, - Err(e) => return CommandResult::error(e), + Err(e) => return CommandResult::error_msg(e), }; let index = match parse_note_index(Some(index_text), notes.len(), "/note edit ") { Ok(index) => index, - Err(e) => return CommandResult::error(e), + Err(e) => return CommandResult::error_msg(e), }; notes[index] = new_content.to_string(); @@ -121,18 +121,18 @@ fn edit_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { index + 1, notes_path.display() )), - Err(e) => CommandResult::error(e), + Err(e) => CommandResult::error_msg(e), } } fn remove_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { let mut notes = match read_notes(notes_path) { Ok(notes) => notes, - Err(e) => return CommandResult::error(e), + Err(e) => return CommandResult::error_msg(e), }; let index = match parse_note_index(rest, notes.len(), "/note remove ") { Ok(index) => index, - Err(e) => return CommandResult::error(e), + Err(e) => return CommandResult::error_msg(e), }; notes.remove(index); @@ -142,14 +142,14 @@ fn remove_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult { index + 1, notes_path.display() )), - Err(e) => CommandResult::error(e), + Err(e) => CommandResult::error_msg(e), } } fn clear_notes_command(notes_path: &Path) -> CommandResult { match write_notes(notes_path, &[]) { Ok(()) => CommandResult::message(format!("Notes cleared in {}", notes_path.display())), - Err(e) => CommandResult::error(e), + Err(e) => CommandResult::error_msg(e), } } diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 12f755493..0ff598926 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -27,7 +27,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let model_arg = parts.next(); let Some(target) = ApiProvider::parse(name) else { - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama." )); }; @@ -38,7 +38,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { Some(raw) => match normalize_model_name(&expand_model_alias(raw)) { Some(normalized) => Some(normalized), None => { - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro." )); } diff --git a/crates/tui/src/commands/rename.rs b/crates/tui/src/commands/rename.rs index e551cf61b..07416bb8f 100644 --- a/crates/tui/src/commands/rename.rs +++ b/crates/tui/src/commands/rename.rs @@ -16,17 +16,17 @@ const MAX_TITLE_LEN: usize = 100; pub fn rename(app: &mut App, arg: Option<&str>) -> CommandResult { let new_title = match arg.map(str::trim).filter(|s| !s.is_empty()) { Some(t) => t, - None => return CommandResult::error("Usage: /rename "), + None => return CommandResult::error_msg("Usage: /rename "), }; if new_title.chars().count() > MAX_TITLE_LEN { - return CommandResult::error(format!("Title too long (max {MAX_TITLE_LEN} characters)")); + return CommandResult::error_msg(format!("Title too long (max {MAX_TITLE_LEN} characters)")); } let session_id = match &app.current_session_id { Some(id) => id.clone(), None => { - return CommandResult::error( + return CommandResult::error_msg( "No active session. Send a message first to start a session.", ); } @@ -34,7 +34,7 @@ pub fn rename(app: &mut App, arg: Option<&str>) -> CommandResult { let manager = match SessionManager::default_location() { Ok(m) => m, - Err(e) => return CommandResult::error(format!("Could not open sessions directory: {e}")), + Err(e) => return CommandResult::error_msg(format!("Could not open sessions directory: {e}")), }; rename_with_manager(new_title, &session_id, &manager, app) @@ -48,7 +48,7 @@ fn rename_with_manager( ) -> CommandResult { let mut session = match manager.load_session(session_id) { Ok(s) => s, - Err(e) => return CommandResult::error(format!("Could not load session: {e}")), + Err(e) => return CommandResult::error_msg(format!("Could not load session: {e}")), }; // Sync with current App state to avoid overwriting unsaved messages. @@ -63,7 +63,7 @@ fn rename_with_manager( match manager.save_session(&session) { Ok(_) => CommandResult::message(format!("Session renamed to \"{new_title}\"")), - Err(e) => CommandResult::error(format!("Could not save session: {e}")), + Err(e) => CommandResult::error_msg(format!("Could not save session: {e}")), } } diff --git a/crates/tui/src/commands/review.rs b/crates/tui/src/commands/review.rs index 518d0ff59..7cc6cf28c 100644 --- a/crates/tui/src/commands/review.rs +++ b/crates/tui/src/commands/review.rs @@ -17,7 +17,7 @@ fn warnings_suffix(registry: &SkillRegistry) -> String { pub fn review(app: &mut App, args: Option<&str>) -> CommandResult { let target = args.unwrap_or("").trim(); if target.is_empty() { - return CommandResult::error("Usage: /review "); + return CommandResult::error_msg("Usage: /review "); } let skills_dir = app.skills_dir.clone(); @@ -40,7 +40,7 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult { Some(skill) => skill, None => { let global_display = global_dir.display(); - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "Review skill not found in {} or {}. Create ~/.codewhale/skills/review/SKILL.md.{}", skills_dir.display(), global_display, diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index c4a7af92c..2dd118d65 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -36,7 +36,7 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { &app.workspace, u64::from(app.session.total_tokens), app.system_prompt.as_ref(), - Some(app.mode.label()), + Some(app.mode.label(app.ui_locale)), ); app.sync_cost_to_metadata(&mut session.metadata); session.artifacts = app.session_artifacts.clone(); @@ -80,13 +80,13 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { /// Fork the active conversation into a new saved sibling session and switch to it. pub fn fork(app: &mut App) -> CommandResult { if app.api_messages.is_empty() { - return CommandResult::error("Nothing to fork. Send or load a message first."); + return CommandResult::error_msg("Nothing to fork. Send or load a message first."); } let manager = match crate::session_manager::SessionManager::default_location() { Ok(manager) => manager, Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); + return CommandResult::error_msg(format!("could not open sessions directory: {err}")); } }; @@ -101,13 +101,13 @@ pub fn fork(app: &mut App) -> CommandResult { &app.workspace, u64::from(app.session.total_tokens), app.system_prompt.as_ref(), - Some(app.mode.label()), + Some(app.mode.label(app.ui_locale)), ); app.sync_cost_to_metadata(&mut parent.metadata); parent.artifacts = app.session_artifacts.clone(); if let Err(err) = manager.save_session(&parent) { - return CommandResult::error(format!("Failed to save parent session: {err}")); + return CommandResult::error_msg(format!("Failed to save parent session: {err}")); } let mut forked = create_saved_session_with_mode( @@ -116,13 +116,13 @@ pub fn fork(app: &mut App) -> CommandResult { &app.workspace, u64::from(app.session.total_tokens), app.system_prompt.as_ref(), - Some(app.mode.label()), + Some(app.mode.label(app.ui_locale)), ); forked.metadata.copy_cost_from(&parent.metadata); forked.metadata.mark_forked_from(&parent.metadata); if let Err(err) = manager.save_session(&forked) { - return CommandResult::error(format!("Failed to save forked session: {err}")); + return CommandResult::error_msg(format!("Failed to save forked session: {err}")); } app.current_session_id = Some(forked.metadata.id.clone()); diff --git a/crates/tui/src/commands/share.rs b/crates/tui/src/commands/share.rs index 1d559f2b0..6c01bb11d 100644 --- a/crates/tui/src/commands/share.rs +++ b/crates/tui/src/commands/share.rs @@ -54,7 +54,7 @@ fn do_share(app: &mut App) -> CommandResult { // is what we share. let history_len = app.history.len(); let model = &app.model; - let mode = app.mode.label(); + let mode = app.mode.label(app.ui_locale); // Use an AppAction to signal the engine to perform the async work. CommandResult::with_message_and_action( diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index a8a4997f9..b088863b7 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -51,7 +51,7 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { // collide with skill names. Skill names that start with // `-` aren't allowed by the loader so this is safe. if trimmed.starts_with('-') || trimmed.split_whitespace().count() > 1 { - return CommandResult::error("Usage: /skills [--remote|sync|]"); + return CommandResult::error_msg("Usage: /skills [--remote|sync|]"); } prefix = Some(trimmed.to_ascii_lowercase()); } @@ -193,7 +193,7 @@ pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult { let raw = match name { Some(n) => n.trim(), None => { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /skill \n\nSubcommands:\n /skill install >\n /skill update \n /skill uninstall \n /skill trust ", ); } @@ -242,11 +242,11 @@ fn activate_skill(app: &mut App, name: &str) -> CommandResult { let warnings = render_skill_warnings(®istry); if available.is_empty() { - CommandResult::error(format!( + CommandResult::error_msg(format!( "Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}" )) } else { - CommandResult::error(format!( + CommandResult::error_msg(format!( "Skill '{}' not found.\n\nAvailable skills: {}{}", name, available.join(", "), @@ -260,13 +260,13 @@ fn activate_skill(app: &mut App, name: &str) -> CommandResult { fn install_skill(app: &mut App, spec: &str) -> CommandResult { if spec.is_empty() { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /skill install >", ); } let source = match InstallSource::parse(spec) { Ok(s) => s, - Err(err) => return CommandResult::error(format!("Invalid install source: {err}")), + Err(err) => return CommandResult::error_msg(format!("Invalid install source: {err}")), }; let skills_dir = app.skills_dir.clone(); let (network, max_size, registry_url) = installer_settings(app); @@ -293,12 +293,12 @@ fn install_skill(app: &mut App, spec: &str) -> CommandResult { )) } Ok(InstallOutcome::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) + CommandResult::error_msg(needs_approval_message(&host)) } Ok(InstallOutcome::NetworkDenied(host)) => { - CommandResult::error(network_denied_message(&host)) + CommandResult::error_msg(network_denied_message(&host)) } - Err(err) => CommandResult::error(format!("Install failed: {err:#}")), + Err(err) => CommandResult::error_msg(format!("Install failed: {err:#}")), } } @@ -306,7 +306,7 @@ fn install_skill(app: &mut App, spec: &str) -> CommandResult { fn update_skill(app: &mut App, name: &str) -> CommandResult { if name.is_empty() { - return CommandResult::error("Usage: /skill update "); + return CommandResult::error_msg("Usage: /skill update "); } let skills_dir = app.skills_dir.clone(); let (network, max_size, registry_url) = installer_settings(app); @@ -326,12 +326,12 @@ fn update_skill(app: &mut App, name: &str) -> CommandResult { path_or_default(&installed.path) )), Ok(UpdateResult::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) + CommandResult::error_msg(needs_approval_message(&host)) } Ok(UpdateResult::NetworkDenied(host)) => { - CommandResult::error(network_denied_message(&host)) + CommandResult::error_msg(network_denied_message(&host)) } - Err(err) => CommandResult::error(format!("Update failed: {err:#}")), + Err(err) => CommandResult::error_msg(format!("Update failed: {err:#}")), } } @@ -339,14 +339,14 @@ fn update_skill(app: &mut App, name: &str) -> CommandResult { fn uninstall_skill(app: &mut App, name: &str) -> CommandResult { if name.is_empty() { - return CommandResult::error("Usage: /skill uninstall "); + return CommandResult::error_msg("Usage: /skill uninstall "); } match install::uninstall(name, &app.skills_dir) { Ok(()) => { app.refresh_skill_cache(); CommandResult::message(format!("Removed skill '{name}'.")) } - Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")), + Err(err) => CommandResult::error_msg(format!("Uninstall failed: {err:#}")), } } @@ -354,13 +354,13 @@ fn uninstall_skill(app: &mut App, name: &str) -> CommandResult { fn trust_skill(app: &mut App, name: &str) -> CommandResult { if name.is_empty() { - return CommandResult::error("Usage: /skill trust "); + return CommandResult::error_msg("Usage: /skill trust "); } match install::trust(name, &app.skills_dir) { Ok(()) => CommandResult::message(format!( "Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/." )), - Err(err) => CommandResult::error(format!("Trust failed: {err:#}")), + Err(err) => CommandResult::error_msg(format!("Trust failed: {err:#}")), } } @@ -389,12 +389,12 @@ pub fn list_remote_skills(app: &mut App) -> CommandResult { CommandResult::message(out) } Ok(RegistryFetchResult::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) + CommandResult::error_msg(needs_approval_message(&host)) } Ok(RegistryFetchResult::Denied(host)) => { - CommandResult::error(network_denied_message(&host)) + CommandResult::error_msg(network_denied_message(&host)) } - Err(err) => CommandResult::error(format_registry_error("Failed to fetch registry", &err)), + Err(err) => CommandResult::error_msg(format_registry_error("Failed to fetch registry", &err)), } } @@ -414,9 +414,9 @@ fn sync_skills(app: &mut App) -> CommandResult { }); match result { - Ok(SyncResult::RegistryDenied(host)) => CommandResult::error(network_denied_message(&host)), + Ok(SyncResult::RegistryDenied(host)) => CommandResult::error_msg(network_denied_message(&host)), Ok(SyncResult::RegistryNeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) + CommandResult::error_msg(needs_approval_message(&host)) } Ok(SyncResult::Done { outcomes }) => { let total = outcomes.len(); @@ -460,7 +460,7 @@ fn sync_skills(app: &mut App) -> CommandResult { CommandResult::message(out) } - Err(err) => CommandResult::error(format_registry_error("Sync failed", &err)), + Err(err) => CommandResult::error_msg(format_registry_error("Sync failed", &err)), } } diff --git a/crates/tui/src/commands/status.rs b/crates/tui/src/commands/status.rs index fb1a7e6da..8a2deb3b5 100644 --- a/crates/tui/src/commands/status.rs +++ b/crates/tui/src/commands/status.rs @@ -33,7 +33,7 @@ fn format_status(app: &App) -> String { ), ); push_row(&mut out, "Directory:", &display_path(&app.workspace)); - push_row(&mut out, "Mode:", app.mode.label()); + push_row(&mut out, "Mode:", app.mode.label(app.ui_locale)); push_row(&mut out, "Permissions:", &permission_summary(app)); push_row(&mut out, "Project docs:", &project_docs(&app.workspace)); push_row( diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02737eb7a..008c3d3d8 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -54,6 +54,7 @@ use crate::tools::subagent::{ use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; use crate::tools::user_input::{UserInputRequest, UserInputResponse}; use crate::tools::{ToolContext, ToolRegistryBuilder}; +use crate::localization::Locale; use crate::tui::app::AppMode; use crate::utils::spawn_supervised; @@ -1063,7 +1064,7 @@ impl Engine { let fork_context_for_runtime = if self.config.features.enabled(Feature::Subagents) { let state = StructuredState::capture( - mode.label(), + mode.label(Locale::En), self.config.workspace.clone(), std::env::current_dir().ok(), &self.session.working_set, @@ -1716,7 +1717,7 @@ impl Engine { let seams = seam_mgr.collect_seam_texts(&self.session.messages).await; let state_text = { let s = StructuredState::capture( - mode.label(), + mode.label(Locale::En), self.config.workspace.clone(), std::env::current_dir().ok(), &self.session.working_set, @@ -1817,7 +1818,7 @@ impl Engine { // 3. Capture structured state. Locks are held only for the snapshot. let state = StructuredState::capture( - mode.label(), + mode.label(Locale::En), self.config.workspace.clone(), std::env::current_dir().ok(), &self.session.working_set, diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 3eeeb7e20..84317c591 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -588,6 +588,67 @@ pub enum MessageId { // ── Sidebar ────────────────────────────────────────── SidebarNoAgents, + + // ── Config Scope ────────────────────────────────────── + ConfigScopeSession, + ConfigScopeSaved, + ConfigFieldScope, + ConfigFieldCurrent, + ConfigFieldNew, + ConfigFieldHint, + ConfigEditCancelled, + + // ── App Mode ────────────────────────────────────────── + AppModeAgent, + AppModeYolo, + AppModePlan, + AppModeAgentDesc, + AppModeYoloDesc, + AppModePlanDesc, + VimModeNormal, + VimModeInsert, + VimModeVisual, + + // ── Onboarding Welcome ──────────────────────────────── + OnboardWelcomeTitle, + OnboardWelcomeSubtitle, + OnboardWelcomeDesc, + OnboardWelcomePressEnter, + OnboardWelcomeCtrlCExit, + + // ── Context Inspector ───────────────────────────────── + CtxInspectorTitle, + CtxInspectorModel, + CtxInspectorSession, + CtxInspectorTranscript, + CtxInspectorWorkspaceStatus, + CtxInspectorNotSampled, + CtxInspectorEmpty, + CtxInspectorSystemPrompt, + CtxInspectorStablePrefix, + CtxInspectorVolatileWorkingSet, + CtxInspectorNone, + CtxInspectorTotal, + CtxInspectorTextPromptLayers, + CtxInspectorSingleBlob, + CtxInspectorNoSystemPrompt, + CtxInspectorTip, + CtxInspectorReferences, + CtxInspectorMoreReferences, + CtxInspectorNoReferences, + CtxInspectorIncluded, + CtxInspectorAttached, + CtxInspectorNotIncluded, + CtxInspectorRecentTools, + CtxInspectorActive, + CtxInspectorNoToolActivity, + CtxInspectorOutputCaptured, + CtxInspectorNoOutputYet, + CtxInspectorPromptLayerCacheFriendly, + CtxInspectorPromptLayerChangesBySession, + CtxInspectorStatusCritical, + CtxInspectorStatusHigh, + CtxInspectorStatusOk, } #[allow(dead_code)] @@ -944,6 +1005,59 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::AgentStatusCancelled, MessageId::AgentStatusFailed, MessageId::SidebarNoAgents, + MessageId::ConfigScopeSession, + MessageId::ConfigScopeSaved, + MessageId::ConfigFieldScope, + MessageId::ConfigFieldCurrent, + MessageId::ConfigFieldNew, + MessageId::ConfigFieldHint, + MessageId::ConfigEditCancelled, + MessageId::AppModeAgent, + MessageId::AppModeYolo, + MessageId::AppModePlan, + MessageId::AppModeAgentDesc, + MessageId::AppModeYoloDesc, + MessageId::AppModePlanDesc, + MessageId::VimModeNormal, + MessageId::VimModeInsert, + MessageId::VimModeVisual, + MessageId::OnboardWelcomeTitle, + MessageId::OnboardWelcomeSubtitle, + MessageId::OnboardWelcomeDesc, + MessageId::OnboardWelcomePressEnter, + MessageId::OnboardWelcomeCtrlCExit, + MessageId::CtxInspectorTitle, + MessageId::CtxInspectorModel, + MessageId::CtxInspectorSession, + MessageId::CtxInspectorTranscript, + MessageId::CtxInspectorWorkspaceStatus, + MessageId::CtxInspectorNotSampled, + MessageId::CtxInspectorEmpty, + MessageId::CtxInspectorSystemPrompt, + MessageId::CtxInspectorStablePrefix, + MessageId::CtxInspectorVolatileWorkingSet, + MessageId::CtxInspectorNone, + MessageId::CtxInspectorTotal, + MessageId::CtxInspectorTextPromptLayers, + MessageId::CtxInspectorSingleBlob, + MessageId::CtxInspectorNoSystemPrompt, + MessageId::CtxInspectorTip, + MessageId::CtxInspectorReferences, + MessageId::CtxInspectorMoreReferences, + MessageId::CtxInspectorNoReferences, + MessageId::CtxInspectorIncluded, + MessageId::CtxInspectorAttached, + MessageId::CtxInspectorNotIncluded, + MessageId::CtxInspectorRecentTools, + MessageId::CtxInspectorActive, + MessageId::CtxInspectorNoToolActivity, + MessageId::CtxInspectorOutputCaptured, + MessageId::CtxInspectorNoOutputYet, + MessageId::CtxInspectorPromptLayerCacheFriendly, + MessageId::CtxInspectorPromptLayerChangesBySession, + MessageId::CtxInspectorStatusCritical, + MessageId::CtxInspectorStatusHigh, + MessageId::CtxInspectorStatusOk, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1634,6 +1748,59 @@ fn english(id: MessageId) -> &'static str { MessageId::AgentStatusCancelled => "cancelled", MessageId::AgentStatusFailed => "failed", MessageId::SidebarNoAgents => "No agents", + MessageId::ConfigScopeSession => "SESSION", + MessageId::ConfigScopeSaved => "SAVED", + MessageId::ConfigFieldScope => "Scope: ", + MessageId::ConfigFieldCurrent => "Current: ", + MessageId::ConfigFieldNew => "New: ", + MessageId::ConfigFieldHint => "Hint: ", + MessageId::ConfigEditCancelled => "Edit cancelled", + MessageId::AppModeAgent => "AGENT", + MessageId::AppModeYolo => "YOLO", + MessageId::AppModePlan => "PLAN", + MessageId::AppModeAgentDesc => "Agent mode - autonomous task execution with tools", + MessageId::AppModeYoloDesc => "YOLO mode - full tool access without approvals", + MessageId::AppModePlanDesc => "Plan mode - design before implementing", + MessageId::VimModeNormal => "-- NORMAL --", + MessageId::VimModeInsert => "-- INSERT --", + MessageId::VimModeVisual => "-- VISUAL --", + MessageId::OnboardWelcomeTitle => "codewhale", + MessageId::OnboardWelcomeSubtitle => "A focused terminal workspace for longer model sessions.", + MessageId::OnboardWelcomeDesc => "You'll add an API key, review trust for this directory, and then land in the chat.", + MessageId::OnboardWelcomePressEnter => "Press Enter to continue.", + MessageId::OnboardWelcomeCtrlCExit => "Ctrl+C exits at any point.", + MessageId::CtxInspectorTitle => "Session Context", + MessageId::CtxInspectorModel => "Model: {}", + MessageId::CtxInspectorSession => "Session: {}", + MessageId::CtxInspectorTranscript => "Transcript: {} cells, {} API messages", + MessageId::CtxInspectorWorkspaceStatus => "Workspace status: {}", + MessageId::CtxInspectorNotSampled => "not sampled yet", + MessageId::CtxInspectorEmpty => "(empty)", + MessageId::CtxInspectorSystemPrompt => "System Prompt Structure", + MessageId::CtxInspectorStablePrefix => " Stable prefix: {} block(s), ~{} tokens [cache-friendly]", + MessageId::CtxInspectorVolatileWorkingSet => " Volatile working set: 1 block, ~{} tokens [changes every turn]", + MessageId::CtxInspectorNone => "none", + MessageId::CtxInspectorTotal => " Total: {} block(s), ~{} tokens", + MessageId::CtxInspectorTextPromptLayers => " Text prompt layers: {} layer(s), ~{} tokens", + MessageId::CtxInspectorSingleBlob => " Single text blob (~{} tokens) [stable prefix only]", + MessageId::CtxInspectorNoSystemPrompt => " No system prompt set.", + MessageId::CtxInspectorTip => " Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. Keep the system prompt append-only to maximize reuse.", + MessageId::CtxInspectorReferences => "References", + MessageId::CtxInspectorMoreReferences => "- ... {} more reference(s)", + MessageId::CtxInspectorNoReferences => "- No file, directory, or media references recorded yet.", + MessageId::CtxInspectorIncluded => "included", + MessageId::CtxInspectorAttached => "attached", + MessageId::CtxInspectorNotIncluded => "not included", + MessageId::CtxInspectorRecentTools => "Recent Tools", + MessageId::CtxInspectorActive => "active", + MessageId::CtxInspectorNoToolActivity => "- No tool activity recorded yet.", + MessageId::CtxInspectorOutputCaptured => "output captured", + MessageId::CtxInspectorNoOutputYet => "no output yet", + MessageId::CtxInspectorPromptLayerCacheFriendly => "cache-friendly", + MessageId::CtxInspectorPromptLayerChangesBySession => "changes by session/turn", + MessageId::CtxInspectorStatusCritical => "critical", + MessageId::CtxInspectorStatusHigh => "high", + MessageId::CtxInspectorStatusOk => "ok", } } @@ -2180,6 +2347,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::AgentStatusCancelled => "キャンセル済み", MessageId::AgentStatusFailed => "失敗", MessageId::SidebarNoAgents => "エージェントなし", + _ => english(id), }) } @@ -2630,6 +2798,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::AgentStatusCancelled => "已取消", MessageId::AgentStatusFailed => "失败", MessageId::SidebarNoAgents => "无 Agent", + _ => english(id), }) } @@ -3184,6 +3353,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::AgentStatusCancelled => "cancelado", MessageId::AgentStatusFailed => "falhou", MessageId::SidebarNoAgents => "Sem agentes", + _ => english(id), }) } @@ -3577,6 +3747,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { } MessageId::OnboardTipsFooterEnter => "Presiona Enter", MessageId::OnboardTipsFooterAction => " para abrir el workspace", + _ => english(id), }) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f7cd2e115..2dce13f82 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -751,21 +751,21 @@ impl AppMode { } /// Short label used in the UI footer. - pub fn label(self) -> &'static str { + pub fn label(self, locale: Locale) -> &'static str { match self { - AppMode::Agent => "AGENT", - AppMode::Yolo => "YOLO", - AppMode::Plan => "PLAN", + AppMode::Agent => tr(locale, MessageId::AppModeAgent), + AppMode::Yolo => tr(locale, MessageId::AppModeYolo), + AppMode::Plan => tr(locale, MessageId::AppModePlan), } } #[allow(dead_code)] /// Description shown in help or onboarding text. - pub fn description(self) -> &'static str { + pub fn description(self, locale: Locale) -> &'static str { match self { - AppMode::Agent => "Agent mode - autonomous task execution with tools", - AppMode::Yolo => "YOLO mode - full tool access without approvals", - AppMode::Plan => "Plan mode - design before implementing", + AppMode::Agent => tr(locale, MessageId::AppModeAgentDesc), + AppMode::Yolo => tr(locale, MessageId::AppModeYoloDesc), + AppMode::Plan => tr(locale, MessageId::AppModePlanDesc), } } } @@ -845,11 +845,11 @@ pub enum VimMode { impl VimMode { /// Short status-bar label shown in the composer border. #[must_use] - pub fn label(self) -> &'static str { + pub fn label(self, locale: Locale) -> &'static str { match self { - Self::Normal => "-- NORMAL --", - Self::Insert => "-- INSERT --", - Self::Visual => "-- VISUAL --", + Self::Normal => tr(locale, MessageId::VimModeNormal), + Self::Insert => tr(locale, MessageId::VimModeInsert), + Self::Visual => tr(locale, MessageId::VimModeVisual), } } } @@ -2096,7 +2096,7 @@ 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(format!("Switched to {} mode", mode.label(self.ui_locale))); if entering_yolo { self.yolo_restore = Some(YoloRestoreState { @@ -2121,8 +2121,8 @@ impl App { // Execute mode change hooks let context = HookContext::new() - .with_mode(mode.label()) - .with_previous_mode(previous_mode.label()) + .with_mode(mode.label(self.ui_locale)) + .with_previous_mode(previous_mode.label(self.ui_locale)) .with_workspace(self.workspace.clone()) .with_model(&self.model); let _ = self.hooks.execute(HookEvent::ModeChange, &context); @@ -2172,7 +2172,7 @@ impl App { /// Create a hook context with common fields pre-populated pub fn base_hook_context(&self) -> HookContext { HookContext::new() - .with_mode(self.mode.label()) + .with_mode(self.mode.label(self.ui_locale)) .with_workspace(self.workspace.clone()) .with_model(&self.model) .with_session_id(self.hooks.session_id()) @@ -5643,7 +5643,7 @@ mod tests { assert_eq!(app.status_toasts.len(), 1); assert_eq!( app.status_toasts.back().expect("mode toast").text, - format!("Switched to {} mode", first_mode.label()) + format!("Switched to {} mode", first_mode.label(Locale::En)) ); app.set_mode(second_mode); @@ -5651,7 +5651,7 @@ mod tests { assert_eq!(app.status_toasts.len(), 1); assert_eq!( app.status_toasts.back().expect("mode toast").text, - format!("Switched to {} mode", second_mode.label()) + format!("Switched to {} mode", second_mode.label(Locale::En)) ); app.set_mode(third_mode); @@ -5659,7 +5659,7 @@ mod tests { assert_eq!(app.status_toasts.len(), 1); assert_eq!( app.status_toasts.back().expect("mode toast").text, - format!("Switched to {} mode", third_mode.label()) + format!("Switched to {} mode", third_mode.label(Locale::En)) ); } diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 92e3208ef..14528156f 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -26,7 +26,7 @@ //! happen *before* the view is constructed (see `tui/ui.rs`); this //! module always assumes the user is being asked. -use crate::localization::Locale; +use crate::localization::{Locale, MessageId, tr}; use crate::sandbox::SandboxPolicy; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; use crate::tui::widgets::{ApprovalWidget, ElevationWidget, Renderable}; @@ -691,28 +691,22 @@ pub enum ElevationOption { impl ElevationOption { /// Get the display label for this option. - pub fn label(&self) -> &'static str { + pub fn label(&self, locale: Locale) -> &'static str { match self { - ElevationOption::WithNetwork => "Allow outbound network", - ElevationOption::WithWriteAccess(_) => "Allow extra write access", - ElevationOption::FullAccess => "Full access (filesystem + network)", - ElevationOption::Abort => "Abort", + ElevationOption::WithNetwork => tr(locale, MessageId::ElevationOptionNetwork), + ElevationOption::WithWriteAccess(_) => tr(locale, MessageId::ElevationOptionWrite), + ElevationOption::FullAccess => tr(locale, MessageId::ElevationOptionFullAccess), + ElevationOption::Abort => tr(locale, MessageId::ElevationOptionAbort), } } /// Get a short description. - pub fn description(&self) -> &'static str { + pub fn description(&self, locale: Locale) -> &'static str { match self { - ElevationOption::WithNetwork => { - "Retry this tool call with outbound network access for downloads and HTTP requests" - } - ElevationOption::WithWriteAccess(_) => { - "Retry this tool call with additional writable filesystem scope" - } - ElevationOption::FullAccess => { - "Retry without sandbox limits; grants unrestricted filesystem and network access" - } - ElevationOption::Abort => "Cancel this tool execution", + ElevationOption::WithNetwork => tr(locale, MessageId::ElevationOptionNetworkDesc), + ElevationOption::WithWriteAccess(_) => tr(locale, MessageId::ElevationOptionWriteDesc), + ElevationOption::FullAccess => tr(locale, MessageId::ElevationOptionFullAccessDesc), + ElevationOption::Abort => tr(locale, MessageId::ElevationOptionAbortDesc), } } @@ -797,13 +791,15 @@ impl ElevationRequest { pub struct ElevationView { request: ElevationRequest, selected: usize, + locale: Locale, } impl ElevationView { - pub fn new(request: ElevationRequest) -> Self { + pub fn new(request: ElevationRequest, locale: Locale) -> Self { Self { request, selected: 0, + locale, } } @@ -839,6 +835,11 @@ impl ElevationView { pub fn selected(&self) -> usize { self.selected } + + /// Get the locale used for display. + pub fn locale(&self) -> Locale { + self.locale + } } impl ModalView for ElevationView { @@ -878,7 +879,7 @@ impl ModalView for ElevationView { } fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) { - let elevation_widget = ElevationWidget::new(&self.request, self.selected); + let elevation_widget = ElevationWidget::new(&self.request, self.selected, self.locale()); elevation_widget.render(area, buf); } } @@ -1560,7 +1561,7 @@ mod tests { fn test_elevation_view_initial_state() { let request = ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false); - let view = ElevationView::new(request); + let view = ElevationView::new(request, Locale::En); assert_eq!(view.selected, 0); } @@ -1568,7 +1569,7 @@ mod tests { fn test_elevation_view_keybindings() { let request = ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('n'))); assert!(matches!( @@ -1581,7 +1582,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('w'))); assert!(matches!( action, @@ -1593,7 +1594,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('f'))); assert!(matches!( action, @@ -1605,7 +1606,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Esc)); assert!(matches!( action, @@ -1617,7 +1618,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('a'))); assert!(matches!( action, @@ -1631,7 +1632,7 @@ mod tests { #[test] fn test_elevation_view_navigation() { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); assert_eq!(view.selected, 0); @@ -1651,7 +1652,7 @@ mod tests { #[test] fn test_elevation_view_enter_uses_selected_option() { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); view.handle_key(create_key_event(KeyCode::Down)); assert_eq!(view.selected, 1); @@ -1673,34 +1674,34 @@ mod tests { #[test] fn test_elevation_option_labels() { assert_eq!( - ElevationOption::WithNetwork.label(), + ElevationOption::WithNetwork.label(Locale::En), "Allow outbound network" ); assert_eq!( - ElevationOption::FullAccess.label(), + ElevationOption::FullAccess.label(Locale::En), "Full access (filesystem + network)" ); assert!( ElevationOption::WithWriteAccess(vec![]) - .label() + .label(Locale::En) .contains("write") ); - assert_eq!(ElevationOption::Abort.label(), "Abort"); + assert_eq!(ElevationOption::Abort.label(Locale::En), "Abort"); } #[test] fn test_elevation_option_descriptions() { assert!( ElevationOption::WithNetwork - .description() + .description(Locale::En) .contains("network") ); assert!( ElevationOption::FullAccess - .description() + .description(Locale::En) .contains("filesystem and network access") ); - assert!(ElevationOption::Abort.description().contains("Cancel")); + assert!(ElevationOption::Abort.description(Locale::En).contains("Cancel")); } #[test] diff --git a/crates/tui/src/tui/context_inspector.rs b/crates/tui/src/tui/context_inspector.rs index f141a7f13..6a62ff5fb 100644 --- a/crates/tui/src/tui/context_inspector.rs +++ b/crates/tui/src/tui/context_inspector.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; use std::fmt::Write; use crate::compaction::estimate_input_tokens_conservative; +use crate::localization::{Locale, MessageId, tr}; use crate::models::{ LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS, SystemPrompt, context_window_for_model, }; @@ -71,10 +72,10 @@ enum PromptLayerKind { } impl PromptLayerKind { - fn label(self) -> &'static str { + fn label(self, locale: Locale) -> &'static str { match self { - Self::Static => "cache-friendly", - Self::Dynamic => "changes by session/turn", + Self::Static => tr(locale, MessageId::CtxInspectorPromptLayerCacheFriendly), + Self::Dynamic => tr(locale, MessageId::CtxInspectorPromptLayerChangesBySession), } } } @@ -87,47 +88,60 @@ struct PromptTextLayer<'a> { } #[must_use] -pub fn build_context_inspector_text(app: &App) -> String { +pub fn build_context_inspector_text(app: &App, locale: Locale) -> String { let mut out = String::new(); let usage = context_usage(app); - let status = context_status(usage.2); + let status = context_status(usage.2, locale); - let _ = writeln!(out, "Session Context"); + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorTitle)); let _ = writeln!(out, "---------------"); - let _ = writeln!(out, "Model: {}", app.model); + let _ = writeln!( + out, + "{}", + tr(locale, MessageId::CtxInspectorModel).replace("{}", &app.model) + ); let _ = writeln!( out, "Workspace: {}", crate::utils::display_path(&app.workspace) ); if let Some(session_id) = app.current_session_id.as_deref() { - let _ = writeln!(out, "Session: {session_id}"); + let _ = writeln!( + out, + "{}", + tr(locale, MessageId::CtxInspectorSession).replace("{}", session_id) + ); } let (used, max, percent) = usage; let _ = writeln!( out, "Context: {status} - ~{used}/{max} tokens ({percent:.1}%)" ); + let tmpl_transcript = tr(locale, MessageId::CtxInspectorTranscript); let _ = writeln!( out, - "Transcript: {} cells, {} API messages", - app.history.len(), - app.api_messages.len() + "{}", + tmpl_transcript + .replacen("{}", &app.history.len().to_string(), 1) + .replace("{}", &app.api_messages.len().to_string()) ); + let workspace_status = app + .workspace_context + .as_deref() + .unwrap_or(tr(locale, MessageId::CtxInspectorNotSampled)); let _ = writeln!( out, - "Workspace status: {}", - app.workspace_context - .as_deref() - .unwrap_or("not sampled yet") + "{}", + tr(locale, MessageId::CtxInspectorWorkspaceStatus) + .replace("{}", workspace_status) ); let _ = writeln!(out); - push_system_prompt_structure(&mut out, app); + push_system_prompt_structure(&mut out, app, locale); let _ = writeln!(out); - push_references(&mut out, &app.session_context_references); + push_references(&mut out, &app.session_context_references, locale); let _ = writeln!(out); - push_tools(&mut out, app); + push_tools(&mut out, app, locale); out } @@ -142,25 +156,22 @@ fn context_usage(app: &App) -> (usize, u32, f64) { (used, max, percent) } -fn context_status(percent: f64) -> &'static str { +fn context_status(percent: f64, locale: Locale) -> &'static str { if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { - "critical" + tr(locale, MessageId::CtxInspectorStatusCritical) } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT { - "high" + tr(locale, MessageId::CtxInspectorStatusHigh) } else { - "ok" + tr(locale, MessageId::CtxInspectorStatusOk) } } /// Inspect the system prompt structure, split into cache-friendly stable /// prefix blocks and the volatile working-set tail block. -fn push_system_prompt_structure(out: &mut String, app: &App) { - let _ = writeln!(out, "System Prompt Structure"); +fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) { + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorSystemPrompt)); let _ = writeln!(out, "-----------------------"); - // Conservative token estimate: ~3 chars per token (consistent with - // compaction.rs internal helpers — replicated here to avoid depending - // on a private function). let text_tokens = |text: &str| text.chars().count().div_ceil(3); let total_est = match &app.system_prompt { @@ -186,27 +197,40 @@ fn push_system_prompt_structure(out: &mut String, app: &App) { .sum(); let working_tokens = working_block.map(|b| text_tokens(&b.text)).unwrap_or(0); + let tmpl_stable = tr(locale, MessageId::CtxInspectorStablePrefix); let _ = writeln!( out, - " Stable prefix: {stable_count} block(s), ~{stable_tokens} tokens [cache-friendly]" + "{}", + tmpl_stable + .replacen("{}", &stable_count.to_string(), 1) + .replace("{}", &stable_tokens.to_string()) ); if let Some(block) = working_block { + let tmpl_volatile = tr(locale, MessageId::CtxInspectorVolatileWorkingSet); let _ = writeln!( out, - " Volatile working set: 1 block, ~{working_tokens} tokens [changes every turn]" + "{}", + tmpl_volatile.replace("{}", &working_tokens.to_string()) ); let _ = writeln!( out, " First line: {}", - block.text.lines().next().unwrap_or("(empty)") + block.text.lines().next().unwrap_or(tr(locale, MessageId::CtxInspectorEmpty)) ); } else { - let _ = writeln!(out, " Volatile working set: none"); + let _ = writeln!( + out, + " Volatile working set: {}", + tr(locale, MessageId::CtxInspectorNone) + ); } + let tmpl_total = tr(locale, MessageId::CtxInspectorTotal); let _ = writeln!( out, - " Total: {} block(s), ~{total_est} tokens", - blocks.len() + "{}", + tmpl_total + .replacen("{}", &blocks.len().to_string(), 1) + .replace("{}", &total_est.to_string()) ); } Some(SystemPrompt::Text(text)) => { @@ -216,10 +240,13 @@ fn push_system_prompt_structure(out: &mut String, app: &App) { .first() .is_some_and(|layer| layer.name != "System prompt") { + let tmpl_layers = tr(locale, MessageId::CtxInspectorTextPromptLayers); let _ = writeln!( out, - " Text prompt layers: {} layer(s), ~{total_est} tokens", - layers.len() + "{}", + tmpl_layers + .replacen("{}", &layers.len().to_string(), 1) + .replace("{}", &total_est.to_string()) ); for layer in layers { let tokens = text_tokens(layer.body); @@ -228,27 +255,24 @@ fn push_system_prompt_structure(out: &mut String, app: &App) { " - {}: ~{} tokens [{}]", layer.name, tokens, - layer.kind.label() + layer.kind.label(locale) ); } } else { let _ = writeln!( out, - " Single text blob (~{total_est} tokens) [stable prefix only]" + "{}", + tr(locale, MessageId::CtxInspectorSingleBlob) + .replace("{}", &total_est.to_string()) ); } } None => { - let _ = writeln!(out, " No system prompt set."); + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorNoSystemPrompt)); } } - // Cache-economics hint - let _ = writeln!( - out, - " Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. \ - Volatile working-set changes break the cache only for the tail." - ); + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorTip)); } fn split_text_prompt_layers(text: &str) -> Vec> { @@ -287,8 +311,8 @@ fn split_text_prompt_layers(text: &str) -> Vec> { layers } -fn push_references(out: &mut String, references: &[SessionContextReference]) { - let _ = writeln!(out, "References"); +fn push_references(out: &mut String, references: &[SessionContextReference], locale: Locale) { + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorReferences)); let _ = writeln!(out, "----------"); let mut seen = HashSet::new(); @@ -305,7 +329,12 @@ fn push_references(out: &mut String, references: &[SessionContextReference]) { if rendered >= MAX_REFERENCE_ROWS { let remaining = references.len().saturating_sub(rendered); if remaining > 0 { - let _ = writeln!(out, "- ... {remaining} more reference(s)"); + let _ = writeln!( + out, + "{}", + tr(locale, MessageId::CtxInspectorMoreReferences) + .replace("{}", &remaining.to_string()) + ); } break; } @@ -316,12 +345,12 @@ fn push_references(out: &mut String, references: &[SessionContextReference]) { }; let state = if reference.included { if reference.expanded { - "included" + tr(locale, MessageId::CtxInspectorIncluded) } else { - "attached" + tr(locale, MessageId::CtxInspectorAttached) } } else { - "not included" + tr(locale, MessageId::CtxInspectorNotIncluded) }; let detail = reference .detail @@ -338,15 +367,12 @@ fn push_references(out: &mut String, references: &[SessionContextReference]) { } if rendered == 0 { - let _ = writeln!( - out, - "- No file, directory, or media references recorded yet." - ); + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorNoReferences)); } } -fn push_tools(out: &mut String, app: &App) { - let _ = writeln!(out, "Recent Tools"); +fn push_tools(out: &mut String, app: &App, locale: Locale) { + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorRecentTools)); let _ = writeln!(out, "------------"); let mut rows: Vec<(usize, &ToolDetailRecord)> = app @@ -358,7 +384,7 @@ fn push_tools(out: &mut String, app: &App) { let mut rendered = 0usize; for detail in app.active_tool_details.values() { - push_tool_row(out, "active", detail); + push_tool_row(out, tr(locale, MessageId::CtxInspectorActive), detail, locale); rendered += 1; if rendered >= MAX_TOOL_ROWS { return; @@ -369,12 +395,12 @@ fn push_tools(out: &mut String, app: &App) { .take(MAX_TOOL_ROWS.saturating_sub(rendered)) { let location = format!("cell {cell_idx}"); - push_tool_row(out, &location, detail); + push_tool_row(out, &location, detail, locale); rendered += 1; } if rendered == 0 { - let _ = writeln!(out, "- No tool activity recorded yet."); + let _ = writeln!(out, "{}", tr(locale, MessageId::CtxInspectorNoToolActivity)); } else { let _ = writeln!( out, @@ -383,11 +409,11 @@ fn push_tools(out: &mut String, app: &App) { } } -fn push_tool_row(out: &mut String, location: &str, detail: &ToolDetailRecord) { +fn push_tool_row(out: &mut String, location: &str, detail: &ToolDetailRecord, locale: Locale) { let output_state = if detail.output.as_deref().is_some_and(|out| !out.is_empty()) { - "output captured" + tr(locale, MessageId::CtxInspectorOutputCaptured) } else { - "no output yet" + tr(locale, MessageId::CtxInspectorNoOutputYet) }; let _ = writeln!( out, @@ -449,7 +475,7 @@ mod tests { #[test] fn inspector_formats_empty_state() { let app = test_app(); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("Session Context")); assert!(text.contains("No file, directory, or media references recorded yet.")); assert!(text.contains("No tool activity recorded yet.")); @@ -476,7 +502,7 @@ mod tests { }, }); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("[file] @src/main.rs -> /tmp/project/src/main.rs")); } @@ -491,14 +517,14 @@ mod tests { }], }); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("Context: critical"), "{text}"); } #[test] fn inspector_no_system_prompt_shows_section() { let app = test_app(); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("System Prompt Structure")); assert!(text.contains("No system prompt set.")); } @@ -520,7 +546,7 @@ mod tests { }, ])); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("System Prompt Structure")); assert!( text.contains("Stable prefix: 1 block"), @@ -561,7 +587,7 @@ mod tests { }, ])); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("Stable prefix: 2 block(s)")); assert!(text.contains("Volatile working set: none")); } @@ -573,7 +599,7 @@ mod tests { "You are CodeWhale.\n\n\nRules\n\n\n## Project Context Pack\n{}\n\n## Environment\n- lang: en\n\n## Skills\n- rust\n\n## Context Management\nKeep compact\n\n## Compact\nTemplate\n\n## Repo Working Set\nsrc/".to_string(), )); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("System Prompt Structure")); assert!(text.contains("Text prompt layers")); assert!(text.contains("Global system prefix")); @@ -592,7 +618,7 @@ mod tests { let mut app = test_app(); app.system_prompt = Some(SystemPrompt::Text("You are CodeWhale.".to_string())); - let text = build_context_inspector_text(&app); + let text = build_context_inspector_text(&app, Locale::En); assert!(text.contains("Single text blob")); assert!(text.contains("stable prefix only")); } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 868bfe671..7e228ff1e 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -274,7 +274,7 @@ impl HistoryCell { options.low_motion, ), HistoryCell::Tool(cell) if !options.show_tool_details => { - let mut lines = cell.lines_with_motion(width, options.low_motion); + let mut lines = cell.lines_with_locale(width, options.locale); if lines.len() > 2 { lines.truncate(2); lines.push(details_affordance_line( @@ -285,7 +285,7 @@ impl HistoryCell { lines } HistoryCell::Tool(cell) if options.calm_mode => { - let mut lines = cell.lines_with_motion(width, options.low_motion); + let mut lines = cell.render(width, options.low_motion, RenderMode::Live, options.locale); if lines.len() > TOOL_CARD_SUMMARY_LINES { lines.truncate(TOOL_CARD_SUMMARY_LINES); lines.push(details_affordance_line( @@ -295,7 +295,7 @@ impl HistoryCell { } lines } - HistoryCell::Tool(cell) => cell.lines_with_motion(width, options.low_motion), + HistoryCell::Tool(cell) => cell.render(width, options.low_motion, RenderMode::Live, options.locale), HistoryCell::User { content } => render_user_message(content, width), HistoryCell::Assistant { content, streaming } => render_message( ASSISTANT_GLYPH, @@ -660,28 +660,32 @@ impl ToolCell { } pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { - self.render(width, low_motion, RenderMode::Live) + self.render(width, low_motion, RenderMode::Live, Locale::En) } /// Full-content rendering for the pager / clipboard. Tool output that /// would be capped + suffixed with "Alt+V for details" in the live view /// is emitted in full here. pub fn transcript_lines(&self, width: u16) -> Vec> { - self.render(width, /*low_motion*/ false, RenderMode::Transcript) + self.render(width, /*low_motion*/ false, RenderMode::Transcript, Locale::En) } - fn render(&self, width: u16, low_motion: bool, mode: RenderMode) -> Vec> { + pub fn lines_with_locale(&self, width: u16, locale: Locale) -> Vec> { + self.render(width, false, RenderMode::Live, locale) + } + + fn render(&self, width: u16, low_motion: bool, mode: RenderMode, locale: Locale) -> Vec> { match self { - ToolCell::Exec(cell) => cell.render(width, low_motion, mode), - ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::PatchSummary(cell) => cell.render(width, low_motion, mode), - ToolCell::Review(cell) => cell.render(width, low_motion, mode), - ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::Mcp(cell) => cell.render(width, low_motion, mode), - ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::Generic(cell) => cell.lines_with_mode(width, low_motion, mode), + ToolCell::Exec(cell) => cell.render(width, low_motion, mode, locale), + ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion, locale), + ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion, locale), + ToolCell::PatchSummary(cell) => cell.render(width, low_motion, mode, locale), + ToolCell::Review(cell) => cell.render(width, low_motion, mode, locale), + ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion, locale), + ToolCell::Mcp(cell) => cell.render(width, low_motion, mode, locale), + ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion, locale), + ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion, locale), + ToolCell::Generic(cell) => cell.lines_with_mode(width, low_motion, mode, locale), } } } @@ -712,7 +716,7 @@ impl ExecCell { /// Render the execution cell into lines (live view, capped output). #[cfg(test)] pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { - self.render(width, low_motion, RenderMode::Live) + self.render(width, low_motion, RenderMode::Live, Locale::En) } pub(super) fn render( @@ -720,6 +724,7 @@ impl ExecCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); let command_summary = command_header_summary(&self.command); @@ -734,6 +739,7 @@ impl ExecCell { self.status, self.started_at, low_motion, + locale, )); if self.status == ToolStatus::Success && self.source == ExecSource::User { @@ -806,7 +812,7 @@ pub struct ExploringCell { impl ExploringCell { /// Render the exploring cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { let mut lines = Vec::new(); let all_done = self .entries @@ -825,6 +831,7 @@ impl ExploringCell { status, None, low_motion, + locale, )); for entry in &self.entries { @@ -868,7 +875,7 @@ pub struct PlanUpdateCell { impl PlanUpdateCell { /// Render the plan update cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( "Plan", @@ -876,6 +883,7 @@ impl PlanUpdateCell { self.status, None, low_motion, + locale, )); if let Some(explanation) = self.explanation.as_ref() { @@ -928,6 +936,7 @@ impl PatchSummaryCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header_with_summary( @@ -937,6 +946,7 @@ impl PatchSummaryCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "file", @@ -977,6 +987,7 @@ impl ReviewCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( @@ -985,6 +996,7 @@ impl ReviewCell { self.status, None, low_motion, + locale, )); if !self.target.trim().is_empty() { @@ -1106,7 +1118,7 @@ pub struct DiffPreviewCell { } impl DiffPreviewCell { - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { let mut lines = Vec::new(); let diff_summary = diff_render::diff_summary_label(&self.diff); lines.push(render_tool_header_with_summary( @@ -1116,6 +1128,7 @@ impl DiffPreviewCell { ToolStatus::Success, None, low_motion, + locale, )); lines.extend(render_compact_kv( "title", @@ -1143,6 +1156,7 @@ impl McpToolCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header_with_summary( @@ -1152,6 +1166,7 @@ impl McpToolCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "name", @@ -1189,7 +1204,7 @@ pub struct ViewImageCell { impl ViewImageCell { /// Render the image view cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { let path = self.path.display().to_string(); let mut lines = vec![render_tool_header_with_summary( "Image", @@ -1198,6 +1213,7 @@ impl ViewImageCell { ToolStatus::Success, None, low_motion, + locale, )]; lines.extend(render_compact_kv("path", &path, tool_value_style(), width)); lines @@ -1214,7 +1230,7 @@ pub struct WebSearchCell { impl WebSearchCell { /// Render the web search cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header_with_summary( "Search", @@ -1223,6 +1239,7 @@ impl WebSearchCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "query", @@ -1278,11 +1295,12 @@ impl GenericToolCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { // Issue #241: when the underlying tool is a checklist/todo update and // the output is parseable, render a purpose-built progress card // instead of dumping the JSON into the generic tool block. - if let Some(lines) = self.try_render_as_checklist(width, low_motion, mode) { + if let Some(lines) = self.try_render_as_checklist(width, low_motion, mode, locale) { return lines; } @@ -1296,7 +1314,7 @@ impl GenericToolCell { if matches!(mode, RenderMode::Live) && matches!(self.name.as_str(), "agent_open" | "agent_spawn") { - return self.render_agent_spawn_compact(low_motion); + return self.render_agent_spawn_compact(low_motion, locale); } let mut lines = Vec::new(); @@ -1316,6 +1334,7 @@ impl GenericToolCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "name", @@ -1364,6 +1383,7 @@ impl GenericToolCell { self.status, None, low_motion, + locale, )); lines.extend(diff_render::render_diff(output, width)); } else { @@ -1393,7 +1413,7 @@ impl GenericToolCell { /// `◐ delegate · agent_open agent-abc12 [running]` /// Falls back to a placeholder when the spawn is still pending and /// no agent id has been assigned yet. - fn render_agent_spawn_compact(&self, low_motion: bool) -> Vec> { + fn render_agent_spawn_compact(&self, low_motion: bool, locale: Locale) -> Vec> { let family = crate::tui::widgets::tool_card::ToolFamily::Delegate; let agent_id = self .output @@ -1407,6 +1427,7 @@ impl GenericToolCell { self.status, None, low_motion, + locale, )] } @@ -1418,6 +1439,7 @@ impl GenericToolCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Option>> { if !is_checklist_tool_name(&self.name) { return None; @@ -1442,6 +1464,7 @@ impl GenericToolCell { &change, width, low_motion, + locale, )); } @@ -1452,6 +1475,7 @@ impl GenericToolCell { width, low_motion, mode, + locale, )) } } @@ -1639,6 +1663,7 @@ fn render_checklist_change_card( change: &ChecklistChange, width: u16, low_motion: bool, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); let header_summary = format!( @@ -1653,6 +1678,7 @@ fn render_checklist_change_card( status, None, low_motion, + locale, )); // Look up the title from the snapshot. `id` in tool input is @@ -1728,6 +1754,7 @@ fn render_checklist_card( width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); let header_summary = format!( @@ -1742,6 +1769,7 @@ fn render_checklist_card( status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "checklist", @@ -2973,9 +3001,10 @@ fn render_tool_header( status: ToolStatus, started_at: Option, low_motion: bool, + locale: Locale, ) -> Line<'static> { let family = crate::tui::widgets::tool_card::tool_family_for_title(title); - render_tool_header_with_family(family, state, status, started_at, low_motion) + render_tool_header_with_family(family, state, status, started_at, low_motion, locale) } fn render_tool_header_with_summary( @@ -2985,10 +3014,11 @@ fn render_tool_header_with_summary( status: ToolStatus, started_at: Option, low_motion: bool, + locale: Locale, ) -> Line<'static> { let family = crate::tui::widgets::tool_card::tool_family_for_title(title); render_tool_header_with_family_and_summary( - family, summary, state, status, started_at, low_motion, + family, summary, state, status, started_at, low_motion, locale, ) } @@ -3001,8 +3031,9 @@ fn render_tool_header_with_family( status: ToolStatus, started_at: Option, low_motion: bool, + locale: Locale, ) -> Line<'static> { - render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion) + render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion, locale) } fn render_tool_header_with_family_and_summary( @@ -3012,6 +3043,7 @@ fn render_tool_header_with_family_and_summary( status: ToolStatus, started_at: Option, low_motion: bool, + locale: Locale, ) -> Line<'static> { // For long-running tools, append elapsed seconds so the user can see the // call isn't stuck. Threshold matches the eye's "did this hang?" reflex @@ -3026,7 +3058,7 @@ fn render_tool_header_with_family_and_summary( }; let glyph = crate::tui::widgets::tool_card::family_glyph(family); - let verb = crate::tui::widgets::tool_card::family_label(family); + let verb = crate::tui::widgets::tool_card::family_label_locale(family, locale); let mut spans = vec![ Span::styled( @@ -3338,11 +3370,15 @@ mod tests { running_status_label_with_elapsed, }; use crate::deepseek_theme::Theme; + use crate::localization::Locale; use crate::models::{ContentBlock, Message}; use crate::palette; use ratatui::style::Modifier; use std::time::{Duration, Instant}; + /// A string longer than the truncation threshold to exercise spillover paths. + const LONG_OUTPUT: &str = "a very long output that should be truncated in live mode because it exceeds the TOOL_TEXT_LIMIT of 180 characters which means this needs to be at least 181 characters to trigger the truncation logic and show the ellipsis truncation affordance to the user indicating there is more content available that was hidden."; + // ---- elapsed-seconds badge for long-running tools ---- // // Below 3s the label stays "running" — quick reads/greps shouldn't @@ -3371,37 +3407,34 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(120, true, super::RenderMode::Live); - let joined: String = lines - .iter() - .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) - .collect(); + let lines = cell.lines_with_mode(120, true, super::RenderMode::Live, Locale::En); assert!( - joined.contains("full output:"), - "expected annotation prefix: {joined:?}" + lines + .iter() + .any(|l| l.spans.iter().any(|s| s.content.contains("full output:"))), + "spillover annotation should include 'full output:' prefix" ); assert!( - joined.contains("/Users/dev/.deepseek/tool_outputs/call-abc12.txt"), - "expected the spillover path: {joined:?}" + lines + .iter() + .any(|l| l.spans.iter().any(|s| s.content.contains("call-abc12.txt"))), + "spillover annotation should include the path" ); } #[test] - fn render_spillover_annotation_omitted_in_transcript_mode() { - use std::path::PathBuf; - // Transcript mode is for replay; the full output is already - // inline so the annotation would just be redundant. + fn generic_tool_cell_transcript_renders_full_output_without_truncation() { let cell = GenericToolCell { - name: "exec_shell".to_string(), + name: "read_file".to_string(), status: ToolStatus::Success, input_summary: None, - output: Some("output".to_string()), + output: Some(LONG_OUTPUT.into()), prompts: None, - spillover_path: Some(PathBuf::from("/tmp/spill.txt")), + spillover_path: None, output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript); + let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript, Locale::En); let joined: String = lines .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) @@ -3425,7 +3458,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live, Locale::En); let joined: String = lines .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) @@ -3447,7 +3480,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(40, true, super::RenderMode::Live); + let lines = cell.lines_with_mode(40, true, super::RenderMode::Live, Locale::En); let annotation_line = lines .iter() .find(|l| { @@ -3524,7 +3557,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live, Locale::En); // One header line, no details/args/output expansion. assert_eq!(lines.len(), 1, "expected exactly 1 line, got {lines:?}"); let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); @@ -3559,7 +3592,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live, Locale::En); assert_eq!(lines.len(), 1); let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(rendered.contains('\u{2026}'), "{rendered:?}"); // … @@ -3581,7 +3614,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript, Locale::En); // Transcript mode emits header + name kv + (no args, output present) // + output rows. At minimum more than the live one-liner. assert!(lines.len() > 1, "expected verbose transcript render"); @@ -3601,7 +3634,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live, Locale::En); assert!( lines.len() > 1, "non-spawn tools should keep their full block" @@ -3689,7 +3722,8 @@ mod tests { &snapshot, &change, 80, - true, + false, + Locale::En, ); // Header + change line + summary affordance = 3 lines. assert!(lines.len() >= 3, "expected ≥3 lines, got {}", lines.len()); @@ -3751,7 +3785,8 @@ mod tests { &snapshot, &change, 80, - true, + false, + Locale::En, ); let change_line: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); assert!(change_line.contains("#99")); @@ -4277,7 +4312,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live, Locale::En); let header_visible: String = lines[0] .spans .iter() @@ -4306,7 +4341,7 @@ mod tests { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live, Locale::En); let header_visible: String = lines[0] .spans .iter() @@ -4435,7 +4470,7 @@ mod tests { status: ToolStatus::Running, }; - let lines = cell.lines_with_motion(80, true); + let lines = cell.lines_with_motion(80, true, Locale::En); // Header: " " (v0.6.6 layout). // PlanUpdate has no canonical family yet, so it falls into the 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..a505d18c7 100644 --- a/crates/tui/src/tui/onboarding/welcome.rs +++ b/crates/tui/src/tui/onboarding/welcome.rs @@ -3,12 +3,13 @@ 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> { vec![ Line::from(Span::styled( - "codewhale", + tr(locale, MessageId::OnboardWelcomeTitle), Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::BOLD), @@ -19,11 +20,11 @@ pub fn lines() -> Vec> { )), Line::from(""), Line::from(Span::styled( - "A focused terminal workspace for longer model sessions.", + tr(locale, MessageId::OnboardWelcomeSubtitle), 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::OnboardWelcomeDesc), Style::default().fg(palette::TEXT_MUTED), )), Line::from(Span::styled( @@ -32,11 +33,11 @@ pub fn lines() -> Vec> { )), Line::from(""), Line::from(Span::styled( - "Press Enter to continue.", + tr(locale, MessageId::OnboardWelcomePressEnter), Style::default().fg(palette::TEXT_PRIMARY), )), Line::from(Span::styled( - "Ctrl+C exits at any point.", + tr(locale, MessageId::OnboardWelcomeCtrlCExit), Style::default().fg(palette::TEXT_MUTED), )), ] diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 332e72c19..06fac0f83 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -1424,7 +1424,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { role_counts, }; let rows = sidebar_agent_rows(app); - let lines = subagent_panel_lines(&summary, &rows, content_width, usable_rows.max(1)); + let lines = subagent_panel_lines(&summary, &rows, content_width, usable_rows.max(1), app.ui_locale); render_sidebar_section(f, area, "Agents", lines, Vec::new(), app); } @@ -1489,7 +1489,7 @@ fn sidebar_agent_rows(app: &App) -> Vec { id: agent.agent_id.clone(), name: agent.nickname.clone().unwrap_or_else(|| agent.name.clone()), role: agent.agent_type.as_str().to_string(), - status: subagent_status_text(&agent.status).to_string(), + status: subagent_status_text(&agent.status, app.ui_locale).to_string(), progress, steps_taken: agent.steps_taken, duration_ms: Some(agent.duration_ms), @@ -1522,13 +1522,13 @@ fn sidebar_agent_rows(app: &App) -> Vec { rows } -fn subagent_status_text(status: &SubAgentStatus) -> &'static str { +fn subagent_status_text(status: &SubAgentStatus, locale: Locale) -> &'static str { match status { - SubAgentStatus::Running => "running", - SubAgentStatus::Completed => "done", - SubAgentStatus::Interrupted(_) => "interrupted", - SubAgentStatus::Failed(_) => "failed", - SubAgentStatus::Cancelled => "canceled", + SubAgentStatus::Running => tr(locale, MessageId::AgentLifecycleRunning), + SubAgentStatus::Completed => tr(locale, MessageId::AgentLifecycleDone), + SubAgentStatus::Interrupted(_) => tr(locale, MessageId::AgentStatusInterrupted), + SubAgentStatus::Failed(_) => tr(locale, MessageId::AgentLifecycleFailed), + SubAgentStatus::Cancelled => tr(locale, MessageId::AgentLifecycleCancelled), } } @@ -1539,6 +1539,7 @@ pub fn subagent_panel_lines( rows: &[SidebarAgentRow], content_width: usize, max_rows: usize, + locale: Locale, ) -> Vec> { let mut lines: Vec> = Vec::with_capacity(max_rows.max(4)); @@ -1549,7 +1550,7 @@ pub fn subagent_panel_lines( && !summary.foreground_rlm_running { lines.push(Line::from(Span::styled( - "No agents", + tr(locale, MessageId::SidebarNoAgents), Style::default().fg(palette::TEXT_MUTED), ))); return lines; @@ -1889,6 +1890,7 @@ mod tests { work_panel_empty_hint, work_panel_lines, }; use crate::config::Config; + use crate::localization::Locale; use crate::palette::PaletteMode; use crate::tools::plan::StepStatus; use crate::tools::todo::TodoStatus; @@ -2561,7 +2563,7 @@ mod tests { #[test] fn navigator_empty_state_says_no_agents() { let summary = SidebarSubagentSummary::default(); - let lines = subagent_panel_lines(&summary, &[], 32, 8); + let lines = subagent_panel_lines(&summary, &[], 32, 8, Locale::En); let text = lines_to_text(&lines); assert_eq!(text, vec!["No agents".to_string()]); } @@ -2601,7 +2603,7 @@ mod tests { duration_ms: Some(21_000), }, ]; - let text = lines_to_text(&subagent_panel_lines(&summary, &rows, 64, 12)); + let text = lines_to_text(&subagent_panel_lines(&summary, &rows, 64, 12, Locale::En)); assert!(text[0].contains("2 running"), "header: {:?}", text[0]); assert!(text[0].contains("/ 3"), "total in header: {:?}", text[0]); assert!( @@ -2632,7 +2634,7 @@ mod tests { role_counts: std::collections::BTreeMap::new(), }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8)); + let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8, Locale::En)); assert!(text[0].contains("1 running"), "header: {:?}", text[0]); assert!(text[0].contains("/ 6"), "fanout total: {:?}", text[0]); @@ -2651,7 +2653,7 @@ mod tests { foreground_rlm_running: false, role_counts, }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 32, 8)); + let text = lines_to_text(&subagent_panel_lines(&summary, &[], 32, 8, Locale::En)); assert!(text[0].contains("1 done"), "settled header: {:?}", text[0]); } @@ -2671,7 +2673,7 @@ mod tests { foreground_rlm_running: false, role_counts, }; - let lines = subagent_panel_lines(&summary, &[], 16, 8); + let lines = subagent_panel_lines(&summary, &[], 16, 8, Locale::En); let role_line: &str = lines[1] .spans .first() @@ -2689,7 +2691,7 @@ mod tests { foreground_rlm_running: true, ..SidebarSubagentSummary::default() }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8)); + let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8, Locale::En)); assert!(!text[0].contains("No agents"), "header: {text:?}"); assert!( diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 59fce4442..e9c39d43e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1920,7 +1920,7 @@ async fn run_event_loop( "tool_name": tool_name, "approval_key": approval_key, "session_id": app.current_session_id, - "mode": app.mode.label(), + "mode": app.mode.label(app.ui_locale), }), ); let _ = engine_handle.approve_tool_call(id.clone()).await; @@ -1930,7 +1930,7 @@ async fn run_event_loop( serde_json::json!({ "tool_name": tool_name, "session_id": app.current_session_id, - "mode": app.mode.label(), + "mode": app.mode.label(app.ui_locale), }), ); let _ = engine_handle.deny_tool_call(id.clone()).await; @@ -1962,7 +1962,7 @@ async fn run_event_loop( "tool_name": tool_name, "description": description, "session_id": app.current_session_id, - "mode": app.mode.label(), + "mode": app.mode.label(app.ui_locale), }), ); app.view_stack @@ -4619,7 +4619,7 @@ pub(crate) fn open_context_inspector(app: &mut App) { .last_transcript_area .map(|area| area.width) .unwrap_or(80); - let content = build_context_inspector_text(app); + let content = build_context_inspector_text(app, app.ui_locale); app.view_stack.push(PagerView::from_text( "Context inspector", &content, @@ -5768,6 +5768,7 @@ fn render(f: &mut Frame, app: &mut App) { workspace_name, app.is_loading, app.ui_theme.header_bg, + app.ui_locale, ) .with_usage( app.session.total_conversation_tokens, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4f0baa5bf..05835011a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5855,7 +5855,7 @@ fn checklist_write_renders_dedicated_card() { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live); + let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live, crate::localization::Locale::En); let text: Vec = lines .iter() .map(|line| { diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 357e340c5..d710940ae 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -505,10 +505,10 @@ enum ConfigScope { } impl ConfigScope { - fn label(self) -> &'static str { + fn label(self, locale: Locale) -> &'static str { match self { - ConfigScope::Session => "SESSION", - ConfigScope::Saved => "SAVED", + ConfigScope::Session => tr(locale, MessageId::ConfigScopeSession), + ConfigScope::Saved => tr(locale, MessageId::ConfigScopeSaved), } } @@ -846,7 +846,7 @@ impl ConfigView { let section = row.section.label(self.locale).to_lowercase(); let key = row.key.to_lowercase(); let value = row.value.to_lowercase(); - let scope = row.scope.label().to_lowercase(); + let scope = row.scope.label(self.locale).to_lowercase(); filter.split_whitespace().all(|term| { section.contains(term) @@ -979,7 +979,7 @@ impl ConfigView { match key.code { KeyCode::Esc => { self.editing = None; - self.status = Some("Edit cancelled".to_string()); + self.status = Some(self.tr(MessageId::ConfigEditCancelled).to_string()); ViewAction::None } KeyCode::Enter => { @@ -1152,7 +1152,7 @@ fn config_hint_for_key(key: &str) -> &'static str { } } -fn render_config_editor_value_line(edit: &ConfigEdit) -> ratatui::text::Line<'static> { +fn render_config_editor_value_line(edit: &ConfigEdit, locale: Locale) -> ratatui::text::Line<'static> { use ratatui::{ style::Style, text::{Line, Span}, @@ -1160,7 +1160,7 @@ fn render_config_editor_value_line(edit: &ConfigEdit) -> ratatui::text::Line<'st let mut spans = Vec::new(); spans.push(Span::styled( - "New: ", + tr(locale, MessageId::ConfigFieldNew), Style::default().fg(palette::TEXT_MUTED), )); @@ -1354,20 +1354,20 @@ impl ModalView for ConfigView { )])); lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("Scope: ", Style::default().fg(palette::TEXT_MUTED)), - Span::raw(edit.scope.label()), + Span::styled(self.tr(MessageId::ConfigFieldScope), Style::default().fg(palette::TEXT_MUTED)), + Span::raw(edit.scope.label(self.locale)), ])); lines.push(Line::from(vec![ - Span::styled("Current: ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled(self.tr(MessageId::ConfigFieldCurrent), Style::default().fg(palette::TEXT_MUTED)), Span::raw(truncate_view_text(&edit.original_value, 60)), ])); lines.push(Line::from("")); - lines.push(render_config_editor_value_line(edit)); + lines.push(render_config_editor_value_line(edit, self.locale)); lines.push(Line::from("")); let hint = config_hint_for_key(&edit.key); if !hint.is_empty() { lines.push(Line::from(vec![ - Span::styled("Hint: ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled(self.tr(MessageId::ConfigFieldHint), Style::default().fg(palette::TEXT_MUTED)), Span::raw(hint), ])); } @@ -1453,7 +1453,7 @@ impl ModalView for ConfigView { " {: &'static str { + fn label(self, locale: Locale) -> &'static str { match self { - Self::Pending => "pending", - Self::Running => "running", - Self::Completed => "done", - Self::Failed => "failed", - Self::Cancelled => "cancelled", + Self::Pending => tr(locale, MessageId::AgentLifecyclePending), + Self::Running => tr(locale, MessageId::AgentLifecycleRunning), + Self::Completed => tr(locale, MessageId::AgentLifecycleDone), + Self::Failed => tr(locale, MessageId::AgentLifecycleFailed), + Self::Cancelled => tr(locale, MessageId::AgentLifecycleCancelled), } } @@ -99,7 +100,7 @@ impl DelegateCard { } #[must_use] - pub fn render_lines(&self, _width: u16) -> Vec> { + pub fn render_lines(&self, _width: u16, locale: Locale) -> Vec> { let mut lines = Vec::with_capacity(self.actions.len() + 3); let role = readable_agent_role(&self.agent_type); let short_id = crate::session_manager::truncate_id(&self.agent_id).to_string(); @@ -113,6 +114,7 @@ impl DelegateCard { self.status, &role, &detail, + locale, )); if self.truncated { lines.push(Line::from(Span::styled( @@ -286,7 +288,7 @@ impl FanoutCard { } #[must_use] - pub fn render_lines(&self, _width: u16) -> Vec> { + pub fn render_lines(&self, _width: u16, locale: Locale) -> Vec> { let mut lines = Vec::with_capacity(3); let header_status = self.aggregate_status(); let title = format!("{} ({} workers)", self.kind, self.workers.len()); @@ -295,7 +297,7 @@ impl FanoutCard { } else { ToolFamily::Fanout }; - lines.push(card_header(family, header_status, &self.kind, &title)); + lines.push(card_header(family, header_status, &self.kind, &title, locale)); lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled( @@ -343,9 +345,10 @@ fn card_header( status: AgentLifecycle, role: &str, detail: &str, + locale: Locale, ) -> Line<'static> { let glyph = family_glyph(family); - let verb = family_label(family); + let verb = family_label_locale(family, locale); let header_color = status.color(); Line::from(vec![ Span::styled( @@ -355,7 +358,7 @@ fn card_header( .add_modifier(Modifier::BOLD), ), Span::styled( - verb.to_string(), + verb, Style::default() .fg(header_color) .add_modifier(Modifier::BOLD), @@ -364,7 +367,7 @@ fn card_header( Span::styled(role.to_string(), Style::default().fg(palette::TEXT_PRIMARY)), Span::raw(" "), Span::styled( - format!("[{}]", status.label()), + format!("[{}]", status.label(locale)), Style::default().fg(header_color), ), Span::raw(" "), @@ -495,6 +498,8 @@ pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool { #[cfg(test)] mod tests { + use crate::localization::Locale; + use super::*; fn render_to_strings(lines: &[Line<'static>]) -> Vec { @@ -528,7 +533,7 @@ mod tests { "stable steady-state size" ); - let rendered = render_to_strings(&card.render_lines(80)); + let rendered = render_to_strings(&card.render_lines(80, Locale::En)); assert!( rendered.iter().any(|line| line.contains('\u{2026}')), "ellipsis indicator must render: got {rendered:?}" @@ -562,7 +567,7 @@ mod tests { }; assert!(apply_to_delegate(&mut card, &msg)); assert_eq!(card.status, AgentLifecycle::Completed); - let rendered = render_to_strings(&card.render_lines(80)); + let rendered = render_to_strings(&card.render_lines(80, Locale::En)); assert!( rendered .iter() @@ -584,7 +589,7 @@ mod tests { "scheduler progress should not become a stale transcript row" ); - let rendered = render_to_strings(&card.render_lines(80)).join("\n"); + let rendered = render_to_strings(&card.render_lines(80, Locale::En)).join("\n"); assert!(!rendered.contains("step 1/100"), "{rendered}"); assert!( !rendered.contains("requesting model response"), @@ -614,7 +619,7 @@ mod tests { } )); - let rendered = render_to_strings(&card.render_lines(80)).join("\n"); + let rendered = render_to_strings(&card.render_lines(80, Locale::En)).join("\n"); assert!(rendered.contains("read_file"), "{rendered}"); assert!( !rendered.contains("[7]"), @@ -654,7 +659,7 @@ mod tests { card.upsert_worker("w_2", AgentLifecycle::Completed); card.upsert_worker("w_3", AgentLifecycle::Completed); card.upsert_worker("w_4", AgentLifecycle::Failed); - let rendered = render_to_strings(&card.render_lines(80)); + let rendered = render_to_strings(&card.render_lines(80, Locale::En)); // The stats row is the one carrying "running" too; the header may // mention "done" alone via the lifecycle status badge. let stats = rendered diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index 3c6804125..1264b4987 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -11,6 +11,7 @@ use ratatui::{ }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use crate::localization::Locale; use crate::palette; use crate::tui::app::AppMode; @@ -71,6 +72,7 @@ pub struct HeaderData<'a> { pub model: &'a str, pub workspace_name: &'a str, pub mode: AppMode, + pub locale: Locale, pub is_streaming: bool, pub background: ratatui::style::Color, /// Total tokens used in this session (cumulative, for display). @@ -107,11 +109,13 @@ impl<'a> HeaderData<'a> { workspace_name: &'a str, is_streaming: bool, background: ratatui::style::Color, + locale: Locale, ) -> Self { Self { model, workspace_name, mode, + locale, is_streaming, background, total_tokens: 0, @@ -184,12 +188,8 @@ impl<'a> HeaderWidget<'a> { } } - fn mode_name(mode: AppMode) -> &'static str { - match mode { - AppMode::Agent => "Agent", - AppMode::Yolo => "Yolo", - AppMode::Plan => "Plan", - } + fn mode_name(mode: AppMode, locale: Locale) -> &'static str { + mode.label(locale) } fn span_width(spans: &[Span<'_>]) -> usize { @@ -529,7 +529,7 @@ impl<'a> HeaderWidget<'a> { return Vec::new(); } - let mode_label = Self::mode_name(self.data.mode); + let mode_label = Self::mode_name(self.data.mode, self.data.locale); let mode_style = Style::default() .fg(Self::mode_color(self.data.mode)) .add_modifier(Modifier::BOLD); @@ -538,7 +538,7 @@ impl<'a> HeaderWidget<'a> { let fallback = self .data .mode - .label() + .label(self.data.locale) .chars() .next() .unwrap_or('?') @@ -600,6 +600,7 @@ impl Renderable for HeaderWidget<'_> { #[cfg(test)] mod tests { use super::{HeaderData, HeaderWidget, Renderable}; + use crate::localization::Locale; use crate::palette; use crate::tui::app::AppMode; use ratatui::{buffer::Buffer, layout::Rect}; @@ -622,15 +623,16 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ), 72, ); - assert!(rendered.contains("Agent")); + assert!(rendered.contains("AGENT")); assert!(rendered.contains("codewhale-tui")); assert!(rendered.contains("deepseek-v4-pro")); - assert!(!rendered.contains("Plan")); - assert!(!rendered.contains("Yolo")); + assert!(!rendered.contains("PLAN")); + assert!(!rendered.contains("YOLO")); } #[test] @@ -645,6 +647,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ), 120, ); @@ -666,6 +669,7 @@ mod tests { "codewhale-tui", true, palette::DEEPSEEK_INK, + Locale::En, ) .with_usage(1_000, Some(128_000), 0.0, Some(2_000)), 12, @@ -676,7 +680,7 @@ mod tests { "version chip should drop under width pressure: {rendered:?}", ); assert!( - rendered.contains("Yolo") || rendered.contains('Y'), + rendered.contains("YOLO") || rendered.contains('Y'), "mode label must survive: {rendered:?}", ); } @@ -690,6 +694,7 @@ mod tests { "workspace", true, palette::DEEPSEEK_INK, + Locale::En, ) .with_usage(42_000, Some(128_000), 0.0, Some(48_000)), 72, @@ -703,7 +708,7 @@ mod tests { #[test] fn narrow_header_keeps_context_percent_visible() { let rendered = render_header( - HeaderData::new(AppMode::Agent, "", "", true, palette::DEEPSEEK_INK).with_usage( + HeaderData::new(AppMode::Agent, "", "", true, palette::DEEPSEEK_INK, Locale::En).with_usage( 0, Some(128_000), 0.0, @@ -724,14 +729,15 @@ mod tests { "repo", true, palette::DEEPSEEK_INK, + Locale::En, ) .with_usage(1_000, Some(10_000), 0.0, Some(4_000)), 8, ); assert!(rendered.trim_start().starts_with('Y')); - assert!(!rendered.contains("Plan")); - assert!(!rendered.contains("Agent")); + assert!(!rendered.contains("PLAN")); + assert!(!rendered.contains("AGENT")); } #[test] @@ -743,6 +749,7 @@ mod tests { "repo", false, palette::DEEPSEEK_INK, + Locale::En, ), 48, ); @@ -760,6 +767,7 @@ mod tests { "repo", false, palette::DEEPSEEK_INK, + Locale::En, ) .with_usage(1_000, Some(128_000), 0.0, Some(320_000)), 48, @@ -778,6 +786,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ) .with_provider(Some("NIM")), 72, @@ -797,6 +806,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ), 72, ); @@ -862,6 +872,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ) .with_reasoning_effort(Some("max")) .with_status_indicator(Some("🐳")), @@ -893,6 +904,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ) .with_reasoning_effort(Some("max")) .with_status_indicator(None), diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 4cc7cbf34..2cf993adf 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1497,11 +1497,12 @@ fn option_abort(locale: Locale) -> &'static str { pub struct ElevationWidget<'a> { request: &'a ElevationRequest, selected: usize, + locale: Locale, } impl<'a> ElevationWidget<'a> { - pub fn new(request: &'a ElevationRequest, selected: usize) -> Self { - Self { request, selected } + pub fn new(request: &'a ElevationRequest, selected: usize, locale: Locale) -> Self { + Self { request, selected, locale } } } @@ -1624,12 +1625,12 @@ impl Renderable for ElevationWidget<'_> { format!("[{key}] "), Style::default().fg(palette::STATUS_SUCCESS), ), - Span::styled(option.label(), style.fg(label_color)), + Span::styled(option.label(self.locale), style.fg(label_color)), ])); lines.push(Line::from(vec![ Span::raw(" "), Span::styled( - option.description(), + option.description(self.locale), Style::default().fg(palette::TEXT_MUTED), ), ])); @@ -1879,7 +1880,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(app.ui_locale); let vim_width = UnicodeWidthStr::width(vim_label); let sep_width = UnicodeWidthStr::width(" · "); if vim_width + sep_width + 4 <= max_width { @@ -1904,7 +1905,7 @@ 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(app.ui_locale), max_width), vim_mode_style(app.composer.vim_mode), )); } @@ -3021,6 +3022,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(); @@ -3044,6 +3046,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(); From 2d6b7357957c398ac4d24626f4027dd44669d5b0 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 27 May 2026 09:12:46 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix:=20rebase=20conflict=20=E2=80=94=20us?= =?UTF-8?q?e=20error=5Fmsg()=20for=20upstream's=20new=201-arg=20error()=20?= =?UTF-8?q?calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/commands/config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 6d93e3503..8c19c7605 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -129,7 +129,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { { Ok(config) => config, Err(err) => { - return CommandResult::error(format!("Failed to load config: {err}")); + return CommandResult::error_msg(format!("Failed to load config: {err}")); } }; Some(config.deepseek_base_url()) @@ -460,7 +460,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "base_url" => { let value = value.trim(); if value.is_empty() { - return CommandResult::error("base_url cannot be empty"); + return CommandResult::error_msg("base_url cannot be empty"); } if persist { match persist_root_string_key(app.config_path.as_deref(), "base_url", value) { @@ -470,10 +470,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> path.display() )); } - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + Err(err) => return CommandResult::error_msg(format!("Failed to save: {err}")), } } - return CommandResult::error(format!( + return CommandResult::error_msg(format!( "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." )); } From 51e00c1a8d6e04a169549ff561c3556fcd8a728c Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 27 May 2026 09:18:19 +0800 Subject: [PATCH 10/15] chore: remove unused Locale/Config imports to fix CI -Dwarnings --- crates/tui/src/commands/core.rs | 2 +- crates/tui/src/commands/provider.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index d71a9b289..8d79cb8a0 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -4,7 +4,7 @@ use std::fmt::Write; use std::path::PathBuf; use crate::config::{ApiProvider, COMMON_DEEPSEEK_MODELS, normalize_model_name_for_provider}; -use crate::localization::{Locale, MessageId, tr}; +use crate::localization::{MessageId, tr}; use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort}; use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents}; diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 0ff598926..5a6043024 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -4,8 +4,7 @@ //! `/provider` with no args opens the picker modal (#52). `/provider ` //! keeps the v0.6.6 CLI form for muscle-memory + scripted use. -use crate::config::{ApiProvider, Config, normalize_model_name, provider_passes_model_through}; -use crate::localization::Locale; +use crate::config::{ApiProvider, normalize_model_name, provider_passes_model_through}; use crate::tui::app::{App, AppAction}; use super::CommandResult; From 08ede38db118c7e569965a7437ec9119e9efbe4c Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 27 May 2026 09:39:36 +0800 Subject: [PATCH 11/15] fix: clippy + dead_code warnings under --all-features - Move ApiProvider import to cfg(test) block in core.rs, debug.rs - Move CostCurrency import to cfg(test) block in debug.rs - Remove dead family_label function, replace test call with family_label_locale --- crates/tui/src/commands/core.rs | 12 +++-- crates/tui/src/commands/debug.rs | 5 +- crates/tui/src/commands/rename.rs | 8 +++- crates/tui/src/commands/skills.rs | 8 +++- crates/tui/src/core/engine.rs | 2 +- crates/tui/src/localization.rs | 24 +++++++--- crates/tui/src/tui/approval.rs | 6 ++- crates/tui/src/tui/context_inspector.rs | 16 +++++-- crates/tui/src/tui/history.rs | 61 ++++++++++++++++++++---- crates/tui/src/tui/sidebar.rs | 8 +++- crates/tui/src/tui/ui/tests.rs | 7 ++- crates/tui/src/tui/views/mod.rs | 20 ++++++-- crates/tui/src/tui/widgets/agent_card.rs | 8 +++- crates/tui/src/tui/widgets/header.rs | 15 +++--- crates/tui/src/tui/widgets/mod.rs | 6 ++- crates/tui/src/tui/widgets/tool_card.rs | 22 ++------- 16 files changed, 162 insertions(+), 66 deletions(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 8d79cb8a0..656ab5cc5 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -3,7 +3,7 @@ use std::fmt::Write; use std::path::PathBuf; -use crate::config::{ApiProvider, COMMON_DEEPSEEK_MODELS, normalize_model_name_for_provider}; +use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name_for_provider}; use crate::localization::{MessageId, tr}; use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort}; use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents}; @@ -167,7 +167,8 @@ pub fn models(_app: &mut App) -> CommandResult { pub fn subagents(app: &mut App) -> CommandResult { if app.view_stack.top_kind() != Some(ModalKind::SubAgents) { let agents = subagent_view_agents(app, &app.subagent_cache); - app.view_stack.push(SubAgentsView::new(agents, app.ui_locale)); + app.view_stack + .push(SubAgentsView::new(agents, app.ui_locale)); } app.status_message = Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string()); CommandResult::action(AppAction::ListSubAgents) @@ -207,7 +208,10 @@ pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { }; if !candidate.exists() { - return CommandResult::error_msg(format!("Workspace does not exist: {}", candidate.display())); + return CommandResult::error_msg(format!( + "Workspace does not exist: {}", + candidate.display() + )); } if !candidate.is_dir() { return CommandResult::error_msg(format!( @@ -379,7 +383,7 @@ pub fn translate(app: &mut App) -> CommandResult { mod tests { use super::*; use crate::client::PromptInspection; - use crate::config::Config; + use crate::config::{ApiProvider, Config}; use crate::localization::Locale; use crate::models::Message; use crate::tui::app::{App, AppMode, TuiOptions, TurnCacheRecord}; diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 87746632e..8379ba236 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -7,10 +7,8 @@ use std::time::Instant; use super::CommandResult; use crate::client::{PromptInspection, inspect_prompt_for_request}; use crate::compaction::estimate_input_tokens_conservative; -use crate::config::ApiProvider; use crate::localization::{Locale, MessageId, tr}; use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model}; -use crate::pricing::CostCurrency; use crate::tui::app::{App, AppAction, TurnCacheRecord}; use crate::tui::history::HistoryCell; @@ -423,8 +421,9 @@ fn humanize_age(d: std::time::Duration) -> String { #[cfg(test)] mod tests { use super::*; - use crate::config::Config; + use crate::config::{ApiProvider, Config}; use crate::models::{ContentBlock, Message, SystemBlock}; + use crate::pricing::CostCurrency; use crate::tui::app::{App, TuiOptions}; use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus}; use std::path::PathBuf; diff --git a/crates/tui/src/commands/rename.rs b/crates/tui/src/commands/rename.rs index 07416bb8f..adbce69a7 100644 --- a/crates/tui/src/commands/rename.rs +++ b/crates/tui/src/commands/rename.rs @@ -20,7 +20,9 @@ pub fn rename(app: &mut App, arg: Option<&str>) -> CommandResult { }; if new_title.chars().count() > MAX_TITLE_LEN { - return CommandResult::error_msg(format!("Title too long (max {MAX_TITLE_LEN} characters)")); + return CommandResult::error_msg(format!( + "Title too long (max {MAX_TITLE_LEN} characters)" + )); } let session_id = match &app.current_session_id { @@ -34,7 +36,9 @@ pub fn rename(app: &mut App, arg: Option<&str>) -> CommandResult { let manager = match SessionManager::default_location() { Ok(m) => m, - Err(e) => return CommandResult::error_msg(format!("Could not open sessions directory: {e}")), + Err(e) => { + return CommandResult::error_msg(format!("Could not open sessions directory: {e}")); + } }; rename_with_manager(new_title, &session_id, &manager, app) diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index b088863b7..c7b097a8a 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -394,7 +394,9 @@ pub fn list_remote_skills(app: &mut App) -> CommandResult { Ok(RegistryFetchResult::Denied(host)) => { CommandResult::error_msg(network_denied_message(&host)) } - Err(err) => CommandResult::error_msg(format_registry_error("Failed to fetch registry", &err)), + Err(err) => { + CommandResult::error_msg(format_registry_error("Failed to fetch registry", &err)) + } } } @@ -414,7 +416,9 @@ fn sync_skills(app: &mut App) -> CommandResult { }); match result { - Ok(SyncResult::RegistryDenied(host)) => CommandResult::error_msg(network_denied_message(&host)), + Ok(SyncResult::RegistryDenied(host)) => { + CommandResult::error_msg(network_denied_message(&host)) + } Ok(SyncResult::RegistryNeedsApproval(host)) => { CommandResult::error_msg(needs_approval_message(&host)) } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 008c3d3d8..ef94ba180 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -33,6 +33,7 @@ use crate::cycle_manager::{ use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, StreamError}; use crate::features::{Feature, Features}; use crate::llm_client::LlmClient; +use crate::localization::Locale; use crate::mcp::McpPool; #[cfg(test)] use crate::models::ToolCaller; @@ -54,7 +55,6 @@ use crate::tools::subagent::{ use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; use crate::tools::user_input::{UserInputRequest, UserInputResponse}; use crate::tools::{ToolContext, ToolRegistryBuilder}; -use crate::localization::Locale; use crate::tui::app::AppMode; use crate::utils::spawn_supervised; diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 84317c591..3a89bc999 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1765,8 +1765,12 @@ fn english(id: MessageId) -> &'static str { MessageId::VimModeInsert => "-- INSERT --", MessageId::VimModeVisual => "-- VISUAL --", MessageId::OnboardWelcomeTitle => "codewhale", - MessageId::OnboardWelcomeSubtitle => "A focused terminal workspace for longer model sessions.", - MessageId::OnboardWelcomeDesc => "You'll add an API key, review trust for this directory, and then land in the chat.", + MessageId::OnboardWelcomeSubtitle => { + "A focused terminal workspace for longer model sessions." + } + MessageId::OnboardWelcomeDesc => { + "You'll add an API key, review trust for this directory, and then land in the chat." + } MessageId::OnboardWelcomePressEnter => "Press Enter to continue.", MessageId::OnboardWelcomeCtrlCExit => "Ctrl+C exits at any point.", MessageId::CtxInspectorTitle => "Session Context", @@ -1777,17 +1781,25 @@ fn english(id: MessageId) -> &'static str { MessageId::CtxInspectorNotSampled => "not sampled yet", MessageId::CtxInspectorEmpty => "(empty)", MessageId::CtxInspectorSystemPrompt => "System Prompt Structure", - MessageId::CtxInspectorStablePrefix => " Stable prefix: {} block(s), ~{} tokens [cache-friendly]", - MessageId::CtxInspectorVolatileWorkingSet => " Volatile working set: 1 block, ~{} tokens [changes every turn]", + MessageId::CtxInspectorStablePrefix => { + " Stable prefix: {} block(s), ~{} tokens [cache-friendly]" + } + MessageId::CtxInspectorVolatileWorkingSet => { + " Volatile working set: 1 block, ~{} tokens [changes every turn]" + } MessageId::CtxInspectorNone => "none", MessageId::CtxInspectorTotal => " Total: {} block(s), ~{} tokens", MessageId::CtxInspectorTextPromptLayers => " Text prompt layers: {} layer(s), ~{} tokens", MessageId::CtxInspectorSingleBlob => " Single text blob (~{} tokens) [stable prefix only]", MessageId::CtxInspectorNoSystemPrompt => " No system prompt set.", - MessageId::CtxInspectorTip => " Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. Keep the system prompt append-only to maximize reuse.", + MessageId::CtxInspectorTip => { + " Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. Keep the system prompt append-only to maximize reuse." + } MessageId::CtxInspectorReferences => "References", MessageId::CtxInspectorMoreReferences => "- ... {} more reference(s)", - MessageId::CtxInspectorNoReferences => "- No file, directory, or media references recorded yet.", + MessageId::CtxInspectorNoReferences => { + "- No file, directory, or media references recorded yet." + } MessageId::CtxInspectorIncluded => "included", MessageId::CtxInspectorAttached => "attached", MessageId::CtxInspectorNotIncluded => "not included", diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 14528156f..15d600d26 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -1701,7 +1701,11 @@ mod tests { .description(Locale::En) .contains("filesystem and network access") ); - assert!(ElevationOption::Abort.description(Locale::En).contains("Cancel")); + assert!( + ElevationOption::Abort + .description(Locale::En) + .contains("Cancel") + ); } #[test] diff --git a/crates/tui/src/tui/context_inspector.rs b/crates/tui/src/tui/context_inspector.rs index 6a62ff5fb..372839ce7 100644 --- a/crates/tui/src/tui/context_inspector.rs +++ b/crates/tui/src/tui/context_inspector.rs @@ -132,8 +132,7 @@ pub fn build_context_inspector_text(app: &App, locale: Locale) -> String { let _ = writeln!( out, "{}", - tr(locale, MessageId::CtxInspectorWorkspaceStatus) - .replace("{}", workspace_status) + tr(locale, MessageId::CtxInspectorWorkspaceStatus).replace("{}", workspace_status) ); let _ = writeln!(out); @@ -215,7 +214,11 @@ fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) { let _ = writeln!( out, " First line: {}", - block.text.lines().next().unwrap_or(tr(locale, MessageId::CtxInspectorEmpty)) + block + .text + .lines() + .next() + .unwrap_or(tr(locale, MessageId::CtxInspectorEmpty)) ); } else { let _ = writeln!( @@ -384,7 +387,12 @@ fn push_tools(out: &mut String, app: &App, locale: Locale) { let mut rendered = 0usize; for detail in app.active_tool_details.values() { - push_tool_row(out, tr(locale, MessageId::CtxInspectorActive), detail, locale); + push_tool_row( + out, + tr(locale, MessageId::CtxInspectorActive), + detail, + locale, + ); rendered += 1; if rendered >= MAX_TOOL_ROWS { return; diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 7e228ff1e..ba97a3f4f 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -285,7 +285,8 @@ impl HistoryCell { lines } HistoryCell::Tool(cell) if options.calm_mode => { - let mut lines = cell.render(width, options.low_motion, RenderMode::Live, options.locale); + let mut lines = + cell.render(width, options.low_motion, RenderMode::Live, options.locale); if lines.len() > TOOL_CARD_SUMMARY_LINES { lines.truncate(TOOL_CARD_SUMMARY_LINES); lines.push(details_affordance_line( @@ -295,7 +296,9 @@ impl HistoryCell { } lines } - HistoryCell::Tool(cell) => cell.render(width, options.low_motion, RenderMode::Live, options.locale), + HistoryCell::Tool(cell) => { + cell.render(width, options.low_motion, RenderMode::Live, options.locale) + } HistoryCell::User { content } => render_user_message(content, width), HistoryCell::Assistant { content, streaming } => render_message( ASSISTANT_GLYPH, @@ -667,14 +670,25 @@ impl ToolCell { /// would be capped + suffixed with "Alt+V for details" in the live view /// is emitted in full here. pub fn transcript_lines(&self, width: u16) -> Vec> { - self.render(width, /*low_motion*/ false, RenderMode::Transcript, Locale::En) + self.render( + width, + /*low_motion*/ false, + RenderMode::Transcript, + Locale::En, + ) } pub fn lines_with_locale(&self, width: u16, locale: Locale) -> Vec> { self.render(width, false, RenderMode::Live, locale) } - fn render(&self, width: u16, low_motion: bool, mode: RenderMode, locale: Locale) -> Vec> { + fn render( + &self, + width: u16, + low_motion: bool, + mode: RenderMode, + locale: Locale, + ) -> Vec> { match self { ToolCell::Exec(cell) => cell.render(width, low_motion, mode, locale), ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion, locale), @@ -812,7 +826,12 @@ pub struct ExploringCell { impl ExploringCell { /// Render the exploring cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { + pub fn lines_with_motion( + &self, + width: u16, + low_motion: bool, + locale: Locale, + ) -> Vec> { let mut lines = Vec::new(); let all_done = self .entries @@ -875,7 +894,12 @@ pub struct PlanUpdateCell { impl PlanUpdateCell { /// Render the plan update cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { + pub fn lines_with_motion( + &self, + width: u16, + low_motion: bool, + locale: Locale, + ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( "Plan", @@ -1118,7 +1142,12 @@ pub struct DiffPreviewCell { } impl DiffPreviewCell { - pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { + pub fn lines_with_motion( + &self, + width: u16, + low_motion: bool, + locale: Locale, + ) -> Vec> { let mut lines = Vec::new(); let diff_summary = diff_render::diff_summary_label(&self.diff); lines.push(render_tool_header_with_summary( @@ -1204,7 +1233,12 @@ pub struct ViewImageCell { impl ViewImageCell { /// Render the image view cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { + pub fn lines_with_motion( + &self, + width: u16, + low_motion: bool, + locale: Locale, + ) -> Vec> { let path = self.path.display().to_string(); let mut lines = vec![render_tool_header_with_summary( "Image", @@ -1230,7 +1264,12 @@ pub struct WebSearchCell { impl WebSearchCell { /// Render the web search cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool, locale: Locale) -> Vec> { + pub fn lines_with_motion( + &self, + width: u16, + low_motion: bool, + locale: Locale, + ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header_with_summary( "Search", @@ -3033,7 +3072,9 @@ fn render_tool_header_with_family( low_motion: bool, locale: Locale, ) -> Line<'static> { - render_tool_header_with_family_and_summary(family, None, state, status, started_at, low_motion, locale) + render_tool_header_with_family_and_summary( + family, None, state, status, started_at, low_motion, locale, + ) } fn render_tool_header_with_family_and_summary( diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 06fac0f83..954945a6a 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -1424,7 +1424,13 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { role_counts, }; let rows = sidebar_agent_rows(app); - let lines = subagent_panel_lines(&summary, &rows, content_width, usable_rows.max(1), app.ui_locale); + let lines = subagent_panel_lines( + &summary, + &rows, + content_width, + usable_rows.max(1), + app.ui_locale, + ); render_sidebar_section(f, area, "Agents", lines, Vec::new(), app); } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 05835011a..1cb267383 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5855,7 +5855,12 @@ fn checklist_write_renders_dedicated_card() { output_summary: None, is_diff: false, }; - let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live, crate::localization::Locale::En); + let lines = cell.lines_with_mode( + 80, + true, + crate::tui::history::RenderMode::Live, + crate::localization::Locale::En, + ); let text: Vec = lines .iter() .map(|line| { diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d710940ae..5930ee7b5 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1152,7 +1152,10 @@ fn config_hint_for_key(key: &str) -> &'static str { } } -fn render_config_editor_value_line(edit: &ConfigEdit, locale: Locale) -> ratatui::text::Line<'static> { +fn render_config_editor_value_line( + edit: &ConfigEdit, + locale: Locale, +) -> ratatui::text::Line<'static> { use ratatui::{ style::Style, text::{Line, Span}, @@ -1354,11 +1357,17 @@ impl ModalView for ConfigView { )])); lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(self.tr(MessageId::ConfigFieldScope), Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + self.tr(MessageId::ConfigFieldScope), + Style::default().fg(palette::TEXT_MUTED), + ), Span::raw(edit.scope.label(self.locale)), ])); lines.push(Line::from(vec![ - Span::styled(self.tr(MessageId::ConfigFieldCurrent), Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + self.tr(MessageId::ConfigFieldCurrent), + Style::default().fg(palette::TEXT_MUTED), + ), Span::raw(truncate_view_text(&edit.original_value, 60)), ])); lines.push(Line::from("")); @@ -1367,7 +1376,10 @@ impl ModalView for ConfigView { let hint = config_hint_for_key(&edit.key); if !hint.is_empty() { lines.push(Line::from(vec![ - Span::styled(self.tr(MessageId::ConfigFieldHint), Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + self.tr(MessageId::ConfigFieldHint), + Style::default().fg(palette::TEXT_MUTED), + ), Span::raw(hint), ])); } diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 710b6f878..2acbf5339 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -297,7 +297,13 @@ impl FanoutCard { } else { ToolFamily::Fanout }; - lines.push(card_header(family, header_status, &self.kind, &title, locale)); + lines.push(card_header( + family, + header_status, + &self.kind, + &title, + locale, + )); lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled( diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index 1264b4987..e3f34e4c9 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -708,12 +708,15 @@ mod tests { #[test] fn narrow_header_keeps_context_percent_visible() { let rendered = render_header( - HeaderData::new(AppMode::Agent, "", "", true, palette::DEEPSEEK_INK, Locale::En).with_usage( - 0, - Some(128_000), - 0.0, - Some(48_000), - ), + HeaderData::new( + AppMode::Agent, + "", + "", + true, + palette::DEEPSEEK_INK, + Locale::En, + ) + .with_usage(0, Some(128_000), 0.0, Some(48_000)), 14, ); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 2cf993adf..dd33e2868 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1502,7 +1502,11 @@ pub struct ElevationWidget<'a> { impl<'a> ElevationWidget<'a> { pub fn new(request: &'a ElevationRequest, selected: usize, locale: Locale) -> Self { - Self { request, selected, locale } + Self { + request, + selected, + locale, + } } } diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index de472967d..fd9348836 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -152,23 +152,6 @@ pub fn family_glyph(family: ToolFamily) -> &'static str { } /// The short verb label for a family — appears in card headers next to the -/// glyph. Lowercased on purpose; the verb-glyph + label is the new card -/// title vocabulary. -#[must_use] -pub fn family_label(family: ToolFamily) -> &'static str { - match family { - ToolFamily::Read => "read", - ToolFamily::Patch => "patch", - ToolFamily::Run => "run", - ToolFamily::Find => "find", - ToolFamily::Delegate => "delegate", - ToolFamily::Fanout => "fanout", - ToolFamily::Rlm => "rlm", - ToolFamily::Think => "think", - ToolFamily::Generic => "tool", - } -} - /// Locale-aware version of [`family_label`]. Returns an owned string so the /// caller can use it directly in a `Span` without extra `.to_string()`. #[must_use] @@ -218,9 +201,10 @@ pub fn rail_glyph(rail: CardRail) -> &'static str { #[cfg(test)] mod tests { use super::{ - CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_family_for_name, + CardRail, ToolFamily, family_glyph, family_label_locale, rail_glyph, tool_family_for_name, tool_family_for_title, tool_header_summary_for_name, }; + use crate::localization::Locale; #[test] fn legacy_titles_route_to_expected_families() { @@ -289,7 +273,7 @@ mod tests { "family {family:?} has empty glyph", ); assert!( - !family_label(family).is_empty(), + !family_label_locale(family, Locale::En).is_empty(), "family {family:?} has empty label", ); } From d0786ad3758baa4b02776e89c374968eca009a42 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 27 May 2026 09:57:31 +0800 Subject: [PATCH 12/15] fix: update qa_pty assertion for AGENT/YOLO/PLAN uppercase labels --- crates/tui/tests/qa_pty.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tui/tests/qa_pty.rs b/crates/tui/tests/qa_pty.rs index d5d4b5f59..55ce2d330 100644 --- a/crates/tui/tests/qa_pty.rs +++ b/crates/tui/tests/qa_pty.rs @@ -82,7 +82,10 @@ fn assert_viewport_starts_at_top(frame: &qa_harness::Frame) { "viewport content drifted below row 0:\n{dump}" ); assert!( - frame.row(0).contains("Plan") + frame.row(0).contains("PLAN") + || frame.row(0).contains("AGENT") + || frame.row(0).contains("YOLO") + || frame.row(0).contains("Plan") || frame.row(0).contains("Agent") || frame.row(0).contains("Yolo") || frame.row(0).contains("DeepSeek"), From e94676f6d400a5b6e7456a0c28a647f5053e08fe Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 27 May 2026 10:08:33 +0800 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20address=20Gemini=20review=20?= =?UTF-8?q?=E2=80=94=20named=20placeholders,=20error=5Fmsg=20usage,=20doc?= =?UTF-8?q?=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/commands/config.rs | 6 ++-- crates/tui/src/commands/cycle.rs | 9 +++-- crates/tui/src/commands/hooks.rs | 3 +- crates/tui/src/commands/mcp.rs | 3 +- crates/tui/src/commands/memory.rs | 6 ++-- crates/tui/src/commands/mod.rs | 18 ++++------ crates/tui/src/commands/restore.rs | 3 +- crates/tui/src/commands/session.rs | 8 ++--- crates/tui/src/commands/share.rs | 6 ++-- crates/tui/src/commands/stash.rs | 3 +- crates/tui/src/localization.rs | 36 +++++++++---------- crates/tui/src/tui/context_inspector.rs | 28 +++++++-------- crates/tui/src/tui/widgets/mod.rs | 2 +- .../src/tui/widgets/pending_input_preview.rs | 2 +- 14 files changed, 57 insertions(+), 76 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 8c19c7605..77f1f0ac2 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -772,9 +772,8 @@ pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult { } "add" => trust_add(&workspace, rest), "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest), - other => CommandResult::error( + other => CommandResult::error_msg( t(MessageId::CmdTrustUnknownAction).replace("{action}", other), - app.ui_locale, ), } } @@ -1266,9 +1265,8 @@ pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { app.lsp_enabled = false; CommandResult::message(t(MessageId::CmdLspDisabled)) } - other => CommandResult::error( + other => CommandResult::error_msg( t(MessageId::CmdLspUnknownArg).replace("{arg}", other), - app.ui_locale, ), } } diff --git a/crates/tui/src/commands/cycle.rs b/crates/tui/src/commands/cycle.rs index daa05d388..79053a64a 100644 --- a/crates/tui/src/commands/cycle.rs +++ b/crates/tui/src/commands/cycle.rs @@ -45,13 +45,12 @@ pub fn list_cycles(app: &App) -> CommandResult { /// `/cycle ` — print the full briefing for cycle `n`. pub fn show_cycle(app: &App, arg: Option<&str>) -> CommandResult { let Some(raw) = arg.map(str::trim) else { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /cycle — n is the cycle number from /cycles".to_string(), - app.ui_locale, ); }; if raw.is_empty() { - return CommandResult::error("Usage: /cycle ".to_string(), app.ui_locale); + return CommandResult::error_msg("Usage: /cycle ".to_string()); } let Ok(n) = raw.parse::() else { return CommandResult::error( @@ -102,10 +101,10 @@ pub fn recall_archive(app: &App, arg: Option<&str>) -> CommandResult { use crate::tools::spec::{ToolContext, ToolSpec}; let Some(raw) = arg.map(str::trim) else { - return CommandResult::error("Usage: /recall ".to_string(), app.ui_locale); + return CommandResult::error_msg("Usage: /recall ".to_string()); }; if raw.is_empty() { - return CommandResult::error("Usage: /recall ".to_string(), app.ui_locale); + return CommandResult::error_msg("Usage: /recall ".to_string()); } let session_id = app diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index 52b7452d1..bb1da3a22 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -26,9 +26,8 @@ pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult { match sub.as_str() { "" | "list" | "ls" | "show" => list(app), "events" | "event" | "list-events" => events(), - other => CommandResult::error( + other => CommandResult::error_msg( format!("unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`."), - app.ui_locale, ), } } diff --git a/crates/tui/src/commands/mcp.rs b/crates/tui/src/commands/mcp.rs index 0b722b174..bc5002c7c 100644 --- a/crates/tui/src/commands/mcp.rs +++ b/crates/tui/src/commands/mcp.rs @@ -32,9 +32,8 @@ pub fn mcp(app: &mut App, args: Option<&str>) -> CommandResult { }, "validate" => CommandResult::action(AppAction::Mcp(McpUiAction::Validate)), "reload" | "reconnect" => CommandResult::action(AppAction::Mcp(McpUiAction::Reload)), - _ => CommandResult::error( + _ => CommandResult::error_msg( "Usage: /mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", - app.ui_locale, ), } } diff --git a/crates/tui/src/commands/memory.rs b/crates/tui/src/commands/memory.rs index 5fa71c1c3..6654eb290 100644 --- a/crates/tui/src/commands/memory.rs +++ b/crates/tui/src/commands/memory.rs @@ -45,9 +45,8 @@ fn memory_help(path: &Path) -> String { pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { if !app.use_memory { - return CommandResult::error( + return CommandResult::error_msg( "user memory is disabled. Enable with `[memory] enabled = true` in `~/.deepseek/config.toml` or `DEEPSEEK_MEMORY=on` in your environment, then restart the TUI.", - app.ui_locale, ); } @@ -82,12 +81,11 @@ pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { path.display() )), "help" => CommandResult::message(memory_help(&path)), - _ => CommandResult::error( + _ => CommandResult::error_msg( format!( "unknown subcommand `{sub}`. Try `/memory help`.\n\n{}", memory_help(&path) ), - app.ui_locale, ), } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 5f20dced6..bcc9d15e3 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -87,8 +87,7 @@ impl CommandResult { } } - /// Create a simple error message (English prefix) - #[allow(dead_code)] + /// Create a simple error message without any prefix pub fn error_msg(msg: impl Into) -> Self { Self { message: Some(msg.into()), @@ -670,13 +669,11 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "rlm" | "recursive" | "digui" => rlm(app, arg), // Legacy command migrations (kept out of registry/autocomplete intentionally). - "set" => CommandResult::error( + "set" => CommandResult::error_msg( "The /set command was retired. Use /config to edit settings and /settings to inspect current values.", - app.ui_locale, ), - "deepseek" => CommandResult::error( + "deepseek" => CommandResult::error_msg( "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).", - app.ui_locale, ), _ => { @@ -687,9 +684,8 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { } let suggestions = suggest_command_names(command, 3); if suggestions.is_empty() { - CommandResult::error( + CommandResult::error_msg( format!("Unknown command: /{command}. Type /help for available commands."), - app.ui_locale, ) } else { let list = suggestions @@ -697,11 +693,10 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { .map(|name| format!("/{name}")) .collect::>() .join(", "); - CommandResult::error( + CommandResult::error_msg( format!( "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." ), - app.ui_locale, ) } } @@ -758,11 +753,10 @@ pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { let target = match target { Some(p) if !p.trim().is_empty() => p.trim().to_string(), _ => { - return CommandResult::error( + return CommandResult::error_msg( "Usage: /rlm [N] \n\n\ Opens a persistent RLM context with sub_rlm depth N (0-3, default 1)." .to_string(), - app.ui_locale, ); } }; diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/restore.rs index 02e8f0296..4d1cd465b 100644 --- a/crates/tui/src/commands/restore.rs +++ b/crates/tui/src/commands/restore.rs @@ -46,9 +46,8 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let n: usize = match arg.parse() { Ok(n) if n >= 1 => n, _ => { - return CommandResult::error( + return CommandResult::error_msg( format!("Usage: /restore (N is 1-based; got '{arg}')",), - app.ui_locale, ); } }; diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 2dd118d65..1d610c383 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -151,7 +151,7 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { app.workspace.join(p) } } else { - return CommandResult::error("Usage: /load ", app.ui_locale); + return CommandResult::error_msg("Usage: /load "); }; let content = match std::fs::read_to_string(&load_path) { @@ -308,9 +308,8 @@ pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { app.view_stack.push(SessionPickerView::new(&app.workspace)); CommandResult::ok() } - _ => CommandResult::error( + _ => CommandResult::error_msg( format!("unknown subcommand `{action}`. usage: /sessions [show|prune ]"), - app.ui_locale, ), } } @@ -324,9 +323,8 @@ fn prune(app: &mut App, days_arg: Option<&str>) -> CommandResult { let days_str = match days_arg { Some(s) => s, None => { - return CommandResult::error( + return CommandResult::error_msg( "usage: /sessions prune (e.g. `/sessions prune 30` to drop sessions older than 30 days)", - app.ui_locale, ); } }; diff --git a/crates/tui/src/commands/share.rs b/crates/tui/src/commands/share.rs index 6c01bb11d..f41910d58 100644 --- a/crates/tui/src/commands/share.rs +++ b/crates/tui/src/commands/share.rs @@ -31,11 +31,10 @@ pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { so you can paste it into Slack, GitHub, Twitter, etc." .to_string(), ), - _ => CommandResult::error( + _ => CommandResult::error_msg( format!( "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." ), - app.ui_locale, ), } } @@ -44,9 +43,8 @@ pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { fn do_share(app: &mut App) -> CommandResult { // Check if there's any session content to share if app.history.is_empty() { - return CommandResult::error( + return CommandResult::error_msg( "Nothing to share. The current session is empty.", - app.ui_locale, ); } diff --git a/crates/tui/src/commands/stash.rs b/crates/tui/src/commands/stash.rs index 683790409..fb0a55384 100644 --- a/crates/tui/src/commands/stash.rs +++ b/crates/tui/src/commands/stash.rs @@ -25,11 +25,10 @@ pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult { "" | "list" | "ls" | "show" => list(), "pop" | "restore" => pop(app), "clear" | "wipe" | "drop" => clear(app.ui_locale), - other => CommandResult::error( + other => CommandResult::error_msg( format!( "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`." ), - app.ui_locale, ), } } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 3a89bc999..38261252a 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1509,13 +1509,13 @@ fn english(id: MessageId) -> &'static str { MessageId::ComposerTitle => "Composer", MessageId::ComposerDraftTitle => "Draft", MessageId::ComposerQueueForNextTurn => "↵ queue for next turn", - MessageId::ComposerQueueCount => "↵ queue ({} waiting)", + MessageId::ComposerQueueCount => "↵ queue ({count} waiting)", MessageId::ComposerSteerHint => "↵ steering (Ctrl+Enter)", MessageId::ComposerQueuedHint => "↵ queued (Ctrl+Enter to steer)", MessageId::ComposerOfflineQueueHint => "↵ offline queue", MessageId::PendingInputsHeader => "Pending inputs", MessageId::PendingInputsContextHeader => "Context for next send", - MessageId::PendingInputsEditHint => "{} edit last queued message", + MessageId::PendingInputsEditHint => "{key} edit last queued message", MessageId::StatusPickerTitle => " Status line ", MessageId::StatusPickerToggle => "toggle", MessageId::StatusPickerAll => "all", @@ -1774,29 +1774,29 @@ fn english(id: MessageId) -> &'static str { MessageId::OnboardWelcomePressEnter => "Press Enter to continue.", MessageId::OnboardWelcomeCtrlCExit => "Ctrl+C exits at any point.", MessageId::CtxInspectorTitle => "Session Context", - MessageId::CtxInspectorModel => "Model: {}", - MessageId::CtxInspectorSession => "Session: {}", - MessageId::CtxInspectorTranscript => "Transcript: {} cells, {} API messages", - MessageId::CtxInspectorWorkspaceStatus => "Workspace status: {}", + MessageId::CtxInspectorModel => "Model: {model}", + MessageId::CtxInspectorSession => "Session: {session}", + MessageId::CtxInspectorTranscript => "Transcript: {cells} cells, {api_messages} API messages", + MessageId::CtxInspectorWorkspaceStatus => "Workspace status: {status}", MessageId::CtxInspectorNotSampled => "not sampled yet", MessageId::CtxInspectorEmpty => "(empty)", MessageId::CtxInspectorSystemPrompt => "System Prompt Structure", MessageId::CtxInspectorStablePrefix => { - " Stable prefix: {} block(s), ~{} tokens [cache-friendly]" + " Stable prefix: {count} block(s), ~{tokens} tokens [cache-friendly]" } MessageId::CtxInspectorVolatileWorkingSet => { - " Volatile working set: 1 block, ~{} tokens [changes every turn]" + " Volatile working set: 1 block, ~{tokens} tokens [changes every turn]" } MessageId::CtxInspectorNone => "none", - MessageId::CtxInspectorTotal => " Total: {} block(s), ~{} tokens", - MessageId::CtxInspectorTextPromptLayers => " Text prompt layers: {} layer(s), ~{} tokens", - MessageId::CtxInspectorSingleBlob => " Single text blob (~{} tokens) [stable prefix only]", + MessageId::CtxInspectorTotal => " Total: {count} block(s), ~{tokens} tokens", + MessageId::CtxInspectorTextPromptLayers => " Text prompt layers: {count} layer(s), ~{tokens} tokens", + MessageId::CtxInspectorSingleBlob => " Single text blob (~{tokens} tokens) [stable prefix only]", MessageId::CtxInspectorNoSystemPrompt => " No system prompt set.", MessageId::CtxInspectorTip => { " Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. Keep the system prompt append-only to maximize reuse." } MessageId::CtxInspectorReferences => "References", - MessageId::CtxInspectorMoreReferences => "- ... {} more reference(s)", + MessageId::CtxInspectorMoreReferences => "- ... {count} more reference(s)", MessageId::CtxInspectorNoReferences => { "- No file, directory, or media references recorded yet." } @@ -2124,13 +2124,13 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::ComposerTitle => "コンポーザー", MessageId::ComposerDraftTitle => "下書き", MessageId::ComposerQueueForNextTurn => "↵ 次のターンにキュー", - MessageId::ComposerQueueCount => "↵ キュー({} 待機中)", + MessageId::ComposerQueueCount => "↵ キュー({count} 待機中)", MessageId::ComposerSteerHint => "↵ ステアリング(Ctrl+Enter)", MessageId::ComposerQueuedHint => "↵ キュー済み(Ctrl+Enterでステアリング)", MessageId::ComposerOfflineQueueHint => "↵ オフラインキュー", MessageId::PendingInputsHeader => "保留中の入力", MessageId::PendingInputsContextHeader => "次回送信のコンテキスト", - MessageId::PendingInputsEditHint => "{} 最後のキュー済みメッセージを編集", + MessageId::PendingInputsEditHint => "{key} 最後のキュー済みメッセージを編集", MessageId::StatusPickerTitle => " ステータスライン ", MessageId::StatusPickerToggle => "切替", MessageId::StatusPickerAll => "すべて", @@ -2603,13 +2603,13 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::ComposerTitle => "编辑器", MessageId::ComposerDraftTitle => "草稿", MessageId::ComposerQueueForNextTurn => "↵ 排队等待下一轮", - MessageId::ComposerQueueCount => "↵ 排队({} 等待中)", + MessageId::ComposerQueueCount => "↵ 排队({count} 等待中)", MessageId::ComposerSteerHint => "↵ 引导回复(Ctrl+Enter)", MessageId::ComposerQueuedHint => "↵ 已排队(Ctrl+Enter 转向)", MessageId::ComposerOfflineQueueHint => "↵ 离线排队", MessageId::PendingInputsHeader => "待处理输入", MessageId::PendingInputsContextHeader => "下次发送的上下文", - MessageId::PendingInputsEditHint => "{} 编辑最后一条已排队消息", + MessageId::PendingInputsEditHint => "{key} 编辑最后一条已排队消息", MessageId::StatusPickerTitle => " 状态栏 ", MessageId::StatusPickerToggle => "切换", MessageId::StatusPickerAll => "全选", @@ -3114,13 +3114,13 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::ComposerTitle => "Compositor", MessageId::ComposerDraftTitle => "Rascunho", MessageId::ComposerQueueForNextTurn => "↵ fila para próximo turno", - MessageId::ComposerQueueCount => "↵ fila ({} aguardando)", + MessageId::ComposerQueueCount => "↵ fila ({count} aguardando)", MessageId::ComposerSteerHint => "↵ direcionar (Ctrl+Enter)", MessageId::ComposerQueuedHint => "↵ na fila (Ctrl+Enter para direcionar)", MessageId::ComposerOfflineQueueHint => "↵ fila offline", MessageId::PendingInputsHeader => "Entradas pendentes", MessageId::PendingInputsContextHeader => "Contexto para próximo envio", - MessageId::PendingInputsEditHint => "{} editar última mensagem na fila", + MessageId::PendingInputsEditHint => "{key} editar última mensagem na fila", MessageId::StatusPickerTitle => " Linha de status ", MessageId::StatusPickerToggle => "alternar", MessageId::StatusPickerAll => "todos", diff --git a/crates/tui/src/tui/context_inspector.rs b/crates/tui/src/tui/context_inspector.rs index 372839ce7..e8a02a87a 100644 --- a/crates/tui/src/tui/context_inspector.rs +++ b/crates/tui/src/tui/context_inspector.rs @@ -98,7 +98,7 @@ pub fn build_context_inspector_text(app: &App, locale: Locale) -> String { let _ = writeln!( out, "{}", - tr(locale, MessageId::CtxInspectorModel).replace("{}", &app.model) + tr(locale, MessageId::CtxInspectorModel).replace("{model}", &app.model) ); let _ = writeln!( out, @@ -109,7 +109,7 @@ pub fn build_context_inspector_text(app: &App, locale: Locale) -> String { let _ = writeln!( out, "{}", - tr(locale, MessageId::CtxInspectorSession).replace("{}", session_id) + tr(locale, MessageId::CtxInspectorSession).replace("{session}", session_id) ); } let (used, max, percent) = usage; @@ -122,8 +122,8 @@ pub fn build_context_inspector_text(app: &App, locale: Locale) -> String { out, "{}", tmpl_transcript - .replacen("{}", &app.history.len().to_string(), 1) - .replace("{}", &app.api_messages.len().to_string()) + .replace("{cells}", &app.history.len().to_string()) + .replace("{api_messages}", &app.api_messages.len().to_string()) ); let workspace_status = app .workspace_context @@ -132,7 +132,7 @@ pub fn build_context_inspector_text(app: &App, locale: Locale) -> String { let _ = writeln!( out, "{}", - tr(locale, MessageId::CtxInspectorWorkspaceStatus).replace("{}", workspace_status) + tr(locale, MessageId::CtxInspectorWorkspaceStatus).replace("{status}", workspace_status) ); let _ = writeln!(out); @@ -201,15 +201,15 @@ fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) { out, "{}", tmpl_stable - .replacen("{}", &stable_count.to_string(), 1) - .replace("{}", &stable_tokens.to_string()) + .replace("{count}", &stable_count.to_string()) + .replace("{tokens}", &stable_tokens.to_string()) ); if let Some(block) = working_block { let tmpl_volatile = tr(locale, MessageId::CtxInspectorVolatileWorkingSet); let _ = writeln!( out, "{}", - tmpl_volatile.replace("{}", &working_tokens.to_string()) + tmpl_volatile.replace("{tokens}", &working_tokens.to_string()) ); let _ = writeln!( out, @@ -232,8 +232,8 @@ fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) { out, "{}", tmpl_total - .replacen("{}", &blocks.len().to_string(), 1) - .replace("{}", &total_est.to_string()) + .replace("{count}", &blocks.len().to_string()) + .replace("{tokens}", &total_est.to_string()) ); } Some(SystemPrompt::Text(text)) => { @@ -248,8 +248,8 @@ fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) { out, "{}", tmpl_layers - .replacen("{}", &layers.len().to_string(), 1) - .replace("{}", &total_est.to_string()) + .replace("{count}", &layers.len().to_string()) + .replace("{tokens}", &total_est.to_string()) ); for layer in layers { let tokens = text_tokens(layer.body); @@ -266,7 +266,7 @@ fn push_system_prompt_structure(out: &mut String, app: &App, locale: Locale) { out, "{}", tr(locale, MessageId::CtxInspectorSingleBlob) - .replace("{}", &total_est.to_string()) + .replace("{tokens}", &total_est.to_string()) ); } } @@ -336,7 +336,7 @@ fn push_references(out: &mut String, references: &[SessionContextReference], loc out, "{}", tr(locale, MessageId::CtxInspectorMoreReferences) - .replace("{}", &remaining.to_string()) + .replace("{count}", &remaining.to_string()) ); } break; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index dd33e2868..d3dfea03f 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -599,7 +599,7 @@ impl Renderable for ComposerWidget<'_> { let label = if queue_count > 0 { self.app .tr(crate::localization::MessageId::ComposerQueueCount) - .replace("{}", &(queue_count.saturating_add(1).to_string())) + .replace("{count}", &(queue_count.saturating_add(1).to_string())) } else { self.app .tr(crate::localization::MessageId::ComposerQueueForNextTurn) diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index 9e46f5c20..6e48f10e1 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -148,7 +148,7 @@ impl PendingInputPreview { let hint = localization::tr(self.locale, localization::MessageId::PendingInputsEditHint); lines.push(Line::from(vec![Span::styled( - hint.replace("{}", self.edit_binding.label), + hint.replace("{key}", self.edit_binding.label), dim, )])); } From 55435b4c8824f0fd20e0b7b1bce7a71f114fc44c Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 27 May 2026 10:12:25 +0800 Subject: [PATCH 14/15] fmt: cargo fmt after review fixes --- crates/tui/src/commands/config.rs | 10 ++++------ crates/tui/src/commands/hooks.rs | 6 +++--- crates/tui/src/commands/memory.rs | 10 ++++------ crates/tui/src/commands/mod.rs | 14 ++++++-------- crates/tui/src/commands/restore.rs | 6 +++--- crates/tui/src/commands/session.rs | 6 +++--- crates/tui/src/commands/share.rs | 12 ++++-------- crates/tui/src/commands/stash.rs | 8 +++----- crates/tui/src/localization.rs | 12 +++++++++--- crates/tui/src/tui/widgets/mod.rs | 5 ++++- 10 files changed, 43 insertions(+), 46 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 77f1f0ac2..c633f26b2 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -772,9 +772,9 @@ pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult { } "add" => trust_add(&workspace, rest), "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest), - other => CommandResult::error_msg( - t(MessageId::CmdTrustUnknownAction).replace("{action}", other), - ), + other => { + CommandResult::error_msg(t(MessageId::CmdTrustUnknownAction).replace("{action}", other)) + } } } @@ -1265,9 +1265,7 @@ pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { app.lsp_enabled = false; CommandResult::message(t(MessageId::CmdLspDisabled)) } - other => CommandResult::error_msg( - t(MessageId::CmdLspUnknownArg).replace("{arg}", other), - ), + other => CommandResult::error_msg(t(MessageId::CmdLspUnknownArg).replace("{arg}", other)), } } diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index bb1da3a22..830319b98 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -26,9 +26,9 @@ pub fn hooks(app: &App, arg: Option<&str>) -> CommandResult { match sub.as_str() { "" | "list" | "ls" | "show" => list(app), "events" | "event" | "list-events" => events(), - other => CommandResult::error_msg( - format!("unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`."), - ), + other => CommandResult::error_msg(format!( + "unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`." + )), } } diff --git a/crates/tui/src/commands/memory.rs b/crates/tui/src/commands/memory.rs index 6654eb290..149e60a3d 100644 --- a/crates/tui/src/commands/memory.rs +++ b/crates/tui/src/commands/memory.rs @@ -81,12 +81,10 @@ pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult { path.display() )), "help" => CommandResult::message(memory_help(&path)), - _ => CommandResult::error_msg( - format!( - "unknown subcommand `{sub}`. Try `/memory help`.\n\n{}", - memory_help(&path) - ), - ), + _ => CommandResult::error_msg(format!( + "unknown subcommand `{sub}`. Try `/memory help`.\n\n{}", + memory_help(&path) + )), } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index bcc9d15e3..47718c337 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -684,20 +684,18 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { } let suggestions = suggest_command_names(command, 3); if suggestions.is_empty() { - CommandResult::error_msg( - format!("Unknown command: /{command}. Type /help for available commands."), - ) + CommandResult::error_msg(format!( + "Unknown command: /{command}. Type /help for available commands." + )) } else { let list = suggestions .into_iter() .map(|name| format!("/{name}")) .collect::>() .join(", "); - CommandResult::error_msg( - format!( - "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." - ), - ) + CommandResult::error_msg(format!( + "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." + )) } } } diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/restore.rs index 4d1cd465b..13d98f00a 100644 --- a/crates/tui/src/commands/restore.rs +++ b/crates/tui/src/commands/restore.rs @@ -46,9 +46,9 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let n: usize = match arg.parse() { Ok(n) if n >= 1 => n, _ => { - return CommandResult::error_msg( - format!("Usage: /restore (N is 1-based; got '{arg}')",), - ); + return CommandResult::error_msg(format!( + "Usage: /restore (N is 1-based; got '{arg}')", + )); } }; diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 1d610c383..6a677a993 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -308,9 +308,9 @@ pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { app.view_stack.push(SessionPickerView::new(&app.workspace)); CommandResult::ok() } - _ => CommandResult::error_msg( - format!("unknown subcommand `{action}`. usage: /sessions [show|prune ]"), - ), + _ => CommandResult::error_msg(format!( + "unknown subcommand `{action}`. usage: /sessions [show|prune ]" + )), } } diff --git a/crates/tui/src/commands/share.rs b/crates/tui/src/commands/share.rs index f41910d58..a1528ddbd 100644 --- a/crates/tui/src/commands/share.rs +++ b/crates/tui/src/commands/share.rs @@ -31,11 +31,9 @@ pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { so you can paste it into Slack, GitHub, Twitter, etc." .to_string(), ), - _ => CommandResult::error_msg( - format!( - "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." - ), - ), + _ => CommandResult::error_msg(format!( + "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." + )), } } @@ -43,9 +41,7 @@ pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { fn do_share(app: &mut App) -> CommandResult { // Check if there's any session content to share if app.history.is_empty() { - return CommandResult::error_msg( - "Nothing to share. The current session is empty.", - ); + return CommandResult::error_msg("Nothing to share. The current session is empty."); } // Sanity-check: the extra info block is optional; the session itself diff --git a/crates/tui/src/commands/stash.rs b/crates/tui/src/commands/stash.rs index fb0a55384..3c708c78d 100644 --- a/crates/tui/src/commands/stash.rs +++ b/crates/tui/src/commands/stash.rs @@ -25,11 +25,9 @@ pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult { "" | "list" | "ls" | "show" => list(), "pop" | "restore" => pop(app), "clear" | "wipe" | "drop" => clear(app.ui_locale), - other => CommandResult::error_msg( - format!( - "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`." - ), - ), + other => CommandResult::error_msg(format!( + "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`." + )), } } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 38261252a..1a40b3c00 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1776,7 +1776,9 @@ fn english(id: MessageId) -> &'static str { MessageId::CtxInspectorTitle => "Session Context", MessageId::CtxInspectorModel => "Model: {model}", MessageId::CtxInspectorSession => "Session: {session}", - MessageId::CtxInspectorTranscript => "Transcript: {cells} cells, {api_messages} API messages", + MessageId::CtxInspectorTranscript => { + "Transcript: {cells} cells, {api_messages} API messages" + } MessageId::CtxInspectorWorkspaceStatus => "Workspace status: {status}", MessageId::CtxInspectorNotSampled => "not sampled yet", MessageId::CtxInspectorEmpty => "(empty)", @@ -1789,8 +1791,12 @@ fn english(id: MessageId) -> &'static str { } MessageId::CtxInspectorNone => "none", MessageId::CtxInspectorTotal => " Total: {count} block(s), ~{tokens} tokens", - MessageId::CtxInspectorTextPromptLayers => " Text prompt layers: {count} layer(s), ~{tokens} tokens", - MessageId::CtxInspectorSingleBlob => " Single text blob (~{tokens} tokens) [stable prefix only]", + MessageId::CtxInspectorTextPromptLayers => { + " Text prompt layers: {count} layer(s), ~{tokens} tokens" + } + MessageId::CtxInspectorSingleBlob => { + " Single text blob (~{tokens} tokens) [stable prefix only]" + } MessageId::CtxInspectorNoSystemPrompt => " No system prompt set.", MessageId::CtxInspectorTip => { " Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. Keep the system prompt append-only to maximize reuse." diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index d3dfea03f..593a1a32c 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -599,7 +599,10 @@ impl Renderable for ComposerWidget<'_> { let label = if queue_count > 0 { self.app .tr(crate::localization::MessageId::ComposerQueueCount) - .replace("{count}", &(queue_count.saturating_add(1).to_string())) + .replace( + "{count}", + &(queue_count.saturating_add(1).to_string()), + ) } else { self.app .tr(crate::localization::MessageId::ComposerQueueForNextTurn) From 53ceaa67380cfffdc68813a162baeb3cfd88066b Mon Sep 17 00:00:00 2001 From: gordonlu Date: Wed, 27 May 2026 10:23:32 +0800 Subject: [PATCH 15/15] fix: queue draft header {n} leak, hook/analytics use Locale::En --- crates/tui/src/commands/queue.rs | 5 ++++- crates/tui/src/tui/app.rs | 6 +++--- crates/tui/src/tui/ui.rs | 7 ++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index 0d8aa521c..85115fbaa 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -34,7 +34,10 @@ fn list_queue(app: &mut App) -> CommandResult { let queued = app.queued_message_count(); if let Some(draft) = app.queued_draft.as_ref() { - lines.push(format!("{}:", t(MessageId::CmdEditingQueuedDraft))); + lines.push(format!( + "{}:", + t(MessageId::CmdEditingQueuedDraft).replace("{n}", "") + )); lines.push(format!("- {}", truncate_preview(&draft.display))); } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2dce13f82..cfa3f8523 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2121,8 +2121,8 @@ impl App { // Execute mode change hooks let context = HookContext::new() - .with_mode(mode.label(self.ui_locale)) - .with_previous_mode(previous_mode.label(self.ui_locale)) + .with_mode(mode.label(Locale::En)) + .with_previous_mode(previous_mode.label(Locale::En)) .with_workspace(self.workspace.clone()) .with_model(&self.model); let _ = self.hooks.execute(HookEvent::ModeChange, &context); @@ -2172,7 +2172,7 @@ impl App { /// Create a hook context with common fields pre-populated pub fn base_hook_context(&self) -> HookContext { HookContext::new() - .with_mode(self.mode.label(self.ui_locale)) + .with_mode(self.mode.label(Locale::En)) .with_workspace(self.workspace.clone()) .with_model(&self.model) .with_session_id(self.hooks.session_id()) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e9c39d43e..5d70122d3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -48,6 +48,7 @@ use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; use crate::hooks::{HookEvent, HookExecutor}; use crate::llm_client::LlmClient; +use crate::localization::Locale; use crate::models::{ ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model, }; @@ -1920,7 +1921,7 @@ async fn run_event_loop( "tool_name": tool_name, "approval_key": approval_key, "session_id": app.current_session_id, - "mode": app.mode.label(app.ui_locale), + "mode": app.mode.label(Locale::En), }), ); let _ = engine_handle.approve_tool_call(id.clone()).await; @@ -1930,7 +1931,7 @@ async fn run_event_loop( serde_json::json!({ "tool_name": tool_name, "session_id": app.current_session_id, - "mode": app.mode.label(app.ui_locale), + "mode": app.mode.label(Locale::En), }), ); let _ = engine_handle.deny_tool_call(id.clone()).await; @@ -1962,7 +1963,7 @@ async fn run_event_loop( "tool_name": tool_name, "description": description, "session_id": app.current_session_id, - "mode": app.mode.label(app.ui_locale), + "mode": app.mode.label(Locale::En), }), ); app.view_stack