mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -136,35 +136,39 @@ pub fn config_error_from_typed_toml<T: DeserializeOwned>(
|
||||
config_error_from_typed_toml_for_source::<T>(
|
||||
ConfigDiagnosticSource::Path(path.as_ref()),
|
||||
contents,
|
||||
/*layer*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
fn config_error_from_typed_toml_for_source<T: DeserializeOwned>(
|
||||
source: ConfigDiagnosticSource<'_>,
|
||||
contents: &str,
|
||||
layer: Option<&ConfigLayerSource>,
|
||||
) -> Option<ConfigError> {
|
||||
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<T, _> = 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<T: DeserializeOwned>(
|
||||
@@ -211,6 +215,7 @@ where
|
||||
if let Some(error) = config_error_from_typed_toml_for_source::<T>(
|
||||
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::<T>(&path, &contents) {
|
||||
if let Some(error) = config_error_from_typed_toml_for_source::<T>(
|
||||
ConfigDiagnosticSource::Path(&path),
|
||||
&contents,
|
||||
Some(&layer.name),
|
||||
) {
|
||||
return Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::<crate::ConfigLoadError>())
|
||||
.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");
|
||||
|
||||
@@ -79,6 +79,11 @@ pub fn is_structured_feature_path<S: AsRef<str>>(path: &[S]) -> bool {
|
||||
}
|
||||
|
||||
fn merge_toml_values_at_path(base: &mut TomlValue, overlay: TomlValue, path: &mut Vec<String>) {
|
||||
// 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) {
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
|
||||
/// Local status line selection. Set to "default" for built-in items or specify an MCP server and tool.
|
||||
pub status_line_provider: Option<Box<StatusLineSelection>>,
|
||||
|
||||
/// Color status line items with colors derived from the active syntax theme.
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
|
||||
@@ -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`.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
|
||||
/// Local choice of built-in status items or an MCP provider.
|
||||
pub tui_status_line_provider: Option<Box<StatusLineSelection>>,
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -696,6 +696,7 @@ impl App {
|
||||
¤t_cwd,
|
||||
config_cwd,
|
||||
(&self.config, &self.local_settings),
|
||||
&self.app_server_target,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -96,19 +96,33 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
|
||||
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<Output = Result<Config>> + 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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::<codex_config::ConfigLoadError>())
|
||||
.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 [
|
||||
|
||||
Reference in New Issue
Block a user