From 8cdabf23aecf8fbd668e513e2e6258b93c98bd63 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Thu, 12 Nov 2026 23:34:22 +0000 Subject: [PATCH] Add local status line provider configuration (#61211) ## What changed Add `tui.status_line_provider` to select `"default"` or an MCP provider identified by `server` and `tool`. Replace provider identities as a unit across configuration layers so partial overrides cannot inherit half an identity. Ignore this setting from project configuration and packaged defaults when computing effective values and origins, while retaining strict validation. Preserve relative path resolution when an ignored provider value is malformed. Carry the selection into TUI local settings and refresh it on configuration reload. Use the client's configuration home when rebuilding settings for remote sessions. ## Testing Add coverage for provider precedence, incomplete profile overrides, malformed values and diagnostics, relative path resolution, and local preference preservation when resuming remote sessions. GitOrigin-RevId: 0777d754d46cf009569ff0c90fd7426f8b7650bf --- codex-rs/config/src/config_layer_source.rs | 35 +++++ codex-rs/config/src/diagnostics.rs | 43 ++++-- codex-rs/config/src/loader/mod.rs | 6 +- codex-rs/config/src/loader/tests.rs | 61 ++++++++ codex-rs/config/src/merge.rs | 5 + codex-rs/config/src/state.rs | 15 +- codex-rs/config/src/state_tests.rs | 28 ++++ codex-rs/config/src/types.rs | 25 +++ codex-rs/core/config.schema.json | 42 +++++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 5 + codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/tui/src/app/agents_overview.rs | 1 + codex-rs/tui/src/app/config_persistence.rs | 30 +++- codex-rs/tui/src/app/new_session.rs | 7 +- codex-rs/tui/src/app/resume_config.rs | 10 +- codex-rs/tui/src/app/tests.rs | 68 ++++++-- .../app/tests/session_lifecycle_requests.rs | 2 +- .../tests/terminal_size_telemetry_tests.rs | 1 + codex-rs/tui/src/local_settings.rs | 1 + codex-rs/tui/src/local_settings_tests.rs | 145 +++++++++++++++++- 21 files changed, 490 insertions(+), 43 deletions(-) diff --git a/codex-rs/config/src/config_layer_source.rs b/codex-rs/config/src/config_layer_source.rs index c044ff5593..7041efe178 100644 --- a/codex-rs/config/src/config_layer_source.rs +++ b/codex-rs/config/src/config_layer_source.rs @@ -1,5 +1,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; +use std::borrow::Cow; +use toml::Value as TomlValue; /// Provenance for one layer in the effective Codex configuration. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,7 +29,40 @@ pub enum ConfigLayerSource { LegacyManagedConfigTomlFromMdm, } +pub(crate) fn without_status_line_provider(config: &TomlValue) -> Cow<'_, TomlValue> { + let mut values = Cow::Borrowed(config); + if config + .get("tui") + .and_then(|tui| tui.get("status_line_provider")) + .is_some() + && let Some(table) = values.to_mut().as_table_mut() + && let Some(tui) = table.get_mut("tui").and_then(TomlValue::as_table_mut) + { + tui.remove("status_line_provider"); + if tui.is_empty() { + table.remove("tui"); + } + } + values +} + impl ConfigLayerSource { + /// Project settings and packaged defaults cannot select a status line provider. + pub(crate) fn effective_values<'a>(&self, config: &'a TomlValue) -> Cow<'a, TomlValue> { + match self { + Self::PackagedDefaults { .. } | Self::Project { .. } => { + without_status_line_provider(config) + } + Self::User { .. } + | Self::SessionFlags + | Self::System { .. } + | Self::EnterpriseManaged { .. } + | Self::Mdm { .. } + | Self::LegacyManagedConfigTomlFromFile { .. } + | Self::LegacyManagedConfigTomlFromMdm => Cow::Borrowed(config), + } + } + /// A setting from a layer with a higher precedence overrides a setting /// from a layer with a lower precedence. pub fn precedence(&self) -> i16 { diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 0036932887..b6a3c516a1 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -136,35 +136,39 @@ pub fn config_error_from_typed_toml( config_error_from_typed_toml_for_source::( ConfigDiagnosticSource::Path(path.as_ref()), contents, + /*layer*/ None, ) } fn config_error_from_typed_toml_for_source( source: ConfigDiagnosticSource<'_>, contents: &str, + layer: Option<&ConfigLayerSource>, ) -> Option { let deserializer = match toml::de::Deserializer::parse(contents) { Ok(deserializer) => deserializer, Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)), }; - let result: Result = serde_path_to_error::deserialize(deserializer); - match result { - Ok(_) => None, - Err(err) => { - let path_hint = err.path().clone(); - let toml_err: toml::de::Error = err.into_inner(); - let range = span_for_config_path(contents, &path_hint) - .or_else(|| toml_err.span()) - .map(|span| text_range_from_span(contents, span)) - .unwrap_or_else(default_range); - Some(ConfigError::new( - source.to_path_buf(), - range, - toml_err.message(), - )) - } + let mut err = serde_path_to_error::deserialize::<_, T>(deserializer).err()?; + // Diagnose the same effective fields against the text we just read from disk. + if let Some(layer) = layer + && let Ok(raw) = toml::from_str(contents) + && let std::borrow::Cow::Owned(effective) = layer.effective_values(&raw) + { + err = serde_path_to_error::deserialize::<_, T>(effective).err()?; } + let path_hint = err.path().clone(); + let toml_err: toml::de::Error = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + toml_err.message(), + )) } pub async fn first_layer_config_error( @@ -211,6 +215,7 @@ where if let Some(error) = config_error_from_typed_toml_for_source::( ConfigDiagnosticSource::DisplayName(&source_name), contents, + Some(&layer.name), ) { return Some(error); } @@ -234,7 +239,11 @@ where continue; }; let _guard = AbsolutePathBufGuard::new(parent); - if let Some(error) = config_error_from_typed_toml::(&path, &contents) { + if let Some(error) = config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::Path(&path), + &contents, + Some(&layer.name), + ) { return Some(error); } } diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index bdc19e6cbe..fcf5f95e99 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1653,7 +1653,11 @@ pub fn resolve_relative_paths_in_config_toml( } // The typed round trip resolves AbsolutePathBuf fields against this layer's directory. let _guard = AbsolutePathBufGuard::new(base_dir); - let Ok(resolved) = ConfigToml::try_from(value_from_config_toml.clone()) else { + // Provider identities contain no paths. An ignored malformed identity must not + // prevent an unrelated path in this layer from being resolved against its directory. + let path_input = + crate::config_layer_source::without_status_line_provider(&value_from_config_toml); + let Ok(resolved) = ConfigToml::try_from(path_input.into_owned()) else { return Ok(value_from_config_toml); }; drop(_guard); diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index b6cf45848f..57b057733a 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -564,6 +564,67 @@ impl ExecutorFileSystem for TestFileSystem { } } +#[tokio::test] +async fn project_diagnostic_uses_the_current_file_and_ignores_its_provider() -> anyhow::Result<()> { + let dir = tempdir()?; + let path = dir.path().join(CONFIG_TOML_FILE); + let layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path(dir.path())?, + }, + TomlValue::Table(Default::default()), + ); + std::fs::write( + &path, + "[tui]\nstatus_line_provider = 42\nvim_mode_default = 43\n", + )?; + let error = first_layer_config_error_from_entries(&[layer]) + .await + .expect("invalid vim setting"); + assert_eq!((error.path, error.range.start.line), (path, 3)); + assert!(error.message.contains("43"), "{}", error.message); + Ok(()) +} + +#[tokio::test] +async fn packaged_status_line_provider_is_ignored_but_strictly_validated() -> anyhow::Result<()> { + let dir = tempdir()?; + let path = AbsolutePathBuf::resolve_path_against_base("defaults.toml", dir.path()); + std::fs::write(&path, "[tui]\nstatus_line_provider = 42\n")?; + let load = |strict_config| { + load_config_layers_state( + &TestFileSystem, + dir.path(), + /*cwd*/ None, + &[], + ConfigLoadOptions { + loader_overrides: LoaderOverrides { + packaged_defaults_path: Some(path.clone()), + ..LoaderOverrides::without_managed_config_for_tests() + }, + strict_config, + ..Default::default() + }, + &crate::NoopThreadConfigLoader, + ) + }; + let stack = load(/*strict_config*/ false).await?; + assert_eq!(stack.effective_config().get("tui"), None); + let error = load(/*strict_config*/ true) + .await + .expect_err("malformed packaged provider"); + let diagnostic = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("packaged config diagnostic") + .config_error(); + assert_eq!( + (diagnostic.path.as_path(), diagnostic.range.start.line), + (path.as_path(), 2) + ); + Ok(()) +} + #[tokio::test] async fn packaged_defaults_have_lower_precedence_than_existing_config_layers() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs index 70855f98f2..187e99e4cc 100644 --- a/codex-rs/config/src/merge.rs +++ b/codex-rs/config/src/merge.rs @@ -79,6 +79,11 @@ pub fn is_structured_feature_path>(path: &[S]) -> bool { } fn merge_toml_values_at_path(base: &mut TomlValue, overlay: TomlValue, path: &mut Vec) { + // A server and tool identify one provider. A partial override cannot inherit half an identity. + if matches!(path.as_slice(), [tui, key] if tui == "tui" && key == "status_line_provider") { + *base = overlay; + return; + } replace_shell_environment_policy_filter_representation(base, &overlay, path); if is_structured_feature_path(path) { diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 84cc70d556..f53328a711 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -668,7 +668,7 @@ impl ConfigLayerStack { pub fn effective_config(&self) -> TomlValue { let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.layers_low_to_high() { - merge_toml_values(&mut merged, &layer.config); + merge_toml_values(&mut merged, &layer.name.effective_values(&layer.config)); } if let Some(requirements) = &self.model_provider_requirements { crate::model_provider_requirements::apply(&mut merged, requirements); @@ -699,7 +699,7 @@ impl ConfigLayerStack { let mut provider_paths = vec!["features.network_proxy.credentials.".to_string()]; for layer in self.layers_low_to_high() { - let config = normalize_key_aliases(&layer.config); + let config = normalize_key_aliases(&layer.name.effective_values(&layer.config)); if let Some(profiles) = config.get("profiles").and_then(TomlValue::as_table) { provider_paths.extend( profiles @@ -716,9 +716,12 @@ impl ConfigLayerStack { ); } - let is_credential_origin = - |path: &str| provider_paths.iter().any(|prefix| path.starts_with(prefix)); - if origins.keys().any(|path| is_credential_origin(path)) + let needs_effective_origin = |path: &str| { + path == "tui.status_line_provider" + || path.starts_with("tui.status_line_provider.") + || provider_paths.iter().any(|prefix| path.starts_with(prefix)) + }; + if origins.keys().any(|path| needs_effective_origin(path)) && let Some(layer) = self.layers_low_to_high().next_back() { let effective = self.effective_config(); @@ -731,7 +734,7 @@ impl ConfigLayerStack { &include, ); origins.retain(|path, _| { - !is_credential_origin(path) || effective_origins.contains_key(path) + !needs_effective_origin(path) || effective_origins.contains_key(path) }); } diff --git a/codex-rs/config/src/state_tests.rs b/codex-rs/config/src/state_tests.rs index 8dad010f4a..297e3015b3 100644 --- a/codex-rs/config/src/state_tests.rs +++ b/codex-rs/config/src/state_tests.rs @@ -288,6 +288,34 @@ approval_policy = "on-request" ); } +#[test] +fn a_project_setting_only_the_provider_preserves_the_user_tui_value() -> anyhow::Result<()> { + let dir = TempDir::new()?; + let project = ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: test_user_config_path(&dir, ".codex"), + }, + toml::toml! { [tui] status_line_provider = "default" }.into(), + ); + for contents in ["", "tui = 42"] { + let user_config: TomlValue = toml::from_str(contents)?; + let user = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_user_config_path(&dir, "config.toml"), + profile: None, + }, + user_config.clone(), + ); + let stack = ConfigLayerStack::new( + vec![user, project.clone()], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + assert_eq!(stack.effective_config(), user_config); + } + Ok(()) +} + #[test] fn layer_iterators_preserve_precedence_and_disabled_layers() { let temp_dir = TempDir::new().expect("tempdir"); diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index e23e9764f8..fc1729ac08 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -880,6 +880,28 @@ impl QuickDial { } } +/// The configured MCP server and advertised tool name identify a status line provider. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct StatusLineProviderId { + pub server: String, + pub tool: String, +} + +/// An explicit default overrides an MCP provider from a lower configuration layer. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum StatusLineSelection { + Builtin(BuiltinStatusLine), + Mcp(StatusLineProviderId), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum BuiltinStatusLine { + Default, +} + /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -997,6 +1019,9 @@ pub struct Tui { #[serde(default)] pub status_line: Option>, + /// Local status line selection. Set to "default" for built-in items or specify an MCP server and tool. + pub status_line_provider: Option>, + /// Color status line items with colors derived from the active syntax theme. /// Defaults to `true`. #[serde(default = "default_true")] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 37db5fe3de..c217537692 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -609,6 +609,12 @@ }, "type": "object" }, + "BuiltinStatusLine": { + "enum": [ + "default" + ], + "type": "string" + }, "BundledSkillsConfig": { "additionalProperties": false, "properties": { @@ -5005,6 +5011,34 @@ } ] }, + "StatusLineProviderId": { + "additionalProperties": false, + "description": "The configured MCP server and advertised tool name identify a status line provider.", + "properties": { + "server": { + "type": "string" + }, + "tool": { + "type": "string" + } + }, + "required": [ + "server", + "tool" + ], + "type": "object" + }, + "StatusLineSelection": { + "anyOf": [ + { + "$ref": "#/definitions/BuiltinStatusLine" + }, + { + "$ref": "#/definitions/StatusLineProviderId" + } + ], + "description": "An explicit default overrides an MCP provider from a lower configuration layer." + }, "ThreadStoreToml": { "oneOf": [ { @@ -5674,6 +5708,14 @@ }, "type": "array" }, + "status_line_provider": { + "allOf": [ + { + "$ref": "#/definitions/StatusLineSelection" + } + ], + "description": "Local status line selection. Set to \"default\" for built-in items or specify an MCP server and tool." + }, "status_line_use_colors": { "default": true, "description": "Color status line items with colors derived from the active syntax theme. Defaults to `true`.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2d41d3fcb4..21b8331730 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1412,6 +1412,7 @@ fn config_toml_deserializes_model_availability_nux() { right_click_paste: Default::default(), alternate_screen: AltScreenMode::default(), status_line: None, + status_line_provider: None, status_line_use_colors: true, terminal_title: None, theme: None, @@ -4781,6 +4782,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { right_click_paste: Default::default(), alternate_screen: AltScreenMode::Auto, status_line: None, + status_line_provider: None, status_line_use_colors: true, terminal_title: None, theme: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f958572c8a..8e11d8f7b6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -54,6 +54,7 @@ use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::QuickDial; use codex_config::types::ResumeCwdMode; use codex_config::types::SessionPickerViewMode; +use codex_config::types::StatusLineSelection; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; @@ -878,6 +879,9 @@ pub struct Config { /// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`. pub tui_status_line: Option>, + /// Local choice of built-in status items or an MCP provider. + pub tui_status_line_provider: Option>, + /// Whether to color status line items with colors from the active syntax theme. pub tui_status_line_use_colors: bool, @@ -4857,6 +4861,7 @@ impl Config { .map(|t| t.alternate_screen) .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), + tui_status_line_provider: cfg.tui.as_ref().and_then(|t| t.status_line_provider.clone()), tui_status_line_use_colors: cfg .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 80bb021eae..e17bb9a44e 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -310,6 +310,7 @@ async fn new_config( tui_right_click_paste: Default::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_status_line_provider: None, tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 1081c053fe..7f0410ed82 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -696,6 +696,7 @@ impl App { ¤t_cwd, config_cwd, (&self.config, &self.local_settings), + &self.app_server_target, ) .await { diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index cb28791ff1..220026e4d4 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -96,19 +96,33 @@ impl App { } pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { - self.rebuild_config_for_cwd_from(&self.config, cwd).await + self.rebuild_config_for_cwd_from( + &self.config, + &self.local_settings, + &self.app_server_target, + cwd, + ) + .await } pub(super) fn rebuild_config_for_cwd_from( &self, base: &Config, + local: &crate::local_settings::LocalSettings, + target: &AppServerTarget, cwd: PathBuf, ) -> impl std::future::Future> + use<> { let mut overrides = self.harness_overrides.clone(); overrides.cwd = Some(cwd.clone()); let cwd_display = cwd.display().to_string(); + // A remote app server's execution config can name a home on another machine. + let codex_home = if target.uses_remote_workspace() { + &local.codex_home + } else { + &base.codex_home + }; let builder = ConfigBuilder::default() - .codex_home(base.codex_home.to_path_buf()) + .codex_home(codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) .loader_overrides(self.loader_overrides.clone()) @@ -494,6 +508,8 @@ impl App { self.chat_widget.local_settings.tui.session_picker_view = self.local_settings.tui.session_picker_view; self.chat_widget.local_settings.tui.resume_cwd = self.local_settings.tui.resume_cwd; + self.chat_widget.local_settings.tui.status_line_provider = + self.local_settings.tui.status_line_provider.clone(); self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) @@ -536,9 +552,10 @@ impl App { current_cwd: &Path, resume_cwd: PathBuf, fallback: (&Config, &crate::local_settings::LocalSettings), + target: &AppServerTarget, ) -> Result<(Config, crate::local_settings::LocalSettings)> { match self - .rebuild_config_for_cwd_from(fallback.0, resume_cwd.clone()) + .rebuild_config_for_cwd_from(fallback.0, fallback.1, target, resume_cwd.clone()) .await { Ok(config) => { @@ -1994,6 +2011,10 @@ enabled = false expected_widget_settings.tui.session_picker_view = Some(codex_config::types::SessionPickerViewMode::Comfortable); expected_widget_settings.tui.resume_cwd = Some(codex_config::types::ResumeCwdMode::Current); + expected_widget_settings.tui.status_line_provider = + Some(Box::new(codex_config::types::StatusLineSelection::Builtin( + codex_config::types::BuiltinStatusLine::Default, + ))); let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); std::fs::write( @@ -2003,6 +2024,7 @@ enabled = false terminal_resize_reflow_max_rows = 9000 session_picker_view = "comfortable" resume_cwd = "current" +status_line_provider = "default" theme = "dracula" "#, )?; @@ -2097,6 +2119,7 @@ theme = "dracula" ¤t_cwd, current_cwd.to_path_buf(), (&app.config, &app.local_settings), + &app.app_server_target, ) .await?; @@ -2119,6 +2142,7 @@ theme = "dracula" ¤t_cwd, next_cwd, (&app.config, &app.local_settings), + &app.app_server_target, ) .await; diff --git a/codex-rs/tui/src/app/new_session.rs b/codex-rs/tui/src/app/new_session.rs index e567c09970..a0721815fd 100644 --- a/codex-rs/tui/src/app/new_session.rs +++ b/codex-rs/tui/src/app/new_session.rs @@ -85,7 +85,12 @@ impl App { app_server.remote_cwd_override().unwrap_or(Path::new(".")) } }; - let refresh = self.rebuild_config_for_cwd_from(&settings.config, cwd.clone()); + let refresh = self.rebuild_config_for_cwd_from( + &settings.config, + &settings.local_settings, + &settings.target, + cwd.clone(), + ); let load = async { let defaults = crate::config_update::read_effective_config_if_supported( app_server.request_handle(), diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index ccbf24ba08..28e68a0b03 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -54,6 +54,8 @@ impl App { let mut config = self .rebuild_config_for_cwd_from( &settings.config, + &settings.local_settings, + &settings.target, settings.config.cwd.to_path_buf(), ) .await?; @@ -161,6 +163,7 @@ impl App { &config_current_cwd, config_resume_cwd, (¤t_config, &local_settings), + &target, ) .await { @@ -295,7 +298,12 @@ impl App { *config = StartupDraftPump::run_with_optional_draft( startup_draft.as_deref_mut(), tui, - self.rebuild_config_for_cwd_from(config, config.cwd.to_path_buf()), + self.rebuild_config_for_cwd_from( + config, + local_settings, + target, + config.cwd.to_path_buf(), + ), ) .await .map_err(|error| { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 02a50518ca..e0c965f7d7 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -11152,6 +11152,10 @@ async fn in_app_resume_session_cwd_without_metadata_is_non_fatal() -> Result<()> #[tokio::test] async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> { + use codex_config::types::BuiltinStatusLine; + use codex_config::types::StatusLineProviderId; + use codex_config::types::StatusLineSelection; + // Keep the large setup and resume futures off the Windows test stack. let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await; let local_cwd = app.config.cwd.to_path_buf(); @@ -11206,18 +11210,15 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> RuntimePermissionProfileOverride::from_restored_config(&prior_config), ); - let control = Box::pin(app.resume_target_session( - &mut tui, - &mut app_server, - crate::resume_picker::SessionTarget { - backend_id, - path: Some(rollout_path), - thread_id: ThreadId::from_string(&thread_id)?, - cwd: None, - history_mode: None, - }, - )) - .await?; + let target = crate::resume_picker::SessionTarget { + backend_id, + path: Some(rollout_path), + thread_id: ThreadId::from_string(&thread_id)?, + cwd: None, + history_mode: None, + }; + let control = + Box::pin(app.resume_target_session(&mut tui, &mut app_server, target.clone())).await?; assert!(matches!(control, AppRunControl::Continue)); assert_eq!(app.harness_overrides.cwd, Some(remote_cwd)); @@ -11228,6 +11229,49 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> assert_eq!(app.config.workspace_roots, local_workspace_roots); assert_eq!(app.runtime_approval_policy_override, None); assert!(app.runtime_permission_profile_override.is_none()); + + // Preparing this stored remote from an embedded conversation still uses its client preferences. + let mut stored = app.backend_settings(); + let remote_home = tempdir()?; + let profile = stored.local_settings.codex_home.join("work.config.toml"); + let managed = stored.local_settings.codex_home.join("managed.toml"); + std::fs::write( + &profile, + "[tui]\nstatus_line_provider = { server = 'work', tool = 'usage' }\n", + )?; + app.loader_overrides = LoaderOverrides { + user_config_path: Some(profile), + user_config_profile: Some("work".parse()?), + ..LoaderOverrides::with_managed_config_path_for_tests(managed.to_path_buf()) + }; + stored.config.codex_home = remote_home.path().to_path_buf().abs(); + app.app_server_target = crate::AppServerTarget::Embedded; + let (_, local) = Box::pin(app.resume_config_for_target( + &mut tui, + &mut app_server, + &target, + super::resume_preparation::ResumeSource::Backend(&stored), + )) + .await + .expect("prepare stored remote"); + assert_eq!(local.codex_home, stored.local_settings.codex_home); + assert_eq!( + local.tui.status_line_provider.as_deref(), + Some(&StatusLineSelection::Mcp(StatusLineProviderId { + server: "work".into(), + tool: "usage".into(), + })), + ); + std::fs::write(managed, "[tui]\nstatus_line_provider = 'default'\n")?; + app.refresh_in_memory_config_from_disk().await?; + assert_eq!( + app.chat_widget + .local_settings + .tui + .status_line_provider + .as_deref(), + Some(&StatusLineSelection::Builtin(BuiltinStatusLine::Default)), + ); app_server.shutdown().await?; Ok(()) } diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index e3b8d37d50..aa54f17c81 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1879,7 +1879,7 @@ async fn cloud_resume_with_current_directory_needs_no_cwd_override() -> Result<( let (mut app, codex_home) = make_history_test_app().await?; std::fs::write( - codex_home.path().join("config.toml"), + &app.local_settings.user_config_path, "[tui]\nresume_cwd = 'current'\n", )?; app.launch_cwd = codex_home.path().to_path_buf(); diff --git a/codex-rs/tui/src/app/tests/terminal_size_telemetry_tests.rs b/codex-rs/tui/src/app/tests/terminal_size_telemetry_tests.rs index 5075ba1149..dbc5ada1b2 100644 --- a/codex-rs/tui/src/app/tests/terminal_size_telemetry_tests.rs +++ b/codex-rs/tui/src/app/tests/terminal_size_telemetry_tests.rs @@ -19,6 +19,7 @@ async fn terminal_size_telemetry_respects_reloaded_exporter_and_consent() -> Res &cwd, cwd.clone(), (&app.config, &app.local_settings), + &app.app_server_target, )) .await?; app.config = config; diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 0d8477b271..07f23e5488 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -85,6 +85,7 @@ impl LocalSettings { right_click_paste: config.tui_right_click_paste, alternate_screen: config.tui_alternate_screen, status_line: config.tui_status_line.clone(), + status_line_provider: config.tui_status_line_provider.clone(), status_line_use_colors: config.tui_status_line_use_colors, terminal_title: config.tui_terminal_title.clone(), theme: config.tui_theme.clone(), diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index defcdd42de..a6177cdb72 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -2,8 +2,10 @@ use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::edit::ConfigEditsBuilder; use codex_config::LoaderOverrides; +use codex_config::types::BuiltinStatusLine; use codex_config::types::RightClickPaste; use codex_config::types::SessionPickerViewMode; +use codex_config::types::StatusLineSelection; use codex_terminal_detection::Multiplexer; use pretty_assertions::assert_eq; @@ -136,6 +138,7 @@ right_click_paste = "off" vim_mode_default = true terminal_resize_reflow_max_rows = 0 session_picker_view = "comfortable" +status_line_provider = { server = "remote.work", tool = "usage" } [tui.default_hosts.project_a] user_a = "remote.work" [tui.effects] @@ -161,7 +164,7 @@ fast_default_opt_out = true std::fs::create_dir(project.path().join(".codex"))?; std::fs::write( project.path().join(".codex/config.toml"), - "[tui]\ntheme = 'project-theme'\n[tui.default_hosts.project_a]\nuser_a = 'project-override'\nuser_b = 'project-only'\n", + "[tui]\ntheme = 'project-theme'\nstatus_line_provider = { server = 'project', tool = 'usage' }\n[tui.default_hosts.project_a]\nuser_a = 'project-override'\nuser_b = 'project-only'\n", )?; crate::legacy_core::config::set_project_trust_level( home.path(), @@ -170,6 +173,7 @@ fast_default_opt_out = true )?; let cli_overrides = vec![ ("tui.disable_paste_burst".into(), true.into()), + ("tui.status_line_provider".into(), "default".into()), ("tui.right_click_paste".into(), "on".into()), ("tui.mouse_scroll_speed".into(), 1.5.into()), // The removed flag must not override the TUI preference. @@ -206,6 +210,10 @@ fast_default_opt_out = true ); assert_eq!(config.features.legacy_feature_usages().count(), 1); assert_eq!(config.tui_theme.as_deref(), Some("project-theme")); + assert_eq!( + config.tui_status_line_provider.as_deref(), + Some(&StatusLineSelection::Builtin(BuiltinStatusLine::Default)), + ); assert!( LocalSettings::from(&config) .tui @@ -227,6 +235,141 @@ fast_default_opt_out = true Ok(()) } +#[tokio::test] +async fn malformed_status_line_provider_uses_project_and_user_validation_rules() +-> anyhow::Result<()> { + use codex_config::ConfigLayerSource; + + let home = tempfile::tempdir()?; + let project = tempfile::tempdir()?; + let project_config = project.path().join(".codex"); + std::fs::create_dir(&project_config)?; + let malformed = "[tui]\nstatus_line_provider = 42\n"; + std::fs::write( + project_config.join("instructions.md"), + "project instructions", + )?; + let project_file = project_config.join("config.toml"); + std::fs::write( + &project_file, + format!("model_instructions_file = 'instructions.md'\n{malformed}"), + )?; + let system_config = home.path().join("system.toml"); + std::fs::write( + &system_config, + "[tui]\nstatus_line_provider = { server = 'system', tool = 'usage' }\n", + )?; + let user_config = home.path().join("config.toml"); + std::fs::write(&user_config, "[tui]\nstatus_line_provider = 'default'\n")?; + crate::legacy_core::config::set_project_trust_level( + home.path(), + project.path(), + codex_protocol::config_types::TrustLevel::Trusted, + )?; + let build = |strict| { + ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .fallback_cwd(Some(project.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + system_config_path: Some(system_config.clone()), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .strict_config(strict) + .build() + }; + let config = build(/*strict*/ false).await?; + let layers = &config.config_layer_stack; + let project_layer = layers + .layers_low_to_high() + .find(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .expect("trusted project layer"); + assert_eq!( + project_layer.version, + codex_config::version_for_toml(&project_layer.config) + ); + assert_eq!( + project_layer.config["tui"]["status_line_provider"].as_integer(), + Some(42) + ); + assert_eq!( + project_layer.config["model_instructions_file"].as_str(), + project_config.join("instructions.md").to_str() + ); + assert_eq!( + layers.effective_config()["tui"]["status_line_provider"].as_str(), + Some("default") + ); + assert_eq!( + layers.origins_with_path_filter( + |path| path.starts_with(&["tui".into(), "status_line_provider".into()]) + ), + std::collections::HashMap::from([( + "tui.status_line_provider".into(), + layers + .get_active_user_layer() + .expect("user layer") + .metadata() + )]) + ); + + let assert_error_at = |error: std::io::Error, path: &std::path::Path, line| { + let diagnostic = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("config diagnostic") + .config_error(); + assert_eq!( + (diagnostic.path.as_path(), diagnostic.range.start.line), + (path, line) + ); + }; + let error = build(/*strict*/ true) + .await + .expect_err("malformed project preference"); + assert_error_at(error, &project_file, 3); + + std::fs::write(&user_config, malformed)?; + for strict in [false, true] { + assert_error_at( + build(strict).await.expect_err("malformed user preference"), + &user_config, + 2, + ); + } + Ok(()) +} + +#[tokio::test] +async fn status_line_provider_requires_a_complete_identity_in_the_selected_profile() +-> anyhow::Result<()> { + let home = tempfile::tempdir()?; + let profile = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?; + std::fs::write( + home.path().join("config.toml"), + "[tui]\nstatus_line_provider = { server = 'personal', tool = 'usage' }\n", + )?; + for partial in ["server = 'work'", "tool = 'quota'"] { + std::fs::write( + &profile, + format!("[tui]\nstatus_line_provider = {{ {partial} }}\n"), + )?; + let result = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides { + user_config_path: Some(profile.clone()), + user_config_profile: Some("work".parse()?), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await; + assert!( + result.is_err(), + "accepted an incomplete provider: {partial}" + ); + } + Ok(()) +} + #[tokio::test] async fn copy_on_select_respects_terminal_defaults_and_config_overrides() -> anyhow::Result<()> { for (configured, launch_override, expected) in [