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/ 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/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/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 651b4d5d4..c633f26b2 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::{ @@ -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.", ); } @@ -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()) @@ -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!( @@ -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." )); } @@ -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]"), } } @@ -759,24 +759,22 @@ 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_msg(t(MessageId::CmdTrustUnknownAction).replace("{action}", other)) + } } } @@ -811,13 +809,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() )); @@ -827,19 +825,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}")), } } @@ -1249,6 +1247,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 +1255,35 @@ 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_msg(t(MessageId::CmdLspUnknownArg).replace("{arg}", other)), } } /// 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( + t(MessageId::CmdLogoutFailed).replace("{reason}", &e.to_string()), + app.ui_locale, + ), } } @@ -1295,6 +1291,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 +1397,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] @@ -2046,7 +2045,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 0a50f1d8e..656ab5cc5 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,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.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 +179,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 +199,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 +208,13 @@ 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 +275,7 @@ pub fn home_dashboard(app: &mut App) -> CommandResult { stats, "{} {}", tr(locale, MessageId::HomeMode), - app.mode.label() + app.mode.label(locale) ); let _ = writeln!( stats, @@ -379,7 +383,8 @@ 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}; use crate::tui::history::HistoryCell; @@ -410,8 +415,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/cycle.rs b/crates/tui/src/commands/cycle.rs index 7a1c9c651..79053a64a 100644 --- a/crates/tui/src/commands/cycle.rs +++ b/crates/tui/src/commands/cycle.rs @@ -45,17 +45,18 @@ 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(), ); }; if raw.is_empty() { - return CommandResult::error("Usage: /cycle ".to_string()); + return CommandResult::error_msg("Usage: /cycle ".to_string()); } 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 +70,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 +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()); + return CommandResult::error_msg("Usage: /recall ".to_string()); }; if raw.is_empty() { - return CommandResult::error("Usage: /recall ".to_string()); + return CommandResult::error_msg("Usage: /recall ".to_string()); } let session_id = app @@ -120,7 +122,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 a89bd1744..8379ba236 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -123,7 +123,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 )) } @@ -421,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; @@ -450,9 +451,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 } @@ -1543,17 +1544,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); } }; @@ -1580,7 +1581,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:") { @@ -1740,6 +1741,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/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/hooks.rs b/crates/tui/src/commands/hooks.rs index fbf7d760b..830319b98 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -26,7 +26,7 @@ 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!( + other => CommandResult::error_msg(format!( "unknown subcommand `{other}`. Try `/hooks list` or `/hooks events`." )), } 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/mcp.rs b/crates/tui/src/commands/mcp.rs index 2a29f7293..bc5002c7c 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,22 +17,22 @@ 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( + _ => CommandResult::error_msg( "Usage: /mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", ), } @@ -44,10 +45,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 +64,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..149e60a3d 100644 --- a/crates/tui/src/commands/memory.rs +++ b/crates/tui/src/commands/memory.rs @@ -45,7 +45,7 @@ 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.", ); } @@ -71,14 +71,17 @@ 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!( + _ => 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 f21df395f..47718c337 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -87,10 +87,23 @@ impl CommandResult { } } - /// Create an error message result - pub fn error(msg: impl Into) -> Self { + /// Create a simple error message without any prefix + pub fn error_msg(msg: impl Into) -> Self { Self { - message: Some(format!("Error: {}", msg.into())), + 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 { + message: Some(format!( + "{} {}", + tr(locale, MessageId::CmdErrorPrefix), + msg.into() + )), action: None, is_error: true, } @@ -656,10 +669,10 @@ 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.", ), - "deepseek" => CommandResult::error( + "deepseek" => CommandResult::error_msg( "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).", ), @@ -671,7 +684,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { } let suggestions = suggest_command_names(command, 3); if suggestions.is_empty() { - CommandResult::error(format!( + CommandResult::error_msg(format!( "Unknown command: /{command}. Type /help for available commands." )) } else { @@ -680,7 +693,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { .map(|name| format!("/{name}")) .collect::>() .join(", "); - CommandResult::error(format!( + CommandResult::error_msg(format!( "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." )) } @@ -733,12 +746,12 @@ 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(), _ => { - 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(), @@ -765,12 +778,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).", ); @@ -814,7 +827,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 915cce8c5..5a6043024 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -26,7 +26,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." )); }; @@ -37,7 +37,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." )); } @@ -66,6 +66,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 +92,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/commands/queue.rs b/crates/tui/src/commands/queue.rs index b1c76b8b6..85115fbaa 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,35 @@ 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( + 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).replace("{n}", "") + )); 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 +57,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(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(err, locale), }; let Some(message) = app.remove_queued_message(index) else { - return CommandResult::error("Queued message not found"); + return CommandResult::error(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(err, locale), }; if app.remove_queued_message(index).is_none() { - return CommandResult::error("Queued message not found"); + return CommandResult::error(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 +146,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 +172,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/rename.rs b/crates/tui/src/commands/rename.rs index e551cf61b..adbce69a7 100644 --- a/crates/tui/src/commands/rename.rs +++ b/crates/tui/src/commands/rename.rs @@ -16,17 +16,19 @@ 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 +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(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 +52,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 +67,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/restore.rs b/crates/tui/src/commands/restore.rs index 8ea3540e5..13d98f00a 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,20 @@ 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!( + return CommandResult::error_msg(format!( "Usage: /restore (N is 1-based; got '{arg}')", )); } }; 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 +76,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/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 a54c44034..6a677a993 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(); @@ -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,23 +68,25 @@ 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), } } /// 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}")); } }; @@ -94,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( @@ -109,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()); @@ -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_msg("Usage: /load "); }; 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,7 +308,7 @@ pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { app.view_stack.push(SessionPickerView::new(&app.workspace)); CommandResult::ok() } - _ => CommandResult::error(format!( + _ => CommandResult::error_msg(format!( "unknown subcommand `{action}`. usage: /sessions [show|prune ]" )), } @@ -306,11 +319,11 @@ 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( + return CommandResult::error_msg( "usage: /sessions prune (e.g. `/sessions prune 30` to drop sessions older than 30 days)", ); } @@ -318,16 +331,20 @@ fn prune(_app: &mut App, days_arg: Option<&str>) -> CommandResult { 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 +355,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..a1528ddbd 100644 --- a/crates/tui/src/commands/share.rs +++ b/crates/tui/src/commands/share.rs @@ -31,7 +31,7 @@ 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!( + _ => CommandResult::error_msg(format!( "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." )), } @@ -41,14 +41,14 @@ 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_msg("Nothing to share. The current session is empty."); } // Sanity-check: the extra info block is optional; the session itself // 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..c7b097a8a 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,14 @@ 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_msg(format_registry_error("Failed to fetch registry", &err)) } - Err(err) => CommandResult::error(format_registry_error("Failed to fetch registry", &err)), } } @@ -414,9 +416,11 @@ 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 +464,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/stash.rs b/crates/tui/src/commands/stash.rs index 1723e4403..3c708c78d 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,8 +24,8 @@ 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!( + "clear" | "wipe" | "drop" => clear(app.ui_locale), + other => CommandResult::error_msg(format!( "unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`." )), } @@ -52,11 +53,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/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/commands/task.rs b/crates/tui/src/commands/task.rs index c96fe29a1..e2dce26d4 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(t(MessageId::CmdTaskUsageAdd), app.ui_locale); }; CommandResult::action(AppAction::TaskAdd { prompt: prompt.to_string(), @@ -26,17 +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("Usage: /task show "); + 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("Usage: /task cancel "); + return CommandResult::error(t(MessageId::CmdTaskUsageCancel), app.ui_locale); }; CommandResult::action(AppAction::TaskCancel { id: id.to_string() }) } - _ => CommandResult::error("Usage: /task [add |list|show |cancel ]"), + _ => CommandResult::error(t(MessageId::CmdTaskUsageGeneral), app.ui_locale), } } @@ -44,11 +46,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 +74,9 @@ mod tests { initial_input: None, }, &Config::default(), - ) + ); + app.ui_locale = Locale::En; + app } #[test] diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02737eb7a..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; @@ -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 874bb2ec8..1a40b3c00 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -454,6 +454,201 @@ 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, + 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, + // 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, + + // ── 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, + + // ── 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)] @@ -688,6 +883,181 @@ 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, + 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, + 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, + 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, + 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 { @@ -1136,6 +1506,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 ({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 => "{key} 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:", @@ -1215,6 +1609,216 @@ 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", + // ── 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)" + } + 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", + 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: {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: {count} block(s), ~{tokens} tokens [cache-friendly]" + } + MessageId::CtxInspectorVolatileWorkingSet => { + " Volatile working set: 1 block, ~{tokens} tokens [changes every turn]" + } + 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::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 => "- ... {count} 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", } } @@ -1523,6 +2127,30 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::LinksTip => "ヒント: API キーはダッシュボードコンソールで取得できます。", MessageId::SubagentsFetching => "サブエージェントの状態を取得中...", MessageId::HelpUnknownCommand => "不明なコマンド: {topic}", + MessageId::ComposerTitle => "コンポーザー", + MessageId::ComposerDraftTitle => "下書き", + MessageId::ComposerQueueForNextTurn => "↵ 次のターンにキュー", + MessageId::ComposerQueueCount => "↵ キュー({count} 待機中)", + MessageId::ComposerSteerHint => "↵ ステアリング(Ctrl+Enter)", + MessageId::ComposerQueuedHint => "↵ キュー済み(Ctrl+Enterでステアリング)", + MessageId::ComposerOfflineQueueHint => "↵ オフラインキュー", + MessageId::PendingInputsHeader => "保留中の入力", + MessageId::PendingInputsContextHeader => "次回送信のコンテキスト", + MessageId::PendingInputsEditHint => "{key} 最後のキュー済みメッセージを編集", + 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 => "モード:", @@ -1602,6 +2230,142 @@ 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 => "このツール実行をキャンセル", + // ── 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 で再キュー/送信)" + } + 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 => "エージェントなし", + _ => english(id), }) } @@ -1842,6 +2606,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 => "↵ 排队({count} 等待中)", + MessageId::ComposerSteerHint => "↵ 引导回复(Ctrl+Enter)", + MessageId::ComposerQueuedHint => "↵ 已排队(Ctrl+Enter 转向)", + MessageId::ComposerOfflineQueueHint => "↵ 离线排队", + MessageId::PendingInputsHeader => "待处理输入", + MessageId::PendingInputsContextHeader => "下次发送的上下文", + MessageId::PendingInputsEditHint => "{key} 编辑最后一条已排队消息", + 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 => "模式:", @@ -1909,6 +2697,126 @@ 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 => "取消此工具调用", + // ── 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 重新排队/发送)", + 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", + _ => english(id), }) } @@ -2209,6 +3117,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 ({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 => "{key} 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:", @@ -2296,6 +3228,150 @@ 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", + // ── 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)" + } + 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", + _ => english(id), }) } @@ -2689,6 +3765,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 3c49df0ed..cfa3f8523 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(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()) + .with_mode(self.mode.label(Locale::En)) .with_workspace(self.workspace.clone()) .with_model(&self.model) .with_session_id(self.hooks.session_id()) @@ -3108,6 +3108,7 @@ impl App { calm_mode: self.calm_mode, low_motion: self.low_motion, spacing: self.transcript_spacing, + locale: self.ui_locale, } } @@ -5642,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); @@ -5650,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); @@ -5658,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..15d600d26 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,38 @@ 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..e8a02a87a 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,59 @@ 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("{model}", &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}", 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 + .replace("{cells}", &app.history.len().to_string()) + .replace("{api_messages}", &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("{status}", 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 +155,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 +196,44 @@ 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 + .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, - " Volatile working set: 1 block, ~{working_tokens} tokens [changes every turn]" + "{}", + tmpl_volatile.replace("{tokens}", &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 + .replace("{count}", &blocks.len().to_string()) + .replace("{tokens}", &total_est.to_string()) ); } Some(SystemPrompt::Text(text)) => { @@ -216,10 +243,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 + .replace("{count}", &layers.len().to_string()) + .replace("{tokens}", &total_est.to_string()) ); for layer in layers { let tokens = text_tokens(layer.body); @@ -228,27 +258,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("{tokens}", &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 +314,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 +332,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("{count}", &remaining.to_string()) + ); } break; } @@ -316,12 +348,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 +370,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 +387,12 @@ 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 +403,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 +417,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 +483,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 +510,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 +525,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 +554,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 +595,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 +607,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 +626,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 f2ce686e6..ba97a3f4f 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, } } } @@ -265,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( @@ -276,7 +285,8 @@ 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( @@ -286,7 +296,9 @@ 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, @@ -296,7 +308,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) } @@ -651,28 +663,43 @@ 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), } } } @@ -703,7 +730,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( @@ -711,6 +738,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); @@ -725,6 +753,7 @@ impl ExecCell { self.status, self.started_at, low_motion, + locale, )); if self.status == ToolStatus::Success && self.source == ExecSource::User { @@ -797,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) -> 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 @@ -816,6 +850,7 @@ impl ExploringCell { status, None, low_motion, + locale, )); for entry in &self.entries { @@ -859,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) -> 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", @@ -867,6 +907,7 @@ impl PlanUpdateCell { self.status, None, low_motion, + locale, )); if let Some(explanation) = self.explanation.as_ref() { @@ -919,6 +960,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( @@ -928,6 +970,7 @@ impl PatchSummaryCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "file", @@ -968,6 +1011,7 @@ impl ReviewCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( @@ -976,6 +1020,7 @@ impl ReviewCell { self.status, None, low_motion, + locale, )); if !self.target.trim().is_empty() { @@ -1097,7 +1142,12 @@ 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( @@ -1107,6 +1157,7 @@ impl DiffPreviewCell { ToolStatus::Success, None, low_motion, + locale, )); lines.extend(render_compact_kv( "title", @@ -1134,6 +1185,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( @@ -1143,6 +1195,7 @@ impl McpToolCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "name", @@ -1180,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) -> 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", @@ -1189,6 +1247,7 @@ impl ViewImageCell { ToolStatus::Success, None, low_motion, + locale, )]; lines.extend(render_compact_kv("path", &path, tool_value_style(), width)); lines @@ -1205,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) -> 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", @@ -1214,6 +1278,7 @@ impl WebSearchCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "query", @@ -1269,11 +1334,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; } @@ -1287,7 +1353,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(); @@ -1307,6 +1373,7 @@ impl GenericToolCell { self.status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "name", @@ -1355,6 +1422,7 @@ impl GenericToolCell { self.status, None, low_motion, + locale, )); lines.extend(diff_render::render_diff(output, width)); } else { @@ -1384,7 +1452,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 @@ -1398,6 +1466,7 @@ impl GenericToolCell { self.status, None, low_motion, + locale, )] } @@ -1409,6 +1478,7 @@ impl GenericToolCell { width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Option>> { if !is_checklist_tool_name(&self.name) { return None; @@ -1433,6 +1503,7 @@ impl GenericToolCell { &change, width, low_motion, + locale, )); } @@ -1443,6 +1514,7 @@ impl GenericToolCell { width, low_motion, mode, + locale, )) } } @@ -1630,6 +1702,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!( @@ -1644,6 +1717,7 @@ fn render_checklist_change_card( status, None, low_motion, + locale, )); // Look up the title from the snapshot. `id` in tool input is @@ -1719,6 +1793,7 @@ fn render_checklist_card( width: u16, low_motion: bool, mode: RenderMode, + locale: Locale, ) -> Vec> { let mut lines = Vec::new(); let header_summary = format!( @@ -1733,6 +1808,7 @@ fn render_checklist_card( status, None, low_motion, + locale, )); lines.extend(render_compact_kv( "checklist", @@ -2964,9 +3040,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( @@ -2976,10 +3053,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, ) } @@ -2992,8 +3070,11 @@ 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( @@ -3003,6 +3084,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 @@ -3017,7 +3099,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( @@ -3329,11 +3411,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 @@ -3362,37 +3448,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())) @@ -3416,7 +3499,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())) @@ -3438,7 +3521,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| { @@ -3515,7 +3598,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(); @@ -3550,7 +3633,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:?}"); // … @@ -3572,7 +3655,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"); @@ -3592,7 +3675,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" @@ -3680,7 +3763,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()); @@ -3742,7 +3826,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")); @@ -4268,7 +4353,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() @@ -4297,7 +4382,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() @@ -4426,7 +4511,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 2ebd58dd6..954945a6a 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; @@ -1423,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)); + 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); } @@ -1488,7 +1495,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), @@ -1521,13 +1528,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), } } @@ -1538,6 +1545,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)); @@ -1548,7 +1556,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; @@ -1888,6 +1896,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; @@ -2560,7 +2569,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()]); } @@ -2600,7 +2609,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!( @@ -2631,7 +2640,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]); @@ -2650,7 +2659,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]); } @@ -2670,7 +2679,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() @@ -2688,7 +2697,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 fb89de619..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(), + "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(), + "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(), + "mode": app.mode.label(Locale::En), }), ); app.view_stack @@ -2028,7 +2029,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}")); } @@ -4618,7 +4620,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, @@ -4864,6 +4866,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 +5631,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( @@ -5766,6 +5769,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..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); + 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 68ce1ac7a..5930ee7b5 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), } } @@ -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,10 +843,10 @@ 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(); + let scope = row.scope.label(self.locale).to_lowercase(); filter.split_whitespace().all(|term| { section.contains(term) @@ -975,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 => { @@ -1148,7 +1152,10 @@ 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}, @@ -1156,7 +1163,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), )); @@ -1350,20 +1357,29 @@ 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), ])); } @@ -1425,7 +1441,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(), ))); } @@ -1449,7 +1465,7 @@ impl ModalView for ConfigView { " {:, scroll: usize, + locale: Locale, } /// Build the agent rows shown by `/subagents`. @@ -1648,8 +1665,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, + } } } @@ -1712,7 +1733,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 { @@ -1733,15 +1754,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(), ))); @@ -1789,38 +1830,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, ); } @@ -1839,11 +1885,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)), ])) @@ -1864,6 +1910,7 @@ fn append_subagent_group( section_style: ratatui::style::Style, agents: &[&SubAgentResult], content_width: usize, + locale: Locale, ) { use ratatui::{ style::Style, @@ -1886,7 +1933,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(" "), @@ -1913,7 +1960,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)), @@ -1970,28 +2017,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 { @@ -2048,7 +2105,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 +2190,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 +2223,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/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 5b7098a0e..2acbf5339 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -17,9 +17,10 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tools::subagent::MailboxMessage; -use crate::tui::widgets::tool_card::{ToolFamily, family_glyph, family_label}; +use crate::tui::widgets::tool_card::{ToolFamily, family_glyph, family_label_locale}; /// Maximum number of recent actions kept on a `DelegateCard`. Older entries /// are dropped from the head; an ellipsis row signals truncation. @@ -40,13 +41,13 @@ impl AgentLifecycle { matches!(self, Self::Completed | Self::Failed | Self::Cancelled) } - fn label(self) -> &'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,13 @@ 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 +351,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 +364,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 +373,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 +504,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 +539,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 +573,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 +595,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 +625,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 +665,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..e3f34e4c9 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,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).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, ); @@ -724,14 +732,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 +752,7 @@ mod tests { "repo", false, palette::DEEPSEEK_INK, + Locale::En, ), 48, ); @@ -760,6 +770,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 +789,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ) .with_provider(Some("NIM")), 72, @@ -797,6 +809,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ), 72, ); @@ -862,6 +875,7 @@ mod tests { "codewhale-tui", false, palette::DEEPSEEK_INK, + Locale::En, ) .with_reasoning_effort(Some("max")) .with_status_indicator(Some("🐳")), @@ -893,6 +907,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 2c478a29e..593a1a32c 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -594,23 +594,38 @@ 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( + "{count}", + &(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 +645,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), ))) @@ -1484,11 +1500,16 @@ 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, + } } } @@ -1611,12 +1632,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), ), ])); @@ -1866,7 +1887,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 { @@ -1891,7 +1912,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), )); } @@ -3008,6 +3029,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(); @@ -3031,6 +3053,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(); diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index cc3829dbc..6e48f10e1 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("{key}", self.edit_binding.label), dim, )])); } diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index 6020069b1..fd9348836 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)] @@ -150,21 +152,22 @@ 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. +/// 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(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", - } +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 @@ -198,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() { @@ -269,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", ); } 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"),