From 25cda684b494b94eb3e440f8b97349bda7753659 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 15 May 2026 15:34:01 -0700 Subject: [PATCH] tui: persist primary settings via app server config RPC --- codex-rs/tui/src/app/event_dispatch.rs | 155 +++++++++++++++++-------- codex-rs/tui/src/config_rpc.rs | 79 +++++++++++++ codex-rs/tui/src/lib.rs | 1 + 3 files changed, 186 insertions(+), 49 deletions(-) create mode 100644 codex-rs/tui/src/config_rpc.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 811c24cc4d..f753029263 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1182,13 +1182,36 @@ impl App { } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); - match ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_model(Some(model.as_str()), effort) - .apply() - .await + let scoped_key = |key: &str| { + if let Some(profile) = profile { + format!("profiles.{profile}.{key}") + } else { + key.to_string() + } + }; + let effort_edit = effort.map_or_else( + || crate::config_rpc::clear_config_value(scoped_key("model_reasoning_effort")), + |effort| { + crate::config_rpc::replace_config_value( + scoped_key("model_reasoning_effort"), + serde_json::json!(effort.to_string()), + ) + }, + ); + match crate::config_rpc::write_config_batch( + app_server.request_handle(), + vec![ + crate::config_rpc::replace_config_value( + scoped_key("model"), + serde_json::json!(model.as_str()), + ), + effort_edit, + ], + /*reload_user_config*/ true, + ) + .await { - Ok(()) => { + Ok(_) => { let effort_label = effort .map(|selected_effort| selected_effort.to_string()) .unwrap_or_else(|| "default".to_string()); @@ -1260,13 +1283,22 @@ impl App { } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); - match ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_personality(Some(personality)) - .apply() - .await + let key_path = if let Some(profile) = profile { + format!("profiles.{profile}.personality") + } else { + "personality".to_string() + }; + match crate::config_rpc::write_config_batch( + app_server.request_handle(), + vec![crate::config_rpc::replace_config_value( + key_path, + serde_json::json!(personality.to_string()), + )], + /*reload_user_config*/ true, + ) + .await { - Ok(()) => { + Ok(_) => { let label = Self::personality_label(personality); let mut message = format!("Personality set to {label}"); if let Some(profile) = profile { @@ -1297,15 +1329,45 @@ impl App { self.refresh_status_line(); let profile = self.active_profile.as_deref(); self.config.service_tier = service_tier.clone(); - let mut edits = ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_service_tier(service_tier.clone()); + let scoped_key = |key: &str| { + if let Some(profile) = profile { + format!("profiles.{profile}.{key}") + } else { + key.to_string() + } + }; + let mut edits = vec![service_tier.as_ref().map_or_else( + || crate::config_rpc::clear_config_value(scoped_key("service_tier")), + |service_tier| { + let config_value = + match codex_protocol::config_types::ServiceTier::from_request_value( + service_tier, + ) { + Some(codex_protocol::config_types::ServiceTier::Fast) => "fast", + Some(codex_protocol::config_types::ServiceTier::Flex) => "flex", + None => service_tier.as_str(), + }; + crate::config_rpc::replace_config_value( + scoped_key("service_tier"), + serde_json::json!(config_value), + ) + }, + )]; if service_tier.is_none() { self.config.notices.fast_default_opt_out = Some(true); - edits = edits.set_fast_default_opt_out(/*opted_out*/ true); + edits.push(crate::config_rpc::replace_config_value( + "notice.fast_default_opt_out", + serde_json::json!(true), + )); } - match edits.apply().await { - Ok(()) => { + match crate::config_rpc::write_config_batch( + app_server.request_handle(), + edits, + /*reload_user_config*/ true, + ) + .await + { + Ok(_) => { let mut message = if let Some(service_tier) = service_tier { format!("Service tier set to {service_tier}") } else { @@ -1468,23 +1530,20 @@ impl App { self.sync_active_thread_permission_settings_to_cached_session() .await; let profile = self.active_profile.as_deref(); - let segments = if let Some(profile) = profile { - vec![ - "profiles".to_string(), - profile.to_string(), - "approvals_reviewer".to_string(), - ] + let key_path = if let Some(profile) = profile { + format!("profiles.{profile}.approvals_reviewer") } else { - vec!["approvals_reviewer".to_string()] + "approvals_reviewer".to_string() }; - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .with_edits([ConfigEdit::SetPath { - segments, - value: policy.to_string().into(), - }]) - .apply() - .await + if let Err(err) = crate::config_rpc::write_config_batch( + app_server.request_handle(), + vec![crate::config_rpc::replace_config_value( + key_path, + serde_json::json!(policy.to_string()), + )], + /*reload_user_config*/ true, + ) + .await { tracing::error!( error = %err, @@ -1575,27 +1634,25 @@ impl App { } AppEvent::PersistPlanModeReasoningEffort(effort) => { let profile = self.active_profile.as_deref(); - let segments = if let Some(profile) = profile { - vec![ - "profiles".to_string(), - profile.to_string(), - "plan_mode_reasoning_effort".to_string(), - ] + let key_path = if let Some(profile) = profile { + format!("profiles.{profile}.plan_mode_reasoning_effort") } else { - vec!["plan_mode_reasoning_effort".to_string()] + "plan_mode_reasoning_effort".to_string() }; let edit = if let Some(effort) = effort { - ConfigEdit::SetPath { - segments, - value: effort.to_string().into(), - } + crate::config_rpc::replace_config_value( + key_path, + serde_json::json!(effort.to_string()), + ) } else { - ConfigEdit::ClearPath { segments } + crate::config_rpc::clear_config_value(key_path) }; - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) - .with_edits([edit]) - .apply() - .await + if let Err(err) = crate::config_rpc::write_config_batch( + app_server.request_handle(), + vec![edit], + /*reload_user_config*/ true, + ) + .await { tracing::error!( error = %err, diff --git a/codex-rs/tui/src/config_rpc.rs b/codex-rs/tui/src/config_rpc.rs new file mode 100644 index 0000000000..272cf45e36 --- /dev/null +++ b/codex-rs/tui/src/config_rpc.rs @@ -0,0 +1,79 @@ +//! App-server-backed config persistence helpers for the TUI. +//! +//! This module centralizes the small typed RPC wrappers the TUI uses when a +//! config mutation must be owned by the app server rather than written to the +//! local `config.toml` directly. + +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SkillsConfigWriteParams; +use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_utils_absolute_path::AbsolutePathBuf; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +pub(crate) fn replace_config_value(key_path: impl Into, value: JsonValue) -> ConfigEdit { + ConfigEdit { + key_path: key_path.into(), + value, + merge_strategy: MergeStrategy::Replace, + } +} + +pub(crate) fn upsert_config_value(key_path: impl Into, value: JsonValue) -> ConfigEdit { + ConfigEdit { + key_path: key_path.into(), + value, + merge_strategy: MergeStrategy::Upsert, + } +} + +pub(crate) fn clear_config_value(key_path: impl Into) -> ConfigEdit { + replace_config_value(key_path, JsonValue::Null) +} + +pub(crate) async fn write_config_batch( + request_handle: AppServerRequestHandle, + edits: Vec, + reload_user_config: bool, +) -> Result { + let request_id = RequestId::String(format!("tui-config-write-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::ConfigBatchWrite { + request_id, + params: ConfigBatchWriteParams { + edits, + file_path: None, + expected_version: None, + reload_user_config, + }, + }) + .await + .wrap_err("config/batchWrite failed in TUI") +} + +pub(crate) async fn write_skill_enabled( + request_handle: AppServerRequestHandle, + path: AbsolutePathBuf, + enabled: bool, +) -> Result { + let request_id = RequestId::String(format!("tui-skill-config-write-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::SkillsConfigWrite { + request_id, + params: SkillsConfigWriteParams { + path: Some(path), + name: None, + enabled, + }, + }) + .await + .wrap_err("skills/config/write failed in TUI") +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 69b6bec0f3..cb4426ea78 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -112,6 +112,7 @@ mod clipboard_copy; mod clipboard_paste; mod collaboration_modes; mod color; +mod config_rpc; pub(crate) mod custom_terminal; mod pets; pub use custom_terminal::Terminal;