mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui2): add /experimental menu (#9562)
Adds an /experimental slash command and bottom-pane view to toggle beta features. Persists feature-flag updates to config.toml, matching tui behavior.
This commit is contained in:
committed by
GitHub
Unverified
parent
c14e6813fb
commit
0a26675155
@@ -47,6 +47,7 @@ use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ThreadManager;
|
||||
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;
|
||||
@@ -1991,6 +1992,39 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateFeatureFlags { updates } => {
|
||||
if updates.is_empty() {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_profile(self.active_profile.as_deref());
|
||||
for (feature, enabled) in &updates {
|
||||
let key = feature.key();
|
||||
if *enabled {
|
||||
self.config.features.enable(*feature);
|
||||
self.chat_widget.set_feature_enabled(*feature, true);
|
||||
builder = builder.set_feature_enabled(key, true);
|
||||
} else {
|
||||
self.config.features.disable(*feature);
|
||||
self.chat_widget.set_feature_enabled(*feature, false);
|
||||
if feature.default_enabled() {
|
||||
builder = builder.set_feature_enabled(key, false);
|
||||
} else {
|
||||
builder = builder.with_edits([ConfigEdit::ClearPath {
|
||||
segments: vec!["features".to_string(), 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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -168,6 +169,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 config.
|
||||
UpdateFeatureFlags {
|
||||
updates: Vec<(Feature, bool)>,
|
||||
},
|
||||
|
||||
/// Update whether the full access warning prompt has been acknowledged.
|
||||
UpdateFullAccessWarningAcknowledged(bool),
|
||||
|
||||
|
||||
@@ -297,7 +297,17 @@ mod tests {
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cmds, vec!["model", "resume", "compact", "mention", "mcp"]);
|
||||
assert_eq!(
|
||||
cmds,
|
||||
vec![
|
||||
"model",
|
||||
"experimental",
|
||||
"resume",
|
||||
"compact",
|
||||
"mention",
|
||||
"mcp"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
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 codex_core::features::Feature;
|
||||
|
||||
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 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(crate) feature: Feature,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) 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,
|
||||
display_shortcut: None,
|
||||
match_indices: None,
|
||||
description: Some(item.description.clone()),
|
||||
wrap_indent: None,
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
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(),
|
||||
])
|
||||
}
|
||||
@@ -49,6 +49,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;
|
||||
@@ -99,6 +100,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;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ use codex_backend_client::Client as BackendClient;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConstraintResult;
|
||||
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;
|
||||
@@ -121,10 +122,12 @@ use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
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::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
|
||||
use crate::bottom_pane::ExperimentalFeaturesView;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
|
||||
@@ -2059,6 +2062,9 @@ impl ChatWidget {
|
||||
// Not supported; on non-Windows this command should never be reachable.
|
||||
};
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
self.open_experimental_popup();
|
||||
}
|
||||
SlashCommand::Quit | SlashCommand::Exit => {
|
||||
self.request_quit_without_confirmation();
|
||||
}
|
||||
@@ -3338,6 +3344,25 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_experimental_popup(&mut self) {
|
||||
let features: Vec<BetaFeatureItem> = FEATURES
|
||||
.iter()
|
||||
.filter_map(|spec| {
|
||||
let name = spec.stage.beta_menu_name()?;
|
||||
let description = spec.stage.beta_menu_description()?;
|
||||
Some(BetaFeatureItem {
|
||||
feature: spec.id,
|
||||
name: name.to_string(),
|
||||
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,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum SlashCommand {
|
||||
Approvals,
|
||||
#[strum(serialize = "setup-elevated-sandbox")]
|
||||
ElevateSandbox,
|
||||
Experimental,
|
||||
Skills,
|
||||
Review,
|
||||
New,
|
||||
@@ -58,6 +59,7 @@ impl SlashCommand {
|
||||
SlashCommand::Collab => "change collaboration mode (experimental)",
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::Experimental => "toggle beta features",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
SlashCommand::Rollout => "print the rollout file path",
|
||||
@@ -83,6 +85,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
|
||||
Reference in New Issue
Block a user