tui: persist primary settings via app server config RPC

This commit is contained in:
Eric Traut
2026-05-15 15:34:01 -07:00
parent e6a7368810
commit 25cda684b4
3 changed files with 186 additions and 49 deletions

View File

@@ -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,

View File

@@ -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<String>, value: JsonValue) -> ConfigEdit {
ConfigEdit {
key_path: key_path.into(),
value,
merge_strategy: MergeStrategy::Replace,
}
}
pub(crate) fn upsert_config_value(key_path: impl Into<String>, value: JsonValue) -> ConfigEdit {
ConfigEdit {
key_path: key_path.into(),
value,
merge_strategy: MergeStrategy::Upsert,
}
}
pub(crate) fn clear_config_value(key_path: impl Into<String>) -> ConfigEdit {
replace_config_value(key_path, JsonValue::Null)
}
pub(crate) async fn write_config_batch(
request_handle: AppServerRequestHandle,
edits: Vec<ConfigEdit>,
reload_user_config: bool,
) -> Result<ConfigWriteResponse> {
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<SkillsConfigWriteResponse> {
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")
}

View File

@@ -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;