From cac96cd7b1756ab42e8925d938817a2ac10ebb6e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 3 Sep 2026 02:14:29 +0000 Subject: [PATCH] Discover TUI experimental features from the server (#42425) ## What changed - Load the `/experimental` catalog from the running server for the active thread, with loading, empty, and failure states in the popup. - Show beta features in server order and preserve server-reported names, descriptions, and configured values. Mark features as read-only when the local config writer cannot safely update them. - Persist only changed, writable feature flags. Bound discovery with page-size and page-count limits, repeated-cursor detection, deduplication, cancellation, and a timeout. ## Testing - Cover paginated discovery, malformed or unresponsive servers, popup cancellation, read-only controls, selective writes, and the new popup states. GitOrigin-RevId: 1f49eb961c8ab220a466e15f032c1dc61ed86d38 --- codex-rs/tui/src/app/event_dispatch.rs | 7 + codex-rs/tui/src/app_event.rs | 8 + .../bottom_pane/experimental_features_view.rs | 154 ++++++++++++++---- .../experimental_features_view_tests.rs | 148 +++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + ...experimental_features_changed_default.snap | 12 ++ ...w__tests__experimental_features_empty.snap | 13 ++ ...xperimental_features_server_discovery.snap | 14 ++ ...ts__experimental_features_unavailable.snap | 14 ++ codex-rs/tui/src/chatwidget.rs | 2 - .../tui/src/chatwidget/settings_popups.rs | 29 ++-- ..._tests__experimental_features_loading.snap | 12 ++ ...t__tests__experimental_features_popup.snap | 5 +- ..._features_popup_configured_key_chords.snap | 5 +- codex-rs/tui/src/chatwidget/tests.rs | 1 - .../chatwidget/tests/popups_and_settings.rs | 49 +++--- ...ttings__experimental_features_startup.snap | 5 + codex-rs/tui/src/experimental_features.rs | 74 +++++++++ .../tui/src/experimental_features_tests.rs | 147 +++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + 20 files changed, 630 insertions(+), 71 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_changed_default.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_empty.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_server_discovery.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_unavailable.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_loading.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__popups_and_settings__experimental_features_startup.snap create mode 100644 codex-rs/tui/src/experimental_features.rs create mode 100644 codex-rs/tui/src/experimental_features_tests.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 57fcbf38cf..b405f0598d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2380,6 +2380,13 @@ impl App { .add_error_message(format!("Failed to save approvals reviewer: {err}")); } } + AppEvent::FetchExperimentalFeatures { thread_id, response_tx } => { + crate::experimental_features::fetch( + app_server.request_handle(), + thread_id, + response_tx, + ); + } AppEvent::UpdateFeatureFlags { updates } => { self.update_feature_flags(app_server, updates).await; } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6ab7269de3..50512ba28b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1148,6 +1148,14 @@ pub(crate) enum AppEvent { /// Update the current approvals reviewer in the running app and widget. UpdateApprovalsReviewer(ApprovalsReviewer), + /// Discover experimental features for the requesting popup only. + FetchExperimentalFeatures { + thread_id: ThreadId, + response_tx: tokio::sync::oneshot::Sender< + Result, String>, + >, + }, + /// Update feature flags and persist them to the top-level config. UpdateFeatureFlags { updates: Vec<(Feature, bool)>, 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 8813d0655d..59224c8750 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -1,3 +1,13 @@ +//! Experimental controls with popup-owned discovery and configured enablement. +//! Only changed, supported controls are submitted to the existing writer. + +use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureStage; +use codex_features::FEATURES; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::oneshot; + use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -33,7 +43,7 @@ use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; pub(crate) struct ExperimentalFeatureItem { - pub feature: Feature, + pub feature: Option, pub name: String, pub description: String, pub enabled: bool, @@ -41,10 +51,12 @@ pub(crate) struct ExperimentalFeatureItem { pub(crate) struct ExperimentalFeaturesView { features: Vec, + initial_enabled: Vec, + catalog_rx: Option, String>>>, + discovery_status: &'static str, state: ScrollState, complete: bool, app_event_tx: AppEventSender, - header: Box, footer_hint: Line<'static>, keymap: ListKeymap, } @@ -52,21 +64,22 @@ pub(crate) struct ExperimentalFeaturesView { impl ExperimentalFeaturesView { pub(crate) fn new( features: Vec, + catalog_rx: Option, String>>>, app_event_tx: AppEventSender, keymap: ListKeymap, ) -> Self { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Experimental features".bold())); - header.push(Line::from( - "Toggle experimental features. Changes are saved to config.toml.".dim(), - )); - let mut view = Self { + discovery_status: if catalog_rx.is_some() { + "Loading server experiments…" + } else { + "" + }, + catalog_rx, + initial_enabled: features.iter().map(|item| item.enabled).collect(), features, state: ScrollState::new(), complete: false, app_event_tx, - header: Box::new(header), footer_hint: experimental_popup_hint_line(&keymap), keymap, }; @@ -74,6 +87,20 @@ impl ExperimentalFeaturesView { view } + fn header(&self, width: u16) -> impl Renderable { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Experimental features".bold())); + for text in [ + "Checked features are configured on. Some experimental features take effect only in new tasks or after restarting the Codex server.", + self.discovery_status, + ].into_iter().filter(|text| !text.is_empty()) { + for line in textwrap::wrap(text, usize::from(width.max(1))) { + header.push(Line::from(line.into_owned().dim())); + } + } + header + } + fn initialize_selection(&mut self) { if self.visible_len() == 0 { self.state.selected_idx = None; @@ -96,10 +123,16 @@ impl ExperimentalFeaturesView { ' ' }; let marker = if item.enabled { 'x' } else { ' ' }; - let name = format!("{prefix} [{marker}] {}", item.name); + let read_only = if item.feature.is_none() { + " (read-only)" + } else { + "" + }; + let name = format!("{prefix} [{marker}] {}{read_only}", item.name); rows.push(GenericDisplayRow { name, description: Some(item.description.clone()), + is_disabled: item.feature.is_none(), ..Default::default() }); } @@ -154,7 +187,9 @@ impl ExperimentalFeaturesView { return; }; - if let Some(item) = self.features.get_mut(selected_idx) { + if let Some(item) = self.features.get_mut(selected_idx) + && item.feature.is_some() + { item.enabled = !item.enabled; } } @@ -165,6 +200,65 @@ impl ExperimentalFeaturesView { } impl BottomPaneView for ExperimentalFeaturesView { + fn pre_draw_tick(&mut self, _now: Instant) -> bool { + let Some(receiver) = self.catalog_rx.as_mut() else { + return false; + }; + let result = match receiver.try_recv() { + Ok(result) => result, + Err(oneshot::error::TryRecvError::Empty) => return false, + Err(oneshot::error::TryRecvError::Closed) => { + Err("Discovery was interrupted".to_string()) + } + }; + self.catalog_rx = None; + match result { + Ok(features) => { + let mut count = 0; + for feature in features { + if feature.stage != ExperimentalFeatureStage::Beta { + continue; + } + // The existing writer uses local defaults and Feature IDs. A new + // server control or a changed default must wait for config-write migration. + let writable = FEATURES + .iter() + .find(|spec| { + spec.key == feature.name + && spec.stage.experimental_menu_name().is_some() + && spec.default_enabled == feature.default_enabled + }) + .map(|spec| spec.id); + self.initial_enabled.push(feature.enabled); + self.features.push(ExperimentalFeatureItem { + feature: writable, + name: feature.display_name.unwrap_or(feature.name), + description: feature.description.unwrap_or_default(), + enabled: feature.enabled, + }); + count += 1; + } + self.discovery_status = if count == 0 { + "No server experiments available." + } else { + "" + }; + self.initialize_selection(); + } + Err(error) => { + tracing::warn!(%error, "experimental feature discovery failed"); + self.discovery_status = "Server experiments unavailable. Reopen /experimental to retry; restart this Codex client if requests remain unanswered."; + } + } + true + } + + fn next_frame_delay(&self) -> Option { + self.catalog_rx + .as_ref() + .map(|_| Duration::from_millis(/*millis*/ 100)) + } + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) } @@ -196,13 +290,14 @@ impl BottomPaneView for ExperimentalFeaturesView { } fn on_ctrl_c(&mut self) -> CancellationEvent { - // Save the updates - if !self.features.is_empty() { - let updates = self - .features - .iter() - .map(|item| (item.feature, item.enabled)) - .collect(); + let updates: Vec<_> = self + .features + .iter() + .zip(&self.initial_enabled) + .filter(|(item, initial)| item.enabled != **initial) + .filter_map(|(item, _)| item.feature.map(|feature| (feature, item.enabled))) + .collect(); + if !updates.is_empty() { self.app_event_tx .send(AppEvent::UpdateFeatureFlags { updates }); } @@ -225,9 +320,8 @@ impl Renderable for ExperimentalFeaturesView { .style(user_message_style()) .render(content_area, buf); - let header_height = self - .header - .desired_height(content_area.width.saturating_sub(4)); + let header = self.header(content_area.width.saturating_sub(4)); + let header_height = header.desired_height(content_area.width.saturating_sub(4)); let rows = self.build_rows(); let rows_width = Self::rows_width(content_area.width); let rows_height = measure_rows_height( @@ -243,9 +337,9 @@ impl Renderable for ExperimentalFeaturesView { ]) .areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2))); - self.header.render(header_area, buf); + header.render(header_area, buf); - if list_area.height > 0 { + if list_area.height > 0 && (!rows.is_empty() || self.catalog_rx.is_none()) { let render_area = Rect { x: list_area.x.saturating_sub(2), y: list_area.y, @@ -281,7 +375,9 @@ impl Renderable for ExperimentalFeaturesView { rows_width.saturating_add(1), ); - let mut height = self.header.desired_height(width.saturating_sub(4)); + let mut height = self + .header(width.saturating_sub(4)) + .desired_height(width.saturating_sub(4)); height = height.saturating_add(rows_height + 3); height.saturating_add(1) } @@ -294,11 +390,11 @@ fn experimental_popup_hint_line(keymap: &ListKeymap) -> Line<'static> { " to select".into(), ]; if let Some(accept) = keymap.primary_hint(ListAction::Accept) { - spans.extend([ - " or ".into(), - accept.into(), - " to save for next conversation".into(), - ]); + spans.extend([" or ".into(), accept.into(), " to save".into()]); } Line::from(spans) } + +#[cfg(test)] +#[path = "experimental_features_view_tests.rs"] +mod tests; 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 new file mode 100644 index 0000000000..d6f0edd9bb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs @@ -0,0 +1,148 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn server_feature(name: &str) -> ExperimentalFeature { + ExperimentalFeature { + name: name.to_string(), + stage: ExperimentalFeatureStage::Beta, + display_name: Some(format!("Server {name}")), + description: Some("Description supplied by the server.".to_string()), + announcement: None, + enabled: true, + default_enabled: false, + } +} + +#[test] +fn experimental_features_discovery_preserves_server_order_and_limits_writes() { + let (app_tx, mut app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let mut view = ExperimentalFeaturesView::new( + Vec::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + view.toggle_selected(); + let mut stable = server_feature("stable_feature"); + stable.stage = ExperimentalFeatureStage::Stable; + catalog_tx + .send(Ok(vec![ + server_feature("network_proxy"), + server_feature("future_feature"), + stable, + server_feature("prevent_idle_sleep"), + ])) + .unwrap(); + assert!(view.pre_draw_tick(Instant::now())); + assert_eq!( + view.features + .iter() + .map(|item| (item.feature, item.enabled)) + .collect::>(), + vec![ + (Some(Feature::NetworkProxy), true), + (None, true), + (Some(Feature::PreventIdleSleep), true), + ] + ); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 100, + view.desired_height(/*width*/ 100), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + insta::assert_snapshot!( + "experimental_features_server_discovery", + buffer_text(&buffer) + ); + // Read-only entries retain their configured checkmark when Space is pressed. + view.state.selected_idx = Some(1); + view.toggle_selected(); + assert!(view.features[1].enabled); + view.state.selected_idx = Some(0); + view.toggle_selected(); + view.state.selected_idx = Some(2); + view.toggle_selected(); + view.on_ctrl_c(); + let AppEvent::UpdateFeatureFlags { updates } = app_rx.try_recv().unwrap() else { + panic!("expected feature edits"); + }; + assert_eq!( + updates, + vec![ + (Feature::NetworkProxy, false), + (Feature::PreventIdleSleep, false) + ] + ); +} + +#[test] +fn experimental_features_empty_error_and_cancel_remain_usable() { + let mut changed_default = server_feature("network_proxy"); + changed_default.default_enabled = true; + for (snapshot, result) in [ + ("experimental_features_empty", Ok(Vec::new())), + ( + "experimental_features_unavailable", + Err("method not found".to_string()), + ), + ( + "experimental_features_changed_default", + Ok(vec![changed_default]), + ), + ] { + let (app_tx, mut app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let mut view = ExperimentalFeaturesView::new( + Vec::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + assert!(view.next_frame_delay().is_some()); + catalog_tx.send(result).unwrap(); + assert!(view.pre_draw_tick(Instant::now())); + assert_eq!(view.next_frame_delay(), None); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 80, + view.desired_height(/*width*/ 80), + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer); + insta::assert_snapshot!(snapshot, buffer_text(&buffer)); + view.toggle_selected(); + view.on_ctrl_c(); + assert!(view.is_complete()); + assert!(app_rx.try_recv().is_err()); + } + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let (catalog_tx, catalog_rx) = oneshot::channel(); + let view = ExperimentalFeaturesView::new( + Vec::new(), + Some(catalog_rx), + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + drop(view); + assert!(catalog_tx.send(Ok(Vec::new())).is_err()); +} + +fn buffer_text(buffer: &Buffer) -> String { + buffer + .content + .chunks(usize::from(buffer.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 66481782b9..5921a0c833 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -209,6 +209,7 @@ pub(crate) use chat_composer_history::HistoryEntry; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; +#[cfg(test)] pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SELECTION_TOGGLE_BLOCKED_PREFIX; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_changed_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_changed_default.snap new file mode 100644 index 0000000000..9519153a8c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_changed_default.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +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 network_proxy (read-only) 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__experimental_features_empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_empty.snap new file mode 100644 index 0000000000..f3d09a6abd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_empty.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +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/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_server_discovery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_server_discovery.snap new file mode 100644 index 0000000000..0c200d1d5e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_server_discovery.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +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 network_proxy Description supplied by the server. + [x] Server future_feature (read-only) Description supplied by the server. + [x] Server prevent_idle_sleep 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__experimental_features_unavailable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_unavailable.snap new file mode 100644 index 0000000000..fb446fea9b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_unavailable.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +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. + Server experiments unavailable. Reopen /experimental to retry; restart this + Codex client if requests remain unanswered. + + No experimental features available for now + + Press space to select or enter to save diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 710be1b8d0..dd3a23f24f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -122,7 +122,6 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; use codex_config::types::WindowsSandboxModeToml; use codex_connectors::AppInfo; -use codex_features::FEATURES; use codex_features::Feature; #[cfg(test)] use codex_git_utils::CommitLogEntry; @@ -279,7 +278,6 @@ use crate::bottom_pane::CollaborationModeIndicator; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExecApprovalRequest; -use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::GoalStatusIndicator; use crate::bottom_pane::HistoryEntry; diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index 6ea0047d15..293e4bac9a 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -87,22 +87,21 @@ impl ChatWidget { } pub(crate) fn open_experimental_popup(&mut self) { - let features: Vec = FEATURES - .iter() - .filter_map(|spec| { - let name = spec.stage.experimental_menu_name()?; - let description = spec.stage.experimental_menu_description()?; - Some(ExperimentalFeatureItem { - feature: spec.id, - name: name.to_string(), - description: description.to_string(), - enabled: self.config.features.enabled(spec.id), - }) - }) - .collect(); - + let Some(thread_id) = self.thread_id() else { + self.add_info_message( + "Experimental features are unavailable until startup completes.".to_string(), + /*hint*/ None, + ); + return; + }; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + self.app_event_tx.send(AppEvent::FetchExperimentalFeatures { + thread_id, + response_tx, + }); let view = ExperimentalFeaturesView::new( - features, + Vec::new(), + Some(response_rx), self.app_event_tx.clone(), self.bottom_pane.list_keymap(), ); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_loading.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_loading.snap new file mode 100644 index 0000000000..f787758e0f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_loading.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + Loading server experiments… + + + + 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 c5211f6943..6b2bb50133 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 @@ -3,11 +3,12 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Experimental features - Toggle experimental features. Changes are saved to config.toml. + 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. - Press space to select or enter to save for next conversation + Press space to select or enter to save diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup_configured_key_chords.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup_configured_key_chords.snap index e3cfbd477a..667b48d69d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup_configured_key_chords.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup_configured_key_chords.snap @@ -3,8 +3,9 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Experimental features - Toggle experimental features. Changes are saved to config.toml. + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. › [x] Shell tool Allow the model to run shell commands. - Press space to select or ctrl + x enter to save for next conversation + Press space to select or ctrl + x enter to save diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d440077dc6..dfc3f3c8c1 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -122,7 +122,6 @@ pub(super) use codex_config::types::ApprovalsReviewer; pub(super) use codex_config::types::Notifications; pub(super) use codex_config::types::WindowsSandboxModeToml; pub(super) use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; -pub(super) use codex_features::FEATURES; pub(super) use codex_features::Feature; pub(super) use codex_git_utils::CommitLogEntry; pub(super) use codex_models_manager::test_support::construct_model_info_offline_for_tests; 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 7d8d9db5c8..f2257c38c6 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,5 +1,6 @@ use super::*; use crate::app_event::ConnectorsSnapshot; +use crate::bottom_pane::ExperimentalFeatureItem; use crate::chatwidget::connectors::ConnectorsCacheState; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; @@ -11,7 +12,6 @@ use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSource; use codex_connectors::AppInfo; -use codex_features::Stage; use pretty_assertions::assert_eq; #[tokio::test] @@ -3000,13 +3000,13 @@ async fn experimental_features_popup_snapshot() { let features = vec![ ExperimentalFeatureItem { - feature: Feature::JsRepl, + feature: Some(Feature::JsRepl), name: "JavaScript REPL".to_string(), description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(), enabled: false, }, ExperimentalFeatureItem { - feature: Feature::ShellTool, + feature: Some(Feature::ShellTool), name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), enabled: true, @@ -3014,6 +3014,7 @@ async fn experimental_features_popup_snapshot() { ]; let view = ExperimentalFeaturesView::new( features, + /*catalog_rx*/ None, chat.app_event_tx.clone(), crate::keymap::RuntimeKeymap::defaults().list, ); @@ -3030,11 +3031,12 @@ async fn experimental_features_popup_snapshot() { .expect("valid experimental-feature chord"); let view = ExperimentalFeaturesView::new( vec![ExperimentalFeatureItem { - feature: Feature::ShellTool, + feature: Some(Feature::ShellTool), name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), enabled: true, }], + /*catalog_rx*/ None, chat.app_event_tx.clone(), keymap.list, ); @@ -3050,11 +3052,12 @@ async fn experimental_features_toggle_saves_on_exit() { let expected_feature = Feature::JsRepl; let view = ExperimentalFeaturesView::new( vec![ExperimentalFeatureItem { - feature: expected_feature, + feature: Some(expected_feature), name: "JavaScript REPL".to_string(), description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(), enabled: false, }], + /*catalog_rx*/ None, chat.app_event_tx.clone(), crate::keymap::RuntimeKeymap::defaults().list, ); @@ -3085,23 +3088,29 @@ async fn experimental_features_toggle_saves_on_exit() { } #[tokio::test] -async fn experimental_popup_omits_stable_guardian_approval() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let guardian_stage = FEATURES - .iter() - .find(|spec| spec.id == Feature::GuardianApproval) - .map(|spec| spec.stage) - .expect("expected guardian approval feature metadata"); - - assert_eq!(guardian_stage, Stage::Stable); - +async fn experimental_popup_loading_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.open_experimental_popup(); - - let popup = render_bottom_popup(&chat, /*width*/ 120); - assert!( - !popup.contains("Auto-review"), - "expected stable auto-review feature to be omitted from experimental popup, got:\n{popup}" + assert!(!chat.has_active_view()); + let cell = assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(cell)) => cell); + insta::assert_snapshot!( + "experimental_features_startup", + lines_to_single_string(&cell.display_lines(/*width*/ 80)) ); + assert!(rx.try_recv().is_err()); + chat.thread_id = Some(ThreadId::new()); + chat.open_experimental_popup(); + let AppEvent::FetchExperimentalFeatures { response_tx, .. } = rx.try_recv().unwrap() else { + panic!("expected experimental discovery request"); + }; + assert_chatwidget_snapshot!( + "experimental_features_loading", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(response_tx.send(Ok(Vec::new())).is_err()); + assert!(!chat.has_active_view()); + assert!(rx.try_recv().is_err()); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__popups_and_settings__experimental_features_startup.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__popups_and_settings__experimental_features_startup.snap new file mode 100644 index 0000000000..e33b50cf9f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__popups_and_settings__experimental_features_startup.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: lines_to_single_string(&cell.display_lines(80)) +--- +• Experimental features are unavailable until startup completes. diff --git a/codex-rs/tui/src/experimental_features.rs b/codex-rs/tui/src/experimental_features.rs new file mode 100644 index 0000000000..28a92b58c0 --- /dev/null +++ b/codex-rs/tui/src/experimental_features.rs @@ -0,0 +1,74 @@ +//! Bounded, popup-scoped experimental-feature discovery through the existing RPC. +//! Dropping a popup stops pagination. One reserved request ID bounds abandoned +//! requests until the server replies or the connection closes. + +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureListParams; +use codex_app_server_protocol::ExperimentalFeatureListResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::ThreadId; +use std::collections::HashSet; +use std::time::Duration; +use tokio::sync::oneshot; + +pub(crate) fn fetch( + request_handle: AppServerRequestHandle, + thread_id: ThreadId, + mut response_tx: oneshot::Sender, String>>, +) { + tokio::spawn(async move { + let discovery = async { + let mut features = Vec::new(); + let mut cursor = None; + let mut cursors = HashSet::new(); + let mut names = HashSet::new(); + for _ in 0..10 { + let response = request_handle + .request_typed::( + ClientRequest::ExperimentalFeatureList { + // The client rejects duplicate pending IDs, bounding unanswered + // requests across popup cancellation and timeout/retry cycles. + request_id: RequestId::String("tui-experimental-features".to_string()), + params: ExperimentalFeatureListParams { + cursor, + limit: Some(100), + thread_id: Some(thread_id.to_string()), + }, + }, + ) + .await + .map_err(|_| "Experimental feature request failed".to_string())?; + if response.data.len() > 100 { + return Err("Experimental feature page exceeds requested limit".to_string()); + } + features.extend( + response + .data + .into_iter() + .filter(|feature| names.insert(feature.name.clone())), + ); + cursor = response.next_cursor; + let Some(next) = cursor.as_ref() else { + return Ok(features); + }; + if !cursors.insert(next.clone()) { + return Err("Experimental feature pagination repeated a cursor".to_string()); + } + } + Err("Experimental feature discovery exceeded 10 pages".to_string()) + }; + tokio::select! { + _ = response_tx.closed() => {}, + result = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), discovery) => { + let result = result.unwrap_or_else(|_| Err("Experimental feature discovery timed out".to_string())); + let _ = response_tx.send(result); + } + } + }); +} + +#[cfg(test)] +#[path = "experimental_features_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/experimental_features_tests.rs b/codex-rs/tui/src/experimental_features_tests.rs new file mode 100644 index 0000000000..3eb1e5f4cd --- /dev/null +++ b/codex-rs/tui/src/experimental_features_tests.rs @@ -0,0 +1,147 @@ +use super::*; +use codex_app_server_client::RemoteAppServerClient; +use codex_app_server_client::RemoteAppServerConnectArgs; +use codex_app_server_client::RemoteAppServerEndpoint; +use codex_app_server_protocol::JSONRPCMessage; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn experimental_features_rpc_paginates_thread_config_and_bounds_bad_servers() { + for scenario in ["pages", "cycle", "limit", "error", "timeout"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let thread_id = ThreadId::new(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let mut page = 0; + while let Some(Ok(Message::Text(text))) = socket.next().await { + let JSONRPCMessage::Request(request) = serde_json::from_str(&text).unwrap() else { + continue; + }; + let result = if request.method == "initialize" { + json!({"userAgent": "experimental-test"}) + } else { + assert_eq!(request.method, "experimentalFeature/list"); + assert_eq!( + request.params, + Some(json!({ + "cursor": if page == 0 { None } else { Some(page.to_string()) }, + "limit": 100, "threadId": thread_id.to_string() + })) + ); + page += 1; + if scenario == "timeout" { + // Leave the connection open without answering discovery. + continue; + } + if scenario == "error" { + socket.send(Message::Text(json!({"id": request.id, "error": {"code": -32601, "message": "method not found", "data": "private wire content"}}).to_string().into())).await.unwrap(); + continue; + } + json!({"data": [{ + "name": format!("feature-{page}"), "stage": "beta", "displayName": "Server experiment", + "description": "Server description", "announcement": null, + "enabled": true, "defaultEnabled": false + }], "nextCursor": match scenario { + "pages" if page == 2 => None, + "cycle" => Some("1".to_string()), + _ => Some(page.to_string()), + }}) + }; + socket + .send(Message::Text( + json!({"id": request.id, "result": result}) + .to_string() + .into(), + )) + .await + .unwrap(); + } + page + }); + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url: url, + auth_token: None, + }, + client_name: "experimental-test".to_string(), + client_version: "0.0.0".to_string(), + experimental_api: true, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, + }) + .await + .unwrap(); + let (tx, rx) = oneshot::channel(); + fetch( + AppServerRequestHandle::Remote(client.request_handle()), + thread_id, + tx, + ); + let result = tokio::time::timeout(Duration::from_secs(/*secs*/ 10), rx) + .await + .unwrap() + .unwrap(); + if scenario == "pages" { + let features = result.unwrap(); + assert_eq!( + features, + ["feature-1", "feature-2"] + .into_iter() + .map(|name| ExperimentalFeature { + name: name.to_string(), + stage: codex_app_server_protocol::ExperimentalFeatureStage::Beta, + display_name: Some("Server experiment".to_string()), + description: Some("Server description".to_string()), + announcement: None, + enabled: true, + default_enabled: false, + }) + .collect::>() + ); + } else { + let error = result.unwrap_err(); + assert_eq!( + error, + match scenario { + "cycle" => "Experimental feature pagination repeated a cursor", + "limit" => "Experimental feature discovery exceeded 10 pages", + "error" => "Experimental feature request failed", + "timeout" => "Experimental feature discovery timed out", + _ => unreachable!(), + } + ); + } + if scenario == "timeout" { + // Reopening while the abandoned request is still unanswered cannot + // accumulate more pending requests or forward more calls to the server. + for _ in 0..3 { + let (tx, rx) = oneshot::channel(); + fetch( + AppServerRequestHandle::Remote(client.request_handle()), + thread_id, + tx, + ); + assert_eq!( + rx.await.unwrap(), + Err("Experimental feature request failed".to_string()) + ); + } + } + client.shutdown().await.unwrap(); + assert_eq!( + server.await.unwrap(), + match scenario { + "pages" | "cycle" => 2, + "limit" => 10, + _ => 1, + } + ); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e604e745d5..1a14ef58bc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -119,6 +119,7 @@ mod collaboration_modes; mod color; mod config_update; pub(crate) mod custom_terminal; +mod experimental_features; mod pets; pub use custom_terminal::Terminal; mod auto_review_denials;