Files
codex/codex-rs/tui/src/experimental_features.rs
T
Eric Traut cac96cd7b1 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
2026-09-03 02:49:36 +00:00

75 lines
3.2 KiB
Rust

//! 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;