From a0124597d7353b5ec5e886b0c1cfc2a7ea85fbc2 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 4 May 2026 15:23:37 -0700 Subject: [PATCH] Clean up experimental feature popup backend wiring --- .../schema/json/ClientRequest.json | 16 ++- .../codex_app_server_protocol.schemas.json | 16 ++- .../codex_app_server_protocol.v2.schemas.json | 16 ++- .../v2/ExperimentalFeatureListParams.json | 16 ++- .../v2/ExperimentalFeatureListParams.ts | 10 +- .../src/protocol/common.rs | 4 +- .../app-server-protocol/src/protocol/v2.rs | 6 + codex-rs/app-server/README.md | 4 +- .../app-server/src/config_manager_service.rs | 62 ++++++++++- .../src/config_manager_service_tests.rs | 13 +++ .../request_processors/catalog_processor.rs | 24 +++- .../request_processors/config_processor.rs | 28 ++++- .../suite/v2/experimental_feature_list.rs | 31 +++++- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/config_persistence.rs | 102 +++++++++++++++++ codex-rs/tui/src/app/event_dispatch.rs | 11 +- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/app_server_session.rs | 105 ++++++++++++++++++ .../bottom_pane/experimental_features_view.rs | 38 +++++-- codex-rs/tui/src/chatwidget.rs | 17 +-- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 3 +- codex-rs/tui/src/chatwidget/tests.rs | 1 - .../chatwidget/tests/popups_and_settings.rs | 65 ++++++++--- 23 files changed, 525 insertions(+), 67 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index fdd4c155b0..01970245a8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -785,6 +785,20 @@ "integer", "null" ] + }, + "cwd": { + "description": "Optional working directory used to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "profile": { + "description": "Optional config profile used to compute effective feature enablement.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -6327,4 +6341,4 @@ } ], "title": "ClientRequest" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6c2ad69e1b..b7734ef83c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8363,6 +8363,20 @@ "integer", "null" ] + }, + "cwd": { + "description": "Optional working directory used to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "profile": { + "description": "Optional config profile used to compute effective feature enablement.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", @@ -18261,4 +18275,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 150b221adf..8f8fa3a49c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4861,6 +4861,20 @@ "integer", "null" ] + }, + "cwd": { + "description": "Optional working directory used to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "profile": { + "description": "Optional config profile used to compute effective feature enablement.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", @@ -16146,4 +16160,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json index ab562edbf2..2742dbd29a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json @@ -16,8 +16,22 @@ "integer", "null" ] + }, + "cwd": { + "description": "Optional working directory used to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "profile": { + "description": "Optional config profile used to compute effective feature enablement.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts index 0a1555d998..9b0f9fa317 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts @@ -10,4 +10,12 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ -limit?: number | null, }; +limit?: number | null, +/** + * Optional working directory used to resolve project config layers. + */ +cwd?: string | null, +/** + * Optional config profile used to compute effective feature enablement. + */ +profile?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c5a7d61f01..ff0e83a2e1 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2528,7 +2528,9 @@ mod tests { "id": 8, "params": { "cursor": null, - "limit": null + "limit": null, + "cwd": null, + "profile": null } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index aea80029a8..0a61af2f84 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2611,6 +2611,12 @@ pub struct ExperimentalFeatureListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// Optional working directory used to resolve project config layers. + #[ts(optional = nullable)] + pub cwd: Option, + /// Optional config profile used to compute effective feature enablement. + #[ts(optional = nullable)] + pub profile: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index df41d859cd..21cf739194 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -193,8 +193,8 @@ Example with notification opt-out: - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. -- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, optional cwd/profile-scoped enablement, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for beta features returned by `experimentalFeature/list`, plus the additional capability toggles `apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`, and `workspace_dependencies`. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `hooks/list` — list discovered hooks for one or more `cwd` values. diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index aef4393a09..0318c67a9d 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -401,10 +401,64 @@ fn parse_key_path(path: &str) -> Result, String> { if path.trim().is_empty() { return Err("keyPath must not be empty".to_string()); } - Ok(path - .split('.') - .map(std::string::ToString::to_string) - .collect()) + + let mut segments = Vec::new(); + let mut current = String::new(); + let mut chars = path.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '.' => segments.push(std::mem::take(&mut current)), + '"' if current.is_empty() => { + parse_quoted_key_path_segment(&mut chars, &mut current)?; + if let Some(next) = chars.peek() + && *next != '.' + { + return Err( + "quoted keyPath segment must be followed by `.` or end of path".to_string(), + ); + } + } + ch => current.push(ch), + } + } + segments.push(current); + Ok(segments) +} + +fn parse_quoted_key_path_segment( + chars: &mut std::iter::Peekable, + output: &mut String, +) -> Result<(), String> +where + I: Iterator, +{ + while let Some(ch) = chars.next() { + match ch { + '"' => return Ok(()), + '\\' => output.push(parse_key_path_escape(chars)?), + ch => output.push(ch), + } + } + Err("unterminated quoted keyPath segment".to_string()) +} + +fn parse_key_path_escape(chars: &mut std::iter::Peekable) -> Result +where + I: Iterator, +{ + match chars.next() { + Some('"') => Ok('"'), + Some('\\') => Ok('\\'), + Some('n') => Ok('\n'), + Some('r') => Ok('\r'), + Some('t') => Ok('\t'), + Some('b') => Ok('\u{08}'), + Some('f') => Ok('\u{0c}'), + Some(other) => Err(format!( + "unsupported escape in quoted keyPath segment: \\{other}" + )), + None => Err("unterminated escape in quoted keyPath segment".to_string()), + } } #[derive(Debug)] diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index e9b0b3c769..60d992dbfe 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -88,6 +88,16 @@ unified_exec = true }) .await .expect("write succeeds"); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "profiles.\"team.prod\".features.goals".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("quoted profile write succeeds"); let updated = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"# Codex user configuration @@ -101,6 +111,9 @@ hide_full_access_warning = true [features] unified_exec = true personality = true + +[profiles."team.prod".features] +goals = true "#; assert_eq!(updated, expected); Ok(()) diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index c2876514a5..dea7135d71 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -284,8 +284,28 @@ impl CatalogRequestProcessor { &self, params: ExperimentalFeatureListParams, ) -> Result { - let ExperimentalFeatureListParams { cursor, limit } = params; - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let ExperimentalFeatureListParams { + cursor, + limit, + cwd, + profile, + } = params; + let request_overrides = profile.map(|profile| { + HashMap::from([("profile".to_string(), serde_json::Value::String(profile))]) + }); + let config = self + .config_manager + .load_for_cwd( + request_overrides, + ConfigOverrides::default(), + cwd.map(PathBuf::from), + ) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to reload config: {err}"), + data: None, + })?; let auth = self.auth_manager.auth().await; let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 8043675e0d..0f89117013 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -40,7 +40,9 @@ use codex_config::MatcherGroup as CoreMatcherGroup; use codex_config::ResidencyRequirement as CoreResidencyRequirement; use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; +use codex_features::FEATURES; use codex_features::Feature; +use codex_features::Stage; use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; use codex_login::AuthManager; @@ -51,7 +53,7 @@ use codex_protocol::protocol::Op; use serde_json::json; use std::path::PathBuf; -const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ +const ALWAYS_SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "apps", "memories", "plugins", @@ -59,8 +61,26 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "tool_search", "tool_suggest", "tool_call_mcp_elicitation", + "workspace_dependencies", ]; +fn supported_experimental_feature_enablement_keys() -> Vec<&'static str> { + let mut keys = ALWAYS_SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.to_vec(); + for spec in FEATURES { + if matches!(spec.stage, Stage::Experimental { .. }) && !keys.contains(&spec.key) { + keys.push(spec.key); + } + } + keys +} + +fn experimental_feature_enablement_supported(key: &str) -> bool { + ALWAYS_SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key) + || FEATURES + .iter() + .any(|spec| spec.key == key && matches!(spec.stage, Stage::Experimental { .. })) +} + #[derive(Clone)] pub(crate) struct ConfigRequestProcessor { outgoing: Arc, @@ -97,7 +117,7 @@ impl ConfigRequestProcessor { let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); let mut response = self.config_manager.read(params).await.map_err(map_error)?; let config = self.load_latest_config(fallback_cwd).await?; - for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { + for feature_key in supported_experimental_feature_enablement_keys() { let Some(feature) = feature_for_key(feature_key) else { continue; }; @@ -339,13 +359,13 @@ impl ConfigRequestProcessor { let ExperimentalFeatureEnablementSetParams { enablement } = params; for key in enablement.keys() { if canonical_feature_for_key(key).is_some() { - if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { + if experimental_feature_enablement_supported(key) { continue; } return Err(invalid_request(format!( "unsupported feature enablement `{key}`: currently supported features are {}", - SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + supported_experimental_feature_enablement_keys().join(", ") ))); } diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index a186485df6..1468fe15de 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -27,6 +27,7 @@ use serde_json::json; use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; +use toml::Value as TomlValue; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -39,8 +40,16 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); #[tokio::test] async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Result<()> { let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\ngoals = false\n\n[profiles.dev.features]\ngoals = true\n", + )?; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![( + "profile".to_string(), + TomlValue::String("dev".to_string()), + )]) .fallback_cwd(Some(codex_home.path().to_path_buf())) .loader_overrides(LoaderOverrides::with_managed_config_path_for_tests( codex_home.path().join("managed_config.toml"), @@ -52,7 +61,10 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) + .send_experimental_feature_list_request(ExperimentalFeatureListParams { + profile: Some("dev".to_string()), + ..Default::default() + }) .await?; let actual = read_response::(&mut mcp, request_id).await?; @@ -239,6 +251,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("goals".to_string(), true), ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), @@ -252,6 +265,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re enablement: BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("goals".to_string(), true), ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), @@ -282,6 +296,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("goals")), + Some(&json!(true)) + ); assert_eq!( config .additional @@ -308,12 +329,14 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re } #[tokio::test] -async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> { +async fn experimental_feature_enablement_set_allows_electron_app_runtime_toggles() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let remote_control_enabled = false; - let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]); + let enablement = BTreeMap::from([ + ("remote_control".to_string(), false), + ("workspace_dependencies".to_string(), true), + ]); let actual = set_experimental_feature_enablement(&mut mcp, enablement.clone()).await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 66c5dfd184..8ea71701e1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -18,6 +18,7 @@ use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::app_server_rate_limit_snapshots; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; use crate::bottom_pane::SelectionItem; diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 69eba70cf9..5b50799478 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -87,6 +87,108 @@ impl App { self.chat_widget.set_approvals_reviewer(reviewer); } + pub(super) async fn open_experimental_popup_with_app_server( + &mut self, + app_server: &mut AppServerSession, + ) { + let cwd = if app_server.is_remote() { + app_server.remote_cwd_override().map(Path::to_path_buf) + } else { + Some(self.chat_widget.config_ref().cwd.to_path_buf()) + }; + match app_server + .experimental_feature_list(cwd.as_deref(), self.active_profile.as_deref()) + .await + { + Ok(response) => { + let features = response + .data + .into_iter() + .filter_map(ExperimentalFeatureItem::from_api) + .collect(); + self.chat_widget.show_experimental_popup(features); + } + Err(err) => { + tracing::error!(error = %err, "failed to load experimental features"); + self.chat_widget + .add_error_message(format!("Failed to load experimental features: {err}")); + } + } + } + + pub(super) async fn update_feature_flags_with_app_server( + &mut self, + app_server: &mut AppServerSession, + updates: Vec<(Feature, bool)>, + ) { + if updates.is_empty() { + return; + } + + let memory_tool_was_enabled = self.config.features.enabled(Feature::MemoryTool); + let requested_keys = updates + .iter() + .map(|(feature, _)| feature.key().to_string()) + .collect::>(); + + if let Err(err) = app_server + .write_feature_flags(&updates, self.active_profile.as_deref()) + .await + { + tracing::error!(error = %err, "failed to persist remote feature flags"); + self.chat_widget + .add_error_message(format!("Failed to update experimental features: {err}")); + return; + } + + let cwd = if app_server.is_remote() { + app_server.remote_cwd_override().map(Path::to_path_buf) + } else { + Some(self.chat_widget.config_ref().cwd.to_path_buf()) + }; + let response = match app_server + .experimental_feature_list(cwd.as_deref(), self.active_profile.as_deref()) + .await + { + Ok(response) => response, + Err(err) => { + tracing::error!(error = %err, "failed to refresh remote feature flags"); + self.chat_widget.add_error_message(format!( + "Updated experimental features, but failed to refresh local state: {err}" + )); + return; + } + }; + + let mut memory_tool_is_enabled = memory_tool_was_enabled; + for feature in response.data { + if !requested_keys.contains(&feature.name) { + continue; + } + let Some(core_feature) = codex_features::feature_for_key(&feature.name) else { + continue; + }; + + let enabled = self + .chat_widget + .set_feature_enabled(core_feature, feature.enabled); + if let Err(err) = self.config.features.set_enabled(core_feature, enabled) { + tracing::warn!( + error = %err, + feature = feature.name, + "failed to sync constrained app feature state from remote app-server" + ); + } + if core_feature == Feature::MemoryTool { + memory_tool_is_enabled = enabled; + } + } + + if memory_tool_is_enabled && !memory_tool_was_enabled { + self.chat_widget.add_memories_enable_notice(); + } + } + pub(super) fn try_set_approval_policy_on_config( &mut self, config: &mut Config, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index aefab7a1b3..3e665a80cd 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -669,6 +669,10 @@ impl App { AppEvent::ClearThreadGoal { thread_id } => { self.clear_thread_goal(app_server, thread_id).await; } + AppEvent::OpenExperimentalFeaturesPopup => { + self.open_experimental_popup_with_app_server(app_server) + .await; + } AppEvent::SendAddCreditsNudgeEmail { credit_type } => { if self .chat_widget @@ -1458,7 +1462,12 @@ impl App { } } AppEvent::UpdateFeatureFlags { updates } => { - self.update_feature_flags(updates).await; + if self.remote_app_server_url.is_some() { + self.update_feature_flags_with_app_server(app_server, updates) + .await; + } else { + self.update_feature_flags(updates).await; + } } AppEvent::UpdateMemorySettings { use_memories, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b9e4380003..2f80ae0e3e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -240,6 +240,9 @@ pub(crate) enum AppEvent { thread_id: ThreadId, }, + /// Load the experimental-features popup from the app-server catalog. + OpenExperimentalFeaturesPopup, + /// Result of refreshing rate limits. RateLimitsLoaded { origin: RateLimitRefreshOrigin, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 449da8e212..2a3ec91cd0 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -21,7 +21,10 @@ use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit as AppServerConfigEdit; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExperimentalFeatureListParams; +use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportParams; @@ -33,6 +36,7 @@ use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MemoryResetResponse; +use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; @@ -102,6 +106,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_features::Feature; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; @@ -887,6 +892,68 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn write_feature_flags( + &mut self, + updates: &[(Feature, bool)], + active_profile: Option<&str>, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ConfigBatchWrite { + request_id, + params: ConfigBatchWriteParams { + edits: updates + .iter() + .map(|(feature, enabled)| AppServerConfigEdit { + key_path: feature_config_key_path(*feature, active_profile), + value: serde_json::json!(enabled), + merge_strategy: MergeStrategy::Upsert, + }) + .collect(), + file_path: None, + expected_version: None, + reload_user_config: true, + }, + }) + .await + .wrap_err("config/batchWrite failed while updating feature flags in TUI") + } + + pub(crate) async fn experimental_feature_list( + &mut self, + cwd: Option<&std::path::Path>, + active_profile: Option<&str>, + ) -> Result { + let mut cursor = None; + let mut data = Vec::new(); + loop { + let request_id = self.next_request_id(); + let response: ExperimentalFeatureListResponse = self + .client + .request_typed(ClientRequest::ExperimentalFeatureList { + request_id, + params: ExperimentalFeatureListParams { + cursor, + limit: Some(100), + cwd: cwd.map(|cwd| cwd.to_string_lossy().to_string()), + profile: active_profile.map(ToOwned::to_owned), + }, + }) + .await + .wrap_err("experimentalFeature/list failed in TUI")?; + data.extend(response.data); + match response.next_cursor { + Some(next_cursor) => cursor = Some(next_cursor), + None => { + return Ok(ExperimentalFeatureListResponse { + data, + next_cursor: None, + }); + } + } + } + } + pub(crate) async fn thread_realtime_start( &mut self, thread_id: ThreadId, @@ -969,6 +1036,44 @@ impl AppServerSession { } } +fn feature_config_key_path(feature: Feature, active_profile: Option<&str>) -> String { + match active_profile { + Some(profile) => format!( + "profiles.{}.features.{}", + key_path_segment(profile), + feature.key() + ), + None => format!("features.{}", feature.key()), + } +} + +fn key_path_segment(segment: &str) -> String { + if !segment.is_empty() + && segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return segment.to_string(); + } + + let mut quoted = String::with_capacity(segment.len() + 2); + quoted.push('"'); + for ch in segment.chars() { + match ch { + '"' => quoted.push_str("\\\""), + '\\' => quoted.push_str("\\\\"), + '\n' => quoted.push_str("\\n"), + '\r' => quoted.push_str("\\r"), + '\t' => quoted.push_str("\\t"), + '\u{08}' => quoted.push_str("\\b"), + '\u{0c}' => quoted.push_str("\\f"), + ch => quoted.push(ch), + } + } + quoted.push('"'); + quoted +} + fn thread_realtime_start_params( thread_id: ThreadId, transport: Option, 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 c36d70c9fb..08a975c2ed 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,5 @@ +use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureStage; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -20,6 +22,7 @@ use crate::render::renderable::Renderable; use crate::style::user_message_style; use codex_features::Feature; +use codex_features::feature_for_key; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -31,11 +34,28 @@ use super::selection_popup_common::render_rows; pub(crate) struct ExperimentalFeatureItem { pub feature: Feature, - pub name: String, + pub display_name: String, pub description: String, + pub original_enabled: bool, pub enabled: bool, } +impl ExperimentalFeatureItem { + pub(crate) fn from_api(feature: ExperimentalFeature) -> Option { + if feature.stage != ExperimentalFeatureStage::Beta { + return None; + } + let core_feature = feature_for_key(&feature.name)?; + Some(Self { + feature: core_feature, + display_name: feature.display_name?, + description: feature.description?, + original_enabled: feature.enabled, + enabled: feature.enabled, + }) + } +} + pub(crate) struct ExperimentalFeaturesView { features: Vec, state: ScrollState, @@ -90,7 +110,7 @@ impl ExperimentalFeaturesView { ' ' }; let marker = if item.enabled { 'x' } else { ' ' }; - let name = format!("{prefix} [{marker}] {}", item.name); + let name = format!("{prefix} [{marker}] {}", item.display_name); rows.push(GenericDisplayRow { name, description: Some(item.description.clone()), @@ -198,13 +218,13 @@ 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() + .filter(|item| item.enabled != item.original_enabled) + .map(|item| (item.feature, item.enabled)) + .collect(); + if !updates.is_empty() { self.app_event_tx .send(AppEvent::UpdateFeatureFlags { updates }); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8a61e79fe0..a9fe57e7cb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -129,7 +129,6 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; use codex_config::types::WindowsSandboxModeToml; use codex_core_skills::model::SkillMetadata; -use codex_features::FEATURES; use codex_features::Feature; #[cfg(test)] use codex_git_utils::CommitLogEntry; @@ -8460,21 +8459,7 @@ 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(); - + pub(crate) fn show_experimental_popup(&mut self, features: Vec) { let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone()); self.bottom_pane.show_view(Box::new(view)); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index aaa71cca80..232c6c39e2 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -297,7 +297,8 @@ impl ChatWidget { ); } SlashCommand::Experimental => { - self.open_experimental_popup(); + self.app_event_tx + .send(AppEvent::OpenExperimentalFeaturesPopup); } SlashCommand::AutoReview => { self.open_auto_review_denials_popup(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 323d0b749e..ab68a2f66b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -131,7 +131,6 @@ 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_core_skills::model::SkillMetadata; -pub(super) use codex_features::FEATURES; pub(super) use codex_features::Feature; pub(super) use codex_git_utils::CommitLogEntry; pub(super) use codex_otel::RuntimeMetricsSummary; 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 a2c4f11d50..1650347fc6 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,10 +1,11 @@ use super::*; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; -use codex_features::Stage; use pretty_assertions::assert_eq; #[tokio::test] @@ -2159,14 +2160,16 @@ async fn experimental_features_popup_snapshot() { let features = vec![ ExperimentalFeatureItem { feature: Feature::JsRepl, - name: "JavaScript REPL".to_string(), + display_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(), + original_enabled: false, enabled: false, }, ExperimentalFeatureItem { feature: Feature::ShellTool, - name: "Shell tool".to_string(), + display_name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), + original_enabled: true, enabled: true, }, ]; @@ -2183,12 +2186,22 @@ async fn experimental_features_toggle_saves_on_exit() { let expected_feature = Feature::JsRepl; let view = ExperimentalFeaturesView::new( - vec![ExperimentalFeatureItem { - feature: 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, - }], + vec![ + ExperimentalFeatureItem { + feature: expected_feature, + display_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(), + original_enabled: false, + enabled: false, + }, + ExperimentalFeatureItem { + feature: Feature::ShellTool, + display_name: "Shell tool".to_string(), + description: "Allow the model to run shell commands.".to_string(), + original_enabled: true, + enabled: true, + }, + ], chat.app_event_tx.clone(), ); chat.bottom_pane.show_view(Box::new(view)); @@ -2220,15 +2233,33 @@ 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"); + let features = vec![ + ExperimentalFeature { + name: "guardian_approval".to_string(), + stage: ExperimentalFeatureStage::Stable, + display_name: None, + description: None, + announcement: None, + enabled: true, + default_enabled: true, + }, + ExperimentalFeature { + name: "memories".to_string(), + stage: ExperimentalFeatureStage::Beta, + display_name: Some("Memories".to_string()), + description: Some("Allow Codex to create and use memories.".to_string()), + announcement: None, + enabled: false, + default_enabled: false, + }, + ]; - assert_eq!(guardian_stage, Stage::Stable); - - chat.open_experimental_popup(); + chat.show_experimental_popup( + features + .into_iter() + .filter_map(ExperimentalFeatureItem::from_api) + .collect(), + ); let popup = render_bottom_popup(&chat, /*width*/ 120); assert!(