mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +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:
@@ -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