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:
Joe Savona
2026-11-13 00:28:10 +00:00
committed by copyberry
parent f562a7b3e2
commit 8cdabf23ae
21 changed files with 490 additions and 43 deletions
+1
View File
@@ -696,6 +696,7 @@ impl App {
&current_cwd,
config_cwd,
(&self.config, &self.local_settings),
&self.app_server_target,
)
.await
{
+27 -3
View File
@@ -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"
&current_cwd,
current_cwd.to_path_buf(),
(&app.config, &app.local_settings),
&app.app_server_target,
)
.await?;
@@ -2119,6 +2142,7 @@ theme = "dracula"
&current_cwd,
next_cwd,
(&app.config, &app.local_settings),
&app.app_server_target,
)
.await;
+6 -1
View File
@@ -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(),
+9 -1
View File
@@ -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,
(&current_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| {
+56 -12
View File
@@ -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;
+1
View File
@@ -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(),
+144 -1
View File
@@ -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 [