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 <img width="986" height="288" alt="Screenshot 2025-12-15 at 15 38 35" src="https://github.com/user-attachments/assets/78b7a71d-0e43-4828-a118-91c5237909c7" /> <img width="509" height="109" alt="Screenshot 2025-12-15 at 17 35 44" src="https://github.com/user-attachments/assets/6933de52-9b66-4abf-b58b-a5f26d5747e2" />
This commit is contained in:
parent
9352c6b235
commit
ac6ba286aa
15 changed files with 577 additions and 38 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub struct ResponsesOptions {
|
|||
pub store_override: Option<bool>,
|
||||
pub conversation_id: Option<String>,
|
||||
pub session_source: Option<SessionSource>,
|
||||
pub extra_headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
|
||||
|
|
@ -73,6 +74,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
|
|||
store_override,
|
||||
conversation_id,
|
||||
session_source,
|
||||
extra_headers,
|
||||
} = options;
|
||||
|
||||
let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input)
|
||||
|
|
@ -85,6 +87,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
|
|||
.conversation(conversation_id)
|
||||
.session_source(session_source)
|
||||
.store_override(store_override)
|
||||
.extra_headers(extra_headers)
|
||||
.build(self.streaming.provider())?;
|
||||
|
||||
self.stream_request(request).await
|
||||
|
|
|
|||
|
|
@ -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<Value
|
|||
}
|
||||
}
|
||||
|
||||
fn beta_feature_headers(config: &Config) -> 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::<Vec<_>>();
|
||||
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<S>(api_stream: S, otel_manager: OtelManager) -> ResponseStream
|
||||
where
|
||||
S: futures::Stream<Item = std::result::Result<ResponseEvent, ApiError>>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
292
codex-rs/tui/src/bottom_pane/experimental_features_view.rs
Normal file
292
codex-rs/tui/src/bottom_pane/experimental_features_view.rs
Normal file
|
|
@ -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<BetaFeatureItem>,
|
||||
state: ScrollState,
|
||||
complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
header: Box<dyn Renderable>,
|
||||
footer_hint: Line<'static>,
|
||||
}
|
||||
|
||||
impl ExperimentalFeaturesView {
|
||||
pub(crate) fn new(features: Vec<BetaFeatureItem>, 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<GenericDisplayRow> {
|
||||
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(),
|
||||
])
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KeyBinding>,
|
||||
|
|
|
|||
|
|
@ -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<BetaFeatureItem> = 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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<R: Rng + ?Sized>(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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue