From 9c9451131fefe6c76e6dd97e8098300298442e64 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Thu, 10 Sep 2026 19:04:24 +0000 Subject: [PATCH] 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 --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/agents_overview.rs | 7 +- codex-rs/tui/src/app/event_dispatch.rs | 6 + codex-rs/tui/src/app/new_session.rs | 37 +- codex-rs/tui/src/app/realtime_settings.rs | 123 ++++++ codex-rs/tui/src/app/startup.rs | 6 +- .../tui/src/app/tests/realtime_handoff_e2e.rs | 7 +- .../tui/src/app/tests/realtime_requests.rs | 357 +++++++++++++++++- .../app/tests/session_lifecycle_requests.rs | 34 +- ...ltime_requests__overridden_voice_save.snap | 6 + ...ealtime_requests__remote_voice_picker.snap | 18 + ...s__remote_voice_picker_server_default.snap | 13 + codex-rs/tui/src/app/thread_routing.rs | 3 + codex-rs/tui/src/app_event.rs | 8 + .../tui/src/app_server_session/realtime.rs | 4 +- ...and_popup__tests__command_popup_voice.snap | 4 +- codex-rs/tui/src/chatwidget.rs | 1 + .../tui/src/chatwidget/realtime_settings.rs | 54 +++ .../src/chatwidget/realtime_settings_tests.rs | 71 ++++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 3 +- ...ings__tests__saved_voice_confirmation.snap | 6 + ...ltime_settings__tests__voice_settings.snap | 18 + ..._tests__voice_settings_server_catalog.snap | 12 + codex-rs/tui/src/config_update.rs | 28 +- codex-rs/tui/src/slash_command.rs | 2 +- 25 files changed, 781 insertions(+), 48 deletions(-) create mode 100644 codex-rs/tui/src/app/realtime_settings.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__overridden_voice_save.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap create mode 100644 codex-rs/tui/src/chatwidget/realtime_settings.rs create mode 100644 codex-rs/tui/src/chatwidget/realtime_settings_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__saved_voice_confirmation.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4e4f534ca6..0235848086 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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; diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 822b932605..f12800ba19 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -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!( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bcd076f26f..3643390f30 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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(), diff --git a/codex-rs/tui/src/app/new_session.rs b/codex-rs/tui/src/app/new_session.rs index d0361d8fcf..1f0e394628 100644 --- a/codex-rs/tui/src/app/new_session.rs +++ b/codex-rs/tui/src/app/new_session.rs @@ -5,37 +5,6 @@ use super::*; use codex_config::ConfigLayerSource; -pub(super) async fn read_new_session_defaults( - app_server: &AppServerSession, - cwd: &Path, -) -> Result> { - // 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::(), - 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, diff --git a/codex-rs/tui/src/app/realtime_settings.rs b/codex-rs/tui/src/app/realtime_settings.rs new file mode 100644 index 0000000000..d265d4b25c --- /dev/null +++ b/codex-rs/tui/src/app/realtime_settings.rs @@ -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> { + 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::( + 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> { + // 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}")), + } + } +} diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 4ce96b87f7..074b11433a 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -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, diff --git a/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs b/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs index 305650684b..a7a0aeec7e 100644 --- a/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs +++ b/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs @@ -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 diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs index 77e5d07eb4..a752852a4f 100644 --- a/codex-rs/tui/src/app/tests/realtime_requests.rs +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -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::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .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::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert!(message.contains("saved but not applied")); + if !project_override { + insta::assert_snapshot!("overridden_voice_save", message); + } + server.shutdown().await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 2b3fca5cb3..6bf901e229 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -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)?); diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__overridden_voice_save.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__overridden_voice_save.snap new file mode 100644 index 0000000000..b621fee738 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__overridden_voice_save.snap @@ -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 diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap new file mode 100644 index 0000000000..74eba06dee --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap @@ -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 diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap new file mode 100644 index 0000000000..ca2b2cb7a5 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap @@ -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 diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index d9ea5f126b..9af8e4c0ee 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -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) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 7611d89cf5..8d34d8ed2e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, diff --git a/codex-rs/tui/src/app_server_session/realtime.rs b/codex-rs/tui/src/app_server_session/realtime.rs index f9fff2c00b..3614301e89 100644 --- a/codex-rs/tui/src/app_server_session/realtime.rs +++ b/codex-rs/tui/src/app_server_session/realtime.rs @@ -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, + voice: Option, ) -> 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 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_voice.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_voice.snap index 417c3b62eb..ba3bc2ab73 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_voice.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_voice.snap @@ -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, ] } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4e34536699..0ea8de2f64 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/realtime_settings.rs b/codex-rs/tui/src/chatwidget/realtime_settings.rs new file mode 100644 index 0000000000..0ea6a16aa2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/realtime_settings.rs @@ -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, + 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) { + 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; diff --git a/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs new file mode 100644 index 0000000000..84657da2bd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs @@ -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::>() + .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) + ); +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index f67ece542e..147b41d1bb 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__saved_voice_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__saved_voice_confirmation.snap new file mode 100644 index 0000000000..e9d4195c93 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__saved_voice_confirmation.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap new file mode 100644 index 0000000000..96cfe1c9ba --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap new file mode 100644 index 0000000000..901314ac55 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime_settings__tests__voice_settings_server_catalog.snap @@ -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 diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index 9814b6a2be..259c043f4f 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -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> { + match read_effective_config(request_handle, cwd.display().to_string()).await { + Ok(response) => Ok(Some(response.config)), + Err(err) + if matches!( + err.downcast_ref::(), + 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), + } +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 99fea9eee7..ecfadd8758 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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",