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 [