diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 47e3ca9cf6..64a360a2e5 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -827,8 +827,10 @@ "flate2_1.1.8": "{\"dependencies\":[{\"name\":\"cloudflare-zlib-sys\",\"optional\":true,\"req\":\"^0.3.6\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"libz-ng-sys\",\"optional\":true,\"req\":\"^1.1.16\"},{\"default_features\":false,\"name\":\"libz-sys\",\"optional\":true,\"req\":\"^1.1.20\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8.5\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"emscripten\\\")))\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"optional\":true,\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\",\"rust-allocator\"],\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5.5\"}],\"features\":{\"any_c_zlib\":[\"any_zlib\"],\"any_impl\":[],\"any_zlib\":[\"any_impl\"],\"cloudflare_zlib\":[\"any_c_zlib\",\"cloudflare-zlib-sys\"],\"default\":[\"rust_backend\"],\"miniz-sys\":[\"rust_backend\"],\"rust_backend\":[\"miniz_oxide\",\"any_impl\"],\"zlib\":[\"any_c_zlib\",\"libz-sys\"],\"zlib-default\":[\"any_c_zlib\",\"libz-sys/default\"],\"zlib-ng\":[\"any_c_zlib\",\"libz-ng-sys\"],\"zlib-ng-compat\":[\"zlib\",\"libz-sys/zlib-ng\"],\"zlib-rs\":[\"any_zlib\",\"dep:zlib-rs\"]}}", "float-cmp_0.10.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.1\"}],\"features\":{\"default\":[\"ratio\"],\"ratio\":[\"num-traits\"],\"std\":[]}}", "fluent-bundle_0.15.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"fluent-langneg\",\"req\":\"^0.13\"},{\"name\":\"fluent-syntax\",\"req\":\"^0.11.1\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"name\":\"intl-memoizer\",\"req\":\"^0.5.2\"},{\"name\":\"intl_pluralrules\",\"req\":\"^7.0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"rustc-hash\",\"req\":\"^1\"},{\"name\":\"self_cell\",\"req\":\"^0.10\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.8\"},{\"name\":\"smallvec\",\"req\":\"^1\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{\"all-benchmarks\":[],\"default\":[]}}", + "fluent-bundle_0.16.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"fluent-langneg\",\"req\":\"^0.13\"},{\"name\":\"fluent-syntax\",\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"name\":\"intl-memoizer\",\"req\":\"^0.5.3\"},{\"name\":\"intl_pluralrules\",\"req\":\"^7.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"rustc-hash\",\"req\":\"^2.1\"},{\"name\":\"self_cell\",\"req\":\"^1.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9\"},{\"name\":\"smallvec\",\"req\":\"^1.13\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{\"all-benchmarks\":[],\"default\":[]}}", "fluent-langneg_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-locale\",\"req\":\"^0.9\"}],\"features\":{\"cldr\":[\"unic-langid/likelysubtags\"],\"default\":[]}}", "fluent-syntax_0.11.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"}],\"features\":{\"all-benchmarks\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"]}}", + "fluent-syntax_0.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{\"all-benchmarks\":[],\"default\":[],\"json\":[\"serde\",\"dep:serde_json\"],\"serde\":[\"dep:serde\"]}}", "fluent_0.16.1": "{\"dependencies\":[{\"name\":\"fluent-bundle\",\"req\":\"^0.15.3\"},{\"name\":\"fluent-pseudo\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", "flume_0.11.1": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"features\":[\"getrandom\"],\"name\":\"nanorand\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"nanorand\"],\"select\":[],\"spin\":[]}}", "flume_0.12.0": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\",\"js\"],\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"fastrand\"],\"select\":[],\"spin\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d6a13a3d4c..9e90f461a8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1848,6 +1848,7 @@ dependencies = [ "codex-file-search", "codex-git", "codex-hooks", + "codex-i18n", "codex-keyring-store", "codex-network-proxy", "codex-otel", @@ -2111,6 +2112,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-i18n" +version = "0.0.0" +dependencies = [ + "fluent-bundle 0.16.0", + "include_dir", + "pretty_assertions", + "sys-locale", + "thiserror 2.0.18", + "unic-langid", +] + [[package]] name = "codex-keyring-store" version = "0.0.0" @@ -4139,7 +4152,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" dependencies = [ - "fluent-bundle", + "fluent-bundle 0.15.3", "unic-langid", ] @@ -4150,7 +4163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" dependencies = [ "fluent-langneg", - "fluent-syntax", + "fluent-syntax 0.11.1", "intl-memoizer", "intl_pluralrules", "rustc-hash 1.1.0", @@ -4159,6 +4172,22 @@ dependencies = [ "unic-langid", ] +[[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 0.12.0", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.1", + "self_cell 1.2.2", + "smallvec", + "unic-langid", +] + [[package]] name = "fluent-langneg" version = "0.13.1" @@ -4177,6 +4206,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[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.18", +] + [[package]] name = "flume" version = "0.11.1" @@ -4875,7 +4914,7 @@ dependencies = [ "arc-swap", "fluent", "fluent-langneg", - "fluent-syntax", + "fluent-syntax 0.11.1", "i18n-embed-impl", "intl-memoizer", "log", @@ -4894,7 +4933,7 @@ checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" dependencies = [ "find-crate", "fluent", - "fluent-syntax", + "fluent-syntax 0.11.1", "i18n-config", "i18n-embed", "proc-macro-error2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 35ff64195e..ad8051c1ec 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -3,6 +3,7 @@ members = [ "backend-client", "ansi-escape", "async-utils", + "i18n", "app-server", "app-server-client", "app-server-protocol", @@ -112,6 +113,7 @@ codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } codex-git = { path = "utils/git" } codex-hooks = { path = "hooks" } +codex-i18n = { path = "i18n" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } @@ -191,6 +193,7 @@ env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" flate2 = "1.1.4" +fluent-bundle = "0.16.0" futures = { version = "0.3", default-features = false } gethostname = "1.1.0" globset = "0.4" @@ -304,6 +307,7 @@ tree-sitter-bash = "0.25" ts-rs = "11" tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } uds_windows = "1.1.0" +unic-langid = "0.9.6" unicode-segmentation = "1.12.0" unicode-width = "0.2" url = "2" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d11e209813..a2677f6d9f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -41,6 +41,7 @@ codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } codex-hooks = { workspace = true } +codex-i18n = { workspace = true } codex-keyring-store = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b2f88d3344..fd1cdd1fe8 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2183,6 +2183,10 @@ ], "description": "Optional absolute path to the Node runtime used by `js_repl`." }, + "locale": { + "description": "Preferred locale for localized user-facing strings.", + "type": "string" + }, "log_dir": { "allOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ee856664dd..3e9f814312 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -16,6 +16,7 @@ use crate::config_loader::RequirementSource; use crate::features::Feature; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_i18n::DEFAULT_LOCALE; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -4247,6 +4248,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { )?; assert_eq!( Config { + locale: o3_profile_config.locale.clone(), model: Some("o3".to_string()), review_model: None, model_context_window: None, @@ -4388,6 +4390,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { fixture.codex_home(), )?; let expected_gpt3_profile_config = Config { + locale: gpt3_profile_config.locale.clone(), model: Some("gpt-3.5-turbo".to_string()), review_model: None, model_context_window: None, @@ -4527,6 +4530,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { fixture.codex_home(), )?; let expected_zdr_profile_config = Config { + locale: zdr_profile_config.locale.clone(), model: Some("o3".to_string()), review_model: None, model_context_window: None, @@ -4652,6 +4656,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { fixture.codex_home(), )?; let expected_gpt5_profile_config = Config { + locale: gpt5_profile_config.locale.clone(), model: Some("gpt-5.1".to_string()), review_model: None, model_context_window: None, @@ -6155,6 +6160,59 @@ speaker = "Desk Speakers" Ok(()) } +#[test] +fn locale_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +locale = "zh-CN" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!(cfg.locale.as_deref(), Some("zh-CN")); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.locale, "zh-CN"); + Ok(()) +} + +#[test] +fn invalid_locale_is_rejected() { + let codex_home = TempDir::new().expect("tempdir"); + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + locale: Some("not a locale".to_string()), + ..Default::default() + }, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect_err("invalid locale should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!(err.to_string().contains("invalid locale `not a locale`")); +} + +#[test] +fn missing_or_unsupported_locale_falls_back_to_default() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.locale.trim().is_empty()); + assert!(config.locale == DEFAULT_LOCALE || config.locale == "zh-CN"); + Ok(()) +} + #[derive(Deserialize, Debug, PartialEq)] struct TuiTomlTest { #[serde(default)] diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a1f270458a..f5f3b6c18f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -65,6 +65,7 @@ use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; +use codex_i18n::resolve_locale; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; @@ -237,6 +238,9 @@ pub struct Config { /// Warnings collected during config load that should be shown on startup. pub startup_warnings: Vec, + /// Resolved UI locale used for localized user-facing strings. + pub locale: String, + /// Optional override of model selection. pub model: Option, @@ -1247,6 +1251,9 @@ pub struct ConfigToml { #[serde(default)] pub notify: Option>, + /// Preferred locale for localized user-facing strings. + pub locale: Option, + /// System instructions. pub instructions: Option, @@ -2400,6 +2407,9 @@ impl Config { let allow_login_shell = cfg.allow_login_shell.unwrap_or(true); let history = cfg.history.unwrap_or_default(); + let locale = resolve_locale(cfg.locale.as_deref()).map_err(|err| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{err}")) + })?; let agent_max_threads = cfg .agents @@ -2650,6 +2660,7 @@ impl Config { NetworkSandboxPolicy::from(&effective_sandbox_policy) }; let config = Self { + locale, model, service_tier, review_model, diff --git a/codex-rs/core/src/consequential_tool_message_templates.json b/codex-rs/core/src/consequential_tool_message_templates.json index 83e11c79a2..381712c3b7 100644 --- a/codex-rs/core/src/consequential_tool_message_templates.json +++ b/codex-rs/core/src/consequential_tool_message_templates.json @@ -1,5 +1,5 @@ { - "schema_version": 4, + "schema_version": 5, "templates": [ { "source_tool_index": 0, @@ -9,18 +9,18 @@ "template_params": [ { "name": "pr_number", - "label": "Pull request" + "label_id": "approval-param-pull-request" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "comment", - "label": "Comment" + "label_id": "approval-param-comment" } ], - "template": "Allow {connector_name} to add a comment to a pull request?" + "question_id": "approval-question-github-add-comment-to-issue" }, { "source_tool_index": 1, @@ -30,18 +30,18 @@ "template_params": [ { "name": "reaction", - "label": "Reaction" + "label_id": "approval-param-reaction" }, { "name": "comment_id", - "label": "Comment" + "label_id": "approval-param-comment" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to add a reaction to an issue comment?" + "question_id": "approval-question-github-add-reaction-to-issue-comment" }, { "source_tool_index": 2, @@ -51,18 +51,18 @@ "template_params": [ { "name": "reaction", - "label": "Reaction" + "label_id": "approval-param-reaction" }, { "name": "pr_number", - "label": "Pull request" + "label_id": "approval-param-pull-request" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to add a reaction to a pull request?" + "question_id": "approval-question-github-add-reaction-to-pr" }, { "source_tool_index": 3, @@ -72,18 +72,18 @@ "template_params": [ { "name": "reaction", - "label": "Reaction" + "label_id": "approval-param-reaction" }, { "name": "comment_id", - "label": "Comment" + "label_id": "approval-param-comment" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to add a reaction to a pull request review comment?" + "question_id": "approval-question-github-add-reaction-to-pr-review-comment" }, { "source_tool_index": 4, @@ -93,18 +93,18 @@ "template_params": [ { "name": "action", - "label": "Action" + "label_id": "approval-param-action" }, { "name": "pr_number", - "label": "Pull request" + "label_id": "approval-param-pull-request" }, { "name": "review", - "label": "Review" + "label_id": "approval-param-review" } ], - "template": "Allow {connector_name} to submit a pull request review?" + "question_id": "approval-question-github-add-review-to-pr" }, { "source_tool_index": 5, @@ -114,14 +114,14 @@ "template_params": [ { "name": "repository_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "content", - "label": "Content" + "label_id": "approval-param-content" } ], - "template": "Allow {connector_name} to create a Git blob?" + "question_id": "approval-question-github-create-blob" }, { "source_tool_index": 6, @@ -131,14 +131,14 @@ "template_params": [ { "name": "branch_name", - "label": "Branch" + "label_id": "approval-param-branch" }, { "name": "repository_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to create a branch?" + "question_id": "approval-question-github-create-branch" }, { "source_tool_index": 7, @@ -148,14 +148,14 @@ "template_params": [ { "name": "repository_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "message", - "label": "Message" + "label_id": "approval-param-message" } ], - "template": "Allow {connector_name} to create a commit?" + "question_id": "approval-question-github-create-commit" }, { "source_tool_index": 8, @@ -165,18 +165,18 @@ "template_params": [ { "name": "title", - "label": "Title" + "label_id": "approval-param-title" }, { "name": "head_branch", - "label": "Head branch" + "label_id": "approval-param-head-branch" }, { "name": "base_branch", - "label": "Base branch" + "label_id": "approval-param-base-branch" } ], - "template": "Allow {connector_name} to create a pull request?" + "question_id": "approval-question-github-create-pull-request" }, { "source_tool_index": 9, @@ -186,14 +186,14 @@ "template_params": [ { "name": "repository_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "tree_elements", - "label": "Changes" + "label_id": "approval-param-changes" } ], - "template": "Allow {connector_name} to create a Git tree?" + "question_id": "approval-question-github-create-tree" }, { "source_tool_index": 10, @@ -203,14 +203,14 @@ "template_params": [ { "name": "pr_number", - "label": "Pull request" + "label_id": "approval-param-pull-request" }, { "name": "repository_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to enable pull request auto-merge?" + "question_id": "approval-question-github-enable-auto-merge" }, { "source_tool_index": 11, @@ -220,18 +220,18 @@ "template_params": [ { "name": "label", - "label": "Label" + "label_id": "approval-param-label" }, { "name": "pr_number", - "label": "Pull request" + "label_id": "approval-param-pull-request" }, { "name": "repository_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to add a label to a pull request?" + "question_id": "approval-question-github-label-pr" }, { "source_tool_index": 12, @@ -241,18 +241,18 @@ "template_params": [ { "name": "reaction_id", - "label": "Reaction" + "label_id": "approval-param-reaction" }, { "name": "comment_id", - "label": "Comment" + "label_id": "approval-param-comment" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to remove a reaction from an issue comment?" + "question_id": "approval-question-github-remove-reaction-from-issue-comment" }, { "source_tool_index": 13, @@ -262,18 +262,18 @@ "template_params": [ { "name": "reaction_id", - "label": "Reaction" + "label_id": "approval-param-reaction" }, { "name": "pr_number", - "label": "Pull request" + "label_id": "approval-param-pull-request" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to remove a reaction from a pull request?" + "question_id": "approval-question-github-remove-reaction-from-pr" }, { "source_tool_index": 14, @@ -283,18 +283,18 @@ "template_params": [ { "name": "reaction_id", - "label": "Reaction" + "label_id": "approval-param-reaction" }, { "name": "comment_id", - "label": "Comment" + "label_id": "approval-param-comment" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" } ], - "template": "Allow {connector_name} to remove a reaction from a pull request review comment?" + "question_id": "approval-question-github-remove-reaction-from-pr-review-comment" }, { "source_tool_index": 15, @@ -304,18 +304,18 @@ "template_params": [ { "name": "pr_number", - "label": "Pull request" + "label_id": "approval-param-pull-request" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "comment", - "label": "Comment" + "label_id": "approval-param-comment" } ], - "template": "Allow {connector_name} to reply to a pull request review comment?" + "question_id": "approval-question-github-reply-to-review-comment" }, { "source_tool_index": 16, @@ -325,18 +325,18 @@ "template_params": [ { "name": "comment_id", - "label": "Comment ID" + "label_id": "approval-param-comment-id" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "comment", - "label": "Comment" + "label_id": "approval-param-comment" } ], - "template": "Allow {connector_name} to update an issue comment?" + "question_id": "approval-question-github-update-issue-comment" }, { "source_tool_index": 17, @@ -346,18 +346,18 @@ "template_params": [ { "name": "branch_name", - "label": "Branch" + "label_id": "approval-param-branch" }, { "name": "repository_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "sha", - "label": "Commit" + "label_id": "approval-param-commit" } ], - "template": "Allow {connector_name} to update a branch reference?" + "question_id": "approval-question-github-update-ref" }, { "source_tool_index": 18, @@ -367,18 +367,18 @@ "template_params": [ { "name": "comment_id", - "label": "Comment ID" + "label_id": "approval-param-comment-id" }, { "name": "repo_full_name", - "label": "Repository" + "label_id": "approval-param-repository" }, { "name": "comment", - "label": "Comment" + "label_id": "approval-param-comment" } ], - "template": "Allow {connector_name} to update a pull request review comment?" + "question_id": "approval-question-github-update-review-comment" }, { "source_tool_index": 19, @@ -388,18 +388,18 @@ "template_params": [ { "name": "title", - "label": "Title" + "label_id": "approval-param-title" }, { "name": "start_time", - "label": "Start" + "label_id": "approval-param-start" }, { "name": "attendees", - "label": "Attendees" + "label_id": "approval-param-attendees" } ], - "template": "Allow {connector_name} to create an event?" + "question_id": "approval-question-google-calendar-create-event" }, { "source_tool_index": 20, @@ -409,10 +409,10 @@ "template_params": [ { "name": "event_id", - "label": "Event" + "label_id": "approval-param-event" } ], - "template": "Allow {connector_name} to delete an event?" + "question_id": "approval-question-google-calendar-delete-event" }, { "source_tool_index": 21, @@ -422,14 +422,14 @@ "template_params": [ { "name": "response_status", - "label": "Response Status" + "label_id": "approval-param-response-status" }, { "name": "event_id", - "label": "Event" + "label_id": "approval-param-event" } ], - "template": "Allow {connector_name} to respond to an event?" + "question_id": "approval-question-google-calendar-respond-event" }, { "source_tool_index": 22, @@ -439,10 +439,10 @@ "template_params": [ { "name": "event_id", - "label": "Event" + "label_id": "approval-param-event" } ], - "template": "Allow {connector_name} to update an event?" + "question_id": "approval-question-google-calendar-update-event" }, { "source_tool_index": 23, @@ -452,14 +452,14 @@ "template_params": [ { "name": "spreadsheet_url", - "label": "Spreadsheet" + "label_id": "approval-param-spreadsheet" }, { "name": "requests", - "label": "Changes" + "label_id": "approval-param-changes" } ], - "template": "Allow {connector_name} to apply spreadsheet updates?" + "question_id": "approval-question-google-sheets-batch-update" }, { "source_tool_index": 24, @@ -469,10 +469,10 @@ "template_params": [ { "name": "title", - "label": "Title" + "label_id": "approval-param-title" } ], - "template": "Allow {connector_name} to create a spreadsheet?" + "question_id": "approval-question-google-sheets-create-spreadsheet" }, { "source_tool_index": 25, @@ -482,18 +482,18 @@ "template_params": [ { "name": "source_sheet_name", - "label": "Source Sheet Name" + "label_id": "approval-param-source-sheet-name" }, { "name": "spreadsheet_url", - "label": "Spreadsheet" + "label_id": "approval-param-spreadsheet" }, { "name": "new_file_name", - "label": "New File Name" + "label_id": "approval-param-new-file-name" } ], - "template": "Allow {connector_name} to copy a sheet into a new spreadsheet?" + "question_id": "approval-question-google-sheets-duplicate-sheet-in-new-file" }, { "source_tool_index": 26, @@ -503,14 +503,14 @@ "template_params": [ { "name": "presentation_url", - "label": "Presentation" + "label_id": "approval-param-presentation" }, { "name": "requests", - "label": "Changes" + "label_id": "approval-param-changes" } ], - "template": "Allow {connector_name} to apply presentation updates?" + "question_id": "approval-question-google-slides-batch-update" }, { "source_tool_index": 27, @@ -520,10 +520,10 @@ "template_params": [ { "name": "title", - "label": "Title" + "label_id": "approval-param-title" } ], - "template": "Allow {connector_name} to create a presentation?" + "question_id": "approval-question-google-slides-create-presentation" }, { "source_tool_index": 28, @@ -533,14 +533,14 @@ "template_params": [ { "name": "document_url", - "label": "Document" + "label_id": "approval-param-document" }, { "name": "requests", - "label": "Changes" + "label_id": "approval-param-changes" } ], - "template": "Allow {connector_name} to apply document updates?" + "question_id": "approval-question-google-docs-batch-update" }, { "source_tool_index": 29, @@ -550,10 +550,10 @@ "template_params": [ { "name": "title", - "label": "Title" + "label_id": "approval-param-title" } ], - "template": "Allow {connector_name} to create a document?" + "question_id": "approval-question-google-docs-create-document" }, { "source_tool_index": 30, @@ -563,10 +563,10 @@ "template_params": [ { "name": "url", - "label": "URL" + "label_id": "approval-param-url" } ], - "template": "Allow {connector_name} to copy a file?" + "question_id": "approval-question-google-drive-copy-document" }, { "source_tool_index": 31, @@ -576,14 +576,14 @@ "template_params": [ { "name": "url", - "label": "URL" + "label_id": "approval-param-url" }, { "name": "permission", - "label": "Permission" + "label_id": "approval-param-permission" } ], - "template": "Allow {connector_name} to change file sharing?" + "question_id": "approval-question-google-drive-share-document" }, { "source_tool_index": 32, @@ -593,14 +593,14 @@ "template_params": [ { "name": "channel_id", - "label": "Conversation" + "label_id": "approval-param-conversation" }, { "name": "message", - "label": "Message" + "label_id": "approval-param-message" } ], - "template": "Allow {connector_name} to send a message?" + "question_id": "approval-question-slack-slack-send-message" }, { "source_tool_index": 33, @@ -610,18 +610,18 @@ "template_params": [ { "name": "channel_id", - "label": "Conversation" + "label_id": "approval-param-conversation" }, { "name": "post_at", - "label": "Send at" + "label_id": "approval-param-send-at" }, { "name": "message", - "label": "Message" + "label_id": "approval-param-message" } ], - "template": "Allow {connector_name} to schedule a message?" + "question_id": "approval-question-slack-slack-schedule-message" }, { "source_tool_index": 34, @@ -631,14 +631,14 @@ "template_params": [ { "name": "title", - "label": "Title" + "label_id": "approval-param-title" }, { "name": "content", - "label": "Content" + "label_id": "approval-param-content" } ], - "template": "Allow {connector_name} to create a canvas?" + "question_id": "approval-question-slack-slack-create-canvas" }, { "source_tool_index": 35, @@ -648,14 +648,14 @@ "template_params": [ { "name": "channel_id", - "label": "Conversation" + "label_id": "approval-param-conversation" }, { "name": "message", - "label": "Message" + "label_id": "approval-param-message" } ], - "template": "Allow {connector_name} to create a message draft?" + "question_id": "approval-question-slack-slack-send-message-draft" }, { "source_tool_index": 36, @@ -665,14 +665,14 @@ "template_params": [ { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" }, { "name": "body", - "label": "Body" + "label_id": "approval-param-body" } ], - "template": "Allow {connector_name} to add a comment to an issue?" + "question_id": "approval-question-linear-add-comment-to-issue" }, { "source_tool_index": 37, @@ -682,14 +682,14 @@ "template_params": [ { "name": "label_id", - "label": "Label" + "label_id": "approval-param-label" }, { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" } ], - "template": "Allow {connector_name} to add a label to an issue?" + "question_id": "approval-question-linear-add-label-to-issue" }, { "source_tool_index": 38, @@ -699,18 +699,18 @@ "template_params": [ { "name": "url", - "label": "URL" + "label_id": "approval-param-url" }, { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" }, { "name": "title", - "label": "Title" + "label_id": "approval-param-title" } ], - "template": "Allow {connector_name} to attach a link to an issue?" + "question_id": "approval-question-linear-add-url-attachment-to-issue" }, { "source_tool_index": 39, @@ -720,14 +720,14 @@ "template_params": [ { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" }, { "name": "user_id", - "label": "User" + "label_id": "approval-param-user" } ], - "template": "Allow {connector_name} to assign an issue?" + "question_id": "approval-question-linear-assign-issue" }, { "source_tool_index": 40, @@ -737,14 +737,14 @@ "template_params": [ { "name": "title", - "label": "Title" + "label_id": "approval-param-title" }, { "name": "team_id", - "label": "Team" + "label_id": "approval-param-team" } ], - "template": "Allow {connector_name} to create an issue?" + "question_id": "approval-question-linear-create-issue" }, { "source_tool_index": 41, @@ -754,10 +754,10 @@ "template_params": [ { "name": "label_name", - "label": "Label Name" + "label_id": "approval-param-label-name" } ], - "template": "Allow {connector_name} to create a label?" + "question_id": "approval-question-linear-create-label" }, { "source_tool_index": 42, @@ -767,14 +767,14 @@ "template_params": [ { "name": "name", - "label": "Name" + "label_id": "approval-param-name" }, { "name": "team_id", - "label": "Team" + "label_id": "approval-param-team" } ], - "template": "Allow {connector_name} to create a project?" + "question_id": "approval-question-linear-create-project" }, { "source_tool_index": 43, @@ -784,14 +784,14 @@ "template_params": [ { "name": "label_id", - "label": "Label" + "label_id": "approval-param-label" }, { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" } ], - "template": "Allow {connector_name} to remove a label from an issue?" + "question_id": "approval-question-linear-remove-label-from-issue" }, { "source_tool_index": 44, @@ -801,10 +801,10 @@ "template_params": [ { "name": "comment_id", - "label": "Comment" + "label_id": "approval-param-comment" } ], - "template": "Allow {connector_name} to resolve a comment?" + "question_id": "approval-question-linear-resolve-comment" }, { "source_tool_index": 45, @@ -814,14 +814,14 @@ "template_params": [ { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" }, { "name": "state_id", - "label": "State" + "label_id": "approval-param-state" } ], - "template": "Allow {connector_name} to change issue state?" + "question_id": "approval-question-linear-set-issue-state" }, { "source_tool_index": 46, @@ -831,10 +831,10 @@ "template_params": [ { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" } ], - "template": "Allow {connector_name} to unassign an issue?" + "question_id": "approval-question-linear-unassign-issue" }, { "source_tool_index": 47, @@ -844,14 +844,14 @@ "template_params": [ { "name": "issue_id", - "label": "Issue" + "label_id": "approval-param-issue" }, { "name": "issue_update", - "label": "Changes" + "label_id": "approval-param-changes" } ], - "template": "Allow {connector_name} to update an issue?" + "question_id": "approval-question-linear-update-issue" }, { "source_tool_index": 48, @@ -861,14 +861,14 @@ "template_params": [ { "name": "project_id", - "label": "Project" + "label_id": "approval-param-project" }, { "name": "update_fields", - "label": "Changes" + "label_id": "approval-param-changes" } ], - "template": "Allow {connector_name} to update a project?" + "question_id": "approval-question-linear-update-project" }, { "source_tool_index": 49, @@ -876,7 +876,7 @@ "server_name": "codex_apps", "tool_title": "apply_labels_to_emails", "template_params": [], - "template": "Allow {connector_name} to apply label changes to messages?" + "question_id": "approval-question-gmail-apply-labels-to-emails" }, { "source_tool_index": 50, @@ -884,7 +884,7 @@ "server_name": "codex_apps", "tool_title": "batch_modify_email", "template_params": [], - "template": "Allow {connector_name} to update message labels?" + "question_id": "approval-question-gmail-batch-modify-email" }, { "source_tool_index": 51, @@ -894,14 +894,14 @@ "template_params": [ { "name": "label_name", - "label": "Label Name" + "label_id": "approval-param-label-name" }, { "name": "query", - "label": "Query" + "label_id": "approval-param-query" } ], - "template": "Allow {connector_name} to label matching messages?" + "question_id": "approval-question-gmail-bulk-label-matching-emails" }, { "source_tool_index": 52, @@ -911,18 +911,18 @@ "template_params": [ { "name": "to", - "label": "To" + "label_id": "approval-param-to" }, { "name": "subject", - "label": "Subject" + "label_id": "approval-param-subject" }, { "name": "body", - "label": "Body" + "label_id": "approval-param-body" } ], - "template": "Allow {connector_name} to create an email draft?" + "question_id": "approval-question-gmail-create-draft" }, { "source_tool_index": 53, @@ -932,10 +932,10 @@ "template_params": [ { "name": "name", - "label": "Name" + "label_id": "approval-param-name" } ], - "template": "Allow {connector_name} to create a label?" + "question_id": "approval-question-gmail-create-label" }, { "source_tool_index": 54, @@ -945,18 +945,18 @@ "template_params": [ { "name": "to", - "label": "To" + "label_id": "approval-param-to" }, { "name": "subject", - "label": "Subject" + "label_id": "approval-param-subject" }, { "name": "body", - "label": "Body" + "label_id": "approval-param-body" } ], - "template": "Allow {connector_name} to send an email?" + "question_id": "approval-question-gmail-send-email" } ] } diff --git a/codex-rs/core/src/mcp_tool_approval_templates.rs b/codex-rs/core/src/mcp_tool_approval_templates.rs index b1e0ee27d9..d57ebd8c61 100644 --- a/codex-rs/core/src/mcp_tool_approval_templates.rs +++ b/codex-rs/core/src/mcp_tool_approval_templates.rs @@ -1,14 +1,14 @@ use std::collections::HashSet; use std::sync::LazyLock; +use codex_i18n::format_message; use serde::Deserialize; use serde::Serialize; use serde_json::Map; use serde_json::Value; use tracing::warn; -const CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION: u8 = 4; -const CONNECTOR_NAME_TEMPLATE_VAR: &str = "{connector_name}"; +const CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION: u8 = 5; static CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES: LazyLock< Option>, @@ -40,17 +40,18 @@ struct ConsequentialToolMessageTemplate { connector_id: String, server_name: String, tool_title: String, - template: String, + question_id: String, template_params: Vec, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] struct ConsequentialToolTemplateParam { name: String, - label: String, + label_id: String, } pub(crate) fn render_mcp_tool_approval_template( + locale: &str, server_name: &str, connector_id: Option<&str>, connector_name: Option<&str>, @@ -60,6 +61,7 @@ pub(crate) fn render_mcp_tool_approval_template( let templates = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.as_ref()?; render_mcp_tool_approval_template_from_templates( templates, + locale, server_name, connector_id, connector_name, @@ -93,6 +95,7 @@ fn load_consequential_tool_message_templates() -> Option, connector_name: Option<&str>, @@ -106,10 +109,11 @@ fn render_mcp_tool_approval_template_from_templates( && template.connector_id == connector_id && template.tool_title == tool_title })?; - let elicitation_message = render_question_template(&template.template, connector_name)?; + let elicitation_message = + render_question_template(locale, &template.question_id, connector_name)?; let (tool_params, tool_params_display) = match tool_params { Some(Value::Object(tool_params)) => { - render_tool_params(tool_params, &template.template_params)? + render_tool_params(locale, tool_params, &template.template_params)? } Some(_) => return None, None => (None, Vec::new()), @@ -123,23 +127,24 @@ fn render_mcp_tool_approval_template_from_templates( }) } -fn render_question_template(template: &str, connector_name: Option<&str>) -> Option { - let template = template.trim(); - if template.is_empty() { +fn render_question_template( + locale: &str, + question_id: &str, + connector_name: Option<&str>, +) -> Option { + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty())?; + let rendered = format_message(locale, question_id, &[("connector_name", connector_name)])?; + let rendered = rendered.trim(); + if rendered.is_empty() { return None; } - - if template.contains(CONNECTOR_NAME_TEMPLATE_VAR) { - let connector_name = connector_name - .map(str::trim) - .filter(|name| !name.is_empty())?; - return Some(template.replace(CONNECTOR_NAME_TEMPLATE_VAR, connector_name)); - } - - Some(template.to_string()) + Some(rendered.to_string()) } fn render_tool_params( + locale: &str, tool_params: &Map, template_params: &[ConsequentialToolTemplateParam], ) -> Option<(Option, Vec)> { @@ -148,7 +153,8 @@ fn render_tool_params( let mut handled_names = HashSet::new(); for template_param in template_params { - let label = template_param.label.trim(); + let label = format_message(locale, &template_param.label_id, &[])?; + let label = label.trim(); if label.is_empty() { return None; } @@ -202,21 +208,22 @@ mod tests { connector_id: "calendar".to_string(), server_name: "codex_apps".to_string(), tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), + question_id: "approval-question-google-calendar-create-event".to_string(), template_params: vec![ ConsequentialToolTemplateParam { name: "calendar_id".to_string(), - label: "Calendar".to_string(), + label_id: "approval-param-calendar".to_string(), }, ConsequentialToolTemplateParam { name: "title".to_string(), - label: "Title".to_string(), + label_id: "approval-param-title".to_string(), }, ], }]; let rendered = render_mcp_tool_approval_template_from_templates( &templates, + "en-US", "codex_apps", Some("calendar"), Some("Calendar"), @@ -265,13 +272,14 @@ mod tests { connector_id: "calendar".to_string(), server_name: "codex_apps".to_string(), tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), + question_id: "approval-question-google-calendar-create-event".to_string(), template_params: Vec::new(), }]; assert_eq!( render_mcp_tool_approval_template_from_templates( &templates, + "en-US", "codex_apps", Some("calendar"), Some("Calendar"), @@ -288,16 +296,17 @@ mod tests { connector_id: "calendar".to_string(), server_name: "codex_apps".to_string(), tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), + question_id: "approval-question-google-calendar-create-event".to_string(), template_params: vec![ConsequentialToolTemplateParam { name: "calendar_id".to_string(), - label: "timezone".to_string(), + label_id: "approval-param-timezone".to_string(), }], }]; assert_eq!( render_mcp_tool_approval_template_from_templates( &templates, + "en-US", "codex_apps", Some("calendar"), Some("Calendar"), @@ -322,15 +331,16 @@ mod tests { connector_id: "github".to_string(), server_name: "codex_apps".to_string(), tool_title: "add_comment".to_string(), - template: "Allow GitHub to add a comment to a pull request?".to_string(), + question_id: "approval-question-github-add-comment-to-issue".to_string(), template_params: Vec::new(), }]; let rendered = render_mcp_tool_approval_template_from_templates( &templates, + "en-US", "codex_apps", Some("github"), - None, + Some("GitHub"), Some("add_comment"), Some(&json!({})), ); @@ -352,13 +362,14 @@ mod tests { connector_id: "calendar".to_string(), server_name: "codex_apps".to_string(), tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), + question_id: "approval-question-google-calendar-create-event".to_string(), template_params: Vec::new(), }]; assert_eq!( render_mcp_tool_approval_template_from_templates( &templates, + "en-US", "codex_apps", Some("calendar"), None, @@ -368,4 +379,46 @@ mod tests { None ); } + + #[test] + fn renders_exact_match_in_chinese() { + let templates = vec![ConsequentialToolMessageTemplate { + connector_id: "google_docs".to_string(), + server_name: "codex_apps".to_string(), + tool_title: "create_document".to_string(), + question_id: "approval-question-google-docs-create-document".to_string(), + template_params: vec![ConsequentialToolTemplateParam { + name: "title".to_string(), + label_id: "approval-param-title".to_string(), + }], + }]; + + let rendered = render_mcp_tool_approval_template_from_templates( + &templates, + "zh-CN", + "codex_apps", + Some("google_docs"), + Some("Google Docs"), + Some("create_document"), + Some(&json!({ + "title": "路线图评审", + })), + ); + + assert_eq!( + rendered, + Some(RenderedMcpToolApprovalTemplate { + question: "允许 Google Docs 创建文档吗?".to_string(), + elicitation_message: "允许 Google Docs 创建文档吗?".to_string(), + tool_params: Some(json!({ + "title": "路线图评审", + })), + tool_params_display: vec![RenderedMcpToolApprovalParam { + name: "title".to_string(), + value: json!("路线图评审"), + display_name: "标题".to_string(), + }], + }) + ); + } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 06d801cbac..eb10992822 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -551,6 +551,7 @@ async fn maybe_request_mcp_tool_approval( ); let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}"); let rendered_template = render_mcp_tool_approval_template( + &turn_context.config.locale, &invocation.server, metadata.and_then(|metadata| metadata.connector_id.as_deref()), metadata.and_then(|metadata| metadata.connector_name.as_deref()), diff --git a/codex-rs/i18n/BUILD.bazel b/codex-rs/i18n/BUILD.bazel new file mode 100644 index 0000000000..3feab04eea --- /dev/null +++ b/codex-rs/i18n/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "i18n", + crate_name = "codex_i18n", + compile_data = glob(["src/locales/**"]), +) diff --git a/codex-rs/i18n/Cargo.toml b/codex-rs/i18n/Cargo.toml new file mode 100644 index 0000000000..846d894bab --- /dev/null +++ b/codex-rs/i18n/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-i18n" +version.workspace = true + +[lib] +name = "codex_i18n" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +fluent-bundle = { workspace = true } +include_dir = { workspace = true } +sys-locale = { workspace = true } +thiserror = { workspace = true } +unic-langid = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/i18n/src/lib.rs b/codex-rs/i18n/src/lib.rs new file mode 100644 index 0000000000..dede6552e7 --- /dev/null +++ b/codex-rs/i18n/src/lib.rs @@ -0,0 +1,224 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + +use fluent_bundle::FluentArgs; +use fluent_bundle::FluentResource; +use fluent_bundle::concurrent::FluentBundle; +use include_dir::Dir; +use include_dir::include_dir; +use thiserror::Error; +use unic_langid::LanguageIdentifier; + +const LOCALES_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/locales"); +pub const DEFAULT_LOCALE: &str = "en-US"; + +type Bundle = FluentBundle; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum LocaleError { + #[error("invalid locale `{0}`")] + InvalidLocale(String), +} + +static SUPPORTED_LOCALES: LazyLock> = LazyLock::new(load_supported_locales); + +static BUNDLES: LazyLock> = LazyLock::new(load_bundles); + +pub fn resolve_locale(config_locale: Option<&str>) -> Result { + resolve_locale_with_system(config_locale, sys_locale::get_locale().as_deref()) +} + +pub fn resolve_locale_with_system( + config_locale: Option<&str>, + system_locale: Option<&str>, +) -> Result { + if let Some(config_locale) = config_locale { + let requested = parse_locale(config_locale) + .ok_or_else(|| LocaleError::InvalidLocale(config_locale.trim().to_string()))?; + return Ok(negotiate_locale(Some(&requested))); + } + + let requested = system_locale.and_then(parse_locale); + Ok(negotiate_locale(requested.as_ref())) +} + +pub fn format_message(locale: &str, message_id: &str, args: &[(&str, &str)]) -> Option { + let bundle = BUNDLES + .get(locale) + .or_else(|| BUNDLES.get(DEFAULT_LOCALE))?; + render_message(bundle, message_id, args).or_else(|| { + BUNDLES + .get(DEFAULT_LOCALE) + .and_then(|fallback| render_message(fallback, message_id, args)) + }) +} + +fn parse_locale(value: &str) -> Option { + normalize_locale_candidate(value)?.parse().ok() +} + +fn normalize_locale_candidate(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + let without_modifier = trimmed.split('@').next()?; + let without_encoding = without_modifier.split('.').next()?; + let normalized = without_encoding.replace('_', "-"); + (!normalized.is_empty()).then_some(normalized) +} + +fn negotiate_locale(requested: Option<&LanguageIdentifier>) -> String { + let Some(requested) = requested else { + return DEFAULT_LOCALE.to_string(); + }; + + let exact = requested.to_string(); + if SUPPORTED_LOCALES.iter().any(|locale| locale == &exact) { + return exact; + } + + let requested_language = requested.language.as_str(); + SUPPORTED_LOCALES + .iter() + .find(|locale| locale.split('-').next() == Some(requested_language)) + .cloned() + .unwrap_or_else(|| DEFAULT_LOCALE.to_string()) +} + +fn load_supported_locales() -> Vec { + let mut locales = LOCALES_DIR + .dirs() + .filter_map(|dir| dir.path().file_name()) + .filter_map(|name| name.to_str()) + .map(ToString::to_string) + .collect::>(); + locales.sort(); + locales +} + +fn load_bundles() -> HashMap { + let mut bundles = HashMap::new(); + + for dir in LOCALES_DIR.dirs() { + let Some(locale_name) = dir.path().file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(language_identifier) = parse_locale(locale_name) else { + continue; + }; + let mut bundle = FluentBundle::new_concurrent(vec![language_identifier]); + bundle.set_use_isolating(false); + + for file in dir.files() { + if file.path().extension().and_then(|value| value.to_str()) != Some("ftl") { + continue; + } + + let Some(contents) = file.contents_utf8() else { + panic!("locale files must be valid UTF-8"); + }; + let Ok(resource) = FluentResource::try_new(contents.to_string()) else { + panic!("locale files must be valid Fluent resources"); + }; + if bundle.add_resource(resource).is_err() { + panic!("locale resources must not define duplicate messages"); + } + } + + bundles.insert(locale_name.to_string(), bundle); + } + + bundles +} + +fn render_message(bundle: &Bundle, message_id: &str, args: &[(&str, &str)]) -> Option { + let message = bundle.get_message(message_id)?; + let pattern = message.value()?; + let mut errors = Vec::new(); + let fluent_args = (!args.is_empty()).then(|| { + let mut fluent_args = FluentArgs::new(); + for (name, value) in args { + fluent_args.set(*name, *value); + } + fluent_args + }); + + Some( + bundle + .format_pattern(pattern, fluent_args.as_ref(), &mut errors) + .into_owned(), + ) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn explicit_locale_overrides_system_locale() { + assert_eq!( + resolve_locale_with_system(Some("zh-CN"), Some("en-US")).expect("resolve locale"), + "zh-CN" + ); + } + + #[test] + fn unsupported_explicit_locale_falls_back_to_default() { + assert_eq!( + resolve_locale_with_system(Some("fr-FR"), Some("zh-CN")).expect("resolve locale"), + DEFAULT_LOCALE + ); + } + + #[test] + fn supported_system_locale_is_used_when_config_is_missing() { + assert_eq!( + resolve_locale_with_system(None, Some("zh")).expect("resolve locale"), + "zh-CN" + ); + } + + #[test] + fn unsupported_system_locale_falls_back_to_default() { + assert_eq!( + resolve_locale_with_system(None, Some("fr-FR")).expect("resolve locale"), + DEFAULT_LOCALE + ); + } + + #[test] + fn invalid_explicit_locale_is_rejected() { + assert_eq!( + resolve_locale_with_system(Some("not a locale"), None), + Err(LocaleError::InvalidLocale("not a locale".to_string())) + ); + } + + #[test] + fn formats_english_message() { + assert_eq!( + format_message( + "en-US", + "approval-question-google-docs-create-document", + &[("connector_name", "Google Docs")] + ), + Some("Allow Google Docs to create a document?".to_string()) + ); + } + + #[test] + fn formats_chinese_message() { + assert_eq!( + format_message( + "zh-CN", + "approval-question-google-docs-create-document", + &[("connector_name", "Google Docs")] + ), + Some("允许 Google Docs 创建文档吗?".to_string()) + ); + } +} diff --git a/codex-rs/i18n/src/locales/en-US/approval.ftl b/codex-rs/i18n/src/locales/en-US/approval.ftl new file mode 100644 index 0000000000..285bfde3f1 --- /dev/null +++ b/codex-rs/i18n/src/locales/en-US/approval.ftl @@ -0,0 +1,98 @@ +approval-param-action = Action +approval-param-attendees = Attendees +approval-param-base-branch = Base branch +approval-param-body = Body +approval-param-branch = Branch +approval-param-calendar = Calendar +approval-param-changes = Changes +approval-param-comment = Comment +approval-param-comment-id = Comment ID +approval-param-commit = Commit +approval-param-content = Content +approval-param-conversation = Conversation +approval-param-document = Document +approval-param-event = Event +approval-param-head-branch = Head branch +approval-param-issue = Issue +approval-param-label = Label +approval-param-label-name = Label Name +approval-param-message = Message +approval-param-name = Name +approval-param-new-file-name = New File Name +approval-param-permission = Permission +approval-param-presentation = Presentation +approval-param-project = Project +approval-param-pull-request = Pull request +approval-param-query = Query +approval-param-reaction = Reaction +approval-param-repository = Repository +approval-param-response-status = Response Status +approval-param-review = Review +approval-param-send-at = Send at +approval-param-source-sheet-name = Source Sheet Name +approval-param-spreadsheet = Spreadsheet +approval-param-start = Start +approval-param-state = State +approval-param-subject = Subject +approval-param-team = Team +approval-param-title = Title +approval-param-to = To +approval-param-timezone = timezone +approval-param-url = URL +approval-param-user = User + +approval-question-github-add-comment-to-issue = Allow {$connector_name} to add a comment to a pull request? +approval-question-github-add-reaction-to-issue-comment = Allow {$connector_name} to add a reaction to an issue comment? +approval-question-github-add-reaction-to-pr = Allow {$connector_name} to add a reaction to a pull request? +approval-question-github-add-reaction-to-pr-review-comment = Allow {$connector_name} to add a reaction to a pull request review comment? +approval-question-github-add-review-to-pr = Allow {$connector_name} to submit a pull request review? +approval-question-github-create-blob = Allow {$connector_name} to create a Git blob? +approval-question-github-create-branch = Allow {$connector_name} to create a branch? +approval-question-github-create-commit = Allow {$connector_name} to create a commit? +approval-question-github-create-pull-request = Allow {$connector_name} to create a pull request? +approval-question-github-create-tree = Allow {$connector_name} to create a Git tree? +approval-question-github-enable-auto-merge = Allow {$connector_name} to enable pull request auto-merge? +approval-question-github-label-pr = Allow {$connector_name} to add a label to a pull request? +approval-question-github-remove-reaction-from-issue-comment = Allow {$connector_name} to remove a reaction from an issue comment? +approval-question-github-remove-reaction-from-pr = Allow {$connector_name} to remove a reaction from a pull request? +approval-question-github-remove-reaction-from-pr-review-comment = Allow {$connector_name} to remove a reaction from a pull request review comment? +approval-question-github-reply-to-review-comment = Allow {$connector_name} to reply to a pull request review comment? +approval-question-github-update-issue-comment = Allow {$connector_name} to update an issue comment? +approval-question-github-update-ref = Allow {$connector_name} to update a branch reference? +approval-question-github-update-review-comment = Allow {$connector_name} to update a pull request review comment? +approval-question-gmail-apply-labels-to-emails = Allow {$connector_name} to apply label changes to messages? +approval-question-gmail-batch-modify-email = Allow {$connector_name} to update message labels? +approval-question-gmail-bulk-label-matching-emails = Allow {$connector_name} to label matching messages? +approval-question-gmail-create-draft = Allow {$connector_name} to create an email draft? +approval-question-gmail-create-label = Allow {$connector_name} to create a label? +approval-question-gmail-send-email = Allow {$connector_name} to send an email? +approval-question-google-calendar-create-event = Allow {$connector_name} to create an event? +approval-question-google-calendar-delete-event = Allow {$connector_name} to delete an event? +approval-question-google-calendar-respond-event = Allow {$connector_name} to respond to an event? +approval-question-google-calendar-update-event = Allow {$connector_name} to update an event? +approval-question-google-docs-batch-update = Allow {$connector_name} to apply document updates? +approval-question-google-docs-create-document = Allow {$connector_name} to create a document? +approval-question-google-drive-copy-document = Allow {$connector_name} to copy a file? +approval-question-google-drive-share-document = Allow {$connector_name} to change file sharing? +approval-question-google-sheets-batch-update = Allow {$connector_name} to apply spreadsheet updates? +approval-question-google-sheets-create-spreadsheet = Allow {$connector_name} to create a spreadsheet? +approval-question-google-sheets-duplicate-sheet-in-new-file = Allow {$connector_name} to copy a sheet into a new spreadsheet? +approval-question-google-slides-batch-update = Allow {$connector_name} to apply presentation updates? +approval-question-google-slides-create-presentation = Allow {$connector_name} to create a presentation? +approval-question-linear-add-comment-to-issue = Allow {$connector_name} to add a comment to an issue? +approval-question-linear-add-label-to-issue = Allow {$connector_name} to add a label to an issue? +approval-question-linear-add-url-attachment-to-issue = Allow {$connector_name} to attach a link to an issue? +approval-question-linear-assign-issue = Allow {$connector_name} to assign an issue? +approval-question-linear-create-issue = Allow {$connector_name} to create an issue? +approval-question-linear-create-label = Allow {$connector_name} to create a label? +approval-question-linear-create-project = Allow {$connector_name} to create a project? +approval-question-linear-remove-label-from-issue = Allow {$connector_name} to remove a label from an issue? +approval-question-linear-resolve-comment = Allow {$connector_name} to resolve a comment? +approval-question-linear-set-issue-state = Allow {$connector_name} to change issue state? +approval-question-linear-unassign-issue = Allow {$connector_name} to unassign an issue? +approval-question-linear-update-issue = Allow {$connector_name} to update an issue? +approval-question-linear-update-project = Allow {$connector_name} to update a project? +approval-question-slack-slack-create-canvas = Allow {$connector_name} to create a canvas? +approval-question-slack-slack-schedule-message = Allow {$connector_name} to schedule a message? +approval-question-slack-slack-send-message = Allow {$connector_name} to send a message? +approval-question-slack-slack-send-message-draft = Allow {$connector_name} to create a message draft? diff --git a/codex-rs/i18n/src/locales/zh-CN/approval.ftl b/codex-rs/i18n/src/locales/zh-CN/approval.ftl new file mode 100644 index 0000000000..5497a28559 --- /dev/null +++ b/codex-rs/i18n/src/locales/zh-CN/approval.ftl @@ -0,0 +1,98 @@ +approval-param-action = 操作 +approval-param-attendees = 参与者 +approval-param-base-branch = 目标分支 +approval-param-body = 正文 +approval-param-branch = 分支 +approval-param-calendar = 日历 +approval-param-changes = 更改 +approval-param-comment = 评论 +approval-param-comment-id = 评论 ID +approval-param-commit = 提交 +approval-param-content = 内容 +approval-param-conversation = 对话 +approval-param-document = 文档 +approval-param-event = 事件 +approval-param-head-branch = 源分支 +approval-param-issue = Issue +approval-param-label = 标签 +approval-param-label-name = 标签名称 +approval-param-message = 消息 +approval-param-name = 名称 +approval-param-new-file-name = 新文件名 +approval-param-permission = 权限 +approval-param-presentation = 演示文稿 +approval-param-project = 项目 +approval-param-pull-request = 拉取请求 +approval-param-query = 查询 +approval-param-reaction = 回应表情 +approval-param-repository = 仓库 +approval-param-response-status = 回复状态 +approval-param-review = 审查 +approval-param-send-at = 发送时间 +approval-param-source-sheet-name = 源工作表名称 +approval-param-spreadsheet = 电子表格 +approval-param-start = 开始时间 +approval-param-state = 状态 +approval-param-subject = 主题 +approval-param-team = 团队 +approval-param-title = 标题 +approval-param-to = 收件人 +approval-param-timezone = 时区 +approval-param-url = 链接 +approval-param-user = 用户 + +approval-question-github-add-comment-to-issue = 允许 {$connector_name} 为拉取请求添加评论吗? +approval-question-github-add-reaction-to-issue-comment = 允许 {$connector_name} 为 issue 评论添加回应表情吗? +approval-question-github-add-reaction-to-pr = 允许 {$connector_name} 为拉取请求添加回应表情吗? +approval-question-github-add-reaction-to-pr-review-comment = 允许 {$connector_name} 为拉取请求审查评论添加回应表情吗? +approval-question-github-add-review-to-pr = 允许 {$connector_name} 提交拉取请求审查吗? +approval-question-github-create-blob = 允许 {$connector_name} 创建 Git blob 吗? +approval-question-github-create-branch = 允许 {$connector_name} 创建分支吗? +approval-question-github-create-commit = 允许 {$connector_name} 创建提交吗? +approval-question-github-create-pull-request = 允许 {$connector_name} 创建拉取请求吗? +approval-question-github-create-tree = 允许 {$connector_name} 创建 Git tree 吗? +approval-question-github-enable-auto-merge = 允许 {$connector_name} 启用拉取请求自动合并吗? +approval-question-github-label-pr = 允许 {$connector_name} 为拉取请求添加标签吗? +approval-question-github-remove-reaction-from-issue-comment = 允许 {$connector_name} 从 issue 评论中移除回应表情吗? +approval-question-github-remove-reaction-from-pr = 允许 {$connector_name} 从拉取请求中移除回应表情吗? +approval-question-github-remove-reaction-from-pr-review-comment = 允许 {$connector_name} 从拉取请求审查评论中移除回应表情吗? +approval-question-github-reply-to-review-comment = 允许 {$connector_name} 回复拉取请求审查评论吗? +approval-question-github-update-issue-comment = 允许 {$connector_name} 更新 issue 评论吗? +approval-question-github-update-ref = 允许 {$connector_name} 更新分支引用吗? +approval-question-github-update-review-comment = 允许 {$connector_name} 更新拉取请求审查评论吗? +approval-question-gmail-apply-labels-to-emails = 允许 {$connector_name} 对邮件应用标签更改吗? +approval-question-gmail-batch-modify-email = 允许 {$connector_name} 更新邮件标签吗? +approval-question-gmail-bulk-label-matching-emails = 允许 {$connector_name} 为匹配邮件添加标签吗? +approval-question-gmail-create-draft = 允许 {$connector_name} 创建邮件草稿吗? +approval-question-gmail-create-label = 允许 {$connector_name} 创建标签吗? +approval-question-gmail-send-email = 允许 {$connector_name} 发送邮件吗? +approval-question-google-calendar-create-event = 允许 {$connector_name} 创建事件吗? +approval-question-google-calendar-delete-event = 允许 {$connector_name} 删除事件吗? +approval-question-google-calendar-respond-event = 允许 {$connector_name} 回复事件吗? +approval-question-google-calendar-update-event = 允许 {$connector_name} 更新事件吗? +approval-question-google-docs-batch-update = 允许 {$connector_name} 应用文档更新吗? +approval-question-google-docs-create-document = 允许 {$connector_name} 创建文档吗? +approval-question-google-drive-copy-document = 允许 {$connector_name} 复制文件吗? +approval-question-google-drive-share-document = 允许 {$connector_name} 更改文件共享设置吗? +approval-question-google-sheets-batch-update = 允许 {$connector_name} 应用电子表格更新吗? +approval-question-google-sheets-create-spreadsheet = 允许 {$connector_name} 创建电子表格吗? +approval-question-google-sheets-duplicate-sheet-in-new-file = 允许 {$connector_name} 将工作表复制到新的电子表格吗? +approval-question-google-slides-batch-update = 允许 {$connector_name} 应用演示文稿更新吗? +approval-question-google-slides-create-presentation = 允许 {$connector_name} 创建演示文稿吗? +approval-question-linear-add-comment-to-issue = 允许 {$connector_name} 为 issue 添加评论吗? +approval-question-linear-add-label-to-issue = 允许 {$connector_name} 为 issue 添加标签吗? +approval-question-linear-add-url-attachment-to-issue = 允许 {$connector_name} 为 issue 附加链接吗? +approval-question-linear-assign-issue = 允许 {$connector_name} 分配 issue 吗? +approval-question-linear-create-issue = 允许 {$connector_name} 创建 issue 吗? +approval-question-linear-create-label = 允许 {$connector_name} 创建标签吗? +approval-question-linear-create-project = 允许 {$connector_name} 创建项目吗? +approval-question-linear-remove-label-from-issue = 允许 {$connector_name} 从 issue 中移除标签吗? +approval-question-linear-resolve-comment = 允许 {$connector_name} 解决评论吗? +approval-question-linear-set-issue-state = 允许 {$connector_name} 更改 issue 状态吗? +approval-question-linear-unassign-issue = 允许 {$connector_name} 取消分配 issue 吗? +approval-question-linear-update-issue = 允许 {$connector_name} 更新 issue 吗? +approval-question-linear-update-project = 允许 {$connector_name} 更新项目吗? +approval-question-slack-slack-create-canvas = 允许 {$connector_name} 创建画布吗? +approval-question-slack-slack-schedule-message = 允许 {$connector_name} 安排发送消息吗? +approval-question-slack-slack-send-message = 允许 {$connector_name} 发送消息吗? +approval-question-slack-slack-send-message-draft = 允许 {$connector_name} 创建消息草稿吗? diff --git a/docs/config.md b/docs/config.md index d03fb98434..73808fa870 100644 --- a/docs/config.md +++ b/docs/config.md @@ -26,6 +26,17 @@ Codex can run a notification hook when the agent finishes a turn. See the config When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`. +## Locale + +Set top-level `locale` in `config.toml` to choose the preferred locale for +localized user-facing strings. For example: + +```toml +locale = "zh-CN" +``` + +When unset, Codex falls back to the system locale and then to `en-US`. + ## JSON Schema The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.