Files
codex/codex-rs/tui/src/config_update.rs
T
Benjamin Carlsson 9c9451131f Add /voice settings to choose a voice for future conversations (#44622)
## Why

The TUI has no voice picker, and realtime start requests omit the voice preference. Voice selection also needs to reflect the active project's effective server settings, which can differ from cached local settings.

## What changed

- Add `/voice settings` with the server's voice catalog and current effective selection, falling back to the built-in catalog when unavailable.
- Save `realtime.voice` through the app server and reread effective settings before confirming the selection. Report save failures and overrides.
- Resolve the effective voice for each realtime start. Apply selections to the next voice conversation without interrupting live audio, and retain compatibility with servers that lack `config/read` or return unknown voice names.

## Testing

Add picker and confirmation snapshots, plus request tests covering persistence, server defaults, catalog fallback, unknown voices, config read failures, overrides, and project settings after thread switches.

GitOrigin-RevId: d47e3da6c1164053c5be9d1da0f5932f2a09261f
2026-09-10 19:14:57 +00:00

360 lines
13 KiB
Rust

//! App-server-backed config read and update helpers for the TUI.
//!
//! This module centralizes the small typed update helpers 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_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::EnvironmentInfoParams;
use codex_app_server_protocol::EnvironmentInfoResponse;
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_config::loader::project_trust_key;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_features::FEATURES;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::TrustLevel;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use serde_json::Value as JsonValue;
use std::fmt::Display;
use std::path::Path;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteProjectTrust {
pub cwd: PathBuf,
pub trust_target: PathBuf,
}
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 clear_config_value(key_path: impl Into<String>) -> ConfigEdit {
replace_config_value(key_path, JsonValue::Null)
}
pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String {
let app_id = serde_json::Value::String(app_id.to_string()).to_string();
format!("apps.{app_id}.{key_path}")
}
pub(crate) fn format_config_error(err: &impl Display) -> String {
format!("{err:#}")
}
fn trusted_project_edit(project_path: &Path) -> ConfigEdit {
let project_key = project_trust_key(project_path)
.replace('\\', "\\\\")
.replace('"', "\\\"");
replace_config_value(
format!("projects.\"{project_key}\".trust_level"),
serde_json::json!(TrustLevel::Trusted.to_string()),
)
}
pub(crate) fn build_model_selection_edits(
model: &str,
effort: Option<impl ToString>,
) -> Vec<ConfigEdit> {
let effort_edit = effort.map_or_else(
|| clear_config_value("model_reasoning_effort"),
|effort| {
replace_config_value(
"model_reasoning_effort",
serde_json::json!(effort.to_string()),
)
},
);
vec![
replace_config_value("model", serde_json::json!(model)),
effort_edit,
]
}
pub(crate) fn build_service_tier_selection_edits(service_tier: Option<&str>) -> Vec<ConfigEdit> {
let service_tier_edit = service_tier.map_or_else(
|| clear_config_value("service_tier"),
|service_tier| {
let config_value = if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE {
SERVICE_TIER_DEFAULT_REQUEST_VALUE
} else {
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,
}
};
replace_config_value("service_tier", serde_json::json!(config_value))
},
);
vec![service_tier_edit]
}
#[cfg(target_os = "windows")]
pub(crate) fn build_windows_sandbox_mode_edits(elevated_enabled: bool) -> Vec<ConfigEdit> {
let feature_key_path = |feature: &str| format!("features.{feature}");
vec![
replace_config_value(
"windows.sandbox",
serde_json::json!(if elevated_enabled {
"elevated"
} else {
"unelevated"
}),
),
clear_config_value(feature_key_path("experimental_windows_sandbox")),
clear_config_value(feature_key_path("elevated_windows_sandbox")),
clear_config_value(feature_key_path("enable_experimental_windows_sandbox")),
]
}
pub(crate) fn build_feature_enabled_edit(feature_key: &str, enabled: bool) -> ConfigEdit {
let key_path = format!("features.{feature_key}");
let is_default_false_feature = FEATURES
.iter()
.find(|spec| spec.key == feature_key)
.is_some_and(|spec| !spec.default_enabled);
if enabled || !is_default_false_feature {
replace_config_value(key_path, serde_json::json!(enabled))
} else {
clear_config_value(key_path)
}
}
pub(crate) fn build_memory_settings_edits(
use_memories: bool,
generate_memories: bool,
) -> Vec<ConfigEdit> {
vec![
replace_config_value("memories.use_memories", serde_json::json!(use_memories)),
replace_config_value(
"memories.generate_memories",
serde_json::json!(generate_memories),
),
]
}
pub(crate) fn build_oss_provider_edit(provider: &str) -> ConfigEdit {
replace_config_value("oss_provider", serde_json::json!(provider))
}
pub(crate) async fn write_config_batch(
request_handle: AppServerRequestHandle,
edits: Vec<ConfigEdit>,
) -> 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: true,
},
})
.await
.wrap_err("config/batchWrite failed in TUI")
}
pub(crate) async fn write_trusted_project(
request_handle: AppServerRequestHandle,
project_path: &Path,
) -> Result<ConfigWriteResponse> {
write_config_batch(request_handle, vec![trusted_project_edit(project_path)]).await
}
pub(crate) async fn read_effective_config(
request_handle: AppServerRequestHandle,
cwd: String,
) -> Result<ConfigReadResponse> {
let request_id = RequestId::String(format!("tui-config-read-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::ConfigRead {
request_id,
params: ConfigReadParams {
include_layers: false,
cwd: Some(cwd),
},
})
.await
.wrap_err("config/read failed in TUI")
}
pub(crate) async fn read_remote_project_trust(
request_handle: AppServerRequestHandle,
cwd: &Path,
) -> Result<Option<RemoteProjectTrust>> {
let cwd_string = cwd.to_string_lossy().into_owned();
let cwd = match LegacyAppPathString::from_string(cwd_string.clone()).to_inferred_path_uri() {
Some(cwd) => cwd,
None => {
let request_id = RequestId::String(format!("tui-environment-info-{}", Uuid::new_v4()));
let environment: EnvironmentInfoResponse = request_handle
.request_typed(ClientRequest::EnvironmentInfo {
request_id,
params: EnvironmentInfoParams {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
},
})
.await
.wrap_err("environment/info failed while resolving remote project trust")?;
environment
.cwd
.ok_or_else(|| color_eyre::eyre::eyre!("remote app server did not provide a cwd"))?
.join(&cwd_string)
.wrap_err("failed to resolve the remote project directory")?
}
};
let cwd_uri = cwd;
let cwd = cwd_uri.inferred_native_path_string();
let request_id = RequestId::String(format!("tui-project-trust-read-{}", Uuid::new_v4()));
let response: JsonValue = request_handle
.request_typed(ClientRequest::ConfigRead {
request_id,
params: ConfigReadParams {
include_layers: true,
cwd: Some(cwd.clone()),
},
})
.await
.wrap_err("config/read failed while checking remote project trust")?;
let project_layers = response
.get("layers")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter(|layer| layer["name"]["type"] == "project")
.collect::<Vec<_>>();
let disabled_project = project_layers.iter().rev().find(|layer| {
layer
.get("disabledReason")
.and_then(JsonValue::as_str)
.is_some()
});
let disabled_reason = disabled_project
.and_then(|layer| layer.get("disabledReason"))
.and_then(JsonValue::as_str);
let trust_target = disabled_reason
.and_then(|reason| reason.split_once(", add "))
.and_then(|(_, reason)| reason.rsplit_once(" as a trusted project in "))
.map(|(trust_target, _)| trust_target)
.or_else(|| {
disabled_project
.and_then(|layer| layer["name"]["dotCodexFolder"].as_str())
.and_then(|path| {
path.strip_suffix("/.codex")
.or_else(|| path.strip_suffix("\\.codex"))
})
})
.unwrap_or(&cwd);
let projects = response["config"]["projects"].as_object();
let has_trust_decision = projects
.and_then(|projects| projects.get(trust_target))
.and_then(|project| project.get("trust_level"))
.and_then(JsonValue::as_str)
.is_some_and(|level| matches!(level, "trusted" | "untrusted"));
let explicitly_untrusted = disabled_reason.is_some_and(|reason| {
projects.into_iter().flatten().any(|(path, project)| {
project.get("trust_level").and_then(JsonValue::as_str) == Some("untrusted")
&& reason
.strip_prefix(path)
.is_some_and(|suffix| suffix.starts_with(" is marked as untrusted"))
})
});
if has_trust_decision
|| explicitly_untrusted
|| (disabled_project.is_none()
&& project_layers
.iter()
.any(|layer| layer.get("disabledReason").is_none()))
{
return Ok(None);
}
if project_layers.is_empty()
&& projects.into_iter().flatten().any(|(path, project)| {
project.get("trust_level").and_then(JsonValue::as_str) == Some("untrusted")
&& LegacyAppPathString::from_string(path.clone())
.to_inferred_path_uri()
.is_some_and(|project_uri| cwd_uri.starts_with(&project_uri))
})
{
return Err(color_eyre::eyre::eyre!(
"remote project directory is inside an explicitly untrusted project; pass the repository root explicitly with --cd"
));
}
Ok(Some(RemoteProjectTrust {
cwd: PathBuf::from(&cwd),
trust_target: PathBuf::from(trust_target),
}))
}
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()));
let _: SkillsConfigWriteResponse = 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")?;
Ok(())
}
#[cfg(test)]
#[path = "config_update_tests.rs"]
mod tests;
/// Read effective server settings, retaining compatibility with servers without config/read.
pub(crate) async fn read_effective_config_if_supported(
request_handle: AppServerRequestHandle,
cwd: &Path,
) -> Result<Option<codex_app_server_protocol::Config>> {
match read_effective_config(request_handle, cwd.display().to_string()).await {
Ok(response) => Ok(Some(response.config)),
Err(err)
if matches!(
err.downcast_ref::<TypedRequestError>(),
Some(TypedRequestError::Server { source, .. })
if source.code == -32601
|| source.code == -32600
&& source.message.contains("config/read")
&& (source.message.contains("unknown variant")
|| source.message.contains("unknown method"))
) =>
{
// Callers retain their legacy behavior when the server lacks config/read.
Ok(None)
}
Err(err) => Err(err),
}
}