diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index fffb7b51ae..e8c8d1381e 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -1680,7 +1680,11 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RealtimeConversation, key: "realtime_conversation", - stage: Stage::Removed, + stage: Stage::Experimental { + name: "Voice conversations", + menu_description: "Talk with Codex using /voice.", + announcement: "NEW: Voice conversations can now be enabled from /experimental. Restart Codex after enabling, then use /voice.", + }, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index 9ea74c2ba8..dbd24a1257 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -1,5 +1,6 @@ //! Experimental controls with popup-owned discovery and configured enablement. //! Failed saves retain intent for explicit retry; cancellation remains available. +//! Server voice discovery is gated by the client package's native runtime. use crate::experimental_features::FeatureWriteResult; use codex_app_server_protocol::ExperimentalFeature; @@ -53,6 +54,7 @@ pub(crate) struct ExperimentalFeatureItem { pub(crate) struct ExperimentalFeaturesView { features: Vec, + voice_supported: bool, initial_enabled: Vec, unconfirmed: Vec, catalog_rx: Option, String>>>, @@ -82,6 +84,7 @@ impl ExperimentalFeaturesView { } .to_string(), thread_id, + voice_supported: codex_realtime_webrtc::RealtimeWebrtcSession::is_supported(), write_rx: None, catalog_rx, unconfirmed: Vec::new(), @@ -312,7 +315,10 @@ impl BottomPaneView for ExperimentalFeaturesView { Ok(features) => { let mut count = 0; for feature in features { - if feature.stage != ExperimentalFeatureStage::Beta { + if feature.stage != ExperimentalFeatureStage::Beta + || (feature.name == Feature::RealtimeConversation.key() + && !self.voice_supported) + { continue; } self.initial_enabled.push(feature.enabled); diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs index 3ae7173d60..9c9122560a 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs @@ -207,3 +207,32 @@ fn snapshot_view(name: &str, view: &ExperimentalFeaturesView) { view.render(area, &mut buffer); insta::assert_snapshot!(name, buffer_text(&buffer)); } + +#[test] +fn voice_discovery_requires_the_client_runtime() { + for supported in [false, true] { + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let mut view = ExperimentalFeaturesView::new( + Vec::new(), + ThreadId::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.voice_supported = supported; + catalog_tx + .send(Ok(vec![server_feature("realtime_conversation")])) + .unwrap(); + assert!(view.pre_draw_tick(Instant::now())); + assert_eq!(view.features.len(), usize::from(supported)); + snapshot_view( + if supported { + "voice_runtime_available" + } else { + "voice_runtime_unavailable" + }, + &view, + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_available.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_available.snap new file mode 100644 index 0000000000..b1159ac9ff --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_available.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +assertion_line: 208 +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + +› [x] Server realtime_conversation Description supplied by the server. + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_unavailable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_unavailable.snap new file mode 100644 index 0000000000..12654f5dc0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_unavailable.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +assertion_line: 208 +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + No server experiments available. + + No experimental features available for now + + Press space to select or enter to save diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap index a3d78715e7..1fdb880a0d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap @@ -1,16 +1,18 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 3083 expression: popup --- Experimental features Checked features are configured on. Some experimental features take effect only in new tasks or after restarting the Codex server. -› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for - interactive website debugging and other inline - JavaScript execution capabilities. - [x] Shell tool Allow the model to run shell commands. - [ ] Worktrees Create isolated Git worktrees and group sessions by - repository. +› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for + interactive website debugging and other inline + JavaScript execution capabilities. + [x] Shell tool Allow the model to run shell commands. + [ ] Worktrees Create isolated Git worktrees and group sessions by + repository. + [ ] Voice conversations Talk with Codex using /voice. Press space to select or enter to save diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 9847cc8431..6e538ad0c0 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3039,6 +3039,7 @@ async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let worktrees = Feature::Worktrees.stage(); + let voice = Feature::RealtimeConversation.stage(); let features = vec![ ExperimentalFeatureItem { key: Feature::JsRepl.key().to_string(), @@ -3061,6 +3062,13 @@ async fn experimental_features_popup_snapshot() { description: worktrees.experimental_menu_description().unwrap().to_string(), enabled: false, }, + ExperimentalFeatureItem { + key: Feature::RealtimeConversation.key().to_string(), + writable: true, + name: voice.experimental_menu_name().unwrap().to_string(), + description: voice.experimental_menu_description().unwrap().to_string(), + enabled: false, + }, ]; let view = ExperimentalFeaturesView::new( features, diff --git a/codex-rs/tui/src/snapshots/codex_tui__tooltips__tests__experimental_voice_tooltip.snap b/codex-rs/tui/src/snapshots/codex_tui__tooltips__tests__experimental_voice_tooltip.snap new file mode 100644 index 0000000000..c9a8edd901 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__tooltips__tests__experimental_voice_tooltip.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/tooltips.rs +assertion_line: 382 +expression: "available.into_iter().filter(|tip| *tip ==\nvoice_tip).collect::>().join(\"\\n\")" +--- +NEW: Voice conversations can now be enabled from /experimental. Restart Codex after enabling, then use /voice. diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 9ea7912ac2..6e075404ff 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -1,4 +1,5 @@ use codex_features::FEATURES; +use codex_features::Feature; use codex_protocol::account::PlanType; use lazy_static::lazy_static; use rand::Rng; @@ -36,14 +37,17 @@ lazy_static! { static ref ALL_TOOLTIPS: Vec<&'static str> = { let mut tips = Vec::new(); tips.extend(TOOLTIPS.iter().copied()); - tips.extend(experimental_tooltips()); + tips.extend(experimental_tooltips( + codex_realtime_webrtc::RealtimeWebrtcSession::is_supported, + )); tips }; } -fn experimental_tooltips() -> Vec<&'static str> { +fn experimental_tooltips(voice_supported: impl Fn() -> bool) -> Vec<&'static str> { FEATURES .iter() + .filter(|spec| spec.id != Feature::RealtimeConversation || voice_supported()) .filter_map(|spec| spec.stage.experimental_announcement()) .collect() } @@ -358,6 +362,33 @@ mod tests { use rand::SeedableRng; use rand::rngs::StdRng; + #[test] + fn experimental_voice_tooltip_requires_runtime_support() { + let unavailable = experimental_tooltips(|| false); + let available = experimental_tooltips(|| true); + let voice_tip = FEATURES + .iter() + .find(|spec| spec.id == Feature::RealtimeConversation) + .and_then(|spec| spec.stage.experimental_announcement()) + .expect("voice has an experimental announcement"); + assert_eq!( + unavailable, + available + .iter() + .copied() + .filter(|tip| *tip != voice_tip) + .collect::>() + ); + insta::assert_snapshot!( + "experimental_voice_tooltip", + available + .into_iter() + .filter(|tip| *tip == voice_tip) + .collect::>() + .join("\n") + ); + } + #[test] fn random_tooltip_returns_some_tip_when_available() { let mut rng = StdRng::seed_from_u64(42);