From ac6ba286aaef3ca6a49c084dcecdf96d25ae8364 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 17 Dec 2025 17:08:03 +0000 Subject: [PATCH] feat: experimental menu (#8071) This will automatically render any `Stage::Beta` features. The change only gets applied to the *next session*. This started as a bug but actually this is a good thing to prevent out of distribution push Screenshot 2025-12-15 at 15 38 35 Screenshot 2025-12-15 at 17 35 44 --- codex-rs/cli/src/main.rs | 2 +- codex-rs/codex-api/src/endpoint/responses.rs | 3 + codex-rs/core/src/client.rs | 23 ++ codex-rs/core/src/features.rs | 60 +++- codex-rs/tui/src/app.rs | 37 +++ codex-rs/tui/src/app_event.rs | 6 + .../bottom_pane/experimental_features_view.rs | 292 ++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 3 + .../src/bottom_pane/selection_popup_common.rs | 1 + codex-rs/tui/src/chatwidget.rs | 50 +++ ...t__tests__experimental_features_popup.snap | 11 + codex-rs/tui/src/chatwidget/tests.rs | 65 ++++ codex-rs/tui/src/slash_command.rs | 3 + codex-rs/tui/src/tooltips.rs | 20 +- docs/slash_commands.md | 39 +-- 15 files changed, 577 insertions(+), 38 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/experimental_features_view.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 113c6a751..e29e5a5e5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -410,7 +410,7 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str { use codex_core::features::Stage; match stage { Stage::Experimental => "experimental", - Stage::Beta => "beta", + Stage::Beta { .. } => "beta", Stage::Stable => "stable", Stage::Deprecated => "deprecated", Stage::Removed => "removed", diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 310f7e57b..476e8b8f1 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -32,6 +32,7 @@ pub struct ResponsesOptions { pub store_override: Option, pub conversation_id: Option, pub session_source: Option, + pub extra_headers: HeaderMap, } impl ResponsesClient { @@ -73,6 +74,7 @@ impl ResponsesClient { store_override, conversation_id, session_source, + extra_headers, } = options; let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input) @@ -85,6 +87,7 @@ impl ResponsesClient { .conversation(conversation_id) .session_source(session_source) .store_override(store_override) + .extra_headers(extra_headers) .build(self.streaming.provider())?; self.stream_request(request).await diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8ab56019c..aaf3b0ea3 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -45,6 +45,7 @@ use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result; +use crate::features::FEATURES; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; @@ -261,6 +262,7 @@ impl ModelClient { store_override: None, conversation_id: Some(conversation_id.clone()), session_source: Some(session_source.clone()), + extra_headers: beta_feature_headers(&self.config), }; let stream_result = client @@ -396,6 +398,27 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec ApiHeaderMap { + let enabled = FEATURES + .iter() + .filter_map(|spec| { + if spec.stage.beta_menu_description().is_some() && config.features.enabled(spec.id) { + Some(spec.key) + } else { + None + } + }) + .collect::>(); + let value = enabled.join(","); + let mut headers = ApiHeaderMap::new(); + if !value.is_empty() + && let Ok(header_value) = HeaderValue::from_str(value.as_str()) + { + headers.insert("x-codex-beta-features", header_value); + } + headers +} + fn map_response_stream(api_stream: S, otel_manager: OtelManager) -> ResponseStream where S: futures::Stream> diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 431eabe3c..ed9af0bdb 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -18,12 +18,33 @@ pub(crate) use legacy::LegacyFeatureToggles; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stage { Experimental, - Beta, + Beta { + menu_description: &'static str, + announcement: &'static str, + }, Stable, Deprecated, Removed, } +impl Stage { + pub fn beta_menu_description(self) -> Option<&'static str> { + match self { + Stage::Beta { + menu_description, .. + } => Some(menu_description), + _ => None, + } + } + + pub fn beta_announcement(self) -> Option<&'static str> { + match self { + Stage::Beta { announcement, .. } => Some(announcement), + _ => None, + } + } +} + /// Unique features toggled via configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Feature { @@ -292,13 +313,32 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, - // Unstable features. + FeatureSpec { + id: Feature::WebSearchRequest, + key: "web_search_request", + stage: Stage::Stable, + default_enabled: false, + }, + // Beta program. Rendered in the `/experimental` menu for users. + FeatureSpec { + id: Feature::Skills, + key: "skills", + stage: Stage::Beta { + menu_description: "Define new `skills` for the model", + announcement: "NEW! Try the new `skills` features. Enable in /experimental!", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::UnifiedExec, key: "unified_exec", - stage: Stage::Experimental, + stage: Stage::Beta { + menu_description: "Run long-running terminal commands in the background.", + announcement: "NEW! Try Background terminals for long running processes. Enable in /experimental!", + }, default_enabled: false, }, + // Unstable features. FeatureSpec { id: Feature::RmcpClient, key: "rmcp_client", @@ -308,13 +348,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", - stage: Stage::Beta, - default_enabled: false, - }, - FeatureSpec { - id: Feature::WebSearchRequest, - key: "web_search_request", - stage: Stage::Stable, + stage: Stage::Experimental, default_enabled: false, }, FeatureSpec { @@ -347,12 +381,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, - FeatureSpec { - id: Feature::Skills, - key: "skills", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::ShellSnapshot, key: "shell_snapshot", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 14be12cb5..c394b6417 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -23,6 +23,7 @@ use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; +use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; @@ -944,6 +945,42 @@ impl App { } } } + AppEvent::UpdateFeatureFlags { updates } => { + if updates.is_empty() { + return Ok(true); + } + let mut builder = ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(self.active_profile.as_deref()); + for (feature, enabled) in &updates { + let feature_key = feature.key(); + if *enabled { + // Update the in-memory configs. + self.config.features.enable(*feature); + self.chat_widget.set_feature_enabled(*feature, true); + builder = builder.set_feature_enabled(feature_key, true); + } else { + // Update the in-memory configs. + self.config.features.disable(*feature); + self.chat_widget.set_feature_enabled(*feature, false); + if feature.default_enabled() { + builder = builder.set_feature_enabled(feature_key, false); + } else { + // If the feature already default to `false`, we drop the key + // in the config file so that the user does not miss the feature + // once it gets globally released. + builder = builder.with_edits(vec![ConfigEdit::ClearPath { + segments: vec!["features".to_string(), feature_key.to_string()], + }]); + } + } + } + if let Err(err) = builder.apply().await { + tracing::error!(error = %err, "failed to persist feature flags"); + self.chat_widget.add_error_message(format!( + "Failed to update experimental features: {err}" + )); + } + } AppEvent::SkipNextWorldWritableScan => { self.skip_world_writable_scan_once = true; } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index adb9c1308..0be556de8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,7 @@ use codex_protocol::openai_models::ModelPreset; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; +use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_protocol::openai_models::ReasoningEffort; @@ -117,6 +118,11 @@ pub(crate) enum AppEvent { /// Update the current sandbox policy in the running app and widget. UpdateSandboxPolicy(SandboxPolicy), + /// Update feature flags and persist them to the top-level config. + UpdateFeatureFlags { + updates: Vec<(Feature, bool)>, + }, + /// Update whether the full access warning prompt has been acknowledged. UpdateFullAccessWarningAcknowledged(bool), diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs new file mode 100644 index 000000000..45a0d4d98 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -0,0 +1,292 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Widget; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::style::user_message_style; + +use codex_core::features::Feature; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; + +pub(crate) struct BetaFeatureItem { + pub feature: Feature, + pub name: String, + pub description: String, + pub enabled: bool, +} + +pub(crate) struct ExperimentalFeaturesView { + features: Vec, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, + header: Box, + footer_hint: Line<'static>, +} + +impl ExperimentalFeaturesView { + pub(crate) fn new(features: Vec, app_event_tx: AppEventSender) -> Self { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Experimental features".bold())); + header.push(Line::from( + "Toggle beta features. Changes are saved to config.toml.".dim(), + )); + + let mut view = Self { + features, + state: ScrollState::new(), + complete: false, + app_event_tx, + header: Box::new(header), + footer_hint: experimental_popup_hint_line(), + }; + view.initialize_selection(); + view + } + + fn initialize_selection(&mut self) { + if self.visible_len() == 0 { + self.state.selected_idx = None; + } else if self.state.selected_idx.is_none() { + self.state.selected_idx = Some(0); + } + } + + fn visible_len(&self) -> usize { + self.features.len() + } + + fn build_rows(&self) -> Vec { + let mut rows = Vec::with_capacity(self.features.len()); + let selected_idx = self.state.selected_idx; + for (idx, item) in self.features.iter().enumerate() { + let prefix = if selected_idx == Some(idx) { + '›' + } else { + ' ' + }; + let marker = if item.enabled { 'x' } else { ' ' }; + let name = format!("{prefix} [{marker}] {}", item.name); + rows.push(GenericDisplayRow { + name, + description: Some(item.description.clone()), + ..Default::default() + }); + } + + rows + } + + fn move_up(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn move_down(&mut self) { + let len = self.visible_len(); + if len == 0 { + return; + } + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn toggle_selected(&mut self) { + let Some(selected_idx) = self.state.selected_idx else { + return; + }; + + if let Some(item) = self.features.get_mut(selected_idx) { + item.enabled = !item.enabled; + } + } + + fn rows_width(total_width: u16) -> u16 { + total_width.saturating_sub(2) + } +} + +impl BottomPaneView for ExperimentalFeaturesView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.toggle_selected(), + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + 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(); + self.app_event_tx + .send(AppEvent::UpdateFeatureFlags { updates }); + } + + self.complete = true; + CancellationEvent::Handled + } +} + +impl Renderable for ExperimentalFeaturesView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + Block::default() + .style(user_message_style()) + .render(content_area, buf); + + let header_height = self + .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( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + let [header_area, _, list_area] = Layout::vertical([ + Constraint::Max(header_height), + Constraint::Max(1), + Constraint::Length(rows_height), + ]) + .areas(content_area.inset(Insets::vh(1, 2))); + + self.header.render(header_area, buf); + + if list_area.height > 0 { + let render_area = Rect { + x: list_area.x.saturating_sub(2), + y: list_area.y, + width: rows_width.max(1), + height: list_area.height, + }; + render_rows( + render_area, + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "No experimental features available for now", + ); + } + + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_area.height, + }; + self.footer_hint.clone().dim().render(hint_area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let rows = self.build_rows(); + let rows_width = Self::rows_width(width); + let rows_height = measure_rows_height( + &rows, + &self.state, + MAX_POPUP_ROWS, + rows_width.saturating_add(1), + ); + + let mut height = self.header.desired_height(width.saturating_sub(4)); + height = height.saturating_add(rows_height + 3); + height.saturating_add(1) + } +} + +fn experimental_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to toggle or ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to save for next conversation".into(), + ]) +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 851668728..2c8aa481f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -25,6 +25,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; +mod experimental_features_view; mod file_search_popup; mod footer; mod list_selection_view; @@ -53,6 +54,8 @@ pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusIndicatorWidget; +pub(crate) use experimental_features_view::BetaFeatureItem; +pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index af7fbb5d1..d44283aa1 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -15,6 +15,7 @@ use crate::key_hint::KeyBinding; use super::scroll_state::ScrollState; /// A generic representation of a display row for selection popups. +#[derive(Default)] pub(crate) struct GenericDisplayRow { pub name: String, pub display_shortcut: Option, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 89d2fa7ac..046848589 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9,6 +9,8 @@ use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; use codex_core::config::Config; use codex_core::config::types::Notifications; +use codex_core::features::FEATURES; +use codex_core::features::Feature; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::openai_models::model_family::ModelFamily; @@ -85,9 +87,11 @@ use tracing::debug; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::BetaFeatureItem; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -1571,6 +1575,9 @@ impl ChatWidget { SlashCommand::Approvals => { self.open_approvals_popup(); } + SlashCommand::Experimental => { + self.open_experimental_popup(); + } SlashCommand::Quit | SlashCommand::Exit => { self.request_exit(); } @@ -2635,6 +2642,24 @@ impl ChatWidget { }); } + pub(crate) fn open_experimental_popup(&mut self) { + let features: Vec = FEATURES + .iter() + .filter_map(|spec| { + let description = spec.stage.beta_menu_description()?; + Some(BetaFeatureItem { + feature: spec.id, + name: feature_label_from_key(spec.key), + description: description.to_string(), + enabled: self.config.features.enabled(spec.id), + }) + }) + .collect(); + + let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone()); + self.bottom_pane.show_view(Box::new(view)); + } + fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, @@ -2977,6 +3002,14 @@ impl ChatWidget { } } + pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { + if enabled { + self.config.features.enable(feature); + } else { + self.config.features.disable(feature); + } + } + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { self.config.notices.hide_full_access_warning = Some(acknowledged); } @@ -3310,6 +3343,23 @@ impl ChatWidget { } } +fn feature_label_from_key(key: &str) -> String { + let mut out = String::with_capacity(key.len()); + let mut capitalize = true; + for ch in key.chars() { + if ch == '_' || ch == '-' { + out.push(' '); + capitalize = true; + } else if capitalize { + out.push(ch.to_ascii_uppercase()); + capitalize = false; + } else { + out.push(ch); + } + } + out +} + impl Drop for ChatWidget { fn drop(&mut self) { self.stop_rate_limit_poller(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap new file mode 100644 index 000000000..c0de5e4ee --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Experimental features + Toggle beta features. Changes are saved to config.toml. + +› [ ] Ghost snapshots Capture undo snapshots each turn. + [x] Shell tool Allow the model to run shell commands. + + Press enter to toggle or esc to save for next conversation diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 49b4a1efa..036d651ac 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1738,6 +1738,71 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +#[test] +fn experimental_features_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + let features = vec![ + BetaFeatureItem { + feature: Feature::GhostCommit, + name: "Ghost snapshots".to_string(), + description: "Capture undo snapshots each turn.".to_string(), + enabled: false, + }, + BetaFeatureItem { + feature: Feature::ShellTool, + name: "Shell tool".to_string(), + description: "Allow the model to run shell commands.".to_string(), + enabled: true, + }, + ]; + let view = ExperimentalFeaturesView::new(features, chat.app_event_tx.clone()); + chat.bottom_pane.show_view(Box::new(view)); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("experimental_features_popup", popup); +} + +#[test] +fn experimental_features_toggle_saves_on_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let expected_feature = Feature::GhostCommit; + let view = ExperimentalFeaturesView::new( + vec![BetaFeatureItem { + feature: expected_feature, + name: "Ghost snapshots".to_string(), + description: "Capture undo snapshots each turn.".to_string(), + enabled: false, + }], + chat.app_event_tx.clone(), + ); + chat.bottom_pane.show_view(Box::new(view)); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!( + rx.try_recv().is_err(), + "expected no updates until exiting the popup" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let mut updates = None; + while let Ok(event) = rx.try_recv() { + if let AppEvent::UpdateFeatureFlags { + updates: event_updates, + } = event + { + updates = Some(event_updates); + break; + } + } + + let updates = updates.expect("expected UpdateFeatureFlags event"); + assert_eq!(updates, vec![(expected_feature, true)]); +} + #[test] fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e0c676812..bfc5616e2 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Experimental, Skills, Review, New, @@ -51,6 +52,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", + SlashCommand::Experimental => "toggle beta features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", @@ -74,6 +76,7 @@ impl SlashCommand { | SlashCommand::Undo | SlashCommand::Model | SlashCommand::Approvals + | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index eb419c2ea..8a3a1c1e9 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -1,14 +1,28 @@ +use codex_core::features::FEATURES; use lazy_static::lazy_static; use rand::Rng; const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); +fn beta_tooltips() -> Vec<&'static str> { + FEATURES + .iter() + .filter_map(|spec| spec.stage.beta_announcement()) + .collect() +} + lazy_static! { static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .collect(); + static ref ALL_TOOLTIPS: Vec<&'static str> = { + let mut tips = Vec::new(); + tips.extend(TOOLTIPS.iter().copied()); + tips.extend(beta_tooltips()); + tips + }; } pub(crate) fn random_tooltip() -> Option<&'static str> { @@ -17,10 +31,12 @@ pub(crate) fn random_tooltip() -> Option<&'static str> { } fn pick_tooltip(rng: &mut R) -> Option<&'static str> { - if TOOLTIPS.is_empty() { + if ALL_TOOLTIPS.is_empty() { None } else { - TOOLTIPS.get(rng.random_range(0..TOOLTIPS.len())).copied() + ALL_TOOLTIPS + .get(rng.random_range(0..ALL_TOOLTIPS.len())) + .copied() } } diff --git a/docs/slash_commands.md b/docs/slash_commands.md index ed5b4dc85..c1f9daf9d 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -10,24 +10,25 @@ Slash commands are special commands you can type that start with `/`. Control Codex’s behavior during an interactive session with slash commands. -| Command | Purpose | -| ------------ | -------------------------------------------------------------------------- | -| `/model` | choose what model and reasoning effort to use | -| `/approvals` | choose what Codex can do without approval | -| `/review` | review my current changes and find issues | -| `/new` | start a new chat during a conversation | -| `/resume` | resume an old chat | -| `/init` | create an AGENTS.md file with instructions for Codex | -| `/compact` | summarize conversation to prevent hitting the context limit | -| `/undo` | ask Codex to undo a turn | -| `/diff` | show git diff (including untracked files) | -| `/mention` | mention a file | -| `/status` | show current session configuration and token usage | -| `/mcp` | list configured MCP tools | -| `/skills` | browse and insert skills (experimental; see [docs/skills.md](./skills.md)) | -| `/logout` | log out of Codex | -| `/quit` | exit Codex | -| `/exit` | exit Codex | -| `/feedback` | send logs to maintainers | +| Command | Purpose | +| --------------- | -------------------------------------------------------------------------- | +| `/model` | choose what model and reasoning effort to use | +| `/approvals` | choose what Codex can do without approval | +| `/review` | review my current changes and find issues | +| `/new` | start a new chat during a conversation | +| `/resume` | resume an old chat | +| `/init` | create an AGENTS.md file with instructions for Codex | +| `/compact` | summarize conversation to prevent hitting the context limit | +| `/undo` | ask Codex to undo a turn | +| `/diff` | show git diff (including untracked files) | +| `/mention` | mention a file | +| `/status` | show current session configuration and token usage | +| `/mcp` | list configured MCP tools | +| `/experimental` | open the experimental menu to enable features from our beta program | +| `/skills` | browse and insert skills (experimental; see [docs/skills.md](./skills.md)) | +| `/logout` | log out of Codex | +| `/quit` | exit Codex | +| `/exit` | exit Codex | +| `/feedback` | send logs to maintainers | ---