mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
exp pt 1.
This commit is contained in:
@@ -194,6 +194,9 @@ pub struct Config {
|
||||
/// All characters are inserted as they are received, and no buffering
|
||||
/// or placeholder replacement will occur for fast keypress bursts.
|
||||
pub disable_paste_burst: bool,
|
||||
|
||||
/// Experimental feature flags for the TUI loaded from `[tui.experimental]`.
|
||||
pub experimental_flags: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -1053,6 +1056,12 @@ impl Config {
|
||||
.as_ref()
|
||||
.map(|t| t.notifications.clone())
|
||||
.unwrap_or_default(),
|
||||
experimental_flags: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
.and_then(|t| t.experimental.as_ref())
|
||||
.map(|e| e.flags.clone())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -1631,6 +1640,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("o3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -1689,6 +1699,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -1762,6 +1773,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("zdr".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
@@ -1821,6 +1833,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
experimental_flags: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
|
||||
|
||||
@@ -212,11 +212,77 @@ fn remove_toml_edit_segments(doc: &mut DocumentMut, segments: &[&str]) -> bool {
|
||||
current.remove(segments[segments.len() - 1]).is_some()
|
||||
}
|
||||
|
||||
/// Persist boolean overrides into `config.toml`. Keys are specified by explicit
|
||||
/// segments (e.g., `["tui", "experimental", "my-flag"]`). When a profile is
|
||||
/// active, values are written under `profiles.<name>.…` unless the provided
|
||||
/// segments already start with `profiles`.
|
||||
pub async fn persist_bool_overrides(
|
||||
codex_home: &Path,
|
||||
profile: Option<&str>,
|
||||
overrides: &[(&[&str], bool)],
|
||||
) -> Result<()> {
|
||||
if overrides.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let read_result = tokio::fs::read_to_string(&config_path).await;
|
||||
let mut doc = match read_result {
|
||||
Ok(contents) => contents.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tokio::fs::create_dir_all(codex_home).await?;
|
||||
DocumentMut::new()
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let effective_profile = if let Some(p) = profile {
|
||||
Some(p.to_owned())
|
||||
} else {
|
||||
doc.get("profile")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
let mut mutated = false;
|
||||
for (segments, value) in overrides.iter().copied() {
|
||||
let mut seg_buf: Vec<&str> = Vec::new();
|
||||
let segments_to_apply: &[&str];
|
||||
if let Some(ref name) = effective_profile {
|
||||
if segments.first().copied() == Some("profiles") {
|
||||
segments_to_apply = segments;
|
||||
} else {
|
||||
seg_buf.reserve(2 + segments.len());
|
||||
seg_buf.push("profiles");
|
||||
seg_buf.push(name.as_str());
|
||||
seg_buf.extend_from_slice(segments);
|
||||
segments_to_apply = seg_buf.as_slice();
|
||||
}
|
||||
} else {
|
||||
segments_to_apply = segments;
|
||||
}
|
||||
|
||||
let item_value = toml_edit::value(value);
|
||||
apply_toml_edit_override_segments(&mut doc, segments_to_apply, item_value);
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tmp_file = NamedTempFile::new_in(codex_home)?;
|
||||
tokio::fs::write(tmp_file.path(), doc.to_string()).await?;
|
||||
tmp_file.persist(config_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// Verifies model and effort are written at top-level when no profile is set.
|
||||
#[tokio::test]
|
||||
@@ -242,6 +308,69 @@ model_reasoning_effort = "high"
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
// `read_config` helper is defined at the bottom of this tests module.
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_bool_overrides_top_level_tui_experimental() {
|
||||
let tmpdir = tempdir().expect("tmp");
|
||||
let codex_home = tmpdir.path();
|
||||
|
||||
persist_bool_overrides(
|
||||
codex_home,
|
||||
None,
|
||||
&[
|
||||
(&["tui", "experimental", "feat-a"], true),
|
||||
(&["tui", "experimental", "feat-b"], false),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("persist bool overrides");
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let parsed: TomlValue = toml::from_str(&contents).expect("valid toml");
|
||||
let feat_a = parsed
|
||||
.get("tui")
|
||||
.and_then(|t| t.get("experimental"))
|
||||
.and_then(|e| e.get("feat-a"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap();
|
||||
let feat_b = parsed
|
||||
.get("tui")
|
||||
.and_then(|t| t.get("experimental"))
|
||||
.and_then(|e| e.get("feat-b"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap();
|
||||
assert!(feat_a);
|
||||
assert!(!feat_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_bool_overrides_profile_tui_experimental() {
|
||||
let tmpdir = tempdir().expect("tmp");
|
||||
let codex_home = tmpdir.path();
|
||||
|
||||
// Persist under the "dev" profile.
|
||||
persist_bool_overrides(
|
||||
codex_home,
|
||||
Some("dev"),
|
||||
&[(&["tui", "experimental", "alpha"], true)],
|
||||
)
|
||||
.await
|
||||
.expect("persist bool overrides");
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let parsed: TomlValue = toml::from_str(&contents).expect("valid toml");
|
||||
let alpha = parsed
|
||||
.get("profiles")
|
||||
.and_then(|p| p.get("dev"))
|
||||
.and_then(|d| d.get("tui"))
|
||||
.and_then(|t| t.get("experimental"))
|
||||
.and_then(|e| e.get("alpha"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap();
|
||||
assert!(alpha);
|
||||
}
|
||||
|
||||
/// Verifies values are written under the active profile when `profile` is set.
|
||||
#[tokio::test]
|
||||
async fn set_defaults_update_profile_when_profile_set() {
|
||||
|
||||
@@ -96,6 +96,18 @@ pub struct Tui {
|
||||
/// Defaults to `false`.
|
||||
#[serde(default)]
|
||||
pub notifications: Notifications,
|
||||
|
||||
/// Experimental feature flags scoped to the TUI. Keys are arbitrary
|
||||
/// kebab-case strings and values are booleans.
|
||||
#[serde(default)]
|
||||
pub experimental: Option<TuiExperimental>,
|
||||
}
|
||||
|
||||
/// Flattened map of experimental feature flags under the `[tui.experimental]` table.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct TuiExperimental {
|
||||
#[serde(default, flatten)]
|
||||
pub flags: std::collections::HashMap<String, bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
|
||||
@@ -361,6 +361,62 @@ impl App {
|
||||
AppEvent::OpenReviewCustomPrompt => {
|
||||
self.chat_widget.show_review_custom_prompt();
|
||||
}
|
||||
AppEvent::UpdateExperimentalFlags(flags) => {
|
||||
self.config.experimental_flags = flags.clone();
|
||||
self.chat_widget.set_experimental_flags(flags.clone());
|
||||
self.chat_widget
|
||||
.add_info_message("Experimental features updated".to_string(), None);
|
||||
}
|
||||
AppEvent::PersistExperimentalFlags(flags) => {
|
||||
use codex_core::config_edit::persist_bool_overrides;
|
||||
// Persist into [tui.experimental]
|
||||
let profile = self.active_profile.as_deref();
|
||||
let mut entries: Vec<(Vec<String>, bool)> = Vec::new();
|
||||
for (k, v) in flags.iter() {
|
||||
entries.push((
|
||||
vec!["tui".to_string(), "experimental".to_string(), k.clone()],
|
||||
*v,
|
||||
));
|
||||
}
|
||||
// Convert to segments for the helper
|
||||
let segs: Vec<(Vec<&str>, bool)> = entries
|
||||
.iter()
|
||||
.map(|(p, v)| (p.iter().map(|s| s.as_str()).collect::<Vec<&str>>(), *v))
|
||||
.collect();
|
||||
|
||||
// Transform to borrowed tuples
|
||||
let borrowed: Vec<(&[&str], bool)> =
|
||||
segs.iter().map(|(v, b)| (v.as_slice(), *b)).collect();
|
||||
|
||||
match persist_bool_overrides(&self.config.codex_home, profile, &borrowed).await {
|
||||
Ok(()) => {
|
||||
let count = flags.len();
|
||||
if let Some(p) = profile {
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Saved {count} experimental flags for profile {p}"),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Saved {count} experimental flags"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("failed to persist experimental flags: {err}");
|
||||
if let Some(p) = profile {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save experimental flags for profile `{p}`: {err}"
|
||||
));
|
||||
} else {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save experimental flags: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::history_cell::HistoryCell;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
@@ -73,4 +74,10 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Open the custom prompt option from the review popup.
|
||||
OpenReviewCustomPrompt,
|
||||
|
||||
/// Update the current experimental feature flags in memory.
|
||||
UpdateExperimentalFlags(HashMap<String, bool>),
|
||||
|
||||
/// Persist experimental feature flags into config.toml under [tui.experimental].
|
||||
PersistExperimentalFlags(HashMap<String, bool>),
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ use super::selection_popup_common::render_rows;
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
/// Callback invoked when a multi‑select view is accepted.
|
||||
/// The provided `Vec<usize>` contains the indices (into the `items` vector)
|
||||
/// of all entries that are currently checked.
|
||||
pub(crate) type MultiSelectAcceptAction = Box<dyn Fn(&AppEventSender, &Vec<usize>) + Send + Sync>;
|
||||
|
||||
pub(crate) struct SelectionItem {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
@@ -41,6 +46,12 @@ pub(crate) struct SelectionViewParams {
|
||||
pub is_searchable: bool,
|
||||
pub search_placeholder: Option<String>,
|
||||
pub empty_message: Option<String>,
|
||||
/// When true, the list supports toggling multiple items via Space and
|
||||
/// submitting all selections with Enter.
|
||||
pub is_multi_select: bool,
|
||||
/// Optional callback invoked on Enter when `is_multi_select` is true.
|
||||
/// If `None`, accepting the view will simply dismiss it.
|
||||
pub on_accept_multi: Option<MultiSelectAcceptAction>,
|
||||
}
|
||||
|
||||
pub(crate) struct ListSelectionView {
|
||||
@@ -56,6 +67,10 @@ pub(crate) struct ListSelectionView {
|
||||
search_placeholder: Option<String>,
|
||||
empty_message: Option<String>,
|
||||
filtered_indices: Vec<usize>,
|
||||
is_multi_select: bool,
|
||||
/// Set of item indices (into `items`) that are currently checked.
|
||||
checked: std::collections::HashSet<usize>,
|
||||
on_accept_multi: Option<MultiSelectAcceptAction>,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -86,7 +101,18 @@ impl ListSelectionView {
|
||||
},
|
||||
empty_message: params.empty_message,
|
||||
filtered_indices: Vec::new(),
|
||||
is_multi_select: params.is_multi_select,
|
||||
checked: Default::default(),
|
||||
on_accept_multi: params.on_accept_multi,
|
||||
};
|
||||
if s.is_multi_select {
|
||||
// Seed checked set from items marked as current.
|
||||
for (idx, it) in s.items.iter().enumerate() {
|
||||
if it.is_current {
|
||||
s.checked.insert(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.apply_filter();
|
||||
s
|
||||
}
|
||||
@@ -165,13 +191,27 @@ impl ListSelectionView {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let name = item.name.as_str();
|
||||
let name_with_marker = if item.is_current {
|
||||
let name_with_marker = if self.is_multi_select {
|
||||
// In multi‑select mode, `is_current` seeds the initial checkbox
|
||||
// state and is reflected via [x]/[ ] rather than "(current)".
|
||||
item.name.clone()
|
||||
} else if item.is_current {
|
||||
format!("{name} (current)")
|
||||
} else {
|
||||
item.name.clone()
|
||||
};
|
||||
let n = visible_idx + 1;
|
||||
let display_name = format!("{prefix} {n}. {name_with_marker}");
|
||||
let display_name = if self.is_multi_select {
|
||||
let actual = *actual_idx;
|
||||
let checked = if self.checked.contains(&actual) {
|
||||
"[x]"
|
||||
} else {
|
||||
"[ ]"
|
||||
};
|
||||
format!("{prefix} {n}. {checked} {name_with_marker}")
|
||||
} else {
|
||||
format!("{prefix} {n}. {name_with_marker}")
|
||||
};
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
match_indices: None,
|
||||
@@ -198,6 +238,16 @@ impl ListSelectionView {
|
||||
}
|
||||
|
||||
fn accept(&mut self) {
|
||||
if self.is_multi_select {
|
||||
if let Some(cb) = &self.on_accept_multi {
|
||||
let mut selected: Vec<usize> = self.checked.iter().copied().collect();
|
||||
selected.sort_unstable();
|
||||
cb(&self.app_event_tx, &selected);
|
||||
}
|
||||
self.complete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(idx) = self.state.selected_idx
|
||||
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||
&& let Some(item) = self.items.get(*actual_idx)
|
||||
@@ -230,6 +280,20 @@ impl BottomPaneView for ListSelectionView {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(' '),
|
||||
..
|
||||
} if self.is_multi_select => {
|
||||
if let Some(visible_idx) = self.state.selected_idx
|
||||
&& let Some(actual_idx) = self.filtered_indices.get(visible_idx)
|
||||
{
|
||||
if self.checked.contains(actual_idx) {
|
||||
self.checked.remove(actual_idx);
|
||||
} else {
|
||||
self.checked.insert(*actual_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
@@ -391,9 +455,13 @@ mod tests {
|
||||
use super::BottomPaneView;
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||
use crate::tui::FrameRequester;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::layout::Rect;
|
||||
// KeyEvent::new suffices; the view doesn't check kind/state.
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
|
||||
@@ -497,4 +565,98 @@ mod tests {
|
||||
let lines = render_lines(&view);
|
||||
assert!(lines.contains("▌ filters"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_select_toggles_and_accepts() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
description: Some("Codex can read files".to_string()),
|
||||
is_current: true, // pre-checked via seed
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Full Access".to_string(),
|
||||
description: Some("Codex can edit files".to_string()),
|
||||
is_current: false,
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Third".to_string(),
|
||||
description: None,
|
||||
is_current: false,
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
},
|
||||
];
|
||||
|
||||
let accepted: Arc<Mutex<Option<Vec<usize>>>> = Arc::new(Mutex::new(None));
|
||||
let accepted_clone = accepted.clone();
|
||||
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: "Experimental features".to_string(),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
is_multi_select: true,
|
||||
on_accept_multi: Some(Box::new(move |_tx, selected| {
|
||||
*accepted_clone.lock().unwrap() = Some(selected.clone());
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
// Initially, first item should be checked ([x]) via is_current seed
|
||||
let initial = render_lines(&view);
|
||||
assert!(initial.contains("1. [x] Read Only"));
|
||||
assert!(initial.contains("2. [ ] Full Access"));
|
||||
|
||||
// Build a minimal BottomPane to deliver key events to the view.
|
||||
let (tx2_raw, _rx2) = unbounded_channel::<AppEvent>();
|
||||
let tx2 = AppEventSender::new(tx2_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx2,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: String::new(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
// Toggle first item off with Space
|
||||
view.handle_key_event(
|
||||
&mut pane,
|
||||
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
|
||||
);
|
||||
let after_toggle = render_lines(&view);
|
||||
assert!(after_toggle.contains("1. [ ] Read Only"));
|
||||
|
||||
// Move to second item and toggle it on
|
||||
view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
view.handle_key_event(
|
||||
&mut pane,
|
||||
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
|
||||
);
|
||||
let after_second = render_lines(&view);
|
||||
assert!(after_second.contains("2. [x] Full Access"));
|
||||
|
||||
// Accept selection with Enter: should call the callback with [1]
|
||||
assert!(!view.is_complete());
|
||||
view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(view.is_complete());
|
||||
|
||||
let accepted_vec = accepted.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(accepted_vec, vec![1]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,6 +932,9 @@ impl ChatWidget {
|
||||
SlashCommand::Mcp => {
|
||||
self.add_mcp_output();
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
self.open_experimental_popup();
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -1297,6 +1300,50 @@ impl ChatWidget {
|
||||
));
|
||||
}
|
||||
|
||||
/// Open a popup to choose experimental features to enable/disable.
|
||||
pub(crate) fn open_experimental_popup(&mut self) {
|
||||
use crate::experimental::ALL_FEATURES;
|
||||
let mut items: Vec<SelectionItem> = Vec::with_capacity(ALL_FEATURES.len());
|
||||
|
||||
for feature in ALL_FEATURES.iter() {
|
||||
let enabled = crate::experimental::is_enabled(&self.config, feature.key);
|
||||
items.push(SelectionItem {
|
||||
name: feature.name.to_string(),
|
||||
description: Some(feature.description.to_string()),
|
||||
is_current: enabled, // seed checked state in multi‑select mode
|
||||
actions: vec![],
|
||||
dismiss_on_select: false,
|
||||
search_value: Some(feature.key.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let footer = "Space to toggle • Enter to save • Esc to cancel".to_string();
|
||||
let keys: Vec<String> = ALL_FEATURES.iter().map(|f| f.key.to_string()).collect();
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: "Experimental features".to_string(),
|
||||
subtitle: Some("Toggle experimental TUI options".to_string()),
|
||||
footer_hint: Some(footer),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to filter features".to_string()),
|
||||
empty_message: Some("no features".to_string()),
|
||||
is_multi_select: true,
|
||||
on_accept_multi: Some(Box::new(move |tx2, selected_indices| {
|
||||
use std::collections::HashMap;
|
||||
let mut flags: HashMap<String, bool> = HashMap::new();
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
let enabled = selected_indices.contains(&i);
|
||||
flags.insert(key.clone(), enabled);
|
||||
}
|
||||
tx2.send(crate::app_event::AppEvent::UpdateExperimentalFlags(
|
||||
flags.clone(),
|
||||
));
|
||||
tx2.send(crate::app_event::AppEvent::PersistExperimentalFlags(flags));
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a popup to choose the model preset (model + reasoning effort).
|
||||
pub(crate) fn open_model_popup(&mut self) {
|
||||
let current_model = self.config.model.clone();
|
||||
@@ -1418,6 +1465,13 @@ impl ChatWidget {
|
||||
self.config.model_reasoning_effort = effort;
|
||||
}
|
||||
|
||||
pub(crate) fn set_experimental_flags(
|
||||
&mut self,
|
||||
flags: std::collections::HashMap<String, bool>,
|
||||
) {
|
||||
self.config.experimental_flags = flags;
|
||||
}
|
||||
|
||||
/// Set the model in the widget's config copy.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
self.session_header.set_model(model);
|
||||
|
||||
44
codex-rs/tui/src/experimental.rs
Normal file
44
codex-rs/tui/src/experimental.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use codex_core::config::Config;
|
||||
|
||||
/// Definition of a single experimental feature toggle.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Feature {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub default_on: bool,
|
||||
}
|
||||
|
||||
/// Central registry of experimental features.
|
||||
/// Add new toggles here and gate UI code using `is_enabled(cfg, feature.key)`.
|
||||
pub const ALL_FEATURES: &[Feature] = &[
|
||||
Feature {
|
||||
key: "compact-status-indicator",
|
||||
name: "Compact status indicator",
|
||||
description: "Use a more compact, single-line working indicator.",
|
||||
default_on: false,
|
||||
},
|
||||
Feature {
|
||||
key: "alt-diff-pager",
|
||||
name: "Alternative diff pager",
|
||||
description: "Try a new diff layout and navigation.",
|
||||
default_on: false,
|
||||
},
|
||||
];
|
||||
|
||||
fn default_for(key: &str) -> bool {
|
||||
ALL_FEATURES
|
||||
.iter()
|
||||
.find(|f| f.key == key)
|
||||
.map(|f| f.default_on)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns whether a feature is enabled using the current config and built-in default.
|
||||
pub fn is_enabled(config: &Config, key: &str) -> bool {
|
||||
config
|
||||
.experimental_flags
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or_else(|| default_for(key))
|
||||
}
|
||||
@@ -42,6 +42,7 @@ mod clipboard_paste;
|
||||
pub mod custom_terminal;
|
||||
mod diff_render;
|
||||
mod exec_command;
|
||||
mod experimental;
|
||||
mod file_search;
|
||||
mod frames;
|
||||
mod get_git_diff;
|
||||
|
||||
@@ -24,6 +24,7 @@ pub enum SlashCommand {
|
||||
Mcp,
|
||||
Logout,
|
||||
Quit,
|
||||
Experimental,
|
||||
#[cfg(debug_assertions)]
|
||||
TestApproval,
|
||||
}
|
||||
@@ -44,6 +45,7 @@ impl SlashCommand {
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
SlashCommand::Experimental => "toggle experimental TUI features",
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => "test approval request",
|
||||
}
|
||||
@@ -63,6 +65,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
|
||||
Reference in New Issue
Block a user