Clean up experimental feature popup backend wiring

This commit is contained in:
canvrno-oai
2026-05-04 15:23:37 -07:00
parent 87d2235b54
commit a0124597d7
23 changed files with 525 additions and 67 deletions
+15 -1
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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, };
@@ -2528,7 +2528,9 @@ mod tests {
"id": 8,
"params": {
"cursor": null,
"limit": null
"limit": null,
"cwd": null,
"profile": null
}
}),
serde_json::to_value(&request)?,
@@ -2611,6 +2611,12 @@ pub struct ExperimentalFeatureListParams {
/// Optional page size; defaults to a reasonable server-side value.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// Optional working directory used to resolve project config layers.
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Optional config profile used to compute effective feature enablement.
#[ts(optional = nullable)]
pub profile: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
+2 -2
View File
@@ -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 <feature_name> > 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 <feature_name> > 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.
@@ -401,10 +401,64 @@ fn parse_key_path(path: &str) -> Result<Vec<String>, 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<I>(
chars: &mut std::iter::Peekable<I>,
output: &mut String,
) -> Result<(), String>
where
I: Iterator<Item = char>,
{
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<I>(chars: &mut std::iter::Peekable<I>) -> Result<char, String>
where
I: Iterator<Item = char>,
{
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)]
@@ -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(())
@@ -284,8 +284,28 @@ impl CatalogRequestProcessor {
&self,
params: ExperimentalFeatureListParams,
) -> Result<ExperimentalFeatureListResponse, JSONRPCErrorError> {
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())
@@ -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<OutgoingMessageSender>,
@@ -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(", ")
)));
}
@@ -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::<ExperimentalFeatureListResponse>(&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?;
+1
View File
@@ -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;
+102
View File
@@ -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::<std::collections::BTreeSet<_>>();
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,
+10 -1
View File
@@ -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,
+3
View File
@@ -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,
+105
View File
@@ -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<ConfigWriteResponse> {
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<ExperimentalFeatureListResponse> {
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<ThreadRealtimeStartTransport>,
@@ -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<Self> {
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<ExperimentalFeatureItem>,
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 });
}
+1 -16
View File
@@ -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<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();
pub(crate) fn show_experimental_popup(&mut self, features: Vec<ExperimentalFeatureItem>) {
let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
}
@@ -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();
-1
View File
@@ -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;
@@ -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!(