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
This commit is contained in:
Benjamin Carlsson
2026-09-10 19:14:57 +00:00
committed by copyberry
parent 5c013177d8
commit 9c9451131f
25 changed files with 781 additions and 48 deletions
+1
View File
@@ -237,6 +237,7 @@ mod platform_actions;
mod plugin_mentions;
mod rate_limit_refresh;
mod realtime_delivery;
mod realtime_settings;
mod reasoning_replay;
mod recap;
mod reconnect;
+6 -1
View File
@@ -669,7 +669,12 @@ impl App {
.unwrap_or(Path::new(".")),
};
let mut server_model_cleared = false;
match super::new_session::read_new_session_defaults(app_server, defaults_cwd).await {
match crate::config_update::read_effective_config_if_supported(
app_server.request_handle(),
defaults_cwd,
)
.await
{
Ok(Some(defaults)) => {
server_model_cleared = defaults.model.is_none();
let use_server_provider = matches!(
+6
View File
@@ -2405,6 +2405,12 @@ impl App {
}
self.chat_widget.on_plugin_mentions_loaded(plugins);
}
AppEvent::OpenRealtimeSettings => {
self.open_realtime_settings(app_server).await;
}
AppEvent::PersistRealtimeVoiceSelection { voice } => {
self.persist_realtime_voice(app_server, voice).await;
}
AppEvent::PersistPersonalitySelection { personality } => {
match crate::config_update::write_config_batch(
app_server.request_handle(),
+5 -32
View File
@@ -5,37 +5,6 @@
use super::*;
use codex_config::ConfigLayerSource;
pub(super) async fn read_new_session_defaults(
app_server: &AppServerSession,
cwd: &Path,
) -> Result<Option<codex_app_server_protocol::Config>> {
// config/read resolves relative paths on the server. With no remote launch override,
// "." uses the same server process directory as thread/start's omitted cwd.
match crate::config_update::read_effective_config(
app_server.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"))
) =>
{
// Older servers can still start threads using the existing local defaults.
Ok(None)
}
Err(err) => Err(err),
}
}
pub(super) fn has_launch_setting(
config: &Config,
cli_kv_overrides: &[(String, TomlValue)],
@@ -82,7 +51,11 @@ impl App {
app_server.remote_cwd_override().unwrap_or(Path::new("."))
}
};
let defaults = read_new_session_defaults(app_server, defaults_cwd).await?;
let defaults = crate::config_update::read_effective_config_if_supported(
app_server.request_handle(),
defaults_cwd,
)
.await?;
// Stage local preferences and permission carryover without changing the active task.
let mut config = match self.rebuild_config_for_cwd(cwd).await {
Ok(config) => config,
+123
View File
@@ -0,0 +1,123 @@
//! Resolve and persist voice preferences through the owning server before updating the UI.
use super::*;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadRealtimeListVoicesParams;
use codex_app_server_protocol::ThreadRealtimeListVoicesResponse;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::RealtimeVoicesList;
use uuid::Uuid;
/// Unknown voices belong to newer servers; omit the override instead of blocking audio.
/// An unset preference explicitly selects the V1/V3 server default, not a stale thread preference.
fn configured_voice(
config: &codex_app_server_protocol::Config,
default_voice: RealtimeVoice,
) -> Result<Option<RealtimeVoice>> {
let value = config
.additional
.get("realtime")
.and_then(|realtime| realtime.get("voice"));
match value {
None | Some(serde_json::Value::Null) => Ok(Some(default_voice)),
Some(value @ serde_json::Value::String(_)) => {
Ok(serde_json::from_value(value.clone()).ok())
}
Some(value) => Ok(Some(serde_json::from_value(value.clone())?)),
}
}
impl App {
pub(super) async fn realtime_voices(
&self,
app_server: &AppServerSession,
) -> RealtimeVoicesList {
match app_server
.request_handle()
.request_typed::<ThreadRealtimeListVoicesResponse>(
ClientRequest::ThreadRealtimeListVoices {
request_id: RequestId::String(format!("tui-voice-list-{}", Uuid::new_v4())),
params: ThreadRealtimeListVoicesParams {},
},
)
.await
{
Ok(response) => response.voices,
Err(_) => RealtimeVoicesList::builtin(),
}
}
pub(super) async fn effective_realtime_voice(
&self,
app_server: &AppServerSession,
voices: &RealtimeVoicesList,
) -> Result<Option<RealtimeVoice>> {
// Session setup installs the active conversation cwd for both server modes.
let config = crate::config_update::read_effective_config_if_supported(
app_server.request_handle(),
&self.chat_widget.config_ref().cwd,
)
.await?;
config
.as_ref()
.map(|config| configured_voice(config, voices.default_v1))
.transpose()
.map(Option::flatten)
}
pub(super) async fn open_realtime_settings(&mut self, app_server: &AppServerSession) {
let voices = self.realtime_voices(app_server).await;
match self.effective_realtime_voice(app_server, &voices).await {
Ok(voice) => self.chat_widget.open_realtime_settings(voice, voices),
Err(error) => self
.chat_widget
.add_error_message(format!("Failed to read voice settings: {error}")),
}
}
pub(super) async fn persist_realtime_voice(
&mut self,
app_server: &AppServerSession,
voice: RealtimeVoice,
) {
match crate::config_update::write_config_batch(
app_server.request_handle(),
vec![crate::config_update::replace_config_value(
"realtime.voice",
serde_json::json!(voice),
)],
)
.await
{
Ok(response) => {
let voices = self.realtime_voices(app_server).await;
let effective = crate::config_update::read_effective_config_if_supported(
app_server.request_handle(), &self.chat_widget.config_ref().cwd,
).await.and_then(|config| match config.as_ref() {
Some(config) => configured_voice(config, voices.default_v1),
None if response.status == codex_app_server_protocol::WriteStatus::OkOverridden => Err(color_eyre::eyre::eyre!("the saved voice is overridden, but this server cannot read effective settings")),
None => Ok(Some(voice)),
});
match effective {
Ok(effective_voice) => {
self.config.realtime.voice = effective_voice;
self.chat_widget.set_realtime_voice(effective_voice);
if effective_voice == Some(voice) {
self.chat_widget.on_realtime_voice_saved(voice);
} else {
self.chat_widget.add_error_message(format!(
"Voice preference was saved but not applied: {}",
super::config_persistence::overridden_write_message(&response),
));
}
}
Err(error) => self.chat_widget.add_error_message(format!("Voice preference was saved, but effective settings could not be read: {error}")),
}
}
Err(error) => self
.chat_widget
.add_error_message(format!("Failed to save voice: {error}")),
}
}
}
+5 -1
View File
@@ -53,7 +53,11 @@ pub(super) async fn prepare_fresh_startup_config(
app_server.remote_cwd_override().unwrap_or(Path::new("."))
}
};
let defaults = super::new_session::read_new_session_defaults(app_server, defaults_cwd).await?;
let defaults = crate::config_update::read_effective_config_if_supported(
app_server.request_handle(),
defaults_cwd,
)
.await?;
if let Some(defaults) = defaults.as_ref() {
super::new_session::overlay_new_session_defaults(
config,
@@ -128,7 +128,12 @@ async fn delegated_core_events_keep_private_output_hidden_and_deliver_final_spee
while ops.try_recv().is_ok() {}
let mut tui = crate::tui::test_support::make_test_tui()?;
app_server
.thread_realtime_start(thread_id, "v=offer\r\n".to_string(), /*model*/ None)
.thread_realtime_start(
thread_id,
"v=offer\r\n".to_string(),
/*model*/ None,
/*voice*/ None,
)
.await?;
// The sideband fixture sends its delegation after the first outbound request.
app_server
+351 -6
View File
@@ -77,18 +77,76 @@ enum ItemEventKind {
}
#[tokio::test]
async fn realtime_start_routes_v3_offer_without_startup_context() -> Result<()> {
async fn remote_voice_start_routes_v3_offer_and_effective_preference() -> Result<()> {
use super::session_lifecycle_requests::HistoryCapabilities;
for (capabilities, expected_voice) in [
(HistoryCapabilities::Current, Some("juniper")),
(HistoryCapabilities::ConfigReadUnsupported(-32601), None),
(HistoryCapabilities::Current, Some("cove")),
(HistoryCapabilities::VoiceCatalogCustom, Some("maple")),
(HistoryCapabilities::VoiceCatalogUnavailable, Some("cove")),
(HistoryCapabilities::ConfigReadUnknownVoice, None),
] {
check_remote_voice_start(capabilities, expected_voice).await?;
}
Ok(())
}
async fn check_remote_voice_start(
capabilities: super::session_lifecycle_requests::HistoryCapabilities,
expected_voice: Option<&str>,
) -> Result<()> {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server(
&app.config,
RealtimeRequestBehavior::AcceptStart,
)
.await?;
let (mut app_server, requests, proxy) =
super::session_lifecycle_requests::start_recording_app_server_with_realtime_speech(
&app.config,
capabilities,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
crate::app_server_session::ThreadParamsMode::Remote,
RealtimeRequestBehavior::AcceptStart,
codex_config::LoaderOverrides::default(),
)
.await?;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.chat_widget
.handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::PersistRealtimeVoiceSelection {
voice: codex_protocol::protocol::RealtimeVoice::Juniper,
},
))
.await?;
if capabilities == super::session_lifecycle_requests::HistoryCapabilities::Current {
assert_eq!(
(
app.config.realtime.voice,
app.chat_widget.config_ref().realtime.voice
),
(
Some(codex_protocol::protocol::RealtimeVoice::Juniper),
Some(codex_protocol::protocol::RealtimeVoice::Juniper)
)
);
}
assert_eq!(
recorded_params(&requests, "config/batchWrite")[0]["edits"],
serde_json::json!([{"keyPath": "realtime.voice", "value": "juniper", "mergeStrategy": "replace"}]),
);
if expected_voice != Some("juniper") {
std::fs::write(
app.config.codex_home.join("config.toml"),
"[realtime]\ntype = \"conversational\"\n",
)?;
}
// A reconnected TUI can retain a different local preference from its server.
app.config.realtime.voice = Some(codex_protocol::protocol::RealtimeVoice::Maple);
app.chat_widget
.on_realtime_voice_saved(codex_protocol::protocol::RealtimeVoice::Maple);
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
@@ -109,6 +167,7 @@ async fn realtime_start_routes_v3_offer_without_startup_context() -> Result<()>
"includeStartupContext": start["includeStartupContext"],
"outputModality": start["outputModality"],
"version": start["version"],
"voice": start["voice"],
"transport": start["transport"],
}),
serde_json::json!({
@@ -117,6 +176,7 @@ async fn realtime_start_routes_v3_offer_without_startup_context() -> Result<()>
"includeStartupContext": false,
"outputModality": "audio",
"version": "v3",
"voice": expected_voice,
"transport": {"type": "webrtc", "sdp": "v=0\r\n"},
})
);
@@ -1471,3 +1531,288 @@ async fn delegated_final_speech_reaches_app_server_once_and_stale_speech_is_reje
proxy.await??;
Ok(())
}
#[tokio::test]
async fn rejected_voice_setting_preserves_current_selection() -> Result<()> {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let (mut app_server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?;
let previous = (
app.config.realtime.voice,
app.chat_widget.config_ref().realtime.voice,
);
std::fs::write(app.config.codex_home.join("config.toml"), "realtime = [")?;
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::PersistRealtimeVoiceSelection {
voice: codex_protocol::protocol::RealtimeVoice::Juniper,
},
))
.await?;
assert_eq!(
(
app.config.realtime.voice,
app.chat_widget.config_ref().realtime.voice
),
previous,
);
let messages = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(
cell.display_lines(/*width*/ 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n"),
),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
assert!(messages.contains("Failed to save voice"));
app_server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn remote_voice_config_read_failure_does_not_start_with_local_voice() -> Result<()> {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?;
std::fs::write(app.config.codex_home.join("config.toml"), "realtime = [")?;
let thread_id = ThreadId::new();
let error = app
.try_submit_active_thread_op_via_app_server(
&mut app_server,
thread_id,
&AppCommand::RealtimeConversationStart {
thread_id,
offer_sdp: String::from("v=0\r\n").into(),
},
)
.await
.expect_err("invalid server config must not fall back to local voice");
assert!(error.to_string().contains("config/read"));
assert!(recorded_params(&requests, "thread/realtime/start").is_empty());
app_server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn embedded_voice_settings_follow_project_after_thread_switch() -> Result<()> {
use codex_protocol::protocol::RealtimeVoice;
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let projects = tempfile::tempdir()?;
for voice in [RealtimeVoice::Maple, RealtimeVoice::Sol] {
let cwd = projects.path().join(voice.wire_name());
std::fs::create_dir_all(cwd.join(".codex"))?;
std::fs::write(
cwd.join(".codex/config.toml"),
format!("[realtime]\nvoice = \"{}\"\n", voice.wire_name()),
)?;
crate::legacy_core::config::set_project_trust_level(
&app.config.codex_home,
&cwd,
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?;
}
app.config.realtime.voice = Some(RealtimeVoice::Maple);
let (mut server, requests, proxy) = start_recording_realtime_speech_app_server(
&app.config,
RealtimeRequestBehavior::AcceptStart,
)
.await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
for voice in [
RealtimeVoice::Maple,
RealtimeVoice::Sol,
RealtimeVoice::Maple,
] {
let thread_id = ThreadId::new();
let cwd = projects.path().join(voice.wire_name());
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
test_thread_session(thread_id, cwd),
Vec::new(),
),
);
Box::pin(app.select_agent_thread(&mut tui, &mut server, thread_id)).await?;
app.open_realtime_settings(&server).await;
let popup = crate::chatwidget::tests::helpers::render_bottom_popup(
&app.chat_widget,
/*width*/ 80,
);
assert!(
popup.contains(&format!("{} (current)", voice.wire_name())),
"{popup}"
);
app.try_submit_active_thread_op_via_app_server(
&mut server,
thread_id,
&AppCommand::RealtimeConversationStart {
thread_id,
offer_sdp: String::from("v=0\r\n").into(),
},
)
.await?;
assert_eq!(
recorded_params(&requests, "thread/realtime/start")
.last()
.unwrap()["voice"],
serde_json::json!(voice),
);
}
server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn remote_voice_picker_reads_server_preference() -> Result<()> {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[realtime]\nvoice = \"sol\"\n",
)?;
app.chat_widget
.set_realtime_voice(Some(codex_protocol::protocol::RealtimeVoice::Maple));
let (server, requests, proxy) = start_recording_remote_app_server(&app.config).await?;
app.open_realtime_settings(&server).await;
assert_eq!(
recorded_params(&requests, "thread/realtime/listVoices").len(),
1
);
insta::assert_snapshot!(
"remote_voice_picker",
crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80)
);
server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn remote_voice_picker_uses_server_catalog_and_falls_back_when_unavailable() -> Result<()> {
use super::session_lifecycle_requests::HistoryCapabilities;
for (capabilities, expected_catalog) in [
(HistoryCapabilities::VoiceCatalogCustom, true),
(HistoryCapabilities::VoiceCatalogUnavailable, false),
] {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let (server, requests, proxy) =
super::session_lifecycle_requests::start_recording_app_server_with_realtime_speech(
&app.config,
capabilities,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
crate::app_server_session::ThreadParamsMode::Remote,
RealtimeRequestBehavior::Forward,
codex_config::LoaderOverrides::default(),
)
.await?;
app.open_realtime_settings(&server).await;
let popup = crate::chatwidget::tests::helpers::render_bottom_popup(
&app.chat_widget,
/*width*/ 80,
);
assert_eq!(popup.contains("1. maple (current)"), expected_catalog);
assert_eq!(popup.contains(" 3. spruce"), !expected_catalog);
if expected_catalog {
insta::assert_snapshot!("remote_voice_picker_server_default", popup);
}
assert_eq!(
recorded_params(&requests, "thread/realtime/listVoices").len(),
1
);
server.shutdown().await?;
proxy.await??;
}
Ok(())
}
#[tokio::test]
async fn overridden_voice_save_keeps_effective_voice() -> Result<()> {
for project_override in [false, true] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let project = tempfile::tempdir()?;
let overrides = if project_override {
std::fs::create_dir_all(project.path().join(".codex"))?;
std::fs::write(
project.path().join(".codex/config.toml"),
"[realtime]\nvoice = \"maple\"\n",
)?;
crate::legacy_core::config::set_project_trust_level(
&app.config.codex_home,
project.path(),
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?;
app.config.cwd = AbsolutePathBuf::from_absolute_path(project.path())?;
app.chat_widget
.handle_thread_session_quiet(test_thread_session(
ThreadId::new(),
project.path().to_path_buf(),
));
Vec::new()
} else {
vec![(
"realtime.voice".to_string(),
toml::Value::String("maple".to_string()),
)]
};
let client = crate::start_embedded_app_server(
codex_arg0::Arg0DispatchPaths::default(),
app.config.clone(),
overrides,
codex_config::LoaderOverrides::without_managed_config_for_tests(),
/*strict_config*/ false,
codex_config::CloudConfigBundleLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
/*state_db*/ None,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
)
.await?;
let server = AppServerSession::new(
codex_app_server_client::AppServerClient::InProcess(client),
crate::app_server_session::ThreadParamsMode::Embedded,
);
while events.try_recv().is_ok() {}
app.persist_realtime_voice(&server, codex_protocol::protocol::RealtimeVoice::Juniper)
.await;
assert_eq!(
(
app.config.realtime.voice,
app.chat_widget.config_ref().realtime.voice
),
(
Some(codex_protocol::protocol::RealtimeVoice::Maple),
Some(codex_protocol::protocol::RealtimeVoice::Maple)
),
);
let message = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(
cell.display_lines(/*width*/ 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n"),
),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("saved but not applied"));
if !project_override {
insta::assert_snapshot!("overridden_voice_save", message);
}
server.shutdown().await?;
}
Ok(())
}
@@ -147,6 +147,9 @@ pub(super) enum HistoryCapabilities {
ThreadStartFails,
ConfigReadUnsupported(i64),
ConfigReadFails,
ConfigReadUnknownVoice,
VoiceCatalogCustom,
VoiceCatalogUnavailable,
}
/// Returns and resets `(thread/loaded/list, thread/read)` request counts.
@@ -240,7 +243,7 @@ pub(super) async fn start_recording_realtime_speech_app_server(
.await
}
async fn start_recording_app_server_with_realtime_speech(
pub(super) async fn start_recording_app_server_with_realtime_speech(
config: &Config,
history_capabilities: HistoryCapabilities,
mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>,
@@ -372,6 +375,17 @@ async fn start_recording_app_server_with_realtime_speech(
message: "config temporarily unavailable".to_string(),
},
})
} else if history_capabilities == HistoryCapabilities::VoiceCatalogUnavailable
&& request.method == "thread/realtime/listVoices"
{
JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error: JSONRPCErrorError {
code: -32601,
data: None,
message: "method not found".to_string(),
},
})
} else if history_capabilities == HistoryCapabilities::ThreadStartFails
&& request.method == "thread/start"
{
@@ -484,7 +498,25 @@ async fn start_recording_app_server_with_realtime_speech(
},
})
} else {
let unknown_voice = history_capabilities
== HistoryCapabilities::ConfigReadUnknownVoice
&& matches!(&request, ClientRequest::ConfigRead { .. });
let custom_voice_catalog = history_capabilities
== HistoryCapabilities::VoiceCatalogCustom
&& matches!(
&request,
ClientRequest::ThreadRealtimeListVoices { .. }
);
let mut result = embedded.request(request).await?;
if unknown_voice && let Ok(value) = &mut result {
value["config"]["realtime"]["voice"] =
serde_json::json!("future_voice");
}
if custom_voice_catalog && let Ok(value) = &mut result {
value["voices"]["v1"] =
serde_json::json!(["maple", "cove", "juniper"]);
value["voices"]["defaultV1"] = serde_json::json!("maple");
}
if background {
let terminal = r#"{"data":[{"itemId":"x","processId":"x","command":"x","cwd":"/"}],"nextCursor":null}"#;
result = Ok(serde_json::from_str(terminal)?);
@@ -0,0 +1,6 @@
---
source: tui/src/app/tests/realtime_requests.rs
assertion_line: 1679
expression: message
---
■ Voice preference was saved but not applied: Overridden by session flags
@@ -0,0 +1,18 @@
---
source: tui/src/app/tests/realtime_requests.rs
assertion_line: 1638
expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)"
---
Select voice
Applies to your next voice conversation.
1. juniper
2. maple
3. spruce
4. ember
5. vale
6. breeze
7. arbor
› 8. sol (current)
Press enter to confirm or esc to go back
@@ -0,0 +1,13 @@
---
source: tui/src/app/tests/realtime_requests.rs
assertion_line: 1721
expression: popup
---
Select voice
Applies to your next voice conversation.
› 1. maple (current)
2. cove
3. juniper
Press enter to confirm or esc to go back
+3
View File
@@ -938,11 +938,14 @@ impl App {
.config_ref()
.experimental_realtime_ws_model
.clone();
let voices = self.realtime_voices(app_server).await;
let voice = self.effective_realtime_voice(app_server, &voices).await?;
app_server
.thread_realtime_start(
*realtime_thread_id,
String::from(offer_sdp.clone()),
model,
voice,
)
.await?;
Ok(true)
+8
View File
@@ -1138,6 +1138,14 @@ pub(crate) enum AppEvent {
/// Show the cyber auto-review notice after the model selection confirmation.
CyberModelAutoReviewNotice,
/// Read the owning server preference before showing the voice picker.
OpenRealtimeSettings,
/// Save the voice for subsequent conversations through the app server.
PersistRealtimeVoiceSelection {
voice: codex_protocol::protocol::RealtimeVoice,
},
/// Persist the selected personality to the appropriate config.
PersistPersonalitySelection {
personality: Personality,
@@ -12,6 +12,7 @@ use codex_app_server_protocol::ThreadRealtimeStopResponse;
use codex_protocol::ThreadId;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeOutputModality;
use codex_protocol::protocol::RealtimeVoice;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
@@ -21,6 +22,7 @@ impl AppServerSession {
thread_id: ThreadId,
offer_sdp: String,
model: Option<String>,
voice: Option<RealtimeVoice>,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeStartResponse = self
@@ -46,7 +48,7 @@ impl AppServerSession {
realtime_session_id: None,
transport: Some(ThreadRealtimeStartTransport::Webrtc { sdp: offer_sdp }),
version: Some(RealtimeConversationVersion::V3),
voice: None,
voice,
},
})
.await
@@ -5,11 +5,11 @@ expression: "format!(\"{buf:?}\")"
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 1 },
content: [
" /voice start or stop a live voice conversation ",
" /voice start or stop voice; use /voice settings to choose a voice ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 49, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 68, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
+1
View File
@@ -408,6 +408,7 @@ mod reset_credits;
pub(crate) use self::rate_limits::limit_label_for_window;
mod completion;
mod realtime;
mod realtime_settings;
mod realtime_split_flap;
pub(crate) use realtime::MAX_REPLAY_TRANSCRIPT_CELLS;
pub(crate) use realtime::MAX_TRANSCRIPT_BYTES;
@@ -0,0 +1,54 @@
//! Voice selection for subsequent conversations; saving never interrupts live audio.
use super::*;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::RealtimeVoicesList;
impl ChatWidget {
pub(crate) fn open_realtime_settings(
&mut self,
current: Option<RealtimeVoice>,
voices: RealtimeVoicesList,
) {
// The TUI uses V3, which shares the V1 voice catalog.
let items = voices
.v1
.into_iter()
.map(|voice| SelectionItem {
name: voice.wire_name().to_string(),
is_current: Some(voice) == current,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeVoiceSelection { voice });
})],
dismiss_on_select: true,
..Default::default()
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select voice".to_string()),
subtitle: Some("Applies to your next voice conversation.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn set_realtime_voice(&mut self, voice: Option<RealtimeVoice>) {
self.config.realtime.voice = voice;
}
pub(crate) fn on_realtime_voice_saved(&mut self, voice: RealtimeVoice) {
self.set_realtime_voice(Some(voice));
self.add_info_message(
format!(
"Voice set to {}. Applies to your next voice conversation.",
voice.wire_name()
),
/*hint*/ None,
);
}
}
#[cfg(test)]
#[path = "realtime_settings_tests.rs"]
mod tests;
@@ -0,0 +1,71 @@
//! Voice picker rendering and selection routing without audio hardware.
use super::*;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::chatwidget::tests::render_bottom_popup;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn voice_settings_selects_a_voice_without_starting_audio() {
let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
chat.config
.features
.enable(Feature::RealtimeConversation)
.unwrap();
chat.config.realtime.voice = Some(RealtimeVoice::Maple);
chat.dispatch_command_with_args(SlashCommand::Voice, "settings".to_string(), Vec::new());
assert!(
std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::OpenRealtimeSettings))
);
chat.open_realtime_settings(Some(RealtimeVoice::Maple), RealtimeVoicesList::builtin());
insta::assert_snapshot!("voice_settings", render_bottom_popup(&chat, /*width*/ 80));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let voice = std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event {
AppEvent::PersistRealtimeVoiceSelection { voice } => Some(voice),
_ => None,
});
assert_eq!(voice, Some(RealtimeVoice::Juniper));
assert_eq!(chat.config.realtime.voice, Some(RealtimeVoice::Maple));
assert!(ops.try_recv().is_err());
assert!(!chat.realtime_conversation_is_running());
}
#[tokio::test]
async fn saved_voice_confirmation_snapshot() {
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
while events.try_recv().is_ok() {}
chat.on_realtime_voice_saved(RealtimeVoice::Juniper);
let lines = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(cell.display_lines(/*width*/ 80)),
_ => None,
})
.flatten()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("saved_voice_confirmation", lines);
}
#[tokio::test]
async fn voice_settings_uses_server_catalog() {
let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
chat.open_realtime_settings(
Some(RealtimeVoice::Cove),
RealtimeVoicesList {
v1: vec![RealtimeVoice::Cove, RealtimeVoice::Maple],
..RealtimeVoicesList::builtin()
},
);
insta::assert_snapshot!(
"voice_settings_server_catalog",
render_bottom_popup(&chat, /*width*/ 80)
);
}
@@ -755,9 +755,10 @@ impl ChatWidget {
}
}
SlashCommand::Voice => match trimmed.to_ascii_lowercase().as_str() {
"settings" => self.app_event_tx.send(AppEvent::OpenRealtimeSettings),
"mute" => self.toggle_realtime_microphone(),
"stop" => self.stop_realtime_conversation(),
_ => self.add_error_message("Usage: /voice [mute|stop]".to_string()),
_ => self.add_error_message("Usage: /voice [settings|mute|stop]".to_string()),
},
SlashCommand::Ide => {
self.handle_ide_command_args(trimmed);
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/realtime_settings_tests.rs
assertion_line: 46
expression: lines
---
• Voice set to juniper. Applies to your next voice conversation.
@@ -0,0 +1,18 @@
---
source: tui/src/chatwidget/realtime_settings_tests.rs
assertion_line: 17
expression: "render_bottom_popup(&chat, 80)"
---
Select voice
Applies to your next voice conversation.
1. juniper
› 2. maple (current)
3. spruce
4. ember
5. vale
6. breeze
7. arbor
8. sol
Press enter to confirm or esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/realtime_settings_tests.rs
assertion_line: 67
expression: "render_bottom_popup(&chat, 80)"
---
Select voice
Applies to your next voice conversation.
› 1. cove (current)
2. maple
Press enter to confirm or esc to go back
+27 -1
View File
@@ -1,10 +1,11 @@
//! App-server-backed config update helpers for the TUI.
//! 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;
@@ -331,3 +332,28 @@ pub(crate) async fn write_skill_enabled(
#[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),
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ impl SlashCommand {
}
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Voice => "start or stop a live voice conversation",
SlashCommand::Voice => "start or stop voice; use /voice settings to choose a voice",
SlashCommand::Goal => "set or view the goal for a long-running task",
SlashCommand::Agents => "view and switch between all active agent sessions",
SlashCommand::MultiAgents => "switch between this session's subagents",