From 8073e78ae00f628cfca705cebc1938d7c4821ed8 Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Thu, 29 Oct 2026 20:59:41 +0000 Subject: [PATCH 1/9] agent: add Fluent localization foundation --- .agents/skills/develop-maple-agent/SKILL.md | 10 + apps/maple-agent/CLAUDE.md | 9 + apps/maple-agent/Cargo.lock | 151 ++++++++- apps/maple-agent/app/Cargo.toml | 8 + apps/maple-agent/app/build.rs | 2 + apps/maple-agent/app/locales/en/app.ftl | 12 + apps/maple-agent/app/src/desktop.rs | 8 +- apps/maple-agent/app/src/settings.rs | 28 ++ apps/maple-agent/app/src/ui/i18n.rs | 182 +++++++++++ apps/maple-agent/app/src/ui/i18n/tests.rs | 287 ++++++++++++++++++ apps/maple-agent/app/src/ui/mod.rs | 1 + apps/maple-agent/app/src/ui/settings.rs | 39 ++- .../app/src/ui/settings/navigation.rs | 28 ++ .../app/src/ui/update_confirmation.rs | 40 ++- apps/maple-agent/app/src/update.rs | 2 + .../app/tests/fixtures/i18n/en/app.ftl | 4 + .../app/tests/fixtures/i18n/fr/app.ftl | 3 + apps/maple-agent/docs/localization.md | 90 ++++++ 18 files changed, 893 insertions(+), 11 deletions(-) create mode 100644 apps/maple-agent/app/locales/en/app.ftl create mode 100644 apps/maple-agent/app/src/ui/i18n.rs create mode 100644 apps/maple-agent/app/src/ui/i18n/tests.rs create mode 100644 apps/maple-agent/app/tests/fixtures/i18n/en/app.ftl create mode 100644 apps/maple-agent/app/tests/fixtures/i18n/fr/app.ftl create mode 100644 apps/maple-agent/docs/localization.md diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index b04f74baf..cae70e8d1 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -108,3 +108,13 @@ platform ([Agent updates](../../../apps/maple-agent/docs/updates.md)). Releasing is a production action, so run it only when the owner asks. Installed clients parse the feed strictly; keep the wire contract in that doc unchanged. See [desktop builds](../../../apps/maple-agent/docs/desktop-builds.md) for packaging. + +## Localization + +New Agent UI labels use `ui::i18n` and the embedded Fluent catalogs; follow +[the localization contract](../../../apps/maple-agent/docs/localization.md). +Plain labels are cached; parameterized messages are formatted on state changes. +Language changes apply on restart. Preserve machine identifiers, model-facing +text, user content and persisted timeline text. Do not translate host/ACP errors +in place. Expand the converted-module guard with each extraction, and require +platform/font/packaging evidence before enabling another locale. diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index 8d271ea11..bd6b256e6 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -164,3 +164,12 @@ This app must feel instant. Treat frame time and UI-thread stalls as bugs. cached view. Avoid `Window::refresh`; it discards every cached view. - Release builds use fat LTO and one codegen unit. Use release builds for any performance check; the dev profile is `opt-level = 1`. + +## Localization + +Use the embedded Fluent catalog and `ui::i18n` for new UI labels, including +accessibility text; see [localization](docs/localization.md) for extraction and +locale enablement. Cache parameterized messages when their state changes. Keep +element IDs, protocol values, model prompts, user content and persisted timeline +text unchanged. Language changes apply on restart. Extend the converted-module +guard as each module is extracted; host errors and native menus need owned work. diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index be62c786f..9da0e15d6 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -3383,6 +3383,71 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.3", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.20", +] + +[[package]] +name = "fluent-template-macros" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f17b7ed5b7108d57ca049e1fa7583d4fadbb71ef0e47a72dc847c1e704f37fb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unic-langid", + "walkdir", +] + +[[package]] +name = "fluent-templates" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fc3bfc211d9a9210bd7ccba798da57148e5b02c62b98b6cef09d7228f2f232" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "fluent-template-macros", + "intl-memoizer", + "log", + "thiserror 2.0.20", + "unic-langid", + "walkdir", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -4904,6 +4969,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.24" @@ -5829,6 +5913,9 @@ dependencies = [ "dirs 6.0.0", "embed-resource", "env_logger", + "fluent-bundle", + "fluent-syntax", + "fluent-templates", "futures-lite", "futures-util", "gpui", @@ -5863,6 +5950,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "spellbook", + "syn 2.0.119", + "sys-locale", "tempfile", "tokio", "tokio-util", @@ -8205,6 +8294,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -10529,7 +10624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -10689,6 +10784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -11087,6 +11183,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "typeid" version = "1.0.3" @@ -11116,6 +11221,49 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.119", + "unic-langid-impl", +] + [[package]] name = "unicase" version = "2.9.0" @@ -13390,6 +13538,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index dc5842871..6e425ec64 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -15,6 +15,9 @@ default = ["desktop", "acp", "proxy"] # The gpui window. Without it the binary is headless and needs no display # libraries; only `acp` and `proxy` modes remain. desktop = [ + "dep:fluent-bundle", + "dep:fluent-templates", + "dep:sys-locale", "dep:gpui", "dep:gpui-platform", "dep:image", @@ -38,6 +41,9 @@ acp = ["maple-agent/acp"] proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http", "dep:hyper", "dep:hyper-util", "dep:futures-util"] [dependencies] +fluent-bundle = { version = "0.16", optional = true } +fluent-templates = { version = "0.15.1", default-features = false, features = ["macros", "walkdir"], optional = true } +sys-locale = { version = "0.3", optional = true } dirs = "6" maple-agent = { path = "../crates/maple-agent", default-features = false } maple-billing = { path = "../crates/maple-billing" } @@ -113,6 +119,8 @@ raw-window-handle = { version = "0.6", optional = true } windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory", "Win32_System_Threading", "Win32_System_ErrorReporting", "Win32_System_SystemInformation", "Win32_Storage_FileSystem"] } [dev-dependencies] +fluent-syntax = "0.12" +syn = { version = "2", features = ["full", "visit"] } tempfile = "3" parking_lot = "0.12" # The gpui test harness. Keep this out of [dependencies]: with test-support on, diff --git a/apps/maple-agent/app/build.rs b/apps/maple-agent/app/build.rs index e39af89bf..4d0de3d61 100644 --- a/apps/maple-agent/app/build.rs +++ b/apps/maple-agent/app/build.rs @@ -7,6 +7,8 @@ mod git_metadata; mod release_profile; fn main() { + // Track added and removed catalogs as well as edits to embedded resources. + println!("cargo:rerun-if-changed=locales"); release_profile::emit("../release-profiles.json", "../release_profile.rs", true); let git_metadata::GitMetadata { revision, diff --git a/apps/maple-agent/app/locales/en/app.ftl b/apps/maple-agent/app/locales/en/app.ftl new file mode 100644 index 000000000..2f3030aff --- /dev/null +++ b/apps/maple-agent/app/locales/en/app.ftl @@ -0,0 +1,12 @@ +# Source catalog. Keep message IDs stable; never translate protocol or element IDs. +settings-language = Language +settings-language-description = Applies after restarting Maple. Text not yet translated remains in English. +# $language is the native name of the resolved system language. +settings-language-system = System ({ $language }) +language-name = English +update-confirmation-title = Restart Maple to update? +# $version is the target application version, not translated. +update-confirmation-title-version = Restart Maple to install v{ $version }? +update-confirmation-detail = This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update. +update-confirmation-later = Later +update-confirmation-restart = Restart to Update diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 655262b6a..6488c1c8c 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -895,6 +895,7 @@ impl MapleApp { } } this.set_store_resident(status.keeps_resident(), cx); + ui::i18n::initialize_saved_preference(this.settings.locale.as_deref()); ui::theme::apply_preference( ui::theme::Preference::parse(&this.settings.theme), cx, @@ -1120,10 +1121,15 @@ impl MapleApp { return; } self.restart_prompt_pending = true; + #[cfg(not(target_os = "linux"))] let title = crate::update::restart_title(self.updater.snapshot().version.as_deref()); #[cfg(target_os = "linux")] let answer = { - let (prompt, answer) = ui::update_confirmation::open(title, window, cx); + let (prompt, answer) = ui::update_confirmation::open( + self.updater.snapshot().version.as_deref(), + window, + cx, + ); self.restart_confirmation = Some(prompt); cx.notify(); answer diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 634dcb4e4..33b58b5a9 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -135,6 +135,9 @@ pub struct AppSettings { /// Window size and state from the last run. #[serde(default)] pub window: Option, + /// UI language override; None follows the operating system. + #[serde(default, deserialize_with = "view_preference_or_default")] + pub locale: Option, /// Color theme: "system", "dark", or "light". #[serde(default = "default_theme")] pub theme: String, @@ -318,6 +321,7 @@ impl Default for AppSettings { reduce_motion: false, harness_instructions: String::new(), window: None, + locale: None, theme: default_theme(), chat_font_family: default_chat_font_family(), chat_font_size: default_chat_font_size(), @@ -987,6 +991,30 @@ pub(crate) fn install_test_store(store: std::sync::Arc] = &[None, Some("en")]; +static NATIVE_NAMES: LazyLock> = LazyLock::new(|| { + LANGUAGES + .iter() + .flatten() + .map(|locale| { + ( + *locale, + lookup( + &locale.parse().expect("enabled locale"), + "language-name", + &HashMap::new(), + ) + .into(), + ) + }) + .collect() +}); +static SYSTEM: LazyLock = LazyLock::new(|| { + // English assertions must not depend on the machine running the suite. + #[cfg(test)] + { + ENGLISH + } + #[cfg(not(test))] + { + resolve(sys_locale::get_locale().as_deref()) + } +}); + +struct LocaleState { + locale: LanguageIdentifier, + loaded: bool, + messages: HashMap<&'static str, SharedString>, + system_label: Option, +} + +thread_local! { + // GPUI renders on its owning thread. Tests have independent UI threads. + static ACTIVE: RefCell = RefCell::new(LocaleState { + locale: SYSTEM.clone(), loaded: false, messages: HashMap::new(), system_label: None, + }); +} + +fn resolve(requested: Option<&str>) -> LanguageIdentifier { + resolve_enabled(requested, LANGUAGES) +} + +fn resolve_enabled(requested: Option<&str>, enabled: &[Option<&str>]) -> LanguageIdentifier { + let Some(requested) = requested else { + return ENGLISH; + }; + // Accept POSIX locale spellings as well as BCP-47 from platform APIs. + let normalized = requested + .split(['.', '@']) + .next() + .unwrap_or_default() + .replace('_', "-"); + let Ok(mut locale) = normalized.parse::() else { + return ENGLISH; + }; + loop { + let tag = locale.to_string(); + if enabled.iter().flatten().any(|enabled| *enabled == tag) { + return locale; + } + if locale.variants().next().is_some() { + locale.clear_variants(); + } else if locale.region.is_some() { + locale.region = None; + } else if locale.script.is_some() { + locale.script = None; + } else { + return ENGLISH; + } + } +} + +/// Called once after the host hydrates saved settings. Before unlock the UI +/// uses the OS default. Later edits deliberately take effect on next launch: +/// cached panels, precomputed sections and native menus have separate lifecycles. +pub(crate) fn initialize_saved_preference(preference: Option<&str>) { + ACTIVE.with_borrow_mut(|state| { + if state.loaded { + return; + } + state.locale = preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))); + state.messages.clear(); + state.system_label = None; + state.loaded = true; + }); +} + +pub(super) fn preference_label(preference: Option<&str>) -> SharedString { + if let Some(locale) = preference { + // Only enabled values can be picked. Unknown saved preferences resolve + // to English while retaining their value for a future upgraded app. + return NATIVE_NAMES + .get(locale) + .unwrap_or(&NATIVE_NAMES["en"]) + .clone(); + } + const KEY: &str = "settings-language-system"; + if let Some(label) = ACTIVE.with_borrow(|state| state.system_label.clone()) { + return label; + } + let name = lookup(&SYSTEM, "language-name", &HashMap::new()); + let label = tr_args(KEY, [("language", name.into())]); + ACTIVE.with_borrow_mut(|state| { + state.system_label = Some(label.clone()); + }); + label +} + +pub(crate) fn tr(key: &'static str) -> SharedString { + ACTIVE.with_borrow_mut(|state| { + state + .messages + .entry(key) + .or_insert_with(|| lookup(&state.locale, key, &HashMap::new()).into()) + .clone() + }) +} + +/// Values retain their Fluent type: pass numbers as numbers for CLDR plurals. +/// Fluent's bidi isolation remains enabled for interpolated text. +pub(crate) fn tr_args<'a>( + key: &'static str, + args: impl IntoIterator)>, +) -> SharedString { + let args = args.into_iter().collect(); + ACTIVE.with_borrow(|state| lookup(&state.locale, key, &args).into()) +} + +fn lookup(locale: &LanguageIdentifier, key: &str, args: &HashMap<&str, FluentValue<'_>>) -> String { + lookup_from(&CATALOGS, locale, key, args) +} + +fn lookup_from( + catalogs: &fluent_templates::StaticLoader, + locale: &LanguageIdentifier, + key: &str, + args: &HashMap<&str, FluentValue<'_>>, +) -> String { + if let Ok(value) = catalogs.lookup_single_language(locale, key, Some(args)) + && !value.trim().is_empty() + { + return value; + } + // Do not log arguments or resolver errors: they can contain user content. + log::warn!("Localization fallback for key {key} in locale {locale}"); + if *locale != ENGLISH + && let Ok(value) = catalogs.lookup_single_language(&ENGLISH, key, Some(args)) + && !value.trim().is_empty() + { + return value; + } + key.to_owned() +} + +#[cfg(test)] +#[path = "i18n/tests.rs"] +mod tests; diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs new file mode 100644 index 000000000..fa227b617 --- /dev/null +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -0,0 +1,287 @@ +use super::*; +use fluent_bundle::{FluentArgs, FluentBundle, FluentResource}; +use fluent_syntax::{ast::Entry, parser}; +use fluent_templates::Loader; +use std::{collections::BTreeSet, path::Path}; +use syn::visit::Visit; + +#[test] +fn locale_resolution_accepts_os_spellings_and_falls_back_safely() { + for locale in [ + None, + Some("en"), + Some("en-US"), + Some("en_GB.UTF-8"), + Some("en_US@variant"), + Some("C"), + Some("fr-FR"), + Some("../../en"), + Some(""), + ] { + assert_eq!(resolve(locale), ENGLISH); + } +} + +#[test] +fn resolution_prefers_exact_catalogs_then_language_without_losing_script_first() { + let enabled = [ + None, + Some("en"), + Some("fr"), + Some("fr-CA"), + Some("zh"), + Some("zh-Hant"), + ]; + for (requested, expected) in [ + ("fr-CA", "fr-CA"), + ("fr_FR.UTF-8", "fr"), + ("zh-Hant-TW", "zh-Hant"), + ("zh-Hans-CN", "zh"), + ("de-DE", "en"), + ] { + assert_eq!( + resolve_enabled(Some(requested), &enabled).to_string(), + expected + ); + } +} + +#[test] +fn missing_keys_and_locales_fall_back_without_blank_text() { + let args = HashMap::new(); + assert_eq!( + lookup(&langid!("fr"), "update-confirmation-later", &args), + "Later" + ); + assert_eq!(lookup(&ENGLISH, "missing-key", &args), "missing-key"); + // A missing variable must not leak Fluent's error placeholder into UI. + assert_eq!( + lookup(&ENGLISH, "settings-language-system", &args), + "settings-language-system" + ); + assert_eq!( + tr_args("settings-language-system", [("language", "English".into())]).as_ref(), + "System (\u{2068}English\u{2069})" + ); +} + +#[test] +fn saved_preference_is_loaded_once_and_plain_labels_are_cached() { + ACTIVE.with_borrow_mut(|state| { + state.locale = langid!("fr"); + state.loaded = false; + state.messages.clear(); + }); + initialize_saved_preference(Some("en-US")); + assert_eq!(tr("update-confirmation-later").as_ref(), "Later"); + ACTIVE.with_borrow_mut(|state| { + assert_eq!(state.locale, ENGLISH); + assert_eq!(state.messages.len(), 1); + // A cached label remains usable even if a catalog cannot be queried. + state + .messages + .insert("update-confirmation-later", "cached".into()); + }); + ACTIVE.with_borrow_mut(|state| state.locale = langid!("fr")); + initialize_saved_preference(Some("en")); + ACTIVE.with_borrow(|state| assert_eq!(state.locale, langid!("fr"))); + assert_eq!(tr("update-confirmation-later").as_ref(), "cached"); + ACTIVE.with_borrow_mut(|state| { + state.messages.clear(); + state.locale = ENGLISH; + }); +} + +fn catalog_keys(source: &str) -> BTreeSet { + let resource = parser::parse(source).expect("valid Fluent syntax"); + let mut keys = BTreeSet::new(); + for entry in resource.body { + match entry { + Entry::Message(message) => { + assert!( + message.value.is_some(), + "message {} must have a value", + message.id.name + ); + assert!(keys.insert(message.id.name.to_owned()), "duplicate message"); + for attribute in message.attributes { + assert!(keys.insert(format!("{}.{}", message.id.name, attribute.id.name))); + } + } + Entry::Term(term) => { + assert!(keys.insert(format!("-{}", term.id.name)), "duplicate term"); + } + _ => {} + } + } + keys +} + +#[test] +fn shipped_catalogs_parse_and_have_identical_keys() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let english = catalog_keys(&std::fs::read_to_string(root.join("en/app.ftl")).unwrap()); + let mut locales = BTreeSet::new(); + for entry in std::fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + assert!( + entry.file_type().unwrap().is_dir(), + "catalogs live in locale/app.ftl" + ); + let locale = entry.file_name().into_string().unwrap(); + locale.parse::().unwrap(); + assert_eq!( + std::fs::read_dir(entry.path()).unwrap().count(), + 1, + "one app.ftl per locale" + ); + let source = std::fs::read_to_string(entry.path().join("app.ftl")).unwrap(); + assert_eq!(catalog_keys(&source), english, "catalog {locale}"); + locales.insert(locale); + } + assert_eq!( + locales, + LANGUAGES + .iter() + .flatten() + .map(|locale| locale.to_string()) + .collect() + ); + assert_eq!( + locales, + CATALOGS.locales().map(ToString::to_string).collect() + ); +} + +#[test] +fn numeric_arguments_use_locale_plural_rules() { + let mut bundle = FluentBundle::new(vec![langid!("ru")]); + bundle + .add_resource( + FluentResource::try_new( + "count = { $count ->\n [one] one\n [few] few\n [many] many\n *[other] other\n}" + .to_owned(), + ) + .unwrap(), + ) + .unwrap(); + let pattern = bundle.get_message("count").unwrap().value().unwrap(); + for (count, expected) in [(1, "one"), (2, "few"), (5, "many"), (21, "one")] { + let mut args = FluentArgs::new(); + args.set("count", count); + let mut errors = Vec::new(); + assert_eq!( + bundle.format_pattern(pattern, Some(&args), &mut errors), + expected + ); + assert!(errors.is_empty()); + } +} + +#[derive(Default)] +struct UiStrings { + keys: BTreeSet, + literals: Vec, +} + +impl<'ast> Visit<'ast> for UiStrings { + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if node.ident != "tests" { + syn::visit::visit_item_mod(self, node); + } + } + + fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { + if let syn::Expr::Path(path) = &*node.func + && path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "tr" || segment.ident == "tr_args") + && let Some(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(key), + .. + })) = node.args.first() + { + self.keys.insert(key.value()); + } + syn::visit::visit_expr_call(self, node); + } + + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if ["child", "aria_label", "tooltip", "placeholder"] + .iter() + .any(|name| node.method == name) + && let Some(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + })) = node.args.first() + { + self.literals.push(text.value()); + } + syn::visit::visit_expr_method_call(self, node); + } +} + +fn visit_ui(path: &Path, visitor: &mut UiStrings) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit_ui(&path, visitor); + } else if path.extension().is_some_and(|extension| extension == "rs") + && !path.ends_with("i18n/tests.rs") + { + visitor.visit_file(&syn::parse_file(&std::fs::read_to_string(path).unwrap()).unwrap()); + } + } +} + +#[test] +fn ui_lookup_keys_exist_in_source_catalog() { + let mut visitor = UiStrings::default(); + visit_ui( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src/ui"), + &mut visitor, + ); + let keys = catalog_keys(include_str!("../../../locales/en/app.ftl")); + assert!(!visitor.keys.is_empty()); + for key in visitor.keys { + assert!(keys.contains(&key), "missing source message: {key}"); + } +} + +#[test] +fn converted_modules_do_not_reintroduce_inline_labels() { + // Grow this list with each extraction. Unconverted modules are explicitly + // outside the gate rather than freezing hundreds of existing literals. + let mut visitor = UiStrings::default(); + visitor.visit_file(&syn::parse_file(include_str!("../update_confirmation.rs")).unwrap()); + assert!( + visitor.literals.is_empty(), + "inline UI text: {:?}", + visitor.literals + ); +} + +fluent_templates::static_loader! { + static PARTIAL = { + locales: "./tests/fixtures/i18n", + fallback_language: "en", + }; +} + +#[test] +fn partial_catalog_falls_back_per_message_including_format_errors() { + for (key, expected) in [ + ("present", "Français"), + ("missing", "English fallback"), + ("invalid", "Safe fallback"), + ("empty", "Nonempty fallback"), + ("unknown", "unknown"), + ] { + assert_eq!( + lookup_from(&PARTIAL, &langid!("fr"), key, &HashMap::new()), + expected + ); + } +} diff --git a/apps/maple-agent/app/src/ui/mod.rs b/apps/maple-agent/app/src/ui/mod.rs index 251a55b9f..38f10486c 100644 --- a/apps/maple-agent/app/src/ui/mod.rs +++ b/apps/maple-agent/app/src/ui/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod application_vim; pub mod chat; pub mod context_ring; pub mod decorations; +pub(crate) mod i18n; pub mod icons; pub mod login; pub mod markdown; diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 0717607f9..9b2be2e27 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -115,6 +115,7 @@ impl Section { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SettingMenu { Appearance, + Language, ChatFont, ChatSize, Voice, @@ -126,6 +127,7 @@ impl SettingMenu { fn id(self) -> &'static str { match self { Self::Appearance => "appearance", + Self::Language => "language", Self::ChatFont => "chat-font", Self::ChatSize => "chat-size", Self::Voice => "voice", @@ -951,6 +953,13 @@ impl SettingsScreen { /// share this order, so an index means the same option in both. fn menu_options(&self, menu: SettingMenu) -> Vec { match menu { + SettingMenu::Language => super::i18n::LANGUAGES + .iter() + .map(|locale| SettingOption { + label: super::i18n::preference_label(*locale).to_string(), + current: self.settings.locale.as_deref() == *locale, + }) + .collect(), SettingMenu::Appearance => [ theme::Preference::System, theme::Preference::Dark, @@ -1000,6 +1009,9 @@ impl SettingsScreen { /// The saved value shown on the dropdown's trigger button. fn menu_value(&self, menu: SettingMenu) -> String { match menu { + SettingMenu::Language => { + super::i18n::preference_label(self.settings.locale.as_deref()).to_string() + } SettingMenu::Appearance => self.theme.label().to_string(), SettingMenu::ChatFont => { crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family) @@ -1119,6 +1131,13 @@ impl SettingsScreen { cx: &mut Context, ) { match menu { + SettingMenu::Language => { + let Some(locale) = super::i18n::LANGUAGES.get(index) else { + return; + }; + let locale = locale.map(str::to_owned); + self.edit_setting(move |settings| settings.locale = locale, cx); + } SettingMenu::Appearance => { let Some(preference) = [ theme::Preference::System, @@ -1266,7 +1285,11 @@ impl SettingsScreen { })); self.popup.render(dropdown, Placement::BelowEnd, window, cx) }); - let slug = title.to_lowercase().replace(' ', "-"); + let slug = if menu == SettingMenu::Language { + menu.id().to_owned() + } else { + title.to_lowercase().replace(' ', "-") + }; let button = widgets::secondary_button(gpui::SharedString::from(format!("setting-value-{slug}"))) .debug_selector(move || format!("setting-value-{slug}")) @@ -1277,7 +1300,11 @@ impl SettingsScreen { .child(self.menu_value_label(menu)) .child(icon("chevron-down", px(12.), theme::text_muted())); widgets::card_row() - .id(setting_row_id(title)) + .id(if menu == SettingMenu::Language { + gpui::SharedString::from("setting-language") + } else { + setting_row_id(title) + }) .flex() .items_center() .justify_between() @@ -1748,6 +1775,14 @@ impl SettingsScreen { cx, ), )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Language), + self.setting_menu_row( + &super::i18n::tr("settings-language"), + &super::i18n::tr("settings-language-description"), + SettingMenu::Language, window, cx, + ), + )) .child(section_title("Chat appearance")) .child(self.application_target( || SettingsTarget::General(GeneralTarget::ChatFont), diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index ba3508c2a..1eaffe0e6 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -28,6 +28,7 @@ pub(super) enum SettingsRegion { pub(super) enum GeneralTarget { Web, Appearance, + Language, ChatFont, ChatSize, ToolDetails, @@ -134,6 +135,7 @@ impl SettingsScreen { Section::General => [ GeneralTarget::Web, GeneralTarget::Appearance, + GeneralTarget::Language, GeneralTarget::ChatFont, GeneralTarget::ChatSize, GeneralTarget::ToolDetails, @@ -388,6 +390,7 @@ impl SettingsScreen { fn activate_general_target(&mut self, target: GeneralTarget, cx: &mut Context) { match target { GeneralTarget::Web => self.toggle_web_default(cx), + GeneralTarget::Language => self.toggle_setting_menu(SettingMenu::Language, cx), GeneralTarget::Appearance => self.toggle_setting_menu(SettingMenu::Appearance, cx), GeneralTarget::ChatFont => self.toggle_setting_menu(SettingMenu::ChatFont, cx), GeneralTarget::ChatSize => self.toggle_setting_menu(SettingMenu::ChatSize, cx), @@ -1181,6 +1184,25 @@ mod tests { ); } + #[gpui::test] + fn language_dropdown_supports_pointer_and_keyboard_selection(cx: &mut TestAppContext) { + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); + let (settings, cx) = dropdown_window(cx, false); + for expected in [Some("en"), None] { + let button = cx + .debug_bounds("setting-value-language") + .expect("language selector"); + cx.simulate_click(button.center(), gpui::Modifiers::default()); + cx.simulate_keystrokes("down enter"); + settings.update(cx, |this, _| { + assert_eq!(this.settings.locale.as_deref(), expected); + assert_eq!(this.popup.open_key(), None); + }); + } + crate::settings::discard_decrypted_cache(); + } + #[gpui::test] fn picking_from_the_dropdown_persists_in_reopened_store(cx: &mut TestAppContext) { let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); @@ -1197,6 +1219,8 @@ mod tests { settings.update(cx, |this, cx| { this.toggle_setting_menu(SettingMenu::Appearance, cx); this.pick_setting_option(SettingMenu::Appearance, 2, cx); + this.toggle_setting_menu(SettingMenu::Language, cx); + this.pick_setting_option(SettingMenu::Language, 1, cx); }); crate::settings::update_settings_and_wait(|_| {}); // Detach the writer's Arc and discard its cached value before closing @@ -1216,6 +1240,10 @@ mod tests { Some("light"), "a dropdown pick must reach the authoritative store" ); + assert_eq!( + saved.get("locale").and_then(serde_json::Value::as_str), + Some("en") + ); reopened.close().unwrap(); cx.run_until_parked(); } diff --git a/apps/maple-agent/app/src/ui/update_confirmation.rs b/apps/maple-agent/app/src/ui/update_confirmation.rs index 2a74d1e9e..c17e86a2f 100644 --- a/apps/maple-agent/app/src/ui/update_confirmation.rs +++ b/apps/maple-agent/app/src/ui/update_confirmation.rs @@ -4,7 +4,10 @@ use gpui::{App, Context, Entity, FocusHandle, Render, SharedString, Window, div, prelude::*, px}; use tokio::sync::oneshot; -use super::{theme, widgets}; +use super::{ + i18n::{tr, tr_args}, + theme, widgets, +}; pub(crate) struct UpdateConfirmation { title: SharedString, @@ -15,14 +18,22 @@ pub(crate) struct UpdateConfirmation { } pub(crate) fn open( - title: String, + version: Option<&str>, window: &mut Window, cx: &mut App, ) -> (Entity, oneshot::Receiver) { let (sender, receiver) = oneshot::channel(); let previous_focus = window.focused(cx); let view = cx.new(|cx| UpdateConfirmation { - title: title.into(), + title: version.map_or_else( + || tr("update-confirmation-title"), + |version| { + tr_args( + "update-confirmation-title-version", + [("version", version.into())], + ) + }, + ), answer: Some(sender), previous_focus, later_focus: cx.focus_handle(), @@ -119,7 +130,7 @@ impl Render for UpdateConfirmation { .min_h_0() .overflow_y_scroll() .text_sm() - .child(crate::update::RESTART_DETAIL), + .child(tr("update-confirmation-detail")), ) .child( div() @@ -133,7 +144,7 @@ impl Render for UpdateConfirmation { .debug_selector(|| "update-confirmation-later".to_string()) .track_focus(&self.later_focus) .focus_visible(widgets::focus_ring) - .child("Later") + .child(tr("update-confirmation-later")) .on_click(cx.listener(|this, _, window, cx| { this.respond(0, window, cx) })), @@ -143,7 +154,7 @@ impl Render for UpdateConfirmation { .debug_selector(|| "update-confirmation-restart".to_string()) .track_focus(&self.restart_focus) .focus_visible(widgets::focus_ring) - .child("Restart to Update") + .child(tr("update-confirmation-restart")) .on_click(cx.listener(|this, _, window, cx| { this.respond(1, window, cx) })), @@ -179,7 +190,11 @@ mod tests { let answer = Rc::new(RefCell::new(None)); let result = answer.clone(); let (host, cx) = cx.add_window_view(move |window, cx| { - let (prompt, receiver) = open(crate::update::restart_title(Some("0.0.14")), window, cx); + let (prompt, receiver) = open(Some("0.0.14"), window, cx); + assert_eq!( + prompt.read(cx).title.as_ref(), + "Restart Maple to install v\u{2068}0.0.14\u{2069}?" + ); *result.borrow_mut() = Some(receiver); PromptHost { prompt: Some(prompt), @@ -189,6 +204,17 @@ mod tests { (host, receiver, cx) } + #[gpui::test] + fn updater_confirmation_without_version_uses_generic_title(cx: &mut gpui::TestAppContext) { + cx.add_window_view(|window, cx| { + let (prompt, _answer) = open(None, window, cx); + assert_eq!(prompt.read(cx).title.as_ref(), "Restart Maple to update?"); + PromptHost { + prompt: Some(prompt), + } + }); + } + #[gpui::test] fn updater_confirmation_wraps_and_keeps_actions_inside_the_viewport( cx: &mut gpui::TestAppContext, diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index f35bb06ed..e5cc6e7ec 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -45,6 +45,7 @@ pub enum UpdateAction { /// Title of the restart confirmation. It names the version, since the /// sidebar's update button shows only an icon. +#[cfg(not(target_os = "linux"))] pub fn restart_title(version: Option<&str>) -> String { version.map_or_else( || "Restart Maple to update?".to_string(), @@ -52,6 +53,7 @@ pub fn restart_title(version: Option<&str>) -> String { ) } +#[cfg(not(target_os = "linux"))] pub const RESTART_DETAIL: &str = "This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update."; #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/apps/maple-agent/app/tests/fixtures/i18n/en/app.ftl b/apps/maple-agent/app/tests/fixtures/i18n/en/app.ftl new file mode 100644 index 000000000..f0b944335 --- /dev/null +++ b/apps/maple-agent/app/tests/fixtures/i18n/en/app.ftl @@ -0,0 +1,4 @@ +present = English +missing = English fallback +invalid = Safe fallback +empty = Nonempty fallback diff --git a/apps/maple-agent/app/tests/fixtures/i18n/fr/app.ftl b/apps/maple-agent/app/tests/fixtures/i18n/fr/app.ftl new file mode 100644 index 000000000..1bb9f2293 --- /dev/null +++ b/apps/maple-agent/app/tests/fixtures/i18n/fr/app.ftl @@ -0,0 +1,3 @@ +present = Français +invalid = { $missing } +empty = { "" } diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md new file mode 100644 index 000000000..cd747e344 --- /dev/null +++ b/apps/maple-agent/docs/localization.md @@ -0,0 +1,90 @@ +# Agent localization + +The first implementation follows the Fluent design in PR #1370 and its review. +It currently covers the update confirmation dialog (including accessibility +text) and the language setting. English is the only enabled catalog. Most of +the app is still English; this is infrastructure for incremental extraction, +not a claim of complete localization. + +## Locale and lifetime + +Settings → General → Language offers System and English. The optional `locale` +preference uses the existing host-synced UI settings row. Old or malformed +preferences use System; unknown language tags resolve to English without +preventing startup or overwriting the saved value. + +The OS locale is read once, normalized from platform/POSIX spelling, and matched +against enabled catalogs, first exactly, then without variants, region, and +script. English is the final default. Before the store is unlocked, the UI uses +the OS default. The first loaded host settings snapshot selects the process +locale. Language edits apply after **quitting and reopening Maple**; closing +and reopening Settings is not enough. This avoids incomplete live updates of +cached GPUI panels, precomputed sidebar sections, and native menus. No per-frame +or language-change window refresh is introduced. Unit/GPUI tests default to +English regardless of the executing machine's locale. + +## Adding UI text + +Catalogs live in `app/locales/{locale}/app.ftl`. `fluent-templates` embeds them +at compile time; there is no runtime catalog directory or disk access. The +existing Nix fileset includes `app/`; `build.rs` tracks the catalog directory, +including additions/removals. `fluent-bundle` supplies typed argument values. + +- Use stable module-prefixed message IDs, such as `update-confirmation-title`. +- Use `ui::i18n::tr("message-id")` for plain labels. Each resolved label is cached + as a `SharedString` for the process locale; rendering only clones that handle. +- Use `tr_args("message-id", [("count", count.into())])` for interpolation. + Format these when the input state changes and retain the result in the view; + do not format dynamic messages on every render. Pass numbers as numbers so + Fluent can select CLDR plural branches. The System language label is cached. +- Translate accessibility labels too. Keep element IDs, keymap slots, commands, + protocol values and other machine-readable identifiers stable. +- Keep complete sentences together. Add translator comments explaining args. + Fluent bidi isolation (U+2068/U+2069) remains enabled; assertions must account + for it. GPUI shaping/fallback-font checks are required before enabling scripts + that need them. + +Lookup tries the selected catalog, then English, then the key itself. Empty or +unformattable values also fall back. Logs contain only the key and catalog, not +arguments or resolver errors that might contain user text. Cached plain labels +log a fallback once per first resolution rather than on every frame. + +## Validation and incremental extraction + +The existing `agent-test` cargo suite checks catalog syntax, duplicate IDs, +identical shipped key sets, enabled/catalog parity, literal UI lookup keys, +fallback behavior, argument isolation and numeric plurals. The converted-module +check rejects direct literals in common label methods; expand its module list +as extraction proceeds. It is deliberately a narrow heuristic, not proof that +all user-facing text has been found. Existing unconverted modules remain debt; +new user-facing text should use the catalog rather than increasing that debt. +No new workflow or CI lane is needed. + +A partial catalog can fall back safely at runtime, but it is not enabled or +shipped until completeness checks pass. To add a language, add its complete +catalog and native `language-name`, then register it in `i18n::LANGUAGES`. +Validate layout, font fallback, bidi marks, and platform-native language +metadata before enablement. The current macOS bundle declares English only; +additional languages require owned packaging changes and evidence. + +## Explicit follow-ups + +Extraction is substantially larger than just `child(...)` calls: shortcut names +and categories, desktop code, backend/host/remote errors, and other non-UI files +also contain display text. Work module by module, starting with Settings/login. + +- Host/ACP/backend errors remain English strings. Localizing them needs typed + semantic errors plus arguments across the process boundary, formatted by the + consuming UI; it is not a reason to translate shared host strings in place. +- Persisted timeline labels remain unchanged. Their future migration requires + semantic kinds, a `TIMELINE_FORMAT` bump and plain/keyed projection tests. +- Model-facing tool descriptions, system prompts, notices sent to models, user + content and generated content are never translated by UI extraction. +- Native/tray menus, OS dialogs and the standalone OAuth completion HTML are + outside this first milestone. Each has its own lifecycle/packaging contract. +- Dates, localized numeric separators and money need a separate formatting + decision; Fluent plural selection does not provide complete locale formatting. +- Spell checking remains `en_US`; UI language and content language are separate. +- RTL layout/mirroring is not supported by this implementation. Do not enable + RTL locales until the GPUI/platform limitation is addressed. CJK and other + scripts require font-fallback testing on every supported desktop platform. From 105cac085d6b1cf88d9440fe4b9fc1c9119f76b8 Mon Sep 17 00:00:00 2001 From: Alex Sears Date: Fri, 30 Oct 2026 12:01:59 -0400 Subject: [PATCH 2/9] docs(agent): remove superseded localization plan reference --- apps/maple-agent/docs/localization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md index cd747e344..e499efd9d 100644 --- a/apps/maple-agent/docs/localization.md +++ b/apps/maple-agent/docs/localization.md @@ -1,6 +1,6 @@ # Agent localization -The first implementation follows the Fluent design in PR #1370 and its review. +The localization foundation uses embedded Fluent catalogs. It currently covers the update confirmation dialog (including accessibility text) and the language setting. English is the only enabled catalog. Most of the app is still English; this is infrastructure for incremental extraction, From 1319d5807975c46f7c5a6d4d3a84b2d2230756fd Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Mon, 2 Nov 2026 23:14:14 +0000 Subject: [PATCH 3/9] agent: gate localization behind an explicit preview opt-in --- apps/maple-agent/README.md | 5 ++ apps/maple-agent/app/src/env.rs | 3 +- apps/maple-agent/app/src/ui/i18n.rs | 46 ++++++++++++++++++- apps/maple-agent/app/src/ui/i18n/tests.rs | 19 ++++++++ apps/maple-agent/app/src/ui/settings.rs | 24 ++++++---- .../app/src/ui/settings/navigation.rs | 28 +++++++++++ apps/maple-agent/docs/localization.md | 18 ++++++++ 7 files changed, 131 insertions(+), 12 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index d16e848cc..506d2b6c6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -605,6 +605,11 @@ ignored. See [build profiles](docs/release-profiles.md). | `MAPLE_DISABLE_UPDATE_CHECK` | `1` turns Agent update checks off. | unset | | `RUST_LOG` | Log filter. | `info` | +`MAPLE_ENABLE_LOCALIZATION=1` enables the localization preview in **all** build +profiles, including Dev and Prod. It defaults off: Language settings are hidden +and UI text uses English. Restart to change the gate; saved locale preferences +are preserved. See [localization](docs/localization.md). + ### File locations The application database lives in `/store-v1/`. An absolute diff --git a/apps/maple-agent/app/src/env.rs b/apps/maple-agent/app/src/env.rs index aab16da24..da6cfe46d 100644 --- a/apps/maple-agent/app/src/env.rs +++ b/apps/maple-agent/app/src/env.rs @@ -2,8 +2,7 @@ //! trimmed, and an empty value counts as unset so a stray `NAME=` in a //! launcher does not override a default with nothing. -// A headless build (no `desktop` feature) has no update check, the only -// caller of `env_flag`. +// Only desktop update checks and the localization preview gate use `env_flag`. #![cfg_attr(not(feature = "desktop"), allow(dead_code))] /// The trimmed value of `name`, or `None` when unset or blank. diff --git a/apps/maple-agent/app/src/ui/i18n.rs b/apps/maple-agent/app/src/ui/i18n.rs index b994c126f..041dff112 100644 --- a/apps/maple-agent/app/src/ui/i18n.rs +++ b/apps/maple-agent/app/src/ui/i18n.rs @@ -15,6 +15,46 @@ fluent_templates::static_loader! { } const ENGLISH: LanguageIdentifier = langid!("en"); + +/// Preview opt-in, fixed for this process in every build profile. +pub(super) fn enabled() -> bool { + #[cfg(not(test))] + { + static ENABLED: LazyLock = + LazyLock::new(|| crate::env::env_flag("MAPLE_ENABLE_LOCALIZATION")); + *ENABLED + } + #[cfg(test)] + TEST_ENABLED.with(std::cell::Cell::get) +} + +#[cfg(test)] +thread_local! { + static TEST_ENABLED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Scoped UI-thread override: tests never mutate the process environment. +#[cfg(test)] +pub(super) struct TestLocalizationGuard(bool); + +#[cfg(test)] +impl TestLocalizationGuard { + pub(super) fn enable() -> Self { + Self(TEST_ENABLED.with(|enabled| enabled.replace(true))) + } +} + +#[cfg(test)] +impl Drop for TestLocalizationGuard { + fn drop(&mut self) { + TEST_ENABLED.with(|enabled| enabled.set(self.0)); + } +} + +fn gated_locale(enabled: bool, locale: impl FnOnce() -> LanguageIdentifier) -> LanguageIdentifier { + if enabled { locale() } else { ENGLISH } +} + /// Enabled catalogs. None is the persisted system preference. Add a language /// here only when its catalog has passed the completeness checks. pub(super) const LANGUAGES: &[Option<&str>] = &[None, Some("en")]; @@ -43,7 +83,7 @@ static SYSTEM: LazyLock = LazyLock::new(|| { } #[cfg(not(test))] { - resolve(sys_locale::get_locale().as_deref()) + gated_locale(enabled(), || resolve(sys_locale::get_locale().as_deref())) } }); @@ -103,7 +143,9 @@ pub(crate) fn initialize_saved_preference(preference: Option<&str>) { if state.loaded { return; } - state.locale = preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))); + state.locale = gated_locale(enabled(), || { + preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))) + }); state.messages.clear(); state.system_label = None; state.loaded = true; diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs index fa227b617..3648af2e6 100644 --- a/apps/maple-agent/app/src/ui/i18n/tests.rs +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -67,6 +67,7 @@ fn missing_keys_and_locales_fall_back_without_blank_text() { #[test] fn saved_preference_is_loaded_once_and_plain_labels_are_cached() { + let _localization = TestLocalizationGuard::enable(); ACTIVE.with_borrow_mut(|state| { state.locale = langid!("fr"); state.loaded = false; @@ -285,3 +286,21 @@ fn partial_catalog_falls_back_per_message_including_format_errors() { ); } } + +#[test] +fn localization_gate_defaults_off_and_ignores_locale_sources() { + assert!(!enabled()); + assert_eq!( + gated_locale(false, || panic!("disabled gate must not read locale")), + ENGLISH + ); + assert_eq!(gated_locale(true, || langid!("fr")), langid!("fr")); + ACTIVE.with_borrow_mut(|state| { + state.locale = langid!("fr"); + state.loaded = false; + state.messages.clear(); + }); + initialize_saved_preference(Some("fr")); + ACTIVE.with_borrow(|state| assert_eq!(state.locale, ENGLISH)); + assert_eq!(tr("update-confirmation-later").as_ref(), "Later"); +} diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 9b2be2e27..af2e6b230 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -932,6 +932,9 @@ impl SettingsScreen { /// open one. Like a native popup button, the menu opens on the saved /// value. pub(super) fn toggle_setting_menu(&mut self, menu: SettingMenu, cx: &mut Context) { + if menu == SettingMenu::Language && !super::i18n::enabled() { + return; + } if self.popup.is_open(&menu) { self.popup.close(cx); } else { @@ -1132,6 +1135,9 @@ impl SettingsScreen { ) { match menu { SettingMenu::Language => { + if !super::i18n::enabled() { + return; + } let Some(locale) = super::i18n::LANGUAGES.get(index) else { return; }; @@ -1775,14 +1781,16 @@ impl SettingsScreen { cx, ), )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Language), - self.setting_menu_row( - &super::i18n::tr("settings-language"), - &super::i18n::tr("settings-language-description"), - SettingMenu::Language, window, cx, - ), - )) + .when(super::i18n::enabled(), |pane| { + pane.child(self.application_target( + || SettingsTarget::General(GeneralTarget::Language), + self.setting_menu_row( + &super::i18n::tr("settings-language"), + &super::i18n::tr("settings-language-description"), + SettingMenu::Language, window, cx, + ), + )) + }) .child(section_title("Chat appearance")) .child(self.application_target( || SettingsTarget::General(GeneralTarget::ChatFont), diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 1eaffe0e6..177d388f9 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -149,6 +149,7 @@ impl SettingsScreen { GeneralTarget::SpeechSpeed, ] .into_iter() + .filter(|target| *target != GeneralTarget::Language || crate::ui::i18n::enabled()) .map(SettingsTarget::General) .collect(), Section::Account => self.account_targets(), @@ -1184,8 +1185,34 @@ mod tests { ); } + #[gpui::test] + fn language_gate_hides_selector_and_preserves_saved_preference(cx: &mut TestAppContext) { + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + assert!(!crate::ui::i18n::enabled()); + crate::settings::install_test_settings(crate::settings::AppSettings { + locale: Some("en".into()), + ..Default::default() + }); + let (settings, cx) = dropdown_window(cx, true); + assert!(cx.debug_bounds("setting-value-language").is_none()); + settings.update(cx, |this, cx| { + this.settings.locale = crate::settings::load_settings().locale; + assert!( + !this + .visible_application_targets() + .contains(&SettingsTarget::General(GeneralTarget::Language)) + ); + this.toggle_setting_menu(SettingMenu::Language, cx); + assert_eq!(this.popup.open_key(), None); + this.pick_setting_option(SettingMenu::Language, 0, cx); + assert_eq!(this.settings.locale.as_deref(), Some("en")); + }); + crate::settings::discard_decrypted_cache(); + } + #[gpui::test] fn language_dropdown_supports_pointer_and_keyboard_selection(cx: &mut TestAppContext) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); crate::settings::install_test_settings(crate::settings::AppSettings::default()); let (settings, cx) = dropdown_window(cx, false); @@ -1205,6 +1232,7 @@ mod tests { #[gpui::test] fn picking_from_the_dropdown_persists_in_reopened_store(cx: &mut TestAppContext) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); let root = tempfile::tempdir().unwrap(); let paths = maple_agent::store::StorePaths::new(root.path()); diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md index e499efd9d..f89b7543f 100644 --- a/apps/maple-agent/docs/localization.md +++ b/apps/maple-agent/docs/localization.md @@ -6,6 +6,24 @@ text) and the language setting. English is the only enabled catalog. Most of the app is still English; this is infrastructure for incremental extraction, not a claim of complete localization. +## Preview gate + +Localization is off by default in unpackaged, Dev, and Prod builds. Set +`MAPLE_ENABLE_LOCALIZATION=1` in the application's launch environment to enable +it (`true` and `yes` also work, case-insensitively). The value is read once per +process; quit and reopen to change it. For hosted Dev from this component: + +```sh +MAPLE_RELEASE_PROFILE=dev MAPLE_ENABLE_LOCALIZATION=1 \ + nix develop --no-update-lock-file -c just run +``` + +Without the opt-in, the Language row and its keyboard-navigation target are +hidden, locale selection stays English, and existing saved language preferences +are preserved but ignored. Fluent catalogs/helpers remain compiled in. This is +a local preview gate, not a server-controlled rollout or an authorization check. +Tests default to the gate off and explicitly opt in for selector coverage. + ## Locale and lifetime Settings → General → Language offers System and English. The optional `locale` From 66df7da8481ffc60d6b67018d29a86f8f8f8877d Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Tue, 3 Nov 2026 15:20:30 +0000 Subject: [PATCH 4/9] agent: add Spanish localization preview --- apps/maple-agent/app/locales/es/app.ftl | 12 +++++ apps/maple-agent/app/src/ui/i18n.rs | 2 +- apps/maple-agent/app/src/ui/i18n/tests.rs | 40 ++++++++++++++ .../app/src/ui/settings/navigation.rs | 10 ++-- .../app/src/ui/update_confirmation.rs | 53 ++++++++++++++----- apps/maple-agent/docs/localization.md | 16 +++--- 6 files changed, 107 insertions(+), 26 deletions(-) create mode 100644 apps/maple-agent/app/locales/es/app.ftl diff --git a/apps/maple-agent/app/locales/es/app.ftl b/apps/maple-agent/app/locales/es/app.ftl new file mode 100644 index 000000000..e4a7d690a --- /dev/null +++ b/apps/maple-agent/app/locales/es/app.ftl @@ -0,0 +1,12 @@ +# Traducción de la interfaz. Los identificadores de mensajes y elementos no se traducen. +settings-language = Idioma +settings-language-description = Se aplica después de reiniciar Maple. El texto que aún no se haya traducido seguirá en inglés. +# $language es el nombre del idioma del sistema en su propio idioma. +settings-language-system = Sistema ({ $language }) +language-name = Español +update-confirmation-title = ¿Reiniciar Maple para actualizar? +# $version es la versión de destino de la aplicación y no se traduce. +update-confirmation-title-version = ¿Reiniciar Maple para instalar la versión { $version }? +update-confirmation-detail = Esto detendrá las tareas en ejecución y el acceso remoto en este equipo. Los editores que usan Maple deberán volver a conectarse después de la actualización. +update-confirmation-later = Más tarde +update-confirmation-restart = Reiniciar para actualizar diff --git a/apps/maple-agent/app/src/ui/i18n.rs b/apps/maple-agent/app/src/ui/i18n.rs index 041dff112..3d86257e0 100644 --- a/apps/maple-agent/app/src/ui/i18n.rs +++ b/apps/maple-agent/app/src/ui/i18n.rs @@ -57,7 +57,7 @@ fn gated_locale(enabled: bool, locale: impl FnOnce() -> LanguageIdentifier) -> L /// Enabled catalogs. None is the persisted system preference. Add a language /// here only when its catalog has passed the completeness checks. -pub(super) const LANGUAGES: &[Option<&str>] = &[None, Some("en")]; +pub(super) const LANGUAGES: &[Option<&str>] = &[None, Some("en"), Some("es")]; static NATIVE_NAMES: LazyLock> = LazyLock::new(|| { LANGUAGES .iter() diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs index 3648af2e6..ba676e811 100644 --- a/apps/maple-agent/app/src/ui/i18n/tests.rs +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -20,6 +20,46 @@ fn locale_resolution_accepts_os_spellings_and_falls_back_safely() { ] { assert_eq!(resolve(locale), ENGLISH); } + for locale in ["es", "es-ES", "es-MX", "es_ES.UTF-8"] { + assert_eq!(resolve(Some(locale)), langid!("es")); + } +} + +#[test] +fn spanish_preference_formats_existing_surfaces_and_applies_only_on_restart() { + let _localization = TestLocalizationGuard::enable(); + ACTIVE.with_borrow_mut(|state| { + state.locale = ENGLISH; + state.loaded = false; + state.messages.clear(); + }); + initialize_saved_preference(Some("es")); + assert_eq!(tr("settings-language").as_ref(), "Idioma"); + assert_eq!(preference_label(Some("es")).as_ref(), "Español"); + assert_eq!( + tr("update-confirmation-title").as_ref(), + "¿Reiniciar Maple para actualizar?" + ); + assert_eq!( + tr_args( + "update-confirmation-title-version", + [("version", "0.0.16".into())] + ) + .as_ref(), + "¿Reiniciar Maple para instalar la versión \u{2068}0.0.16\u{2069}?" + ); + assert_eq!( + tr_args("settings-language-system", [("language", "Español".into())]).as_ref(), + "Sistema (\u{2068}Español\u{2069})" + ); + initialize_saved_preference(Some("en")); + assert_eq!(tr("settings-language").as_ref(), "Idioma"); + ACTIVE.with_borrow_mut(|state| { + state.locale = ENGLISH; + state.loaded = false; + state.messages.clear(); + state.system_label = None; + }); } #[test] diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 177d388f9..53a022f4a 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -1190,7 +1190,7 @@ mod tests { let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); assert!(!crate::ui::i18n::enabled()); crate::settings::install_test_settings(crate::settings::AppSettings { - locale: Some("en".into()), + locale: Some("es".into()), ..Default::default() }); let (settings, cx) = dropdown_window(cx, true); @@ -1205,7 +1205,7 @@ mod tests { this.toggle_setting_menu(SettingMenu::Language, cx); assert_eq!(this.popup.open_key(), None); this.pick_setting_option(SettingMenu::Language, 0, cx); - assert_eq!(this.settings.locale.as_deref(), Some("en")); + assert_eq!(this.settings.locale.as_deref(), Some("es")); }); crate::settings::discard_decrypted_cache(); } @@ -1216,7 +1216,7 @@ mod tests { let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); crate::settings::install_test_settings(crate::settings::AppSettings::default()); let (settings, cx) = dropdown_window(cx, false); - for expected in [Some("en"), None] { + for expected in [Some("en"), Some("es"), None] { let button = cx .debug_bounds("setting-value-language") .expect("language selector"); @@ -1248,7 +1248,7 @@ mod tests { this.toggle_setting_menu(SettingMenu::Appearance, cx); this.pick_setting_option(SettingMenu::Appearance, 2, cx); this.toggle_setting_menu(SettingMenu::Language, cx); - this.pick_setting_option(SettingMenu::Language, 1, cx); + this.pick_setting_option(SettingMenu::Language, 2, cx); }); crate::settings::update_settings_and_wait(|_| {}); // Detach the writer's Arc and discard its cached value before closing @@ -1270,7 +1270,7 @@ mod tests { ); assert_eq!( saved.get("locale").and_then(serde_json::Value::as_str), - Some("en") + Some("es") ); reopened.close().unwrap(); cx.run_until_parked(); diff --git a/apps/maple-agent/app/src/ui/update_confirmation.rs b/apps/maple-agent/app/src/ui/update_confirmation.rs index c17e86a2f..1d33caf3d 100644 --- a/apps/maple-agent/app/src/ui/update_confirmation.rs +++ b/apps/maple-agent/app/src/ui/update_confirmation.rs @@ -114,13 +114,6 @@ impl Render for UpdateConfirmation { .flex_col() .gap_4() .text_color(gpui::rgb(theme::text_primary())) - .child( - div() - .w_full() - .flex_shrink_0() - .text_lg() - .child(self.title.clone()), - ) .child( div() .id("update-confirmation-body") @@ -129,8 +122,15 @@ impl Render for UpdateConfirmation { .min_w_0() .min_h_0() .overflow_y_scroll() - .text_sm() - .child(tr("update-confirmation-detail")), + .child( + div() + .w_full() + .flex() + .flex_col() + .gap_4() + .child(div().text_lg().child(self.title.clone())) + .child(div().text_sm().child(tr("update-confirmation-detail"))), + ), ) .child( div() @@ -186,15 +186,23 @@ mod tests { Entity, oneshot::Receiver, &mut gpui::VisualTestContext, + ) { + show_with_title(cx, "Restart Maple to install v\u{2068}0.0.14\u{2069}?") + } + + fn show_with_title<'a>( + cx: &'a mut gpui::TestAppContext, + expected_title: &str, + ) -> ( + Entity, + oneshot::Receiver, + &'a mut gpui::VisualTestContext, ) { let answer = Rc::new(RefCell::new(None)); let result = answer.clone(); let (host, cx) = cx.add_window_view(move |window, cx| { let (prompt, receiver) = open(Some("0.0.14"), window, cx); - assert_eq!( - prompt.read(cx).title.as_ref(), - "Restart Maple to install v\u{2068}0.0.14\u{2069}?" - ); + assert_eq!(prompt.read(cx).title.as_ref(), expected_title); *result.borrow_mut() = Some(receiver); PromptHost { prompt: Some(prompt), @@ -220,6 +228,25 @@ mod tests { cx: &mut gpui::TestAppContext, ) { let (_host, _answer, cx) = show(cx); + assert_layout(cx); + } + + #[gpui::test] + fn spanish_confirmation_wraps_and_keeps_actions_inside_the_viewport( + cx: &mut gpui::TestAppContext, + ) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); + crate::ui::i18n::initialize_saved_preference(Some("es")); + let (_host, mut answer, cx) = show_with_title( + cx, + "¿Reiniciar Maple para instalar la versión \u{2068}0.0.14\u{2069}?", + ); + assert_layout(cx); + cx.simulate_keystrokes("escape"); + assert_eq!(answer.try_recv().unwrap(), 0); + } + + fn assert_layout(cx: &mut gpui::VisualTestContext) { for (width, height) in [(1213., 800.), (640., 600.), (320., 480.), (640., 220.)] { cx.simulate_resize(gpui::size(px(width), px(height))); let card = cx.debug_bounds("update-confirmation").unwrap(); diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md index f89b7543f..6418d9baa 100644 --- a/apps/maple-agent/docs/localization.md +++ b/apps/maple-agent/docs/localization.md @@ -2,9 +2,9 @@ The localization foundation uses embedded Fluent catalogs. It currently covers the update confirmation dialog (including accessibility -text) and the language setting. English is the only enabled catalog. Most of -the app is still English; this is infrastructure for incremental extraction, -not a claim of complete localization. +text) and the language setting. English and Spanish are enabled in the preview. +Most of the app is still English; this is infrastructure for incremental +extraction, not a claim of complete localization. ## Preview gate @@ -26,8 +26,8 @@ Tests default to the gate off and explicitly opt in for selector coverage. ## Locale and lifetime -Settings → General → Language offers System and English. The optional `locale` -preference uses the existing host-synced UI settings row. Old or malformed +Settings → General → Language offers System, English and Español. The optional +`locale` preference uses the existing host-synced UI settings row. Old or malformed preferences use System; unknown language tags resolve to English without preventing startup or overwriting the saved value. @@ -82,8 +82,10 @@ A partial catalog can fall back safely at runtime, but it is not enabled or shipped until completeness checks pass. To add a language, add its complete catalog and native `language-name`, then register it in `i18n::LANGUAGES`. Validate layout, font fallback, bidi marks, and platform-native language -metadata before enablement. The current macOS bundle declares English only; -additional languages require owned packaging changes and evidence. +metadata before production enablement. Spanish currently has Linux preview +evidence only; macOS/Windows runtime and Spanish native language metadata remain +follow-ups. The current macOS bundle declares English only, so native OS dialogs +are outside this preview's Spanish coverage. ## Explicit follow-ups From 95a92a96451aca05ef81a2772ccd1a4a7c194659 Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Tue, 3 Nov 2026 16:38:08 +0000 Subject: [PATCH 5/9] agent: show restart notice for language changes --- apps/maple-agent/app/locales/en/app.ftl | 4 + apps/maple-agent/app/locales/es/app.ftl | 4 + apps/maple-agent/app/src/desktop.rs | 72 +++++++++-- .../app/src/desktop/maintenance_tests.rs | 19 +++ .../app/src/desktop/native_quit.rs | 8 +- apps/maple-agent/app/src/ui/i18n.rs | 9 ++ apps/maple-agent/app/src/ui/i18n/tests.rs | 5 + apps/maple-agent/app/src/ui/settings.rs | 16 +++ .../app/src/ui/settings/language.rs | 117 ++++++++++++++++++ .../app/src/ui/settings/navigation.rs | 72 +++++++++++ apps/maple-agent/docs/localization.md | 12 +- 11 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/settings/language.rs diff --git a/apps/maple-agent/app/locales/en/app.ftl b/apps/maple-agent/app/locales/en/app.ftl index 2f3030aff..9a606531c 100644 --- a/apps/maple-agent/app/locales/en/app.ftl +++ b/apps/maple-agent/app/locales/en/app.ftl @@ -4,6 +4,10 @@ settings-language-description = Applies after restarting Maple. Text not yet tra # $language is the native name of the resolved system language. settings-language-system = System ({ $language }) language-name = English +settings-language-restart-title = Restart Maple to apply the language change +settings-language-restart-detail = Your choice is saved. Restarting stops running tasks and remote access on this computer. +settings-language-restart-later = Later +settings-language-restart-now = Restart now update-confirmation-title = Restart Maple to update? # $version is the target application version, not translated. update-confirmation-title-version = Restart Maple to install v{ $version }? diff --git a/apps/maple-agent/app/locales/es/app.ftl b/apps/maple-agent/app/locales/es/app.ftl index e4a7d690a..53bcaff9e 100644 --- a/apps/maple-agent/app/locales/es/app.ftl +++ b/apps/maple-agent/app/locales/es/app.ftl @@ -4,6 +4,10 @@ settings-language-description = Se aplica después de reiniciar Maple. El texto # $language es el nombre del idioma del sistema en su propio idioma. settings-language-system = Sistema ({ $language }) language-name = Español +settings-language-restart-title = Reinicia Maple para aplicar el cambio de idioma +settings-language-restart-detail = Tu elección se ha guardado. Reiniciar detendrá las tareas en ejecución y el acceso remoto en este equipo. +settings-language-restart-later = Más tarde +settings-language-restart-now = Reiniciar ahora update-confirmation-title = ¿Reiniciar Maple para actualizar? # $version es la versión de destino de la aplicación y no se traduce. update-confirmation-title-version = ¿Reiniciar Maple para instalar la versión { $version }? diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 6488c1c8c..05b4b86fa 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -2087,6 +2087,15 @@ impl MapleApp { }, ) .detach(); + cx.subscribe( + &screen, + |app: &mut MapleApp, emitter, _: &ui::settings::LanguageRestartRequested, cx| { + if app.is_current_settings_screen(&emitter) { + restart_for_language_change(cx); + } + }, + ) + .detach(); cx.subscribe(&screen, |app: &mut MapleApp, emitter, event: &ui::store_gate::PasswordFormFinished, cx| { if matches!(&app.screen, Screen::Settings(active) if active.entity_id() == emitter.entity_id()) { app.password_settings_finished(event, cx); @@ -2560,6 +2569,25 @@ struct DesktopLifecycle { } impl gpui::Global for DesktopLifecycle {} +struct LanguageRestartPending; +impl gpui::Global for LanguageRestartPending {} + +fn restart_for_language_change(cx: &mut App) { + let Some(state) = cx.try_global::() else { + return; + }; + if state.closing.get() + || state.frontend_detached.get() + || state.quit_completed.get() + || state.updater.restart_path().is_some() + || !may_finish_quit(cx) + { + return; + } + cx.set_global(LanguageRestartPending); + quit_app(cx); +} + /// Recheck final effects: an earlier Quit cannot exit an installation. fn may_finish_quit(cx: &App) -> bool { !cx.try_global::().is_some_and(|state| { @@ -2612,20 +2640,35 @@ fn restart_installed_update(path: PathBuf, cx: &mut App) { { state.quit_completed.set(true); #[cfg(target_os = "macos")] - native_quit::allow_update_restart(); + native_quit::allow_restart(); cx.set_restart_path(path); cx.restart(); #[cfg(target_os = "macos")] - native_quit::finish_update_restart(); + native_quit::finish_restart(); } } fn finish_quit(cx: &mut App) { if !may_finish_quit(cx) { - cancel_native_quit(); + cancel_quit(cx); show_update_about(cx); return; } + if cx.has_global::() { + if !cx + .try_global::() + .is_some_and(|state| state.quit_completed.get()) + { + return; + } + cx.remove_global::(); + #[cfg(target_os = "macos")] + native_quit::allow_restart(); + cx.restart(); + #[cfg(target_os = "macos")] + native_quit::finish_restart(); + return; + } #[cfg(target_os = "macos")] native_quit::complete(cx); #[cfg(not(target_os = "macos"))] @@ -2710,16 +2753,12 @@ fn drain_and_quit(state: DesktopLifecycle, cx: &mut App) { match stop.await { Ok(Ok(())) => cx.update(|cx| { if !may_finish_quit(cx) { - state.closing.set(false); - cancel_native_quit(); + cancel_quit(cx); show_update_about(cx); return; } state.quit_completed.set(true); - #[cfg(target_os = "macos")] - native_quit::complete(cx); - #[cfg(not(target_os = "macos"))] - cx.quit(); + finish_quit(cx); }), result => { state.closing.set(false); @@ -2838,6 +2877,9 @@ fn cancel_native_quit() { } fn cancel_quit(cx: &mut App) { + if cx.has_global::() { + cx.remove_global::(); + } if let Some(state) = cx.try_global::() { state.closing.set(false); state.quit_requested.set(false); @@ -2900,7 +2942,7 @@ struct QuitPrompt { kind: QuitPromptKind, } impl Render for QuitPrompt { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let (title, message, retry) = match &self.kind { QuitPromptKind::Confirm(activity) => ( "Stop this computer's Maple host?", @@ -2940,7 +2982,15 @@ impl Render for QuitPrompt { .child( ui::widgets::secondary_button("retry-maple-quit") .debug_selector(|| "retry-maple-quit".to_string()) - .child(if retry { "Retry Quit" } else { "Quit Maple" }) + .child(if cx.has_global::() { + ui::i18n::tr("settings-language-restart-now") + } else { + gpui::SharedString::from(if retry { + "Retry Quit" + } else { + "Quit Maple" + }) + }) .on_click(move |_, window, cx| { window.remove_window(); if retry { diff --git a/apps/maple-agent/app/src/desktop/maintenance_tests.rs b/apps/maple-agent/app/src/desktop/maintenance_tests.rs index 7c20ca94c..bc6ba24df 100644 --- a/apps/maple-agent/app/src/desktop/maintenance_tests.rs +++ b/apps/maple-agent/app/src/desktop/maintenance_tests.rs @@ -775,6 +775,7 @@ fn pending_quit_cannot_finish_an_update_installation(cx: &mut gpui::TestAppConte let backend = Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); let updater = crate::update::Controller::new(false); + let mut restarted = cx.expect_restart(); cx.update(|cx| { cx.set_global(DesktopLifecycle { backend, @@ -793,10 +794,28 @@ fn pending_quit_cannot_finish_an_update_installation(cx: &mut gpui::TestAppConte assert!(may_finish_quit(cx)); updater.set_test_state(crate::update::State::Installing); assert!(!may_finish_quit(cx)); + restart_for_language_change(cx); + assert!(!cx.has_global::()); assert!(!cx.global::().quit_completed.get()); updater.set_test_state(crate::update::State::Error); assert!(may_finish_quit(cx)); + cx.set_global(LanguageRestartPending); + finish_quit(cx); + assert_eq!( + restarted.try_recv().unwrap(), + None, + "restart must await the host drain" + ); + cancel_quit(cx); + assert!(!cx.has_global::()); + cx.set_global(LanguageRestartPending); + cx.global::().quit_completed.set(true); + finish_quit(cx); + assert_eq!(restarted.try_recv().unwrap(), Some(None)); + assert!(!cx.has_global::()); }); + #[cfg(target_os = "macos")] + native_quit::reset_frontend_detach_for_test(); } #[gpui::test] diff --git a/apps/maple-agent/app/src/desktop/native_quit.rs b/apps/maple-agent/app/src/desktop/native_quit.rs index 6970c3aa6..dd3d34f74 100644 --- a/apps/maple-agent/app/src/desktop/native_quit.rs +++ b/apps/maple-agent/app/src/desktop/native_quit.rs @@ -111,15 +111,15 @@ pub(super) fn complete(cx: &mut gpui::App) { allow_termination(cx); } -/// Called only after an update commit retained owner.lock for process exit. -/// Allow GPUI's restart termination, without quitting before its helper starts. -pub(super) fn allow_update_restart() { +/// Allow GPUI's restart termination only after a verified host stop or an +/// update commit retaining owner.lock. Do not quit before its helper starts. +pub(super) fn allow_restart() { TERMINATION_ALLOWED.with(|allowed| allowed.set(true)); } /// GPUI's helper has now been requested. Complete an already deferred native /// Quit, while leaving the retry UI alive if no Quit was pending and spawn fails. -pub(super) fn finish_update_restart() { +pub(super) fn finish_restart() { if PENDING.with(Cell::get) { defer_reply(true); } diff --git a/apps/maple-agent/app/src/ui/i18n.rs b/apps/maple-agent/app/src/ui/i18n.rs index 3d86257e0..fbf7fe3b6 100644 --- a/apps/maple-agent/app/src/ui/i18n.rs +++ b/apps/maple-agent/app/src/ui/i18n.rs @@ -152,6 +152,15 @@ pub(crate) fn initialize_saved_preference(preference: Option<&str>) { }); } +/// Compare effective languages, so System and an equivalent explicit choice +/// do not ask for an unnecessary restart. Call when the preference changes. +pub(super) fn requires_restart(preference: Option<&str>) -> bool { + enabled() + && ACTIVE.with_borrow(|state| { + state.locale != preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))) + }) +} + pub(super) fn preference_label(preference: Option<&str>) -> SharedString { if let Some(locale) = preference { // Only enabled values can be picked. Unknown saved preferences resolve diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs index ba676e811..82be0d4b0 100644 --- a/apps/maple-agent/app/src/ui/i18n/tests.rs +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -34,6 +34,8 @@ fn spanish_preference_formats_existing_surfaces_and_applies_only_on_restart() { state.messages.clear(); }); initialize_saved_preference(Some("es")); + assert!(!requires_restart(Some("es-MX"))); + assert!(requires_restart(Some("en"))); assert_eq!(tr("settings-language").as_ref(), "Idioma"); assert_eq!(preference_label(Some("es")).as_ref(), "Español"); assert_eq!( @@ -60,6 +62,9 @@ fn spanish_preference_formats_existing_surfaces_and_applies_only_on_restart() { state.messages.clear(); state.system_label = None; }); + assert!(!requires_restart(None)); + assert!(!requires_restart(Some("en-US"))); + assert!(requires_restart(Some("es"))); } #[test] diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index af2e6b230..c96066b89 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -35,6 +35,7 @@ mod advanced; mod api_credits; mod api_keys; mod billing; +mod language; #[cfg(feature = "proxy")] mod local_api; mod mcp; @@ -53,6 +54,8 @@ pub struct SettingsClosed(pub AppSettings); /// Emitted when the user clicks Sign out in the settings header. pub struct SignOutRequested; +pub(crate) struct LanguageRestartRequested; + /// Emitted after the server confirmed the account is deleted and the /// local session is gone; the app returns to the login form. pub struct AccountDeleted; @@ -150,6 +153,8 @@ pub struct SettingsScreen { remote_screen: Entity, user_id: String, settings: AppSettings, + language_restart_pending: bool, + language_restart_dismissed: bool, update: crate::update::Snapshot, /// `settings.theme` parsed once; render only reads the label. theme: theme::Preference, @@ -266,6 +271,7 @@ impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} +impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} @@ -420,6 +426,8 @@ impl SettingsScreen { remote_screen, user_id, theme: theme::Preference::parse(&settings.theme), + language_restart_pending: super::i18n::requires_restart(settings.locale.as_deref()), + language_restart_dismissed: false, settings, update: crate::update::Snapshot::initial(), section, @@ -1142,6 +1150,11 @@ impl SettingsScreen { return; }; let locale = locale.map(str::to_owned); + if self.settings.locale != locale { + self.language_restart_pending = + super::i18n::requires_restart(locale.as_deref()); + self.language_restart_dismissed = false; + } self.edit_setting(move |settings| settings.locale = locale, cx); } SettingMenu::Appearance => { @@ -1791,6 +1804,9 @@ impl SettingsScreen { ), )) }) + .when(self.language_restart_visible(), |pane| { + pane.child(self.language_restart_notice(cx)) + }) .child(section_title("Chat appearance")) .child(self.application_target( || SettingsTarget::General(GeneralTarget::ChatFont), diff --git a/apps/maple-agent/app/src/ui/settings/language.rs b/apps/maple-agent/app/src/ui/settings/language.rs new file mode 100644 index 000000000..a364cf41a --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/language.rs @@ -0,0 +1,117 @@ +use gpui::{Context, Div, div, prelude::*, px}; + +use super::{GeneralTarget, LanguageRestartRequested, SettingsScreen, SettingsTarget}; +use crate::ui::{i18n::tr, icons::icon, theme, widgets}; + +impl SettingsScreen { + pub(super) fn language_restart_visible(&self) -> bool { + crate::ui::i18n::enabled() + && self.language_restart_pending + && !self.language_restart_dismissed + } + + pub(super) fn restart_for_language(&mut self, cx: &mut Context) { + if self.language_restart_visible() { + cx.emit(LanguageRestartRequested); + } + } + + pub(super) fn defer_language_restart(&mut self, cx: &mut Context) { + self.language_restart_dismissed = true; + self.reconcile_application_vim_target(); + cx.notify(); + } + + pub(super) fn language_restart_notice(&self, cx: &mut Context) -> gpui::Stateful
{ + widgets::card_row() + .id("language-restart-notice") + .debug_selector(|| "language-restart-notice".into()) + .role(gpui::Role::Status) + .aria_label(tr("settings-language-restart-title")) + .border_color(gpui::rgb(theme::accent())) + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_start() + .gap_3() + .child(icon("circle-alert", px(18.), theme::accent())) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(tr("settings-language-restart-title")), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(tr("settings-language-restart-detail")), + ), + ), + ) + .child( + div() + .flex() + .flex_wrap() + .justify_end() + .gap_2() + .child( + self.application_target( + || SettingsTarget::General(GeneralTarget::LanguageRestartLater), + widgets::secondary_button("language-restart-later") + .debug_selector(|| "language-restart-later".into()) + .aria_label(tr("settings-language-restart-later")) + .tab_index(3) + .child(tr("settings-language-restart-later")) + .on_click( + cx.listener(|this, _, _, cx| this.defer_language_restart(cx)), + ) + .on_key_down(cx.listener( + |this, event: &gpui::KeyDownEvent, _, cx| { + if activates(event) { + this.defer_language_restart(cx); + cx.stop_propagation(); + } + }, + )), + ), + ) + .child( + self.application_target( + || SettingsTarget::General(GeneralTarget::LanguageRestartNow), + widgets::primary_button("language-restart-now") + .debug_selector(|| "language-restart-now".into()) + .aria_label(tr("settings-language-restart-now")) + .tab_index(4) + .child(tr("settings-language-restart-now")) + .on_click( + cx.listener(|this, _, _, cx| this.restart_for_language(cx)), + ) + .on_key_down(cx.listener( + |this, event: &gpui::KeyDownEvent, _, cx| { + if activates(event) { + this.restart_for_language(cx); + cx.stop_propagation(); + } + }, + )), + ), + ), + ) + } +} + +fn activates(event: &gpui::KeyDownEvent) -> bool { + !event.keystroke.modifiers.modified() + && matches!(event.keystroke.key.as_str(), "enter" | "space" | " ") +} diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 53a022f4a..a4ba063f9 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -29,6 +29,8 @@ pub(super) enum GeneralTarget { Web, Appearance, Language, + LanguageRestartLater, + LanguageRestartNow, ChatFont, ChatSize, ToolDetails, @@ -136,6 +138,8 @@ impl SettingsScreen { GeneralTarget::Web, GeneralTarget::Appearance, GeneralTarget::Language, + GeneralTarget::LanguageRestartLater, + GeneralTarget::LanguageRestartNow, GeneralTarget::ChatFont, GeneralTarget::ChatSize, GeneralTarget::ToolDetails, @@ -150,6 +154,12 @@ impl SettingsScreen { ] .into_iter() .filter(|target| *target != GeneralTarget::Language || crate::ui::i18n::enabled()) + .filter(|target| { + !matches!( + target, + GeneralTarget::LanguageRestartLater | GeneralTarget::LanguageRestartNow + ) || self.language_restart_visible() + }) .map(SettingsTarget::General) .collect(), Section::Account => self.account_targets(), @@ -392,6 +402,8 @@ impl SettingsScreen { match target { GeneralTarget::Web => self.toggle_web_default(cx), GeneralTarget::Language => self.toggle_setting_menu(SettingMenu::Language, cx), + GeneralTarget::LanguageRestartLater => self.defer_language_restart(cx), + GeneralTarget::LanguageRestartNow => self.restart_for_language(cx), GeneralTarget::Appearance => self.toggle_setting_menu(SettingMenu::Appearance, cx), GeneralTarget::ChatFont => self.toggle_setting_menu(SettingMenu::ChatFont, cx), GeneralTarget::ChatSize => self.toggle_setting_menu(SettingMenu::ChatSize, cx), @@ -1230,6 +1242,66 @@ mod tests { crate::settings::discard_decrypted_cache(); } + #[gpui::test] + fn language_restart_notice_can_restart_defer_and_clear_on_revert(cx: &mut TestAppContext) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); + let (settings, cx) = dropdown_window(cx, true); + let requests = std::rc::Rc::new(std::cell::Cell::new(0)); + let received = requests.clone(); + let _subscription = cx.update(|_, app| { + app.subscribe( + &settings, + move |_, _: &super::super::LanguageRestartRequested, _| { + received.set(received.get() + 1); + }, + ) + }); + assert!(cx.debug_bounds("language-restart-notice").is_none()); + settings.update(cx, |this, cx| { + this.pick_setting_option(SettingMenu::Language, 1, cx); + assert!( + !this.language_restart_visible(), + "English and System resolve alike" + ); + this.pick_setting_option(SettingMenu::Language, 2, cx); + assert!(this.language_restart_visible()); + this.application_vim.region = SettingsRegion::Pane; + this.application_vim.target = + Some(SettingsTarget::General(GeneralTarget::LanguageRestartNow)); + this.application_focus_pending = true; + }); + cx.run_until_parked(); + assert!(cx.debug_bounds("language-restart-notice").is_some()); + cx.simulate_keystrokes("enter"); + assert_eq!(requests.get(), 1); + let later = cx.debug_bounds("language-restart-later").unwrap(); + cx.simulate_click(later.center(), gpui::Modifiers::default()); + settings.update(cx, |this, cx| { + assert!(!this.language_restart_visible()); + assert_eq!(this.settings.locale.as_deref(), Some("es")); + assert!( + !this + .visible_application_targets() + .contains(&SettingsTarget::General(GeneralTarget::LanguageRestartNow)) + ); + this.pick_setting_option(SettingMenu::Language, 0, cx); + assert!(!this.language_restart_visible()); + this.pick_setting_option(SettingMenu::Language, 2, cx); + assert!( + this.language_restart_visible(), + "a new change restores the notice" + ); + this.pick_setting_option(SettingMenu::Language, 1, cx); + assert!( + !this.language_restart_visible(), + "reverting clears the notice" + ); + }); + crate::settings::discard_decrypted_cache(); + } + #[gpui::test] fn picking_from_the_dropdown_persists_in_reopened_store(cx: &mut TestAppContext) { let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md index 6418d9baa..679809ff9 100644 --- a/apps/maple-agent/docs/localization.md +++ b/apps/maple-agent/docs/localization.md @@ -36,8 +36,16 @@ against enabled catalogs, first exactly, then without variants, region, and script. English is the final default. Before the store is unlocked, the UI uses the OS default. The first loaded host settings snapshot selects the process locale. Language edits apply after **quitting and reopening Maple**; closing -and reopening Settings is not enough. This avoids incomplete live updates of -cached GPUI panels, precomputed sidebar sections, and native menus. No per-frame +and reopening Settings is not enough. + +When a saved choice differs from the running language, Settings shows a localized +restart notice with Restart now and Later. Restart now uses the normal quit +coordinator, including saving preferences and stopping the host; cancelling Quit +also cancels the restart. Later dismisses the notice for this Settings visit; +reopening Settings reminds again until the language is applied or reverted. + +Restarting avoids incomplete live updates of cached GPUI panels, precomputed +sidebar sections, and native menus. No per-frame or language-change window refresh is introduced. Unit/GPUI tests default to English regardless of the executing machine's locale. From ed83b633b090d6d3e481004e83de324b746b9a93 Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Tue, 3 Nov 2026 18:57:30 +0000 Subject: [PATCH 6/9] agent: translate General settings into Spanish --- apps/maple-agent/app/locales/en/app.ftl | 86 +++++ apps/maple-agent/app/locales/es/app.ftl | 86 +++++ apps/maple-agent/app/src/settings.rs | 9 - apps/maple-agent/app/src/ui/i18n/tests.rs | 2 + apps/maple-agent/app/src/ui/settings.rs | 338 ++++-------------- .../app/src/ui/settings/general.rs | 330 +++++++++++++++++ .../app/src/ui/settings/navigation.rs | 25 ++ apps/maple-agent/app/src/ui/theme.rs | 8 - apps/maple-agent/app/src/ui/typography.rs | 18 - apps/maple-agent/docs/localization.md | 4 +- 10 files changed, 596 insertions(+), 310 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/settings/general.rs diff --git a/apps/maple-agent/app/locales/en/app.ftl b/apps/maple-agent/app/locales/en/app.ftl index 9a606531c..f6f81a406 100644 --- a/apps/maple-agent/app/locales/en/app.ftl +++ b/apps/maple-agent/app/locales/en/app.ftl @@ -14,3 +14,89 @@ update-confirmation-title-version = Restart Maple to install v{ $version }? update-confirmation-detail = This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update. update-confirmation-later = Later update-confirmation-restart = Restart to Update + +# General settings and shared settings navigation. +settings-defaults = Defaults +settings-chat-appearance = Chat appearance +settings-editing = Editing +settings-voice = Voice +settings-web = New tasks can use the web +settings-web-description = Offers web_search and open_url to the model. Each task can switch web access on or off from its composer. +settings-appearance = Appearance +settings-appearance-description = Follow the system theme, or force dark or light. +settings-chat-font = Chat font +settings-text-size = Text size +settings-text-size-description = Size of conversation and composer text. Sidebar and buttons stay Manrope. +settings-tool-details = Show tool call details +settings-tool-details-description = Tool cards include their input and output payloads. +settings-notifications = Desktop notifications +settings-notifications-description = Notify when a task finishes or needs your input while the window is not focused. +settings-password-details = Show task details in password mode +settings-password-details-description = Include task titles and questions in desktop notifications. Your operating system may retain this text after Maple quits. +settings-reduce-motion = Reduce motion +settings-reduce-motion-description = Hold spinners and pulsing dots still and skip reveal animations. +settings-tool-summaries = Summarize tool calls +settings-tool-summaries-description = Completed tool calls with long output get a one-line summary on their cards. +settings-composer-vim = Vim mode in composer +settings-composer-vim-description = Use Normal, Insert, and Visual editing modes in the main chat composer. Other text fields stay unchanged. +settings-application-vim = Vim navigation across the app +settings-application-vim-description = Navigate stable sidebar, transcript, dialog, and Settings targets. Ordinary text fields still edit normally. +settings-speech-voice = Speech voice +settings-speech-voice-description = The voice that reads messages aloud. +settings-speech-speed = Speech speed +settings-speech-speed-description = How fast messages are read aloud. +settings-preview = Live preview +settings-preview-assistant = Assistant +settings-preview-you = You +settings-preview-text = Clear, comfortable text makes longer conversations easier to follow. +settings-preview-bold = Bold should look crisp, not blurry. +settings-preview-reply = This size feels just right. +settings-notification-test-title = Desktop notifications on +settings-notification-test-body = You will see alerts like this at { $time }. +settings-section-general = General +settings-section-account = Account +settings-section-billing = Billing +settings-section-api-keys = API keys +settings-section-local-api = Local API +settings-section-shortcuts = Keyboard Shortcuts +settings-section-prompt = Additional instructions +settings-section-integrations = Integrations +settings-section-remote = Remote computers +settings-section-usage = Usage +settings-section-about = About +settings-section-advanced = Privacy & Security +settings-back = ← Back +settings-title = Settings +settings-sign-out = Sign out +settings-sections = Settings sections +settings-theme-label-system = System +settings-theme-label-dark = Dark +settings-theme-label-light = Light +settings-font-label-system = System +settings-font-label-manrope = Manrope +settings-font-label-geist = Geist +settings-font-label-serif = Serif +settings-font-note-system = The familiar interface font for your device. +settings-font-note-manrope = Maple's brand face, with a compact modern feel. +settings-font-note-geist = A lighter grotesque that pairs with Geist Mono. +settings-font-note-serif = A traditional reading style with distinct letterforms. +settings-voice-neutral-female = Neutral — Female +settings-voice-neutral-male = Neutral — Male +settings-voice-casual-female = Casual — Female +settings-voice-casual-male = Casual — Male +settings-voice-cheerful-female = Cheerful — Female +settings-voice-ar-male = Arabic-accented — Male +settings-voice-de-female = German-accented — Female +settings-voice-de-male = German-accented — Male +settings-voice-es-female = Spanish-accented — Female +settings-voice-es-male = Spanish-accented — Male +settings-voice-fr-female = French-accented — Female +settings-voice-fr-male = French-accented — Male +settings-voice-hi-female = Hindi-accented — Female +settings-voice-hi-male = Hindi-accented — Male +settings-voice-it-female = Italian-accented — Female +settings-voice-it-male = Italian-accented — Male +settings-voice-nl-female = Dutch-accented — Female +settings-voice-nl-male = Dutch-accented — Male +settings-voice-pt-female = Portuguese-accented — Female +settings-voice-pt-male = Portuguese-accented — Male diff --git a/apps/maple-agent/app/locales/es/app.ftl b/apps/maple-agent/app/locales/es/app.ftl index 53bcaff9e..4ea6461c8 100644 --- a/apps/maple-agent/app/locales/es/app.ftl +++ b/apps/maple-agent/app/locales/es/app.ftl @@ -14,3 +14,89 @@ update-confirmation-title-version = ¿Reiniciar Maple para instalar la versión update-confirmation-detail = Esto detendrá las tareas en ejecución y el acceso remoto en este equipo. Los editores que usan Maple deberán volver a conectarse después de la actualización. update-confirmation-later = Más tarde update-confirmation-restart = Reiniciar para actualizar + +# General settings and shared settings navigation. +settings-defaults = Valores predeterminados +settings-chat-appearance = Apariencia del chat +settings-editing = Edición +settings-voice = Voz +settings-web = Las nuevas tareas pueden usar la web +settings-web-description = Ofrece web_search y open_url al modelo. Cada tarea puede activar o desactivar el acceso a la web desde su cuadro de mensaje. +settings-appearance = Apariencia +settings-appearance-description = Usa el tema del sistema o elige el modo oscuro o claro. +settings-chat-font = Fuente del chat +settings-text-size = Tamaño del texto +settings-text-size-description = Tamaño del texto de la conversación y del cuadro de mensaje. La barra lateral y los botones siguen usando Manrope. +settings-tool-details = Mostrar detalles de las llamadas a herramientas +settings-tool-details-description = Las tarjetas de herramientas incluyen sus datos de entrada y salida. +settings-notifications = Notificaciones de escritorio +settings-notifications-description = Recibe una notificación cuando una tarea termine o necesite tu respuesta mientras la ventana no esté activa. +settings-password-details = Mostrar detalles de las tareas en el modo con contraseña +settings-password-details-description = Incluye los títulos de las tareas y las preguntas en las notificaciones de escritorio. El sistema operativo puede conservar este texto después de cerrar Maple. +settings-reduce-motion = Reducir el movimiento +settings-reduce-motion-description = Mantiene estáticos los indicadores de carga y los puntos intermitentes, y omite las animaciones de aparición. +settings-tool-summaries = Resumir las llamadas a herramientas +settings-tool-summaries-description = Las llamadas a herramientas completadas con resultados largos muestran un resumen de una línea en sus tarjetas. +settings-composer-vim = Modo Vim en el cuadro de mensaje +settings-composer-vim-description = Usa los modos Normal, Insertar y Visual en el cuadro de mensaje principal del chat. Los demás campos de texto no cambian. +settings-application-vim = Navegación Vim en toda la aplicación +settings-application-vim-description = Navega por los elementos de la barra lateral, la conversación, los diálogos y los ajustes. Los campos de texto se siguen editando con normalidad. +settings-speech-voice = Voz de lectura +settings-speech-voice-description = La voz que lee los mensajes en voz alta. +settings-speech-speed = Velocidad de lectura +settings-speech-speed-description = La velocidad a la que se leen los mensajes en voz alta. +settings-preview = Vista previa en tiempo real +settings-preview-assistant = Asistente +settings-preview-you = Tú +settings-preview-text = Un texto claro y cómodo facilita seguir conversaciones largas. +settings-preview-bold = La negrita debe verse nítida, sin borrosidad. +settings-preview-reply = Este tamaño se lee muy bien. +settings-notification-test-title = Notificaciones de escritorio activadas +settings-notification-test-body = Verás avisos como este a las { $time }. +settings-section-general = General +settings-section-account = Cuenta +settings-section-billing = Facturación +settings-section-api-keys = Claves de API +settings-section-local-api = API local +settings-section-shortcuts = Atajos de teclado +settings-section-prompt = Instrucciones adicionales +settings-section-integrations = Integraciones +settings-section-remote = Equipos remotos +settings-section-usage = Uso +settings-section-about = Acerca de +settings-section-advanced = Privacidad y seguridad +settings-back = ← Volver +settings-title = Ajustes +settings-sign-out = Cerrar sesión +settings-sections = Secciones de ajustes +settings-theme-label-system = Sistema +settings-theme-label-dark = Oscuro +settings-theme-label-light = Claro +settings-font-label-system = Sistema +settings-font-label-manrope = Manrope +settings-font-label-geist = Geist +settings-font-label-serif = Serif +settings-font-note-system = La fuente habitual de la interfaz de tu dispositivo. +settings-font-note-manrope = La fuente de Maple, de estilo compacto y moderno. +settings-font-note-geist = Una fuente de palo seco ligera que combina con Geist Mono. +settings-font-note-serif = Un estilo de lectura tradicional con letras bien definidas. +settings-voice-neutral-female = Neutra — Femenina +settings-voice-neutral-male = Neutra — Masculina +settings-voice-casual-female = Informal — Femenina +settings-voice-casual-male = Informal — Masculina +settings-voice-cheerful-female = Alegre — Femenina +settings-voice-ar-male = Con acento árabe — Masculina +settings-voice-de-female = Con acento alemán — Femenina +settings-voice-de-male = Con acento alemán — Masculina +settings-voice-es-female = Con acento español — Femenina +settings-voice-es-male = Con acento español — Masculina +settings-voice-fr-female = Con acento francés — Femenina +settings-voice-fr-male = Con acento francés — Masculina +settings-voice-hi-female = Con acento hindi — Femenina +settings-voice-hi-male = Con acento hindi — Masculina +settings-voice-it-female = Con acento italiano — Femenina +settings-voice-it-male = Con acento italiano — Masculina +settings-voice-nl-female = Con acento neerlandés — Femenina +settings-voice-nl-male = Con acento neerlandés — Masculina +settings-voice-pt-female = Con acento portugués — Femenina +settings-voice-pt-male = Con acento portugués — Masculina diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 33b58b5a9..8098ae57d 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -186,15 +186,6 @@ pub const TTS_SPEEDS: [f32; 6] = [0.8, 1.0, 1.2, 1.5, 1.8, 2.0]; const DEFAULT_TTS_VOICE: &str = "casual_female"; const DEFAULT_TTS_SPEED: f32 = 1.0; -/// Label for a voice id; the id itself when it is unknown. -pub fn tts_voice_label(voice: &str) -> &str { - TTS_VOICES - .iter() - .find(|(id, _)| *id == voice) - .map(|(_, label)| *label) - .unwrap_or(voice) -} - fn default_tts_voice() -> String { DEFAULT_TTS_VOICE.to_string() } diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs index 82be0d4b0..14b7025bf 100644 --- a/apps/maple-agent/app/src/ui/i18n/tests.rs +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -302,6 +302,8 @@ fn converted_modules_do_not_reintroduce_inline_labels() { // outside the gate rather than freezing hundreds of existing literals. let mut visitor = UiStrings::default(); visitor.visit_file(&syn::parse_file(include_str!("../update_confirmation.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/general.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/language.rs")).unwrap()); assert!( visitor.literals.is_empty(), "inline UI text: {:?}", diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index c96066b89..6e646920f 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -35,7 +35,9 @@ mod advanced; mod api_credits; mod api_keys; mod billing; +mod general; mod language; +use general::{font_label, theme_label, voice_label}; #[cfg(feature = "proxy")] mod local_api; mod mcp; @@ -78,7 +80,7 @@ pub enum Section { } impl Section { - fn label(self) -> &'static str { + fn id(self) -> &'static str { match self { Self::General => "General", Self::Account => "Account", @@ -96,6 +98,24 @@ impl Section { } } + fn label(self) -> gpui::SharedString { + super::i18n::tr(match self { + Self::General => "settings-section-general", + Self::Account => "settings-section-account", + Self::Billing => "settings-section-billing", + Self::ApiKeys => "settings-section-api-keys", + #[cfg(feature = "proxy")] + Self::LocalApi => "settings-section-local-api", + Self::Shortcuts => "settings-section-shortcuts", + Self::Prompt => "settings-section-prompt", + Self::Integrations => "settings-section-integrations", + Self::Remote => "settings-section-remote", + Self::Usage => "settings-section-usage", + Self::About => "settings-section-about", + Self::Advanced => "settings-section-advanced", + }) + } + const ALL: &[Self] = &[ Self::General, Self::Account, @@ -978,14 +998,14 @@ impl SettingsScreen { ] .iter() .map(|&preference| SettingOption { - label: preference.label().to_string(), + label: theme_label(preference).to_string(), current: self.theme == preference, }) .collect(), SettingMenu::ChatFont => crate::ui::typography::ChatFontFamily::ALL .iter() .map(|&family| SettingOption { - label: family.label().to_string(), + label: font_label(family).to_string(), current: crate::ui::typography::ChatFontFamily::parse( &self.settings.chat_font_family, ) == family, @@ -1002,8 +1022,8 @@ impl SettingsScreen { .collect(), SettingMenu::Voice => settings::TTS_VOICES .iter() - .map(|(id, label)| SettingOption { - label: label.to_string(), + .map(|(id, _)| SettingOption { + label: voice_label(id), current: *id == self.settings.tts_voice, }) .collect(), @@ -1023,17 +1043,16 @@ impl SettingsScreen { SettingMenu::Language => { super::i18n::preference_label(self.settings.locale.as_deref()).to_string() } - SettingMenu::Appearance => self.theme.label().to_string(), - SettingMenu::ChatFont => { - crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family) - .label() - .to_string() - } + SettingMenu::Appearance => theme_label(self.theme).to_string(), + SettingMenu::ChatFont => font_label(crate::ui::typography::ChatFontFamily::parse( + &self.settings.chat_font_family, + )) + .to_string(), SettingMenu::ChatSize => format!( "{} px", crate::ui::typography::clamp_chat_font_size(self.settings.chat_font_size) ), - SettingMenu::Voice => settings::tts_voice_label(&self.settings.tts_voice).to_string(), + SettingMenu::Voice => voice_label(&self.settings.tts_voice), SettingMenu::SpeechSpeed => format!("{:.1}\u{d7}", self.settings.tts_speed), } } @@ -1054,85 +1073,6 @@ impl SettingsScreen { } } - fn chat_appearance_preview(&self) -> Div { - let family = crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family); - let size = crate::ui::typography::clamp_chat_font_size(self.settings.chat_font_size); - let speaker = |name: &'static str| { - div() - .text_xs() - .font_family(crate::assets::FONT_BODY) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::text_muted())) - .child(name) - }; - // The settings pane and user bubbles both sit on elevated fills in - // dark mode. Recess a real chat well (`bg_app`) so assistant text - // and the user bubble separate from the card, matching the transcript. - widgets::card_row() - .flex() - .flex_col() - .gap_2() - .child( - div() - .text_xs() - .font_family(crate::assets::FONT_BODY) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::text_muted())) - .child(format!("Live preview · {} · {size} px", family.label())), - ) - .child( - div() - .w_full() - .rounded(theme::RADIUS_MD) - .border_1() - .border_color(gpui::rgb(theme::border())) - .bg(gpui::rgb(if theme::is_light() { - theme::bg_sidebar() - } else { - theme::bg_app() - })) - .px_3() - .py_3() - .flex() - .flex_col() - .gap_2() - .child( - crate::ui::typography::chat_reading(div()) - .max_w(gpui::relative(0.82)) - .flex() - .flex_col() - .gap_1() - .text_color(gpui::rgb(theme::text_primary())) - .child(speaker("Assistant")) - .child( - "Clear, comfortable text makes longer conversations easier to follow.", - ) - .child( - div() - .font_weight(crate::ui::typography::emphasis_weight()) - .child("Bold should look crisp, not blurry."), - ), - ) - .child( - div().w_full().flex().justify_end().child( - crate::ui::typography::chat_reading(div()) - .px_3() - .py_2() - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::bg_user_bubble())) - .border_1() - .border_color(gpui::rgb(theme::user_bubble_border())) - .flex() - .flex_col() - .gap_1() - .text_color(gpui::rgb(theme::text_primary())) - .child(speaker("You")) - .child("This size feels just right."), - ), - ), - ) - } - /// Apply the option at `index`: an absolute set, so the local copy /// and the file end in the same state whatever the file held. pub(super) fn pick_setting_option( @@ -1250,8 +1190,11 @@ impl SettingsScreen { crate::notify::notify( cx, "settings:test", - "Desktop notifications on", - &format!("You will see alerts like this at {enabled_at}."), + &super::i18n::tr("settings-notification-test-title"), + &super::i18n::tr_args( + "settings-notification-test-body", + [("time", enabled_at.into())], + ), &[], ); } @@ -1280,7 +1223,11 @@ impl SettingsScreen { let options = self.menu_options(menu); let dropdown = Menu::new( gpui::SharedString::from(format!("setting-menu-{}", menu.id())), - px(260.), + px(if menu == SettingMenu::Voice { + 360. + } else { + 260. + }), ) .label(title.to_string()) .max_height(px(320.)) @@ -1304,11 +1251,15 @@ impl SettingsScreen { })); self.popup.render(dropdown, Placement::BelowEnd, window, cx) }); - let slug = if menu == SettingMenu::Language { - menu.id().to_owned() - } else { - title.to_lowercase().replace(' ', "-") - }; + let slug = match menu { + SettingMenu::Appearance => "appearance", + SettingMenu::Language => "language", + SettingMenu::ChatFont => "chat-font", + SettingMenu::ChatSize => "text-size", + SettingMenu::Voice => "speech-voice", + SettingMenu::SpeechSpeed => "speech-speed", + } + .to_owned(); let button = widgets::secondary_button(gpui::SharedString::from(format!("setting-value-{slug}"))) .debug_selector(move || format!("setting-value-{slug}")) @@ -1319,11 +1270,14 @@ impl SettingsScreen { .child(self.menu_value_label(menu)) .child(icon("chevron-down", px(12.), theme::text_muted())); widgets::card_row() - .id(if menu == SettingMenu::Language { - gpui::SharedString::from("setting-language") - } else { - setting_row_id(title) - }) + .id(gpui::SharedString::from(format!( + "setting-{}", + match menu { + SettingMenu::ChatSize => "text-size", + SettingMenu::Voice => "speech-voice", + _ => menu.id(), + } + ))) .flex() .items_center() .justify_between() @@ -1641,13 +1595,13 @@ impl Render for SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.close(cx); })) - .child("← Back"), + .child(super::i18n::tr("settings-back")), ) .child( div() .font_weight(gpui::FontWeight::BOLD) .text_color(gpui::rgb(theme::text_primary())) - .child("Settings"), + .child(super::i18n::tr("settings-title")), ) .child(div().flex_1()) .child( @@ -1669,7 +1623,7 @@ impl Render for SettingsScreen { .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(SignOutRequested); })) - .child("Sign out"), + .child(super::i18n::tr("settings-sign-out")), ), )) .child( @@ -1700,7 +1654,7 @@ impl SettingsScreen { div() .id("settings-nav") .role(gpui::Role::TabList) - .aria_label("Settings sections") + .aria_label(super::i18n::tr("settings-sections")) .aria_orientation(gpui::Orientation::Vertical) .w(gpui::px(220.)) .h_full() @@ -1717,7 +1671,7 @@ impl SettingsScreen { div() .id(gpui::SharedString::from(format!( "settings-nav-{}", - section.label() + section.id() ))) .role(gpui::Role::Tab) .aria_label(section.label()) @@ -1770,171 +1724,7 @@ impl SettingsScreen { pane = pane.child(self.render_advanced(cx)); } Section::General => { - pane = pane - .child(section_title("Defaults")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Web), - toggle_row( - "New tasks can use the web", - "Offers web_search and open_url to the model. Each task can \ - switch web access on or off from its composer.", - self.settings.default_web_enabled, - cx.listener(|this, _event, _window, cx| { - this.toggle_web_default(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Appearance), - self.setting_menu_row( - "Appearance", - "Follow the system theme, or force dark or light.", - SettingMenu::Appearance, - window, - cx, - ), - )) - .when(super::i18n::enabled(), |pane| { - pane.child(self.application_target( - || SettingsTarget::General(GeneralTarget::Language), - self.setting_menu_row( - &super::i18n::tr("settings-language"), - &super::i18n::tr("settings-language-description"), - SettingMenu::Language, window, cx, - ), - )) - }) - .when(self.language_restart_visible(), |pane| { - pane.child(self.language_restart_notice(cx)) - }) - .child(section_title("Chat appearance")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ChatFont), - self.setting_menu_row( - "Chat font", - crate::ui::typography::ChatFontFamily::parse( - &self.settings.chat_font_family, - ) - .note(), - SettingMenu::ChatFont, - window, - cx, - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ChatSize), - self.setting_menu_row( - "Text size", - "Size of conversation and composer text. Sidebar and buttons stay Manrope.", - SettingMenu::ChatSize, - window, - cx, - ), - )) - .child(self.chat_appearance_preview()) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ToolDetails), - toggle_row( - "Show tool call details", - "Tool cards include their input and output payloads.", - self.settings.tool_details, - cx.listener(|this, _event, _window, cx| { - this.toggle_tool_details(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Notifications), - toggle_row( - "Desktop notifications", - "Notify when a task finishes or needs your input while \ - the window is not focused.", - self.settings.desktop_notifications, - cx.listener(|this, _event, _window, cx| { - this.toggle_desktop_notifications(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::PasswordNotificationDetails), - toggle_row( - "Show task details in password mode", - "Include task titles and questions in desktop notifications. \ - Your operating system may retain this text after Maple quits.", - self.settings.password_notification_details, - cx.listener(|this, _event, _window, cx| { - this.toggle_password_notification_details(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ReduceMotion), - toggle_row( - "Reduce motion", - "Hold spinners and pulsing dots still and skip reveal \ - animations.", - self.settings.reduce_motion, - cx.listener(|this, _event, _window, cx| { - this.toggle_reduce_motion(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ToolSummaries), - toggle_row( - "Summarize tool calls", - "Completed tool calls with long output get a one-line \ - summary on their cards.", - self.settings.tool_summaries, - cx.listener(|this, _event, _window, cx| { - this.toggle_tool_summaries(cx); - }), - ), - )) - .child(section_title("Editing")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ComposerVim), - toggle_row( - "Vim mode in composer", - "Use Normal, Insert, and Visual editing modes in the main chat composer. Other text fields stay unchanged.", - self.settings.composer_vim_enabled, - cx.listener(|this, _event, _window, cx| { - this.toggle_composer_vim(cx); - }), - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::ApplicationVim), - toggle_row( - "Vim navigation across the app", - "Navigate stable sidebar, transcript, dialog, and Settings targets. Ordinary text fields still edit normally.", - self.settings.application_vim_enabled, - cx.listener(|this, _event, _window, cx| { - this.toggle_application_vim(cx); - }), - ), - )) - .child(section_title("Voice")) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::Voice), - self.setting_menu_row( - "Speech voice", - "The voice that reads messages aloud.", - SettingMenu::Voice, - window, - cx, - ), - )) - .child(self.application_target( - || SettingsTarget::General(GeneralTarget::SpeechSpeed), - self.setting_menu_row( - "Speech speed", - "How fast messages are read aloud.", - SettingMenu::SpeechSpeed, - window, - cx, - ), - )); + pane = self.render_general(pane, window, cx); } Section::Account => { pane = pane.child(self.render_account_pane(cx)); diff --git a/apps/maple-agent/app/src/ui/settings/general.rs b/apps/maple-agent/app/src/ui/settings/general.rs new file mode 100644 index 000000000..4742e65fd --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/general.rs @@ -0,0 +1,330 @@ +//! General settings presentation; persisted values and element IDs stay stable. +use super::*; +use crate::ui::i18n::tr; + +impl SettingsScreen { + pub(super) fn render_general( + &self, + pane: gpui::Stateful
, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Stateful
{ + pane.child(section_title(&tr("settings-defaults"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Web), + localized_toggle_row( + "new-tasks-can-use-the-web", + &tr("settings-web"), + &tr("settings-web-description"), + self.settings.default_web_enabled, + cx.listener(|this, _event, _window, cx| { + this.toggle_web_default(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Appearance), + self.setting_menu_row( + &tr("settings-appearance"), + &tr("settings-appearance-description"), + SettingMenu::Appearance, + window, + cx, + ), + )) + .when(crate::ui::i18n::enabled(), |pane| { + pane.child(self.application_target( + || SettingsTarget::General(GeneralTarget::Language), + self.setting_menu_row( + &crate::ui::i18n::tr("settings-language"), + &crate::ui::i18n::tr("settings-language-description"), + SettingMenu::Language, + window, + cx, + ), + )) + }) + .when(self.language_restart_visible(), |pane| { + pane.child(self.language_restart_notice(cx)) + }) + .child(section_title(&tr("settings-chat-appearance"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ChatFont), + self.setting_menu_row( + &tr("settings-chat-font"), + &font_note(crate::ui::typography::ChatFontFamily::parse( + &self.settings.chat_font_family, + )), + SettingMenu::ChatFont, + window, + cx, + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ChatSize), + self.setting_menu_row( + &tr("settings-text-size"), + &tr("settings-text-size-description"), + SettingMenu::ChatSize, + window, + cx, + ), + )) + .child(self.chat_appearance_preview()) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ToolDetails), + localized_toggle_row( + "show-tool-call-details", + &tr("settings-tool-details"), + &tr("settings-tool-details-description"), + self.settings.tool_details, + cx.listener(|this, _event, _window, cx| { + this.toggle_tool_details(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Notifications), + localized_toggle_row( + "desktop-notifications", + &tr("settings-notifications"), + &tr("settings-notifications-description"), + self.settings.desktop_notifications, + cx.listener(|this, _event, _window, cx| { + this.toggle_desktop_notifications(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::PasswordNotificationDetails), + localized_toggle_row( + "show-task-details-in-password-mode", + &tr("settings-password-details"), + &tr("settings-password-details-description"), + self.settings.password_notification_details, + cx.listener(|this, _event, _window, cx| { + this.toggle_password_notification_details(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ReduceMotion), + localized_toggle_row( + "reduce-motion", + &tr("settings-reduce-motion"), + &tr("settings-reduce-motion-description"), + self.settings.reduce_motion, + cx.listener(|this, _event, _window, cx| { + this.toggle_reduce_motion(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ToolSummaries), + localized_toggle_row( + "summarize-tool-calls", + &tr("settings-tool-summaries"), + &tr("settings-tool-summaries-description"), + self.settings.tool_summaries, + cx.listener(|this, _event, _window, cx| { + this.toggle_tool_summaries(cx); + }), + ), + )) + .child(section_title(&tr("settings-editing"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ComposerVim), + localized_toggle_row( + "vim-mode-in-composer", + &tr("settings-composer-vim"), + &tr("settings-composer-vim-description"), + self.settings.composer_vim_enabled, + cx.listener(|this, _event, _window, cx| { + this.toggle_composer_vim(cx); + }), + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::ApplicationVim), + localized_toggle_row( + "vim-navigation-across-the-app", + &tr("settings-application-vim"), + &tr("settings-application-vim-description"), + self.settings.application_vim_enabled, + cx.listener(|this, _event, _window, cx| { + this.toggle_application_vim(cx); + }), + ), + )) + .child(section_title(&tr("settings-voice"))) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Voice), + self.setting_menu_row( + &tr("settings-speech-voice"), + &tr("settings-speech-voice-description"), + SettingMenu::Voice, + window, + cx, + ), + )) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::SpeechSpeed), + self.setting_menu_row( + &tr("settings-speech-speed"), + &tr("settings-speech-speed-description"), + SettingMenu::SpeechSpeed, + window, + cx, + ), + )) + } + fn chat_appearance_preview(&self) -> Div { + let family = crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family); + let size = crate::ui::typography::clamp_chat_font_size(self.settings.chat_font_size); + let speaker = |name: gpui::SharedString| { + div() + .text_xs() + .font_family(crate::assets::FONT_BODY) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_muted())) + .child(name) + }; + // The settings pane and user bubbles both sit on elevated fills in + // dark mode. Recess a real chat well (`bg_app`) so assistant text + // and the user bubble separate from the card, matching the transcript. + widgets::card_row() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_xs() + .font_family(crate::assets::FONT_BODY) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_muted())) + .flex() + .flex_wrap() + .child(tr("settings-preview")) + .child(format!(" · {} · {size} px", font_label(family))), + ) + .child( + div() + .w_full() + .rounded(theme::RADIUS_MD) + .border_1() + .border_color(gpui::rgb(theme::border())) + .bg(gpui::rgb(if theme::is_light() { + theme::bg_sidebar() + } else { + theme::bg_app() + })) + .px_3() + .py_3() + .flex() + .flex_col() + .gap_2() + .child( + crate::ui::typography::chat_reading(div()) + .max_w(gpui::relative(0.82)) + .flex() + .flex_col() + .gap_1() + .text_color(gpui::rgb(theme::text_primary())) + .child(speaker(tr("settings-preview-assistant"))) + .child(tr("settings-preview-text")) + .child( + div() + .font_weight(crate::ui::typography::emphasis_weight()) + .child(tr("settings-preview-bold")), + ), + ) + .child( + div().w_full().flex().justify_end().child( + crate::ui::typography::chat_reading(div()) + .px_3() + .py_2() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_user_bubble())) + .border_1() + .border_color(gpui::rgb(theme::user_bubble_border())) + .flex() + .flex_col() + .gap_1() + .text_color(gpui::rgb(theme::text_primary())) + .child(speaker(tr("settings-preview-you"))) + .child(tr("settings-preview-reply")), + ), + ), + ) + } +} + +fn localized_toggle_row( + slug: &'static str, + title: &str, + description: &str, + on: bool, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, +) -> gpui::Stateful
{ + widgets::card_row() + .id(gpui::SharedString::from(format!("setting-{slug}"))) + .flex() + .items_center() + .justify_between() + .gap_4() + .hover(|style| style.cursor_pointer()) + .on_click(on_click) + .child(setting_copy(title, description)) + .child(widgets::switch( + gpui::SharedString::from(format!("setting-switch-{slug}")), + on, + )) +} +pub(super) fn theme_label(value: theme::Preference) -> gpui::SharedString { + match value { + theme::Preference::System => tr("settings-theme-label-system"), + theme::Preference::Dark => tr("settings-theme-label-dark"), + theme::Preference::Light => tr("settings-theme-label-light"), + } +} +pub(super) fn font_label(value: crate::ui::typography::ChatFontFamily) -> gpui::SharedString { + match value { + crate::ui::typography::ChatFontFamily::System => tr("settings-font-label-system"), + crate::ui::typography::ChatFontFamily::Manrope => tr("settings-font-label-manrope"), + crate::ui::typography::ChatFontFamily::Geist => tr("settings-font-label-geist"), + crate::ui::typography::ChatFontFamily::Serif => tr("settings-font-label-serif"), + } +} +pub(super) fn font_note(value: crate::ui::typography::ChatFontFamily) -> gpui::SharedString { + match value { + crate::ui::typography::ChatFontFamily::System => tr("settings-font-note-system"), + crate::ui::typography::ChatFontFamily::Manrope => tr("settings-font-note-manrope"), + crate::ui::typography::ChatFontFamily::Geist => tr("settings-font-note-geist"), + crate::ui::typography::ChatFontFamily::Serif => tr("settings-font-note-serif"), + } +} +pub(super) fn voice_label(id: &str) -> String { + match id { + "neutral_female" => tr("settings-voice-neutral-female").to_string(), + "neutral_male" => tr("settings-voice-neutral-male").to_string(), + "casual_female" => tr("settings-voice-casual-female").to_string(), + "casual_male" => tr("settings-voice-casual-male").to_string(), + "cheerful_female" => tr("settings-voice-cheerful-female").to_string(), + "ar_male" => tr("settings-voice-ar-male").to_string(), + "de_female" => tr("settings-voice-de-female").to_string(), + "de_male" => tr("settings-voice-de-male").to_string(), + "es_female" => tr("settings-voice-es-female").to_string(), + "es_male" => tr("settings-voice-es-male").to_string(), + "fr_female" => tr("settings-voice-fr-female").to_string(), + "fr_male" => tr("settings-voice-fr-male").to_string(), + "hi_female" => tr("settings-voice-hi-female").to_string(), + "hi_male" => tr("settings-voice-hi-male").to_string(), + "it_female" => tr("settings-voice-it-female").to_string(), + "it_male" => tr("settings-voice-it-male").to_string(), + "nl_female" => tr("settings-voice-nl-female").to_string(), + "nl_male" => tr("settings-voice-nl-male").to_string(), + "pt_female" => tr("settings-voice-pt-female").to_string(), + "pt_male" => tr("settings-voice-pt-male").to_string(), + _ => id.to_owned(), + } +} diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index a4ba063f9..e0ffae833 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -1080,6 +1080,31 @@ mod tests { }); } + #[gpui::test] + fn spanish_general_preserves_controls_and_saved_values(cx: &mut TestAppContext) { + let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); + crate::ui::i18n::initialize_saved_preference(Some("es")); + let (settings, cx) = dropdown_window(cx, true); + cx.run_until_parked(); + assert!(cx.debug_bounds("setting-value-appearance").is_some()); + assert!(cx.debug_bounds("setting-value-text-size").is_some()); + settings.update(cx, |this, cx| { + assert_eq!(this.menu_value(SettingMenu::Appearance), "Sistema"); + assert_eq!(this.menu_value(SettingMenu::ChatFont), "Sistema"); + let voices = this.menu_options(SettingMenu::Voice); + assert_eq!(voices.len(), crate::settings::TTS_VOICES.len()); + assert_eq!(voices[8].label, "Con acento español — Femenina"); + this.pick_setting_option(SettingMenu::Voice, 8, cx); + assert_eq!(this.settings.tts_voice, "es_female"); + assert_eq!(this.menu_value(SettingMenu::Voice), voices[8].label); + this.pick_setting_option(SettingMenu::Appearance, 1, cx); + assert_eq!(this.settings.theme, "dark"); + assert_eq!(this.menu_value(SettingMenu::Appearance), "Oscuro"); + assert_eq!(Section::General.id(), "General"); + assert_eq!(Section::Advanced.label().as_ref(), "Privacidad y seguridad"); + }); + } + #[gpui::test] fn dropdown_rows_select_highlight_and_close(cx: &mut TestAppContext) { let (settings, cx) = dropdown_window(cx, false); diff --git a/apps/maple-agent/app/src/ui/theme.rs b/apps/maple-agent/app/src/ui/theme.rs index 1754bb1d6..bd284a0b0 100644 --- a/apps/maple-agent/app/src/ui/theme.rs +++ b/apps/maple-agent/app/src/ui/theme.rs @@ -39,14 +39,6 @@ impl Preference { Self::Light => "light", } } - - pub fn label(self) -> &'static str { - match self { - Self::System => "System", - Self::Dark => "Dark", - Self::Light => "Light", - } - } } static PREFERENCE: AtomicU8 = AtomicU8::new(0); diff --git a/apps/maple-agent/app/src/ui/typography.rs b/apps/maple-agent/app/src/ui/typography.rs index 0124a769f..259181dcc 100644 --- a/apps/maple-agent/app/src/ui/typography.rs +++ b/apps/maple-agent/app/src/ui/typography.rs @@ -63,24 +63,6 @@ impl ChatFontFamily { } } - pub fn label(self) -> &'static str { - match self { - Self::System => "System", - Self::Manrope => "Manrope", - Self::Geist => "Geist", - Self::Serif => "Serif", - } - } - - pub fn note(self) -> &'static str { - match self { - Self::System => "The familiar interface font for your device.", - Self::Manrope => "Maple's brand face, with a compact modern feel.", - Self::Geist => "A lighter grotesque that pairs with Geist Mono.", - Self::Serif => "A traditional reading style with distinct letterforms.", - } - } - fn family_name(self) -> &'static str { match self { Self::System => assets::FONT_SYSTEM, diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md index 679809ff9..b0c399442 100644 --- a/apps/maple-agent/docs/localization.md +++ b/apps/maple-agent/docs/localization.md @@ -2,7 +2,9 @@ The localization foundation uses embedded Fluent catalogs. It currently covers the update confirmation dialog (including accessibility -text) and the language setting. English and Spanish are enabled in the preview. +text), the full General settings pane (including dropdown choices, the chat +preview and test notification), and shared Settings navigation. English and +Spanish are enabled in the preview. Most of the app is still English; this is infrastructure for incremental extraction, not a claim of complete localization. From 89564604d40cd8e05b6a6152770352586b87750b Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Tue, 3 Nov 2026 19:57:31 +0000 Subject: [PATCH 7/9] agent: apply language changes live without a preview gate --- .agents/skills/develop-maple-agent/SKILL.md | 2 +- apps/maple-agent/CLAUDE.md | 2 +- apps/maple-agent/app/locales/en/app.ftl | 6 +- apps/maple-agent/app/locales/es/app.ftl | 6 +- apps/maple-agent/app/src/desktop.rs | 72 ++--------- .../app/src/desktop/maintenance_tests.rs | 19 --- .../app/src/desktop/native_quit.rs | 8 +- apps/maple-agent/app/src/ui/i18n.rs | 64 ++++------ apps/maple-agent/app/src/ui/i18n/tests.rs | 34 ++--- apps/maple-agent/app/src/ui/settings.rs | 21 +--- .../app/src/ui/settings/general.rs | 25 ++-- .../app/src/ui/settings/language.rs | 117 ------------------ .../app/src/ui/settings/navigation.rs | 106 ++-------------- .../app/src/ui/update_confirmation.rs | 40 ++++++ apps/maple-agent/docs/localization.md | 60 +++------ 15 files changed, 133 insertions(+), 449 deletions(-) delete mode 100644 apps/maple-agent/app/src/ui/settings/language.rs diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index cae70e8d1..e20db499e 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -114,7 +114,7 @@ parse the feed strictly; keep the wire contract in that doc unchanged. See New Agent UI labels use `ui::i18n` and the embedded Fluent catalogs; follow [the localization contract](../../../apps/maple-agent/docs/localization.md). Plain labels are cached; parameterized messages are formatted on state changes. -Language changes apply on restart. Preserve machine identifiers, model-facing +Language changes apply live; invalidate cached translated text when the locale changes. Preserve machine identifiers, model-facing text, user content and persisted timeline text. Do not translate host/ACP errors in place. Expand the converted-module guard with each extraction, and require platform/font/packaging evidence before enabling another locale. diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index bd6b256e6..798eea571 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -171,5 +171,5 @@ Use the embedded Fluent catalog and `ui::i18n` for new UI labels, including accessibility text; see [localization](docs/localization.md) for extraction and locale enablement. Cache parameterized messages when their state changes. Keep element IDs, protocol values, model prompts, user content and persisted timeline -text unchanged. Language changes apply on restart. Extend the converted-module +text unchanged. Language changes apply live; invalidate cached translated text when the locale changes. Extend the converted-module guard as each module is extracted; host errors and native menus need owned work. diff --git a/apps/maple-agent/app/locales/en/app.ftl b/apps/maple-agent/app/locales/en/app.ftl index f6f81a406..5d051d499 100644 --- a/apps/maple-agent/app/locales/en/app.ftl +++ b/apps/maple-agent/app/locales/en/app.ftl @@ -1,13 +1,9 @@ # Source catalog. Keep message IDs stable; never translate protocol or element IDs. settings-language = Language -settings-language-description = Applies after restarting Maple. Text not yet translated remains in English. +settings-language-description = Changes apply immediately. Text not yet translated remains in English. # $language is the native name of the resolved system language. settings-language-system = System ({ $language }) language-name = English -settings-language-restart-title = Restart Maple to apply the language change -settings-language-restart-detail = Your choice is saved. Restarting stops running tasks and remote access on this computer. -settings-language-restart-later = Later -settings-language-restart-now = Restart now update-confirmation-title = Restart Maple to update? # $version is the target application version, not translated. update-confirmation-title-version = Restart Maple to install v{ $version }? diff --git a/apps/maple-agent/app/locales/es/app.ftl b/apps/maple-agent/app/locales/es/app.ftl index 4ea6461c8..c5de32faa 100644 --- a/apps/maple-agent/app/locales/es/app.ftl +++ b/apps/maple-agent/app/locales/es/app.ftl @@ -1,13 +1,9 @@ # Traducción de la interfaz. Los identificadores de mensajes y elementos no se traducen. settings-language = Idioma -settings-language-description = Se aplica después de reiniciar Maple. El texto que aún no se haya traducido seguirá en inglés. +settings-language-description = Los cambios se aplican de inmediato. El texto que aún no se haya traducido seguirá en inglés. # $language es el nombre del idioma del sistema en su propio idioma. settings-language-system = Sistema ({ $language }) language-name = Español -settings-language-restart-title = Reinicia Maple para aplicar el cambio de idioma -settings-language-restart-detail = Tu elección se ha guardado. Reiniciar detendrá las tareas en ejecución y el acceso remoto en este equipo. -settings-language-restart-later = Más tarde -settings-language-restart-now = Reiniciar ahora update-confirmation-title = ¿Reiniciar Maple para actualizar? # $version es la versión de destino de la aplicación y no se traduce. update-confirmation-title-version = ¿Reiniciar Maple para instalar la versión { $version }? diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 05b4b86fa..6488c1c8c 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -2087,15 +2087,6 @@ impl MapleApp { }, ) .detach(); - cx.subscribe( - &screen, - |app: &mut MapleApp, emitter, _: &ui::settings::LanguageRestartRequested, cx| { - if app.is_current_settings_screen(&emitter) { - restart_for_language_change(cx); - } - }, - ) - .detach(); cx.subscribe(&screen, |app: &mut MapleApp, emitter, event: &ui::store_gate::PasswordFormFinished, cx| { if matches!(&app.screen, Screen::Settings(active) if active.entity_id() == emitter.entity_id()) { app.password_settings_finished(event, cx); @@ -2569,25 +2560,6 @@ struct DesktopLifecycle { } impl gpui::Global for DesktopLifecycle {} -struct LanguageRestartPending; -impl gpui::Global for LanguageRestartPending {} - -fn restart_for_language_change(cx: &mut App) { - let Some(state) = cx.try_global::() else { - return; - }; - if state.closing.get() - || state.frontend_detached.get() - || state.quit_completed.get() - || state.updater.restart_path().is_some() - || !may_finish_quit(cx) - { - return; - } - cx.set_global(LanguageRestartPending); - quit_app(cx); -} - /// Recheck final effects: an earlier Quit cannot exit an installation. fn may_finish_quit(cx: &App) -> bool { !cx.try_global::().is_some_and(|state| { @@ -2640,35 +2612,20 @@ fn restart_installed_update(path: PathBuf, cx: &mut App) { { state.quit_completed.set(true); #[cfg(target_os = "macos")] - native_quit::allow_restart(); + native_quit::allow_update_restart(); cx.set_restart_path(path); cx.restart(); #[cfg(target_os = "macos")] - native_quit::finish_restart(); + native_quit::finish_update_restart(); } } fn finish_quit(cx: &mut App) { if !may_finish_quit(cx) { - cancel_quit(cx); + cancel_native_quit(); show_update_about(cx); return; } - if cx.has_global::() { - if !cx - .try_global::() - .is_some_and(|state| state.quit_completed.get()) - { - return; - } - cx.remove_global::(); - #[cfg(target_os = "macos")] - native_quit::allow_restart(); - cx.restart(); - #[cfg(target_os = "macos")] - native_quit::finish_restart(); - return; - } #[cfg(target_os = "macos")] native_quit::complete(cx); #[cfg(not(target_os = "macos"))] @@ -2753,12 +2710,16 @@ fn drain_and_quit(state: DesktopLifecycle, cx: &mut App) { match stop.await { Ok(Ok(())) => cx.update(|cx| { if !may_finish_quit(cx) { - cancel_quit(cx); + state.closing.set(false); + cancel_native_quit(); show_update_about(cx); return; } state.quit_completed.set(true); - finish_quit(cx); + #[cfg(target_os = "macos")] + native_quit::complete(cx); + #[cfg(not(target_os = "macos"))] + cx.quit(); }), result => { state.closing.set(false); @@ -2877,9 +2838,6 @@ fn cancel_native_quit() { } fn cancel_quit(cx: &mut App) { - if cx.has_global::() { - cx.remove_global::(); - } if let Some(state) = cx.try_global::() { state.closing.set(false); state.quit_requested.set(false); @@ -2942,7 +2900,7 @@ struct QuitPrompt { kind: QuitPromptKind, } impl Render for QuitPrompt { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { let (title, message, retry) = match &self.kind { QuitPromptKind::Confirm(activity) => ( "Stop this computer's Maple host?", @@ -2982,15 +2940,7 @@ impl Render for QuitPrompt { .child( ui::widgets::secondary_button("retry-maple-quit") .debug_selector(|| "retry-maple-quit".to_string()) - .child(if cx.has_global::() { - ui::i18n::tr("settings-language-restart-now") - } else { - gpui::SharedString::from(if retry { - "Retry Quit" - } else { - "Quit Maple" - }) - }) + .child(if retry { "Retry Quit" } else { "Quit Maple" }) .on_click(move |_, window, cx| { window.remove_window(); if retry { diff --git a/apps/maple-agent/app/src/desktop/maintenance_tests.rs b/apps/maple-agent/app/src/desktop/maintenance_tests.rs index bc6ba24df..7c20ca94c 100644 --- a/apps/maple-agent/app/src/desktop/maintenance_tests.rs +++ b/apps/maple-agent/app/src/desktop/maintenance_tests.rs @@ -775,7 +775,6 @@ fn pending_quit_cannot_finish_an_update_installation(cx: &mut gpui::TestAppConte let backend = Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); let updater = crate::update::Controller::new(false); - let mut restarted = cx.expect_restart(); cx.update(|cx| { cx.set_global(DesktopLifecycle { backend, @@ -794,28 +793,10 @@ fn pending_quit_cannot_finish_an_update_installation(cx: &mut gpui::TestAppConte assert!(may_finish_quit(cx)); updater.set_test_state(crate::update::State::Installing); assert!(!may_finish_quit(cx)); - restart_for_language_change(cx); - assert!(!cx.has_global::()); assert!(!cx.global::().quit_completed.get()); updater.set_test_state(crate::update::State::Error); assert!(may_finish_quit(cx)); - cx.set_global(LanguageRestartPending); - finish_quit(cx); - assert_eq!( - restarted.try_recv().unwrap(), - None, - "restart must await the host drain" - ); - cancel_quit(cx); - assert!(!cx.has_global::()); - cx.set_global(LanguageRestartPending); - cx.global::().quit_completed.set(true); - finish_quit(cx); - assert_eq!(restarted.try_recv().unwrap(), Some(None)); - assert!(!cx.has_global::()); }); - #[cfg(target_os = "macos")] - native_quit::reset_frontend_detach_for_test(); } #[gpui::test] diff --git a/apps/maple-agent/app/src/desktop/native_quit.rs b/apps/maple-agent/app/src/desktop/native_quit.rs index dd3d34f74..6970c3aa6 100644 --- a/apps/maple-agent/app/src/desktop/native_quit.rs +++ b/apps/maple-agent/app/src/desktop/native_quit.rs @@ -111,15 +111,15 @@ pub(super) fn complete(cx: &mut gpui::App) { allow_termination(cx); } -/// Allow GPUI's restart termination only after a verified host stop or an -/// update commit retaining owner.lock. Do not quit before its helper starts. -pub(super) fn allow_restart() { +/// Called only after an update commit retained owner.lock for process exit. +/// Allow GPUI's restart termination, without quitting before its helper starts. +pub(super) fn allow_update_restart() { TERMINATION_ALLOWED.with(|allowed| allowed.set(true)); } /// GPUI's helper has now been requested. Complete an already deferred native /// Quit, while leaving the retry UI alive if no Quit was pending and spawn fails. -pub(super) fn finish_restart() { +pub(super) fn finish_update_restart() { if PENDING.with(Cell::get) { defer_reply(true); } diff --git a/apps/maple-agent/app/src/ui/i18n.rs b/apps/maple-agent/app/src/ui/i18n.rs index fbf7fe3b6..80353c38b 100644 --- a/apps/maple-agent/app/src/ui/i18n.rs +++ b/apps/maple-agent/app/src/ui/i18n.rs @@ -16,45 +16,27 @@ fluent_templates::static_loader! { const ENGLISH: LanguageIdentifier = langid!("en"); -/// Preview opt-in, fixed for this process in every build profile. -pub(super) fn enabled() -> bool { - #[cfg(not(test))] - { - static ENABLED: LazyLock = - LazyLock::new(|| crate::env::env_flag("MAPLE_ENABLE_LOCALIZATION")); - *ENABLED - } - #[cfg(test)] - TEST_ENABLED.with(std::cell::Cell::get) -} - +/// Isolate locale state between UI tests without changing process environment. #[cfg(test)] -thread_local! { - static TEST_ENABLED: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// Scoped UI-thread override: tests never mutate the process environment. -#[cfg(test)] -pub(super) struct TestLocalizationGuard(bool); - +pub(super) struct TestLocalizationGuard; #[cfg(test)] impl TestLocalizationGuard { pub(super) fn enable() -> Self { - Self(TEST_ENABLED.with(|enabled| enabled.replace(true))) + Self } } - #[cfg(test)] impl Drop for TestLocalizationGuard { fn drop(&mut self) { - TEST_ENABLED.with(|enabled| enabled.set(self.0)); + ACTIVE.with_borrow_mut(|state| { + state.locale = ENGLISH; + state.loaded = false; + state.messages.clear(); + state.system_label = None; + }); } } -fn gated_locale(enabled: bool, locale: impl FnOnce() -> LanguageIdentifier) -> LanguageIdentifier { - if enabled { locale() } else { ENGLISH } -} - /// Enabled catalogs. None is the persisted system preference. Add a language /// here only when its catalog has passed the completeness checks. pub(super) const LANGUAGES: &[Option<&str>] = &[None, Some("en"), Some("es")]; @@ -83,7 +65,7 @@ static SYSTEM: LazyLock = LazyLock::new(|| { } #[cfg(not(test))] { - gated_locale(enabled(), || resolve(sys_locale::get_locale().as_deref())) + resolve(sys_locale::get_locale().as_deref()) } }); @@ -136,29 +118,31 @@ fn resolve_enabled(requested: Option<&str>, enabled: &[Option<&str>]) -> Languag } /// Called once after the host hydrates saved settings. Before unlock the UI -/// uses the OS default. Later edits deliberately take effect on next launch: -/// cached panels, precomputed sections and native menus have separate lifecycles. +/// uses the OS default. User edits subsequently apply through `apply_preference`. pub(crate) fn initialize_saved_preference(preference: Option<&str>) { ACTIVE.with_borrow_mut(|state| { if state.loaded { return; } - state.locale = gated_locale(enabled(), || { - preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))) - }); + state.locale = preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))); state.messages.clear(); state.system_label = None; state.loaded = true; }); } -/// Compare effective languages, so System and an equivalent explicit choice -/// do not ask for an unnecessary restart. Call when the preference changes. -pub(super) fn requires_restart(preference: Option<&str>) -> bool { - enabled() - && ACTIVE.with_borrow(|state| { - state.locale != preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))) - }) +/// Apply a user selection and invalidate translated labels on the owning UI thread. +pub(crate) fn apply_preference(preference: Option<&str>) { + ACTIVE.with_borrow_mut(|state| { + state.locale = preference.map_or_else(|| SYSTEM.clone(), |value| resolve(Some(value))); + state.loaded = true; + state.messages.clear(); + state.system_label = None; + }); +} + +pub(crate) fn current_locale() -> LanguageIdentifier { + ACTIVE.with_borrow(|state| state.locale.clone()) } pub(super) fn preference_label(preference: Option<&str>) -> SharedString { diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs index 14b7025bf..2b6d90e87 100644 --- a/apps/maple-agent/app/src/ui/i18n/tests.rs +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -26,7 +26,7 @@ fn locale_resolution_accepts_os_spellings_and_falls_back_safely() { } #[test] -fn spanish_preference_formats_existing_surfaces_and_applies_only_on_restart() { +fn spanish_preference_formats_existing_surfaces_and_initializes_once() { let _localization = TestLocalizationGuard::enable(); ACTIVE.with_borrow_mut(|state| { state.locale = ENGLISH; @@ -34,8 +34,6 @@ fn spanish_preference_formats_existing_surfaces_and_applies_only_on_restart() { state.messages.clear(); }); initialize_saved_preference(Some("es")); - assert!(!requires_restart(Some("es-MX"))); - assert!(requires_restart(Some("en"))); assert_eq!(tr("settings-language").as_ref(), "Idioma"); assert_eq!(preference_label(Some("es")).as_ref(), "Español"); assert_eq!( @@ -62,9 +60,6 @@ fn spanish_preference_formats_existing_surfaces_and_applies_only_on_restart() { state.messages.clear(); state.system_label = None; }); - assert!(!requires_restart(None)); - assert!(!requires_restart(Some("en-US"))); - assert!(requires_restart(Some("es"))); } #[test] @@ -303,7 +298,6 @@ fn converted_modules_do_not_reintroduce_inline_labels() { let mut visitor = UiStrings::default(); visitor.visit_file(&syn::parse_file(include_str!("../update_confirmation.rs")).unwrap()); visitor.visit_file(&syn::parse_file(include_str!("../settings/general.rs")).unwrap()); - visitor.visit_file(&syn::parse_file(include_str!("../settings/language.rs")).unwrap()); assert!( visitor.literals.is_empty(), "inline UI text: {:?}", @@ -335,19 +329,15 @@ fn partial_catalog_falls_back_per_message_including_format_errors() { } #[test] -fn localization_gate_defaults_off_and_ignores_locale_sources() { - assert!(!enabled()); - assert_eq!( - gated_locale(false, || panic!("disabled gate must not read locale")), - ENGLISH - ); - assert_eq!(gated_locale(true, || langid!("fr")), langid!("fr")); - ACTIVE.with_borrow_mut(|state| { - state.locale = langid!("fr"); - state.loaded = false; - state.messages.clear(); - }); - initialize_saved_preference(Some("fr")); - ACTIVE.with_borrow(|state| assert_eq!(state.locale, ENGLISH)); - assert_eq!(tr("update-confirmation-later").as_ref(), "Later"); +fn live_switch_invalidates_plain_and_system_labels() { + let _locale = TestLocalizationGuard::enable(); + for (locale, label, system) in [ + ("en", "Language", "System"), + ("es", "Idioma", "Sistema"), + ("en", "Language", "System"), + ] { + apply_preference(Some(locale)); + assert_eq!(tr("settings-language").as_ref(), label); + assert!(preference_label(None).starts_with(system)); + } } diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 6e646920f..96372bb79 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -36,7 +36,6 @@ mod api_credits; mod api_keys; mod billing; mod general; -mod language; use general::{font_label, theme_label, voice_label}; #[cfg(feature = "proxy")] mod local_api; @@ -56,8 +55,6 @@ pub struct SettingsClosed(pub AppSettings); /// Emitted when the user clicks Sign out in the settings header. pub struct SignOutRequested; -pub(crate) struct LanguageRestartRequested; - /// Emitted after the server confirmed the account is deleted and the /// local session is gone; the app returns to the login form. pub struct AccountDeleted; @@ -173,8 +170,6 @@ pub struct SettingsScreen { remote_screen: Entity, user_id: String, settings: AppSettings, - language_restart_pending: bool, - language_restart_dismissed: bool, update: crate::update::Snapshot, /// `settings.theme` parsed once; render only reads the label. theme: theme::Preference, @@ -291,7 +286,6 @@ impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} -impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} @@ -446,8 +440,6 @@ impl SettingsScreen { remote_screen, user_id, theme: theme::Preference::parse(&settings.theme), - language_restart_pending: super::i18n::requires_restart(settings.locale.as_deref()), - language_restart_dismissed: false, settings, update: crate::update::Snapshot::initial(), section, @@ -960,9 +952,6 @@ impl SettingsScreen { /// open one. Like a native popup button, the menu opens on the saved /// value. pub(super) fn toggle_setting_menu(&mut self, menu: SettingMenu, cx: &mut Context) { - if menu == SettingMenu::Language && !super::i18n::enabled() { - return; - } if self.popup.is_open(&menu) { self.popup.close(cx); } else { @@ -1083,18 +1072,12 @@ impl SettingsScreen { ) { match menu { SettingMenu::Language => { - if !super::i18n::enabled() { - return; - } let Some(locale) = super::i18n::LANGUAGES.get(index) else { return; }; let locale = locale.map(str::to_owned); - if self.settings.locale != locale { - self.language_restart_pending = - super::i18n::requires_restart(locale.as_deref()); - self.language_restart_dismissed = false; - } + super::i18n::apply_preference(locale.as_deref()); + cx.refresh_windows(); self.edit_setting(move |settings| settings.locale = locale, cx); } SettingMenu::Appearance => { diff --git a/apps/maple-agent/app/src/ui/settings/general.rs b/apps/maple-agent/app/src/ui/settings/general.rs index 4742e65fd..d0435a3ca 100644 --- a/apps/maple-agent/app/src/ui/settings/general.rs +++ b/apps/maple-agent/app/src/ui/settings/general.rs @@ -32,21 +32,16 @@ impl SettingsScreen { cx, ), )) - .when(crate::ui::i18n::enabled(), |pane| { - pane.child(self.application_target( - || SettingsTarget::General(GeneralTarget::Language), - self.setting_menu_row( - &crate::ui::i18n::tr("settings-language"), - &crate::ui::i18n::tr("settings-language-description"), - SettingMenu::Language, - window, - cx, - ), - )) - }) - .when(self.language_restart_visible(), |pane| { - pane.child(self.language_restart_notice(cx)) - }) + .child(self.application_target( + || SettingsTarget::General(GeneralTarget::Language), + self.setting_menu_row( + &crate::ui::i18n::tr("settings-language"), + &crate::ui::i18n::tr("settings-language-description"), + SettingMenu::Language, + window, + cx, + ), + )) .child(section_title(&tr("settings-chat-appearance"))) .child(self.application_target( || SettingsTarget::General(GeneralTarget::ChatFont), diff --git a/apps/maple-agent/app/src/ui/settings/language.rs b/apps/maple-agent/app/src/ui/settings/language.rs deleted file mode 100644 index a364cf41a..000000000 --- a/apps/maple-agent/app/src/ui/settings/language.rs +++ /dev/null @@ -1,117 +0,0 @@ -use gpui::{Context, Div, div, prelude::*, px}; - -use super::{GeneralTarget, LanguageRestartRequested, SettingsScreen, SettingsTarget}; -use crate::ui::{i18n::tr, icons::icon, theme, widgets}; - -impl SettingsScreen { - pub(super) fn language_restart_visible(&self) -> bool { - crate::ui::i18n::enabled() - && self.language_restart_pending - && !self.language_restart_dismissed - } - - pub(super) fn restart_for_language(&mut self, cx: &mut Context) { - if self.language_restart_visible() { - cx.emit(LanguageRestartRequested); - } - } - - pub(super) fn defer_language_restart(&mut self, cx: &mut Context) { - self.language_restart_dismissed = true; - self.reconcile_application_vim_target(); - cx.notify(); - } - - pub(super) fn language_restart_notice(&self, cx: &mut Context) -> gpui::Stateful
{ - widgets::card_row() - .id("language-restart-notice") - .debug_selector(|| "language-restart-notice".into()) - .role(gpui::Role::Status) - .aria_label(tr("settings-language-restart-title")) - .border_color(gpui::rgb(theme::accent())) - .flex() - .flex_col() - .gap_3() - .child( - div() - .flex() - .items_start() - .gap_3() - .child(icon("circle-alert", px(18.), theme::accent())) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(tr("settings-language-restart-title")), - ) - .child( - div() - .text_sm() - .text_color(gpui::rgb(theme::text_muted())) - .child(tr("settings-language-restart-detail")), - ), - ), - ) - .child( - div() - .flex() - .flex_wrap() - .justify_end() - .gap_2() - .child( - self.application_target( - || SettingsTarget::General(GeneralTarget::LanguageRestartLater), - widgets::secondary_button("language-restart-later") - .debug_selector(|| "language-restart-later".into()) - .aria_label(tr("settings-language-restart-later")) - .tab_index(3) - .child(tr("settings-language-restart-later")) - .on_click( - cx.listener(|this, _, _, cx| this.defer_language_restart(cx)), - ) - .on_key_down(cx.listener( - |this, event: &gpui::KeyDownEvent, _, cx| { - if activates(event) { - this.defer_language_restart(cx); - cx.stop_propagation(); - } - }, - )), - ), - ) - .child( - self.application_target( - || SettingsTarget::General(GeneralTarget::LanguageRestartNow), - widgets::primary_button("language-restart-now") - .debug_selector(|| "language-restart-now".into()) - .aria_label(tr("settings-language-restart-now")) - .tab_index(4) - .child(tr("settings-language-restart-now")) - .on_click( - cx.listener(|this, _, _, cx| this.restart_for_language(cx)), - ) - .on_key_down(cx.listener( - |this, event: &gpui::KeyDownEvent, _, cx| { - if activates(event) { - this.restart_for_language(cx); - cx.stop_propagation(); - } - }, - )), - ), - ), - ) - } -} - -fn activates(event: &gpui::KeyDownEvent) -> bool { - !event.keystroke.modifiers.modified() - && matches!(event.keystroke.key.as_str(), "enter" | "space" | " ") -} diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index e0ffae833..ae24690b1 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -29,8 +29,6 @@ pub(super) enum GeneralTarget { Web, Appearance, Language, - LanguageRestartLater, - LanguageRestartNow, ChatFont, ChatSize, ToolDetails, @@ -138,8 +136,6 @@ impl SettingsScreen { GeneralTarget::Web, GeneralTarget::Appearance, GeneralTarget::Language, - GeneralTarget::LanguageRestartLater, - GeneralTarget::LanguageRestartNow, GeneralTarget::ChatFont, GeneralTarget::ChatSize, GeneralTarget::ToolDetails, @@ -153,13 +149,6 @@ impl SettingsScreen { GeneralTarget::SpeechSpeed, ] .into_iter() - .filter(|target| *target != GeneralTarget::Language || crate::ui::i18n::enabled()) - .filter(|target| { - !matches!( - target, - GeneralTarget::LanguageRestartLater | GeneralTarget::LanguageRestartNow - ) || self.language_restart_visible() - }) .map(SettingsTarget::General) .collect(), Section::Account => self.account_targets(), @@ -402,8 +391,6 @@ impl SettingsScreen { match target { GeneralTarget::Web => self.toggle_web_default(cx), GeneralTarget::Language => self.toggle_setting_menu(SettingMenu::Language, cx), - GeneralTarget::LanguageRestartLater => self.defer_language_restart(cx), - GeneralTarget::LanguageRestartNow => self.restart_for_language(cx), GeneralTarget::Appearance => self.toggle_setting_menu(SettingMenu::Appearance, cx), GeneralTarget::ChatFont => self.toggle_setting_menu(SettingMenu::ChatFont, cx), GeneralTarget::ChatSize => self.toggle_setting_menu(SettingMenu::ChatSize, cx), @@ -1222,31 +1209,6 @@ mod tests { ); } - #[gpui::test] - fn language_gate_hides_selector_and_preserves_saved_preference(cx: &mut TestAppContext) { - let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); - assert!(!crate::ui::i18n::enabled()); - crate::settings::install_test_settings(crate::settings::AppSettings { - locale: Some("es".into()), - ..Default::default() - }); - let (settings, cx) = dropdown_window(cx, true); - assert!(cx.debug_bounds("setting-value-language").is_none()); - settings.update(cx, |this, cx| { - this.settings.locale = crate::settings::load_settings().locale; - assert!( - !this - .visible_application_targets() - .contains(&SettingsTarget::General(GeneralTarget::Language)) - ); - this.toggle_setting_menu(SettingMenu::Language, cx); - assert_eq!(this.popup.open_key(), None); - this.pick_setting_option(SettingMenu::Language, 0, cx); - assert_eq!(this.settings.locale.as_deref(), Some("es")); - }); - crate::settings::discard_decrypted_cache(); - } - #[gpui::test] fn language_dropdown_supports_pointer_and_keyboard_selection(cx: &mut TestAppContext) { let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); @@ -1261,72 +1223,20 @@ mod tests { cx.simulate_keystrokes("down enter"); settings.update(cx, |this, _| { assert_eq!(this.settings.locale.as_deref(), expected); + assert_eq!( + crate::ui::i18n::tr("settings-language").as_ref(), + if expected == Some("es") { + "Idioma" + } else { + "Language" + } + ); assert_eq!(this.popup.open_key(), None); }); } crate::settings::discard_decrypted_cache(); } - #[gpui::test] - fn language_restart_notice_can_restart_defer_and_clear_on_revert(cx: &mut TestAppContext) { - let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); - let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); - crate::settings::install_test_settings(crate::settings::AppSettings::default()); - let (settings, cx) = dropdown_window(cx, true); - let requests = std::rc::Rc::new(std::cell::Cell::new(0)); - let received = requests.clone(); - let _subscription = cx.update(|_, app| { - app.subscribe( - &settings, - move |_, _: &super::super::LanguageRestartRequested, _| { - received.set(received.get() + 1); - }, - ) - }); - assert!(cx.debug_bounds("language-restart-notice").is_none()); - settings.update(cx, |this, cx| { - this.pick_setting_option(SettingMenu::Language, 1, cx); - assert!( - !this.language_restart_visible(), - "English and System resolve alike" - ); - this.pick_setting_option(SettingMenu::Language, 2, cx); - assert!(this.language_restart_visible()); - this.application_vim.region = SettingsRegion::Pane; - this.application_vim.target = - Some(SettingsTarget::General(GeneralTarget::LanguageRestartNow)); - this.application_focus_pending = true; - }); - cx.run_until_parked(); - assert!(cx.debug_bounds("language-restart-notice").is_some()); - cx.simulate_keystrokes("enter"); - assert_eq!(requests.get(), 1); - let later = cx.debug_bounds("language-restart-later").unwrap(); - cx.simulate_click(later.center(), gpui::Modifiers::default()); - settings.update(cx, |this, cx| { - assert!(!this.language_restart_visible()); - assert_eq!(this.settings.locale.as_deref(), Some("es")); - assert!( - !this - .visible_application_targets() - .contains(&SettingsTarget::General(GeneralTarget::LanguageRestartNow)) - ); - this.pick_setting_option(SettingMenu::Language, 0, cx); - assert!(!this.language_restart_visible()); - this.pick_setting_option(SettingMenu::Language, 2, cx); - assert!( - this.language_restart_visible(), - "a new change restores the notice" - ); - this.pick_setting_option(SettingMenu::Language, 1, cx); - assert!( - !this.language_restart_visible(), - "reverting clears the notice" - ); - }); - crate::settings::discard_decrypted_cache(); - } - #[gpui::test] fn picking_from_the_dropdown_persists_in_reopened_store(cx: &mut TestAppContext) { let _localization = crate::ui::i18n::TestLocalizationGuard::enable(); diff --git a/apps/maple-agent/app/src/ui/update_confirmation.rs b/apps/maple-agent/app/src/ui/update_confirmation.rs index 1d33caf3d..c0caae7ed 100644 --- a/apps/maple-agent/app/src/ui/update_confirmation.rs +++ b/apps/maple-agent/app/src/ui/update_confirmation.rs @@ -11,6 +11,8 @@ use super::{ pub(crate) struct UpdateConfirmation { title: SharedString, + version: Option, + locale: fluent_templates::LanguageIdentifier, answer: Option>, previous_focus: Option, later_focus: FocusHandle, @@ -34,6 +36,8 @@ pub(crate) fn open( ) }, ), + version: version.map(str::to_owned), + locale: super::i18n::current_locale(), answer: Some(sender), previous_focus, later_focus: cx.focus_handle(), @@ -61,6 +65,19 @@ impl Render for UpdateConfirmation { if self.answer.is_none() { return div().into_any_element(); } + let locale = super::i18n::current_locale(); + if self.locale != locale { + self.title = self.version.as_deref().map_or_else( + || tr("update-confirmation-title"), + |version| { + tr_args( + "update-confirmation-title-version", + [("version", version.into())], + ) + }, + ); + self.locale = locale; + } let width = (window.viewport_size().width - px(48.)).clamp(px(0.), px(480.)); let height = (window.viewport_size().height - px(48.)).max(px(0.)); div() @@ -180,6 +197,29 @@ mod tests { } } + #[gpui::test] + fn open_dialog_refreshes_its_title_on_language_change(cx: &mut gpui::TestAppContext) { + let _locale = crate::ui::i18n::TestLocalizationGuard::enable(); + let (host, _answer, cx) = show(cx); + for (locale, prefix) in [("es", "¿Reiniciar Maple"), ("en", "Restart Maple")] { + cx.update(|_, app| { + crate::ui::i18n::apply_preference(Some(locale)); + app.refresh_windows(); + }); + cx.run_until_parked(); + host.update(cx, |host, cx| { + assert!( + host.prompt + .as_ref() + .unwrap() + .read(cx) + .title + .starts_with(prefix) + ); + }); + } + } + fn show( cx: &mut gpui::TestAppContext, ) -> ( diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md index b0c399442..85af77c71 100644 --- a/apps/maple-agent/docs/localization.md +++ b/apps/maple-agent/docs/localization.md @@ -8,48 +8,24 @@ Spanish are enabled in the preview. Most of the app is still English; this is infrastructure for incremental extraction, not a claim of complete localization. -## Preview gate - -Localization is off by default in unpackaged, Dev, and Prod builds. Set -`MAPLE_ENABLE_LOCALIZATION=1` in the application's launch environment to enable -it (`true` and `yes` also work, case-insensitively). The value is read once per -process; quit and reopen to change it. For hosted Dev from this component: - -```sh -MAPLE_RELEASE_PROFILE=dev MAPLE_ENABLE_LOCALIZATION=1 \ - nix develop --no-update-lock-file -c just run -``` - -Without the opt-in, the Language row and its keyboard-navigation target are -hidden, locale selection stays English, and existing saved language preferences -are preserved but ignored. Fluent catalogs/helpers remain compiled in. This is -a local preview gate, not a server-controlled rollout or an authorization check. -Tests default to the gate off and explicitly opt in for selector coverage. - -## Locale and lifetime - -Settings → General → Language offers System, English and Español. The optional -`locale` preference uses the existing host-synced UI settings row. Old or malformed -preferences use System; unknown language tags resolve to English without -preventing startup or overwriting the saved value. - -The OS locale is read once, normalized from platform/POSIX spelling, and matched -against enabled catalogs, first exactly, then without variants, region, and -script. English is the final default. Before the store is unlocked, the UI uses -the OS default. The first loaded host settings snapshot selects the process -locale. Language edits apply after **quitting and reopening Maple**; closing -and reopening Settings is not enough. - -When a saved choice differs from the running language, Settings shows a localized -restart notice with Restart now and Later. Restart now uses the normal quit -coordinator, including saving preferences and stopping the host; cancelling Quit -also cancels the restart. Later dismisses the notice for this Settings visit; -reopening Settings reminds again until the language is applied or reverted. - -Restarting avoids incomplete live updates of cached GPUI panels, precomputed -sidebar sections, and native menus. No per-frame -or language-change window refresh is introduced. Unit/GPUI tests default to -English regardless of the executing machine's locale. +## Locale and live switching + +Localization is enabled by default. Settings → General → Language offers System, +English and Español. Changes are saved through the host and apply immediately; +no environment variable or restart is needed. + +The OS locale supplies the initial default. The first host settings snapshot +loads the saved preference. A user selection clears the plain-label and System +label caches and refreshes GPUI windows. General settings resolves labels during +render; an open update confirmation reformats its versioned title only when its +locale changes. Saved control values and element IDs remain stable. + +Locale state currently belongs to the GPUI thread. Call presentation helpers on +that thread; background workers should return semantic data for the UI to format. +Tests use isolated thread-local state and default to English. Other pages and +native/tray menus remain English until extracted. Future translated cached views +must invalidate formatted text on locale changes; refreshing windows alone does +not rebuild their stored strings. ## Adding UI text From 7c62d0e82b3d613887aaba4d914872f1046a9d3d Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Wed, 4 Nov 2026 04:29:46 +0000 Subject: [PATCH 8/9] agent: match locale helper to update dialog platform scope --- apps/maple-agent/app/src/ui/i18n.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/maple-agent/app/src/ui/i18n.rs b/apps/maple-agent/app/src/ui/i18n.rs index 80353c38b..d0bd853bd 100644 --- a/apps/maple-agent/app/src/ui/i18n.rs +++ b/apps/maple-agent/app/src/ui/i18n.rs @@ -141,6 +141,7 @@ pub(crate) fn apply_preference(preference: Option<&str>) { }); } +#[cfg(any(target_os = "linux", test))] pub(crate) fn current_locale() -> LanguageIdentifier { ACTIVE.with_borrow(|state| state.locale.clone()) } From 8df1b3e0ebe5627c57dfa313cf26552c886760e6 Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Wed, 4 Nov 2026 05:48:42 +0000 Subject: [PATCH 9/9] agent: translate all settings pages into Spanish --- apps/maple-agent/app/locales/en/app.ftl | 786 ++++++++++++++++++ apps/maple-agent/app/locales/es/app.ftl | 786 ++++++++++++++++++ apps/maple-agent/app/src/backend/local.rs | 3 +- apps/maple-agent/app/src/billing.rs | 88 +- apps/maple-agent/app/src/remote/update.rs | 35 - apps/maple-agent/app/src/ui/chat/tests.rs | 2 + apps/maple-agent/app/src/ui/i18n.rs | 164 +++- apps/maple-agent/app/src/ui/i18n/tests.rs | 125 ++- apps/maple-agent/app/src/ui/remote.rs | 559 ++++++++----- .../app/src/ui/remote/presentation.rs | 34 + apps/maple-agent/app/src/ui/remote/tests.rs | 165 ++-- apps/maple-agent/app/src/ui/secret_input.rs | 20 +- apps/maple-agent/app/src/ui/settings.rs | 569 ++++++++----- .../app/src/ui/settings/account.rs | 258 +++--- .../app/src/ui/settings/advanced.rs | 195 +++-- .../app/src/ui/settings/api_credits.rs | 216 +++-- .../app/src/ui/settings/api_keys.rs | 71 +- .../app/src/ui/settings/billing.rs | 88 +- .../app/src/ui/settings/local_api.rs | 88 +- apps/maple-agent/app/src/ui/settings/mcp.rs | 40 +- .../app/src/ui/settings/navigation.rs | 67 +- .../app/src/ui/settings/presentation.rs | 219 +++++ .../app/src/ui/settings/shortcut_labels.rs | 271 ++++++ apps/maple-agent/app/src/ui/store_gate.rs | 319 ++++--- .../src/ui/store_gate/password_form_tests.rs | 10 +- apps/maple-agent/app/src/ui/text_input.rs | 42 +- apps/maple-agent/app/src/ui/widgets.rs | 27 +- apps/maple-agent/docs/localization.md | 38 +- 28 files changed, 4208 insertions(+), 1077 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/remote/presentation.rs create mode 100644 apps/maple-agent/app/src/ui/settings/presentation.rs create mode 100644 apps/maple-agent/app/src/ui/settings/shortcut_labels.rs diff --git a/apps/maple-agent/app/locales/en/app.ftl b/apps/maple-agent/app/locales/en/app.ftl index 5d051d499..1b99cfb11 100644 --- a/apps/maple-agent/app/locales/en/app.ftl +++ b/apps/maple-agent/app/locales/en/app.ftl @@ -96,3 +96,789 @@ settings-voice-nl-female = Dutch-accented — Female settings-voice-nl-male = Dutch-accented — Male settings-voice-pt-female = Portuguese-accented — Female settings-voice-pt-male = Portuguese-accented — Male + +# Settings pages +settings-settings-add-instructions-for-every-task = Add instructions for every task… +settings-settings-search-by-command-context-or-shortcut = Search by command, context, or shortcut… +settings-settings-this-integration-is-not-ready-to-enable-on-this-device = This integration is not ready to enable on this device. +settings-settings-this-integration-does-not-need-setup-on-this-device = This integration does not need setup on this device. +settings-settings-enter-a-new-value = Enter a new value +settings-settings-my-server = My server +settings-settings-what-this-server-helps-the-agent-do = What this server helps the agent do +settings-settings-enter-a-server-name = Enter a server name +settings-settings-enter-the-server-url = Enter the server URL +settings-settings-enter-the-server-command = Enter the server command +settings-settings-clear-each-saved-http-header-before-switching-to-standard-io = Clear each saved HTTP header before switching to Standard IO. +settings-settings-saved-new-tasks-use-these-instructions = Saved. New tasks use these instructions. +settings-settings-shortcut-settings-applied = Shortcut settings applied. +settings-settings-shortcut-recording-cancelled = Shortcut recording cancelled. +settings-settings-a-shortcut-can-contain-at-most-four-strokes = A shortcut can contain at most four strokes. +settings-settings-press-at-least-one-shortcut-key = Press at least one shortcut key. +settings-settings-plan = Plan +settings-settings-plan-usage-unavailable = Plan usage unavailable +settings-settings-about = About +settings-settings-version = Version +settings-settings-update = Update +settings-settings-automatically-download-updates = Automatically download updates +settings-settings-download-verified-updates-in-the-background-installation-always-w = Download verified updates in the background. Installation always waits for Restart to Update. +settings-settings-checking-updating = Checking / updating… +settings-settings-retry = Retry +settings-settings-check-for-updates = Check for Updates +settings-settings-download-manually = Download manually +settings-settings-later = Later +settings-settings-backend = Backend +settings-settings-instruction-directory = Instruction directory +settings-settings-keyboard-shortcuts = Keyboard Shortcuts +settings-settings-reset-all = Reset all +settings-settings-customize-the-shortcuts-maple-already-ships-this-page-does-not-ad = Customize the shortcuts Maple already ships. This page does not add commands or change what an action can do. +settings-settings-clear-the-invalid-saved-shortcut-overrides = Clear the invalid saved shortcut overrides? +settings-settings-reset = Reset +settings-settings-cancel = Cancel +settings-settings-no-shortcuts-match-this-search = No shortcuts match this search. +settings-settings-waiting-for-keys = Waiting for keys… +settings-settings-review-the-conflicts-below-before-saving = Review the conflicts below before saving. +settings-settings-press-up-to-four-keys-enter-saves-backspace-removes-the-latest-st = Press up to four keys. Enter saves, Backspace removes the latest stroke, and Escape cancels. +settings-settings-replace-conflicts = Replace conflicts +settings-settings-keep-both = Keep both +settings-settings-unbound = Unbound +settings-settings-modified = Modified +settings-settings-record = Record +settings-settings-disable = Disable +settings-settings-additional-instructions = Additional instructions +settings-settings-added-to-every-task-after-maple-s-own-system-prompt-use-it-for-pr = Added to every task after Maple's own system prompt. Use it for preferences such as tone, language, or how you like to work. Changes apply to tasks started after you save. +settings-settings-save = Save +settings-settings-clear = Clear +settings-settings-integrations = Integrations +settings-settings-execution-computer-this-computer = Execution computer: This computer +settings-settings-manage-integrations-here-enable-coding-agents-to-make-them-availa = Manage integrations here. Enable coding agents to make them available in the composer, then select them for each task. +settings-settings-detecting-integrations = Detecting integrations… +settings-settings-no-supported-integrations-detected-on-this-device = No supported integrations detected on this device. +settings-settings-custom-mcp-servers = Custom MCP servers +settings-settings-add-server = Add server +settings-settings-connect-additional-tools-over-mcp = Connect additional tools over MCP. +settings-settings-loading-custom-mcp-servers = Loading custom MCP servers… +settings-settings-no-custom-mcp-servers-configured = No custom MCP servers configured. +settings-settings-enabled = Enabled +settings-settings-disabled = Disabled +settings-settings-edit = Edit +settings-settings-remove = Remove +settings-settings-cua-team = Cua team +settings-settings-set-up = Set up +settings-settings-enable = Enable +settings-settings-edit-server = Edit server +settings-settings-new-server = New server +settings-settings-name = Name +settings-settings-description = Description +settings-settings-transport = Transport +settings-settings-standard-io-stdio = Standard IO (STDIO) +settings-settings-streamable-http = Streamable HTTP +settings-settings-url = URL +settings-settings-command = Command +settings-settings-environment-values = Environment values +settings-settings-header-values = Header values +settings-settings-new-environment-values = New environment values +settings-settings-key-value-pairs-separated-by-semicolons-existing-values-use-the-c = KEY=value pairs separated by semicolons. Existing values use the choices above. +settings-settings-new-headers = New headers +settings-settings-name-value-pairs-separated-by-semicolons = Name=value pairs separated by semicolons. +settings-settings-needs-setup = Needs setup +settings-settings-not-detected = Not detected +settings-settings-ready = Ready +settings-settings-available = Available +settings-settings-detected = Detected +settings-settings-setup-required = Setup required +settings-settings-powered-by-cua-an-open-source-project-from-the = Powered by Cua, an open-source project from the +settings-settings-built-into-maple = Built into Maple +settings-settings-global = Global +settings-settings-chat = Chat +settings-settings-transcript = Transcript +settings-settings-menus = Menus +settings-settings-text-fields = Text fields +settings-settings-composer-normal = Composer — Normal +settings-settings-composer-visual = Composer — Visual +settings-settings-composer-insert = Composer — Insert +settings-settings-composer-pending-key = Composer — Pending key +settings-settings-exact-collision = Exact collision +settings-settings-prefix-collision = Prefix collision +settings-settings-same-context = same context +settings-settings-overlapping-scoped-context = overlapping scoped context +settings-settings-possibly-overlapping-context = possibly overlapping context +settings-account-current-password = Current password +settings-account-new-password = New password +settings-account-confirm-new-password = Confirm new password +settings-account-type-delete-to-continue = Type DELETE to continue +settings-account-confirmation-code = Confirmation code +settings-account-enter-the-confirmation-code-from-the-email = Enter the confirmation code from the email +settings-account-password-changed = Password changed. +settings-account-verification-email-sent-check-your-inbox = Verification email sent. Check your inbox. +settings-account-account = Account +settings-account-loading-account = Loading account… +settings-account-email = Email +settings-account-no-email-on-this-account = No email on this account +settings-account-sign-in-method = Sign-in method +settings-account-member-since = Member since +settings-account-sign-out = Sign out +settings-account-ends-this-session-on-this-device-your-tasks-stay-in-your-account = Ends this session on this device. Your tasks stay in your account. +settings-account-danger-zone = Danger zone +settings-account-delete-account = Delete account +settings-account-permanently-deletes-your-account-tasks-and-encrypted-data-this-ca = Permanently deletes your account, tasks, and encrypted data. This cannot be undone. +settings-account-delete-account-more = Delete account… +settings-account-this-permanently-deletes-your-account-tasks-and-encrypted-data-ty = This permanently deletes your account, tasks, and encrypted data. Type DELETE, then we email you a confirmation code. +settings-account-cancel = Cancel +settings-account-sending = Sending… +settings-account-send-confirmation-code = Send confirmation code +settings-account-confirm-deletion = Confirm deletion +settings-account-we-emailed-you-a-confirmation-code-enter-it-to-delete-the-account = We emailed you a confirmation code. Enter it to delete the account. The code expires after 24 hours. +settings-account-deleting = Deleting… +settings-account-delete-my-account = Delete my account +settings-account-change-password = Change password +settings-account-use-at-least-8-characters-other-devices-stay-signed-in = Use at least 8 characters. Other devices stay signed in. +settings-account-changing = Changing… +settings-account-verified = Verified +settings-account-unverified = Unverified +settings-account-resend-email = Resend email +settings-account-anonymous-account = Anonymous account +settings-account-this-account-has-no-email-keep-the-account-id-and-password-somewh = This account has no email. Keep the account id and password somewhere safe: they are the only way to sign in again. +settings-account-enter-your-current-password = Enter your current password +settings-account-the-new-passwords-do-not-match = The new passwords do not match +settings-account-the-new-password-must-differ-from-the-current-one = The new password must differ from the current one +settings-advanced-type-erase = Type ERASE +settings-advanced-one-absolute-folder-path-per-line = One absolute folder path per line +settings-advanced-acp-preferences-saved-they-apply-to-new-connections = ACP preferences saved. They apply to new connections. +settings-advanced-default-values-are-ready-save-to-apply-them = Default values are ready. Save to apply them. +settings-advanced-agent-client-protocol-acp = Agent Client Protocol (ACP) +settings-advanced-choose-which-folders-local-acp-clients-can-open-and-how-many-conn = Choose which folders local ACP clients can open and how many connections can run at once. These preferences apply to this account on this computer. +settings-advanced-saving-acp-preferences = Saving ACP preferences… +settings-advanced-allowed-project-folders = Allowed project folders +settings-advanced-enter-one-absolute-folder-path-per-line-leave-the-list-empty-to-a = Enter one absolute folder path per line. Leave the list empty to allow any folder. +settings-advanced-maximum-connections = Maximum connections +settings-advanced-save-acp-preferences = Save ACP preferences +settings-advanced-use-defaults = Use defaults +settings-advanced-retry = Retry +settings-advanced-loading-acp-preferences = Loading ACP preferences… +settings-advanced-type-erase-exactly-to-continue = Type ERASE exactly to continue. +settings-advanced-this-notice-could-not-be-dismissed-try-again = This notice could not be dismissed. Try again. +settings-advanced-changes-need-saving = Changes need saving +settings-advanced-retry-saving = Retry saving +settings-advanced-local-storage-needs-attention = Local storage needs attention +settings-advanced-dismissing = Dismissing… +settings-advanced-dismiss = Dismiss +settings-advanced-local-data = Local data +settings-advanced-encryption-on-this-computer = Encryption on this computer +settings-advanced-encryption-is-on = Encryption is on +settings-advanced-encryption-is-off = Encryption is off +settings-advanced-maple-s-copy-of-your-conversation-text-settings-and-sign-in-on-th = Maple's copy of your conversation text, settings and sign-in on this computer is encrypted with your unlock password. Maple asks for it each time it starts. Attached files and images remain unencrypted. +settings-advanced-your-conversation-text-settings-and-sign-in-are-stored-on-this-co = Your conversation text, settings and sign-in are stored on this computer, readable only by your user account. Set an unlock password to encrypt them. Attached files and images remain unencrypted. +settings-advanced-erase-this-version-s-local-data = Erase this version's local data +settings-advanced-cancel = Cancel +settings-advanced-change-password = Change password… +settings-advanced-set-unlock-password = Set unlock password… +settings-advanced-remove-password = Remove password… +settings-advanced-erase-local-data = Erase local data… +settings-advanced-while-maple-is-locked-your-other-computers-can-t-reach-this-one-a = While Maple is locked, your other computers can't reach this one. After a restart, open Maple and unlock it to turn remote access back on. +settings-advanced-claude-code-keeps-its-own-copy-of-the-work-maple-hands-it-in-its = Claude Code keeps its own copy of the work Maple hands it, in its own folder, and Maple's password doesn't encrypt it. +settings-advanced-codex-keeps-its-own-copy-of-the-work-maple-hands-it-in-its-own-fo = Codex keeps its own copy of the work Maple hands it, in its own folder, and Maple's password doesn't encrypt it. +settings-advanced-encryption-enabled-on-this-computer = Encryption enabled on this computer. +settings-advanced-your-unlock-password-was-changed = Your unlock password was changed. +settings-advanced-your-unlock-password-was-removed = Your unlock password was removed. +settings-advanced-allowed-acp-folders-must-use-absolute-paths = Allowed ACP folders must use absolute paths. +settings-api-credits-loading-balance = Loading balance… +settings-api-credits-balance-unavailable = Balance unavailable +settings-api-credits-enter-an-amount = Enter an amount +settings-api-credits-opened-checkout-in-your-browser-refresh-after-paying-to-see-the-n = Opened checkout in your browser. Refresh after paying to see the new balance. +settings-api-credits-opened-bitcoin-checkout-in-your-browser-credits-arrive-once-the-p = Opened Bitcoin checkout in your browser. Credits arrive once the payment confirms; refresh to see them. +settings-api-credits-api-credits = API credits +settings-api-credits-refresh = Refresh +settings-api-credits-extra-credit-balance = Extra credit balance +settings-api-credits-1-per-1-000-credits-extends-your-plan-when-its-included-usage-run = $1 per 1,000 credits. Extends your plan when its included usage runs out. +settings-api-credits-custom-amount = Custom amount +settings-api-credits-buy-credits = Buy credits +settings-api-credits-pay-with-card = Pay with card +settings-api-credits-pay-with-bitcoin = Pay with Bitcoin +settings-api-credits-opening-checkout = Opening checkout… +settings-api-credits-anonymous-accounts-pay-with-bitcoin = Anonymous accounts pay with Bitcoin +settings-api-credits-checkout-opens-in-your-browser-anonymous-accounts-pay-with-bitcoi = Checkout opens in your browser. Anonymous accounts pay with Bitcoin. +settings-api-credits-checkout-opens-in-your-browser = Checkout opens in your browser. +settings-api-keys-key-name-for-example-cursor = Key name, for example “Cursor” +settings-api-keys-api-keys = API keys +settings-api-keys-unlock-api-access = Unlock API access +settings-api-keys-api-keys-and-credits-come-with-the-pro-max-and-team-plans = API keys and credits come with the Pro, Max, and Team plans. +settings-api-keys-see-plans = See plans +settings-api-keys-creating = Creating… +settings-api-keys-create-key = Create key +settings-api-keys-no-api-keys-yet = No API keys yet. +settings-api-keys-loading-keys = Loading keys… +settings-api-keys-api-credits = API credits +settings-api-keys-buy-more = Buy more +settings-api-keys-copy-the-key-now-for-your-privacy-it-is-not-stored-anywhere-you-c = Copy the key now. For your privacy it is not stored anywhere you can read it back. +settings-api-keys-done = Done +settings-api-keys-deleting = Deleting… +settings-api-keys-confirm-delete = Confirm delete +settings-api-keys-delete = Delete +settings-api-keys-cancel = Cancel +settings-billing-opened-the-subscription-portal-in-your-browser-refresh-after-maki = Opened the subscription portal in your browser. Refresh after making changes. +settings-billing-opened-the-pricing-page-in-your-browser = Opened the pricing page in your browser. +settings-billing-opened-checkout-in-your-browser-refresh-after-paying-to-see-the-n = Opened checkout in your browser. Refresh after paying to see the new plan. +settings-billing-plan = Plan +settings-billing-refresh = Refresh +settings-billing-loading-plan = Loading plan… +settings-billing-free = Free +settings-billing-manage-subscription = Manage subscription +settings-billing-plans = Plans +settings-billing-no-plans-are-on-sale-right-now = No plans are on sale right now. +settings-billing-loading-plans = Loading plans… +settings-billing-payments-open-in-your-browser-bitcoin-payments-and-team-seats-are = Payments open in your browser. Bitcoin payments and team seats are set up on the web pricing page. +settings-billing-current-plan = Current plan +settings-billing-choose = Choose +settings-billing-change-plan = Change plan +settings-billing-see-on-the-web = See on the web +settings-billing-active = Active +settings-billing-trial = Trial +settings-billing-payment-due = Payment due +settings-billing-canceled = Canceled +settings-billing-paid-with-bitcoin-to-change-plans-contact-support-trymaple-ai = Paid with Bitcoin. To change plans, contact support@trymaple.ai. +settings-billing-activated-with-a-subscription-pass-it-ends-on-the-date-above-with = Activated with a subscription pass. It ends on the date above without renewing. +settings-local-api-port-1-65535 = Port (1–65535) +settings-local-api-could-not-read-the-local-api-status-reconnect-to-this-computer-an = Could not read the local API status. Reconnect to this computer and try again. +settings-local-api-could-not-update-the-local-api-refresh-its-status-and-try-again = Could not update the local API. Refresh its status and try again. +settings-local-api-checking-status = Checking status… +settings-local-api-status-unavailable = Status unavailable +settings-local-api-local-api = Local API +settings-local-api-connect-ai-applications-on-this-computer = Connect AI applications on this computer +settings-local-api-expose-maple-s-inference-apis-at-a-local-address-requests-use-map = Expose Maple's inference APIs at a local address. Requests use Maple's secure backend and your API credits. +settings-local-api-learn-more-about-the-local-api = Learn more about the local API +settings-local-api-learn-more = Learn more +settings-local-api-enable-local-api = Enable local API +settings-local-api-applies-to-this-computer-including-while-you-work-with-a-remote-a = Applies to this computer, including while you work with a remote agent. +settings-local-api-enable-local-api-on-this-computer = Enable local API on this computer +settings-local-api-unavailable-while-local-api-settings-are-loading-or-being-updated = Unavailable while local API settings are loading or being updated. +settings-local-api-updating-local-api-settings = Updating local API settings… +settings-local-api-the-local-api-is-enabled-but-paused-select-resume-to-accept-reque = The local API is enabled but paused. Select Resume to accept requests on this computer. +settings-local-api-port = Port +settings-local-api-the-local-address-always-uses-127-0-0-1-stop-the-api-to-change-it = The local address always uses 127.0.0.1. Stop the API to change its port. +settings-local-api-save-port = Save port +settings-local-api-save-and-resume = Save and resume +settings-local-api-save-and-retry = Save and retry +settings-local-api-openai-compatible-base-url = OpenAI-compatible base URL +settings-local-api-paste-this-address-into-your-ai-application-s-provider-settings = Paste this address into your AI application's provider settings. +settings-local-api-copy-local-api-base-url = Copy local API base URL +settings-local-api-when-enabled-the-api-resumes-when-this-computer-s-maple-host-star = When enabled, the API resumes when this computer's Maple host starts and its store is unlocked. Signing out, locking, or quitting the host stops the listener. +settings-local-api-create-a-dedicated-maple-api-key-copy-it-once-and-enter-it-in-you = Create a dedicated Maple API key, copy it once, and enter it in your AI application's API key field. Every request must supply that key as Authorization: Bearer . +settings-local-api-manage-api-keys = Manage API keys +settings-local-api-available-endpoints = Available endpoints +settings-local-api-browser-origin-requests-are-blocked-use-a-desktop-application-cli = Browser-origin requests are blocked. Use a desktop application, CLI, or server client. +settings-local-api-refresh-status = Refresh status +settings-local-api-enter-a-port-between-1-and-65535 = Enter a port between 1 and 65535. +settings-local-api-paused = Paused +settings-local-api-stopped = Stopped +settings-local-api-starting = Starting… +settings-local-api-running-on-this-computer = Running on this computer +settings-local-api-stopping = Stopping… +settings-local-api-could-not-start = Could not start +settings-local-api-resume = Resume +settings-local-api-retry-start = Retry start +settings-mcp-saved-value = Saved value +settings-mcp-no-saved-value = No saved value +settings-mcp-new-value = New value +settings-mcp-will-clear = Will clear +settings-mcp-keep = Keep +settings-mcp-replace = Replace +settings-mcp-clear = Clear +settings-mcp-enter-new-values-as-name-value-separated-by-semicolons = Enter new values as NAME=value, separated by semicolons. +settings-mcp-each-new-entry-needs-a-name-and-value = Each new entry needs a name and value. +settings-mcp-the-server-target-or-name-changed-replace-or-clear-each-saved-val = The server target or name changed. Replace or clear each saved value before saving. +settings-remote-could-not-read-remote-update-status = Could not read remote update status +settings-remote-not-confirmed-don-t-resend-check-again-first = Not confirmed. Don’t resend; check again first. +settings-remote-update-maple-on-both-computers-to-reconnect = Update Maple on both computers to reconnect. +settings-remote-this-version-can-t-be-updated-remotely = This version can’t be updated remotely. +settings-remote-an-update-request-is-already-in-progress-there = An update request is already in progress there. +settings-remote-the-update-isn-t-ready-there-yet = The update isn’t ready there yet. +settings-remote-no-update-is-offered-there = No update is offered there. +settings-remote-it-already-runs-this-version = It already runs this version. +settings-remote-checking-for-updates = Checking for updates… +settings-remote-checking-status = Checking status… +settings-remote-version-unknown = Version unknown +settings-remote-different-remote-protocol = Different remote protocol +settings-remote-not-reachable = Not reachable +settings-remote-update-needed = Update needed +settings-remote-checking-for-updates-more = checking for updates… +settings-remote-checking-status-more = checking status… +settings-remote-this-version-checks-for-updates-only-on-that-computer = This version checks for updates only on that computer. +settings-remote-up-to-date = Up to date +settings-remote-restart-needed = Restart needed +settings-remote-unconfirmed = Unconfirmed +settings-remote-waiting-for-it-to-acknowledge-nothing-has-started = Waiting for it to acknowledge. Nothing has started. +settings-remote-it-restarts-when-done-check-again-once-it-s-back = It restarts when done. Check again once it’s back. +settings-remote-eight-character-pairing-code = Eight-character pairing code +settings-remote-this-computer-s-update-or-pairing-changed-refresh-its-status-befo = This computer’s update or pairing changed. Refresh its status before trying again. +settings-remote-sending-the-update-request = Sending the update request… +settings-remote-computer-paired-it-can-now-use-this-computer-s-projects-and-tools = Computer paired. It can now use this computer’s projects and tools. +settings-remote-enter-all-eight-characters-shown-on-the-selected-computer = Enter all eight characters shown on the selected computer. +settings-remote-computer-name = Computer name +settings-remote-use-a-computer-name-between-1-and-64-characters = Use a computer name between 1 and 64 characters. +settings-remote-sign-in-before-naming-a-remote-computer = Sign in before naming a remote computer +settings-remote-could-not-save-the-computer-name = Could not save the computer name +settings-remote-finding-computers = Finding computers… +settings-remote-updating-remote-settings = Updating remote settings… +settings-remote-preparing-pairing-code = Preparing pairing code… +settings-remote-pairing-computer = Pairing computer… +settings-remote-unpairing-computer = Unpairing computer… +settings-remote-could-not-read-the-pairing-window = Could not read the pairing window +settings-remote-computer-paired-its-projects-are-now-available-in-the-sidebar = Computer paired. Its projects are now available in the sidebar. +settings-remote-computer-unpaired = Computer unpaired. +settings-remote-computer-name-saved-other-computers-pick-it-up-when-they-refresh = Computer name saved. Other computers pick it up when they refresh devices or reconnect. +settings-remote-remote-computers = Remote computers +settings-remote-work-on-another-computer-or-allow-a-paired-computer-to-work-on-th = Work on another computer, or allow a paired computer to work on this one. +settings-remote-loading = Loading… +settings-remote-starting-waiting-for-a-connection = Starting. Waiting for a connection… +settings-remote-on-only-paired-computers-can-connect = On. Only paired computers can connect. +settings-remote-off-other-computers-can-t-connect-here = Off. Other computers can’t connect here. +settings-remote-remote-access = Remote access +settings-remote-turn-off-remote-access = Turn off remote access +settings-remote-enable-remote-access = Enable remote access +settings-remote-this-computer-s-name-on-your-other-computers = This computer’s name on your other computers. +settings-remote-rename-this-computer = Rename this computer +settings-remote-rename = Rename +settings-remote-save-computer-name = Save computer name +settings-remote-save = Save +settings-remote-cancel-rename = Cancel rename +settings-remote-cancel = Cancel +settings-remote-shown-to-your-other-computers-this-doesn-t-change-the-system-host = Shown to your other computers. This doesn’t change the system hostname. +settings-remote-pair-a-new-computer = Pair a new computer +settings-remote-show-a-one-time-code-here-then-enter-it-on-the-other-computer = Show a one-time code here, then enter it on the other computer. +settings-remote-show-pairing-code = Show pairing code +settings-remote-on-the-other-computer-choose-find-computers-select-this-one-and-e = On the other computer, choose Find computers, select this one, and enter this code. +settings-remote-cancel-pairing = Cancel pairing +settings-remote-connect-to-another-computer = Connect to another computer +settings-remote-find-computers-signed-in-to-your-account-turn-on-remote-access-th = Find computers signed in to your account. Turn on remote access there and show a pairing code. +settings-remote-refresh-devices = Refresh devices +settings-remote-find-computers = Find computers +settings-remote-search-again = Search again +settings-remote-no-new-computers-found-check-it-s-online-and-signed-in-then-searc = No new computers found. Check it’s online and signed in, then search again. +settings-remote-ready-to-pair = Ready to pair +settings-remote-show-a-pairing-code-on-this-computer-then-refresh = Show a pairing code on this computer, then refresh. +settings-remote-enter-code = Enter code +settings-remote-cancel-code-entry = Cancel code entry +settings-remote-pairing-code-from-this-computer = Pairing code from this computer +settings-remote-pair-computer = Pair computer +settings-remote-type-or-paste-its-eight-character-code-the-dash-is-optional = Type or paste its eight-character code. The dash is optional. +settings-remote-paired-computers = Paired computers +settings-remote-can-use-this-computer-s-projects-and-tools = Can use this computer’s projects and tools +settings-remote-pairing-needs-confirmation = Pairing needs confirmation +settings-remote-save-name = Save name +settings-remote-update = Update +settings-remote-check-this-computer-for-updates-again = Check this computer for updates again +settings-remote-check-again = Check again +settings-remote-confirm-previous-pairing = Confirm previous pairing +settings-remote-confirm-pairing = Confirm pairing +settings-remote-check-for-updates = Check for updates +settings-remote-rename-more = Rename… +settings-remote-unpair = Unpair +settings-remote-more = More +settings-remote-experimental-remote-update = Experimental remote update +settings-remote-experimental = Experimental +settings-remote-updating = Updating +settings-remote-updater-needs-attention = Updater needs attention + +# Parameterized Settings messages +settings-server-exists = A server named { $name } already exists +settings-shortcut-change-failed = Shortcut change failed: { $message } +settings-shortcut-invalid = Cannot use that shortcut: { $message } +settings-shortcut-counts = { $arg1 } bindings · { $modified-count } modified +settings-shortcut-overrides-invalid = Saved shortcut overrides were not applied; Maple kept the complete default map. { $error } +settings-shortcut-reset-confirm = Reset all { $modified-count } shortcut changes? +settings-shortcut-recording = Recording: { $label } +settings-shortcut-collision = { $arg1 } with { $arg2 } ({ $arg3 }) +settings-shortcut-conflicts = Shortcut conflicts: { $arg1 } +settings-shortcut-default = Default: { $arg1 } +settings-execution-computer = Execution computer: { $arg1 }. Native setup and permissions must be completed there. +settings-integration-version = Version { $version } +settings-usage-percent = { $arg1 }% used +settings-usage-resets = · Resets { $arg1 } +settings-delete-confirmation = Type { $delete-acknowledgement } to confirm +settings-acp-default = Default ({ $arg1 }) +settings-acp-limit-help = Choose 1–{ $arg1 } simultaneous connections. Leave blank for the default ({ $arg2 }). +settings-acp-limit-invalid = Choose a connection limit from 1 to { $arg1 }. +settings-credits-amount-placeholder = Amount in dollars, ${ $min-credit-purchase-dollars } to ${ $arg1 } +settings-credits-balance = { $arg1 } credits +settings-credits-cost = { $arg1 } credits for ${ $arg2 } +settings-api-key-help = Keys work with any OpenAI-compatible client at { $arg1 }/v1 and with `maple-agent proxy --api-key`. A key is shown once, when created. +settings-api-key-ready = “{ $arg1 }” is ready +settings-api-key-created = Created { $arg1 } +settings-local-api-requests = { $count -> + [one] { $count } active request + *[other] { $count } active requests + } +settings-mcp-secret-required = Enter a new value for { $arg1 } or choose Clear. +settings-computer-name = Computer { $arg1 } +settings-remote-client-version = This computer runs v{ $client }. +settings-remote-checking = { $running } · { $arg1 } +settings-remote-last-seen = { $running } when last seen +settings-remote-update-available = { $running } · { $target } available +settings-remote-downloading-version = { $running } · downloading { $target }… +settings-remote-downloading = { $running } · downloading… +settings-remote-checking-updates = { $running } · checking for updates… +settings-remote-updating-status = { $running } · updating… +settings-remote-restart-needed-status = { $running } · restart needed there +settings-remote-sending-update = { $running } · sending update… +settings-remote-updating-version = { $running } · updating to { $target }… +settings-remote-updated = Updated to { $target }. +settings-remote-update-incomplete = The update to { $target } didn’t finish. +settings-remote-update-confirm-title = Update { $name } to { $version }? +settings-remote-update-action = Update { $name } +settings-remote-update-confirm-detail = { $name } stops its running tasks, installs { $version } and restarts. Remote access may not return; if it doesn’t, open Maple on { $name } directly. This computer stays on v{ $client-version } and won’t restart. +settings-pairing-expiry = One attempt · expires in { $arg1 }:{ $arg2 } +settings-computer-options = { $name } options + +# Local encryption settings +settings-password-maple-can-t-reset-your-unlock-password-erasing-removes-the-data-s = Maple can't reset your unlock password. Erasing removes the data saved in this version's store: tasks and history, attachments, settings, sign-in, MCP secrets and this store's remote pairings. On your other computers, remove this computer from Remote computers. Backups, snapshots and previous app data remain untouched. +settings-password-current-unlock-password = Current unlock password +settings-password-enter-a-new-unlock-password = Enter a new unlock password. +settings-password-re-enter-your-new-unlock-password = Re-enter your new unlock password +settings-password-wait-for-the-current-change-to-finish = Wait for the current change to finish. +settings-password-enter-your-current-unlock-password-to-continue = Enter your current unlock password to continue. +settings-password-keep-this-passphrase-maple-could-not-confirm-the-previous-attempt = Keep this passphrase. Maple could not confirm the previous attempt. Reopen Maple to check the store before trying again. +settings-password-wait-for-your-passphrase-to-finish-generating = Wait for your passphrase to finish generating. +settings-password-generate-a-passphrase-or-enter-your-own-password = Generate a passphrase or enter your own password. +settings-password-use-between-15-and-1-024-characters-for-your-new-password = Use between 15 and 1,024 characters for your new password. +settings-password-choose-a-less-predictable-password-try-a-longer-unique-phrase = Choose a less predictable password. Try a longer, unique phrase. +settings-password-re-enter-your-new-unlock-password-in-confirm-password = Re-enter your new unlock password in Confirm password. +settings-password-the-passwords-don-t-match-re-enter-your-new-password-in-confirm-p = The passwords don't match. Re-enter your new password in Confirm password. +settings-password-cancelling-before-the-store-is-replaced = Cancelling before the store is replaced… +settings-password-the-store-is-committing-wait-for-it-to-finish = The store is committing. Wait for it to finish. +settings-password-enable-encryption = Enable encryption +settings-password-protect-saved-conversation-text-settings-and-sign-in-attached-fil = Protect saved conversation text, settings and sign-in. Attached files and images remain unencrypted on this computer. +settings-password-change-unlock-password = Change unlock password +settings-password-enter-your-current-unlock-password-then-choose-a-new-one = Enter your current unlock password, then choose a new one. +settings-password-save-password = Save password +settings-password-remove-encryption = Remove encryption +settings-password-your-local-maple-data-will-no-longer-be-encrypted-enter-your-curr = Your local Maple data will no longer be encrypted. Enter your current password to continue. +settings-password-cancel = Cancel +settings-password-new-unlock-password = New unlock password +settings-password-generate-passphrase = Generate passphrase +settings-password-generating-a-random-passphrase = Generating a random passphrase… +settings-password-couldn-t-generate-a-passphrase-try-again-or-enter-your-own-passwo = Couldn't generate a passphrase. Try again or enter your own password. +settings-password-six-random-words-save-them-in-your-password-manager-before-contin = Six random words. Save them in your password manager before continuing. +settings-password-copy-passphrase = Copy passphrase +settings-password-generate-another = Generate another +settings-password-use-my-own-password = Use my own password +settings-password-use-at-least-15-characters-a-long-unique-phrase-works-well = Use at least 15 characters. A long, unique phrase works well. +settings-password-use-between-15-and-1-024-characters = Use between 15 and 1,024 characters. +settings-password-confirm-password = Confirm password +settings-password-passwords-match = Passwords match +settings-password-passwords-don-t-match = Passwords don't match +settings-password-also-create-a-new-encryption-key = Also create a new encryption key +settings-password-use-a-new-encryption-key-if-your-old-password-and-a-copy-of-this = Use a new encryption key if your old password and a copy of this store may have been exposed. This protects this store and future writes; it cannot revoke existing copies. +settings-password-this-is-separate-from-your-maple-account-password-maple-can-t-res = This is separate from your Maple account password. Maple can't reset it. +settings-password-if-you-forget-it-you-ll-need-to-erase-this-version-s-local-data-a = If you forget it, you'll need to erase this version's local data and pair your other computers again. +settings-password-existing-backups-snapshots-and-previous-app-data-aren-t-re-encryp = Existing backups, snapshots and previous app data aren't re-encrypted. +settings-password-the-new-unlock-password-took-effect-but-maple-couldn-t-confirm-it = The new unlock password took effect, but Maple couldn't confirm its key file was synchronized. Keep both passwords until synchronization finishes. +settings-password-the-new-unlock-password-took-effect-maple-still-needs-to-synchron = The new unlock password took effect. Maple still needs to synchronize its backup key file. +settings-password-maple-opened-with-your-previous-unlock-password-keep-your-current = Maple opened with your previous unlock password. Keep your current and previous unlock passwords until the key files are synchronized. +settings-password-maple-is-open-key-file-synchronization-or-its-security-upgrade-st = Maple is open. Key-file synchronization or its security upgrade still needs attention. Your existing unlock password remains valid. +settings-password-encryption-changed-maple-still-needs-to-save-the-local-mode-chang = Encryption changed. Maple still needs to save the local mode-change notice. +settings-password-too-easy-to-guess = Too easy to guess +settings-password-could-be-stronger = Could be stronger +settings-password-strong = Strong +settings-password-enter-a-password = Enter a password +settings-password-removing-encryption = Removing encryption +settings-password-updating-your-unlock-password = Updating your unlock password +settings-password-encrypting-your-local-data = Encrypting your local data +settings-password-updating-your-local-store = Updating your local store +settings-password-unlock-password = Unlock password +settings-password-strength = Password strength: { $strength } + +# Built-in keyboard shortcut names and categories +settings-shortcut-quit-maple = Quit Maple +settings-shortcut-close-the-active-chat-surface = Close the active chat surface +settings-shortcut-new-task = New task +settings-shortcut-focus-task-search = Focus task search +settings-shortcut-toggle-sidebar = Toggle sidebar +settings-shortcut-toggle-archived-tasks = Toggle archived tasks +settings-shortcut-open-settings = Open settings +settings-shortcut-choose-project = Choose project +settings-shortcut-previous-task = Previous task +settings-shortcut-next-task = Next task +settings-shortcut-hide-maple = Hide Maple +settings-shortcut-hide-others = Hide Others +settings-shortcut-copy-transcript-selection = Copy transcript selection +settings-shortcut-select-all-transcript-text = Select all transcript text +settings-shortcut-previous-menu-item = Previous menu item +settings-shortcut-next-menu-item = Next menu item +settings-shortcut-first-menu-item = First menu item +settings-shortcut-last-menu-item = Last menu item +settings-shortcut-choose-menu-item = Choose menu item +settings-shortcut-choose-menu-item-with-space = Choose menu item with Space +settings-shortcut-close-menu = Close menu +settings-shortcut-open-submenu = Open submenu +settings-shortcut-return-to-parent-menu = Return to parent menu +settings-shortcut-delete-backward = Delete backward +settings-shortcut-delete-forward = Delete forward +settings-shortcut-move-left = Move left +settings-shortcut-move-right = Move right +settings-shortcut-select-left = Select left +settings-shortcut-select-right = Select right +settings-shortcut-select-all-text = Select all text +settings-shortcut-paste = Paste +settings-shortcut-copy = Copy +settings-shortcut-cut = Cut +settings-shortcut-move-to-line-start = Move to line start +settings-shortcut-move-to-line-end = Move to line end +settings-shortcut-move-up = Move up +settings-shortcut-move-down = Move down +settings-shortcut-select-up = Select up +settings-shortcut-select-down = Select down +settings-shortcut-undo = Undo +settings-shortcut-redo = Redo +settings-shortcut-show-character-palette = Show character palette +settings-shortcut-vim-insert-at-first-non-whitespace = Vim insert at first non-whitespace +settings-shortcut-vim-insert-at-line-end = Vim insert at line end +settings-shortcut-vim-open-line-below = Vim open line below +settings-shortcut-vim-open-line-above = Vim open line above +settings-shortcut-vim-undo = Vim undo +settings-shortcut-vim-redo = Vim redo +settings-shortcut-vim-repeat = Vim repeat +settings-shortcut-vim-cancel = Vim cancel +settings-shortcut-vim-cancel-selection = Vim cancel selection +settings-shortcut-vim-leave-insert-mode = Vim leave Insert mode +settings-shortcut-move-word-forward = Move word forward +settings-shortcut-move-word-backward = Move word backward +settings-shortcut-move-to-word-end = Move to word end +settings-shortcut-move-to-previous-word-end = Move to previous WORD end +settings-shortcut-move-to-first-non-blank = Move to first non-blank +settings-shortcut-move-to-matching-bracket = Move to matching bracket +settings-shortcut-move-to-previous-paragraph = Move to previous paragraph +settings-shortcut-move-to-next-paragraph = Move to next paragraph +settings-shortcut-repeat-last-find = Repeat last find +settings-shortcut-repeat-last-find-backward = Repeat last find backward +settings-shortcut-find-character = Find character +settings-shortcut-find-character-backward = Find character backward +settings-shortcut-move-to-before-character = Move to before character +settings-shortcut-move-to-after-character-backward = Move to after character backward +settings-shortcut-replace-with-character = Replace with character +settings-shortcut-toggle-case = Toggle case +settings-shortcut-join-lines = Join lines +settings-shortcut-toggle-linewise-visual-mode = Toggle linewise Visual mode +settings-shortcut-substitute-characters = Substitute characters +settings-shortcut-change-selection = Change selection +settings-shortcut-substitute-lines = Substitute lines +settings-shortcut-change-selected-lines = Change selected lines +settings-shortcut-change-to-line-end = Change to line end +settings-shortcut-change-selected-lines-c = Change selected lines (C) +settings-shortcut-delete-to-line-end = Delete to line end +settings-shortcut-delete-selected-lines = Delete selected lines +settings-shortcut-yank-to-line-end = Yank to line end +settings-shortcut-yank-selected-lines = Yank selected lines +settings-shortcut-delete-before-cursor = Delete before cursor +settings-shortcut-delete-selected-lines-x = Delete selected lines (X) +settings-shortcut-toggle-case-operator = Toggle case operator +settings-shortcut-lowercase-operator = Lowercase operator +settings-shortcut-uppercase-operator = Uppercase operator +settings-shortcut-uppercase-line-guu = Uppercase line (gUU) +settings-shortcut-go-to-other-end-of-selection = Go to other end of selection +settings-shortcut-lowercase-selection = Lowercase selection +settings-shortcut-uppercase-selection = Uppercase selection +settings-shortcut-delete-previous-word = Delete previous word +settings-shortcut-delete-to-line-start = Delete to line start +settings-shortcut-delete-previous-character = Delete previous character +settings-shortcut-vim-cancel-pending-key = Vim cancel pending key +settings-shortcut-move-word-left = Move word left +settings-shortcut-move-word-right = Move word right +settings-shortcut-select-word-left = Select word left +settings-shortcut-select-word-right = Select word right +settings-shortcut-delete-next-word = Delete next word +settings-shortcut-select-to-line-start = Select to line start +settings-shortcut-select-to-line-end = Select to line end +settings-shortcut-move-to-line-start-command = Move to line start (Command) +settings-shortcut-move-to-line-end-command = Move to line end (Command) +settings-shortcut-select-to-line-start-command = Select to line start (Command) +settings-shortcut-select-to-line-end-command = Select to line end (Command) +settings-shortcut-move-to-paragraph-start = Move to paragraph start +settings-shortcut-move-to-paragraph-end = Move to paragraph end +settings-shortcut-select-to-paragraph-start = Select to paragraph start +settings-shortcut-select-to-paragraph-end = Select to paragraph end +settings-shortcut-move-to-document-start = Move to document start +settings-shortcut-move-to-document-end = Move to document end +settings-shortcut-select-to-document-start = Select to document start +settings-shortcut-select-to-document-end = Select to document end +settings-shortcut-application-vim-next-item = Application Vim next item +settings-shortcut-application-vim-previous-item = Application Vim previous item +settings-shortcut-application-vim-first-item = Application Vim first item +settings-shortcut-application-vim-last-item = Application Vim last item +settings-shortcut-application-vim-activate-item = Application Vim activate item +settings-shortcut-application-vim-collapse-item = Application Vim collapse item +settings-shortcut-application-vim-expand-item = Application Vim expand item +settings-shortcut-application-vim-copy-transcript-item = Application Vim copy transcript item +settings-shortcut-application-vim-search = Application Vim search +settings-shortcut-application-vim-newest-assistant-item = Application Vim newest assistant item +settings-shortcut-application-vim-previous-assistant-item = Application Vim previous assistant item +settings-shortcut-application-vim-next-assistant-item = Application Vim next assistant item +settings-shortcut-application-vim-previous-annotation = Application Vim previous annotation +settings-shortcut-application-vim-next-annotation = Application Vim next annotation +settings-shortcut-application-vim-focus-last-composer-insertion = Application Vim focus last composer insertion +settings-shortcut-application-vim-close-or-return = Application Vim close or return +settings-shortcut-application-vim-new-task = Application Vim new task +settings-shortcut-application-vim-open-settings = Application Vim open settings +settings-shortcut-application-vim-move-to-left-region = Application Vim move to left region +settings-shortcut-application-vim-move-to-lower-region = Application Vim move to lower region +settings-shortcut-application-vim-move-to-upper-region = Application Vim move to upper region +settings-shortcut-application-vim-move-to-right-region = Application Vim move to right region +settings-shortcut-application-vim-previous-menu-item = Application Vim previous menu item +settings-shortcut-application-vim-next-menu-item = Application Vim next menu item +settings-shortcut-application-vim-first-menu-item = Application Vim first menu item +settings-shortcut-application-vim-last-menu-item = Application Vim last menu item +settings-shortcut-return-from-an-application-vim-text-field = Return from an application Vim text field +settings-shortcut-leave-composer-for-newest-assistant-item = Leave composer for newest assistant item +settings-shortcut-composer-vim-last-insertion = Composer Vim last insertion +settings-shortcut-leave-composer-for-left-region = Leave composer for left region +settings-shortcut-move-from-composer-to-lower-region = Move from composer to lower region +settings-shortcut-leave-composer-for-upper-region = Leave composer for upper region +settings-shortcut-move-from-composer-to-right-region = Move from composer to right region +settings-shortcut-move-left-h = Move left (h) +settings-shortcut-move-down-j = Move down (j) +settings-shortcut-move-up-k = Move up (k) +settings-shortcut-move-right-l = Move right (l) +settings-shortcut-move-to-first-line = Move to first line +settings-shortcut-move-to-last-line = Move to last line +settings-shortcut-move-left-arrow = Move left (arrow) +settings-shortcut-move-down-arrow = Move down (arrow) +settings-shortcut-move-up-arrow = Move up (arrow) +settings-shortcut-move-right-arrow = Move right (arrow) +settings-shortcut-inner-text-object-insert = Inner text object / insert +settings-shortcut-around-text-object-append = Around text object / append +settings-shortcut-delete-operator = Delete operator +settings-shortcut-change-operator = Change operator +settings-shortcut-yank-operator = Yank operator +settings-shortcut-toggle-visual-mode = Toggle Visual mode +settings-shortcut-delete-characters = Delete characters +settings-shortcut-paste-after = Paste after +settings-shortcut-paste-before = Paste before +settings-shortcut-choose-question-option-1 = Choose question option 1 +settings-shortcut-choose-question-option-2 = Choose question option 2 +settings-shortcut-choose-question-option-3 = Choose question option 3 +settings-shortcut-choose-question-option-4 = Choose question option 4 +settings-shortcut-choose-question-option-5 = Choose question option 5 +settings-shortcut-choose-question-option-6 = Choose question option 6 +settings-shortcut-choose-question-option-7 = Choose question option 7 +settings-shortcut-choose-question-option-8 = Choose question option 8 +settings-shortcut-choose-question-option-9 = Choose question option 9 +settings-shortcut-application-vim-count-0 = Application Vim count 0 +settings-shortcut-application-vim-count-1 = Application Vim count 1 +settings-shortcut-application-vim-count-2 = Application Vim count 2 +settings-shortcut-application-vim-count-3 = Application Vim count 3 +settings-shortcut-application-vim-count-4 = Application Vim count 4 +settings-shortcut-application-vim-count-5 = Application Vim count 5 +settings-shortcut-application-vim-count-6 = Application Vim count 6 +settings-shortcut-application-vim-count-7 = Application Vim count 7 +settings-shortcut-application-vim-count-8 = Application Vim count 8 +settings-shortcut-application-vim-count-9 = Application Vim count 9 +settings-shortcut-count-digit-0 = Count digit 0 +settings-shortcut-count-digit-1 = Count digit 1 +settings-shortcut-count-digit-2 = Count digit 2 +settings-shortcut-count-digit-3 = Count digit 3 +settings-shortcut-count-digit-4 = Count digit 4 +settings-shortcut-count-digit-5 = Count digit 5 +settings-shortcut-count-digit-6 = Count digit 6 +settings-shortcut-count-digit-7 = Count digit 7 +settings-shortcut-count-digit-8 = Count digit 8 +settings-shortcut-count-digit-9 = Count digit 9 +settings-shortcut-category-application = Application +settings-shortcut-category-chat = Chat +settings-shortcut-category-transcript = Transcript +settings-shortcut-category-menus = Menus +settings-shortcut-category-text-editing = Text Editing +settings-shortcut-category-composer-vim = Composer Vim +settings-shortcut-category-application-vim = Application Vim + +# Semantic runtime state presentation +settings-update-disabled = Automatic updates are unavailable in this build. +settings-update-idle = Not checked yet +settings-update-checking = Checking for updates… +settings-update-current = Up to date +settings-update-available = Update available +settings-update-downloading = Downloading update… +settings-update-ready = Ready to update +settings-update-installing = Installing update… +settings-update-restart-required = Restart required +settings-update-version-status = { $status } · v{ $version } +settings-update-download = Download update +settings-update-restart = Restart to Update +settings-update-relaunch = Restart Maple +settings-account-anonymous = Anonymous +settings-permission-accessibility = Accessibility +settings-permission-screen-recording = Screen Recording +settings-permission-desktop-helper = GNOME helper extension +settings-permission-granted = { $permission }: on +settings-permission-needed = { $permission }: needed +settings-integration-cua-name = Computer use +settings-integration-cua-description = Let Maple see your screen and use apps on this computer for you: clicking, typing and scrolling. +settings-integration-codex-description = Let a task hand work to the Codex CLI installed on this computer, with its own account. +settings-integration-claude-description = Let a task hand work to the Claude Code CLI installed on this computer, with its own account. +settings-billing-renews = Renews { $date } +settings-billing-expires = Expires { $date } +settings-billing-month = month +settings-billing-year = year +settings-billing-week = week +settings-billing-day = day +settings-billing-price-period = { $amount }/{ $period } +settings-navigation-no-region = There is no Settings region in that direction +settings-navigation-unavailable = That application Vim command is not available on this Settings target +settings-navigation-count-limit = Application Vim count capped at 999999 +settings-month-january = January +settings-month-february = February +settings-month-march = March +settings-month-april = April +settings-month-may = May +settings-month-june = June +settings-month-july = July +settings-month-august = August +settings-month-september = September +settings-month-october = October +settings-month-november = November +settings-month-december = December +settings-date = { $month } { $day }, { $year } +settings-plan-name = { $name } Plan +settings-reset-monthly = Monthly +settings-reset-soon = in less than 1 hour +settings-reset-tomorrow = Tomorrow +settings-reset-hours = { $count -> + [one] in { $count } hour + *[other] in { $count } hours + } +settings-reset-days = { $count -> + [one] in { $count } day + *[other] in { $count } days + } +settings-remote-blocked-password-protected = Its data is password protected. Update it there. +settings-remote-blocked-unsupported-installation = This installation updates only on that computer. +settings-remote-blocked-updates-disabled = Updates are off for this build. +settings-remote-blocked-update-not-ready = The update isn’t ready there yet. +settings-remote-blocked-busy = It’s busy. Try again shortly. +settings-remote-blocked-desktop-unavailable = Open Maple on that computer to update it. +settings-remote-blocked-coordinator-unavailable = Update it on that computer. +settings-remote-blocked-pairing-confirmation-required = Pair it again to update it remotely. +settings-remote-blocked-unknown = It can’t be updated remotely. +settings-remote-refused-authorization-changed = Not updated. The pairing changed. Check again, or pair it again. +settings-remote-refused-update-changed = Not updated. The update changed. Check again. +settings-remote-refused-not-ready = Not updated. It wasn’t ready. Check again. +settings-remote-refused-unsupported = Not updated. This version can’t update remotely. Update it there. +settings-remote-refused-account-changed = Not updated. The account changed. Reopen Settings. +settings-remote-refused-busy = Not updated. It was busy. Check again shortly. +settings-remote-refused-unavailable = Not updated. It didn’t accept the request. Check again. +settings-remote-refused-download-required = Not updated. It must download the update first. Turn on automatic downloads there, or update it there. +settings-permission-guidance-accessibility = Accessibility: Grant Maple this access in System Settings, then fully quit and reopen Maple. +settings-permission-guidance-screen-recording = Screen Recording: Grant Maple this access in System Settings, then fully quit and reopen Maple. +settings-credits-whole-dollars = Enter a whole-dollar amount +settings-credits-minimum = The minimum is ${ $amount } +settings-credits-maximum = The maximum is ${ $amount } +settings-password-progress-estimate = { $count -> + [one] About { $count } minute left. + *[other] About { $count } minutes left. + } +settings-password-progress-bytes = Copied about { $done } of { $total }. +settings-password-progress-committing = Finishing the change. Keep Maple open. +settings-account-password-minimum = Use at least { $count } characters for the new password +settings-password-unconfirmed-attempt = { $message } Keep this passphrase. Maple could not confirm the previous attempt. Reopen Maple to check the store before trying again. + +# Shared clipboard controls used by Settings +common-copy = Copy +common-copied = Copied diff --git a/apps/maple-agent/app/locales/es/app.ftl b/apps/maple-agent/app/locales/es/app.ftl index c5de32faa..1f06bcb19 100644 --- a/apps/maple-agent/app/locales/es/app.ftl +++ b/apps/maple-agent/app/locales/es/app.ftl @@ -96,3 +96,789 @@ settings-voice-nl-female = Con acento neerlandés — Femenina settings-voice-nl-male = Con acento neerlandés — Masculina settings-voice-pt-female = Con acento portugués — Femenina settings-voice-pt-male = Con acento portugués — Masculina + +# Settings pages +settings-settings-add-instructions-for-every-task = Añade instrucciones para todas las tareas… +settings-settings-search-by-command-context-or-shortcut = Busca por comando, contexto o atajo… +settings-settings-this-integration-is-not-ready-to-enable-on-this-device = Esta integración aún no se puede activar en este dispositivo. +settings-settings-this-integration-does-not-need-setup-on-this-device = Esta integración no necesita configuración en este dispositivo. +settings-settings-enter-a-new-value = Introduce un valor nuevo +settings-settings-my-server = Mi servidor +settings-settings-what-this-server-helps-the-agent-do = Qué permite hacer este servidor al agente +settings-settings-enter-a-server-name = Introduce un nombre para el servidor +settings-settings-enter-the-server-url = Introduce la URL del servidor +settings-settings-enter-the-server-command = Introduce el comando del servidor +settings-settings-clear-each-saved-http-header-before-switching-to-standard-io = Borra cada cabecera HTTP guardada antes de cambiar a entrada/salida estándar. +settings-settings-saved-new-tasks-use-these-instructions = Guardado. Las nuevas tareas usarán estas instrucciones. +settings-settings-shortcut-settings-applied = Ajustes de atajos aplicados. +settings-settings-shortcut-recording-cancelled = Grabación del atajo cancelada. +settings-settings-a-shortcut-can-contain-at-most-four-strokes = Un atajo puede contener como máximo cuatro pulsaciones. +settings-settings-press-at-least-one-shortcut-key = Pulsa al menos una tecla para el atajo. +settings-settings-plan = Plan +settings-settings-plan-usage-unavailable = Uso del plan no disponible +settings-settings-about = Acerca de +settings-settings-version = Versión +settings-settings-update = Actualización +settings-settings-automatically-download-updates = Descargar actualizaciones automáticamente +settings-settings-download-verified-updates-in-the-background-installation-always-w = Descarga actualizaciones verificadas en segundo plano. La instalación siempre espera a que elijas Reiniciar para actualizar. +settings-settings-checking-updating = Buscando o instalando actualizaciones… +settings-settings-retry = Reintentar +settings-settings-check-for-updates = Buscar actualizaciones +settings-settings-download-manually = Descargar manualmente +settings-settings-later = Más tarde +settings-settings-backend = Servidor +settings-settings-instruction-directory = Directorio de instrucciones +settings-settings-keyboard-shortcuts = Atajos de teclado +settings-settings-reset-all = Restablecer todos +settings-settings-customize-the-shortcuts-maple-already-ships-this-page-does-not-ad = Personaliza los atajos que Maple ya incluye. Esta página no añade comandos ni cambia lo que puede hacer una acción. +settings-settings-clear-the-invalid-saved-shortcut-overrides = ¿Borrar los cambios de atajos guardados que no son válidos? +settings-settings-reset = Restablecer +settings-settings-cancel = Cancelar +settings-settings-no-shortcuts-match-this-search = Ningún atajo coincide con esta búsqueda. +settings-settings-waiting-for-keys = Esperando teclas… +settings-settings-review-the-conflicts-below-before-saving = Revisa los conflictos de abajo antes de guardar. +settings-settings-press-up-to-four-keys-enter-saves-backspace-removes-the-latest-st = Pulsa hasta cuatro teclas. Intro guarda, Retroceso quita la última pulsación y Esc cancela. +settings-settings-replace-conflicts = Reemplazar conflictos +settings-settings-keep-both = Conservar ambos +settings-settings-unbound = Sin asignar +settings-settings-modified = Modificado +settings-settings-record = Grabar +settings-settings-disable = Desactivar +settings-settings-additional-instructions = Instrucciones adicionales +settings-settings-added-to-every-task-after-maple-s-own-system-prompt-use-it-for-pr = Se añaden a cada tarea después de las instrucciones del sistema de Maple. Úsalas para indicar tus preferencias de tono, idioma o forma de trabajar. Los cambios se aplican a las tareas iniciadas después de guardar. +settings-settings-save = Guardar +settings-settings-clear = Borrar +settings-settings-integrations = Integraciones +settings-settings-execution-computer-this-computer = Equipo de ejecución: este equipo +settings-settings-manage-integrations-here-enable-coding-agents-to-make-them-availa = Gestiona las integraciones aquí. Activa los agentes de programación para que estén disponibles en el cuadro de mensaje y selecciónalos para cada tarea. +settings-settings-detecting-integrations = Detectando integraciones… +settings-settings-no-supported-integrations-detected-on-this-device = No se han detectado integraciones compatibles en este dispositivo. +settings-settings-custom-mcp-servers = Servidores MCP personalizados +settings-settings-add-server = Añadir servidor +settings-settings-connect-additional-tools-over-mcp = Conecta herramientas adicionales mediante MCP. +settings-settings-loading-custom-mcp-servers = Cargando servidores MCP personalizados… +settings-settings-no-custom-mcp-servers-configured = No hay servidores MCP personalizados configurados. +settings-settings-enabled = Activado +settings-settings-disabled = Desactivado +settings-settings-edit = Editar +settings-settings-remove = Eliminar +settings-settings-cua-team = equipo de Cua +settings-settings-set-up = Configurar +settings-settings-enable = Activar +settings-settings-edit-server = Editar servidor +settings-settings-new-server = Nuevo servidor +settings-settings-name = Nombre +settings-settings-description = Descripción +settings-settings-transport = Transporte +settings-settings-standard-io-stdio = Entrada/salida estándar (STDIO) +settings-settings-streamable-http = HTTP en streaming +settings-settings-url = URL +settings-settings-command = Comando +settings-settings-environment-values = Variables de entorno +settings-settings-header-values = Valores de cabeceras +settings-settings-new-environment-values = Nuevas variables de entorno +settings-settings-key-value-pairs-separated-by-semicolons-existing-values-use-the-c = Pares CLAVE=valor separados por punto y coma. Para los valores existentes, usa las opciones de arriba. +settings-settings-new-headers = Nuevas cabeceras +settings-settings-name-value-pairs-separated-by-semicolons = Pares Nombre=valor separados por punto y coma. +settings-settings-needs-setup = Necesita configuración +settings-settings-not-detected = No detectado +settings-settings-ready = Listo +settings-settings-available = Disponible +settings-settings-detected = Detectado +settings-settings-setup-required = Requiere configuración +settings-settings-powered-by-cua-an-open-source-project-from-the = Desarrollado con Cua, un proyecto de código abierto del +settings-settings-built-into-maple = Integrado en Maple +settings-settings-global = Global +settings-settings-chat = Chat +settings-settings-transcript = Conversación +settings-settings-menus = Menús +settings-settings-text-fields = Campos de texto +settings-settings-composer-normal = Cuadro de mensaje — Normal +settings-settings-composer-visual = Cuadro de mensaje — Visual +settings-settings-composer-insert = Cuadro de mensaje — Insertar +settings-settings-composer-pending-key = Cuadro de mensaje — Tecla pendiente +settings-settings-exact-collision = Conflicto exacto +settings-settings-prefix-collision = Conflicto de prefijo +settings-settings-same-context = mismo contexto +settings-settings-overlapping-scoped-context = contextos específicos superpuestos +settings-settings-possibly-overlapping-context = contextos posiblemente superpuestos +settings-account-current-password = Contraseña actual +settings-account-new-password = Contraseña nueva +settings-account-confirm-new-password = Confirma la contraseña nueva +settings-account-type-delete-to-continue = Escribe DELETE para continuar +settings-account-confirmation-code = Código de confirmación +settings-account-enter-the-confirmation-code-from-the-email = Introduce el código de confirmación del correo +settings-account-password-changed = Contraseña cambiada. +settings-account-verification-email-sent-check-your-inbox = Correo de verificación enviado. Revisa tu bandeja de entrada. +settings-account-account = Cuenta +settings-account-loading-account = Cargando cuenta… +settings-account-email = Correo electrónico +settings-account-no-email-on-this-account = Esta cuenta no tiene correo electrónico +settings-account-sign-in-method = Método de inicio de sesión +settings-account-member-since = Miembro desde +settings-account-sign-out = Cerrar sesión +settings-account-ends-this-session-on-this-device-your-tasks-stay-in-your-account = Cierra esta sesión en este dispositivo. Tus tareas permanecen en tu cuenta. +settings-account-danger-zone = Zona de peligro +settings-account-delete-account = Eliminar cuenta +settings-account-permanently-deletes-your-account-tasks-and-encrypted-data-this-ca = Elimina permanentemente tu cuenta, tus tareas y tus datos cifrados. Esta acción no se puede deshacer. +settings-account-delete-account-more = Eliminar cuenta… +settings-account-this-permanently-deletes-your-account-tasks-and-encrypted-data-ty = Esto elimina permanentemente tu cuenta, tus tareas y tus datos cifrados. Escribe DELETE y te enviaremos un código de confirmación por correo. +settings-account-cancel = Cancelar +settings-account-sending = Enviando… +settings-account-send-confirmation-code = Enviar código de confirmación +settings-account-confirm-deletion = Confirmar eliminación +settings-account-we-emailed-you-a-confirmation-code-enter-it-to-delete-the-account = Te hemos enviado un código de confirmación por correo. Introdúcelo para eliminar la cuenta. El código caduca a las 24 horas. +settings-account-deleting = Eliminando… +settings-account-delete-my-account = Eliminar mi cuenta +settings-account-change-password = Cambiar contraseña +settings-account-use-at-least-8-characters-other-devices-stay-signed-in = Usa al menos 8 caracteres. Los demás dispositivos seguirán conectados. +settings-account-changing = Cambiando… +settings-account-verified = Verificado +settings-account-unverified = Sin verificar +settings-account-resend-email = Reenviar correo +settings-account-anonymous-account = Cuenta anónima +settings-account-this-account-has-no-email-keep-the-account-id-and-password-somewh = Esta cuenta no tiene correo electrónico. Guarda el identificador de la cuenta y la contraseña en un lugar seguro: son la única forma de volver a iniciar sesión. +settings-account-enter-your-current-password = Introduce tu contraseña actual +settings-account-the-new-passwords-do-not-match = Las contraseñas nuevas no coinciden +settings-account-the-new-password-must-differ-from-the-current-one = La contraseña nueva debe ser distinta de la actual +settings-advanced-type-erase = Escribe ERASE +settings-advanced-one-absolute-folder-path-per-line = Una ruta absoluta de carpeta por línea +settings-advanced-acp-preferences-saved-they-apply-to-new-connections = Preferencias de ACP guardadas. Se aplicarán a las conexiones nuevas. +settings-advanced-default-values-are-ready-save-to-apply-them = Los valores predeterminados están listos. Guarda para aplicarlos. +settings-advanced-agent-client-protocol-acp = Protocolo de cliente de agente (ACP) +settings-advanced-choose-which-folders-local-acp-clients-can-open-and-how-many-conn = Elige qué carpetas pueden abrir los clientes ACP locales y cuántas conexiones pueden ejecutarse a la vez. Estas preferencias se aplican a esta cuenta en este equipo. +settings-advanced-saving-acp-preferences = Guardando preferencias de ACP… +settings-advanced-allowed-project-folders = Carpetas de proyectos permitidas +settings-advanced-enter-one-absolute-folder-path-per-line-leave-the-list-empty-to-a = Introduce una ruta absoluta de carpeta por línea. Deja la lista vacía para permitir cualquier carpeta. +settings-advanced-maximum-connections = Conexiones máximas +settings-advanced-save-acp-preferences = Guardar preferencias de ACP +settings-advanced-use-defaults = Usar valores predeterminados +settings-advanced-retry = Reintentar +settings-advanced-loading-acp-preferences = Cargando preferencias de ACP… +settings-advanced-type-erase-exactly-to-continue = Escribe ERASE exactamente para continuar. +settings-advanced-this-notice-could-not-be-dismissed-try-again = No se ha podido descartar este aviso. Inténtalo de nuevo. +settings-advanced-changes-need-saving = Hay cambios sin guardar +settings-advanced-retry-saving = Reintentar guardar +settings-advanced-local-storage-needs-attention = El almacenamiento local necesita atención +settings-advanced-dismissing = Descartando… +settings-advanced-dismiss = Descartar +settings-advanced-local-data = Datos locales +settings-advanced-encryption-on-this-computer = Cifrado en este equipo +settings-advanced-encryption-is-on = El cifrado está activado +settings-advanced-encryption-is-off = El cifrado está desactivado +settings-advanced-maple-s-copy-of-your-conversation-text-settings-and-sign-in-on-th = La copia de tus conversaciones, ajustes y datos de inicio de sesión que Maple guarda en este equipo está cifrada con tu contraseña de desbloqueo. Maple la pide cada vez que se inicia. Los archivos adjuntos y las imágenes permanecen sin cifrar. +settings-advanced-your-conversation-text-settings-and-sign-in-are-stored-on-this-co = Tus conversaciones, ajustes y datos de inicio de sesión se guardan en este equipo y solo tu cuenta de usuario puede leerlos. Establece una contraseña de desbloqueo para cifrarlos. Los archivos adjuntos y las imágenes permanecen sin cifrar. +settings-advanced-erase-this-version-s-local-data = Borrar los datos locales de esta versión +settings-advanced-cancel = Cancelar +settings-advanced-change-password = Cambiar contraseña… +settings-advanced-set-unlock-password = Establecer contraseña de desbloqueo… +settings-advanced-remove-password = Quitar contraseña… +settings-advanced-erase-local-data = Borrar datos locales… +settings-advanced-while-maple-is-locked-your-other-computers-can-t-reach-this-one-a = Mientras Maple esté bloqueado, tus otros equipos no podrán acceder a este. Después de reiniciar, abre Maple y desbloquéalo para restablecer el acceso remoto. +settings-advanced-claude-code-keeps-its-own-copy-of-the-work-maple-hands-it-in-its = Claude Code guarda su propia copia del trabajo que Maple le envía en su propia carpeta, y la contraseña de Maple no la cifra. +settings-advanced-codex-keeps-its-own-copy-of-the-work-maple-hands-it-in-its-own-fo = Codex guarda su propia copia del trabajo que Maple le envía en su propia carpeta, y la contraseña de Maple no la cifra. +settings-advanced-encryption-enabled-on-this-computer = Cifrado activado en este equipo. +settings-advanced-your-unlock-password-was-changed = Se ha cambiado tu contraseña de desbloqueo. +settings-advanced-your-unlock-password-was-removed = Se ha quitado tu contraseña de desbloqueo. +settings-advanced-allowed-acp-folders-must-use-absolute-paths = Las carpetas ACP permitidas deben usar rutas absolutas. +settings-api-credits-loading-balance = Cargando saldo… +settings-api-credits-balance-unavailable = Saldo no disponible +settings-api-credits-enter-an-amount = Introduce un importe +settings-api-credits-opened-checkout-in-your-browser-refresh-after-paying-to-see-the-n = Se ha abierto el pago en tu navegador. Actualiza después de pagar para ver el nuevo saldo. +settings-api-credits-opened-bitcoin-checkout-in-your-browser-credits-arrive-once-the-p = Se ha abierto el pago con Bitcoin en tu navegador. Los créditos llegarán cuando se confirme el pago; actualiza para verlos. +settings-api-credits-api-credits = Créditos de API +settings-api-credits-refresh = Actualizar +settings-api-credits-extra-credit-balance = Saldo de créditos adicionales +settings-api-credits-1-per-1-000-credits-extends-your-plan-when-its-included-usage-run = 1 $ por cada 1000 créditos. Amplía tu plan cuando se agota el uso incluido. +settings-api-credits-custom-amount = Importe personalizado +settings-api-credits-buy-credits = Comprar créditos +settings-api-credits-pay-with-card = Pagar con tarjeta +settings-api-credits-pay-with-bitcoin = Pagar con Bitcoin +settings-api-credits-opening-checkout = Abriendo el pago… +settings-api-credits-anonymous-accounts-pay-with-bitcoin = Las cuentas anónimas pagan con Bitcoin +settings-api-credits-checkout-opens-in-your-browser-anonymous-accounts-pay-with-bitcoi = El pago se abre en tu navegador. Las cuentas anónimas pagan con Bitcoin. +settings-api-credits-checkout-opens-in-your-browser = El pago se abre en tu navegador. +settings-api-keys-key-name-for-example-cursor = Nombre de la clave, por ejemplo «Cursor» +settings-api-keys-api-keys = Claves de API +settings-api-keys-unlock-api-access = Desbloquear acceso a la API +settings-api-keys-api-keys-and-credits-come-with-the-pro-max-and-team-plans = Las claves de API y los créditos están incluidos en los planes Pro, Max y Team. +settings-api-keys-see-plans = Ver planes +settings-api-keys-creating = Creando… +settings-api-keys-create-key = Crear clave +settings-api-keys-no-api-keys-yet = Aún no hay claves de API. +settings-api-keys-loading-keys = Cargando claves… +settings-api-keys-api-credits = Créditos de API +settings-api-keys-buy-more = Comprar más +settings-api-keys-copy-the-key-now-for-your-privacy-it-is-not-stored-anywhere-you-c = Copia la clave ahora. Por tu privacidad, no se guarda en ningún lugar donde puedas volver a consultarla. +settings-api-keys-done = Listo +settings-api-keys-deleting = Eliminando… +settings-api-keys-confirm-delete = Confirmar eliminación +settings-api-keys-delete = Eliminar +settings-api-keys-cancel = Cancelar +settings-billing-opened-the-subscription-portal-in-your-browser-refresh-after-maki = Se ha abierto el portal de suscripción en tu navegador. Actualiza después de hacer cambios. +settings-billing-opened-the-pricing-page-in-your-browser = Se ha abierto la página de precios en tu navegador. +settings-billing-opened-checkout-in-your-browser-refresh-after-paying-to-see-the-n = Se ha abierto el pago en tu navegador. Actualiza después de pagar para ver el nuevo plan. +settings-billing-plan = Plan +settings-billing-refresh = Actualizar +settings-billing-loading-plan = Cargando plan… +settings-billing-free = Gratis +settings-billing-manage-subscription = Gestionar suscripción +settings-billing-plans = Planes +settings-billing-no-plans-are-on-sale-right-now = No hay planes a la venta en este momento. +settings-billing-loading-plans = Cargando planes… +settings-billing-payments-open-in-your-browser-bitcoin-payments-and-team-seats-are = Los pagos se abren en tu navegador. Los pagos con Bitcoin y las plazas de equipo se configuran en la página web de precios. +settings-billing-current-plan = Plan actual +settings-billing-choose = Elegir +settings-billing-change-plan = Cambiar plan +settings-billing-see-on-the-web = Ver en la web +settings-billing-active = Activa +settings-billing-trial = Prueba +settings-billing-payment-due = Pago pendiente +settings-billing-canceled = Cancelada +settings-billing-paid-with-bitcoin-to-change-plans-contact-support-trymaple-ai = Pagado con Bitcoin. Para cambiar de plan, escribe a support@trymaple.ai. +settings-billing-activated-with-a-subscription-pass-it-ends-on-the-date-above-with = Activado con un pase de suscripción. Termina en la fecha indicada arriba y no se renueva. +settings-local-api-port-1-65535 = Puerto (1–65535) +settings-local-api-could-not-read-the-local-api-status-reconnect-to-this-computer-an = No se ha podido leer el estado de la API local. Vuelve a conectarte a este equipo e inténtalo de nuevo. +settings-local-api-could-not-update-the-local-api-refresh-its-status-and-try-again = No se ha podido actualizar la API local. Actualiza su estado e inténtalo de nuevo. +settings-local-api-checking-status = Comprobando estado… +settings-local-api-status-unavailable = Estado no disponible +settings-local-api-local-api = API local +settings-local-api-connect-ai-applications-on-this-computer = Conecta aplicaciones de IA en este equipo +settings-local-api-expose-maple-s-inference-apis-at-a-local-address-requests-use-map = Expone las API de inferencia de Maple en una dirección local. Las solicitudes usan el servidor seguro de Maple y tus créditos de API. +settings-local-api-learn-more-about-the-local-api = Más información sobre la API local +settings-local-api-learn-more = Más información +settings-local-api-enable-local-api = Activar API local +settings-local-api-applies-to-this-computer-including-while-you-work-with-a-remote-a = Se aplica a este equipo, incluso cuando trabajas con un agente remoto. +settings-local-api-enable-local-api-on-this-computer = Activar la API local en este equipo +settings-local-api-unavailable-while-local-api-settings-are-loading-or-being-updated = No disponible mientras se cargan o actualizan los ajustes de la API local. +settings-local-api-updating-local-api-settings = Actualizando ajustes de la API local… +settings-local-api-the-local-api-is-enabled-but-paused-select-resume-to-accept-reque = La API local está activada pero pausada. Selecciona Reanudar para aceptar solicitudes en este equipo. +settings-local-api-port = Puerto +settings-local-api-the-local-address-always-uses-127-0-0-1-stop-the-api-to-change-it = La dirección local siempre usa 127.0.0.1. Detén la API para cambiar su puerto. +settings-local-api-save-port = Guardar puerto +settings-local-api-save-and-resume = Guardar y reanudar +settings-local-api-save-and-retry = Guardar y reintentar +settings-local-api-openai-compatible-base-url = URL base compatible con OpenAI +settings-local-api-paste-this-address-into-your-ai-application-s-provider-settings = Pega esta dirección en los ajustes del proveedor de tu aplicación de IA. +settings-local-api-copy-local-api-base-url = Copiar URL base de la API local +settings-local-api-when-enabled-the-api-resumes-when-this-computer-s-maple-host-star = Cuando está activada, la API se reanuda al iniciar el host de Maple de este equipo y desbloquear su almacenamiento. Cerrar sesión, bloquear o salir del host detiene la escucha. +settings-local-api-create-a-dedicated-maple-api-key-copy-it-once-and-enter-it-in-you = Crea una clave de API de Maple dedicada, cópiala una vez e introdúcela en el campo de clave de API de tu aplicación de IA. Cada solicitud debe incluir esa clave como Authorization: Bearer . +settings-local-api-manage-api-keys = Gestionar claves de API +settings-local-api-available-endpoints = Rutas disponibles +settings-local-api-browser-origin-requests-are-blocked-use-a-desktop-application-cli = Se bloquean las solicitudes procedentes de navegadores. Usa una aplicación de escritorio, una CLI o un cliente de servidor. +settings-local-api-refresh-status = Actualizar estado +settings-local-api-enter-a-port-between-1-and-65535 = Introduce un puerto entre 1 y 65535. +settings-local-api-paused = Pausada +settings-local-api-stopped = Detenida +settings-local-api-starting = Iniciando… +settings-local-api-running-on-this-computer = En ejecución en este equipo +settings-local-api-stopping = Deteniendo… +settings-local-api-could-not-start = No se ha podido iniciar +settings-local-api-resume = Reanudar +settings-local-api-retry-start = Reintentar inicio +settings-mcp-saved-value = Valor guardado +settings-mcp-no-saved-value = Sin valor guardado +settings-mcp-new-value = Valor nuevo +settings-mcp-will-clear = Se borrará +settings-mcp-keep = Conservar +settings-mcp-replace = Reemplazar +settings-mcp-clear = Borrar +settings-mcp-enter-new-values-as-name-value-separated-by-semicolons = Introduce valores nuevos como NOMBRE=valor, separados por punto y coma. +settings-mcp-each-new-entry-needs-a-name-and-value = Cada entrada nueva necesita un nombre y un valor. +settings-mcp-the-server-target-or-name-changed-replace-or-clear-each-saved-val = El destino o el nombre del servidor ha cambiado. Reemplaza o borra cada valor guardado antes de guardar. +settings-remote-could-not-read-remote-update-status = No se ha podido leer el estado de actualización remota +settings-remote-not-confirmed-don-t-resend-check-again-first = Sin confirmar. No vuelvas a enviar la solicitud; comprueba el estado primero. +settings-remote-update-maple-on-both-computers-to-reconnect = Actualiza Maple en ambos equipos para volver a conectarlos. +settings-remote-this-version-can-t-be-updated-remotely = Esta versión no se puede actualizar de forma remota. +settings-remote-an-update-request-is-already-in-progress-there = Ya hay una solicitud de actualización en curso allí. +settings-remote-the-update-isn-t-ready-there-yet = La actualización aún no está lista allí. +settings-remote-no-update-is-offered-there = No hay actualizaciones disponibles allí. +settings-remote-it-already-runs-this-version = Ya tiene esta versión. +settings-remote-checking-for-updates = Buscando actualizaciones… +settings-remote-checking-status = Comprobando estado… +settings-remote-version-unknown = Versión desconocida +settings-remote-different-remote-protocol = Protocolo remoto diferente +settings-remote-not-reachable = No accesible +settings-remote-update-needed = Necesita actualizarse +settings-remote-checking-for-updates-more = buscando actualizaciones… +settings-remote-checking-status-more = comprobando estado… +settings-remote-this-version-checks-for-updates-only-on-that-computer = Esta versión solo busca actualizaciones en ese equipo. +settings-remote-up-to-date = Actualizado +settings-remote-restart-needed = Necesita reiniciarse +settings-remote-unconfirmed = Sin confirmar +settings-remote-waiting-for-it-to-acknowledge-nothing-has-started = Esperando confirmación. No se ha iniciado nada. +settings-remote-it-restarts-when-done-check-again-once-it-s-back = Se reinicia al terminar. Comprueba el estado cuando vuelva a conectarse. +settings-remote-eight-character-pairing-code = Código de emparejamiento de ocho caracteres +settings-remote-this-computer-s-update-or-pairing-changed-refresh-its-status-befo = La actualización o el emparejamiento de este equipo ha cambiado. Actualiza su estado antes de volver a intentarlo. +settings-remote-sending-the-update-request = Enviando la solicitud de actualización… +settings-remote-computer-paired-it-can-now-use-this-computer-s-projects-and-tools = Equipo emparejado. Ya puede usar los proyectos y las herramientas de este equipo. +settings-remote-enter-all-eight-characters-shown-on-the-selected-computer = Introduce los ocho caracteres que aparecen en el equipo seleccionado. +settings-remote-computer-name = Nombre del equipo +settings-remote-use-a-computer-name-between-1-and-64-characters = Usa un nombre de equipo de entre 1 y 64 caracteres. +settings-remote-sign-in-before-naming-a-remote-computer = Inicia sesión antes de asignar un nombre a un equipo remoto +settings-remote-could-not-save-the-computer-name = No se ha podido guardar el nombre del equipo +settings-remote-finding-computers = Buscando equipos… +settings-remote-updating-remote-settings = Actualizando ajustes remotos… +settings-remote-preparing-pairing-code = Preparando código de emparejamiento… +settings-remote-pairing-computer = Emparejando equipo… +settings-remote-unpairing-computer = Desemparejando equipo… +settings-remote-could-not-read-the-pairing-window = No se ha podido leer la ventana de emparejamiento +settings-remote-computer-paired-its-projects-are-now-available-in-the-sidebar = Equipo emparejado. Sus proyectos ya están disponibles en la barra lateral. +settings-remote-computer-unpaired = Equipo desemparejado. +settings-remote-computer-name-saved-other-computers-pick-it-up-when-they-refresh = Nombre del equipo guardado. Los otros equipos lo verán al actualizar la lista de dispositivos o volver a conectarse. +settings-remote-remote-computers = Equipos remotos +settings-remote-work-on-another-computer-or-allow-a-paired-computer-to-work-on-th = Trabaja en otro equipo o permite que un equipo emparejado trabaje en este. +settings-remote-loading = Cargando… +settings-remote-starting-waiting-for-a-connection = Iniciando. Esperando una conexión… +settings-remote-on-only-paired-computers-can-connect = Activado. Solo pueden conectarse los equipos emparejados. +settings-remote-off-other-computers-can-t-connect-here = Desactivado. Otros equipos no pueden conectarse aquí. +settings-remote-remote-access = Acceso remoto +settings-remote-turn-off-remote-access = Desactivar acceso remoto +settings-remote-enable-remote-access = Activar acceso remoto +settings-remote-this-computer-s-name-on-your-other-computers = El nombre de este equipo en tus otros equipos. +settings-remote-rename-this-computer = Cambiar el nombre de este equipo +settings-remote-rename = Cambiar nombre +settings-remote-save-computer-name = Guardar nombre del equipo +settings-remote-save = Guardar +settings-remote-cancel-rename = Cancelar cambio de nombre +settings-remote-cancel = Cancelar +settings-remote-shown-to-your-other-computers-this-doesn-t-change-the-system-host = Se muestra a tus otros equipos. No cambia el nombre del equipo en el sistema. +settings-remote-pair-a-new-computer = Emparejar un equipo nuevo +settings-remote-show-a-one-time-code-here-then-enter-it-on-the-other-computer = Muestra un código de un solo uso aquí e introdúcelo en el otro equipo. +settings-remote-show-pairing-code = Mostrar código de emparejamiento +settings-remote-on-the-other-computer-choose-find-computers-select-this-one-and-e = En el otro equipo, elige Buscar equipos, selecciona este e introduce el código. +settings-remote-cancel-pairing = Cancelar emparejamiento +settings-remote-connect-to-another-computer = Conectarse a otro equipo +settings-remote-find-computers-signed-in-to-your-account-turn-on-remote-access-th = Busca equipos conectados a tu cuenta. Activa el acceso remoto allí y muestra un código de emparejamiento. +settings-remote-refresh-devices = Actualizar dispositivos +settings-remote-find-computers = Buscar equipos +settings-remote-search-again = Volver a buscar +settings-remote-no-new-computers-found-check-it-s-online-and-signed-in-then-searc = No se han encontrado equipos nuevos. Comprueba que estén conectados y tengan la sesión iniciada, y vuelve a buscar. +settings-remote-ready-to-pair = Listo para emparejar +settings-remote-show-a-pairing-code-on-this-computer-then-refresh = Muestra un código de emparejamiento en este equipo y actualiza la lista. +settings-remote-enter-code = Introducir código +settings-remote-cancel-code-entry = Cancelar introducción del código +settings-remote-pairing-code-from-this-computer = Código de emparejamiento de este equipo +settings-remote-pair-computer = Emparejar equipo +settings-remote-type-or-paste-its-eight-character-code-the-dash-is-optional = Escribe o pega su código de ocho caracteres. El guion es opcional. +settings-remote-paired-computers = Equipos emparejados +settings-remote-can-use-this-computer-s-projects-and-tools = Puede usar los proyectos y las herramientas de este equipo +settings-remote-pairing-needs-confirmation = El emparejamiento necesita confirmación +settings-remote-save-name = Guardar nombre +settings-remote-update = Actualizar +settings-remote-check-this-computer-for-updates-again = Volver a buscar actualizaciones en este equipo +settings-remote-check-again = Volver a comprobar +settings-remote-confirm-previous-pairing = Confirmar emparejamiento anterior +settings-remote-confirm-pairing = Confirmar emparejamiento +settings-remote-check-for-updates = Buscar actualizaciones +settings-remote-rename-more = Cambiar nombre… +settings-remote-unpair = Desemparejar +settings-remote-more = Más +settings-remote-experimental-remote-update = Actualización remota experimental +settings-remote-experimental = Experimental +settings-remote-updating = Actualizando +settings-remote-updater-needs-attention = El actualizador necesita atención + +# Parameterized Settings messages +settings-server-exists = Ya existe un servidor llamado { $name } +settings-shortcut-change-failed = No se pudo cambiar el atajo: { $message } +settings-shortcut-invalid = No se puede usar ese atajo: { $message } +settings-shortcut-counts = { $arg1 } atajos · { $modified-count } modificados +settings-shortcut-overrides-invalid = No se aplicaron los cambios de atajos guardados; Maple mantuvo todos los valores predeterminados. { $error } +settings-shortcut-reset-confirm = ¿Restablecer todos los cambios de atajos ({ $modified-count })? +settings-shortcut-recording = Grabando: { $label } +settings-shortcut-collision = { $arg1 } con { $arg2 } ({ $arg3 }) +settings-shortcut-conflicts = Conflictos de atajos: { $arg1 } +settings-shortcut-default = Predeterminado: { $arg1 } +settings-execution-computer = Equipo de ejecución: { $arg1 }. La configuración nativa y los permisos deben completarse allí. +settings-integration-version = Versión { $version } +settings-usage-percent = { $arg1 }% utilizado +settings-usage-resets = · Se restablece { $arg1 } +settings-delete-confirmation = Escribe { $delete-acknowledgement } para confirmar +settings-acp-default = Predeterminado ({ $arg1 }) +settings-acp-limit-help = Elige entre 1 y { $arg1 } conexiones simultáneas. Déjalo vacío para usar el valor predeterminado ({ $arg2 }). +settings-acp-limit-invalid = Elige un límite de conexiones entre 1 y { $arg1 }. +settings-credits-amount-placeholder = Importe en dólares, de ${ $min-credit-purchase-dollars } a ${ $arg1 } +settings-credits-balance = { $arg1 } créditos +settings-credits-cost = { $arg1 } créditos por ${ $arg2 } +settings-api-key-help = Las claves funcionan con cualquier cliente compatible con OpenAI en { $arg1 }/v1 y con `maple-agent proxy --api-key`. La clave se muestra una sola vez, al crearla. +settings-api-key-ready = «{ $arg1 }» está lista +settings-api-key-created = Creada { $arg1 } +settings-local-api-requests = { $count -> + [one] { $count } solicitud activa + *[other] { $count } solicitudes activas + } +settings-mcp-secret-required = Introduce un valor nuevo para { $arg1 } o elige Borrar. +settings-computer-name = Equipo { $arg1 } +settings-remote-client-version = Este equipo usa la versión v{ $client }. +settings-remote-checking = { $running } · { $arg1 } +settings-remote-last-seen = { $running } en la última conexión +settings-remote-update-available = { $running } · { $target } disponible +settings-remote-downloading-version = { $running } · descargando { $target }… +settings-remote-downloading = { $running } · descargando… +settings-remote-checking-updates = { $running } · buscando actualizaciones… +settings-remote-updating-status = { $running } · actualizando… +settings-remote-restart-needed-status = { $running } · necesita reiniciarse allí +settings-remote-sending-update = { $running } · enviando actualización… +settings-remote-updating-version = { $running } · actualizando a { $target }… +settings-remote-updated = Actualizado a { $target }. +settings-remote-update-incomplete = La actualización a { $target } no se completó. +settings-remote-update-confirm-title = ¿Actualizar { $name } a { $version }? +settings-remote-update-action = Actualizar { $name } +settings-remote-update-confirm-detail = { $name } detiene sus tareas en curso, instala { $version } y se reinicia. Es posible que el acceso remoto no se restablezca; si ocurre, abre Maple directamente en { $name }. Este equipo seguirá en la versión v{ $client-version } y no se reiniciará. +settings-pairing-expiry = Un intento · caduca en { $arg1 }:{ $arg2 } +settings-computer-options = Opciones de { $name } + +# Local encryption settings +settings-password-maple-can-t-reset-your-unlock-password-erasing-removes-the-data-s = Maple no puede restablecer tu contraseña de desbloqueo. Al borrar se eliminan los datos guardados en el almacén de esta versión: tareas e historial, archivos adjuntos, ajustes, sesión iniciada, secretos MCP y emparejamientos remotos de este almacén. En tus otros equipos, elimina este equipo de Equipos remotos. Las copias de seguridad, las instantáneas y los datos de versiones anteriores permanecen intactos. +settings-password-current-unlock-password = Contraseña de desbloqueo actual +settings-password-enter-a-new-unlock-password = Introduce una nueva contraseña de desbloqueo. +settings-password-re-enter-your-new-unlock-password = Vuelve a introducir tu nueva contraseña de desbloqueo +settings-password-wait-for-the-current-change-to-finish = Espera a que termine el cambio actual. +settings-password-enter-your-current-unlock-password-to-continue = Introduce tu contraseña de desbloqueo actual para continuar. +settings-password-keep-this-passphrase-maple-could-not-confirm-the-previous-attempt = Conserva esta frase de contraseña. Maple no pudo confirmar el intento anterior. Vuelve a abrir Maple para comprobar el almacén antes de intentarlo de nuevo. +settings-password-wait-for-your-passphrase-to-finish-generating = Espera a que termine de generarse tu frase de contraseña. +settings-password-generate-a-passphrase-or-enter-your-own-password = Genera una frase de contraseña o introduce tu propia contraseña. +settings-password-use-between-15-and-1-024-characters-for-your-new-password = Usa entre 15 y 1024 caracteres para tu nueva contraseña. +settings-password-choose-a-less-predictable-password-try-a-longer-unique-phrase = Elige una contraseña menos predecible. Prueba una frase más larga y única. +settings-password-re-enter-your-new-unlock-password-in-confirm-password = Vuelve a introducir tu nueva contraseña de desbloqueo en Confirmar contraseña. +settings-password-the-passwords-don-t-match-re-enter-your-new-password-in-confirm-p = Las contraseñas no coinciden. Vuelve a introducir la nueva contraseña en Confirmar contraseña. +settings-password-cancelling-before-the-store-is-replaced = Cancelando antes de reemplazar el almacén… +settings-password-the-store-is-committing-wait-for-it-to-finish = El almacén está guardando los cambios. Espera a que termine. +settings-password-enable-encryption = Activar cifrado +settings-password-protect-saved-conversation-text-settings-and-sign-in-attached-fil = Protege el texto guardado de las conversaciones, los ajustes y la sesión iniciada. Los archivos adjuntos y las imágenes permanecen sin cifrar en este equipo. +settings-password-change-unlock-password = Cambiar contraseña de desbloqueo +settings-password-enter-your-current-unlock-password-then-choose-a-new-one = Introduce tu contraseña de desbloqueo actual y elige una nueva. +settings-password-save-password = Guardar contraseña +settings-password-remove-encryption = Eliminar cifrado +settings-password-your-local-maple-data-will-no-longer-be-encrypted-enter-your-curr = Tus datos locales de Maple dejarán de estar cifrados. Introduce tu contraseña actual para continuar. +settings-password-cancel = Cancelar +settings-password-new-unlock-password = Nueva contraseña de desbloqueo +settings-password-generate-passphrase = Generar frase de contraseña +settings-password-generating-a-random-passphrase = Generando una frase de contraseña aleatoria… +settings-password-couldn-t-generate-a-passphrase-try-again-or-enter-your-own-passwo = No se pudo generar una frase de contraseña. Inténtalo de nuevo o introduce tu propia contraseña. +settings-password-six-random-words-save-them-in-your-password-manager-before-contin = Seis palabras aleatorias. Guárdalas en tu gestor de contraseñas antes de continuar. +settings-password-copy-passphrase = Copiar frase de contraseña +settings-password-generate-another = Generar otra +settings-password-use-my-own-password = Usar mi propia contraseña +settings-password-use-at-least-15-characters-a-long-unique-phrase-works-well = Usa al menos 15 caracteres. Una frase larga y única funciona bien. +settings-password-use-between-15-and-1-024-characters = Usa entre 15 y 1024 caracteres. +settings-password-confirm-password = Confirmar contraseña +settings-password-passwords-match = Las contraseñas coinciden +settings-password-passwords-don-t-match = Las contraseñas no coinciden +settings-password-also-create-a-new-encryption-key = Crear también una nueva clave de cifrado +settings-password-use-a-new-encryption-key-if-your-old-password-and-a-copy-of-this = Usa una nueva clave de cifrado si tu contraseña anterior y una copia de este almacén pueden haber quedado expuestas. Esto protege este almacén y los datos que se guarden en adelante; no puede revocar copias existentes. +settings-password-this-is-separate-from-your-maple-account-password-maple-can-t-res = Esta contraseña es distinta de la de tu cuenta de Maple. Maple no puede restablecerla. +settings-password-if-you-forget-it-you-ll-need-to-erase-this-version-s-local-data-a = Si la olvidas, tendrás que borrar los datos locales de esta versión y volver a emparejar tus otros equipos. +settings-password-existing-backups-snapshots-and-previous-app-data-aren-t-re-encryp = Las copias de seguridad, las instantáneas y los datos de versiones anteriores no se vuelven a cifrar. +settings-password-the-new-unlock-password-took-effect-but-maple-couldn-t-confirm-it = La nueva contraseña de desbloqueo entró en vigor, pero Maple no pudo confirmar la sincronización del archivo de claves. Conserva ambas contraseñas hasta que termine la sincronización. +settings-password-the-new-unlock-password-took-effect-maple-still-needs-to-synchron = La nueva contraseña de desbloqueo entró en vigor. Maple aún necesita sincronizar el archivo de copia de seguridad de claves. +settings-password-maple-opened-with-your-previous-unlock-password-keep-your-current = Maple se abrió con tu contraseña de desbloqueo anterior. Conserva tus contraseñas de desbloqueo actual y anterior hasta que los archivos de claves estén sincronizados. +settings-password-maple-is-open-key-file-synchronization-or-its-security-upgrade-st = Maple está abierto. La sincronización de los archivos de claves o su actualización de seguridad aún necesita atención. Tu contraseña de desbloqueo actual sigue siendo válida. +settings-password-encryption-changed-maple-still-needs-to-save-the-local-mode-chang = El cifrado ha cambiado. Maple aún necesita guardar el aviso local del cambio de modo. +settings-password-too-easy-to-guess = Demasiado fácil de adivinar +settings-password-could-be-stronger = Podría ser más segura +settings-password-strong = Segura +settings-password-enter-a-password = Introduce una contraseña +settings-password-removing-encryption = Eliminando el cifrado +settings-password-updating-your-unlock-password = Actualizando tu contraseña de desbloqueo +settings-password-encrypting-your-local-data = Cifrando tus datos locales +settings-password-updating-your-local-store = Actualizando tu almacén local +settings-password-unlock-password = Contraseña de desbloqueo +settings-password-strength = Seguridad de la contraseña: { $strength } + +# Built-in keyboard shortcut names and categories +settings-shortcut-quit-maple = Salir de Maple +settings-shortcut-close-the-active-chat-surface = Cerrar la vista de chat activa +settings-shortcut-new-task = Nueva tarea +settings-shortcut-focus-task-search = Enfocar la búsqueda de tareas +settings-shortcut-toggle-sidebar = Mostrar u ocultar la barra lateral +settings-shortcut-toggle-archived-tasks = Mostrar u ocultar las tareas archivadas +settings-shortcut-open-settings = Abrir ajustes +settings-shortcut-choose-project = Elegir proyecto +settings-shortcut-previous-task = Tarea anterior +settings-shortcut-next-task = Tarea siguiente +settings-shortcut-hide-maple = Ocultar Maple +settings-shortcut-hide-others = Ocultar otras aplicaciones +settings-shortcut-copy-transcript-selection = Copiar la selección de la conversación +settings-shortcut-select-all-transcript-text = Seleccionar todo el texto de la conversación +settings-shortcut-previous-menu-item = Elemento anterior del menú +settings-shortcut-next-menu-item = Elemento siguiente del menú +settings-shortcut-first-menu-item = Primer elemento del menú +settings-shortcut-last-menu-item = Último elemento del menú +settings-shortcut-choose-menu-item = Elegir elemento del menú +settings-shortcut-choose-menu-item-with-space = Elegir elemento del menú con Espacio +settings-shortcut-close-menu = Cerrar menú +settings-shortcut-open-submenu = Abrir submenú +settings-shortcut-return-to-parent-menu = Volver al menú superior +settings-shortcut-delete-backward = Borrar hacia atrás +settings-shortcut-delete-forward = Borrar hacia delante +settings-shortcut-move-left = Mover a la izquierda +settings-shortcut-move-right = Mover a la derecha +settings-shortcut-select-left = Seleccionar a la izquierda +settings-shortcut-select-right = Seleccionar a la derecha +settings-shortcut-select-all-text = Seleccionar todo el texto +settings-shortcut-paste = Pegar +settings-shortcut-copy = Copiar +settings-shortcut-cut = Cortar +settings-shortcut-move-to-line-start = Ir al inicio de la línea +settings-shortcut-move-to-line-end = Ir al final de la línea +settings-shortcut-move-up = Mover arriba +settings-shortcut-move-down = Mover abajo +settings-shortcut-select-up = Seleccionar hacia arriba +settings-shortcut-select-down = Seleccionar hacia abajo +settings-shortcut-undo = Deshacer +settings-shortcut-redo = Rehacer +settings-shortcut-show-character-palette = Mostrar paleta de caracteres +settings-shortcut-vim-insert-at-first-non-whitespace = Vim: insertar en el primer carácter que no sea un espacio +settings-shortcut-vim-insert-at-line-end = Vim: insertar al final de la línea +settings-shortcut-vim-open-line-below = Vim: abrir una línea debajo +settings-shortcut-vim-open-line-above = Vim: abrir una línea encima +settings-shortcut-vim-undo = Vim: deshacer +settings-shortcut-vim-redo = Vim: rehacer +settings-shortcut-vim-repeat = Vim: repetir +settings-shortcut-vim-cancel = Vim: cancelar +settings-shortcut-vim-cancel-selection = Vim: cancelar selección +settings-shortcut-vim-leave-insert-mode = Vim: salir del modo Insertar +settings-shortcut-move-word-forward = Avanzar una palabra +settings-shortcut-move-word-backward = Retroceder una palabra +settings-shortcut-move-to-word-end = Ir al final de la palabra +settings-shortcut-move-to-previous-word-end = Ir al final de la palabra WORD anterior +settings-shortcut-move-to-first-non-blank = Ir al primer carácter que no sea un espacio +settings-shortcut-move-to-matching-bracket = Ir al delimitador correspondiente +settings-shortcut-move-to-previous-paragraph = Ir al párrafo anterior +settings-shortcut-move-to-next-paragraph = Ir al párrafo siguiente +settings-shortcut-repeat-last-find = Repetir la última búsqueda +settings-shortcut-repeat-last-find-backward = Repetir la última búsqueda hacia atrás +settings-shortcut-find-character = Buscar carácter +settings-shortcut-find-character-backward = Buscar carácter hacia atrás +settings-shortcut-move-to-before-character = Ir hasta antes del carácter +settings-shortcut-move-to-after-character-backward = Ir hasta después del carácter hacia atrás +settings-shortcut-replace-with-character = Reemplazar por un carácter +settings-shortcut-toggle-case = Alternar mayúsculas y minúsculas +settings-shortcut-join-lines = Unir líneas +settings-shortcut-toggle-linewise-visual-mode = Alternar el modo Visual por líneas +settings-shortcut-substitute-characters = Sustituir caracteres +settings-shortcut-change-selection = Cambiar selección +settings-shortcut-substitute-lines = Sustituir líneas +settings-shortcut-change-selected-lines = Cambiar líneas seleccionadas +settings-shortcut-change-to-line-end = Cambiar hasta el final de la línea +settings-shortcut-change-selected-lines-c = Cambiar líneas seleccionadas (C) +settings-shortcut-delete-to-line-end = Borrar hasta el final de la línea +settings-shortcut-delete-selected-lines = Borrar líneas seleccionadas +settings-shortcut-yank-to-line-end = Copiar hasta el final de la línea +settings-shortcut-yank-selected-lines = Copiar líneas seleccionadas +settings-shortcut-delete-before-cursor = Borrar antes del cursor +settings-shortcut-delete-selected-lines-x = Borrar líneas seleccionadas (X) +settings-shortcut-toggle-case-operator = Operador para alternar mayúsculas y minúsculas +settings-shortcut-lowercase-operator = Operador de minúsculas +settings-shortcut-uppercase-operator = Operador de mayúsculas +settings-shortcut-uppercase-line-guu = Convertir línea a mayúsculas (gUU) +settings-shortcut-go-to-other-end-of-selection = Ir al otro extremo de la selección +settings-shortcut-lowercase-selection = Convertir selección a minúsculas +settings-shortcut-uppercase-selection = Convertir selección a mayúsculas +settings-shortcut-delete-previous-word = Borrar la palabra anterior +settings-shortcut-delete-to-line-start = Borrar hasta el inicio de la línea +settings-shortcut-delete-previous-character = Borrar el carácter anterior +settings-shortcut-vim-cancel-pending-key = Vim: cancelar tecla pendiente +settings-shortcut-move-word-left = Mover una palabra a la izquierda +settings-shortcut-move-word-right = Mover una palabra a la derecha +settings-shortcut-select-word-left = Seleccionar una palabra a la izquierda +settings-shortcut-select-word-right = Seleccionar una palabra a la derecha +settings-shortcut-delete-next-word = Borrar la palabra siguiente +settings-shortcut-select-to-line-start = Seleccionar hasta el inicio de la línea +settings-shortcut-select-to-line-end = Seleccionar hasta el final de la línea +settings-shortcut-move-to-line-start-command = Ir al inicio de la línea (Comando) +settings-shortcut-move-to-line-end-command = Ir al final de la línea (Comando) +settings-shortcut-select-to-line-start-command = Seleccionar hasta el inicio de la línea (Comando) +settings-shortcut-select-to-line-end-command = Seleccionar hasta el final de la línea (Comando) +settings-shortcut-move-to-paragraph-start = Ir al inicio del párrafo +settings-shortcut-move-to-paragraph-end = Ir al final del párrafo +settings-shortcut-select-to-paragraph-start = Seleccionar hasta el inicio del párrafo +settings-shortcut-select-to-paragraph-end = Seleccionar hasta el final del párrafo +settings-shortcut-move-to-document-start = Ir al inicio del documento +settings-shortcut-move-to-document-end = Ir al final del documento +settings-shortcut-select-to-document-start = Seleccionar hasta el inicio del documento +settings-shortcut-select-to-document-end = Seleccionar hasta el final del documento +settings-shortcut-application-vim-next-item = Vim de la aplicación: elemento siguiente +settings-shortcut-application-vim-previous-item = Vim de la aplicación: elemento anterior +settings-shortcut-application-vim-first-item = Vim de la aplicación: primer elemento +settings-shortcut-application-vim-last-item = Vim de la aplicación: último elemento +settings-shortcut-application-vim-activate-item = Vim de la aplicación: activar elemento +settings-shortcut-application-vim-collapse-item = Vim de la aplicación: contraer elemento +settings-shortcut-application-vim-expand-item = Vim de la aplicación: expandir elemento +settings-shortcut-application-vim-copy-transcript-item = Vim de la aplicación: copiar elemento de la conversación +settings-shortcut-application-vim-search = Vim de la aplicación: buscar +settings-shortcut-application-vim-newest-assistant-item = Vim de la aplicación: respuesta más reciente del asistente +settings-shortcut-application-vim-previous-assistant-item = Vim de la aplicación: respuesta anterior del asistente +settings-shortcut-application-vim-next-assistant-item = Vim de la aplicación: respuesta siguiente del asistente +settings-shortcut-application-vim-previous-annotation = Vim de la aplicación: anotación anterior +settings-shortcut-application-vim-next-annotation = Vim de la aplicación: anotación siguiente +settings-shortcut-application-vim-focus-last-composer-insertion = Vim de la aplicación: enfocar la última inserción del editor +settings-shortcut-application-vim-close-or-return = Vim de la aplicación: cerrar o volver +settings-shortcut-application-vim-new-task = Vim de la aplicación: nueva tarea +settings-shortcut-application-vim-open-settings = Vim de la aplicación: abrir ajustes +settings-shortcut-application-vim-move-to-left-region = Vim de la aplicación: ir a la región izquierda +settings-shortcut-application-vim-move-to-lower-region = Vim de la aplicación: ir a la región inferior +settings-shortcut-application-vim-move-to-upper-region = Vim de la aplicación: ir a la región superior +settings-shortcut-application-vim-move-to-right-region = Vim de la aplicación: ir a la región derecha +settings-shortcut-application-vim-previous-menu-item = Vim de la aplicación: elemento anterior del menú +settings-shortcut-application-vim-next-menu-item = Vim de la aplicación: elemento siguiente del menú +settings-shortcut-application-vim-first-menu-item = Vim de la aplicación: primer elemento del menú +settings-shortcut-application-vim-last-menu-item = Vim de la aplicación: último elemento del menú +settings-shortcut-return-from-an-application-vim-text-field = Volver desde un campo de texto del Vim de la aplicación +settings-shortcut-leave-composer-for-newest-assistant-item = Salir del editor hacia la respuesta más reciente del asistente +settings-shortcut-composer-vim-last-insertion = Vim del editor: última inserción +settings-shortcut-leave-composer-for-left-region = Salir del editor hacia la región izquierda +settings-shortcut-move-from-composer-to-lower-region = Ir del editor a la región inferior +settings-shortcut-leave-composer-for-upper-region = Salir del editor hacia la región superior +settings-shortcut-move-from-composer-to-right-region = Ir del editor a la región derecha +settings-shortcut-move-left-h = Mover a la izquierda (h) +settings-shortcut-move-down-j = Mover abajo (j) +settings-shortcut-move-up-k = Mover arriba (k) +settings-shortcut-move-right-l = Mover a la derecha (l) +settings-shortcut-move-to-first-line = Ir a la primera línea +settings-shortcut-move-to-last-line = Ir a la última línea +settings-shortcut-move-left-arrow = Mover a la izquierda (flecha) +settings-shortcut-move-down-arrow = Mover abajo (flecha) +settings-shortcut-move-up-arrow = Mover arriba (flecha) +settings-shortcut-move-right-arrow = Mover a la derecha (flecha) +settings-shortcut-inner-text-object-insert = Objeto de texto interior / insertar +settings-shortcut-around-text-object-append = Objeto de texto alrededor / añadir +settings-shortcut-delete-operator = Operador de borrado +settings-shortcut-change-operator = Operador de cambio +settings-shortcut-yank-operator = Operador de copia +settings-shortcut-toggle-visual-mode = Alternar modo Visual +settings-shortcut-delete-characters = Borrar caracteres +settings-shortcut-paste-after = Pegar después +settings-shortcut-paste-before = Pegar antes +settings-shortcut-choose-question-option-1 = Elegir opción 1 de la pregunta +settings-shortcut-choose-question-option-2 = Elegir opción 2 de la pregunta +settings-shortcut-choose-question-option-3 = Elegir opción 3 de la pregunta +settings-shortcut-choose-question-option-4 = Elegir opción 4 de la pregunta +settings-shortcut-choose-question-option-5 = Elegir opción 5 de la pregunta +settings-shortcut-choose-question-option-6 = Elegir opción 6 de la pregunta +settings-shortcut-choose-question-option-7 = Elegir opción 7 de la pregunta +settings-shortcut-choose-question-option-8 = Elegir opción 8 de la pregunta +settings-shortcut-choose-question-option-9 = Elegir opción 9 de la pregunta +settings-shortcut-application-vim-count-0 = Vim de la aplicación: contador 0 +settings-shortcut-application-vim-count-1 = Vim de la aplicación: contador 1 +settings-shortcut-application-vim-count-2 = Vim de la aplicación: contador 2 +settings-shortcut-application-vim-count-3 = Vim de la aplicación: contador 3 +settings-shortcut-application-vim-count-4 = Vim de la aplicación: contador 4 +settings-shortcut-application-vim-count-5 = Vim de la aplicación: contador 5 +settings-shortcut-application-vim-count-6 = Vim de la aplicación: contador 6 +settings-shortcut-application-vim-count-7 = Vim de la aplicación: contador 7 +settings-shortcut-application-vim-count-8 = Vim de la aplicación: contador 8 +settings-shortcut-application-vim-count-9 = Vim de la aplicación: contador 9 +settings-shortcut-count-digit-0 = Dígito del contador 0 +settings-shortcut-count-digit-1 = Dígito del contador 1 +settings-shortcut-count-digit-2 = Dígito del contador 2 +settings-shortcut-count-digit-3 = Dígito del contador 3 +settings-shortcut-count-digit-4 = Dígito del contador 4 +settings-shortcut-count-digit-5 = Dígito del contador 5 +settings-shortcut-count-digit-6 = Dígito del contador 6 +settings-shortcut-count-digit-7 = Dígito del contador 7 +settings-shortcut-count-digit-8 = Dígito del contador 8 +settings-shortcut-count-digit-9 = Dígito del contador 9 +settings-shortcut-category-application = Aplicación +settings-shortcut-category-chat = Chat +settings-shortcut-category-transcript = Conversación +settings-shortcut-category-menus = Menús +settings-shortcut-category-text-editing = Edición de texto +settings-shortcut-category-composer-vim = Vim del editor +settings-shortcut-category-application-vim = Vim de la aplicación + +# Semantic runtime state presentation +settings-update-disabled = Las actualizaciones automáticas no están disponibles en esta compilación. +settings-update-idle = Aún no se ha comprobado +settings-update-checking = Buscando actualizaciones… +settings-update-current = Actualizado +settings-update-available = Actualización disponible +settings-update-downloading = Descargando actualización… +settings-update-ready = Listo para actualizar +settings-update-installing = Instalando actualización… +settings-update-restart-required = Es necesario reiniciar +settings-update-version-status = { $status } · v{ $version } +settings-update-download = Descargar actualización +settings-update-restart = Reiniciar para actualizar +settings-update-relaunch = Reiniciar Maple +settings-account-anonymous = Anónimo +settings-permission-accessibility = Accesibilidad +settings-permission-screen-recording = Grabación de pantalla +settings-permission-desktop-helper = Extensión auxiliar de GNOME +settings-permission-granted = { $permission }: activado +settings-permission-needed = { $permission }: necesario +settings-integration-cua-name = Uso del equipo +settings-integration-cua-description = Permite que Maple vea tu pantalla y use las aplicaciones de este equipo por ti: hacer clic, escribir y desplazarse. +settings-integration-codex-description = Permite que una tarea delegue trabajo a la CLI de Codex instalada en este equipo, con su propia cuenta. +settings-integration-claude-description = Permite que una tarea delegue trabajo a la CLI de Claude Code instalada en este equipo, con su propia cuenta. +settings-billing-renews = Se renueva el { $date } +settings-billing-expires = Caduca el { $date } +settings-billing-month = mes +settings-billing-year = año +settings-billing-week = semana +settings-billing-day = día +settings-billing-price-period = { $amount }/{ $period } +settings-navigation-no-region = No hay ninguna región de Ajustes en esa dirección +settings-navigation-unavailable = Ese comando del Vim de la aplicación no está disponible en este control de Ajustes +settings-navigation-count-limit = El contador del Vim de la aplicación está limitado a 999999 +settings-month-january = enero +settings-month-february = febrero +settings-month-march = marzo +settings-month-april = abril +settings-month-may = mayo +settings-month-june = junio +settings-month-july = julio +settings-month-august = agosto +settings-month-september = septiembre +settings-month-october = octubre +settings-month-november = noviembre +settings-month-december = diciembre +settings-date = { $day } de { $month } de { $year } +settings-plan-name = Plan { $name } +settings-reset-monthly = mensualmente +settings-reset-soon = en menos de 1 hora +settings-reset-tomorrow = Mañana +settings-reset-hours = { $count -> + [one] en { $count } hora + *[other] en { $count } horas + } +settings-reset-days = { $count -> + [one] en { $count } día + *[other] en { $count } días + } +settings-remote-blocked-password-protected = Sus datos están protegidos con contraseña. Actualízalo allí. +settings-remote-blocked-unsupported-installation = Esta instalación solo se actualiza desde ese equipo. +settings-remote-blocked-updates-disabled = Las actualizaciones están desactivadas en esta compilación. +settings-remote-blocked-update-not-ready = La actualización aún no está lista allí. +settings-remote-blocked-busy = Está ocupado. Inténtalo de nuevo en un momento. +settings-remote-blocked-desktop-unavailable = Abre Maple en ese equipo para actualizarlo. +settings-remote-blocked-coordinator-unavailable = Actualízalo desde ese equipo. +settings-remote-blocked-pairing-confirmation-required = Vuelve a emparejarlo para actualizarlo de forma remota. +settings-remote-blocked-unknown = No se puede actualizar de forma remota. +settings-remote-refused-authorization-changed = No se actualizó. El emparejamiento cambió. Vuelve a comprobarlo o a emparejarlo. +settings-remote-refused-update-changed = No se actualizó. La actualización cambió. Vuelve a comprobarlo. +settings-remote-refused-not-ready = No se actualizó. No estaba listo. Vuelve a comprobarlo. +settings-remote-refused-unsupported = No se actualizó. Esta versión no permite actualizaciones remotas. Actualízalo allí. +settings-remote-refused-account-changed = No se actualizó. La cuenta cambió. Vuelve a abrir Ajustes. +settings-remote-refused-busy = No se actualizó. Estaba ocupado. Vuelve a comprobarlo en un momento. +settings-remote-refused-unavailable = No se actualizó. No aceptó la solicitud. Vuelve a comprobarlo. +settings-remote-refused-download-required = No se actualizó. Primero debe descargar la actualización. Activa las descargas automáticas allí o actualízalo desde ese equipo. +settings-permission-guidance-accessibility = Accesibilidad: concede este acceso a Maple en Ajustes del Sistema. Después cierra Maple por completo y vuelve a abrirlo. +settings-permission-guidance-screen-recording = Grabación de pantalla: concede este acceso a Maple en Ajustes del Sistema. Después cierra Maple por completo y vuelve a abrirlo. +settings-credits-whole-dollars = Introduce un importe en dólares enteros +settings-credits-minimum = El mínimo es de ${ $amount } +settings-credits-maximum = El máximo es de ${ $amount } +settings-password-progress-estimate = { $count -> + [one] Queda aproximadamente { $count } minuto. + *[other] Quedan aproximadamente { $count } minutos. + } +settings-password-progress-bytes = Se han copiado aproximadamente { $done } de { $total }. +settings-password-progress-committing = Terminando el cambio. Mantén Maple abierto. +settings-account-password-minimum = Usa al menos { $count } caracteres para la nueva contraseña +settings-password-unconfirmed-attempt = { $message } Conserva esta frase de contraseña. Maple no pudo confirmar el intento anterior. Vuelve a abrir Maple para comprobar el almacén antes de intentarlo de nuevo. + +# Shared clipboard controls used by Settings +common-copy = Copiar +common-copied = Copiado diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index 724e652fe..e009085eb 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -2204,7 +2204,8 @@ impl LocalBackend { if !credits.is_multiple_of(crate::billing::CREDITS_PER_DOLLAR) { return Err("Buy credits in whole dollars".to_string()); } - crate::billing::credits_for_dollars(credits / crate::billing::CREDITS_PER_DOLLAR)?; + crate::billing::credits_for_dollars(credits / crate::billing::CREDITS_PER_DOLLAR) + .map_err(|error| error.to_string())?; let account = self.account(user_id).await?; if method == PaymentMethod::Stripe && account.login_method == MapleLoginMethod::Guest { return Err("Anonymous accounts pay with Bitcoin".to_string()); diff --git a/apps/maple-agent/app/src/billing.rs b/apps/maple-agent/app/src/billing.rs index 5c308c716..3e02d2eee 100644 --- a/apps/maple-agent/app/src/billing.rs +++ b/apps/maple-agent/app/src/billing.rs @@ -109,15 +109,33 @@ fn names_free_plan(status: &BillingStatus) -> bool { /// Preset credit purchases in whole dollars, as on the web. pub const CREDIT_PACKAGES_DOLLARS: [u64; 4] = [20, 50, 100, 500]; +/// Validation remains shared with the host; the UI formats these semantic errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreditAmountError { + BelowMinimum, + AboveMaximum, + WholeDollarsRequired, +} +impl std::fmt::Display for CreditAmountError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BelowMinimum => write!(f, "The minimum is ${MIN_CREDIT_PURCHASE_DOLLARS}"), + Self::AboveMaximum => write!( + f, + "The maximum is ${}", + format_count(MAX_CREDIT_PURCHASE_DOLLARS as i64) + ), + Self::WholeDollarsRequired => f.write_str("Enter a whole-dollar amount"), + } + } +} + /// Credits bought by a whole-dollar amount, or why the amount is refused. -pub fn credits_for_dollars(dollars: u64) -> Result { +pub fn credits_for_dollars(dollars: u64) -> Result { if dollars < MIN_CREDIT_PURCHASE_DOLLARS { - Err(format!("The minimum is ${MIN_CREDIT_PURCHASE_DOLLARS}")) + Err(CreditAmountError::BelowMinimum) } else if dollars > MAX_CREDIT_PURCHASE_DOLLARS { - Err(format!( - "The maximum is ${}", - format_count(MAX_CREDIT_PURCHASE_DOLLARS as i64) - )) + Err(CreditAmountError::AboveMaximum) } else { Ok(dollars * CREDITS_PER_DOLLAR) } @@ -125,7 +143,7 @@ pub fn credits_for_dollars(dollars: u64) -> Result { /// Whole dollars typed for a custom credit purchase, with an optional `$`. /// None while the field is empty. -pub fn parse_custom_dollars(text: &str) -> Option> { +pub fn parse_custom_dollars(text: &str) -> Option> { let text = text.trim(); let digits = text.strip_prefix('$').unwrap_or(text).trim(); if digits.is_empty() { @@ -135,7 +153,7 @@ pub fn parse_custom_dollars(text: &str) -> Option> { // Too many digits to parse is over the maximum either way. credits_for_dollars(digits.parse().unwrap_or(u64::MAX)) } else { - Err("Enter a whole-dollar amount".to_string()) + Err(CreditAmountError::WholeDollarsRequired) }) } @@ -144,7 +162,6 @@ pub fn parse_custom_dollars(text: &str) -> Option> { pub trait BillingStatusExt { fn tier(&self) -> PlanTier; fn has_portal(&self) -> bool; - fn period_end_label(&self) -> Option; } impl BillingStatusExt for BillingStatus { @@ -163,19 +180,6 @@ impl BillingStatusExt for BillingStatus { Some("zaprite") | Some("subscription_pass") ) } - - /// "Renews September 30, 2026" or "Expires …" for passes and Bitcoin - /// subscriptions, which do not auto-renew; None without a period end. - fn period_end_label(&self) -> Option { - let end = chrono::DateTime::from_timestamp(self.current_period_end?, 0)? - .with_timezone(&chrono::Local) - .format("%B %-d, %Y"); - let verb = match self.payment_provider.as_deref() { - Some("zaprite") | Some("subscription_pass") => "Expires", - _ => "Renews", - }; - Some(format!("{verb} {end}")) - } } /// "100,000" with thousands separators, for exact balances. @@ -235,6 +239,11 @@ pub fn configured_billing_api_url() -> String { /// quota used in the current period, and the date the period resets. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PlanUsage { + /// Semantic presentation inputs; optional for older hosts. + #[serde(default)] + pub product_name: Option, + #[serde(default)] + pub reset_at: Option, pub plan_label: String, pub percent_used: u8, pub resets_label: String, @@ -251,6 +260,8 @@ impl PlanUsage { let used = status.used_tokens?.max(0); let percent = ((used as f64 / total as f64) * 100.0).clamp(0.0, 100.0); Some(Self { + product_name: status.product_name.clone(), + reset_at: status.usage_reset_date.clone(), plan_label: plan_name_label(status.product_name.as_deref()), percent_used: percent.round() as u8, resets_label: reset_label(status.usage_reset_date.as_deref(), now), @@ -437,19 +448,6 @@ mod tests { assert!(!status.has_portal()); } - #[test] - fn period_end_label_names_renewal_or_expiry() { - let mut status = BillingStatus { - current_period_end: Some(1_790_000_000), - ..BillingStatus::default() - }; - assert!(status.period_end_label().unwrap().starts_with("Renews ")); - status.payment_provider = Some("subscription_pass".into()); - assert!(status.period_end_label().unwrap().starts_with("Expires ")); - status.current_period_end = None; - assert_eq!(status.period_end_label(), None); - } - #[test] fn counts_get_thousands_separators() { assert_eq!(format_count(0), "0"); @@ -545,4 +543,24 @@ mod tests { s.used_tokens = None; assert!(PlanUsage::from_status(&s, now()).is_none()); } + #[test] + fn plan_usage_accepts_older_hosts_without_semantic_display_fields() { + let plan: PlanUsage = serde_json::from_value(serde_json::json!({ + "plan_label": "Pro Plan", "percent_used": 25, "resets_label": "Tomorrow" + })) + .unwrap(); + assert_eq!(plan.percent_used, 25); + assert!(plan.product_name.is_none()); + assert!(plan.reset_at.is_none()); + let status = BillingStatus { + product_name: Some("Max".into()), + total_tokens: Some(100), + used_tokens: Some(25), + usage_reset_date: Some("2027-01-02T00:00:00Z".into()), + ..Default::default() + }; + let plan = PlanUsage::from_status(&status, chrono::Local::now()).unwrap(); + assert_eq!(plan.product_name, status.product_name); + assert_eq!(plan.reset_at, status.usage_reset_date); + } } diff --git a/apps/maple-agent/app/src/remote/update.rs b/apps/maple-agent/app/src/remote/update.rs index fa417831c..ae4309b1a 100644 --- a/apps/maple-agent/app/src/remote/update.rs +++ b/apps/maple-agent/app/src/remote/update.rs @@ -148,23 +148,6 @@ pub enum BlockedReason { Unknown, } -impl BlockedReason { - #[cfg(feature = "desktop")] - pub fn message(self) -> &'static str { - match self { - Self::PasswordProtected => "Its data is password protected. Update it there.", - Self::UnsupportedInstallation => "This installation updates only on that computer.", - Self::UpdatesDisabled => "Updates are off for this build.", - Self::UpdateNotReady => "The update isn’t ready there yet.", - Self::Busy => "It’s busy. Try again shortly.", - Self::DesktopUnavailable => "Open Maple on that computer to update it.", - Self::CoordinatorUnavailable => "Update it on that computer.", - Self::PairingConfirmationRequired => "Pair it again to update it remotely.", - Self::Unknown => "It can’t be updated remotely.", - } - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OperationState { @@ -376,24 +359,6 @@ pub enum InstallRefusal { DownloadRequired, } -impl InstallRefusal { - #[cfg(feature = "desktop")] - pub fn message(self) -> &'static str { - match self { - Self::AuthorizationChanged => "The pairing changed. Check again, or pair it again.", - Self::UpdateChanged => "The update changed. Check again.", - Self::NotReady => "It wasn’t ready. Check again.", - Self::Unsupported => "This version can’t update remotely. Update it there.", - Self::AccountChanged => "The account changed. Reopen Settings.", - Self::Busy => "It was busy. Check again shortly.", - Self::Unavailable => "It didn’t accept the request. Check again.", - Self::DownloadRequired => { - "It must download the update first. Turn on automatic downloads there, or update it there." - } - } - } -} - #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct InstallCommand { diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 93479aeab..e2c86cbe7 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -7799,6 +7799,8 @@ mod state_tests { for plan in [ None, Some(crate::billing::PlanUsage { + product_name: None, + reset_at: None, plan_label: "Pro".into(), percent_used: 42, resets_label: "tomorrow".into(), diff --git a/apps/maple-agent/app/src/ui/i18n.rs b/apps/maple-agent/app/src/ui/i18n.rs index d0bd853bd..e903d0614 100644 --- a/apps/maple-agent/app/src/ui/i18n.rs +++ b/apps/maple-agent/app/src/ui/i18n.rs @@ -141,7 +141,6 @@ pub(crate) fn apply_preference(preference: Option<&str>) { }); } -#[cfg(any(target_os = "linux", test))] pub(crate) fn current_locale() -> LanguageIdentifier { ACTIVE.with_borrow(|state| state.locale.clone()) } @@ -187,6 +186,169 @@ pub(crate) fn tr_args<'a>( ACTIVE.with_borrow(|state| lookup(&state.locale, key, &args).into()) } +type MessageArgs = Vec<(&'static str, FluentValue<'static>)>; +type CachedMessage = (MessageArgs, SharedString); +type ResolvedMessage = Option<(LanguageIdentifier, SharedString)>; + +/// View-owned cache for parameterized labels. Values are bounded per message, +/// discarded with the view, and invalidated together on a language change. +#[derive(Default)] +pub(crate) struct MessageCache(RefCell); + +#[derive(Default)] +struct MessageCacheState { + locale: Option, + messages: HashMap<&'static str, Vec>, + computed: HashMap<&'static str, Vec<(String, SharedString)>>, +} + +impl MessageCache { + /// Cache derived presentation before parsing dates or other raw host fields. + pub(crate) fn computed( + &self, + key: &'static str, + input: &str, + compute: impl FnOnce() -> SharedString, + ) -> SharedString { + let locale = current_locale(); + { + let mut cache = self.0.borrow_mut(); + if cache.locale.as_ref() != Some(&locale) { + cache.messages.clear(); + cache.computed.clear(); + cache.locale = Some(locale); + } + if let Some((_, text)) = cache + .computed + .get(key) + .and_then(|entries| entries.iter().find(|(previous, _)| previous == input)) + { + return text.clone(); + } + } + // The callback may resolve parameterized messages in this same cache. + let text = compute(); + let mut cache = self.0.borrow_mut(); + let entries = cache.computed.entry(key).or_default(); + if entries.len() == 512 { + entries.remove(0); + } + entries.push((input.to_owned(), text.clone())); + text + } + + pub(crate) fn get( + &self, + key: &'static str, + args: impl IntoIterator)>, + ) -> SharedString { + let args: Vec<_> = args.into_iter().collect(); + let locale = current_locale(); + let mut cache = self.0.borrow_mut(); + if cache.locale.as_ref() != Some(&locale) { + cache.messages.clear(); + cache.computed.clear(); + cache.locale = Some(locale); + } + let entries = cache.messages.entry(key).or_default(); + if let Some((_, text)) = entries.iter().find(|(previous, _)| previous == &args) { + return text.clone(); + } + let text = tr_args(key, args.clone()); + // A page may show several rows using the same message. Bound retention + // even when a long-lived view displays many different names or counts. + if entries.len() == 512 { + entries.remove(0); + } + entries.push((args, text.clone())); + text + } +} + +/// A notice retains its semantic message so switching languages does not +/// leave old success or validation copy on a settings page. Host errors stay raw. +#[derive(Clone, Debug)] +pub(crate) enum UiText { + Raw(String), + Message { + key: &'static str, + args: MessageArgs, + cached: std::rc::Rc>, + }, +} +impl PartialEq for UiText { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Raw(a), Self::Raw(b)) => a == b, + ( + Self::Message { + key: a, args: aa, .. + }, + Self::Message { + key: b, args: ba, .. + }, + ) => a == b && aa == ba, + _ => false, + } + } +} +impl UiText { + pub(crate) fn key(key: &'static str) -> Self { + Self::message(key, []) + } + pub(crate) fn message( + key: &'static str, + args: impl IntoIterator)>, + ) -> Self { + Self::Message { + key, + args: args.into_iter().collect(), + cached: Default::default(), + } + } + pub(crate) fn resolve(&self) -> SharedString { + match self { + Self::Raw(value) => value.clone().into(), + Self::Message { key, args, cached } => { + let locale = current_locale(); + if let Some((previous, text)) = &*cached.borrow() + && previous == &locale + { + return text.clone(); + } + let text = if args.is_empty() { + tr(key) + } else { + tr_args(key, args.clone()) + }; + *cached.borrow_mut() = Some((locale, text.clone())); + text + } + } + } +} +impl From for UiText { + fn from(value: String) -> Self { + Self::Raw(value) + } +} +impl From<&str> for UiText { + fn from(value: &str) -> Self { + Self::Raw(value.to_owned()) + } +} +impl std::fmt::Display for UiText { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.resolve().fmt(f) + } +} +impl gpui::IntoElement for UiText { + type Element = ::Element; + fn into_element(self) -> Self::Element { + self.resolve().into_element() + } +} + fn lookup(locale: &LanguageIdentifier, key: &str, args: &HashMap<&str, FluentValue<'_>>) -> String { lookup_from(&CATALOGS, locale, key, args) } diff --git a/apps/maple-agent/app/src/ui/i18n/tests.rs b/apps/maple-agent/app/src/ui/i18n/tests.rs index 2b6d90e87..620ef6c1c 100644 --- a/apps/maple-agent/app/src/ui/i18n/tests.rs +++ b/apps/maple-agent/app/src/ui/i18n/tests.rs @@ -234,15 +234,18 @@ impl<'ast> Visit<'ast> for UiStrings { fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { if let syn::Expr::Path(path) = &*node.func - && path - .path - .segments - .last() - .is_some_and(|segment| segment.ident == "tr" || segment.ident == "tr_args") + && path.path.segments.last().is_some_and(|segment| { + segment.ident == "tr" + || segment.ident == "tr_args" + || segment.ident == "localized" + || segment.ident == "key" + || segment.ident == "message" + }) && let Some(syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(key), .. })) = node.args.first() + && (key.value().starts_with("settings-") || key.value().starts_with("update-")) { self.keys.insert(key.value()); } @@ -250,6 +253,15 @@ impl<'ast> Visit<'ast> for UiStrings { } fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + if node.method == "get" + && let Some(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(key), + .. + })) = node.args.first() + && key.value().starts_with("settings-") + { + self.keys.insert(key.value()); + } if ["child", "aria_label", "tooltip", "placeholder"] .iter() .any(|name| node.method == name) @@ -257,6 +269,7 @@ impl<'ast> Visit<'ast> for UiStrings { lit: syn::Lit::Str(text), .. })) = node.args.first() + && text.value().chars().any(char::is_alphabetic) { self.literals.push(text.value()); } @@ -298,6 +311,15 @@ fn converted_modules_do_not_reintroduce_inline_labels() { let mut visitor = UiStrings::default(); visitor.visit_file(&syn::parse_file(include_str!("../update_confirmation.rs")).unwrap()); visitor.visit_file(&syn::parse_file(include_str!("../settings/general.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/account.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/advanced.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/api_credits.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/api_keys.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/billing.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/local_api.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../settings/mcp.rs")).unwrap()); + visitor.visit_file(&syn::parse_file(include_str!("../remote.rs")).unwrap()); assert!( visitor.literals.is_empty(), "inline UI text: {:?}", @@ -341,3 +363,96 @@ fn live_switch_invalidates_plain_and_system_labels() { assert!(preference_label(None).starts_with(system)); } } + +#[test] +fn cached_messages_and_notices_follow_locale_without_translating_raw_text() { + let _locale = TestLocalizationGuard::enable(); + let cache = MessageCache::default(); + let notice = UiText::key("settings-account-password-changed"); + let raw = UiText::from("A server-owned error".to_string()); + for (locale, singular, plural, success) in [ + ( + "en", + "active request", + "active requests", + "Password changed.", + ), + ( + "es", + "solicitud activa", + "solicitudes activas", + "Contraseña cambiada.", + ), + ( + "en", + "active request", + "active requests", + "Password changed.", + ), + ] { + apply_preference(Some(locale)); + assert!( + cache + .get("settings-local-api-requests", [("count", 1.into())]) + .ends_with(singular) + ); + assert!( + cache + .get("settings-local-api-requests", [("count", 2.into())]) + .ends_with(plural) + ); + assert_eq!(notice.resolve().as_ref(), success); + assert_eq!(raw.resolve().as_ref(), "A server-owned error"); + } +} + +#[test] +fn parameterized_catalogs_format_all_messages_without_missing_arguments() { + // The union of argument names allows every translated pattern to be + // exercised, including plural branches, without copying a message list. + let english = include_str!("../../../locales/en/app.ftl"); + let spanish = include_str!("../../../locales/es/app.ftl"); + let names: BTreeSet<_> = [english, spanish] + .into_iter() + .flat_map(|source| { + source + .split('$') + .skip(1) + .map(|tail| { + tail.chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '-') + .collect::() + }) + .collect::>() + }) + .collect(); + for (locale, source) in [(langid!("en"), english), (langid!("es"), spanish)] { + let mut bundle = FluentBundle::new(vec![locale]); + bundle + .add_resource(FluentResource::try_new(source.to_string()).unwrap()) + .unwrap(); + for count in [0, 1, 2] { + let mut args = FluentArgs::new(); + for name in &names { + args.set( + name.as_str(), + if name == "count" { + FluentValue::from(count) + } else { + FluentValue::from("sample") + }, + ); + } + for key in catalog_keys(source) + .into_iter() + .filter(|key| !key.contains('.') && !key.starts_with('-')) + { + let pattern = bundle.get_message(&key).unwrap().value().unwrap(); + let mut errors = Vec::new(); + let text = bundle.format_pattern(pattern, Some(&args), &mut errors); + assert!(errors.is_empty(), "unformattable message: {key}"); + assert!(!text.trim().is_empty(), "empty message: {key}"); + } + } + } +} diff --git a/apps/maple-agent/app/src/ui/remote.rs b/apps/maple-agent/app/src/ui/remote.rs index 50667d661..5275e7501 100644 --- a/apps/maple-agent/app/src/ui/remote.rs +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -1,5 +1,6 @@ //! Device setup and explicitly targeted remote update controls. Pairing codes //! and local account settings never traverse the remote task protocol. +use crate::ui::i18n::{UiText, tr, tr_args}; use crate::{ backend::AgentBackend, remote::{ @@ -28,6 +29,7 @@ use std::{ time::{Duration, Instant}, }; +mod presentation; #[cfg(test)] mod tests; @@ -41,7 +43,14 @@ pub(crate) fn computer_name(key: &str, advertised: &str, alias: &str) -> String if !alias.trim().is_empty() { alias.to_string() } else if advertised == "Maple computer" { - format!("Computer {}", key.chars().take(8).collect::()) + tr_args( + "settings-computer-name", + [( + "arg1", + (key.chars().take(8).collect::()).to_string().into(), + )], + ) + .to_string() } else { advertised.to_string() } @@ -93,13 +102,13 @@ where PeerInfoQuery::Read => serde_json::from_value(value), PeerInfoQuery::Check => serde_json::from_value(value).map(Some), } - .map_err(|_| "Could not read remote update status".to_owned()) + .map_err(|_| "Could not read remote update status".to_string()) } struct InstallAttempt { request: InstallRequest, phase: InstallPhase, - label: String, + label: UiText, /// Recovered from the host's receipt rather than sent from this view. from_receipt: bool, } @@ -175,8 +184,9 @@ struct InstallIntent { } /// A lost reply may already have taken effect: never resend it blindly. -const UNCONFIRMED_INSTALL: &str = "Not confirmed. Don’t resend; check again first."; -const PROTOCOL_UPDATE_REQUIRED: &str = "Update Maple on both computers to reconnect."; +const UNCONFIRMED_INSTALL: &str = "settings-remote-not-confirmed-don-t-resend-check-again-first"; +const PROTOCOL_UPDATE_REQUIRED: &str = + "settings-remote-update-maple-on-both-computers-to-reconnect"; const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// A quiet note when the two computers run different app versions. Equal @@ -184,31 +194,37 @@ const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); fn version_note(info: &PeerInfo, client_version: &str) -> Option { let client = semver::Version::parse(client_version).ok()?; let remote = semver::Version::parse(&info.running.version).ok()?; - (client != remote).then(|| format!("This computer runs v{client}.")) + (client != remote).then(|| { + tr_args( + "settings-remote-client-version", + [("client", (client).to_string().into())], + ) + .to_string() + }) } fn install_blocked(info: &PeerInfo) -> Option { if !info.supports_remote_updates() { - return Some("This version can’t be updated remotely.".into()); + return Some(tr("settings-remote-this-version-can-t-be-updated-remotely").into()); } // An older host reports an offer it has not downloaded as not ready. It // is still asked: it refuses before admitting work, and the reply says so. if let Some(reason) = &info.update.blocked && !info.install_requestable() { - return Some(reason.message().into()); + return Some(presentation::blocked(*reason).to_string()); } if operation_pending(info) { - return Some("An update request is already in progress there.".into()); + return Some(tr("settings-remote-an-update-request-is-already-in-progress-there").into()); } if !info.install_requestable() { - return Some("The update isn’t ready there yet.".into()); + return Some(tr("settings-remote-the-update-isn-t-ready-there-yet").into()); } let Some(target) = &info.update.offered else { - return Some("No update is offered there.".into()); + return Some(tr("settings-remote-no-update-is-offered-there").into()); }; if info.running.matches_release(target) { - return Some("It already runs this version.".into()); + return Some(tr("settings-remote-it-already-runs-this-version").into()); } None } @@ -239,22 +255,24 @@ impl ComputerUpdate { let checking = self.query == PeerInfoQuery::Check; let Some(info) = &self.info else { self.status_line = match self.phase { - PeerInfoPhase::Loading if checking => "Checking for updates…", - PeerInfoPhase::Loading => "Checking status…", - PeerInfoPhase::Unsupported | PeerInfoPhase::Unchecked => "Version unknown", - PeerInfoPhase::Unreachable => "Version unknown", - PeerInfoPhase::Incompatible => "Different remote protocol", - PeerInfoPhase::Loaded => "Version unknown", + PeerInfoPhase::Loading if checking => tr("settings-remote-checking-for-updates"), + PeerInfoPhase::Loading => tr("settings-remote-checking-status"), + PeerInfoPhase::Unsupported | PeerInfoPhase::Unchecked => { + tr("settings-remote-version-unknown") + } + PeerInfoPhase::Unreachable => tr("settings-remote-version-unknown"), + PeerInfoPhase::Incompatible => tr("settings-remote-different-remote-protocol"), + PeerInfoPhase::Loaded => tr("settings-remote-version-unknown"), } .into(); match self.phase { PeerInfoPhase::Unreachable => { - self.badge = Some(("Not reachable", Tone::Muted)); + self.badge = Some(("settings-remote-not-reachable", Tone::Muted)); self.action = CardAction::CheckAgain; } PeerInfoPhase::Incompatible => { - self.badge = Some(("Update needed", Tone::Warning)); - self.note = Some((PROTOCOL_UPDATE_REQUIRED.into(), Tone::Muted)); + self.badge = Some(("settings-remote-update-needed", Tone::Warning)); + self.note = Some((tr(PROTOCOL_UPDATE_REQUIRED).to_string(), Tone::Muted)); } _ => {} } @@ -263,25 +281,39 @@ impl ComputerUpdate { let running = version_label(&info.running.version); let offered = info.update.offered.as_ref(); if loading { - self.status_line = format!( - "{running} · {}", - if checking { - "checking for updates…" - } else { - "checking status…" - } - ); + self.status_line = tr_args( + "settings-remote-checking", + [ + ("running", (running).to_string().into()), + ( + "arg1", + (if checking { + tr("settings-remote-checking-for-updates-more") + } else { + tr("settings-remote-checking-status-more") + }) + .to_string() + .into(), + ), + ], + ) + .to_string(); return; } // Cached observations stay as history only: they never look like a // current offer, policy decision or version comparison. match self.phase { PeerInfoPhase::Unreachable => { - self.status_line = format!("{running} when last seen"); - self.badge = Some(("Not reachable", Tone::Muted)); + self.status_line = tr_args( + "settings-remote-last-seen", + [("running", (running).to_string().into())], + ) + .to_string(); + self.badge = Some(("settings-remote-not-reachable", Tone::Muted)); if checking && !info.supports_remote_refresh() { self.note = Some(( - "This version checks for updates only on that computer.".into(), + tr("settings-remote-this-version-checks-for-updates-only-on-that-computer") + .into(), Tone::Muted, )); } @@ -290,13 +322,17 @@ impl ComputerUpdate { } PeerInfoPhase::Incompatible => { self.status_line = running; - self.badge = Some(("Update needed", Tone::Warning)); - self.note = Some((PROTOCOL_UPDATE_REQUIRED.into(), Tone::Muted)); + self.badge = Some(("settings-remote-update-needed", Tone::Warning)); + self.note = Some((tr(PROTOCOL_UPDATE_REQUIRED).to_string(), Tone::Muted)); return; } PeerInfoPhase::Loaded => {} _ => { - self.status_line = format!("{running} when last seen"); + self.status_line = tr_args( + "settings-remote-last-seen", + [("running", (running).to_string().into())], + ) + .to_string(); return; } } @@ -305,29 +341,68 @@ impl ComputerUpdate { && matches!(info.update.state, UpdateState::Current | UpdateState::Idle); let target = offered.map(|target| version_label(&target.version)); let (status_line, badge) = match (info.update.state, target.as_deref()) { - _ if up_to_date && info.update.state == UpdateState::Current => { - (running.clone(), Some(("Up to date", Tone::Success))) - } + _ if up_to_date && info.update.state == UpdateState::Current => ( + running.clone(), + Some(("settings-remote-up-to-date", Tone::Success)), + ), // The Update button already says so; the line names the versions. - (UpdateState::Available | UpdateState::Ready, Some(target)) => { - (format!("{running} · {target} available"), None) - } - (UpdateState::Downloading, Some(target)) => { - (format!("{running} · downloading {target}…"), None) - } - (UpdateState::Downloading, None) => (format!("{running} · downloading…"), None), - (UpdateState::Checking, _) => (format!("{running} · checking for updates…"), None), + (UpdateState::Available | UpdateState::Ready, Some(target)) => ( + tr_args( + "settings-remote-update-available", + [ + ("running", (running).to_string().into()), + ("target", (target).to_string().into()), + ], + ) + .to_string(), + None, + ), + (UpdateState::Downloading, Some(target)) => ( + tr_args( + "settings-remote-downloading-version", + [ + ("running", (running).to_string().into()), + ("target", (target).to_string().into()), + ], + ) + .to_string(), + None, + ), + (UpdateState::Downloading, None) => ( + tr_args( + "settings-remote-downloading", + [("running", (running).to_string().into())], + ) + .to_string(), + None, + ), + (UpdateState::Checking, _) => ( + tr_args( + "settings-remote-checking-updates", + [("running", (running).to_string().into())], + ) + .to_string(), + None, + ), (UpdateState::Installing, _) => ( - format!("{running} · updating…"), - Some(("Updating", Tone::Accent)), + tr_args( + "settings-remote-updating-status", + [("running", (running).to_string().into())], + ) + .to_string(), + Some(("settings-remote-updating", Tone::Accent)), ), (UpdateState::RestartRequired, _) => ( - format!("{running} · restart needed there"), - Some(("Restart needed", Tone::Warning)), + tr_args( + "settings-remote-restart-needed-status", + [("running", (running).to_string().into())], + ) + .to_string(), + Some(("settings-remote-restart-needed", Tone::Warning)), ), (UpdateState::Error, _) => ( running.clone(), - Some(("Updater needs attention", Tone::Warning)), + Some(("settings-remote-updater-needs-attention", Tone::Warning)), ), _ => (running.clone(), None), }; @@ -358,17 +433,28 @@ impl ComputerUpdate { let target = version_label(&attempt.request.target.version); match attempt.phase { InstallPhase::Sending => { - self.status_line = format!("{running} · sending update…"); - self.badge = Some(("Updating", Tone::Accent)); + self.status_line = tr_args( + "settings-remote-sending-update", + [("running", (running).to_string().into())], + ) + .to_string(); + self.badge = Some(("settings-remote-updating", Tone::Accent)); } InstallPhase::Accepted => { - self.status_line = format!("{running} · updating to {target}…"); - self.badge = Some(("Updating", Tone::Accent)); - self.note = Some((attempt.label.clone(), Tone::Muted)); + self.status_line = tr_args( + "settings-remote-updating-version", + [ + ("running", (running).to_string().into()), + ("target", (target).to_string().into()), + ], + ) + .to_string(); + self.badge = Some(("settings-remote-updating", Tone::Accent)); + self.note = Some((attempt.label.to_string(), Tone::Muted)); } InstallPhase::Uncertain => { - self.badge = Some(("Unconfirmed", Tone::Warning)); - self.note = Some((attempt.label.clone(), Tone::Warning)); + self.badge = Some(("settings-remote-unconfirmed", Tone::Warning)); + self.note = Some((attempt.label.to_string(), Tone::Warning)); self.action = CardAction::CheckAgain; } // This view's own declined request explains itself until a @@ -379,7 +465,7 @@ impl ComputerUpdate { || (offered == Some(&attempt.request.target) && !info.running.matches_release(&attempt.request.target)) => { - self.note = Some((attempt.label.clone(), Tone::Error)); + self.note = Some((attempt.label.to_string(), Tone::Error)); } // A past success is history once its target runs: say nothing. InstallPhase::Succeeded | InstallPhase::Failed => {} @@ -406,7 +492,9 @@ impl ComputerUpdate { // together: an offered update or one in progress says enough. if self.note.is_none() && self.action == CardAction::None - && self.badge.is_none_or(|(label, _)| label == "Up to date") + && self + .badge + .is_none_or(|(label, _)| label == "settings-remote-up-to-date") && offered.is_none() && let Some(note) = version_note(info, CLIENT_VERSION) { @@ -429,7 +517,7 @@ impl ComputerUpdate { target: operation.target.clone(), }, phase: InstallPhase::Uncertain, - label: UNCONFIRMED_INSTALL.into(), + label: UiText::key(UNCONFIRMED_INSTALL), from_receipt: true, }); } @@ -459,28 +547,38 @@ impl ComputerUpdate { match operation.state { OperationState::PendingAck => { attempt.phase = InstallPhase::Accepted; - attempt.label = "Waiting for it to acknowledge. Nothing has started.".into(); + attempt.label = UiText::key( + "settings-remote-waiting-for-it-to-acknowledge-nothing-has-started", + ); } OperationState::Queued | OperationState::Installing => { attempt.phase = InstallPhase::Accepted; - attempt.label = ACCEPTED_INSTALL.into(); + attempt.label = UiText::key(ACCEPTED_INSTALL); } OperationState::Succeeded => { attempt.phase = InstallPhase::Succeeded; - attempt.label = format!("Updated to {target}."); + attempt.label = UiText::message( + "settings-remote-updated", + [("target", (target).to_string().into())], + ); } OperationState::Failed => { attempt.phase = InstallPhase::Failed; - attempt.label = format!("The update to {target} didn’t finish."); + attempt.label = UiText::message( + "settings-remote-update-incomplete", + [("target", (target).to_string().into())], + ); } OperationState::Unknown => {} } } } -const ACCEPTED_INSTALL: &str = "It restarts when done. Check again once it’s back."; +const ACCEPTED_INSTALL: &str = "settings-remote-it-restarts-when-done-check-again-once-it-s-back"; pub struct RemoteScreen { + locale: fluent_templates::LanguageIdentifier, + messages: crate::ui::i18n::MessageCache, backend: Arc, account: String, active: bool, @@ -493,7 +591,7 @@ pub struct RemoteScreen { local_name_input: Option>, local_code: Option<(String, Instant)>, error: Option, - notice: Option, + notice: Option, busy: Option<&'static str>, computer_updates: HashMap, next_update_generation: u64, @@ -506,6 +604,19 @@ impl EventEmitter for RemoteScreen {} impl EventEmitter for RemoteScreen {} impl RemoteScreen { + fn display_name(&self, key: &str, advertised: &str, alias: &str) -> String { + if alias.trim().is_empty() && advertised == "Maple computer" { + self.messages + .get( + "settings-computer-name", + [("arg1", key.chars().take(8).collect::().into())], + ) + .to_string() + } else { + computer_name(key, advertised, alias) + } + } + pub fn new( backend: Arc, account: String, @@ -515,7 +626,7 @@ impl RemoteScreen { ) -> Self { let weak = cx.entity().downgrade(); let code_input = cx.new(|cx| { - TextInput::new("Eight-character pairing code", cx) + TextInput::localized("settings-remote-eight-character-pairing-code", cx) .pairing_code() .on_enter(move |_, _, cx| { let weak = weak.clone(); @@ -526,6 +637,8 @@ impl RemoteScreen { }) }); let mut screen = Self { + locale: crate::ui::i18n::current_locale(), + messages: Default::default(), backend, account, active, @@ -813,11 +926,28 @@ impl RemoteScreen { peer, grant, generation: update.generation, - title: format!("Update {name} to {version}?"), - action_label: format!("Update {name}"), - detail: format!( - "{name} stops its running tasks, installs {version} and restarts. Remote access may not return; if it doesn’t, open Maple on {name} directly. This computer stays on v{CLIENT_VERSION} and won’t restart." - ), + title: tr_args( + "settings-remote-update-confirm-title", + [ + ("name", (name).to_string().into()), + ("version", (version).to_string().into()), + ], + ) + .to_string(), + action_label: tr_args( + "settings-remote-update-action", + [("name", (name).to_string().into())], + ) + .to_string(), + detail: tr_args( + "settings-remote-update-confirm-detail", + [ + ("name", (name).to_string().into()), + ("version", (version).to_string().into()), + ("client-version", (CLIENT_VERSION).to_string().into()), + ], + ) + .to_string(), request: InstallRequest { operation_id: uuid::Uuid::new_v4().to_string(), target, @@ -841,7 +971,9 @@ impl RemoteScreen { fn take_install_intent(&mut self) -> Option { let confirmation = self.install_confirmation.take()?; if !self.confirmation_is_current(&confirmation) { - self.notice = Some("This computer’s update or pairing changed. Refresh its status before trying again.".into()); + self.notice = Some(UiText::key( + "settings-remote-this-computer-s-update-or-pairing-changed-refresh-its-status-befo", + )); return None; } Some(InstallIntent { @@ -874,7 +1006,7 @@ impl RemoteScreen { update.attempt = Some(InstallAttempt { request: request.clone(), phase: InstallPhase::Sending, - label: "Sending the update request…".into(), + label: UiText::key("settings-remote-sending-the-update-request"), from_receipt: false, }); update.refresh_presentation(); @@ -924,16 +1056,16 @@ impl RemoteScreen { && receipt.target == request.target => { attempt.phase = InstallPhase::Accepted; - attempt.label = ACCEPTED_INSTALL.into(); + attempt.label = UiText::key(ACCEPTED_INSTALL); } Ok(InstallReply::Refused { reason }) => { attempt.phase = InstallPhase::Failed; - attempt.label = format!("Not updated. {}", reason.message()); + attempt.label = presentation::refused(reason); update.check_required = true; } _ => { attempt.phase = InstallPhase::Uncertain; - attempt.label = UNCONFIRMED_INSTALL.into(); + attempt.label = UiText::key(UNCONFIRMED_INSTALL); } } update.refresh_presentation(); @@ -970,8 +1102,9 @@ impl RemoteScreen { self.clear_local_code(); } if paired_controller { - self.notice = - Some("Computer paired. It can now use this computer’s projects and tools.".into()); + self.notice = Some(UiText::key( + "settings-remote-computer-paired-it-can-now-use-this-computer-s-projects-and-tools", + )); } if self .selected_device @@ -1007,7 +1140,7 @@ impl RemoteScreen { }); if outgoing_changed && attempt.phase == InstallPhase::Sending { attempt.phase = InstallPhase::Uncertain; - attempt.label = UNCONFIRMED_INSTALL.into(); + attempt.label = UiText::key(UNCONFIRMED_INSTALL); } update.generation = update.generation.wrapping_add(1); update.phase = PeerInfoPhase::Unchecked; @@ -1049,7 +1182,10 @@ impl RemoteScreen { let code = zeroize::Zeroizing::new(self.code_input.read(cx).text().trim().to_ascii_uppercase()); if code.len() != 8 { - self.error = Some("Enter all eight characters shown on the selected computer.".into()); + self.error = Some( + tr("settings-remote-enter-all-eight-characters-shown-on-the-selected-computer") + .into(), + ); cx.notify(); return; } @@ -1064,12 +1200,14 @@ impl RemoteScreen { fn rename(&mut self, key: String, current: String, cx: &mut Context) { let weak = cx.entity().downgrade(); let input = cx.new(|cx| { - let mut input = TextInput::new("Computer name", cx).on_enter(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - weak.update(cx, |screen, cx| screen.save_name(cx)).ok(); - }); - }); + let mut input = TextInput::localized("settings-remote-computer-name", cx).on_enter( + move |_, _, cx| { + let weak = weak.clone(); + cx.defer(move |cx| { + weak.update(cx, |screen, cx| screen.save_name(cx)).ok(); + }); + }, + ); input.set_text(¤t, cx); input }); @@ -1084,13 +1222,15 @@ impl RemoteScreen { let current = status.name.clone(); let weak = cx.entity().downgrade(); let input = cx.new(|cx| { - let mut input = TextInput::new("Computer name", cx).on_enter(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - weak.update(cx, |screen, cx| screen.save_this_computer_name(cx)) - .ok(); - }); - }); + let mut input = TextInput::localized("settings-remote-computer-name", cx).on_enter( + move |_, _, cx| { + let weak = weak.clone(); + cx.defer(move |cx| { + weak.update(cx, |screen, cx| screen.save_this_computer_name(cx)) + .ok(); + }); + }, + ); input.set_text(¤t, cx); input }); @@ -1119,7 +1259,8 @@ impl RemoteScreen { }; let name = input.read(cx).text().trim().to_string(); if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) { - self.error = Some("Use a computer name between 1 and 64 characters.".into()); + self.error = + Some(tr("settings-remote-use-a-computer-name-between-1-and-64-characters").into()); cx.notify(); return; } @@ -1132,7 +1273,9 @@ impl RemoteScreen { let saved_key = key.clone(); let saved_name = name.clone(); let request = self.backend.spawn(async move { - let user = user.ok_or("Sign in before naming a remote computer")?; + let user = user.ok_or(tr( + "settings-remote-sign-in-before-naming-a-remote-computer", + ))?; backend .set_remote_sidebar( &user, @@ -1142,9 +1285,9 @@ impl RemoteScreen { .await }); let bridge = cx.spawn(async move |this, cx| { - let result = request - .await - .unwrap_or_else(|_| Err("Could not save the computer name".into())); + let result = request.await.unwrap_or_else(|_| { + Err(tr("settings-remote-could-not-save-the-computer-name").into()) + }); if let Err(error) = result { this.update(cx, |screen, cx| { screen.error = Some(error); @@ -1162,9 +1305,9 @@ impl RemoteScreen { return; } self.busy = Some(if discover { - "Finding computers…" + "settings-remote-finding-computers" } else { - "Updating remote settings…" + "settings-remote-updating-remote-settings" }); if discover { self.clear_selection(cx); @@ -1218,10 +1361,10 @@ impl RemoteScreen { return; } self.busy = Some(match method { - "remote_begin_pairing" => "Preparing pairing code…", - "remote_pair" | "remote_reconcile" => "Pairing computer…", - "remote_revoke" => "Unpairing computer…", - _ => "Updating remote settings…", + "remote_begin_pairing" => "settings-remote-preparing-pairing-code", + "remote_pair" | "remote_reconcile" => "settings-remote-pairing-computer", + "remote_revoke" => "settings-remote-unpairing-computer", + _ => "settings-remote-updating-remote-settings", }); self.error = None; self.notice = None; @@ -1248,7 +1391,10 @@ impl RemoteScreen { )) } Err(_) => { - screen.error = Some("Could not read the pairing window".into()); + screen.error = Some( + tr("settings-remote-could-not-read-the-pairing-window") + .into(), + ); cx.notify(); return; } @@ -1257,15 +1403,16 @@ impl RemoteScreen { screen.clear_local_code(); } else if method == "remote_pair" || method == "remote_reconcile" { screen.clear_selection(cx); - screen.notice = Some( - "Computer paired. Its projects are now available in the sidebar." - .into(), - ); + screen.notice = Some(UiText::key( + "settings-remote-computer-paired-its-projects-are-now-available-in-the-sidebar", + )); } else if method == "remote_revoke" { - screen.notice = Some("Computer unpaired.".into()); + screen.notice = Some(UiText::key("settings-remote-computer-unpaired")); } else if method == "remote_set_name" { screen.local_name_input = None; - screen.notice = Some("Computer name saved. Other computers pick it up when they refresh devices or reconnect.".into()); + screen.notice = Some(UiText::key( + "settings-remote-computer-name-saved-other-computers-pick-it-up-when-they-refresh", + )); } screen.refresh(false, cx); } @@ -1285,6 +1432,13 @@ impl Drop for RemoteScreen { } impl Render for RemoteScreen { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let locale = crate::ui::i18n::current_locale(); + if self.locale != locale { + self.locale = locale; + for update in self.computer_updates.values_mut() { + update.refresh_presentation(); + } + } self.popup.sync_focus(window, cx); div() .id("remote-devices") @@ -1292,13 +1446,14 @@ impl Render for RemoteScreen { .flex() .flex_col() .gap_4() - .child(div().text_lg().child("Remote computers")) + .child(div().text_lg().child(tr("settings-remote-remote-computers"))) .child( div().text_color(gpui::rgb(theme::text_secondary())).child( - "Work on another computer, or allow a paired computer to work on this one.", + tr("settings-remote-work-on-another-computer-or-allow-a-paired-computer-to-work-on-th"), ), ) - .when_some(self.busy, |view, message| { + .when_some(self.busy, |view, key| { + let message = tr(key); view.child(div().text_sm().child(message)) }) .when_some(self.error.as_ref(), |view, error| { @@ -1328,13 +1483,13 @@ impl RemoteScreen { fn render_this_computer(&self, cx: &mut Context) -> Div { let enabled = self.status.as_ref().is_some_and(|s| s.enabled); let access = if self.status.is_none() { - "Loading…" + tr("settings-remote-loading") } else if enabled && !self.status.as_ref().is_some_and(|s| s.ready) { - "Starting. Waiting for a connection…" + tr("settings-remote-starting-waiting-for-a-connection") } else if enabled { - "On. Only paired computers can connect." + tr("settings-remote-on-only-paired-computers-can-connect") } else { - "Off. Other computers can’t connect here." + tr("settings-remote-off-other-computers-can-t-connect-here") }; div() .flex() @@ -1346,14 +1501,14 @@ impl RemoteScreen { .items_center() .justify_between() .gap_4() - .child(setting_copy("Remote access", access)) + .child(setting_copy(tr("settings-remote-remote-access"), access)) .when(self.status.is_some(), |row| { row.child( widgets::switch("remote-enable", enabled) .aria_label(if enabled { - "Turn off remote access" + tr("settings-remote-turn-off-remote-access") } else { - "Enable remote access" + tr("settings-remote-enable-remote-access") }) .on_click(cx.listener(move |screen, _, _, cx| { screen.action("remote_enable", serde_json::json!([!enabled]), cx) @@ -1368,11 +1523,11 @@ impl RemoteScreen { .items_center() .justify_between() .gap_4() - .child(setting_copy(&status.name, "This computer’s name on your other computers.")) + .child(setting_copy(&status.name, tr("settings-remote-this-computer-s-name-on-your-other-computers"))) .child( widgets::ghost_button("rename-this-computer") - .aria_label("Rename this computer") - .child("Rename") + .aria_label(tr("settings-remote-rename-this-computer")) + .child(tr("settings-remote-rename")) .debug_selector(|| "rename-this-computer".to_string()) .on_click(cx.listener(|screen, _, window, cx| { screen.rename_this_computer(window, cx) @@ -1393,16 +1548,16 @@ impl RemoteScreen { .child(widgets::input_frame().flex_1().min_w_0().child(input.clone())) .child( widgets::secondary_button("save-this-computer-name") - .aria_label("Save computer name") - .child("Save") + .aria_label(tr("settings-remote-save-computer-name")) + .child(tr("settings-remote-save")) .on_click(cx.listener(|screen, _, _, cx| { screen.save_this_computer_name(cx) })), ) .child( widgets::ghost_button("cancel-this-computer-name") - .aria_label("Cancel rename") - .child("Cancel") + .aria_label(tr("settings-remote-cancel-rename")) + .child(tr("settings-remote-cancel")) .debug_selector(|| "cancel-this-computer-name".to_string()) .on_click(cx.listener(|screen, _, _, cx| { screen.local_name_input = None; @@ -1415,7 +1570,7 @@ impl RemoteScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child("Shown to your other computers. This doesn’t change the system hostname."), + .child(tr("settings-remote-shown-to-your-other-computers-this-doesn-t-change-the-system-host")), ), ), }) @@ -1428,13 +1583,13 @@ impl RemoteScreen { .justify_between() .gap_4() .child(setting_copy( - "Pair a new computer", - "Show a one-time code here, then enter it on the other computer.", + tr("settings-remote-pair-a-new-computer"), + tr("settings-remote-show-a-one-time-code-here-then-enter-it-on-the-other-computer"), )) .child( widgets::primary_button("pairing-window") - .aria_label("Show pairing code") - .child("Show pairing code") + .aria_label(tr("settings-remote-show-pairing-code")) + .child(tr("settings-remote-show-pairing-code")) .on_click(cx.listener(|screen, _, _, cx| { screen.action("remote_begin_pairing", serde_json::json!([]), cx) })), @@ -1446,8 +1601,8 @@ impl RemoteScreen { .flex_col() .gap_3() .child(setting_copy( - "Pair a new computer", - "On the other computer, choose Find computers, select this one, and enter this code.", + tr("settings-remote-pair-a-new-computer"), + tr("settings-remote-on-the-other-computer-choose-find-computers-select-this-one-and-e"), )) .child(pairing_code_boxes(code)) .child( @@ -1460,16 +1615,12 @@ impl RemoteScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child(format!( - "One attempt · expires in {}:{:02}", - left / 60, - left % 60 - )), + .child(self.messages.get("settings-pairing-expiry", [("arg1", (left / 60).to_string().into()), ("arg2", (format!("{:02}", left % 60)).to_string().into())]).to_string()), ) .child( widgets::secondary_button("cancel-window") - .aria_label("Cancel pairing") - .child("Cancel pairing") + .aria_label(tr("settings-remote-cancel-pairing")) + .child(tr("settings-remote-cancel-pairing")) .on_click(cx.listener(|screen, _, _, cx| { screen.action("remote_cancel_pairing", serde_json::json!([]), cx) })), @@ -1498,13 +1649,13 @@ impl RemoteScreen { .justify_between() .gap_4() .child(setting_copy( - "Connect to another computer", - "Find computers signed in to your account. Turn on remote access there and show a pairing code.", + tr("settings-remote-connect-to-another-computer"), + tr("settings-remote-find-computers-signed-in-to-your-account-turn-on-remote-access-th"), )) .child( widgets::secondary_button("discover") - .aria_label(if searched { "Refresh devices" } else { "Find computers" }) - .child(if searched { "Search again" } else { "Find computers" }) + .aria_label(if searched { tr("settings-remote-refresh-devices") } else { tr("settings-remote-find-computers") }) + .child(if searched { tr("settings-remote-search-again") } else { tr("settings-remote-find-computers") }) .on_click(cx.listener(|screen, _, _, cx| screen.refresh(true, cx))), ), ) @@ -1513,7 +1664,7 @@ impl RemoteScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child("No new computers found. Check it’s online and signed in, then search again."), + .child(tr("settings-remote-no-new-computers-found-check-it-s-online-and-signed-in-then-searc")), ) }) .children( @@ -1549,7 +1700,7 @@ impl RemoteScreen { .flex() .flex_col() .gap_1() - .child(computer_name( + .child(self.display_name( &endpoint, &device.name, self.names.get(&endpoint).map_or("", String::as_str), @@ -1559,9 +1710,9 @@ impl RemoteScreen { .text_sm() .text_color(gpui::rgb(theme::text_secondary())) .child(if ready { - "Ready to pair" + tr("settings-remote-ready-to-pair") } else { - "Show a pairing code on this computer, then refresh." + tr("settings-remote-show-a-pairing-code-on-this-computer-then-refresh") }), ), ) @@ -1572,8 +1723,8 @@ impl RemoteScreen { let endpoint = endpoint.clone(); move || format!("select-{endpoint}") }) - .aria_label("Enter code") - .child("Enter code") + .aria_label(tr("settings-remote-enter-code")) + .child(tr("settings-remote-enter-code")) .on_click(cx.listener(move |screen, _, window, cx| { screen.select_device(endpoint.clone(), window, cx) })), @@ -1583,8 +1734,8 @@ impl RemoteScreen { row.child( widgets::ghost_button("cancel-code") .debug_selector(|| "cancel-code".to_string()) - .aria_label("Cancel code entry") - .child("Cancel") + .aria_label(tr("settings-remote-cancel-code-entry")) + .child(tr("settings-remote-cancel")) .on_click(cx.listener(|screen, _, _, cx| { if screen.busy.is_none() { screen.clear_selection(cx); @@ -1601,7 +1752,7 @@ impl RemoteScreen { .flex() .flex_col() .gap_2() - .child(div().text_sm().child("Pairing code from this computer")) + .child(div().text_sm().child(tr("settings-remote-pairing-code-from-this-computer"))) .child( div() .flex() @@ -1617,8 +1768,8 @@ impl RemoteScreen { ) .child( widgets::primary_button("pair-selected") - .aria_label("Pair computer") - .child("Pair computer") + .aria_label(tr("settings-remote-pair-computer")) + .child(tr("settings-remote-pair-computer")) .on_click( cx.listener(|screen, _, _, cx| { screen.pair_selected(cx) @@ -1631,7 +1782,7 @@ impl RemoteScreen { .text_sm() .text_color(gpui::rgb(theme::text_muted())) .child( - "Type or paste its eight-character code. The dash is optional.", + tr("settings-remote-type-or-paste-its-eight-character-code-the-dash-is-optional"), ), ), ) @@ -1643,7 +1794,13 @@ impl RemoteScreen { .flex() .flex_col() .gap_2() - .child(div().pt_2().pb_1().text_lg().child("Paired computers")) + .child( + div() + .pt_2() + .pb_1() + .text_lg() + .child(tr("settings-remote-paired-computers")), + ) .children( self.status .iter() @@ -1662,7 +1819,7 @@ impl RemoteScreen { window: &mut Window, cx: &mut Context, ) -> gpui::Stateful
{ - let name = computer_name( + let name = self.display_name( key, &peer.name, self.names.get(key).map_or("", String::as_str), @@ -1681,17 +1838,20 @@ impl RemoteScreen { update.note.clone(), update.action, ), - None if peer.can_connect => { - (None, "Version unknown".to_owned(), None, CardAction::None) - } + None if peer.can_connect => ( + None, + tr("settings-remote-version-unknown").to_string(), + None, + CardAction::None, + ), None => ( None, if peer.controls_this_computer { - "Can use this computer’s projects and tools" + tr("settings-remote-can-use-this-computer-s-projects-and-tools") } else { - "Pairing needs confirmation" + tr("settings-remote-pairing-needs-confirmation") } - .to_owned(), + .to_string(), None, CardAction::None, ), @@ -1708,9 +1868,10 @@ impl RemoteScreen { }) { note = Some(( - crate::remote::update::BlockedReason::PairingConfirmationRequired - .message() - .to_owned(), + presentation::blocked( + crate::remote::update::BlockedReason::PairingConfirmationRequired, + ) + .to_string(), Tone::Muted, )); } @@ -1729,14 +1890,14 @@ impl RemoteScreen { .child(widgets::input_frame().flex_1().min_w_0().child(input)) .child( widgets::secondary_button(SharedString::from(format!("save-name-{key}"))) - .aria_label("Save name") - .child("Save") + .aria_label(tr("settings-remote-save-name")) + .child(tr("settings-remote-save")) .on_click(cx.listener(|screen, _, _, cx| screen.save_name(cx))), ) .child( widgets::ghost_button(SharedString::from(format!("cancel-name-{key}"))) - .aria_label("Cancel rename") - .child("Cancel") + .aria_label(tr("settings-remote-cancel-rename")) + .child(tr("settings-remote-cancel")) .on_click(cx.listener(|screen, _, _, cx| { screen.renaming = None; screen.error = None; @@ -1759,7 +1920,7 @@ impl RemoteScreen { .child(name.clone()), ) .when_some(badge, |row, (label, tone)| { - row.child(widgets::status_badge(label, tone.color())) + row.child(widgets::status_badge(tr(label), tone.color())) }), }; let details = div() @@ -1819,8 +1980,15 @@ impl RemoteScreen { let key = key.to_owned(); move || format!("update-remote-{key}") }) - .aria_label(format!("Update {name}")) - .child("Update") + .aria_label( + self.messages + .get( + "settings-remote-update-action", + [("name", (name).to_string().into())], + ) + .to_string(), + ) + .child(tr("settings-remote-update")) .on_click(cx.listener( move |screen, _, window, cx| { screen.ask_remote_install(install.clone(), window, cx) @@ -1836,8 +2004,8 @@ impl RemoteScreen { let key = key.to_owned(); move || format!("check-again-{key}") }) - .aria_label("Check this computer for updates again") - .child("Check again") + .aria_label(tr("settings-remote-check-this-computer-for-updates-again")) + .child(tr("settings-remote-check-again")) .on_click(cx.listener(move |screen, _, _, cx| { screen.check_peer_update(check.clone(), cx) })), @@ -1846,8 +2014,8 @@ impl RemoteScreen { .when(peer.pending_confirmation.is_some(), |row| { row.child( widgets::secondary_button(SharedString::from(format!("confirm-{key}"))) - .aria_label("Confirm previous pairing") - .child("Confirm pairing") + .aria_label(tr("settings-remote-confirm-previous-pairing")) + .child(tr("settings-remote-confirm-pairing")) .on_click(cx.listener(move |screen, _, _, cx| { screen.action("remote_reconcile", serde_json::json!([confirm]), cx) })), @@ -1906,13 +2074,20 @@ impl RemoteScreen { let rename = key.to_owned(); let rename_name = name.to_owned(); let revoke = key.to_owned(); - let mut menu = Menu::new(SharedString::from(format!("computer-menu-{key}")), px(200.)) - .label(format!("{name} options")); + let mut menu = Menu::new(SharedString::from(format!("computer-menu-{key}")), px(240.)) + .label( + self.messages + .get( + "settings-computer-options", + [("name", (name).to_string().into())], + ) + .to_string(), + ); if can_connect { menu = menu.item( MenuItem::new( SharedString::from(format!("refresh-update-{key}")), - "Check for updates", + tr("settings-remote-check-for-updates"), move |screen: &mut Self, _, cx| screen.check_peer_update(check.clone(), cx), ) .icon("download") @@ -1923,7 +2098,7 @@ impl RemoteScreen { .item( MenuItem::new( SharedString::from(format!("rename-{key}")), - "Rename…", + tr("settings-remote-rename-more"), move |screen: &mut Self, window, cx| { screen.rename(rename.clone(), rename_name.clone(), cx); if let Some((_, input)) = &screen.renaming { @@ -1937,7 +2112,7 @@ impl RemoteScreen { .item( MenuItem::new( SharedString::from(format!("revoke-{key}")), - "Unpair", + tr("settings-remote-unpair"), move |screen: &mut Self, _, cx| { screen.action("remote_revoke", serde_json::json!([revoke]), cx) }, @@ -1956,7 +2131,7 @@ impl RemoteScreen { widgets::icon_button( SharedString::from(format!("computer-more-{key}")), "ellipsis", - "More", + tr("settings-remote-more"), px(16.), theme::text_secondary(), ) @@ -1982,7 +2157,7 @@ impl RemoteScreen { .id("remote-update-confirmation") .debug_selector(|| "remote-update-confirmation".into()) .role(gpui::Role::Dialog) - .aria_label("Experimental remote update") + .aria_label(tr("settings-remote-experimental-remote-update")) .key_context("Dialog") .w_full() .min_w_0() @@ -2039,7 +2214,7 @@ impl RemoteScreen { .child(confirmation.title.clone()), ) .child(widgets::status_badge( - "Experimental", + tr("settings-remote-experimental"), theme::status_warning(), )), ) @@ -2064,7 +2239,7 @@ impl RemoteScreen { .debug_selector(|| "cancel-remote-update".into()) .track_focus(&confirmation.cancel_focus) .focus_visible(widgets::focus_ring) - .child("Cancel") + .child(tr("settings-remote-cancel")) .on_click(cx.listener(|screen, _, window, cx| { screen.cancel_remote_install(window, cx) })), @@ -2075,7 +2250,7 @@ impl RemoteScreen { .aria_label(confirmation.action_label.clone()) .track_focus(&confirmation.install_focus) .focus_visible(widgets::focus_ring) - .child("Update") + .child(tr("settings-remote-update")) .on_click(cx.listener(|screen, _, window, cx| { screen.confirm_remote_install(window, cx) })), diff --git a/apps/maple-agent/app/src/ui/remote/presentation.rs b/apps/maple-agent/app/src/ui/remote/presentation.rs new file mode 100644 index 000000000..cf7cb35fa --- /dev/null +++ b/apps/maple-agent/app/src/ui/remote/presentation.rs @@ -0,0 +1,34 @@ +//! Localized presentation of typed remote update outcomes. +use crate::{ + remote::update::{BlockedReason, InstallRefusal}, + ui::i18n::{UiText, tr}, +}; +pub(super) fn blocked(reason: BlockedReason) -> gpui::SharedString { + tr(match reason { + BlockedReason::PasswordProtected => "settings-remote-blocked-password-protected", + BlockedReason::UnsupportedInstallation => { + "settings-remote-blocked-unsupported-installation" + } + BlockedReason::UpdatesDisabled => "settings-remote-blocked-updates-disabled", + BlockedReason::UpdateNotReady => "settings-remote-blocked-update-not-ready", + BlockedReason::Busy => "settings-remote-blocked-busy", + BlockedReason::DesktopUnavailable => "settings-remote-blocked-desktop-unavailable", + BlockedReason::CoordinatorUnavailable => "settings-remote-blocked-coordinator-unavailable", + BlockedReason::PairingConfirmationRequired => { + "settings-remote-blocked-pairing-confirmation-required" + } + BlockedReason::Unknown => "settings-remote-blocked-unknown", + }) +} +pub(super) fn refused(reason: InstallRefusal) -> UiText { + UiText::key(match reason { + InstallRefusal::AuthorizationChanged => "settings-remote-refused-authorization-changed", + InstallRefusal::UpdateChanged => "settings-remote-refused-update-changed", + InstallRefusal::NotReady => "settings-remote-refused-not-ready", + InstallRefusal::Unsupported => "settings-remote-refused-unsupported", + InstallRefusal::AccountChanged => "settings-remote-refused-account-changed", + InstallRefusal::Busy => "settings-remote-refused-busy", + InstallRefusal::Unavailable => "settings-remote-refused-unavailable", + InstallRefusal::DownloadRequired => "settings-remote-refused-download-required", + }) +} diff --git a/apps/maple-agent/app/src/ui/remote/tests.rs b/apps/maple-agent/app/src/ui/remote/tests.rs index 67c6c6db0..b6611a143 100644 --- a/apps/maple-agent/app/src/ui/remote/tests.rs +++ b/apps/maple-agent/app/src/ui/remote/tests.rs @@ -9,6 +9,8 @@ fn screen(cx: &mut TestAppContext) -> Entity { // No status request or timer: drive state transitions without connecting // to a developer's host or changing pairing authority. cx.new(|cx| RemoteScreen { + locale: crate::ui::i18n::current_locale(), + messages: Default::default(), backend, account: "account-a".into(), active: false, @@ -191,7 +193,14 @@ fn incoming_pairing_closes_the_code_but_an_outgoing_grant_does_not(cx: &mut Test status.peers.get_mut("peer").unwrap().controls_this_computer = true; screen.update_status(status, cx); assert!(screen.local_code.is_none()); - assert!(screen.notice.as_ref().unwrap().contains("Computer paired")); + assert!( + screen + .notice + .as_ref() + .unwrap() + .resolve() + .contains("Computer paired") + ); assert!(screen.status.as_ref().unwrap().peers["peer"].controls_this_computer); }); } @@ -218,7 +227,14 @@ fn settings_reentry_keeps_the_open_window_with_existing_controllers(cx: &mut Tes status.peers.get_mut("existing").unwrap().grant = [2; 16]; screen.update_status(status, cx); assert!(screen.local_code.is_none()); - assert!(screen.notice.as_ref().unwrap().contains("Computer paired")); + assert!( + screen + .notice + .as_ref() + .unwrap() + .resolve() + .contains("Computer paired") + ); }); } @@ -372,7 +388,7 @@ fn an_available_offer_can_be_requested_without_downloading_it_first() { info.update.blocked = Some(blocked); assert_eq!( install_blocked(&info).as_deref(), - Some(blocked.message()), + Some(presentation::blocked(blocked).as_ref()), "{blocked:?}" ); } @@ -430,7 +446,7 @@ fn an_older_hosts_download_refusal_is_definite_and_short(cx: &mut TestAppContext screen.computer_updates.get_mut("a").unwrap().attempt = Some(InstallAttempt { request: request.clone(), phase: InstallPhase::Sending, - label: String::new(), + label: String::new().into(), from_receipt: false, }); screen.apply_install_reply( @@ -443,8 +459,13 @@ fn an_older_hosts_download_refusal_is_definite_and_short(cx: &mut TestAppContext ); let attempt = screen.computer_updates["a"].attempt.as_ref().unwrap(); assert_eq!(attempt.phase, InstallPhase::Failed); - assert!(attempt.label.contains("must download the update first")); - assert!(!attempt.label.contains("Do not resend")); + assert!( + attempt + .label + .resolve() + .contains("must download the update first") + ); + assert!(!attempt.label.resolve().contains("Do not resend")); // Nothing was admitted there; a fresh check allows another try. screen.apply_peer_info_result( "a", @@ -469,7 +490,7 @@ fn version_advisory_uses_known_app_versions_not_protocol_or_package_identity() { assert!(version_note(&info, "1.2.3").is_none()); let note = version_note(&info, "1.2.4").unwrap(); - assert_eq!(note, "This computer runs v1.2.4."); + assert_eq!(plain(¬e), "This computer runs v1.2.4."); for unknown in ["unknown", "", "not-a-version"] { info.running.version = unknown.into(); assert!(version_note(&info, "1.2.4").is_none()); @@ -478,6 +499,11 @@ fn version_advisory_uses_known_app_versions_not_protocol_or_package_identity() { assert!(version_note(&info, "unknown").is_none()); } +// Exact English wording is still checked; Fluent isolation is covered in i18n tests. +fn plain(text: &str) -> String { + text.replace(['\u{2068}', '\u{2069}'], "") +} + fn note(update: &ComputerUpdate) -> Option<&str> { update.note.as_ref().map(|(note, _)| note.as_str()) } @@ -530,7 +556,7 @@ fn version_advisory_clears_for_equal_unknown_or_unsupported_peer_versions(cx: &m screen.read_with(cx, |screen, _| { let note = note(&screen.computer_updates["a"]).unwrap(); assert_eq!( - note, + plain(note), format!("This computer runs v{}.", env!("CARGO_PKG_VERSION")) ); }); @@ -593,15 +619,17 @@ fn peer_status_distinguishes_protocol_refusal_from_offline_revoked_and_invalid_f let update = &screen.computer_updates["a"]; assert_eq!(update.phase, *phase, "{error}"); assert_eq!( - update.badge == Some(("Update needed", Tone::Warning)), + update.badge == Some(("settings-remote-update-needed", Tone::Warning)), *phase == PeerInfoPhase::Incompatible, "{error}" ); assert!(!update.install_available); // No version comparison or blocked reason from a stale report. - assert!(note(update).is_none_or(|note| note == PROTOCOL_UPDATE_REQUIRED)); + assert!( + note(update).is_none_or(|note| note == tr(PROTOCOL_UPDATE_REQUIRED).as_ref()) + ); if has_cached_info { - assert!(update.status_line.starts_with("v0.0.6")); + assert!(plain(&update.status_line).starts_with("v0.0.6")); assert!(!update.status_line.contains("available")); assert!(update.info.is_some(), "the old report remains historical"); } else { @@ -614,7 +642,7 @@ fn peer_status_distinguishes_protocol_refusal_from_offline_revoked_and_invalid_f screen.apply_peer_info("a", [1; 16], 1, Ok(Some(ready_peer()))); let update = &screen.computer_updates["a"]; assert_eq!(update.phase, PeerInfoPhase::Loaded); - assert_eq!(update.status_line, "v0.0.6 · v0.0.7 available"); + assert_eq!(plain(&update.status_line), "v0.0.6 · v0.0.7 available"); assert_eq!(update.action, CardAction::Update); assert!(update.install_available); }); @@ -651,7 +679,7 @@ fn protocol_refusal_cancels_consent_and_disables_cached_remote_update_offer( screen.read_with(cx, |screen, _| { assert_eq!( note(&screen.computer_updates["a"]), - Some(PROTOCOL_UPDATE_REQUIRED) + Some(tr(PROTOCOL_UPDATE_REQUIRED).as_ref()) ); }); screen.read_with(cx, |screen, _| { @@ -689,11 +717,7 @@ fn installing_a_different_target_warns_without_requiring_the_client_to_update( .detail .contains(&format!("v{}", target.version)) ); - assert!( - confirmation - .detail - .contains(&format!("v{}", env!("CARGO_PKG_VERSION"))) - ); + assert!(plain(&confirmation.detail).contains(&format!("v{}", env!("CARGO_PKG_VERSION")))); assert!(confirmation.detail.contains("won’t restart")); assert!(screen.computer_updates["a"].install_available); assert!(screen.computer_updates["a"].attempt.is_none()); @@ -820,7 +844,7 @@ fn remote_update_consent_defaults_to_cancel_without_touching_client(cx: &mut Tes cx.run_until_parked(); assert!(cx.debug_bounds("remote-update-confirmation").is_some()); screen.read_with(cx, |screen, _| { - let text = &screen.install_confirmation.as_ref().unwrap().detail; + let text = plain(&screen.install_confirmation.as_ref().unwrap().detail); for required in [ "Office a", "v0.0.7", @@ -871,7 +895,7 @@ fn old_peer_status_fallback_does_not_remove_ordinary_remote_access(cx: &mut Test let update = &screen.computer_updates["a"]; assert_eq!(update.phase, PeerInfoPhase::Unsupported); assert!(!update.install_available); - assert_eq!(update.status_line, "Version unknown"); + assert_eq!(plain(&update.status_line), "Version unknown"); assert!( update.note.is_none(), "projects are in the sidebar; no filler" @@ -997,7 +1021,7 @@ fn lost_install_ack_keeps_nonce_and_never_replays_or_updates_client(cx: &mut Tes screen.computer_updates.get_mut("a").unwrap().attempt = Some(InstallAttempt { request: request.clone(), phase: InstallPhase::Sending, - label: String::new(), + label: String::new().into(), from_receipt: false, }); screen.apply_install_reply("a", [1; 16], &request, Err("Disconnected".into())); @@ -1005,7 +1029,7 @@ fn lost_install_ack_keeps_nonce_and_never_replays_or_updates_client(cx: &mut Tes let attempt = screen.computer_updates["a"].attempt.as_ref().unwrap(); assert_eq!(attempt.phase, InstallPhase::Uncertain); assert_eq!(attempt.request, request); - assert_eq!(attempt.label, UNCONFIRMED_INSTALL); + assert_eq!(attempt.label.resolve(), tr(UNCONFIRMED_INSTALL)); assert!(!screen.computer_updates["a"].install_available); let mut acknowledged = ready_peer(); @@ -1059,16 +1083,22 @@ fn settings_reentry_adopts_pending_operation_without_enabling_another_install( if state == OperationState::Unknown { // Still unconfirmed: the only way on is a fresh status check. assert_eq!(update.action, CardAction::CheckAgain); - assert_eq!(update.badge.map(|(label, _)| label), Some("Unconfirmed")); + assert_eq!( + update.badge.map(|(key, _)| tr(key).to_string()).as_deref(), + Some("Unconfirmed") + ); } else { assert_eq!(update.action, CardAction::None); - assert_eq!(update.badge.map(|(label, _)| label), Some("Updating")); + assert_eq!( + update.badge.map(|(key, _)| tr(key).to_string()).as_deref(), + Some("Updating") + ); } let attempt = update.attempt.as_ref().unwrap(); assert_eq!(attempt.request.operation_id, operation_id); - assert_eq!(note(update), Some(attempt.label.as_str())); + assert_eq!(note(update), Some(attempt.label.resolve().as_ref())); if state == OperationState::PendingAck { - assert!(attempt.label.contains("Nothing has started")); + assert!(attempt.label.resolve().contains("Nothing has started")); } } }); @@ -1094,7 +1124,7 @@ fn historical_attempt(phase: InstallPhase) -> InstallAttempt { target: target_release(), }, phase, - label: UNCONFIRMED_INSTALL.into(), + label: UiText::key(UNCONFIRMED_INSTALL), from_receipt: true, } } @@ -1123,7 +1153,7 @@ fn current_and_idle_without_offers_do_not_show_download_instructions() { ..Default::default() }; update.refresh_presentation(); - assert!(update.status_line.starts_with("v0.0.6")); + assert!(plain(&update.status_line).starts_with("v0.0.6")); assert_eq!( update.badge.is_some(), state == UpdateState::Current, @@ -1157,8 +1187,8 @@ fn current_and_idle_without_offers_do_not_show_download_instructions() { update.info.as_mut().unwrap().update.state = UpdateState::Ready; update.info.as_mut().unwrap().update.offered = Some(target_release()); update.refresh_presentation(); - assert_eq!(note(&update), Some(reason.message())); - assert_eq!(update.status_line, "v0.0.6 · v0.0.7 available"); + assert_eq!(note(&update), Some(presentation::blocked(reason).as_ref())); + assert_eq!(plain(&update.status_line), "v0.0.6 · v0.0.7 available"); assert!(!update.install_available); } } @@ -1171,8 +1201,8 @@ fn confirmed_remote_outcome_stays_historical_after_later_upgrade() { ..Default::default() }; update.reconcile_operation(); - let confirmed = update.attempt.as_ref().unwrap().label.clone(); - assert_eq!(confirmed, "Updated to v0.0.7."); + let confirmed = update.attempt.as_ref().unwrap().label.resolve(); + assert_eq!(confirmed, "Updated to \u{2068}v0.0.7\u{2069}."); for status in [None, Some(OperationState::Failed)] { let mut later = historical_info(status); later.running.version = "0.0.8".into(); @@ -1183,9 +1213,12 @@ fn confirmed_remote_outcome_stays_historical_after_later_upgrade() { update.refresh_presentation(); let attempt = update.attempt.as_ref().unwrap(); assert_eq!(attempt.phase, InstallPhase::Succeeded); - assert_eq!(attempt.label, confirmed); - assert_eq!(update.status_line, "v0.0.8"); - assert_eq!(update.badge.map(|(label, _)| label), Some("Up to date")); + assert_eq!(attempt.label.resolve(), confirmed); + assert_eq!(plain(&update.status_line), "v0.0.8"); + assert_eq!( + update.badge.map(|(key, _)| tr(key).to_string()).as_deref(), + Some("Up to date") + ); // A past success is history: the card says nothing about it. assert!(only_version_note(&update)); assert!(!update.install_available); @@ -1214,8 +1247,8 @@ fn running_target_or_later_version_does_not_prove_an_old_request_succeeded() { update.reconcile_operation(); let attempt = update.attempt.as_ref().unwrap(); assert_ne!(attempt.phase, InstallPhase::Succeeded); - assert!(!attempt.label.contains("completed")); - assert!(!attempt.label.contains("now running")); + assert!(!attempt.label.resolve().contains("completed")); + assert!(!attempt.label.resolve().contains("now running")); } } } @@ -1335,16 +1368,19 @@ fn reported_status_is_not_presented_as_a_fresh_update_check() { ..Default::default() }; update.refresh_presentation(); - let reported = update.status_line.clone(); + let reported = plain(&update.status_line); assert!(reported.starts_with("v0.0.6")); update.query = PeerInfoQuery::Check; update.refresh_presentation(); - assert_eq!(update.status_line, reported); + assert_eq!(plain(&update.status_line), reported); // A failed check keeps only history: no offer, badge or action. update.phase = PeerInfoPhase::Unreachable; update.refresh_presentation(); - assert_eq!(update.status_line, "v0.0.6 when last seen"); - assert_eq!(update.badge.map(|(label, _)| label), Some("Not reachable")); + assert_eq!(plain(&update.status_line), "v0.0.6 when last seen"); + assert_eq!( + update.badge.map(|(key, _)| tr(key).to_string()).as_deref(), + Some("Not reachable") + ); assert_eq!(update.action, CardAction::CheckAgain); assert!(!update.install_available); } @@ -1358,7 +1394,10 @@ fn reported_status_is_not_presented_as_a_fresh_update_check() { cold.phase = PeerInfoPhase::Unreachable; cold.refresh_presentation(); assert_eq!(cold.status_line, "Version unknown"); - assert_eq!(cold.badge.map(|(label, _)| label), Some("Not reachable")); + assert_eq!( + cold.badge.map(|(key, _)| tr(key).to_string()).as_deref(), + Some("Not reachable") + ); } #[gpui::test] @@ -1450,7 +1489,7 @@ fn failed_check_keeps_only_historical_values_and_sanitized_legacy_or_offline_mes Err("private-canary https://user:token@example.invalid/private".into()), ); let update = &screen.computer_updates["a"]; - assert_eq!(update.status_line, "v0.0.6 when last seen"); + assert_eq!(plain(&update.status_line), "v0.0.6 when last seen"); assert!(!update.install_available); for text in [Some(update.status_line.as_str()), note(update)] .into_iter() @@ -1491,7 +1530,7 @@ fn definite_install_refusal_is_not_uncertain_and_requires_a_new_check(cx: &mut T screen.computer_updates.get_mut("a").unwrap().attempt = Some(InstallAttempt { request: request.clone(), phase: InstallPhase::Sending, - label: String::new(), + label: String::new().into(), from_receipt: false, }); screen.apply_install_reply( @@ -1503,8 +1542,11 @@ fn definite_install_refusal_is_not_uncertain_and_requires_a_new_check(cx: &mut T let update = &screen.computer_updates["a"]; let attempt = update.attempt.as_ref().unwrap(); assert_eq!(attempt.phase, InstallPhase::Failed); - assert!(attempt.label.contains(reason.message())); - assert!(!attempt.label.contains("Do not resend")); + assert_eq!( + attempt.label.resolve(), + presentation::refused(reason).resolve() + ); + assert!(!attempt.label.resolve().contains("Do not resend")); assert!(!screen.can_install("a")); screen.apply_peer_info("a", [1; 16], 1, Ok(Some(ready_peer()))); assert!( @@ -1537,7 +1579,7 @@ fn typed_acceptance_requires_the_original_operation_and_target(cx: &mut TestAppC screen.computer_updates.get_mut("a").unwrap().attempt = Some(InstallAttempt { request: request.clone(), phase: InstallPhase::Sending, - label: String::new(), + label: String::new().into(), from_receipt: false, }); let receipt = InstallAccepted { @@ -1581,7 +1623,7 @@ fn incoming_repair_does_not_erase_an_uncertain_outgoing_install(cx: &mut TestApp screen.computer_updates.get_mut("a").unwrap().attempt = Some(InstallAttempt { request: request.clone(), phase: InstallPhase::Uncertain, - label: UNCONFIRMED_INSTALL.into(), + label: UiText::key(UNCONFIRMED_INSTALL), from_receipt: false, }); screen.computer_updates.get_mut("a").unwrap().query = PeerInfoQuery::Check; @@ -1661,7 +1703,10 @@ fn outgoing_repair_invalidates_status_and_keeps_unresolved_install_identity( assert_eq!(update.attempt.as_ref().unwrap().request, request); assert_eq!(update.attempt.as_ref().unwrap().phase, expected); if phase == InstallPhase::Sending { - assert_eq!(update.attempt.as_ref().unwrap().label, UNCONFIRMED_INSTALL); + assert_eq!( + update.attempt.as_ref().unwrap().label.resolve(), + tr(UNCONFIRMED_INSTALL) + ); } assert!(!screen.can_install("a")); let generation = update.generation; @@ -1726,7 +1771,7 @@ fn lost_outgoing_authority_preserves_unknown_request_through_repair(cx: &mut Tes screen.computer_updates.get_mut("a").unwrap().attempt = Some(InstallAttempt { request: request.clone(), phase: InstallPhase::Sending, - label: String::new(), + label: String::new().into(), from_receipt: false, }); let original = screen.status.clone().unwrap(); @@ -1745,7 +1790,10 @@ fn lost_outgoing_authority_preserves_unknown_request_through_repair(cx: &mut Tes screen.update_status(original, cx); let update = &screen.computer_updates["a"]; assert_eq!(update.attempt.as_ref().unwrap().request, request); - assert_eq!(update.attempt.as_ref().unwrap().label, UNCONFIRMED_INSTALL); + assert_eq!( + update.attempt.as_ref().unwrap().label.resolve(), + tr(UNCONFIRMED_INSTALL) + ); let generation = update.generation; screen.apply_peer_info_result( "a", @@ -1867,8 +1915,12 @@ fn every_card_state_is_one_short_line_with_at_most_one_action() { }; update.reconcile_operation(); update.refresh_presentation(); - assert_eq!(update.status_line, line, "{name}"); - assert_eq!(update.badge.map(|(label, _)| label), badge, "{name}"); + assert_eq!(plain(&update.status_line), line, "{name}"); + assert_eq!( + update.badge.map(|(key, _)| tr(key).to_string()).as_deref(), + badge, + "{name}" + ); assert_eq!(update.action, action, "{name}"); for text in [Some(update.status_line.as_str()), note(&update)] .into_iter() @@ -1881,13 +1933,16 @@ fn every_card_state_is_one_short_line_with_at_most_one_action() { match name { "past success" => assert!(update.note.is_none(), "history stays quiet"), "failed, still offered" => { - assert_eq!(note(&update), Some("The update to v0.0.7 didn’t finish.")); + assert_eq!( + note(&update), + Some("The update to \u{2068}v0.0.7\u{2069} didn’t finish.") + ); assert_eq!(update.note.as_ref().unwrap().1, Tone::Error); } "password protected" => { assert_eq!( note(&update), - Some(BlockedReason::PasswordProtected.message()) + Some(presentation::blocked(BlockedReason::PasswordProtected).as_ref()) ) } _ => {} diff --git a/apps/maple-agent/app/src/ui/secret_input.rs b/apps/maple-agent/app/src/ui/secret_input.rs index 36da2c241..d8528611d 100644 --- a/apps/maple-agent/app/src/ui/secret_input.rs +++ b/apps/maple-agent/app/src/ui/secret_input.rs @@ -266,6 +266,7 @@ pub struct SecretInput { buffer: SecretBuffer, focus: FocusHandle, placeholder: SharedString, + placeholder_key: Option<&'static str>, masks: SharedString, tab_index: Option, selecting: bool, @@ -280,10 +281,23 @@ impl EventEmitter for SecretInput {} impl EventEmitter for SecretInput {} impl SecretInput { + /// A catalog-backed placeholder and accessibility label that follow live language changes. + pub fn localized(key: &'static str, cx: &mut Context) -> Self { + let mut input = Self::new("", cx); + input.placeholder_key = Some(key); + input + } + + fn placeholder(&self) -> SharedString { + self.placeholder_key + .map_or_else(|| self.placeholder.clone(), super::i18n::tr) + } + pub fn new(placeholder: &str, cx: &mut Context) -> Self { Self { buffer: SecretBuffer::default(), focus: cx.focus_handle(), + placeholder_key: None, placeholder: placeholder.to_owned().into(), masks: "".into(), tab_index: None, @@ -817,7 +831,7 @@ impl Element for SecretElement { ) -> Self::PrepaintState { let input = self.input.read(cx); let text = if input.is_empty() { - input.placeholder.clone() + input.placeholder() } else { input.masks.clone() }; @@ -988,8 +1002,8 @@ impl Render for SecretInput { div() .id(("secret-input", cx.entity_id().as_u64())) .role(gpui::Role::TextInput) - .aria_label(self.placeholder.clone()) - .aria_placeholder(self.placeholder.clone()) + .aria_label(self.placeholder()) + .aria_placeholder(self.placeholder()) .key_context("TextInput SecretInput input_role = other editor_vim_mode = disabled") .track_focus(&self.focus) .cursor(CursorStyle::IBeam) diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 96372bb79..6d6ba08ca 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -2,6 +2,7 @@ //! settings layout. Sections: General (defaults), Account, Additional instructions, //! Integrations, Keyboard Shortcuts, Usage, About. +use crate::ui::i18n::{UiText, tr}; use std::collections::HashSet; use std::sync::Arc; @@ -41,6 +42,8 @@ use general::{font_label, theme_label, voice_label}; mod local_api; mod mcp; mod navigation; +mod presentation; +mod shortcut_labels; use self::account::AccountState; use self::api_keys::ApiKeysState; use self::billing::BillingState; @@ -162,6 +165,8 @@ fn option_id(menu: SettingMenu, index: usize) -> gpui::SharedString { } pub struct SettingsScreen { + locale: fluent_templates::LanguageIdentifier, + messages: super::i18n::MessageCache, /// Backend-call bridges retained for thread-affinity; see /// [`crate::ui::task::call`]. bridged_tasks: std::cell::RefCell>>, @@ -186,20 +191,20 @@ pub struct SettingsScreen { /// Account MCP servers; None until loaded. mcp_servers: Option>, mcp_editor: Option, - mcp_notice: Option, + mcp_notice: Option, mcp_saving: bool, /// Device-local integrations discovered by the runtime; None until loaded. integrations: Option>, - integration_notice: Option, + integration_notice: Option, integration_saving: HashSet, /// Editor for the user's additional instructions (harness instructions). prompt_editor: Entity, - prompt_notice: Option, + prompt_notice: Option, shortcut_snapshot: ShortcutSnapshot, shortcut_search: Entity, shortcut_query: String, shortcut_list_cache: ShortcutListCache, - shortcut_notice: Option, + shortcut_notice: Option, shortcut_recorder: Option, shortcut_interceptor: Option, shortcut_reset_confirmation: bool, @@ -399,25 +404,27 @@ impl SettingsScreen { let application_focus = cx.focus_handle(); let prompt_application_focus = application_focus.clone(); let prompt_editor = cx.new(move |cx| { - let mut input = TextInput::new("Add instructions for every task…", cx) - .with_tab_index(1) - .multiline(16) - .spell_check() - .application_vim(application_vim_enabled) - .on_application_escape(move |window, cx| { - window.focus(&prompt_application_focus, cx) - }); + let mut input = + TextInput::localized("settings-settings-add-instructions-for-every-task", cx) + .with_tab_index(1) + .multiline(16) + .spell_check() + .application_vim(application_vim_enabled) + .on_application_escape(move |window, cx| { + window.focus(&prompt_application_focus, cx) + }); input.set_text(&prompt_text, cx); input }); let search_application_focus = application_focus.clone(); let shortcut_search = cx.new(move |cx| { - TextInput::new("Search by command, context, or shortcut…", cx) - .with_tab_index(2) - .application_vim(application_vim_enabled) - .on_application_escape(move |window, cx| { - window.focus(&search_application_focus, cx) - }) + TextInput::localized( + "settings-settings-search-by-command-context-or-shortcut", + cx, + ) + .with_tab_index(2) + .application_vim(application_vim_enabled) + .on_application_escape(move |window, cx| window.focus(&search_application_focus, cx)) }); cx.observe(&shortcut_search, |this, search, cx| { this.shortcut_query = search.read(cx).text().trim().to_lowercase(); @@ -434,6 +441,8 @@ impl SettingsScreen { let application_vim = SettingsApplicationVimState::new(section); let application_focus_pending = settings.application_vim_enabled; let this = Self { + locale: super::i18n::current_locale(), + messages: Default::default(), bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, integrations_backend, @@ -529,7 +538,7 @@ impl SettingsScreen { |this, result, cx| { match result { Ok(servers) => this.mcp_servers = Some(servers), - Err(message) => this.mcp_notice = Some(message), + Err(message) => this.mcp_notice = Some(message.into()), } if this.settings.application_vim_enabled { this.reconcile_application_vim_target(); @@ -553,7 +562,7 @@ impl SettingsScreen { } Err(message) => { this.integrations = Some(Vec::new()); - this.integration_notice = Some(message); + this.integration_notice = Some(message.into()); } } if this.settings.application_vim_enabled { @@ -584,8 +593,9 @@ impl SettingsScreen { return; }; if !currently_enabled && !can_enable { - self.integration_notice = - Some("This integration is not ready to enable on this device.".to_string()); + self.integration_notice = Some(UiText::key( + "settings-settings-this-integration-is-not-ready-to-enable-on-this-device", + )); cx.notify(); return; } @@ -610,7 +620,7 @@ impl SettingsScreen { this.integration_saving.remove(&id); match result { Ok(integrations) => this.integrations = Some(integrations), - Err(message) => this.integration_notice = Some(message), + Err(message) => this.integration_notice = Some(message.into()), } if this.settings.application_vim_enabled { this.reconcile_application_vim_target(); @@ -632,8 +642,9 @@ impl SettingsScreen { .find(|integration| integration.id == id) .is_some_and(integration_can_setup); if !can_setup { - self.integration_notice = - Some("This integration does not need setup on this device.".to_string()); + self.integration_notice = Some(UiText::key( + "settings-settings-this-integration-does-not-need-setup-on-this-device", + )); cx.notify(); return; } @@ -642,7 +653,7 @@ impl SettingsScreen { let permissions = match self.integrations_backend.begin_integration_setup(&id) { Ok(permissions) => permissions, Err(message) => { - self.integration_notice = Some(message); + self.integration_notice = Some(message.into()); cx.notify(); return; } @@ -674,8 +685,8 @@ impl SettingsScreen { } Err(message) => { this.integration_notice = Some(match this.integration_notice.take() { - Some(notice) => format!("{notice} {message}"), - None => message, + Some(notice) => format!("{notice} {message}").into(), + None => message.into(), }); } } @@ -706,7 +717,7 @@ impl SettingsScreen { this.mcp_servers = Some(servers); this.mcp_editor = None; } - Err(message) => this.mcp_notice = Some(message), + Err(message) => this.mcp_notice = Some(message.into()), } if this.settings.application_vim_enabled { this.reconcile_application_vim_target(); @@ -753,7 +764,12 @@ impl SettingsScreen { name: presence.name.clone(), has_value: presence.has_value, action: McpSecretAction::Keep, - input: field("Enter a new value", "", 10 + index as isize, true), + input: field( + &tr("settings-settings-enter-a-new-value"), + "", + 10 + index as isize, + true, + ), }) .collect(); let saved_headers = existing @@ -766,7 +782,12 @@ impl SettingsScreen { name: presence.name.clone(), has_value: presence.has_value, action: McpSecretAction::Keep, - input: field("Enter a new value", "", 200 + index as isize, true), + input: field( + &tr("settings-settings-enter-a-new-value"), + "", + 200 + index as isize, + true, + ), }) .collect(); self.mcp_editor = Some(McpEditor { @@ -776,13 +797,13 @@ impl SettingsScreen { http, timeout_seconds: existing.as_ref().map(|s| s.timeout_seconds).unwrap_or(300), name: field( - "My server", + &tr("settings-settings-my-server"), existing.as_ref().map(|s| s.name.as_str()).unwrap_or(""), 1, false, ), description: field( - "What this server helps the agent do", + &tr("settings-settings-what-this-server-helps-the-agent-do"), existing .as_ref() .map(|s| s.description.as_str()) @@ -811,22 +832,25 @@ impl SettingsScreen { }; let name = editor.name.read(cx).text().trim().to_string(); if name.is_empty() { - self.mcp_notice = Some("Enter a server name".to_string()); + self.mcp_notice = Some(UiText::key("settings-settings-enter-a-server-name")); cx.notify(); return; } let existing = self.mcp_servers.as_deref().unwrap_or_default(); if name_collides(existing, editor.original_name.as_deref(), &name) { - self.mcp_notice = Some(format!("A server named {name:?} already exists")); + self.mcp_notice = Some(UiText::message( + "settings-server-exists", + [("name", (name).to_string().into())], + )); cx.notify(); return; } let target = editor.target.read(cx).text().trim().to_string(); if target.is_empty() { self.mcp_notice = Some(if editor.http { - "Enter the server URL".to_string() + UiText::key("settings-settings-enter-the-server-url") } else { - "Enter the server command".to_string() + UiText::key("settings-settings-enter-the-server-command") }); cx.notify(); return; @@ -866,13 +890,13 @@ impl SettingsScreen { }) { return Err( - "Clear each saved HTTP header before switching to Standard IO.".to_string(), + tr("settings-settings-clear-each-saved-http-header-before-switching-to-standard-io").to_string(), ); } Ok(()) })(); if let Err(message) = edits { - self.mcp_notice = Some(message); + self.mcp_notice = Some(message.into()); cx.notify(); return; } @@ -1306,7 +1330,9 @@ impl SettingsScreen { settings::update_settings_in_background(move |s| s.harness_instructions = instructions); self.backend .set_harness_instructions(self.settings.effective_harness_instructions()); - self.prompt_notice = Some("Saved. New tasks use these instructions.".to_string()); + self.prompt_notice = Some(UiText::key( + "settings-settings-saved-new-tasks-use-these-instructions", + )); cx.notify(); } @@ -1336,8 +1362,11 @@ impl SettingsScreen { self.reconcile_application_vim_target(); } self.shortcut_notice = Some(match result { - Ok(()) => "Shortcut settings applied.".to_string(), - Err(message) => format!("Shortcut change failed: {message}"), + Ok(()) => UiText::key("settings-settings-shortcut-settings-applied"), + Err(message) => UiText::message( + "settings-shortcut-change-failed", + [("message", (message).to_string().into())], + ), }); cx.notify(); } @@ -1382,7 +1411,9 @@ impl SettingsScreen { match (plain, keystroke.key.as_str()) { (true, "escape") => { self.stop_shortcut_recording(); - self.shortcut_notice = Some("Shortcut recording cancelled.".to_string()); + self.shortcut_notice = Some(UiText::key( + "settings-settings-shortcut-recording-cancelled", + )); } (true, "backspace") => { if let Some(recorder) = self.shortcut_recorder.as_mut() { @@ -1397,8 +1428,9 @@ impl SettingsScreen { if recorder.strokes.len() < 4 { recorder.strokes.push(portable_keystroke_token(keystroke)); } else { - self.shortcut_notice = - Some("A shortcut can contain at most four strokes.".to_string()); + self.shortcut_notice = Some(UiText::key( + "settings-settings-a-shortcut-can-contain-at-most-four-strokes", + )); } } } @@ -1410,7 +1442,9 @@ impl SettingsScreen { return; }; if recorder.strokes.is_empty() { - self.shortcut_notice = Some("Press at least one shortcut key.".to_string()); + self.shortcut_notice = Some(UiText::key( + "settings-settings-press-at-least-one-shortcut-key", + )); return; } let slot_id = recorder.slot_id.clone(); @@ -1437,7 +1471,10 @@ impl SettingsScreen { } } Err(message) => { - self.shortcut_notice = Some(format!("Cannot use that shortcut: {message}")); + self.shortcut_notice = Some(UiText::message( + "settings-shortcut-invalid", + [("message", (message).to_string().into())], + )); } } } @@ -1517,6 +1554,14 @@ fn merge_shortcut_overrides(settings: &mut AppSettings, shortcut_overrides: Shor impl Render for SettingsScreen { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let locale = super::i18n::current_locale(); + if self.locale != locale { + self.locale = locale; + self.rebuild_shortcut_list_cache(); + self.refresh_credit_placeholder(cx); + #[cfg(feature = "acp")] + self.refresh_acp_placeholder(cx); + } // First, so focus requested below wins over a dropdown handing it // back. self.popup.sync_focus(window, cx); @@ -1740,36 +1785,36 @@ impl SettingsScreen { .flex() .items_baseline() .gap_2() - .child(section_title("Plan")) + .child(section_title(&tr("settings-settings-plan"))) .when_some(self.plan.as_ref(), |row, plan| { row.child( div() .text_sm() .font_weight(gpui::FontWeight::MEDIUM) .text_color(gpui::rgb(theme::accent())) - .child(plan.plan_label.clone()), + .child(presentation::plan_name(plan, &self.messages)), ) }), ); pane = pane.child(match &self.plan { - Some(plan) => plan_card(plan), + Some(plan) => plan_card(plan, &self.messages), None => div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("Plan usage unavailable"), + .child(tr("settings-settings-plan-usage-unavailable")), }); } Section::About => { pane = pane - .child(section_title("About")) + .child(section_title(&tr("settings-settings-about"))) .child(info_row( - "Version", + &tr("settings-settings-version"), format!("v{}", crate::ui::titlebar::TitleBar::version()), )) - .child(info_row("Update", self.update.label.clone())) + .child(info_row(&tr("settings-settings-update"), presentation::update_label(&self.update, &self.messages))) .child(toggle_row( - "Automatically download updates", - "Download verified updates in the background. Installation always waits for Restart to Update.", + tr("settings-settings-automatically-download-updates"), + tr("settings-settings-download-verified-updates-in-the-background-installation-always-w"), self.settings.automatic_update_downloads, cx.listener(|this, _, _, cx| { let enabled = !this.settings.automatic_update_downloads; @@ -1780,26 +1825,26 @@ impl SettingsScreen { .child(div().flex().flex_wrap().gap_3() .child(widgets::ghost_button("updates-check") .on_click(cx.listener(|_, _, _, cx| cx.emit(crate::update::UpdateRequested(crate::update::UpdateAction::Check)))) - .child(if self.update.busy() { "Checking / updating…" } else if self.update.state == crate::update::State::Error { "Retry" } else { "Check for Updates" })) + .child(if self.update.busy() { tr("settings-settings-checking-updating") } else if self.update.state == crate::update::State::Error { tr("settings-settings-retry") } else { tr("settings-settings-check-for-updates") })) .when_some(self.update.primary.filter(|action| *action != crate::update::UpdateAction::Check), |row, action| row.child( widgets::ghost_button("updates-primary") .on_click(cx.listener(move |_, _, _, cx| cx.emit(crate::update::UpdateRequested(action)))) - .child(self.update.primary_label) + .child(presentation::update_action(action)) )) .when(self.update.state == crate::update::State::Error && self.update.manual_download_url.is_some(), |row| row.child( widgets::ghost_button("updates-manual") .on_click(cx.listener(|_, _, _, cx| cx.emit(crate::update::UpdateRequested(crate::update::UpdateAction::ManualDownload)))) - .child("Download manually") + .child(tr("settings-settings-download-manually")) )) .when(self.update.state == crate::update::State::Ready, |row| row.child( widgets::ghost_button("updates-later") .on_click(cx.listener(|_, _, _, cx| cx.emit(crate::update::UpdateRequested(crate::update::UpdateAction::Later)))) - .child("Later") + .child(tr("settings-settings-later")) )) ) - .child(info_row("Backend", self.backend.api_url().to_string())) + .child(info_row(&tr("settings-settings-backend"), self.backend.api_url().to_string())) .child(info_row( - "Instruction directory", + &tr("settings-settings-instruction-directory"), crate::backend::instruction_config_root() .to_string_lossy() .to_string(), @@ -1824,7 +1869,7 @@ impl SettingsScreen { .flex() .items_center() .justify_between() - .child(section_title("Keyboard Shortcuts")) + .child(section_title(&tr("settings-settings-keyboard-shortcuts"))) .when( modified_count > 0 || self.shortcut_snapshot.last_error.is_some(), |row| { @@ -1835,7 +1880,7 @@ impl SettingsScreen { this.shortcut_notice = None; cx.notify(); })) - .child("Reset all"), + .child(tr("settings-settings-reset-all")), ) }, ), @@ -1845,7 +1890,7 @@ impl SettingsScreen { .text_sm() .text_color(gpui::rgb(theme::text_muted())) .child( - "Customize the shortcuts Maple already ships. This page does not add commands or change what an action can do.", + tr("settings-settings-customize-the-shortcuts-maple-already-ships-this-page-does-not-ad"), ), ) .child(widgets::input_frame().text_sm().child(self.shortcut_search.clone())) @@ -1853,17 +1898,19 @@ impl SettingsScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child(format!( - "{} bindings · {modified_count} modified", - self.shortcut_snapshot.rows.len() - )), + .child(self.messages.get("settings-shortcut-counts", [("arg1", (self.shortcut_snapshot.rows.len()).to_string().into()), ("modified-count", (modified_count).to_string().into())]).to_string()), ); if let Some(error) = &self.shortcut_snapshot.last_error { pane = pane.child( - widgets::banner(theme::status_warning()).child(format!( - "Saved shortcut overrides were not applied; Maple kept the complete default map. {error}" - )), + widgets::banner(theme::status_warning()).child( + self.messages + .get( + "settings-shortcut-overrides-invalid", + [("error", (error).to_string().into())], + ) + .to_string(), + ), ); } if let Some(warning) = &self.shortcut_snapshot.compatibility_warning { @@ -1880,12 +1927,15 @@ impl SettingsScreen { .gap_3() .child(div().flex_1().child( if self.shortcut_snapshot.last_error.is_some() && modified_count == 0 { - "Clear the invalid saved shortcut overrides?".to_string() + tr("settings-settings-clear-the-invalid-saved-shortcut-overrides") + .to_string() } else { - format!( - "Reset all {modified_count} shortcut change{}?", - if modified_count == 1 { "" } else { "s" } - ) + self.messages + .get( + "settings-shortcut-reset-confirm", + [("modified-count", (modified_count).to_string().into())], + ) + .to_string() }, )) .child( @@ -1893,7 +1943,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.request_shortcut_change(ShortcutSettingsChange::ResetAll, cx); })) - .child("Reset"), + .child(tr("settings-settings-reset")), ) .child( widgets::ghost_button("shortcuts-reset-all-cancel") @@ -1901,7 +1951,7 @@ impl SettingsScreen { this.shortcut_reset_confirmation = false; cx.notify(); })) - .child("Cancel"), + .child(tr("settings-settings-cancel")), ), ); } @@ -1914,7 +1964,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("No shortcuts match this search."), + .child(tr("settings-settings-no-shortcuts-match-this-search")), ); } else { pane = @@ -1931,11 +1981,11 @@ impl SettingsScreen { .rows .iter() .find(|row| row.slot_id == recorder.slot_id) - .map(|row| row.label.as_str()) - .unwrap_or(recorder.slot_id.as_str()) + .map(|row| shortcut_labels::label(&row.label)) + .unwrap_or_else(|| recorder.slot_id.clone().into()) .to_string(); let sequence = if recorder.strokes.is_empty() { - "Waiting for keys…".to_string() + tr("settings-settings-waiting-for-keys").to_string() } else { recorder.strokes.join(" ") }; @@ -1953,7 +2003,7 @@ impl SettingsScreen { .text_sm() .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(gpui::rgb(theme::text_primary())) - .child(format!("Recording: {label}")), + .child(self.messages.get("settings-shortcut-recording", [("label", (label).to_string().into())]).to_string()), ) .child(shortcut_keycap(sequence, true)) .child( @@ -1961,9 +2011,9 @@ impl SettingsScreen { .text_xs() .text_color(gpui::rgb(theme::text_muted())) .child(if recorder.reviewing_conflicts { - "Review the conflicts below before saving." + tr("settings-settings-review-the-conflicts-below-before-saving") } else { - "Press up to four keys. Enter saves, Backspace removes the latest stroke, and Escape cancels." + tr("settings-settings-press-up-to-four-keys-enter-saves-backspace-removes-the-latest-st") }), ); @@ -1979,17 +2029,26 @@ impl SettingsScreen { .rows .iter() .find(|row| row.slot_id == conflict.other_slot_id) - .map(|row| row.label.as_str()) - .unwrap_or(conflict.other_slot_id.as_str()); + .map(|row| shortcut_labels::label(&row.label)) + .unwrap_or_else(|| conflict.other_slot_id.clone().into()); div() .text_xs() .text_color(gpui::rgb(theme::status_warning())) - .child(format!( - "{} with {} ({})", - shortcut_conflict_label(conflict), - other, - conflict.other_slot_id - )) + .child( + self.messages + .get( + "settings-shortcut-collision", + [ + ( + "arg1", + (shortcut_conflict_label(conflict)).to_string().into(), + ), + ("arg2", (other).to_string().into()), + ("arg3", (conflict.other_slot_id).to_string().into()), + ], + ) + .to_string(), + ) })) .child( div() @@ -2000,7 +2059,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.save_recorded_shortcut(true, cx); })) - .child("Replace conflicts"), + .child(tr("settings-settings-replace-conflicts")), ) .when(can_keep_both, |actions| { actions.child( @@ -2008,7 +2067,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.save_recorded_shortcut(false, cx); })) - .child("Keep both"), + .child(tr("settings-settings-keep-both")), ) }) .child( @@ -2017,7 +2076,7 @@ impl SettingsScreen { this.stop_shortcut_recording(); cx.notify(); })) - .child("Cancel"), + .child(tr("settings-settings-cancel")), ), ); } else { @@ -2027,7 +2086,7 @@ impl SettingsScreen { this.stop_shortcut_recording(); cx.notify(); })) - .child("Cancel"), + .child(tr("settings-settings-cancel")), ); } card @@ -2045,7 +2104,7 @@ impl SettingsScreen { let current = row .current_sequence .clone() - .unwrap_or_else(|| "Unbound".to_string()); + .unwrap_or_else(|| tr("settings-settings-unbound").to_string()); let card = widgets::card_row() .flex() .items_center() @@ -2067,7 +2126,7 @@ impl SettingsScreen { .text_sm() .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(gpui::rgb(theme::text_primary())) - .child(row.label.clone()), + .child(shortcut_labels::label(&row.label)), ) .when(row.modified, |title| { title.child( @@ -2078,7 +2137,7 @@ impl SettingsScreen { .text_xs() .bg(gpui::rgb(theme::bg_sidebar_pill())) .text_color(gpui::rgb(theme::accent())) - .child("Modified"), + .child(tr("settings-settings-modified")), ) }), ) @@ -2088,7 +2147,7 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_muted())) .child(format!( "{} · {}", - row.category, + shortcut_labels::category(&row.category), shortcut_context_label(row.context.as_deref()), )), ) @@ -2097,11 +2156,14 @@ impl SettingsScreen { div() .text_xs() .text_color(gpui::rgb(theme::status_warning())) - .child(format!( - "{} shortcut conflict{}", - row.conflicts.len(), - if row.conflicts.len() == 1 { "" } else { "s" } - )), + .child( + self.messages + .get( + "settings-shortcut-conflicts", + [("arg1", (row.conflicts.len()).to_string().into())], + ) + .to_string(), + ), ) }), ) @@ -2116,7 +2178,14 @@ impl SettingsScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child(format!("Default: {}", row.default_sequence)), + .child( + self.messages + .get( + "settings-shortcut-default", + [("arg1", (row.default_sequence).to_string().into())], + ) + .to_string(), + ), ), ) .child( @@ -2131,7 +2200,7 @@ impl SettingsScreen { .on_click(cx.listener(move |this, _event, _window, cx| { this.begin_shortcut_recording(record_id.clone(), cx); })) - .child("Record"), + .child(tr("settings-settings-record")), ) .when(row.current_sequence.is_some(), |actions| { actions.child( @@ -2146,7 +2215,7 @@ impl SettingsScreen { cx, ); })) - .child("Disable"), + .child(tr("settings-settings-disable")), ) }) .when(row.modified, |actions| { @@ -2162,7 +2231,7 @@ impl SettingsScreen { cx, ); })) - .child("Reset"), + .child(tr("settings-settings-reset")), ) }), ); @@ -2174,15 +2243,13 @@ impl SettingsScreen { .flex() .flex_col() .gap_4() - .child(section_title("Additional instructions")) + .child(section_title(&tr("settings-settings-additional-instructions"))) .child( div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) .child( - "Added to every task after Maple's own system prompt. Use it for \ - preferences such as tone, language, or how you like to work. \ - Changes apply to tasks started after you save.", + tr("settings-settings-added-to-every-task-after-maple-s-own-system-prompt-use-it-for-pr"), ), ) .child( @@ -2207,7 +2274,7 @@ impl SettingsScreen { || SettingsTarget::PromptSave, pill_button( "prompt-save".to_string(), - "Save", + tr("settings-settings-save"), true, cx.listener(|this, _event, _window, cx| this.save_prompt(cx)), ), @@ -2216,7 +2283,7 @@ impl SettingsScreen { || SettingsTarget::PromptClear, pill_button( "prompt-clear".to_string(), - "Clear", + tr("settings-settings-clear"), false, cx.listener(|this, _event, _window, cx| this.clear_prompt(cx)), ), @@ -2238,22 +2305,22 @@ impl SettingsScreen { .flex() .flex_col() .gap_4() - .child(section_title("Integrations")) + .child(section_title(&tr("settings-settings-integrations"))) .child(div().text_sm().child(match self.integrations_backend.remote_host() { - Some(host) => format!("Execution computer: {}. Native setup and permissions must be completed there.", &host[..host.len().min(10)]), - None => "Execution computer: This computer".to_string(), + Some(host) => self.messages.get("settings-execution-computer", [("arg1", (host[..host.len().min(10)]).to_string().into())]).to_string(), + None => tr("settings-settings-execution-computer-this-computer").to_string(), })) .child( div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("Manage integrations here. Enable coding agents to make them available in the composer, then select them for each task."), + .child(tr("settings-settings-manage-integrations-here-enable-coding-agents-to-make-them-availa")), ); if let Some(notice) = &self.integration_notice { pane = pane.child(widgets::notice( "integration-notice-close", - gpui::SharedString::from(notice.clone()), + notice.resolve(), cx.listener(|this, _event, _window, cx| { this.integration_notice = None; cx.notify(); @@ -2267,7 +2334,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("Detecting integrations…"), + .child(tr("settings-settings-detecting-integrations")), ); } Some(integrations) => { @@ -2284,7 +2351,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("No supported integrations detected on this device."), + .child(tr("settings-settings-no-supported-integrations-detected-on-this-device")), ); } } @@ -2299,7 +2366,9 @@ impl SettingsScreen { .pt_5() .border_t_1() .border_color(gpui::rgb(theme::border_subtle())) - .child(subsection_title("Custom MCP servers")) + .child(subsection_title(&tr( + "settings-settings-custom-mcp-servers", + ))) .child( self.application_target( || SettingsTarget::McpAdd, @@ -2308,7 +2377,7 @@ impl SettingsScreen { this.open_mcp_editor(None, cx); })) .child(icon("plus", widgets::ROW_ICON, theme::on_accent())) - .child("Add server"), + .child(tr("settings-settings-add-server")), ), ), ); @@ -2316,7 +2385,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("Connect additional tools over MCP."), + .child(tr("settings-settings-connect-additional-tools-over-mcp")), ); if let Some(notice) = &self.mcp_notice { pane = pane.child(widgets::banner(theme::status_warning()).child(notice.clone())); @@ -2329,7 +2398,7 @@ impl SettingsScreen { pane = pane.child( div() .text_color(gpui::rgb(theme::text_muted())) - .child("Loading custom MCP servers…"), + .child(tr("settings-settings-loading-custom-mcp-servers")), ); } Some(servers) if servers.is_empty() => { @@ -2337,7 +2406,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("No custom MCP servers configured."), + .child(tr("settings-settings-no-custom-mcp-servers-configured")), ); } Some(servers) => { @@ -2384,9 +2453,9 @@ impl SettingsScreen { .child(pill_button( format!("mcp-toggle-{name}"), if server.enabled { - "Enabled" + tr("settings-settings-enabled") } else { - "Disabled" + tr("settings-settings-disabled") }, server.enabled, cx.listener({ @@ -2398,7 +2467,7 @@ impl SettingsScreen { widgets::icon_button( gpui::SharedString::from(format!("mcp-edit-{name}")), "pencil", - "Edit", + tr("settings-settings-edit"), widgets::ROW_ICON, theme::text_secondary(), ) @@ -2411,7 +2480,7 @@ impl SettingsScreen { widgets::icon_button( gpui::SharedString::from(format!("mcp-remove-{name}")), "trash-2", - "Remove", + tr("settings-settings-remove"), widgets::ROW_ICON, theme::status_error(), ) @@ -2437,8 +2506,13 @@ impl SettingsScreen { let saving = self.integration_saving.contains(&integration.id); let enabled = integration.enabled_for_new_tasks; let cua_driver = is_cua_driver(&integration.id); - let display_name = integration.name.clone(); - let description = integration.description.clone(); + let display_name = if cua_driver { + tr("settings-integration-cua-name") + } else { + integration.name.clone().into() + }; + let description = + presentation::integration_description(&integration.id, &integration.description); let mut details = div() .flex_1() .min_w_0() @@ -2476,7 +2550,7 @@ impl SettingsScreen { .gap_1() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child(CUA_CREDIT) + .child(tr(CUA_CREDIT)) .child( div() .flex() @@ -2488,7 +2562,7 @@ impl SettingsScreen { .on_click(|_, _, _| { crate::ui::rich_text::open_link(CUA_PROJECT_URL) }) - .child("Cua team"), + .child(tr("settings-settings-cua-team")), ) .child("."), ), @@ -2501,8 +2575,9 @@ impl SettingsScreen { .flat_map(|permissions| permissions.required.iter()) { metadata = metadata.child(cua_permission_badge( - permission.kind.label(), + presentation::permission(permission.kind), permission.granted, + &self.messages, )); } details = details.child(metadata); @@ -2511,7 +2586,14 @@ impl SettingsScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child(format!("Version {version}")), + .child( + self.messages + .get( + "settings-integration-version", + [("version", (version).to_string().into())], + ) + .to_string(), + ), ); } if let Some(detail) = &integration.detail { @@ -2534,7 +2616,7 @@ impl SettingsScreen { || SettingsTarget::IntegrationSetup(integration.id.clone()), pill_button( format!("integration-setup-{}", integration.id), - "Set up", + tr("settings-settings-set-up"), false, cx.listener(move |this, _event, _window, cx| { this.setup_integration(&id, cx); @@ -2551,7 +2633,11 @@ impl SettingsScreen { || SettingsTarget::Integration(integration.id.clone()), pill_button( format!("integration-toggle-{}", integration.id), - if enabled { "Enabled" } else { "Enable" }, + if enabled { + tr("settings-settings-enabled") + } else { + tr("settings-settings-enable") + }, enabled, cx.listener(move |this, _event, _window, cx| { this.toggle_integration(&id, cx); @@ -2607,7 +2693,7 @@ impl SettingsScreen { } fn render_mcp_editor(&self, editor: &McpEditor, cx: &mut Context) -> Div { - let field = |label: &'static str, hint: &'static str, input: Entity| { + let field = |label: &str, hint: &str, input: Entity| { div() .flex() .flex_col() @@ -2617,7 +2703,7 @@ impl SettingsScreen { .text_xs() .font_weight(gpui::FontWeight::MEDIUM) .text_color(gpui::rgb(theme::text_secondary())) - .child(label), + .child(label.to_string()), ) .child(widgets::input_frame().text_sm().child(input)) .when(!hint.is_empty(), |col| { @@ -2625,11 +2711,11 @@ impl SettingsScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child(hint), + .child(hint.to_string()), ) }) }; - let transport_segment = |id: &'static str, label: &'static str, active: bool| { + let transport_segment = |id: &'static str, label: gpui::SharedString, active: bool| { div() .id(id) .px_3() @@ -2664,13 +2750,13 @@ impl SettingsScreen { .font_weight(gpui::FontWeight::MEDIUM) .text_color(gpui::rgb(theme::text_primary())) .child(if editor.original_name.is_some() { - "Edit server" + tr("settings-settings-edit-server") } else { - "New server" + tr("settings-settings-new-server") }), ) - .child(field("Name", "", editor.name.clone())) - .child(field("Description", "", editor.description.clone())) + .child(field(&tr("settings-settings-name"), "", editor.name.clone())) + .child(field(&tr("settings-settings-description"), "", editor.description.clone())) .child( div() .flex() @@ -2681,7 +2767,7 @@ impl SettingsScreen { .text_xs() .font_weight(gpui::FontWeight::MEDIUM) .text_color(gpui::rgb(theme::text_secondary())) - .child("Transport"), + .child(tr("settings-settings-transport")), ) .child( div() @@ -2694,7 +2780,7 @@ impl SettingsScreen { .child( transport_segment( "mcp-transport-stdio", - "Standard IO (STDIO)", + tr("settings-settings-standard-io-stdio"), !editor.http, ) .on_click(cx.listener( @@ -2709,7 +2795,7 @@ impl SettingsScreen { .child( transport_segment( "mcp-transport-http", - "Streamable HTTP", + tr("settings-settings-streamable-http"), editor.http, ) .on_click(cx.listener( @@ -2724,27 +2810,27 @@ impl SettingsScreen { ), ) .child(if editor.http { - field("URL", "", editor.target.clone()) + field(&tr("settings-settings-url"), "", editor.target.clone()) } else { - field("Command", "", editor.target.clone()) + field(&tr("settings-settings-command"), "", editor.target.clone()) }) .when(!editor.saved_environment.is_empty(), |col| { - col.child(subsection_title("Environment values")) + col.child(subsection_title(&tr("settings-settings-environment-values"))) .child(self.render_mcp_secret_fields(&editor.saved_environment, false, cx)) }) .when(!editor.saved_headers.is_empty(), |col| { - col.child(subsection_title("Header values")) + col.child(subsection_title(&tr("settings-settings-header-values"))) .child(self.render_mcp_secret_fields(&editor.saved_headers, true, cx)) }) .child(field( - "New environment values", - "KEY=value pairs separated by semicolons. Existing values use the choices above.", + &tr("settings-settings-new-environment-values"), + &tr("settings-settings-key-value-pairs-separated-by-semicolons-existing-values-use-the-c"), editor.environment.clone(), )) .when(editor.http, |col| { col.child(field( - "New headers", - "Name=value pairs separated by semicolons.", + &tr("settings-settings-new-headers"), + &tr("settings-settings-name-value-pairs-separated-by-semicolons"), editor.headers.clone(), )) }) @@ -2760,7 +2846,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.submit_mcp_editor(cx); })) - .child("Save"), + .child(tr("settings-settings-save")), ) .child( widgets::ghost_button("mcp-cancel") @@ -2769,7 +2855,7 @@ impl SettingsScreen { this.mcp_notice = None; cx.notify(); })) - .child("Cancel"), + .child(tr("settings-settings-cancel")), ), ) } @@ -2777,7 +2863,7 @@ impl SettingsScreen { /// Plan usage meter: plan pill, percent used, reset date, progress bar. /// Mirrors the sidebar card at a larger size. -fn plan_card(plan: &crate::billing::PlanUsage) -> Div { +fn plan_card(plan: &crate::billing::PlanUsage, messages: &super::i18n::MessageCache) -> Div { let fraction = f32::from(plan.percent_used) / 100.0; div() .w_full() @@ -2799,14 +2885,31 @@ fn plan_card(plan: &crate::billing::PlanUsage) -> Div { .font_weight(gpui::FontWeight::BOLD) .text_color(gpui::rgb(theme::text_primary())) .whitespace_nowrap() - .child(format!("{}% used", plan.percent_used)), + .child( + messages + .get( + "settings-usage-percent", + [("arg1", (plan.percent_used).to_string().into())], + ) + .to_string(), + ), ) .child( div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) .whitespace_nowrap() - .child(format!("· Resets {}", plan.resets_label)), + .child( + messages + .get( + "settings-usage-resets", + [( + "arg1", + presentation::reset_date(plan, messages).to_string().into(), + )], + ) + .to_string(), + ), ), ) .child( @@ -2872,7 +2975,7 @@ fn integration_can_setup(integration: &AgentIntegration) -> bool { && !cua_permissions_ready(integration.permissions.as_ref()) } -fn integration_availability(integration: &AgentIntegration) -> (&'static str, u32) { +fn integration_availability(integration: &AgentIntegration) -> (gpui::SharedString, u32) { if integration.enabled_for_new_tasks && is_cua_driver(&integration.id) && matches!( @@ -2881,20 +2984,27 @@ fn integration_availability(integration: &AgentIntegration) -> (&'static str, u3 ) && !cua_permissions_ready(integration.permissions.as_ref()) { - return ("Needs setup", theme::status_warning()); + return (tr("settings-settings-needs-setup"), theme::status_warning()); } match &integration.availability { - AgentIntegrationAvailability::NotDetected => ("Not detected", theme::text_muted()), + AgentIntegrationAvailability::NotDetected => { + (tr("settings-settings-not-detected"), theme::text_muted()) + } AgentIntegrationAvailability::Available if is_cua_driver(&integration.id) => { if cua_permissions_ready(integration.permissions.as_ref()) { - ("Ready", theme::status_success()) + (tr("settings-settings-ready"), theme::status_success()) } else { - ("Available", theme::status_success()) + (tr("settings-settings-available"), theme::status_success()) } } - AgentIntegrationAvailability::Available => ("Detected", theme::status_success()), - AgentIntegrationAvailability::SetupRequired => ("Setup required", theme::status_warning()), + AgentIntegrationAvailability::Available => { + (tr("settings-settings-detected"), theme::status_success()) + } + AgentIntegrationAvailability::SetupRequired => ( + tr("settings-settings-setup-required"), + theme::status_warning(), + ), } } @@ -2902,22 +3012,29 @@ fn cua_permissions_ready(permissions: Option<&AgentIntegrationPermissions>) -> b permissions.is_some_and(AgentIntegrationPermissions::ready) } -fn integration_setup_notice(permissions: &AgentIntegrationPermissions) -> Option { - let missing = permissions.first_missing()?; - let guidance = missing.guidance()?; - Some(format!("{}: {guidance}", missing.label())) +fn integration_setup_notice(permissions: &AgentIntegrationPermissions) -> Option { + use maple_agent::agent::AgentIntegrationPermissionKind; + Some(UiText::key(match permissions.first_missing()? { + AgentIntegrationPermissionKind::Accessibility => { + "settings-permission-guidance-accessibility" + } + AgentIntegrationPermissionKind::ScreenRecording => { + "settings-permission-guidance-screen-recording" + } + AgentIntegrationPermissionKind::DesktopHelper => return None, + })) } /// Credit for the open-source project behind computer use, whose mark is /// the card's icon. The card follows it with "Cua team", linked. -const CUA_CREDIT: &str = "Powered by Cua, an open-source project from the"; +const CUA_CREDIT: &str = "settings-settings-powered-by-cua-an-open-source-project-from-the"; const CUA_PROJECT_URL: &str = "https://cua.ai"; -fn cua_backend_label(_integration: &AgentIntegration) -> &'static str { - "Built into Maple" +fn cua_backend_label(_integration: &AgentIntegration) -> gpui::SharedString { + tr("settings-settings-built-into-maple") } -fn integration_metadata_badge(label: &'static str) -> Div { +fn integration_metadata_badge(label: gpui::SharedString) -> Div { div() .px_2() .py_0p5() @@ -2929,11 +3046,15 @@ fn integration_metadata_badge(label: &'static str) -> Div { .child(label) } -fn cua_permission_badge(label: &'static str, granted: bool) -> Div { +fn cua_permission_badge( + label: gpui::SharedString, + granted: bool, + messages: &super::i18n::MessageCache, +) -> Div { let (status, color) = if granted { - ("on", theme::status_success()) + ("settings-permission-granted", theme::status_success()) } else { - ("needed", theme::status_warning()) + ("settings-permission-needed", theme::status_warning()) }; div() .px_2() @@ -2947,7 +3068,7 @@ fn cua_permission_badge(label: &'static str, granted: bool) -> Div { .text_xs() .text_color(gpui::rgb(theme::text_muted())) .child(div().size(px(5.)).rounded_full().bg(gpui::rgb(color))) - .child(format!("{label} {status}")) + .child(messages.get(status, [("permission", label.to_string().into())])) } fn is_cua_driver(id: &str) -> bool { @@ -2957,7 +3078,7 @@ fn is_cua_driver(id: &str) -> bool { /// Small on/off pill used in list rows. fn pill_button( id: String, - label: &'static str, + label: gpui::SharedString, on: bool, handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, ) -> gpui::Stateful
{ @@ -3021,26 +3142,40 @@ fn shortcut_keycap(label: String, bound: bool) -> Div { .child(label) } -fn shortcut_context_label(context: Option<&str>) -> &str { +fn shortcut_context_label(context: Option<&str>) -> gpui::SharedString { match context { - None => "Global", - Some("Chat") => "Chat", - Some("Transcript") => "Transcript", - Some(crate::ui::popup::MENU_CONTEXT) => "Menus", - Some("TextInput") | Some(crate::keymap::STANDARD_TEXT_CONTEXT) => "Text fields", - Some(crate::ui::text_input::vim_actions::NORMAL_CONTEXT) => "Composer — Normal", - Some(crate::ui::text_input::vim_actions::VISUAL_CONTEXT) => "Composer — Visual", - Some(crate::ui::text_input::vim_actions::INSERT_CONTEXT) => "Composer — Insert", - Some(crate::ui::text_input::vim_actions::WAITING_CONTEXT) => "Composer — Pending key", - Some(context) => context, + None => tr("settings-settings-global"), + Some("Chat") => tr("settings-settings-chat"), + Some("Transcript") => tr("settings-settings-transcript"), + Some(crate::ui::popup::MENU_CONTEXT) => tr("settings-settings-menus"), + Some("TextInput") | Some(crate::keymap::STANDARD_TEXT_CONTEXT) => { + tr("settings-settings-text-fields") + } + Some(crate::ui::text_input::vim_actions::NORMAL_CONTEXT) => { + tr("settings-settings-composer-normal") + } + Some(crate::ui::text_input::vim_actions::VISUAL_CONTEXT) => { + tr("settings-settings-composer-visual") + } + Some(crate::ui::text_input::vim_actions::INSERT_CONTEXT) => { + tr("settings-settings-composer-insert") + } + Some(crate::ui::text_input::vim_actions::WAITING_CONTEXT) => { + tr("settings-settings-composer-pending-key") + } + Some(context) => context.to_string().into(), } } fn shortcut_row_matches(row: &crate::shortcuts::ShortcutRow, query: &str) -> bool { query.is_empty() - || row.label.to_lowercase().contains(query) + || shortcut_labels::label(&row.label) + .to_lowercase() + .contains(query) || row.slot_id.to_lowercase().contains(query) - || row.category.to_lowercase().contains(query) + || shortcut_labels::category(&row.category) + .to_lowercase() + .contains(query) || row .context .as_deref() @@ -3059,13 +3194,13 @@ fn shortcut_row_matches(row: &crate::shortcuts::ShortcutRow, query: &str) -> boo fn shortcut_conflict_label(conflict: &ShortcutConflict) -> String { let kind = match conflict.kind { - ShortcutConflictKind::Exact => "Exact collision", - ShortcutConflictKind::Prefix => "Prefix collision", + ShortcutConflictKind::Exact => tr("settings-settings-exact-collision"), + ShortcutConflictKind::Prefix => tr("settings-settings-prefix-collision"), }; let overlap = match conflict.overlap { - ShortcutContextOverlap::Equivalent => "same context", - ShortcutContextOverlap::Scoped => "overlapping scoped context", - ShortcutContextOverlap::Possible => "possibly overlapping context", + ShortcutContextOverlap::Equivalent => tr("settings-settings-same-context"), + ShortcutContextOverlap::Scoped => tr("settings-settings-overlapping-scoped-context"), + ShortcutContextOverlap::Possible => tr("settings-settings-possibly-overlapping-context"), }; format!("{kind}, {overlap}") } @@ -3098,7 +3233,8 @@ fn portable_keystroke_token(keystroke: &gpui::Keystroke) -> String { } /// Title and description column shared by every settings row. -pub(crate) fn setting_copy(title: &str, description: &str) -> Div { +pub(crate) fn setting_copy(title: impl AsRef, description: impl AsRef) -> Div { + let (title, description) = (title.as_ref(), description.as_ref()); div() .flex() .flex_col() @@ -3119,13 +3255,6 @@ pub(crate) fn setting_copy(title: &str, description: &str) -> Div { ) } -fn setting_row_id(title: &str) -> gpui::SharedString { - gpui::SharedString::from(format!( - "setting-{}", - title.to_lowercase().replace(' ', "-") - )) -} - /// One dropdown choice as data: the label shown in the menu and whether /// it is the saved value. struct SettingOption { @@ -3135,13 +3264,13 @@ struct SettingOption { /// An on/off setting with a switch. The whole row is the click target. fn toggle_row( - title: &str, - description: &str, + title: gpui::SharedString, + description: gpui::SharedString, on: bool, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, ) -> gpui::Stateful
{ widgets::card_row() - .id(setting_row_id(title)) + .id("setting-automatically-download-updates") .flex() .items_center() .justify_between() @@ -3152,7 +3281,7 @@ fn toggle_row( .child(widgets::switch( gpui::SharedString::from(format!( "setting-switch-{}", - title.to_lowercase().replace(' ', "-") + "automatically-download-updates" )), on, )) @@ -3390,19 +3519,19 @@ mod tests { // an empty requirement list is a ready integration, not a blocked one. let permissions = AgentIntegrationPermissions::none_required(); assert!(cua_permissions_ready(Some(&permissions))); - assert_eq!(integration_setup_notice(&permissions), None); + assert!(integration_setup_notice(&permissions).is_none()); } #[test] fn the_setup_notice_names_the_requirement_and_its_remedy() { let notice = integration_setup_notice(&macos_permissions(false, false)) .expect("a missing grant produces a notice"); - assert!(notice.starts_with("Accessibility: ")); - assert!(notice.contains("System Settings")); + assert!(notice.resolve().contains("Accessibility")); + assert!(notice.resolve().contains("System Settings")); let notice = integration_setup_notice(&macos_permissions(true, false)) .expect("a missing grant produces a notice"); - assert!(notice.starts_with("Screen Recording: ")); + assert!(notice.resolve().contains("Screen Recording")); // A compositor helper's remedy changes as the user works through it, // so it is written once on the integration itself. A second copy here @@ -3411,7 +3540,7 @@ mod tests { maple_agent::agent::AgentIntegrationPermissionKind::DesktopHelper, false, ); - assert_eq!(integration_setup_notice(&helper), None); + assert!(integration_setup_notice(&helper).is_none()); } #[test] diff --git a/apps/maple-agent/app/src/ui/settings/account.rs b/apps/maple-agent/app/src/ui/settings/account.rs index b880d9af5..f8faec3b3 100644 --- a/apps/maple-agent/app/src/ui/settings/account.rs +++ b/apps/maple-agent/app/src/ui/settings/account.rs @@ -1,6 +1,7 @@ //! The Account section: who is signed in, email verification, and //! sign-out. Later sections (password, deletion) build on this state. +use crate::ui::i18n::{UiText, tr}; use gpui::{Context, Div, Entity, Focusable, div, prelude::*, px}; use super::{ @@ -33,7 +34,7 @@ pub(super) struct AccountState { /// The profile; None until loaded or when loading failed. pub(super) info: Option, pub(super) load_error: Option, - pub(super) verification_notice: Option, + pub(super) verification_notice: Option, pub(super) verification_busy: bool, pub(super) password: PasswordForm, pub(super) delete: DeleteFlow, @@ -41,7 +42,7 @@ pub(super) struct AccountState { pub(super) delete_ack: Entity, pub(super) delete_code: Entity, pub(super) delete_busy: bool, - pub(super) delete_error: Option, + pub(super) delete_error: Option, } /// Where the two-step account deletion stands. @@ -66,7 +67,7 @@ pub(super) struct PasswordForm { pub(super) confirm: Entity, pub(super) busy: bool, /// (succeeded, message) after the last attempt. - pub(super) notice: Option<(bool, String)>, + pub(super) notice: Option<(bool, UiText)>, } impl PasswordForm { @@ -91,32 +92,34 @@ impl AccountState { application_focus: gpui::FocusHandle, cx: &mut Context, ) -> Self { - let field = |placeholder: &str, tab_index: isize, cx: &mut Context| { - let focus = application_focus.clone(); - cx.new(move |cx| { - TextInput::new(placeholder, cx) - .masked() - .with_tab_index(tab_index) - .application_vim(application_vim_enabled) - .on_application_escape(move |window, cx| window.focus(&focus, cx)) - }) - }; + let field = + |placeholder: &'static str, tab_index: isize, cx: &mut Context| { + let focus = application_focus.clone(); + cx.new(move |cx| { + TextInput::localized(placeholder, cx) + .masked() + .with_tab_index(tab_index) + .application_vim(application_vim_enabled) + .on_application_escape(move |window, cx| window.focus(&focus, cx)) + }) + }; let password = PasswordForm { - current: field("Current password", 10, cx), - new: field("New password", 11, cx), - confirm: field("Confirm new password", 12, cx), + current: field("settings-account-current-password", 10, cx), + new: field("settings-account-new-password", 11, cx), + confirm: field("settings-account-confirm-new-password", 12, cx), busy: false, notice: None, }; - let plain = |placeholder: &str, tab_index: isize, cx: &mut Context| { - let focus = application_focus.clone(); - cx.new(move |cx| { - TextInput::new(placeholder, cx) - .with_tab_index(tab_index) - .application_vim(application_vim_enabled) - .on_application_escape(move |window, cx| window.focus(&focus, cx)) - }) - }; + let plain = + |placeholder: &'static str, tab_index: isize, cx: &mut Context| { + let focus = application_focus.clone(); + cx.new(move |cx| { + TextInput::localized(placeholder, cx) + .with_tab_index(tab_index) + .application_vim(application_vim_enabled) + .on_application_escape(move |window, cx| window.focus(&focus, cx)) + }) + }; Self { info: None, load_error: None, @@ -124,8 +127,8 @@ impl AccountState { verification_busy: false, password, delete: DeleteFlow::Idle, - delete_ack: plain("Type DELETE to continue", 13, cx), - delete_code: plain("Confirmation code", 14, cx), + delete_ack: plain("settings-account-type-delete-to-continue", 13, cx), + delete_code: plain("settings-account-confirmation-code", 14, cx), delete_busy: false, delete_error: None, } @@ -270,7 +273,13 @@ impl SettingsScreen { } let typed = self.account.delete_ack.read(cx).text(); if typed.trim() != DELETE_ACKNOWLEDGEMENT { - self.account.delete_error = Some(format!("Type {DELETE_ACKNOWLEDGEMENT} to confirm")); + self.account.delete_error = Some(UiText::message( + "settings-delete-confirmation", + [( + "delete-acknowledgement", + (DELETE_ACKNOWLEDGEMENT).to_string().into(), + )], + )); cx.notify(); return; } @@ -286,7 +295,7 @@ impl SettingsScreen { this.account.delete_busy = false; match result { Ok(secret) => this.account.delete = DeleteFlow::CodeSent { secret }, - Err(message) => this.account.delete_error = Some(message), + Err(message) => this.account.delete_error = Some(message.into()), } this.reconcile_application_vim_target(); cx.notify(); @@ -306,8 +315,9 @@ impl SettingsScreen { let secret = secret.clone(); let code = self.account.delete_code.read(cx).text(); if code.trim().is_empty() { - self.account.delete_error = - Some("Enter the confirmation code from the email".to_string()); + self.account.delete_error = Some(UiText::key( + "settings-account-enter-the-confirmation-code-from-the-email", + )); cx.notify(); return; } @@ -327,7 +337,7 @@ impl SettingsScreen { this.account.delete_busy = false; match result { Ok(()) => cx.emit(AccountDeleted), - Err(message) => this.account.delete_error = Some(message), + Err(message) => this.account.delete_error = Some(message.into()), } cx.notify(); }, @@ -361,9 +371,9 @@ impl SettingsScreen { for input in this.account.password.inputs() { input.update(cx, |input, cx| input.clear(cx)); } - (true, "Password changed.".to_string()) + (true, UiText::key("settings-account-password-changed")) } - Err(message) => (false, message), + Err(message) => (false, message.into()), }); cx.notify(); }, @@ -385,8 +395,10 @@ impl SettingsScreen { |this, result, cx| { this.account.verification_busy = false; this.account.verification_notice = Some(match result { - Ok(()) => "Verification email sent. Check your inbox.".to_string(), - Err(message) => message, + Ok(()) => { + UiText::key("settings-account-verification-email-sent-check-your-inbox") + } + Err(message) => message.into(), }); cx.notify(); }, @@ -398,7 +410,7 @@ impl SettingsScreen { .flex() .flex_col() .gap_4() - .child(section_title("Account")); + .child(section_title(&tr("settings-account-account"))); let Some(info) = self.account.info.as_ref() else { return pane.child( div() @@ -406,7 +418,7 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_faint())) .child(match &self.account.load_error { Some(message) => message.clone(), - None => "Loading account…".to_string(), + None => tr("settings-account-loading-account").to_string(), }), ); }; @@ -415,7 +427,11 @@ impl SettingsScreen { pane = pane.child(match &info.email { Some(email) => self.render_email_row(email, info.email_verified, cx), None if is_guest => guest_id_row(&info.user_id, cx), - None => info_row("Email", "No email on this account".to_string()).into_any_element(), + None => info_row( + &tr("settings-account-email"), + tr("settings-account-no-email-on-this-account").to_string(), + ) + .into_any_element(), }); if let Some(notice) = &self.account.verification_notice { pane = pane.child( @@ -427,10 +443,13 @@ impl SettingsScreen { } pane = pane .child(info_row( - "Sign-in method", - info.login_method.label().to_string(), + &tr("settings-account-sign-in-method"), + super::presentation::login_method(info.login_method), )) - .child(info_row("Member since", member_since(&info.created_at))); + .child(info_row( + &tr("settings-account-member-since"), + member_since(&info.created_at, &self.messages), + )); if info.login_method.has_password() { pane = pane.child(self.render_password_form(cx)); } @@ -443,9 +462,8 @@ impl SettingsScreen { .justify_between() .gap_4() .child(super::setting_copy( - "Sign out", - "Ends this session on this device. Your tasks stay in your \ - account.", + tr("settings-account-sign-out"), + tr("settings-account-ends-this-session-on-this-device-your-tasks-stay-in-your-account"), )) .child( widgets::secondary_button("account-sign-out") @@ -454,11 +472,11 @@ impl SettingsScreen { .on_click(cx.listener(|_this, _event, _window, cx| { cx.emit(SignOutRequested); })) - .child("Sign out"), + .child(tr("settings-account-sign-out")), ), ), ); - pane.child(section_title("Danger zone")) + pane.child(section_title(&tr("settings-account-danger-zone"))) .child(self.render_delete_account(cx)) } @@ -502,13 +520,12 @@ impl SettingsScreen { .justify_between() .gap_4() .child(super::setting_copy( - "Delete account", - "Permanently deletes your account, tasks, and encrypted data. \ - This cannot be undone.", + tr("settings-account-delete-account"), + tr("settings-account-permanently-deletes-your-account-tasks-and-encrypted-data-this-ca"), )) .child(button( "account-delete-start", - "Delete account…".to_string(), + tr("settings-account-delete-account-more").to_string(), true, AccountTarget::DeleteStart, )), @@ -517,9 +534,8 @@ impl SettingsScreen { DeleteFlow::Acknowledging => { card = card .child(super::setting_copy( - "Delete account", - "This permanently deletes your account, tasks, and encrypted data. \ - Type DELETE, then we email you a confirmation code.", + tr("settings-account-delete-account"), + tr("settings-account-this-permanently-deletes-your-account-tasks-and-encrypted-data-ty"), )) .child(field( AccountTarget::DeleteAcknowledge, @@ -532,16 +548,16 @@ impl SettingsScreen { .gap_2() .child(button( "account-delete-cancel", - "Cancel".to_string(), + tr("settings-account-cancel").to_string(), false, AccountTarget::DeleteCancel, )) .child(button( "account-delete-request", if busy { - "Sending…" + tr("settings-account-sending") } else { - "Send confirmation code" + tr("settings-account-send-confirmation-code") } .to_string(), true, @@ -552,9 +568,8 @@ impl SettingsScreen { DeleteFlow::CodeSent { .. } => { card = card .child(super::setting_copy( - "Confirm deletion", - "We emailed you a confirmation code. Enter it to delete the account. \ - The code expires after 24 hours.", + tr("settings-account-confirm-deletion"), + tr("settings-account-we-emailed-you-a-confirmation-code-enter-it-to-delete-the-account"), )) .child(field(AccountTarget::DeleteCode, &self.account.delete_code)) .child( @@ -564,16 +579,16 @@ impl SettingsScreen { .gap_2() .child(button( "account-delete-cancel", - "Cancel".to_string(), + tr("settings-account-cancel").to_string(), false, AccountTarget::DeleteCancel, )) .child(button( "account-delete-confirm", if busy { - "Deleting…" + tr("settings-account-deleting") } else { - "Delete my account" + tr("settings-account-delete-my-account") } .to_string(), true, @@ -596,39 +611,44 @@ impl SettingsScreen { fn render_password_form(&self, cx: &mut Context) -> Div { let form = &self.account.password; let busy = form.busy; - let field = |target: AccountTarget, label: &str, input: &Entity| { - self.application_target( - move || SettingsTarget::Account(target), - div() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .text_color(gpui::rgb(theme::text_secondary())) - .child(label.to_string()), - ) - .child(widgets::input_frame().child(input.clone())), - ) - }; + let field = + |target: AccountTarget, label: gpui::SharedString, input: &Entity| { + self.application_target( + move || SettingsTarget::Account(target), + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .child(label.to_string()), + ) + .child(widgets::input_frame().child(input.clone())), + ) + }; let mut card = widgets::card_row() .flex() .flex_col() .gap_3() .child(super::setting_copy( - "Change password", - "Use at least 8 characters. Other devices stay signed in.", + tr("settings-account-change-password"), + tr("settings-account-use-at-least-8-characters-other-devices-stay-signed-in"), )) .child(field( AccountTarget::PasswordCurrent, - "Current password", + tr("settings-account-current-password"), &form.current, )) - .child(field(AccountTarget::PasswordNew, "New password", &form.new)) + .child(field( + AccountTarget::PasswordNew, + tr("settings-account-new-password"), + &form.new, + )) .child(field( AccountTarget::PasswordConfirm, - "Confirm new password", + tr("settings-account-confirm-new-password"), &form.confirm, )); if let Some((ok, message)) = &form.notice { @@ -656,9 +676,9 @@ impl SettingsScreen { })) }) .child(if busy { - "Changing…" + tr("settings-account-changing") } else { - "Change password" + tr("settings-account-change-password") }), ), ), @@ -683,9 +703,9 @@ impl SettingsScreen { .child(email.to_string()), ) .child(if verified { - status_badge("Verified", theme::status_success()) + status_badge(tr("settings-account-verified"), theme::status_success()) } else { - status_badge("Unverified", theme::status_warning()) + status_badge(tr("settings-account-unverified"), theme::status_warning()) }); if !verified { value = value.child( @@ -698,7 +718,11 @@ impl SettingsScreen { this.resend_verification(cx); })) }) - .child(if busy { "Sending…" } else { "Resend email" }), + .child(if busy { + tr("settings-account-sending") + } else { + tr("settings-account-resend-email") + }), ); } let row = widgets::card_row() @@ -710,7 +734,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_secondary())) - .child("Email"), + .child(tr("settings-account-email")), ) .child(value); if verified { @@ -733,9 +757,8 @@ fn guest_id_row(user_id: &str, cx: &mut Context) -> gpui::AnyEle .justify_between() .gap_4() .child(super::setting_copy( - "Anonymous account", - "This account has no email. Keep the account id and password \ - somewhere safe: they are the only way to sign in again.", + tr("settings-account-anonymous-account"), + tr("settings-account-this-account-has-no-email-keep-the-account-id-and-password-somewh"), )) .child( div() @@ -761,21 +784,30 @@ fn guest_id_row(user_id: &str, cx: &mut Context) -> gpui::AnyEle /// Client-side checks before the backend call; the messages match the /// web app's form validation. -pub(super) fn password_form_check(current: &str, new: &str, confirm: &str) -> Result<(), String> { +pub(super) fn password_form_check(current: &str, new: &str, confirm: &str) -> Result<(), UiText> { if current.is_empty() { - return Err("Enter your current password".to_string()); + return Err(UiText::key("settings-account-enter-your-current-password")); } - crate::backend::validate_new_password(new)?; + crate::backend::validate_new_password(new).map_err(|_| { + UiText::message( + "settings-account-password-minimum", + [("count", crate::backend::MIN_PASSWORD_LENGTH.into())], + ) + })?; if new != confirm { - return Err("The new passwords do not match".to_string()); + return Err(UiText::key( + "settings-account-the-new-passwords-do-not-match", + )); } if new == current { - return Err("The new password must differ from the current one".to_string()); + return Err(UiText::key( + "settings-account-the-new-password-must-differ-from-the-current-one", + )); } Ok(()) } -fn status_badge(label: &'static str, color: u32) -> Div { +fn status_badge(label: gpui::SharedString, color: u32) -> Div { div() .flex() .items_center() @@ -800,14 +832,20 @@ fn status_badge(label: &'static str, color: u32) -> Div { /// "September 6, 2026" from an RFC 3339 timestamp; the raw text when it /// does not parse. -pub(super) fn member_since(created_at: &str) -> String { - match chrono::DateTime::parse_from_rfc3339(created_at) { - Ok(time) => time - .with_timezone(&chrono::Local) - .format("%B %-d, %Y") - .to_string(), - Err(_) => created_at.to_string(), - } +pub(super) fn member_since(created_at: &str, messages: &crate::ui::i18n::MessageCache) -> String { + messages + .computed( + "settings-member-since", + created_at, + || match chrono::DateTime::parse_from_rfc3339(created_at) { + Ok(time) => super::presentation::date( + time.with_timezone(&chrono::Local).date_naive(), + messages, + ), + Err(_) => created_at.to_string().into(), + }, + ) + .to_string() } #[cfg(test)] @@ -816,9 +854,9 @@ mod tests { #[test] fn member_since_formats_a_date_and_keeps_unparseable_text() { - let formatted = member_since("2024-03-05T12:00:00Z"); - assert!(formatted.ends_with(", 2024"), "{formatted}"); - assert_eq!(member_since("soon"), "soon"); + let formatted = member_since("2024-03-05T12:00:00Z", &Default::default()); + assert!(formatted.ends_with(", \u{2068}2024\u{2069}"), "{formatted}"); + assert_eq!(member_since("soon", &Default::default()), "soon"); } #[test] diff --git a/apps/maple-agent/app/src/ui/settings/advanced.rs b/apps/maple-agent/app/src/ui/settings/advanced.rs index 3ff6fcd69..7d9a6fcd5 100644 --- a/apps/maple-agent/app/src/ui/settings/advanced.rs +++ b/apps/maple-agent/app/src/ui/settings/advanced.rs @@ -1,6 +1,7 @@ //! Local advanced preferences and capability-gated encryption controls. A //! remote task selection never redirects these settings to another computer. +use crate::ui::i18n::{UiText, tr, tr_args}; use gpui::{App, Context, Div, Entity, EventEmitter, div, prelude::*}; #[cfg(feature = "acp")] use maple_agent::acp::AgentAcpConfig; @@ -22,7 +23,7 @@ pub(super) struct AdvancedState { pub confirmation: Entity, pub erase: bool, pub busy: bool, - pub notice: Option, + pub notice: Option, success: Option<&'static str>, mode_notice_acknowledging: Option, mode_notice_acknowledged: Option, @@ -37,7 +38,7 @@ struct AcpSettings { roots: Entity, connection_limit: Entity, saving: bool, - notice: Option, + notice: Option, } impl AdvancedState { @@ -45,7 +46,7 @@ impl AdvancedState { Self { status: None, form: None, - confirmation: cx.new(|cx| TextInput::new("Type ERASE", cx)), + confirmation: cx.new(|cx| TextInput::localized("settings-advanced-type-erase", cx)), erase: false, busy: false, notice: None, @@ -57,13 +58,21 @@ impl AdvancedState { acp: AcpSettings { config: None, roots: cx.new(|cx| { - TextInput::new("One absolute folder path per line", cx) + TextInput::localized("settings-advanced-one-absolute-folder-path-per-line", cx) .multiline(6) .with_tab_index(30) }), connection_limit: cx.new(|cx| { TextInput::new( - &format!("Default ({})", AgentAcpConfig::default().max_connections), + &tr_args( + "settings-acp-default", + [( + "arg1", + (AgentAcpConfig::default().max_connections) + .to_string() + .into(), + )], + ), cx, ) .with_tab_index(31) @@ -81,6 +90,21 @@ impl AdvancedState { } impl SettingsScreen { + #[cfg(feature = "acp")] + pub(super) fn refresh_acp_placeholder(&self, cx: &mut Context) { + let label = tr_args( + "settings-acp-default", + [( + "arg1", + AgentAcpConfig::default().max_connections.to_string().into(), + )], + ); + self.advanced + .acp + .connection_limit + .update(cx, |input, cx| input.set_placeholder(&label, cx)); + } + pub(crate) fn record_mode_notice_ack( &mut self, notice: &StoreModeNotice, @@ -106,7 +130,7 @@ impl SettingsScreen { if let Some(form) = &self.advanced.form { form.update(cx, |form, cx| form.show_local_store_notice(message, cx)); } else { - self.advanced.notice = Some(message); + self.advanced.notice = Some(message.into()); cx.notify(); } true @@ -157,7 +181,7 @@ impl SettingsScreen { ); this.advanced.status = Some(status); } - Err(message) => this.advanced.notice = Some(message), + Err(message) => this.advanced.notice = Some(message.into()), } cx.notify(); }, @@ -198,7 +222,7 @@ impl SettingsScreen { this.advanced.acp.config = Some(config); this.advanced.acp.notice = None; } - Err(message) => this.advanced.acp.notice = Some(message), + Err(message) => this.advanced.acp.notice = Some(message.into()), } cx.notify(); }, @@ -232,7 +256,7 @@ impl SettingsScreen { ) { Ok(config) => config, Err(message) => { - self.advanced.acp.notice = Some(message); + self.advanced.acp.notice = Some(message.into()); cx.notify(); return; } @@ -251,10 +275,11 @@ impl SettingsScreen { // The host validates again and returns the stored values. this.install_acp_fields(&config, cx); this.advanced.acp.config = Some(config); - this.advanced.acp.notice = - Some("ACP preferences saved. They apply to new connections.".into()); + this.advanced.acp.notice = Some(UiText::key( + "settings-advanced-acp-preferences-saved-they-apply-to-new-connections", + )); } - Err(message) => this.advanced.acp.notice = Some(message), + Err(message) => this.advanced.acp.notice = Some(message.into()), } cx.notify(); }, @@ -268,7 +293,9 @@ impl SettingsScreen { return; } self.install_acp_fields(&AgentAcpConfig::default(), cx); - self.advanced.acp.notice = Some("Default values are ready. Save to apply them.".into()); + self.advanced.acp.notice = Some(UiText::key( + "settings-advanced-default-values-are-ready-save-to-apply-them", + )); cx.notify(); } @@ -276,35 +303,43 @@ impl SettingsScreen { fn render_acp_settings(&self, cx: &mut Context) -> Div { let state = &self.advanced.acp; let mut pane = div().flex().flex_col().gap_3() - .child(section_title("Agent Client Protocol (ACP)")) + .child(section_title(&tr("settings-advanced-agent-client-protocol-acp"))) .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child("Choose which folders local ACP clients can open and how many connections can run at once. These preferences apply to this account on this computer.")); + .child(tr("settings-advanced-choose-which-folders-local-acp-clients-can-open-and-how-many-conn"))); if state.saving { - return pane.child(div().text_sm().child("Saving ACP preferences…")); + return pane.child( + div() + .text_sm() + .child(tr("settings-advanced-saving-acp-preferences")), + ); } if state.config.is_some() { pane = pane - .child(div().text_sm().child("Allowed project folders")) + .child(div().text_sm().child(tr("settings-advanced-allowed-project-folders"))) .child(widgets::input_frame().child(state.roots.clone())) .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child("Enter one absolute folder path per line. Leave the list empty to allow any folder.")) - .child(div().text_sm().child("Maximum connections")) + .child(tr("settings-advanced-enter-one-absolute-folder-path-per-line-leave-the-list-empty-to-a"))) + .child(div().text_sm().child(tr("settings-advanced-maximum-connections"))) .child(widgets::input_frame().child(state.connection_limit.clone())) .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child(format!("Choose 1–{} simultaneous connections. Leave blank for the default ({}).", AgentAcpConfig::default().max_connections, AgentAcpConfig::default().max_connections))) + .child(self.messages.get("settings-acp-limit-help", [("arg1", (AgentAcpConfig::default().max_connections).to_string().into()), ("arg2", (AgentAcpConfig::default().max_connections).to_string().into())]))) .child(div().flex().gap_2() .child(widgets::primary_button("advanced-acp-save") - .on_click(cx.listener(|this, _, _, cx| this.save_acp_settings(cx))).child("Save ACP preferences")) + .on_click(cx.listener(|this, _, _, cx| this.save_acp_settings(cx))).child(tr("settings-advanced-save-acp-preferences"))) .child(widgets::secondary_button("advanced-acp-defaults") - .on_click(cx.listener(|this, _, _, cx| this.reset_acp_fields(cx))).child("Use defaults"))); + .on_click(cx.listener(|this, _, _, cx| this.reset_acp_fields(cx))).child(tr("settings-advanced-use-defaults")))); } else if state.notice.is_some() { pane = pane.child( widgets::secondary_button("advanced-acp-retry") .on_click(cx.listener(|this, _, _, cx| this.load_acp_settings(cx))) - .child("Retry"), + .child(tr("settings-advanced-retry")), ); } else { - pane = pane.child(div().text_sm().child("Loading ACP preferences…")); + pane = pane.child( + div() + .text_sm() + .child(tr("settings-advanced-loading-acp-preferences")), + ); } if let Some(notice) = &state.notice { pane = pane.child( @@ -349,7 +384,7 @@ impl SettingsScreen { return; } this.advanced.success = Some(password_success_message(action)); - this.advanced.notice = event.notice.clone(); + this.advanced.notice = event.notice.clone().map(Into::into); this.load_advanced_status(cx); // Desktop may acknowledge this exact receipt only after this // live Settings recipient has installed its inline result. @@ -369,7 +404,9 @@ impl SettingsScreen { } let confirmation = self.advanced.confirmation.read(cx).text(); if confirmation != "ERASE" { - self.advanced.notice = Some("Type ERASE exactly to continue.".into()); + self.advanced.notice = Some(UiText::key( + "settings-advanced-type-erase-exactly-to-continue", + )); cx.notify(); return; } @@ -400,7 +437,7 @@ impl SettingsScreen { this.advanced.erase = false; this.advanced.form = None; } - Err(message) => this.advanced.notice = Some(message), + Err(message) => this.advanced.notice = Some(message.into()), } this.load_advanced_status(cx); cx.notify(); @@ -449,8 +486,9 @@ impl SettingsScreen { && apply_mode_notice_ack(status, ¬ice, acknowledged) && !acknowledged { - this.advanced.notice = - Some("This notice could not be dismissed. Try again.".into()); + this.advanced.notice = Some(UiText::key( + "settings-advanced-this-notice-could-not-be-dismissed-try-again", + )); } cx.notify(); }, @@ -474,18 +512,24 @@ impl SettingsScreen { .child( div() .font_weight(gpui::FontWeight::MEDIUM) - .child("Changes need saving"), + .child(tr("settings-advanced-changes-need-saving")), ) .child(div().text_sm().child(failure.message())) .child( widgets::secondary_button("advanced-retry-store-persistence") .on_click(cx.listener(|_this, _, _, cx| cx.emit(RetryStorePersistence))) - .child("Retry saving"), + .child(tr("settings-advanced-retry-saving")), ), ); } if let Some(notice) = attention - && self.advanced.notice.as_deref() != Some(notice.message.as_str()) + && self + .advanced + .notice + .as_ref() + .map(UiText::resolve) + .as_deref() + != Some(notice.message.as_str()) && persistence.is_none_or(|failure| failure.message() != notice.message.as_str()) { pane = pane.child( @@ -493,7 +537,7 @@ impl SettingsScreen { .child( div() .font_weight(gpui::FontWeight::MEDIUM) - .child("Local storage needs attention"), + .child(tr("settings-advanced-local-storage-needs-attention")), ) .child(div().text_sm().child(notice.message.clone())), ); @@ -510,17 +554,17 @@ impl SettingsScreen { cx.listener(|this, _, _, cx| this.dismiss_local_mode_notice(cx)), ) .child(if acknowledging { - "Dismissing…" + tr("settings-advanced-dismissing") } else { - "Dismiss" + tr("settings-advanced-dismiss") }), ), ); } if !form_open && let Some(notice) = &self.advanced.notice - && mode_notice.is_none_or(|mode| mode.message.as_str() != notice.as_str()) - && persistence.is_none_or(|failure| failure.message() != notice.as_str()) + && mode_notice.is_none_or(|mode| mode.message.as_str() != notice.resolve().as_ref()) + && persistence.is_none_or(|failure| failure.message() != notice.resolve().as_ref()) { pane = pane.child( div() @@ -535,14 +579,19 @@ impl SettingsScreen { pub(super) fn render_advanced(&self, cx: &mut Context) -> Div { let mut pane = div().flex().flex_col().gap_4(); if !self.advanced.available() { - pane = self.render_local_store_notices(pane.child(section_title("Local data")), cx); + pane = self.render_local_store_notices( + pane.child(section_title(&tr("settings-advanced-local-data"))), + cx, + ); #[cfg(feature = "acp")] { pane = pane.child(self.render_acp_settings(cx)); } return pane; } - pane = pane.child(section_title("Encryption on this computer")); + pane = pane.child(section_title(&tr( + "settings-advanced-encryption-on-this-computer", + ))); if let Some(form) = &self.advanced.form { let pane = self.encryption_context(pane.child(form.clone()), true); return self.render_local_store_notices(pane, cx); @@ -554,7 +603,7 @@ impl SettingsScreen { .p_3() .bg(gpui::rgb(theme::bg_elevated())) .text_color(gpui::rgb(theme::status_success())) - .child(success), + .child(tr(success)), ); } let password = self @@ -571,17 +620,17 @@ impl SettingsScreen { theme::text_primary() })) .child(if password { - "Encryption is on" + tr("settings-advanced-encryption-is-on") } else { - "Encryption is off" + tr("settings-advanced-encryption-is-off") }), ); pane=pane.child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())).child(if password { - "Maple's copy of your conversation text, settings and sign-in on this computer is encrypted with your unlock password. Maple asks for it each time it starts. Attached files and images remain unencrypted." - } else {"Your conversation text, settings and sign-in are stored on this computer, readable only by your user account. Set an unlock password to encrypt them. Attached files and images remain unencrypted."})); + tr("settings-advanced-maple-s-copy-of-your-conversation-text-settings-and-sign-in-on-th") + } else {tr("settings-advanced-your-conversation-text-settings-and-sign-in-are-stored-on-this-co")})); if self.advanced.erase { pane = pane - .child(div().text_sm().child(ERASE_COPY)) + .child(div().text_sm().child(tr(ERASE_COPY))) .child(self.advanced.confirmation.clone()) .child( widgets::primary_button("advanced-erase-confirm") @@ -589,7 +638,7 @@ impl SettingsScreen { .on_click( cx.listener(|this, _event, _window, cx| this.erase_local_store(cx)), ) - .child("Erase this version's local data"), + .child(tr("settings-advanced-erase-this-version-s-local-data")), ) .child( widgets::ghost_button("advanced-erase-cancel") @@ -597,7 +646,7 @@ impl SettingsScreen { this.advanced.erase = false; cx.notify(); })) - .child("Cancel"), + .child(tr("settings-advanced-cancel")), ); } else { pane = pane @@ -618,9 +667,9 @@ impl SettingsScreen { ) })) .child(if password { - "Change password…" + tr("settings-advanced-change-password") } else { - "Set unlock password…" + tr("settings-advanced-set-unlock-password") }), ) .when(password, |row| { @@ -629,7 +678,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.open_password_form(PasswordAction::Remove, cx) })) - .child("Remove password…"), + .child(tr("settings-advanced-remove-password")), ) }), ) @@ -641,7 +690,7 @@ impl SettingsScreen { this.advanced.success = None; cx.notify(); })) - .child("Erase local data…"), + .child(tr("settings-advanced-erase-local-data")), ); } pane = self.encryption_context(pane, password); @@ -655,7 +704,7 @@ impl SettingsScreen { fn encryption_context(&self, mut pane: Div, password: bool) -> Div { if self.advanced.remote_enabled { - pane=pane.child(div().text_sm().child("While Maple is locked, your other computers can't reach this one. After a restart, open Maple and unlock it to turn remote access back on.")); + pane=pane.child(div().text_sm().child(tr("settings-advanced-while-maple-is-locked-your-other-computers-can-t-reach-this-one-a"))); } if password { for integration in self @@ -666,12 +715,12 @@ impl SettingsScreen { .filter(|integration| integration.enabled_for_new_tasks) { let copy = match integration.id.as_str() { - "claude" => Some( - "Claude Code keeps its own copy of the work Maple hands it, in its own folder, and Maple's password doesn't encrypt it.", - ), - "codex" => Some( - "Codex keeps its own copy of the work Maple hands it, in its own folder, and Maple's password doesn't encrypt it.", - ), + "claude" => Some(tr( + "settings-advanced-claude-code-keeps-its-own-copy-of-the-work-maple-hands-it-in-its", + )), + "codex" => Some(tr( + "settings-advanced-codex-keeps-its-own-copy-of-the-work-maple-hands-it-in-its-own-fo", + )), _ => None, }; if let Some(copy) = copy { @@ -726,9 +775,11 @@ fn filter_acknowledged_mode_notice( fn password_success_message(action: PasswordAction) -> &'static str { match action { - PasswordAction::Set | PasswordAction::Setup => "Encryption enabled on this computer.", - PasswordAction::Change => "Your unlock password was changed.", - PasswordAction::Remove => "Your unlock password was removed.", + PasswordAction::Set | PasswordAction::Setup => { + "settings-advanced-encryption-enabled-on-this-computer" + } + PasswordAction::Change => "settings-advanced-your-unlock-password-was-changed", + PasswordAction::Remove => "settings-advanced-your-unlock-password-was-removed", } } @@ -744,16 +795,17 @@ fn acp_form_config(roots: &str, connection_limit: &str) -> Result, balance_error: Option, choice: CreditChoice, pub(super) custom: Entity, /// Credits for the typed custom amount, parsed when the field changes. /// None while it is empty. - custom_credits: Option>, + custom_credits: Option>, /// The checkout being minted, if any. purchasing: Option, - notice: Option, + notice: Option, } impl CreditsState { @@ -55,9 +57,20 @@ impl CreditsState { ) -> Self { let custom = cx.new(move |cx| { TextInput::new( - &format!( - "Amount in dollars, ${MIN_CREDIT_PURCHASE_DOLLARS} to ${}", - format_count(MAX_CREDIT_PURCHASE_DOLLARS as i64) + &tr_args( + "settings-credits-amount-placeholder", + [ + ( + "min-credit-purchase-dollars", + (MIN_CREDIT_PURCHASE_DOLLARS).to_string().into(), + ), + ( + "arg1", + (format_count(MAX_CREDIT_PURCHASE_DOLLARS as i64)) + .to_string() + .into(), + ), + ], ), cx, ) @@ -74,6 +87,7 @@ impl CreditsState { }) .detach(); Self { + messages: Default::default(), balance: None, balance_error: None, choice: CreditChoice::Package(CREDIT_PACKAGES_DOLLARS[0]), @@ -84,38 +98,85 @@ impl CreditsState { } } - fn selected_credits(&self) -> Result { + fn selected_credits(&self) -> Result { selected_credits(self.choice, &self.custom_credits) } /// Compact label for the API keys strip. pub(super) fn balance_label(&self) -> String { - balance_label(self.balance, self.balance_error.as_deref()) + balance_label(self.balance, self.balance_error.as_deref(), &self.messages) } } -fn balance_label(balance: Option, balance_error: Option<&str>) -> String { +fn balance_label( + balance: Option, + balance_error: Option<&str>, + messages: &crate::ui::i18n::MessageCache, +) -> String { match (balance, balance_error) { - (Some(balance), _) => format!("{} credits", format_count(balance)), - (None, None) => "Loading balance…".to_string(), - (None, Some(_)) => "Balance unavailable".to_string(), + (Some(balance), _) => messages + .get( + "settings-credits-balance", + [("arg1", (format_count(balance)).to_string().into())], + ) + .to_string(), + (None, None) => tr("settings-api-credits-loading-balance").to_string(), + (None, Some(_)) => tr("settings-api-credits-balance-unavailable").to_string(), } } /// Credits a choice buys, or why it cannot be bought. fn selected_credits( choice: CreditChoice, - custom_credits: &Option>, -) -> Result { + custom_credits: &Option>, +) -> Result { match choice { CreditChoice::Package(dollars) => Ok(dollars * CREDITS_PER_DOLLAR), - CreditChoice::Custom => custom_credits - .clone() - .unwrap_or_else(|| Err("Enter an amount".to_string())), + CreditChoice::Custom => (*custom_credits) + .map(|result| result.map_err(credit_error)) + .unwrap_or_else(|| Err(UiText::key("settings-api-credits-enter-an-amount"))), + } +} + +fn credit_error(error: crate::billing::CreditAmountError) -> UiText { + use crate::billing::CreditAmountError; + match error { + CreditAmountError::WholeDollarsRequired => UiText::key("settings-credits-whole-dollars"), + CreditAmountError::BelowMinimum => UiText::message( + "settings-credits-minimum", + [("amount", MIN_CREDIT_PURCHASE_DOLLARS.to_string().into())], + ), + CreditAmountError::AboveMaximum => UiText::message( + "settings-credits-maximum", + [( + "amount", + format_count(MAX_CREDIT_PURCHASE_DOLLARS as i64).into(), + )], + ), } } impl SettingsScreen { + pub(super) fn refresh_credit_placeholder(&self, cx: &mut Context) { + let label = tr_args( + "settings-credits-amount-placeholder", + [ + ( + "min-credit-purchase-dollars", + MIN_CREDIT_PURCHASE_DOLLARS.to_string().into(), + ), + ( + "arg1", + format_count(MAX_CREDIT_PURCHASE_DOLLARS as i64).into(), + ), + ], + ); + self.billing + .credits + .custom + .update(cx, |input, cx| input.set_placeholder(&label, cx)); + } + /// Guests have no email for card checkout and pay with Bitcoin, as on /// the web. Unknown until the account loads, when card stays offered /// and the backend still refuses it for a guest. @@ -231,15 +292,10 @@ impl SettingsScreen { this.billing.credits.purchasing = None; this.billing.credits.notice = Some(match result { Ok(()) => match method { - PaymentMethod::Stripe => "Opened checkout in your browser. Refresh \ - after paying to see the new balance." - .to_string(), - PaymentMethod::Zaprite => "Opened Bitcoin checkout in your browser. \ - Credits arrive once the payment \ - confirms; refresh to see them." - .to_string(), + PaymentMethod::Stripe => UiText::key("settings-api-credits-opened-checkout-in-your-browser-refresh-after-paying-to-see-the-n"), + PaymentMethod::Zaprite => UiText::key("settings-api-credits-opened-bitcoin-checkout-in-your-browser-credits-arrive-once-the-p"), }, - Err(message) => message, + Err(message) => message.into(), }); cx.notify(); }, @@ -254,7 +310,7 @@ impl SettingsScreen { .flex() .items_center() .justify_between() - .child(section_title("API credits")) + .child(section_title(&tr("settings-api-credits-api-credits"))) .child( self.application_target( target(CreditsTarget::Refresh), @@ -263,15 +319,22 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.refresh_credit_balance(cx); })) - .child("Refresh"), + .child(tr("settings-api-credits-refresh")), ), ), ); // A failed load keeps the last balance, if any, and says so below it. let balance = match (credits.balance, &credits.balance_error) { - (Some(balance), _) => Some(format!("{} credits", format_count(balance))), - (None, None) => Some("Loading balance…".to_string()), + (Some(balance), _) => Some( + self.messages + .get( + "settings-credits-balance", + [("arg1", (format_count(balance)).to_string().into())], + ) + .to_string(), + ), + (None, None) => Some(tr("settings-api-credits-loading-balance").to_string()), (None, Some(_)) => None, }; section = section.child( @@ -283,7 +346,7 @@ impl SettingsScreen { div() .text_xs() .text_color(gpui::rgb(theme::text_muted())) - .child("Extra credit balance"), + .child(tr("settings-api-credits-extra-credit-balance")), ) .when_some(balance, |card, balance| { card.child( @@ -307,32 +370,40 @@ impl SettingsScreen { .text_xs() .text_color(gpui::rgb(theme::text_muted())) .child( - "$1 per 1,000 credits. Extends your plan when its included \ - usage runs out.", + tr("settings-api-credits-1-per-1-000-credits-extends-your-plan-when-its-included-usage-run"), ), ), ); let mut packages = div().flex().flex_wrap().gap_2(); for dollars in CREDIT_PACKAGES_DOLLARS { - packages = packages.child(self.application_target( - target(CreditsTarget::Package(dollars)), - self.credit_choice_button( - CreditChoice::Package(dollars), - format!( - "{} credits", - format_count((dollars * CREDITS_PER_DOLLAR) as i64) + packages = packages.child( + self.application_target( + target(CreditsTarget::Package(dollars)), + self.credit_choice_button( + CreditChoice::Package(dollars), + self.messages + .get( + "settings-credits-balance", + [( + "arg1", + (format_count((dollars * CREDITS_PER_DOLLAR) as i64)) + .to_string() + .into(), + )], + ) + .to_string(), + format!("${dollars}"), + cx, ), - format!("${dollars}"), - cx, ), - )); + ); } packages = packages.child(self.application_target( target(CreditsTarget::Custom), self.credit_choice_button( CreditChoice::Custom, - "Custom amount".to_string(), + tr("settings-api-credits-custom-amount").to_string(), format!( "${MIN_CREDIT_PURCHASE_DOLLARS} – ${}", format_count(MAX_CREDIT_PURCHASE_DOLLARS as i64) @@ -349,21 +420,28 @@ impl SettingsScreen { .text_sm() .font_weight(gpui::FontWeight::MEDIUM) .text_color(gpui::rgb(theme::text_primary())) - .child("Buy credits"), + .child(tr("settings-api-credits-buy-credits")), ) .child(packages); if credits.choice == CreditChoice::Custom { let hint = match &credits.custom_credits { None => None, Some(Ok(amount)) => Some(( - format!( - "{} credits for ${}", - format_count(*amount as i64), - amount / CREDITS_PER_DOLLAR - ), + self.messages + .get( + "settings-credits-cost", + [ + ("arg1", (format_count(*amount as i64)).to_string().into()), + ("arg2", (amount / CREDITS_PER_DOLLAR).to_string().into()), + ], + ) + .to_string(), theme::text_secondary(), )), - Some(Err(message)) => Some((message.clone(), theme::status_error())), + Some(Err(error)) => Some(( + credit_error(*error).resolve().to_string(), + theme::status_error(), + )), }; purchase = purchase.child( div() @@ -384,11 +462,16 @@ impl SettingsScreen { let guest = self.guest_account(); let pay_button = |method: PaymentMethod, enabled: bool| { let (id, label) = match method { - PaymentMethod::Stripe => ("credits-pay-card", "Pay with card"), - PaymentMethod::Zaprite => ("credits-pay-bitcoin", "Pay with Bitcoin"), + PaymentMethod::Stripe => { + ("credits-pay-card", tr("settings-api-credits-pay-with-card")) + } + PaymentMethod::Zaprite => ( + "credits-pay-bitcoin", + tr("settings-api-credits-pay-with-bitcoin"), + ), }; let label = if credits.purchasing == Some(method) { - "Opening checkout…" + tr("settings-api-credits-opening-checkout") } else { label }; @@ -411,7 +494,7 @@ impl SettingsScreen { // Guests see card checkout disabled, as on the web. let card = pay_button(PaymentMethod::Stripe, ready && !guest).when(guest, |el| { el.tooltip(widgets::tooltip( - "Anonymous accounts pay with Bitcoin", + tr("settings-api-credits-anonymous-accounts-pay-with-bitcoin"), None, )) }); @@ -441,9 +524,9 @@ impl SettingsScreen { .text_xs() .text_color(gpui::rgb(theme::text_muted())) .child(if guest { - "Checkout opens in your browser. Anonymous accounts pay with Bitcoin." + tr("settings-api-credits-checkout-opens-in-your-browser-anonymous-accounts-pay-with-bitcoi") } else { - "Checkout opens in your browser." + tr("settings-api-credits-checkout-opens-in-your-browser") }), ), }; @@ -517,13 +600,28 @@ mod tests { selected_credits(CreditChoice::Custom, &Some(Ok(12_000))), Ok(12_000) ); - assert!(selected_credits(CreditChoice::Custom, &Some(Err("no".into()))).is_err()); + assert!( + selected_credits( + CreditChoice::Custom, + &Some(Err(crate::billing::CreditAmountError::AboveMaximum)) + ) + .is_err() + ); } #[test] fn balance_label_covers_loaded_loading_and_error() { - assert_eq!(balance_label(Some(12_345), None), "12,345 credits"); - assert_eq!(balance_label(None, None), "Loading balance…"); - assert_eq!(balance_label(None, Some("no")), "Balance unavailable"); + assert_eq!( + balance_label(Some(12_345), None, &Default::default()), + "\u{2068}12,345\u{2069} credits" + ); + assert_eq!( + balance_label(None, None, &Default::default()), + "Loading balance…" + ); + assert_eq!( + balance_label(None, Some("no"), &Default::default()), + "Balance unavailable" + ); } } diff --git a/apps/maple-agent/app/src/ui/settings/api_keys.rs b/apps/maple-agent/app/src/ui/settings/api_keys.rs index 0caaa7ab5..f64559300 100644 --- a/apps/maple-agent/app/src/ui/settings/api_keys.rs +++ b/apps/maple-agent/app/src/ui/settings/api_keys.rs @@ -1,6 +1,7 @@ //! The API keys section: keys for the OpenAI-compatible API and the local //! proxy. Needs a Pro, Max, or Team plan, like the web app's API settings. +use crate::ui::i18n::{UiText, tr}; use gpui::{Context, Div, Entity, Focusable, div, prelude::*}; use super::{Section, SettingsScreen, SettingsTarget, section_title}; @@ -33,7 +34,7 @@ pub(super) struct ApiKeysState { /// The key name awaiting delete confirmation. pub(super) confirm_delete: Option, pub(super) deleting: Option, - pub(super) notice: Option, + pub(super) notice: Option, } impl ApiKeysState { @@ -43,7 +44,7 @@ impl ApiKeysState { cx: &mut Context, ) -> Self { let name = cx.new(move |cx| { - TextInput::new("Key name, for example “Cursor”", cx) + TextInput::localized("settings-api-keys-key-name-for-example-cursor", cx) .with_tab_index(15) .application_vim(application_vim_enabled) .on_application_escape(move |window, cx| window.focus(&application_focus, cx)) @@ -151,7 +152,7 @@ impl SettingsScreen { } let name = self.api_keys.name.read(cx).text(); if let Err(message) = crate::backend::validate_api_key_name(&name) { - self.api_keys.notice = Some(message); + self.api_keys.notice = Some(message.into()); cx.notify(); return; } @@ -179,7 +180,7 @@ impl SettingsScreen { .insert(0, listed); this.api_keys.created = Some(created); } - Err(message) => this.api_keys.notice = Some(message), + Err(message) => this.api_keys.notice = Some(message.into()), } this.reconcile_application_vim_target(); cx.notify(); @@ -224,7 +225,7 @@ impl SettingsScreen { this.api_keys.created = None; } } - Err(message) => this.api_keys.notice = Some(message), + Err(message) => this.api_keys.notice = Some(message.into()), } this.reconcile_application_vim_target(); cx.notify(); @@ -237,7 +238,7 @@ impl SettingsScreen { .flex() .flex_col() .gap_4() - .child(section_title("API keys")); + .child(section_title(&tr("settings-api-keys-api-keys"))); if self.api_access() == Some(false) { return pane.child( widgets::card_row() @@ -246,8 +247,8 @@ impl SettingsScreen { .justify_between() .gap_4() .child(super::setting_copy( - "Unlock API access", - "API keys and credits come with the Pro, Max, and Team plans.", + tr("settings-api-keys-unlock-api-access"), + tr("settings-api-keys-api-keys-and-credits-come-with-the-pro-max-and-team-plans"), )) .child( self.application_target( @@ -257,7 +258,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.select_section(Section::Billing, cx); })) - .child("See plans"), + .child(tr("settings-api-keys-see-plans")), ), ), ); @@ -269,11 +270,14 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_secondary())) - .child(format!( - "Keys work with any OpenAI-compatible client at {}/v1 and with \ - `maple-agent proxy --api-key`. A key is shown once, when created.", - self.backend.api_url() - )), + .child( + self.messages + .get( + "settings-api-key-help", + [("arg1", (self.backend.api_url()).to_string().into())], + ) + .to_string(), + ), ); let creating = self.api_keys.creating; @@ -299,9 +303,9 @@ impl SettingsScreen { })) }) .child(if creating { - "Creating…" + tr("settings-api-keys-creating") } else { - "Create key" + tr("settings-api-keys-create-key") }), ), ), @@ -324,7 +328,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_faint())) - .child("No API keys yet."), + .child(tr("settings-api-keys-no-api-keys-yet")), ); } Some(keys) => { @@ -339,7 +343,7 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_faint())) .child(match &self.api_keys.error { Some(message) => message.clone(), - None => "Loading keys…".to_string(), + None => tr("settings-api-keys-loading-keys").to_string(), }), ); } @@ -354,8 +358,8 @@ impl SettingsScreen { .justify_between() .gap_4() .child(super::setting_copy( - "API credits", - &self.billing.credits.balance_label(), + tr("settings-api-keys-api-credits"), + self.billing.credits.balance_label(), )) .child( self.application_target( @@ -365,7 +369,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.select_section(Section::Billing, cx); })) - .child("Buy more"), + .child(tr("settings-api-keys-buy-more")), ), ) } @@ -377,9 +381,8 @@ impl SettingsScreen { .gap_2() .border_color(gpui::rgb(theme::accent())) .child(super::setting_copy( - &format!("“{}” is ready", created.name), - "Copy the key now. For your privacy it is not stored anywhere you can \ - read it back.", + self.messages.get("settings-api-key-ready", [("arg1", (created.name).to_string().into())]), + tr("settings-api-keys-copy-the-key-now-for-your-privacy-it-is-not-stored-anywhere-you-c"), )) .child( div() @@ -414,7 +417,7 @@ impl SettingsScreen { this.reconcile_application_vim_target(); cx.notify(); })) - .child("Done"), + .child(tr("settings-api-keys-done")), ), ), ) @@ -444,9 +447,9 @@ impl SettingsScreen { })) }) .child(match (confirming, deleting) { - (_, true) => "Deleting…", - (true, _) => "Confirm delete", - _ => "Delete", + (_, true) => tr("settings-api-keys-deleting"), + (true, _) => tr("settings-api-keys-confirm-delete"), + _ => tr("settings-api-keys-delete"), }), ); let mut actions = div().flex().items_center().gap_2(); @@ -461,7 +464,7 @@ impl SettingsScreen { this.reconcile_application_vim_target(); cx.notify(); })) - .child("Cancel"), + .child(tr("settings-api-keys-cancel")), ), ); } @@ -473,7 +476,15 @@ impl SettingsScreen { .gap_4() .child(super::setting_copy( &key.name, - &format!("Created {}", super::account::member_since(&key.created_at)), + self.messages.get( + "settings-api-key-created", + [( + "arg1", + (super::account::member_since(&key.created_at, &self.messages)) + .to_string() + .into(), + )], + ), )) .child(actions) .into_any_element() diff --git a/apps/maple-agent/app/src/ui/settings/billing.rs b/apps/maple-agent/app/src/ui/settings/billing.rs index 90ed00a45..c70d379fc 100644 --- a/apps/maple-agent/app/src/ui/settings/billing.rs +++ b/apps/maple-agent/app/src/ui/settings/billing.rs @@ -1,10 +1,11 @@ //! The Billing section: current plan, subscription management through the //! Stripe customer portal, prepaid credits, and the plans on sale. +use crate::ui::i18n::{UiText, tr}; use gpui::{Context, Div, div, prelude::*}; use super::{SettingsScreen, SettingsTarget, plan_card, section_title}; -use crate::billing::{BillingStatus, BillingStatusExt, PlanTier, Product, price_label}; +use crate::billing::{BillingStatus, BillingStatusExt, PlanTier, Product}; use crate::ui::theme; use crate::ui::widgets; @@ -24,7 +25,7 @@ pub(super) struct BillingState { pub(super) products: Option>, /// True while a portal or checkout link is being minted. pub(super) busy: bool, - pub(super) notice: Option, + pub(super) notice: Option, pub(super) credits: super::api_credits::CreditsState, } @@ -193,10 +194,8 @@ impl SettingsScreen { |this, result, cx| { this.billing.busy = false; this.billing.notice = Some(match result { - Ok(()) => "Opened the subscription portal in your browser. Refresh \ - after making changes." - .to_string(), - Err(message) => message, + Ok(()) => UiText::key("settings-billing-opened-the-subscription-portal-in-your-browser-refresh-after-maki"), + Err(message) => message.into(), }); cx.notify(); }, @@ -226,8 +225,10 @@ impl SettingsScreen { ProductAction::Web => { self.billing.notice = Some( match crate::backend::open_in_browser(crate::billing::PRICING_URL, "pricing") { - Ok(()) => "Opened the pricing page in your browser.".to_string(), - Err(message) => message, + Ok(()) => { + UiText::key("settings-billing-opened-the-pricing-page-in-your-browser") + } + Err(message) => message.into(), }, ); cx.notify(); @@ -245,10 +246,8 @@ impl SettingsScreen { |this, result, cx| { this.billing.busy = false; this.billing.notice = Some(match result { - Ok(()) => "Opened checkout in your browser. Refresh after \ - paying to see the new plan." - .to_string(), - Err(message) => message, + Ok(()) => UiText::key("settings-billing-opened-checkout-in-your-browser-refresh-after-paying-to-see-the-n"), + Err(message) => message.into(), }); cx.notify(); }, @@ -264,7 +263,7 @@ impl SettingsScreen { .flex() .items_center() .justify_between() - .child(section_title("Plan")) + .child(section_title(&tr("settings-billing-plan"))) .child( self.application_target( || SettingsTarget::Billing(BillingTarget::Refresh), @@ -273,7 +272,7 @@ impl SettingsScreen { .on_click(cx.listener(|this, _event, _window, cx| { this.refresh_billing(cx); })) - .child("Refresh"), + .child(tr("settings-billing-refresh")), ), ), ); @@ -284,7 +283,7 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_faint())) .child(match &self.billing.status_error { Some(message) => message.clone(), - None => "Loading plan…".to_string(), + None => tr("settings-billing-loading-plan").to_string(), }), ); }; @@ -292,7 +291,7 @@ impl SettingsScreen { let plan_name = status .product_name .clone() - .unwrap_or_else(|| "Free".to_string()); + .unwrap_or_else(|| tr("settings-billing-free").to_string()); let mut plan = widgets::card_row().flex().flex_col().gap_2().child( div() .flex() @@ -327,12 +326,12 @@ impl SettingsScreen { this.open_billing_portal(cx); })) }) - .child("Manage subscription"), + .child(tr("settings-billing-manage-subscription")), ), ) }), ); - if let Some(label) = status.period_end_label() { + if let Some(label) = super::presentation::period_end(status, &self.messages) { plan = plan.child( div() .text_sm() @@ -350,7 +349,7 @@ impl SettingsScreen { } pane = pane.child(plan); if let Some(usage) = self.plan.as_ref() { - pane = pane.child(plan_card(usage)); + pane = pane.child(plan_card(usage, &self.messages)); } if let Some(notice) = &self.billing.notice { pane = pane.child( @@ -361,7 +360,7 @@ impl SettingsScreen { ); } - pane = pane.child(section_title("Plans")); + pane = pane.child(section_title(&tr("settings-billing-plans"))); match self.billing.products.as_deref() { Some(products) if !products.is_empty() => { for product in products { @@ -373,7 +372,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_faint())) - .child("No plans are on sale right now."), + .child(tr("settings-billing-no-plans-are-on-sale-right-now")), ); } None => { @@ -381,7 +380,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_faint())) - .child("Loading plans…"), + .child(tr("settings-billing-loading-plans")), ); } } @@ -390,8 +389,7 @@ impl SettingsScreen { .text_xs() .text_color(gpui::rgb(theme::text_muted())) .child( - "Payments open in your browser. Bitcoin payments and team seats are \ - set up on the web pricing page.", + tr("settings-billing-payments-open-in-your-browser-bitcoin-payments-and-team-seats-are"), ), ); if self.api_access() == Some(false) { @@ -409,10 +407,10 @@ impl SettingsScreen { let action = product_action(status, product); let busy = self.billing.busy; let label = match action { - ProductAction::Current => "Current plan", - ProductAction::Checkout => "Choose", - ProductAction::Portal => "Change plan", - ProductAction::Web => "See on the web", + ProductAction::Current => tr("settings-billing-current-plan"), + ProductAction::Checkout => tr("settings-billing-choose"), + ProductAction::Portal => tr("settings-billing-change-plan"), + ProductAction::Web => tr("settings-billing-see-on-the-web"), }; let id = product.id.clone(); let button = match action { @@ -458,14 +456,17 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_primary())) .child(product.name.clone()), ) - .when_some(price_label(product), |row, price| { - row.child( - div() - .text_sm() - .text_color(gpui::rgb(theme::text_secondary())) - .child(price), - ) - }), + .when_some( + super::presentation::price(product, &self.messages), + |row, price| { + row.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .child(price), + ) + }, + ), ) .when_some(product.description.clone(), |column, description| { column.child( @@ -491,11 +492,11 @@ impl SettingsScreen { fn subscription_badge(state: &str) -> Div { let (label, color) = match state { - "active" => ("Active", theme::status_success()), - "trialing" => ("Trial", theme::status_success()), - "past_due" | "unpaid" => ("Payment due", theme::status_error()), - "canceled" => ("Canceled", theme::text_muted()), - other => (other, theme::text_muted()), + "active" => (tr("settings-billing-active"), theme::status_success()), + "trialing" => (tr("settings-billing-trial"), theme::status_success()), + "past_due" | "unpaid" => (tr("settings-billing-payment-due"), theme::status_error()), + "canceled" => (tr("settings-billing-canceled"), theme::text_muted()), + other => (other.to_string().into(), theme::text_muted()), }; div() .px_1p5() @@ -510,11 +511,10 @@ fn subscription_badge(state: &str) -> Div { fn payment_provider_note(status: &BillingStatus) -> Option { match status.payment_provider.as_deref()? { "zaprite" => { - Some("Paid with Bitcoin. To change plans, contact support@trymaple.ai.".to_string()) + Some(tr("settings-billing-paid-with-bitcoin-to-change-plans-contact-support-trymaple-ai").to_string()) } "subscription_pass" => Some( - "Activated with a subscription pass. It ends on the date above without \ - renewing." + tr("settings-billing-activated-with-a-subscription-pass-it-ends-on-the-date-above-with") .to_string(), ), _ => None, diff --git a/apps/maple-agent/app/src/ui/settings/local_api.rs b/apps/maple-agent/app/src/ui/settings/local_api.rs index 7c668012f..8567c4dd2 100644 --- a/apps/maple-agent/app/src/ui/settings/local_api.rs +++ b/apps/maple-agent/app/src/ui/settings/local_api.rs @@ -1,6 +1,7 @@ //! This computer's optional HTTP inference listener. Account keys are created //! in the existing API keys pane and supplied by each calling application. +use crate::ui::i18n::tr; use gpui::{Context, Div, Entity, Focusable, SharedString, Window, div, prelude::*}; use super::{Section, SettingsScreen, SettingsTarget, section_title, setting_copy}; @@ -25,7 +26,6 @@ pub(super) struct LocalApiUiState { status: Option, pub(super) port: Entity, base_url: SharedString, - request_count: SharedString, saving: bool, refreshing: bool, host_epoch: u64, @@ -42,13 +42,12 @@ impl LocalApiUiState { Self { status: None, port: cx.new(move |cx| { - TextInput::new("Port (1–65535)", cx) + TextInput::localized("settings-local-api-port-1-65535", cx) .with_tab_index(19) .application_vim(application_vim_enabled) .on_application_escape(move |window, cx| window.focus(&application_focus, cx)) }), base_url: SharedString::default(), - request_count: SharedString::default(), saving: false, refreshing: false, host_epoch: 0, @@ -100,7 +99,6 @@ impl SettingsScreen { .as_ref() .is_none_or(|current| current.config.port != status.config.port); self.local_api.base_url = format!("http://127.0.0.1:{}/v1", status.config.port).into(); - self.local_api.request_count = format!("{} active requests", status.active_requests).into(); self.local_api.status = Some(status); if port_changed { let port = self @@ -143,7 +141,7 @@ impl SettingsScreen { } Err(_) => { this.local_api.notice = Some( - "Could not read the local API status. Reconnect to this computer and try again.", + "settings-local-api-could-not-read-the-local-api-status-reconnect-to-this-computer-an", ); } } @@ -229,7 +227,7 @@ impl SettingsScreen { Err(_) => { this.refresh_local_api_status(cx); this.local_api.notice = Some( - "Could not update the local API. Refresh its status and try again.", + "settings-local-api-could-not-update-the-local-api-refresh-its-status-and-try-again", ); } } @@ -301,27 +299,27 @@ impl SettingsScreen { let status = state.status.as_ref(); let enabled = status.is_some_and(|status| status.config.enabled); let label = if state.refreshing && status.is_none() { - "Checking status…" + tr("settings-local-api-checking-status") } else { - status.map_or("Status unavailable", state_label) + status.map_or(tr("settings-local-api-status-unavailable"), state_label) }; let mut pane = div() .flex() .flex_col() .gap_4() - .child(section_title("Local API")) + .child(section_title(&tr("settings-local-api-local-api"))) .child( div().flex().flex_col().gap_2() .child(setting_copy( - "Connect AI applications on this computer", - "Expose Maple's inference APIs at a local address. Requests use Maple's secure backend and your API credits.", + tr("settings-local-api-connect-ai-applications-on-this-computer"), + tr("settings-local-api-expose-maple-s-inference-apis-at-a-local-address-requests-use-map"), )) .child(self.application_target( || SettingsTarget::LocalApi(LocalApiTarget::LearnMore), div() .id("local-api-learn-more") .role(gpui::Role::Link) - .aria_label("Learn more about the local API") + .aria_label(tr("settings-local-api-learn-more-about-the-local-api")) .text_sm() .text_color(gpui::rgb(theme::link())) .focus_visible(widgets::focus_ring) @@ -334,7 +332,7 @@ impl SettingsScreen { crate::ui::rich_text::open_link(PROXY_DOCS_URL); } })) - .child("Learn more"), + .child(tr("settings-local-api-learn-more")), )), ) .child( @@ -344,17 +342,17 @@ impl SettingsScreen { .justify_between() .gap_4() .child(setting_copy( - "Enable local API", - "Applies to this computer, including while you work with a remote agent.", + tr("settings-local-api-enable-local-api"), + tr("settings-local-api-applies-to-this-computer-including-while-you-work-with-a-remote-a"), )) .child(self.application_target( || SettingsTarget::LocalApi(LocalApiTarget::Enabled), widgets::switch("local-api-enabled", enabled) .debug_selector(|| "local-api-enabled".to_string()) - .aria_label("Enable local API on this computer") + .aria_label(tr("settings-local-api-enable-local-api-on-this-computer")) .when(!state.can_configure(), |switch| { switch.opacity(0.5).aria_description( - "Unavailable while local API settings are loading or being updated.", + tr("settings-local-api-unavailable-while-local-api-settings-are-loading-or-being-updated"), ) }) .when(state.can_configure(), |switch| { @@ -380,16 +378,16 @@ impl SettingsScreen { .text_sm() .text_color(gpui::rgb(theme::text_secondary())) .child(label) - .when(state.saving, |row| row.child("Updating local API settings…")) + .when(state.saving, |row| row.child(tr("settings-local-api-updating-local-api-settings"))) .when(status.is_some_and(|status| status.state == LocalApiState::Running), |row| { - row.child(state.request_count.clone()) + row.child(self.messages.get("settings-local-api-requests", [("count", status.map_or(0, |status| status.active_requests).into())])) }) .when(status.is_some_and(|status| status.state == LocalApiState::Stopped && status.config.enabled), |row| { - row.child("The local API is enabled but paused. Select Resume to accept requests on this computer.") + row.child(tr("settings-local-api-the-local-api-is-enabled-but-paused-select-resume-to-accept-reque")) }), ); if let Some(message) = state.notice { - pane = pane.child(widgets::banner(theme::status_error()).child(message)); + pane = pane.child(widgets::banner(theme::status_error()).child(tr(message))); } if let Some(error) = status.and_then(|status| status.error.as_ref()) { pane = pane.child(widgets::banner(theme::status_error()).child(error.clone())); @@ -433,7 +431,7 @@ impl SettingsScreen { .items_center() .justify_between() .gap_4() - .child(setting_copy("Port", "The local address always uses 127.0.0.1. Stop the API to change its port.")) + .child(setting_copy(tr("settings-local-api-port"), tr("settings-local-api-the-local-address-always-uses-127-0-0-1-stop-the-api-to-change-it"))) .child(div().flex().items_center().gap_2().child(port_control).when(state.can_edit_port(), |row| { row.child(self.application_target( || SettingsTarget::LocalApi(LocalApiTarget::SavePort), @@ -447,11 +445,11 @@ impl SettingsScreen { } })) .child(if !enabled { - "Save port" + tr("settings-local-api-save-port") } else if status.state == LocalApiState::Stopped { - "Save and resume" + tr("settings-local-api-save-and-resume") } else { - "Save and retry" + tr("settings-local-api-save-and-retry") }), )) })), @@ -464,13 +462,13 @@ impl SettingsScreen { .gap_4() .child( div().flex().flex_col().gap_1() - .child(setting_copy("OpenAI-compatible base URL", "Paste this address into your AI application's provider settings.")) + .child(setting_copy(tr("settings-local-api-openai-compatible-base-url"), tr("settings-local-api-paste-this-address-into-your-ai-application-s-provider-settings"))) .child(div().font_family(crate::assets::FONT_MONO).text_sm().child(state.base_url.clone())), ) .child(self.application_target( || SettingsTarget::LocalApi(LocalApiTarget::CopyUrl), widgets::copy_button("local-api-copy-url", state.base_url.clone(), None, Some(cx.entity_id())) - .aria_label("Copy local API base URL") + .aria_label(tr("settings-local-api-copy-local-api-base-url")) .tab_index(21) .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { if activates(event) { @@ -483,8 +481,8 @@ impl SettingsScreen { } pane.child( div().flex().flex_col().gap_2().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child("When enabled, the API resumes when this computer's Maple host starts and its store is unlocked. Signing out, locking, or quitting the host stops the listener.") - .child("Create a dedicated Maple API key, copy it once, and enter it in your AI application's API key field. Every request must supply that key as Authorization: Bearer .") + .child(tr("settings-local-api-when-enabled-the-api-resumes-when-this-computer-s-maple-host-star")) + .child(tr("settings-local-api-create-a-dedicated-maple-api-key-copy-it-once-and-enter-it-in-you")) .child(self.application_target( || SettingsTarget::LocalApi(LocalApiTarget::ApiKeys), widgets::secondary_button("local-api-open-keys") @@ -496,18 +494,18 @@ impl SettingsScreen { this.select_section(Section::ApiKeys, cx); } })) - .child("Manage API keys"), + .child(tr("settings-local-api-manage-api-keys")), )), ).child( widgets::card_row().flex().flex_col().gap_2() - .child(section_title("Available endpoints")) + .child(section_title(&tr("settings-local-api-available-endpoints"))) .children([ "GET /v1/models", "POST /v1/chat/completions", "POST /v1/embeddings", "POST /v1/systemone", ].into_iter().map(|route| div().font_family(crate::assets::FONT_MONO).text_xs().child(route))) - .child(div().text_xs().text_color(gpui::rgb(theme::text_muted())).child("Browser-origin requests are blocked. Use a desktop application, CLI, or server client.")), + .child(div().text_xs().text_color(gpui::rgb(theme::text_muted())).child(tr("settings-local-api-browser-origin-requests-are-blocked-use-a-desktop-application-cli"))), ).when(!state.refreshing && !state.saving, |pane| { pane.child(self.application_target( || SettingsTarget::LocalApi(LocalApiTarget::Refresh), @@ -520,7 +518,7 @@ impl SettingsScreen { this.refresh_local_api_status(cx); } })) - .child("Refresh status"), + .child(tr("settings-local-api-refresh-status")), )) }) } @@ -532,25 +530,25 @@ fn parse_port(value: &str) -> Result { .parse::() .ok() .filter(|port| *port > 0) - .ok_or("Enter a port between 1 and 65535.") + .ok_or("settings-local-api-enter-a-port-between-1-and-65535") } -fn state_label(status: &LocalApiStatus) -> &'static str { +fn state_label(status: &LocalApiStatus) -> SharedString { match status.state { - LocalApiState::Stopped if status.config.enabled => "Paused", - LocalApiState::Stopped => "Stopped", - LocalApiState::Starting => "Starting…", - LocalApiState::Running => "Running on this computer", - LocalApiState::Stopping => "Stopping…", - LocalApiState::Failed => "Could not start", + LocalApiState::Stopped if status.config.enabled => tr("settings-local-api-paused"), + LocalApiState::Stopped => tr("settings-local-api-stopped"), + LocalApiState::Starting => tr("settings-local-api-starting"), + LocalApiState::Running => tr("settings-local-api-running-on-this-computer"), + LocalApiState::Stopping => tr("settings-local-api-stopping"), + LocalApiState::Failed => tr("settings-local-api-could-not-start"), } } -fn retry_label(status: &LocalApiStatus) -> &'static str { +fn retry_label(status: &LocalApiStatus) -> SharedString { if status.state == LocalApiState::Stopped { - "Resume" + tr("settings-local-api-resume") } else { - "Retry start" + tr("settings-local-api-retry-start") } } @@ -706,7 +704,7 @@ mod tests { assert!(!this.local_api.saving); assert!(!this.local_api.status.as_ref().unwrap().config.enabled); assert_eq!( - this.local_api.notice, + this.local_api.notice.map(tr).as_deref(), Some("Enter a port between 1 and 65535.") ); }); diff --git a/apps/maple-agent/app/src/ui/settings/mcp.rs b/apps/maple-agent/app/src/ui/settings/mcp.rs index dbb1b2046..bcad9010f 100644 --- a/apps/maple-agent/app/src/ui/settings/mcp.rs +++ b/apps/maple-agent/app/src/ui/settings/mcp.rs @@ -1,6 +1,7 @@ //! MCP editor state contains names and presence only until the user enters a //! replacement. Keep, Set and Clear remain distinct at the request boundary. +use crate::ui::i18n::{tr, tr_args}; use gpui::{Context, Div, Entity, div, prelude::*}; use maple_agent::store::records::{ McpDefinition, McpSecretEdit, McpSecretUpdate, McpServerEdit, SecretString, @@ -46,25 +47,27 @@ impl SettingsScreen { .text_xs() .text_color(gpui::rgb(theme::text_muted())) .child(match field.action { - McpSecretAction::Keep if field.has_value => "Saved value", - McpSecretAction::Keep => "No saved value", - McpSecretAction::Set => "New value", - McpSecretAction::Clear => "Will clear", + McpSecretAction::Keep if field.has_value => { + tr("settings-mcp-saved-value") + } + McpSecretAction::Keep => tr("settings-mcp-no-saved-value"), + McpSecretAction::Set => tr("settings-mcp-new-value"), + McpSecretAction::Clear => tr("settings-mcp-will-clear"), }), ), ); row = row.child( div().flex().gap_2().children( [ - (McpSecretAction::Keep, "Keep"), - (McpSecretAction::Set, "Replace"), - (McpSecretAction::Clear, "Clear"), + (McpSecretAction::Keep, "Keep", tr("settings-mcp-keep")), + (McpSecretAction::Set, "Replace", tr("settings-mcp-replace")), + (McpSecretAction::Clear, "Clear", tr("settings-mcp-clear")), ] .into_iter() - .map(|(action, label)| { + .map(|(action, id, label)| { pill_button( format!( - "mcp-secret-{}-{index}-{label}", + "mcp-secret-{}-{index}-{id}", if headers { "header" } else { "env" } ), label, @@ -134,10 +137,11 @@ pub(super) fn field_edits( McpSecretAction::Set => { let value = Zeroizing::new(field.input.read(cx).text()); if value.is_empty() { - return Err(format!( - "Enter a new value for {} or choose Clear.", - field.name - )); + return Err(tr_args( + "settings-mcp-secret-required", + [("arg1", (field.name).to_string().into())], + ) + .to_string()); } McpSecretUpdate::Set(SecretString::new(value.to_string())) } @@ -160,10 +164,11 @@ pub(super) fn new_value_edits(text: &str) -> Result, String> .filter(|entry| !entry.trim().is_empty()) .map(|entry| { let (name, value) = entry.trim().split_once('=').ok_or_else(|| { - "Enter new values as NAME=value, separated by semicolons.".to_string() + tr("settings-mcp-enter-new-values-as-name-value-separated-by-semicolons") + .to_string() })?; if name.trim().is_empty() || value.is_empty() { - return Err("Each new entry needs a name and value.".into()); + return Err(tr("settings-mcp-each-new-entry-needs-a-name-and-value").into()); } Ok(McpSecretEdit { name: name.trim().to_string(), @@ -197,7 +202,10 @@ pub(super) fn require_target_choice( .is_some_and(|entry| matches!(entry.update, McpSecretUpdate::Keep)) }) { - return Err("The server target or name changed. Replace or clear each saved value before saving.".into()); + return Err(tr( + "settings-mcp-the-server-target-or-name-changed-replace-or-clear-each-saved-val", + ) + .into()); } } Ok(()) diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index ae24690b1..d3261277c 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -5,6 +5,7 @@ //! Selecting a row is side-effect free; Enter delegates to the same existing //! method as its pointer control. +use crate::ui::i18n::UiText; use gpui::{Context, Focusable, IntoElement, StatefulInteractiveElement, Window, div, prelude::*}; use super::account::AccountTarget; @@ -338,8 +339,7 @@ impl SettingsScreen { self.application_vim.section = self.section; } _ => { - self.shortcut_notice = - Some("There is no Settings region in that direction".to_string()); + self.shortcut_notice = Some(UiText::key("settings-navigation-no-region")); cx.notify(); return false; } @@ -513,10 +513,7 @@ impl SettingsScreen { } SettingsApplicationCommand::Unavailable => { self.application_vim.count.clear(); - self.shortcut_notice = Some( - "That application Vim command is not available on this Settings target" - .to_string(), - ); + self.shortcut_notice = Some(UiText::key("settings-navigation-unavailable")); cx.notify(); } } @@ -532,7 +529,7 @@ impl SettingsScreen { self.application_vim.count.push(action.digit), CountOutcome::Capped(_) ) { - self.shortcut_notice = Some("Application Vim count capped at 999999".to_string()); + self.shortcut_notice = Some(UiText::key("settings-navigation-count-limit")); } cx.notify(); } @@ -1092,6 +1089,62 @@ mod tests { }); } + #[gpui::test] + fn live_language_switch_preserves_forms_and_rebuilds_spanish_shortcut_search( + cx: &mut TestAppContext, + ) { + let _locale = crate::ui::i18n::TestLocalizationGuard::enable(); + crate::ui::i18n::apply_preference(Some("en")); + let (settings, cx) = dropdown_window(cx, false); + settings.update(cx, |this, cx| { + this.prompt_editor + .update(cx, |input, cx| input.set_text("Keep my instructions", cx)); + this.api_keys + .name + .update(cx, |input, cx| input.set_text("My editor", cx)); + this.prompt_notice = Some(UiText::key( + "settings-settings-saved-new-tasks-use-these-instructions", + )); + this.shortcut_snapshot.rows = vec![crate::shortcuts::ShortcutRow { + slot_id: "chat.new_task".into(), + label: "New task".into(), + category: "Chat".into(), + context: Some("Chat".into()), + default_sequence: "secondary-n".into(), + current_sequence: Some("secondary-n".into()), + modified: false, + conflicts: vec![], + }]; + this.shortcut_search + .update(cx, |input, cx| input.set_text("nueva", cx)); + }); + cx.run_until_parked(); + for (locale, visible) in [("es", 1), ("en", 0), ("es", 1)] { + cx.update(|_, cx| { + crate::ui::i18n::apply_preference(Some(locale)); + cx.refresh_windows(); + }); + cx.run_until_parked(); + settings.update(cx, |this, cx| { + assert_eq!(this.prompt_editor.read(cx).text(), "Keep my instructions"); + assert_eq!(this.api_keys.name.read(cx).text(), "My editor"); + assert_eq!(this.shortcut_list_cache.visible_indices.len(), visible); + assert_eq!(this.shortcut_snapshot.rows[0].slot_id, "chat.new_task"); + assert_eq!( + this.shortcut_snapshot.rows[0].current_sequence.as_deref(), + Some("secondary-n") + ); + assert!( + this.prompt_notice + .as_ref() + .unwrap() + .resolve() + .starts_with(if locale == "es" { "Guardado" } else { "Saved" }) + ); + }); + } + } + #[gpui::test] fn dropdown_rows_select_highlight_and_close(cx: &mut TestAppContext) { let (settings, cx) = dropdown_window(cx, false); diff --git a/apps/maple-agent/app/src/ui/settings/presentation.rs b/apps/maple-agent/app/src/ui/settings/presentation.rs new file mode 100644 index 000000000..80349ca94 --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/presentation.rs @@ -0,0 +1,219 @@ +//! UI-owned descriptions of semantic runtime states. Raw host errors and custom +//! integration metadata remain untouched. +use crate::ui::i18n::{MessageCache, tr}; +use gpui::SharedString; + +pub(super) fn update_label(update: &crate::update::Snapshot, messages: &MessageCache) -> String { + use crate::update::State; + let key = match update.state { + State::Disabled => "settings-update-disabled", + State::Idle => "settings-update-idle", + State::Checking => "settings-update-checking", + State::Current => "settings-update-current", + State::Available => "settings-update-available", + State::Downloading => "settings-update-downloading", + State::Ready => "settings-update-ready", + State::Installing => "settings-update-installing", + State::RestartRequired => "settings-update-restart-required", + State::Error => return update.label.clone(), + }; + let label = tr(key); + match update.version.as_ref() { + Some(version) => messages + .get( + "settings-update-version-status", + [ + ("status", label.to_string().into()), + ("version", version.clone().into()), + ], + ) + .to_string(), + None => label.to_string(), + } +} + +pub(super) fn update_action(action: crate::update::UpdateAction) -> SharedString { + use crate::update::UpdateAction; + tr(match action { + UpdateAction::Check => "settings-settings-check-for-updates", + UpdateAction::Download | UpdateAction::ManualDownload => "settings-update-download", + UpdateAction::Restart => "settings-update-restart", + UpdateAction::Relaunch => "settings-update-relaunch", + UpdateAction::Later => "settings-settings-later", + UpdateAction::AutomaticDownloads(_) => "settings-settings-automatically-download-updates", + }) +} + +pub(super) fn login_method(method: crate::backend::MapleLoginMethod) -> String { + use crate::backend::MapleLoginMethod; + match method { + MapleLoginMethod::Email => tr("settings-account-email").to_string(), + MapleLoginMethod::Guest => tr("settings-account-anonymous").to_string(), + other => other.label().to_string(), + } +} + +pub(super) fn permission(kind: maple_agent::agent::AgentIntegrationPermissionKind) -> SharedString { + use maple_agent::agent::AgentIntegrationPermissionKind; + tr(match kind { + AgentIntegrationPermissionKind::Accessibility => "settings-permission-accessibility", + AgentIntegrationPermissionKind::ScreenRecording => "settings-permission-screen-recording", + AgentIntegrationPermissionKind::DesktopHelper => "settings-permission-desktop-helper", + }) +} + +pub(super) fn integration_description(id: &str, fallback: &str) -> SharedString { + match id { + "cua-driver" => tr("settings-integration-cua-description"), + "codex" => tr("settings-integration-codex-description"), + "claude" => tr("settings-integration-claude-description"), + _ => fallback.to_string().into(), + } +} + +pub(super) fn date(date: chrono::NaiveDate, messages: &MessageCache) -> SharedString { + use chrono::Datelike; + let month = tr(match date.month() { + 1 => "settings-month-january", + 2 => "settings-month-february", + 3 => "settings-month-march", + 4 => "settings-month-april", + 5 => "settings-month-may", + 6 => "settings-month-june", + 7 => "settings-month-july", + 8 => "settings-month-august", + 9 => "settings-month-september", + 10 => "settings-month-october", + 11 => "settings-month-november", + _ => "settings-month-december", + }); + messages.get( + "settings-date", + [ + ("month", month.to_string().into()), + ("day", date.day().to_string().into()), + ("year", date.year().to_string().into()), + ], + ) +} + +pub(super) fn period_end( + status: &crate::billing::BillingStatus, + messages: &MessageCache, +) -> Option { + let end = chrono::DateTime::from_timestamp(status.current_period_end?, 0)? + .with_timezone(&chrono::Local); + let key = match status.payment_provider.as_deref() { + Some("zaprite" | "subscription_pass") => "settings-billing-expires", + _ => "settings-billing-renews", + }; + Some(messages.get( + key, + [("date", date(end.date_naive(), messages).to_string().into())], + )) +} + +pub(super) fn price( + product: &crate::billing::Product, + messages: &MessageCache, +) -> Option { + let price = product.default_price.as_ref()?; + let original = crate::billing::price_label(product)?; + let Some(recurring) = &price.recurring else { + return Some(original.into()); + }; + let period = match recurring.interval.as_str() { + "month" => tr("settings-billing-month"), + "year" => tr("settings-billing-year"), + "week" => tr("settings-billing-week"), + "day" => tr("settings-billing-day"), + _ => return Some(original.into()), + }; + let amount = original + .rsplit_once('/') + .map_or(original.as_str(), |(amount, _)| amount); + Some(messages.get( + "settings-billing-price-period", + [ + ("amount", amount.to_string().into()), + ("period", period.to_string().into()), + ], + )) +} + +pub(super) fn plan_name(plan: &crate::billing::PlanUsage, messages: &MessageCache) -> SharedString { + let Some(name) = &plan.product_name else { + return plan.plan_label.clone().into(); + }; + if name + .split(|ch: char| !ch.is_alphanumeric()) + .any(|word| word.eq_ignore_ascii_case("plan")) + { + return name.clone().into(); + } + messages.get("settings-plan-name", [("name", name.clone().into())]) +} + +pub(super) fn reset_date( + plan: &crate::billing::PlanUsage, + messages: &MessageCache, +) -> SharedString { + let now = chrono::Local::now(); + let fingerprint = format!( + "{}:{}", + plan.reset_at.as_deref().unwrap_or_default(), + now.timestamp() / 60 + ); + messages.computed("settings-reset-date", &fingerprint, || { + let Some(reset) = plan + .reset_at + .as_deref() + .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok()) + .map(|date| date.with_timezone(&chrono::Local)) + else { + return tr("settings-reset-monthly"); + }; + let remaining = reset - now; + if remaining < chrono::Duration::zero() { + return tr("settings-reset-monthly"); + } + let days = (reset.date_naive() - now.date_naive()).num_days(); + match days { + 0 if remaining.num_hours() == 0 => tr("settings-reset-soon"), + 0 => messages.get( + "settings-reset-hours", + [("count", remaining.num_hours().into())], + ), + 1 => tr("settings-reset-tomorrow"), + 2..=7 => messages.get("settings-reset-days", [("count", days.into())]), + _ => date(reset.date_naive(), messages), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn billing_period_follows_language_and_payment_provider() { + let _locale = crate::ui::i18n::TestLocalizationGuard::enable(); + let messages = MessageCache::default(); + let mut status = crate::billing::BillingStatus { + current_period_end: Some(1_790_000_000), + ..Default::default() + }; + for (locale, renews, expires) in [ + ("en", "Renews ", "Expires "), + ("es", "Se renueva el ", "Caduca el "), + ("en", "Renews ", "Expires "), + ] { + crate::ui::i18n::apply_preference(Some(locale)); + status.payment_provider = Some("stripe".into()); + assert!(period_end(&status, &messages).unwrap().starts_with(renews)); + status.payment_provider = Some("subscription_pass".into()); + assert!(period_end(&status, &messages).unwrap().starts_with(expires)); + } + status.current_period_end = None; + assert!(period_end(&status, &messages).is_none()); + } +} diff --git a/apps/maple-agent/app/src/ui/settings/shortcut_labels.rs b/apps/maple-agent/app/src/ui/settings/shortcut_labels.rs new file mode 100644 index 000000000..fd188ae8d --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/shortcut_labels.rs @@ -0,0 +1,271 @@ +//! Presentation adapter for the built-in keymap catalog. Never apply this to +//! user text or arbitrary host errors. The coverage test follows catalog additions. +use crate::ui::i18n::tr; +use gpui::SharedString; + +pub(super) fn label(value: &str) -> SharedString { + key(value).map_or_else(|| value.to_string().into(), tr) +} + +fn key(value: &str) -> Option<&'static str> { + Some(match value { + "Quit Maple" => "settings-shortcut-quit-maple", + "Close the active chat surface" => "settings-shortcut-close-the-active-chat-surface", + "New task" => "settings-shortcut-new-task", + "Focus task search" => "settings-shortcut-focus-task-search", + "Toggle sidebar" => "settings-shortcut-toggle-sidebar", + "Toggle archived tasks" => "settings-shortcut-toggle-archived-tasks", + "Open settings" => "settings-shortcut-open-settings", + "Choose project" => "settings-shortcut-choose-project", + "Previous task" => "settings-shortcut-previous-task", + "Next task" => "settings-shortcut-next-task", + "Hide Maple" => "settings-shortcut-hide-maple", + "Hide Others" => "settings-shortcut-hide-others", + "Copy transcript selection" => "settings-shortcut-copy-transcript-selection", + "Select all transcript text" => "settings-shortcut-select-all-transcript-text", + "Previous menu item" => "settings-shortcut-previous-menu-item", + "Next menu item" => "settings-shortcut-next-menu-item", + "First menu item" => "settings-shortcut-first-menu-item", + "Last menu item" => "settings-shortcut-last-menu-item", + "Choose menu item" => "settings-shortcut-choose-menu-item", + "Choose menu item with Space" => "settings-shortcut-choose-menu-item-with-space", + "Close menu" => "settings-shortcut-close-menu", + "Open submenu" => "settings-shortcut-open-submenu", + "Return to parent menu" => "settings-shortcut-return-to-parent-menu", + "Delete backward" => "settings-shortcut-delete-backward", + "Delete forward" => "settings-shortcut-delete-forward", + "Move left" => "settings-shortcut-move-left", + "Move right" => "settings-shortcut-move-right", + "Select left" => "settings-shortcut-select-left", + "Select right" => "settings-shortcut-select-right", + "Select all text" => "settings-shortcut-select-all-text", + "Paste" => "settings-shortcut-paste", + "Copy" => "settings-shortcut-copy", + "Cut" => "settings-shortcut-cut", + "Move to line start" => "settings-shortcut-move-to-line-start", + "Move to line end" => "settings-shortcut-move-to-line-end", + "Move up" => "settings-shortcut-move-up", + "Move down" => "settings-shortcut-move-down", + "Select up" => "settings-shortcut-select-up", + "Select down" => "settings-shortcut-select-down", + "Undo" => "settings-shortcut-undo", + "Redo" => "settings-shortcut-redo", + "Show character palette" => "settings-shortcut-show-character-palette", + "Vim insert at first non-whitespace" => { + "settings-shortcut-vim-insert-at-first-non-whitespace" + } + "Vim insert at line end" => "settings-shortcut-vim-insert-at-line-end", + "Vim open line below" => "settings-shortcut-vim-open-line-below", + "Vim open line above" => "settings-shortcut-vim-open-line-above", + "Vim undo" => "settings-shortcut-vim-undo", + "Vim redo" => "settings-shortcut-vim-redo", + "Vim repeat" => "settings-shortcut-vim-repeat", + "Vim cancel" => "settings-shortcut-vim-cancel", + "Vim cancel selection" => "settings-shortcut-vim-cancel-selection", + "Vim leave Insert mode" => "settings-shortcut-vim-leave-insert-mode", + "Move WORD forward" => "settings-shortcut-move-word-forward", + "Move WORD backward" => "settings-shortcut-move-word-backward", + "Move to WORD end" => "settings-shortcut-move-to-word-end", + "Move to previous word end" => "settings-shortcut-move-to-previous-word-end", + "Move to previous WORD end" => "settings-shortcut-move-to-previous-word-end", + "Move to first non-blank" => "settings-shortcut-move-to-first-non-blank", + "Move to matching bracket" => "settings-shortcut-move-to-matching-bracket", + "Move to previous paragraph" => "settings-shortcut-move-to-previous-paragraph", + "Move to next paragraph" => "settings-shortcut-move-to-next-paragraph", + "Repeat last find" => "settings-shortcut-repeat-last-find", + "Repeat last find backward" => "settings-shortcut-repeat-last-find-backward", + "Find character" => "settings-shortcut-find-character", + "Find character backward" => "settings-shortcut-find-character-backward", + "Move to before character" => "settings-shortcut-move-to-before-character", + "Move to after character backward" => "settings-shortcut-move-to-after-character-backward", + "Replace with character" => "settings-shortcut-replace-with-character", + "Toggle case" => "settings-shortcut-toggle-case", + "Join lines" => "settings-shortcut-join-lines", + "Toggle linewise Visual mode" => "settings-shortcut-toggle-linewise-visual-mode", + "Substitute characters" => "settings-shortcut-substitute-characters", + "Change selection" => "settings-shortcut-change-selection", + "Substitute lines" => "settings-shortcut-substitute-lines", + "Change selected lines" => "settings-shortcut-change-selected-lines", + "Change to line end" => "settings-shortcut-change-to-line-end", + "Change selected lines (C)" => "settings-shortcut-change-selected-lines-c", + "Delete to line end" => "settings-shortcut-delete-to-line-end", + "Delete selected lines" => "settings-shortcut-delete-selected-lines", + "Yank to line end" => "settings-shortcut-yank-to-line-end", + "Yank selected lines" => "settings-shortcut-yank-selected-lines", + "Delete before cursor" => "settings-shortcut-delete-before-cursor", + "Delete selected lines (X)" => "settings-shortcut-delete-selected-lines-x", + "Toggle case operator" => "settings-shortcut-toggle-case-operator", + "Lowercase operator" => "settings-shortcut-lowercase-operator", + "Uppercase operator" => "settings-shortcut-uppercase-operator", + "Uppercase line (gUU)" => "settings-shortcut-uppercase-line-guu", + "Go to other end of selection" => "settings-shortcut-go-to-other-end-of-selection", + "Lowercase selection" => "settings-shortcut-lowercase-selection", + "Uppercase selection" => "settings-shortcut-uppercase-selection", + "Delete previous word" => "settings-shortcut-delete-previous-word", + "Delete to line start" => "settings-shortcut-delete-to-line-start", + "Delete previous character" => "settings-shortcut-delete-previous-character", + "Vim cancel pending key" => "settings-shortcut-vim-cancel-pending-key", + "Move word left" => "settings-shortcut-move-word-left", + "Move word right" => "settings-shortcut-move-word-right", + "Select word left" => "settings-shortcut-select-word-left", + "Select word right" => "settings-shortcut-select-word-right", + "Delete next word" => "settings-shortcut-delete-next-word", + "Select to line start" => "settings-shortcut-select-to-line-start", + "Select to line end" => "settings-shortcut-select-to-line-end", + "Move to line start (Command)" => "settings-shortcut-move-to-line-start-command", + "Move to line end (Command)" => "settings-shortcut-move-to-line-end-command", + "Select to line start (Command)" => "settings-shortcut-select-to-line-start-command", + "Select to line end (Command)" => "settings-shortcut-select-to-line-end-command", + "Move to paragraph start" => "settings-shortcut-move-to-paragraph-start", + "Move to paragraph end" => "settings-shortcut-move-to-paragraph-end", + "Select to paragraph start" => "settings-shortcut-select-to-paragraph-start", + "Select to paragraph end" => "settings-shortcut-select-to-paragraph-end", + "Move to document start" => "settings-shortcut-move-to-document-start", + "Move to document end" => "settings-shortcut-move-to-document-end", + "Select to document start" => "settings-shortcut-select-to-document-start", + "Select to document end" => "settings-shortcut-select-to-document-end", + "Application Vim next item" => "settings-shortcut-application-vim-next-item", + "Application Vim previous item" => "settings-shortcut-application-vim-previous-item", + "Application Vim first item" => "settings-shortcut-application-vim-first-item", + "Application Vim last item" => "settings-shortcut-application-vim-last-item", + "Application Vim activate item" => "settings-shortcut-application-vim-activate-item", + "Application Vim collapse item" => "settings-shortcut-application-vim-collapse-item", + "Application Vim expand item" => "settings-shortcut-application-vim-expand-item", + "Application Vim copy transcript item" => { + "settings-shortcut-application-vim-copy-transcript-item" + } + "Application Vim search" => "settings-shortcut-application-vim-search", + "Application Vim newest assistant item" => { + "settings-shortcut-application-vim-newest-assistant-item" + } + "Application Vim previous assistant item" => { + "settings-shortcut-application-vim-previous-assistant-item" + } + "Application Vim next assistant item" => { + "settings-shortcut-application-vim-next-assistant-item" + } + "Application Vim previous annotation" => { + "settings-shortcut-application-vim-previous-annotation" + } + "Application Vim next annotation" => "settings-shortcut-application-vim-next-annotation", + "Application Vim focus last composer insertion" => { + "settings-shortcut-application-vim-focus-last-composer-insertion" + } + "Application Vim close or return" => "settings-shortcut-application-vim-close-or-return", + "Application Vim new task" => "settings-shortcut-application-vim-new-task", + "Application Vim open settings" => "settings-shortcut-application-vim-open-settings", + "Application Vim move to left region" => { + "settings-shortcut-application-vim-move-to-left-region" + } + "Application Vim move to lower region" => { + "settings-shortcut-application-vim-move-to-lower-region" + } + "Application Vim move to upper region" => { + "settings-shortcut-application-vim-move-to-upper-region" + } + "Application Vim move to right region" => { + "settings-shortcut-application-vim-move-to-right-region" + } + "Application Vim previous menu item" => { + "settings-shortcut-application-vim-previous-menu-item" + } + "Application Vim next menu item" => "settings-shortcut-application-vim-next-menu-item", + "Application Vim first menu item" => "settings-shortcut-application-vim-first-menu-item", + "Application Vim last menu item" => "settings-shortcut-application-vim-last-menu-item", + "Return from an application Vim text field" => { + "settings-shortcut-return-from-an-application-vim-text-field" + } + "Leave composer for newest assistant item" => { + "settings-shortcut-leave-composer-for-newest-assistant-item" + } + "Composer Vim last insertion" => "settings-shortcut-composer-vim-last-insertion", + "Leave composer for left region" => "settings-shortcut-leave-composer-for-left-region", + "Move from composer to lower region" => { + "settings-shortcut-move-from-composer-to-lower-region" + } + "Leave composer for upper region" => "settings-shortcut-leave-composer-for-upper-region", + "Move from composer to right region" => { + "settings-shortcut-move-from-composer-to-right-region" + } + "Move left (h)" => "settings-shortcut-move-left-h", + "Move down (j)" => "settings-shortcut-move-down-j", + "Move up (k)" => "settings-shortcut-move-up-k", + "Move right (l)" => "settings-shortcut-move-right-l", + "Move word backward" => "settings-shortcut-move-word-backward", + "Move to word end" => "settings-shortcut-move-to-word-end", + "Move to first line" => "settings-shortcut-move-to-first-line", + "Move to last line" => "settings-shortcut-move-to-last-line", + "Move left (arrow)" => "settings-shortcut-move-left-arrow", + "Move down (arrow)" => "settings-shortcut-move-down-arrow", + "Move up (arrow)" => "settings-shortcut-move-up-arrow", + "Move right (arrow)" => "settings-shortcut-move-right-arrow", + "Move word forward" => "settings-shortcut-move-word-forward", + "Inner text object / insert" => "settings-shortcut-inner-text-object-insert", + "Around text object / append" => "settings-shortcut-around-text-object-append", + "Delete operator" => "settings-shortcut-delete-operator", + "Change operator" => "settings-shortcut-change-operator", + "Yank operator" => "settings-shortcut-yank-operator", + "Toggle Visual mode" => "settings-shortcut-toggle-visual-mode", + "Delete characters" => "settings-shortcut-delete-characters", + "Paste after" => "settings-shortcut-paste-after", + "Paste before" => "settings-shortcut-paste-before", + "Choose question option 1" => "settings-shortcut-choose-question-option-1", + "Choose question option 2" => "settings-shortcut-choose-question-option-2", + "Choose question option 3" => "settings-shortcut-choose-question-option-3", + "Choose question option 4" => "settings-shortcut-choose-question-option-4", + "Choose question option 5" => "settings-shortcut-choose-question-option-5", + "Choose question option 6" => "settings-shortcut-choose-question-option-6", + "Choose question option 7" => "settings-shortcut-choose-question-option-7", + "Choose question option 8" => "settings-shortcut-choose-question-option-8", + "Choose question option 9" => "settings-shortcut-choose-question-option-9", + "Application Vim count 0" => "settings-shortcut-application-vim-count-0", + "Application Vim count 1" => "settings-shortcut-application-vim-count-1", + "Application Vim count 2" => "settings-shortcut-application-vim-count-2", + "Application Vim count 3" => "settings-shortcut-application-vim-count-3", + "Application Vim count 4" => "settings-shortcut-application-vim-count-4", + "Application Vim count 5" => "settings-shortcut-application-vim-count-5", + "Application Vim count 6" => "settings-shortcut-application-vim-count-6", + "Application Vim count 7" => "settings-shortcut-application-vim-count-7", + "Application Vim count 8" => "settings-shortcut-application-vim-count-8", + "Application Vim count 9" => "settings-shortcut-application-vim-count-9", + "Count digit 0" => "settings-shortcut-count-digit-0", + "Count digit 1" => "settings-shortcut-count-digit-1", + "Count digit 2" => "settings-shortcut-count-digit-2", + "Count digit 3" => "settings-shortcut-count-digit-3", + "Count digit 4" => "settings-shortcut-count-digit-4", + "Count digit 5" => "settings-shortcut-count-digit-5", + "Count digit 6" => "settings-shortcut-count-digit-6", + "Count digit 7" => "settings-shortcut-count-digit-7", + "Count digit 8" => "settings-shortcut-count-digit-8", + "Count digit 9" => "settings-shortcut-count-digit-9", + _ => return None, + }) +} + +pub(super) fn category(value: &str) -> SharedString { + match value { + "Application" => tr("settings-shortcut-category-application"), + "Chat" => tr("settings-shortcut-category-chat"), + "Transcript" => tr("settings-shortcut-category-transcript"), + "Menus" => tr("settings-shortcut-category-menus"), + "Text Editing" => tr("settings-shortcut-category-text-editing"), + "Composer Vim" => tr("settings-shortcut-category-composer-vim"), + "Application Vim" => tr("settings-shortcut-category-application-vim"), + _ => value.to_string().into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn every_shipped_shortcut_has_a_translation() { + for slot in crate::keymap::catalog() { + assert!( + key(slot.label).is_some(), + "untranslated shortcut: {}", + slot.id + ); + } + } +} diff --git a/apps/maple-agent/app/src/ui/store_gate.rs b/apps/maple-agent/app/src/ui/store_gate.rs index fb0595423..533e2b4fd 100644 --- a/apps/maple-agent/app/src/ui/store_gate.rs +++ b/apps/maple-agent/app/src/ui/store_gate.rs @@ -1,6 +1,7 @@ //! Local-only store entry and password forms. These screens ask the host for //! lifecycle status before loading any account-owned application data. +use super::i18n::tr; use gpui::{ Context, Entity, EventEmitter, Focusable, Render, Subscription, Window, div, prelude::*, px, }; @@ -102,6 +103,7 @@ pub(crate) struct OperationProgress { pub struct StoreGateChanged; pub struct StoreGate { + messages: super::i18n::MessageCache, focus: gpui::FocusHandle, backend: Arc, state: StoreState, @@ -147,6 +149,7 @@ impl StoreGate { /// of unrelated Tokio completions, as with `ChatScreen::new_mounted`. fn new_mounted(backend: Arc, state: StoreState, cx: &mut Context) -> Self { let mut this = Self { + messages: Default::default(), focus: cx.focus_handle(), backend, state, @@ -583,7 +586,7 @@ impl Render for StoreGate { .child(div().text_sm().child(description)); if self.erase { body = body - .child(div().text_sm().child(ERASE_COPY)) + .child(div().text_sm().child(tr(ERASE_COPY))) .child( widgets::input_frame() .id("store-gate-confirmation") @@ -623,7 +626,7 @@ impl Render for StoreGate { } else { body = match self.state { StoreState::Locked => body - .child(password_field("Unlock password", self.password.clone())) + .child(password_field("settings-password-unlock-password", self.password.clone())) .child( widgets::primary_button("store-unlock") .debug_selector(|| "store-unlock".into()) @@ -692,7 +695,7 @@ impl Render for StoreGate { body.child(div().text_sm().child( "If this store uses a password, enter its current unlock password to verify and repair its generation metadata.", )) - .child(password_field("Unlock password", self.password.clone())) + .child(password_field("settings-password-unlock-password", self.password.clone())) .child( widgets::primary_button("store-generation-unlock") .debug_selector(|| "store-generation-unlock".into()) @@ -752,7 +755,7 @@ impl Render for StoreGate { self.repair_progress.phase ) } else { - progress_copy(progress_action(self.progress.operation.as_deref()), &self.progress) + progress_copy(progress_action(self.progress.operation.as_deref()), &self.progress, &self.messages) })) .when( if self.repair_progress.busy { @@ -836,7 +839,8 @@ fn accepts_unlock(state: &StoreState) -> bool { ) } -pub(crate) const ERASE_COPY: &str = "Maple can't reset your unlock password. Erasing removes the data saved in this version's store: tasks and history, attachments, settings, sign-in, MCP secrets and this store's remote pairings. On your other computers, remove this computer from Remote computers. Backups, snapshots and previous app data remain untouched."; +pub(crate) const ERASE_COPY: &str = + "settings-password-maple-can-t-reset-your-unlock-password-erasing-removes-the-data-s"; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum PasswordAction { @@ -874,6 +878,7 @@ enum PasswordField { } pub(crate) struct PasswordForm { + messages: super::i18n::MessageCache, backend: Arc, action: PasswordAction, current: Entity, @@ -917,13 +922,20 @@ impl PasswordForm { .to_string_lossy() .into_owned(), ); - let current = - cx.new(|cx| SecretInput::new("Current unlock password", cx).with_tab_index(1)); - let password = - cx.new(|cx| SecretInput::new("Enter a new unlock password", cx).with_tab_index(2)); - let confirmation = cx - .new(|cx| SecretInput::new("Re-enter your new unlock password", cx).with_tab_index(3)); + let current = cx.new(|cx| { + SecretInput::localized("settings-password-current-unlock-password", cx) + .with_tab_index(1) + }); + let password = cx.new(|cx| { + SecretInput::localized("settings-password-enter-a-new-unlock-password", cx) + .with_tab_index(2) + }); + let confirmation = cx.new(|cx| { + SecretInput::localized("settings-password-re-enter-your-new-unlock-password", cx) + .with_tab_index(3) + }); let mut this = Self { + messages: Default::default(), backend, action, current, @@ -1123,48 +1135,64 @@ impl PasswordForm { } }); } - fn validation_message(&self, cx: &Context) -> Option<&'static str> { + fn validation_message(&self, cx: &Context) -> Option { if self.busy { - return Some("Wait for the current change to finish."); + return Some(tr( + "settings-password-wait-for-the-current-change-to-finish", + )); } if matches!(self.action, PasswordAction::Change | PasswordAction::Remove) && self.current.read(cx).is_empty() { - return Some("Enter your current unlock password to continue."); + return Some(tr( + "settings-password-enter-your-current-unlock-password-to-continue", + )); } if self.action == PasswordAction::Remove { return None; } if self.suggested && self.suggestion_retry_blocked { - return Some( - "Keep this passphrase. Maple could not confirm the previous attempt. Reopen Maple to check the store before trying again.", - ); + return Some(tr( + "settings-password-keep-this-passphrase-maple-could-not-confirm-the-previous-attempt", + )); } if self.suggested && self.generating { - return Some("Wait for your passphrase to finish generating."); + return Some(tr( + "settings-password-wait-for-your-passphrase-to-finish-generating", + )); } if self.suggested && self.phrase.is_none() { - return Some("Generate a passphrase or enter your own password."); + return Some(tr( + "settings-password-generate-a-passphrase-or-enter-your-own-password", + )); } if !self.suggested && self.password.read(cx).is_empty() { - return Some("Enter a new unlock password."); + return Some(tr("settings-password-enter-a-new-unlock-password")); } let Some(assessment) = self.assessment else { - return Some("Use between 15 and 1,024 characters for your new password."); + return Some(tr( + "settings-password-use-between-15-and-1-024-characters-for-your-new-password", + )); }; if assessment.reason == keys::StrengthReason::Length { - return Some("Use between 15 and 1,024 characters for your new password."); + return Some(tr( + "settings-password-use-between-15-and-1-024-characters-for-your-new-password", + )); } if !assessment.allowed() { - return Some("Choose a less predictable password. Try a longer, unique phrase."); + return Some(tr( + "settings-password-choose-a-less-predictable-password-try-a-longer-unique-phrase", + )); } if self.confirmation.read(cx).is_empty() { - return Some("Re-enter your new unlock password in Confirm password."); + return Some(tr( + "settings-password-re-enter-your-new-unlock-password-in-confirm-password", + )); } if !self.confirmation_matches { - return Some( - "The passwords don't match. Re-enter your new password in Confirm password.", - ); + return Some(tr( + "settings-password-the-passwords-don-t-match-re-enter-your-new-password-in-confirm-p", + )); } None } @@ -1174,7 +1202,7 @@ impl PasswordForm { fn submit(&mut self, cx: &mut Context) { if !self.can_submit(cx) { if !self.busy { - self.message = self.validation_message(cx).map(str::to_owned); + self.message = self.validation_message(cx).map(|text| text.to_string()); cx.notify(); } return; @@ -1328,9 +1356,11 @@ impl PasswordForm { self.suggested && self.phrase.is_some() && !generated_retry_valid; self.assess(cx); self.message = Some(if self.suggestion_retry_blocked { - format!( - "{message} Keep this passphrase. Maple could not confirm the previous attempt. Reopen Maple to check the store before trying again." + super::i18n::tr_args( + "settings-password-unconfirmed-attempt", + [("message", message.into())], ) + .to_string() } else { message }); @@ -1394,8 +1424,12 @@ impl PasswordForm { cx, |this, result, cx| { this.message = Some(match result { - Ok(true) => "Cancelling before the store is replaced…".into(), - Ok(false) => "The store is committing. Wait for it to finish.".into(), + Ok(true) => { + tr("settings-password-cancelling-before-the-store-is-replaced").into() + } + Ok(false) => { + tr("settings-password-the-store-is-committing-wait-for-it-to-finish").into() + } Err(message) => message, }); cx.notify(); @@ -1418,24 +1452,29 @@ impl Render for PasswordForm { } let (title, description, submit_label) = match self.action { PasswordAction::Set | PasswordAction::Setup => ( - "Enable encryption", - "Protect saved conversation text, settings and sign-in. Attached files and images remain unencrypted on this computer.", - "Enable encryption", + tr("settings-password-enable-encryption"), + tr( + "settings-password-protect-saved-conversation-text-settings-and-sign-in-attached-fil", + ), + tr("settings-password-enable-encryption"), ), PasswordAction::Change => ( - "Change unlock password", - "Enter your current unlock password, then choose a new one.", - "Save password", + tr("settings-password-change-unlock-password"), + tr("settings-password-enter-your-current-unlock-password-then-choose-a-new-one"), + tr("settings-password-save-password"), ), PasswordAction::Remove => ( - "Remove encryption", - "Your local Maple data will no longer be encrypted. Enter your current password to continue.", - "Remove encryption", + tr("settings-password-remove-encryption"), + tr( + "settings-password-your-local-maple-data-will-no-longer-be-encrypted-enter-your-curr", + ), + tr("settings-password-remove-encryption"), ), }; let mut pane = widgets::card_row() - .w_full() - .max_w(px(560.)) + .w(px(560.)) + .max_w_full() + .flex_shrink_0() .flex() .flex_col() .gap_5() @@ -1459,23 +1498,23 @@ impl Render for PasswordForm { ), ); if self.busy { - pane = pane.child( - div() - .text_sm() - .child(progress_copy(Some(self.action), &self.progress)), - ); + pane = pane.child(div().text_sm().child(progress_copy( + Some(self.action), + &self.progress, + &self.messages, + ))); if self.progress.cancellable { pane = pane.child( widgets::secondary_button("store-operation-cancel") .tab_index(1) .on_click(cx.listener(|this, _event, _window, cx| this.cancel(cx))) - .child("Cancel"), + .child(tr("settings-password-cancel")), ); } } else { if matches!(self.action, PasswordAction::Change | PasswordAction::Remove) { pane = pane.child(password_field( - "Current unlock password", + "settings-password-current-unlock-password", self.current.clone(), )); } @@ -1490,7 +1529,7 @@ impl Render for PasswordForm { div() .text_sm() .font_weight(gpui::FontWeight::MEDIUM) - .child("New unlock password"), + .child(tr("settings-password-new-unlock-password")), ) .when(!self.suggested, |row| { row.child( @@ -1507,7 +1546,7 @@ impl Render for PasswordForm { } }, )) - .child("Generate passphrase"), + .child(tr("settings-password-generate-passphrase")), ) }), ); @@ -1526,14 +1565,14 @@ impl Render for PasswordForm { suggestion.child(suggested_phrase_display(phrase.passphrase.clone())); } else { suggestion = suggestion.child(div().text_sm().child(if self.generating { - "Generating a random passphrase…" + tr("settings-password-generating-a-random-passphrase") } else { - "Couldn't generate a passphrase. Try again or enter your own password." + tr("settings-password-couldn-t-generate-a-passphrase-try-again-or-enter-your-own-passwo") })); } suggestion = suggestion .child(div().text_xs().text_color(gpui::rgb(theme::text_secondary())) - .child("Six random words. Save them in your password manager before continuing.")) + .child(tr("settings-password-six-random-words-save-them-in-your-password-manager-before-contin"))) .child(div().flex().flex_wrap().gap_2() .child(widgets::secondary_button("store-copy-phrase") .tab_index(7) @@ -1541,21 +1580,21 @@ impl Render for PasswordForm { .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { if password_action_key(event, window, cx) { this.copy_phrase(cx); cx.stop_propagation(); } })) - .child("Copy passphrase")) + .child(tr("settings-password-copy-passphrase"))) .child(widgets::ghost_button("store-another-phrase") .tab_index(8) .on_click(cx.listener(|this, _event, _window, cx| this.generate(cx))) .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { if password_action_key(event, window, cx) { this.generate(cx); cx.stop_propagation(); } })) - .child("Generate another")) + .child(tr("settings-password-generate-another"))) .child(widgets::ghost_button("store-use-typed") .tab_index(9) .on_click(cx.listener(|this, _event, _window, cx| this.use_typed(cx))) .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { if password_action_key(event, window, cx) { this.use_typed(cx); cx.stop_propagation(); } })) - .child("Use my own password"))); + .child(tr("settings-password-use-my-own-password")))); new_password = new_password.child(suggestion); } else { new_password = new_password @@ -1568,7 +1607,7 @@ impl Render for PasswordForm { .text_xs() .text_color(gpui::rgb(theme::text_secondary())) .child( - "Use at least 15 characters. A long, unique phrase works well.", + tr("settings-password-use-at-least-15-characters-a-long-unique-phrase-works-well"), ), ); } @@ -1577,14 +1616,20 @@ impl Render for PasswordForm { new_password = new_password.child(div().text_sm().text_color(gpui::rgb(color)).child( if assessment.reason == keys::StrengthReason::Length { - "Use between 15 and 1,024 characters.".to_owned() + tr("settings-password-use-between-15-and-1-024-characters") + .to_string() } else { - format!("Password strength: {label}") + self.messages + .get( + "settings-password-strength", + [("strength", label.to_string().into())], + ) + .to_string() }, )); } pane = pane.child(new_password).child(password_field( - "Confirm password", + "settings-password-confirm-password", self.confirmation.clone(), )); if !self.confirmation.read(cx).is_empty() { @@ -1597,15 +1642,15 @@ impl Render for PasswordForm { theme::status_error() })) .child(if self.confirmation_matches { - "Passwords match" + tr("settings-password-passwords-match") } else { - "Passwords don't match" + tr("settings-password-passwords-don-t-match") }), ); } if self.action == PasswordAction::Change { pane = pane.child(div().flex().flex_col().gap_1() - .child(widgets::checkbox("store-reencrypt", self.reencrypt, "Also create a new encryption key") + .child(widgets::checkbox("store-reencrypt", self.reencrypt, tr("settings-password-also-create-a-new-encryption-key")) .debug_selector(|| "store-reencrypt".into()) .tab_index(4) .on_click(cx.listener(|this, _event, _window, cx| { @@ -1620,14 +1665,14 @@ impl Render for PasswordForm { } }))) .child(div().pl_7().text_xs().text_color(gpui::rgb(theme::text_secondary())) - .child("Use a new encryption key if your old password and a copy of this store may have been exposed. This protects this store and future writes; it cannot revoke existing copies."))); + .child(tr("settings-password-use-a-new-encryption-key-if-your-old-password-and-a-copy-of-this")))); } pane = pane.child(div().flex().flex_col().gap_1().text_xs() .text_color(gpui::rgb(theme::text_secondary())) - .child("This is separate from your Maple account password. Maple can't reset it.") - .child("If you forget it, you'll need to erase this version's local data and pair your other computers again.") + .child(tr("settings-password-this-is-separate-from-your-maple-account-password-maple-can-t-res")) + .child(tr("settings-password-if-you-forget-it-you-ll-need-to-erase-this-version-s-local-data-a")) .when(matches!(self.action, PasswordAction::Set | PasswordAction::Setup), |note| note.child( - "Existing backups, snapshots and previous app data aren't re-encrypted.", + tr("settings-password-existing-backups-snapshots-and-previous-app-data-aren-t-re-encryp"), ))); } } @@ -1661,7 +1706,7 @@ impl Render for PasswordForm { } }, )) - .child("Cancel"), + .child(tr("settings-password-cancel")), ) .child( widgets::primary_button("store-password-submit") @@ -1686,8 +1731,8 @@ impl Render for PasswordForm { fn password_field(label: &'static str, input: Entity) -> gpui::Div { let selector = match label { - "Current unlock password" => "password-current-frame", - "Confirm password" => "password-confirmation-frame", + "settings-password-current-unlock-password" => "password-current-frame", + "settings-password-confirm-password" => "password-confirmation-frame", _ => "password-unlock-frame", }; div() @@ -1699,7 +1744,7 @@ fn password_field(label: &'static str, input: Entity) -> gpui::Div div() .text_sm() .font_weight(gpui::FontWeight::MEDIUM) - .child(label), + .child(tr(label)), ) .child(password_input_frame(selector, input)) } @@ -1851,26 +1896,35 @@ fn suggested_phrase_display(phrase: SecretString) -> impl IntoElement { pub(crate) fn operation_notice(result: &Value) -> Option { let flag = |name| result.get(name).and_then(Value::as_bool).unwrap_or(false); if flag("synchronization_pending") || flag("sync_pending") { - Some("The new unlock password took effect, but Maple couldn't confirm its key file was synchronized. Keep both passwords until synchronization finishes.".into()) + Some(tr("settings-password-the-new-unlock-password-took-effect-but-maple-couldn-t-confirm-it").into()) } else if flag("backup_pending") { - Some("The new unlock password took effect. Maple still needs to synchronize its backup key file.".into()) + Some(tr("settings-password-the-new-unlock-password-took-effect-maple-still-needs-to-synchron").into()) } else if flag("used_previous_password") { - Some("Maple opened with your previous unlock password. Keep your current and previous unlock passwords until the key files are synchronized.".into()) + Some(tr("settings-password-maple-opened-with-your-previous-unlock-password-keep-your-current").into()) } else if flag("reconciliation_pending") { - Some("Maple is open. Key-file synchronization or its security upgrade still needs attention. Your existing unlock password remains valid.".into()) + Some(tr("settings-password-maple-is-open-key-file-synchronization-or-its-security-upgrade-st").into()) } else if flag("notice_pending") { - Some("Encryption changed. Maple still needs to save the local mode-change notice.".into()) + Some(tr("settings-password-encryption-changed-maple-still-needs-to-save-the-local-mode-chang").into()) } else { None } } -fn meter_presentation(level: Option) -> (&'static str, u32) { +fn meter_presentation(level: Option) -> (gpui::SharedString, u32) { match level { - Some(StrengthLevel::TooEasy) => ("Too easy to guess", theme::status_error()), - Some(StrengthLevel::CouldBeStronger) => ("Could be stronger", theme::status_warning()), - Some(StrengthLevel::Strong) => ("Strong", theme::status_success()), - None => ("Enter a password", theme::text_muted()), + Some(StrengthLevel::TooEasy) => ( + tr("settings-password-too-easy-to-guess"), + theme::status_error(), + ), + Some(StrengthLevel::CouldBeStronger) => ( + tr("settings-password-could-be-stronger"), + theme::status_warning(), + ), + Some(StrengthLevel::Strong) => (tr("settings-password-strong"), theme::status_success()), + None => ( + tr("settings-password-enter-a-password"), + theme::text_muted(), + ), } } impl OperationProgress { @@ -1901,40 +1955,49 @@ fn readable_bytes(bytes: u64) -> String { format!("{bytes} B") } -fn progress_copy(action: Option, progress: &OperationProgress) -> String { - let verb = match action { - Some(PasswordAction::Remove) => "Removing encryption", - Some(PasswordAction::Change) => "Updating your unlock password", - Some(PasswordAction::Set | PasswordAction::Setup) => "Encrypting your local data", - None => "Updating your local store", - }; +fn progress_copy( + action: Option, + progress: &OperationProgress, + messages: &super::i18n::MessageCache, +) -> String { + let verb = tr(match action { + Some(PasswordAction::Remove) => "settings-password-removing-encryption", + Some(PasswordAction::Change) => "settings-password-updating-your-unlock-password", + Some(PasswordAction::Set | PasswordAction::Setup) => { + "settings-password-encrypting-your-local-data" + } + None => "settings-password-updating-your-local-store", + }); let exporting = progress.busy && progress.phase == "exporting"; - let estimate = progress - .remaining_ms - .filter(|ms| exporting && *ms > 0) - .map(|ms| { - let minutes = (ms.saturating_add(59_999) / 60_000).max(1); - format!( - " About {minutes} {} left.", - if minutes == 1 { "minute" } else { "minutes" } - ) - }) - .unwrap_or_default(); - let bytes = if exporting && progress.total_bytes > 0 { - format!( - " Copied about {} of {}.", - readable_bytes(progress.completed_bytes), - readable_bytes(progress.total_bytes) - ) - } else { - String::new() - }; - let phase = if progress.busy && progress.phase == "committing" { - " Finishing the change. Keep Maple open." - } else { - "" - }; - format!("{verb}.{estimate}{bytes}{phase}") + let mut parts = vec![verb.to_string() + "."]; + if let Some(ms) = progress.remaining_ms.filter(|ms| exporting && *ms > 0) { + let minutes = (ms.saturating_add(59_999) / 60_000).max(1); + parts.push( + messages + .get( + "settings-password-progress-estimate", + [("count", minutes.into())], + ) + .to_string(), + ); + } + if exporting && progress.total_bytes > 0 { + parts.push( + messages + .get( + "settings-password-progress-bytes", + [ + ("done", readable_bytes(progress.completed_bytes).into()), + ("total", readable_bytes(progress.total_bytes).into()), + ], + ) + .to_string(), + ); + } + if progress.busy && progress.phase == "committing" { + parts.push(tr("settings-password-progress-committing").to_string()); + } + parts.join(" ") } #[cfg(test)] @@ -2098,23 +2161,37 @@ mod tests { remaining_ms: Some(60_000), ..Default::default() }; - let text = progress_copy(Some(PasswordAction::Remove), &progress); + let text = progress_copy(Some(PasswordAction::Remove), &progress, &Default::default()) + .replace(['\u{2068}', '\u{2069}'], ""); assert!(text.starts_with("Removing encryption.")); assert!(text.contains("1.0 MiB of 2.0 MiB")); assert!(text.contains("1 minute left")); assert!(!text.contains("1 minutes")); progress.remaining_ms = Some(60_001); - assert!(progress_copy(None, &progress).contains("2 minutes left")); + assert!( + progress_copy(None, &progress, &Default::default()) + .replace(['\u{2068}', '\u{2069}'], "") + .contains("2 minutes left") + ); progress.remaining_ms = Some(0); - assert!(!progress_copy(None, &progress).contains("left")); + assert!( + !progress_copy(None, &progress, &Default::default()) + .replace(['\u{2068}', '\u{2069}'], "") + .contains("left") + ); for phase in ["verifying", "committing", "complete"] { progress.phase = phase.into(); - let text = progress_copy(None, &progress); + let text = progress_copy(None, &progress, &Default::default()) + .replace(['\u{2068}', '\u{2069}'], ""); assert!(!text.contains("left")); assert!(!text.contains("Copied")); assert!(!text.contains("committed")); } - assert!(progress_copy(None, &progress).starts_with("Updating your local store.")); + assert!( + progress_copy(None, &progress, &Default::default()) + .replace(['\u{2068}', '\u{2069}'], "") + .starts_with("Updating your local store.") + ); assert_eq!(readable_bytes(512), "512 B"); assert_eq!(readable_bytes(1024), "1.0 KiB"); assert_eq!(readable_bytes(1 << 30), "1.0 GiB"); diff --git a/apps/maple-agent/app/src/ui/store_gate/password_form_tests.rs b/apps/maple-agent/app/src/ui/store_gate/password_form_tests.rs index 16bcb1408..44369b03a 100644 --- a/apps/maple-agent/app/src/ui/store_gate/password_form_tests.rs +++ b/apps/maple-agent/app/src/ui/store_gate/password_form_tests.rs @@ -46,7 +46,7 @@ fn strong_matching_change_explains_missing_current_and_preserves_secrets( assert!(reason.to_lowercase().contains("current")); form.submit(cx); - assert_eq!(form.message.as_deref(), Some(reason)); + assert_eq!(form.message.as_deref(), Some(reason.as_ref())); assert!(form.current.read(cx).is_empty()); form.password .read(cx) @@ -69,7 +69,7 @@ fn mismatch_has_specific_feedback_and_does_not_consume_any_field(cx: &mut gpui:: assert!(!form.can_submit(cx)); form.submit(cx); - assert_eq!(form.message.as_deref(), Some(reason)); + assert_eq!(form.message.as_deref(), Some(reason.as_ref())); form.current .read(cx) .with_secret(|value| assert_eq!(value, CURRENT)); @@ -95,7 +95,7 @@ fn strong_password_requires_confirmation_with_an_explicit_reason(cx: &mut gpui:: .expect("confirmation-required feedback"); assert!(reason.to_lowercase().contains("confirm")); form.submit(cx); - assert_eq!(form.message.as_deref(), Some(reason)); + assert_eq!(form.message.as_deref(), Some(reason.as_ref())); assert_no_submission(form); }); } @@ -117,7 +117,7 @@ fn weak_password_reports_strength_instead_of_silently_ignoring_save(cx: &mut gpu || lower.contains("guess") ); form.submit(cx); - assert_eq!(form.message.as_deref(), Some(reason)); + assert_eq!(form.message.as_deref(), Some(reason.as_ref())); assert_no_submission(form); }); } @@ -136,7 +136,7 @@ fn an_overlength_password_reports_its_bound_before_confirmation(cx: &mut gpui::T let lower = reason.to_lowercase(); assert!(lower.contains("long") || lower.contains("1024") || lower.contains("1,024")); form.submit(cx); - assert_eq!(form.message.as_deref(), Some(reason)); + assert_eq!(form.message.as_deref(), Some(reason.as_ref())); assert_eq!(form.password.read(cx).len_chars(), 1025); assert_no_submission(form); }); diff --git a/apps/maple-agent/app/src/ui/text_input.rs b/apps/maple-agent/app/src/ui/text_input.rs index 6b7eb7c41..313f93da6 100644 --- a/apps/maple-agent/app/src/ui/text_input.rs +++ b/apps/maple-agent/app/src/ui/text_input.rs @@ -102,6 +102,7 @@ pub struct TextInput { focus_handle: FocusHandle, content: SharedString, placeholder: SharedString, + placeholder_key: Option<&'static str>, selected_range: Range, selection_reversed: bool, /// A soft wrap has two visual caret positions at the same byte offset. @@ -253,10 +254,23 @@ impl TextInput { self } + /// A catalog-backed placeholder and accessibility label that follow live language changes. + pub fn localized(key: &'static str, cx: &mut Context) -> Self { + let mut input = Self::new("", cx); + input.placeholder_key = Some(key); + input + } + + fn placeholder(&self) -> SharedString { + self.placeholder_key + .map_or_else(|| self.placeholder.clone(), super::i18n::tr) + } + pub fn new(placeholder: &str, cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle(), content: "".into(), + placeholder_key: None, placeholder: SharedString::from(placeholder.to_string()), selected_range: 0..0, selection_reversed: false, @@ -447,6 +461,7 @@ impl TextInput { } pub fn set_placeholder(&mut self, text: &str, cx: &mut Context) { + self.placeholder_key = None; self.placeholder = SharedString::from(text.to_string()); cx.notify(); } @@ -2723,7 +2738,7 @@ impl Element for TextElement { let layout = window.request_measured_layout(style, move |known, available, window, cx| { let input = entity.read(cx); let text = if input.content.is_empty() { - input.placeholder.clone() + input.placeholder() } else { input.display_text() }; @@ -2810,7 +2825,7 @@ impl Element for TextElement { // surrounding element happens to set: an input inside a caption // row or an unstyled container still reads in both themes. let (display_text, text_color) = if content.is_empty() { - (input.placeholder.clone(), theme::placeholder()) + (input.placeholder(), theme::placeholder()) } else { (input.display_text(), rgb(theme::text_primary()).into()) }; @@ -3208,8 +3223,8 @@ impl Render for TextInput { } else { gpui::Role::TextInput }) - .aria_label(self.placeholder.clone()) - .aria_placeholder(self.placeholder.clone()) + .aria_label(self.placeholder()) + .aria_placeholder(self.placeholder()) .flex() .key_context(key_context) .track_focus(&self.focus_handle(cx)) @@ -4571,3 +4586,22 @@ mod tests { ); } } + +#[cfg(test)] +#[gpui::test] +fn localized_placeholder_follows_language_without_replacing_text(cx: &mut gpui::TestAppContext) { + let _locale = super::i18n::TestLocalizationGuard::enable(); + let input = cx.new(|cx| TextInput::localized("settings-account-current-password", cx)); + input.update(cx, |input, cx| input.set_text("typed value", cx)); + for (locale, expected) in [ + ("en", "Current password"), + ("es", "Contraseña actual"), + ("en", "Current password"), + ] { + super::i18n::apply_preference(Some(locale)); + input.update(cx, |input, _| { + assert_eq!(input.placeholder().as_ref(), expected); + assert_eq!(input.text(), "typed value"); + }); + } +} diff --git a/apps/maple-agent/app/src/ui/widgets.rs b/apps/maple-agent/app/src/ui/widgets.rs index cbfa5bb89..c51d9d9c0 100644 --- a/apps/maple-agent/app/src/ui/widgets.rs +++ b/apps/maple-agent/app/src/ui/widgets.rs @@ -144,11 +144,12 @@ pub fn danger_button(id: impl Into) -> Stateful
{ pub fn icon_button( id: impl Into, name: &'static str, - label: &'static str, + label: impl Into, size: Pixels, color: u32, ) -> Stateful
{ - icon_button_base(id, name, label, size, color).tooltip(tooltip(label, None)) + let label = label.into(); + icon_button_base(id, name, label.clone(), size, color).tooltip(tooltip(label, None)) } /// [`icon_button`] for a control that fires `action`: its tooltip also @@ -168,7 +169,7 @@ pub fn icon_button_for_action( fn icon_button_base( id: impl Into, name: &'static str, - label: &'static str, + label: impl Into, size: Pixels, color: u32, ) -> Stateful
{ @@ -212,11 +213,16 @@ pub fn switch(id: impl Into, on: bool) -> Stateful
{ /// A labeled checkbox row. The caller supplies its tab index and click/key /// handlers, so mouse and keyboard activation share the same state change. -pub fn checkbox(id: impl Into, checked: bool, label: &'static str) -> Stateful
{ +pub fn checkbox( + id: impl Into, + checked: bool, + label: impl Into, +) -> Stateful
{ + let label = label.into(); div() .id(id) .role(Role::CheckBox) - .aria_label(label) + .aria_label(label.clone()) .aria_toggled(if checked { gpui::Toggled::True } else { @@ -286,7 +292,8 @@ pub fn switch_track(on: bool) -> Div { /// Small status pill beside a card's title (an integration's availability, /// a paired computer's update state). `color` tints only the label. -pub fn status_badge(label: &'static str, color: u32) -> Div { +pub fn status_badge(label: impl Into, color: u32) -> Div { + let label = label.into(); div() .flex_none() .px_2() @@ -587,7 +594,7 @@ pub fn copy_button( div() .id(id) .role(Role::Button) - .aria_label("Copy") + .aria_label(super::i18n::tr("common-copy")) .flex() .items_center() .gap_1() @@ -637,7 +644,11 @@ pub fn copy_button( theme::text_secondary() }, )) - .child(if copied { "Copied" } else { "Copy" }) + .child(super::i18n::tr(if copied { + "common-copied" + } else { + "common-copy" + })) } #[cfg(test)] diff --git a/apps/maple-agent/docs/localization.md b/apps/maple-agent/docs/localization.md index 85af77c71..ee7ad94fe 100644 --- a/apps/maple-agent/docs/localization.md +++ b/apps/maple-agent/docs/localization.md @@ -1,10 +1,10 @@ # Agent localization The localization foundation uses embedded Fluent catalogs. -It currently covers the update confirmation dialog (including accessibility -text), the full General settings pane (including dropdown choices, the chat -preview and test notification), and shared Settings navigation. English and -Spanish are enabled in the preview. +It covers all Settings pages, their local password/encryption forms, shortcut names +and search, remote-computer controls, and the update confirmation dialog. General +includes dropdown choices, the chat preview and test notification. English and +Spanish are enabled. Most of the app is still English; this is infrastructure for incremental extraction, not a claim of complete localization. @@ -18,11 +18,14 @@ The OS locale supplies the initial default. The first host settings snapshot loads the saved preference. A user selection clears the plain-label and System label caches and refreshes GPUI windows. General settings resolves labels during render; an open update confirmation reformats its versioned title only when its -locale changes. Saved control values and element IDs remain stable. +locale changes. Settings also rebuilds its filtered shortcut list and remote +status projection. Catalog-backed input placeholders and accessibility labels +follow the current locale without replacing typed text. Saved control values, +confirmation words (`DELETE` and `ERASE`), commands and element IDs remain stable. Locale state currently belongs to the GPUI thread. Call presentation helpers on that thread; background workers should return semantic data for the UI to format. -Tests use isolated thread-local state and default to English. Other pages and +Tests use isolated thread-local state and default to English. Pages outside Settings and native/tray menus remain English until extracted. Future translated cached views must invalidate formatted text on locale changes; refreshing windows alone does not rebuild their stored strings. @@ -41,6 +44,11 @@ including additions/removals. `fluent-bundle` supplies typed argument values. Format these when the input state changes and retain the result in the view; do not format dynamic messages on every render. Pass numbers as numbers so Fluent can select CLDR plural branches. The System language label is cached. +- Settings uses view-owned `MessageCache` instances for parameterized render + labels. Each message retains at most 512 argument sets; a locale change clears + the cache, and dropping the view releases it. `UiText` retains semantic keys + and arguments for notices, resolving them again after a language change. Raw + host errors are explicitly separate from these localized notices. - Translate accessibility labels too. Keep element IDs, keymap slots, commands, protocol values and other machine-readable identifiers stable. - Keep complete sentences together. Add translator comments explaining args. @@ -75,11 +83,14 @@ are outside this preview's Spanish coverage. ## Explicit follow-ups -Extraction is substantially larger than just `child(...)` calls: shortcut names -and categories, desktop code, backend/host/remote errors, and other non-UI files -also contain display text. Work module by module, starting with Settings/login. +Extraction is substantially larger than just `child(...)` calls. Settings now +includes an adapter for the built-in shortcut catalog, with coverage tests for +new catalog entries. Desktop code, login, backend/host errors and other non-UI +files still contain display text. -- Host/ACP/backend errors remain English strings. Localizing them needs typed +- Host/ACP/backend errors and host-owned integration detection details remain + English strings. Typed remote update outcomes and credit validation errors + are formatted in the UI. Localizing the remaining host strings needs typed semantic errors plus arguments across the process boundary, formatted by the consuming UI; it is not a reason to translate shared host strings in place. - Persisted timeline labels remain unchanged. Their future migration requires @@ -88,8 +99,11 @@ also contain display text. Work module by module, starting with Settings/login. content and generated content are never translated by UI extraction. - Native/tray menus, OS dialogs and the standalone OAuth completion HTML are outside this first milestone. Each has its own lifecycle/packaging contract. -- Dates, localized numeric separators and money need a separate formatting - decision; Fluent plural selection does not provide complete locale formatting. +- Settings dates use translated month names and date patterns. The plan-usage + DTO includes optional raw product/reset inputs so the UI can format them; + older hosts remain readable. Numeric separators and money still use the + existing application formats; Fluent plural selection does not provide + complete locale formatting. - Spell checking remains `en_US`; UI language and content language are separate. - RTL layout/mirroring is not supported by this implementation. Do not enable RTL locales until the GPUI/platform limitation is addressed. CJK and other