mirror of
https://github.com/openai/codex.git
synced 2026-10-21 14:23:14 +00:00
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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Vec<codex_app_server_protocol::ExperimentalFeature>, String>,
|
||||
>,
|
||||
},
|
||||
|
||||
/// Update feature flags and persist them to the top-level config.
|
||||
UpdateFeatureFlags {
|
||||
updates: Vec<(Feature, bool)>,
|
||||
|
||||
@@ -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<Feature>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
@@ -41,10 +51,12 @@ pub(crate) struct ExperimentalFeatureItem {
|
||||
|
||||
pub(crate) struct ExperimentalFeaturesView {
|
||||
features: Vec<ExperimentalFeatureItem>,
|
||||
initial_enabled: Vec<bool>,
|
||||
catalog_rx: Option<oneshot::Receiver<Result<Vec<ExperimentalFeature>, String>>>,
|
||||
discovery_status: &'static str,
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
header: Box<dyn Renderable>,
|
||||
footer_hint: Line<'static>,
|
||||
keymap: ListKeymap,
|
||||
}
|
||||
@@ -52,21 +64,22 @@ pub(crate) struct ExperimentalFeaturesView {
|
||||
impl ExperimentalFeaturesView {
|
||||
pub(crate) fn new(
|
||||
features: Vec<ExperimentalFeatureItem>,
|
||||
catalog_rx: Option<oneshot::Receiver<Result<Vec<ExperimentalFeature>, 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<Duration> {
|
||||
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;
|
||||
|
||||
@@ -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<_>>(),
|
||||
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::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+12
@@ -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
|
||||
+13
@@ -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
|
||||
+14
@@ -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
|
||||
+14
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -87,22 +87,21 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn open_experimental_popup(&mut self) {
|
||||
let features: Vec<ExperimentalFeatureItem> = 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(),
|
||||
);
|
||||
|
||||
+12
@@ -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
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
+5
@@ -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.
|
||||
@@ -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<Result<Vec<ExperimentalFeature>, 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::<ExperimentalFeatureListResponse>(
|
||||
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;
|
||||
@@ -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::<Vec<_>>()
|
||||
);
|
||||
} 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user