exp pt 1.

This commit is contained in:
Daniel Edrisian
2025-09-20 01:48:18 -07:00
parent 259c2b1287
commit bf0fb63e5f
10 changed files with 483 additions and 2 deletions

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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)]

View File

@@ -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)
}

View File

@@ -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>),
}

View File

@@ -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 multiselect 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 multiselect 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]);
}
}

View File

@@ -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 multiselect 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);

View 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))
}

View File

@@ -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;

View File

@@ -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