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