diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index c84d84e0ff..ec427da7f5 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -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, } 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); diff --git a/codex-rs/core/src/config_edit.rs b/codex-rs/core/src/config_edit.rs index 4b3a5cbdb0..f4edaec57e 100644 --- a/codex-rs/core/src/config_edit.rs +++ b/codex-rs/core/src/config_edit.rs @@ -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..…` 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::()?, + 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() { diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 3737649871..12bc1e638d 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -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, +} + +/// 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, } #[derive(Deserialize, Debug, Clone, PartialEq, Default)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index df7fe442fa..87dcd7b2be 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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, 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::>(), *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) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0d3ccd71c2..172eeeafac 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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), + + /// Persist experimental feature flags into config.toml under [tui.experimental]. + PersistExperimentalFlags(HashMap), } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 4fd6f8d27a..b039c6aa73 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -23,6 +23,11 @@ use super::selection_popup_common::render_rows; /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +/// Callback invoked when a multi‑select view is accepted. +/// The provided `Vec` contains the indices (into the `items` vector) +/// of all entries that are currently checked. +pub(crate) type MultiSelectAcceptAction = Box) + Send + Sync>; + pub(crate) struct SelectionItem { pub name: String, pub description: Option, @@ -41,6 +46,12 @@ pub(crate) struct SelectionViewParams { pub is_searchable: bool, pub search_placeholder: Option, pub empty_message: Option, + /// 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, } pub(crate) struct ListSelectionView { @@ -56,6 +67,10 @@ pub(crate) struct ListSelectionView { search_placeholder: Option, empty_message: Option, filtered_indices: Vec, + is_multi_select: bool, + /// Set of item indices (into `items`) that are currently checked. + checked: std::collections::HashSet, + on_accept_multi: Option, } 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 = 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::(); + 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>>> = 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::(); + 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]); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5ee684f700..4ecade394b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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 = 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 = 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 = 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, + ) { + 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); diff --git a/codex-rs/tui/src/experimental.rs b/codex-rs/tui/src/experimental.rs new file mode 100644 index 0000000000..f0c2893916 --- /dev/null +++ b/codex-rs/tui/src/experimental.rs @@ -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)) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 308e563ded..4db007d84f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 433c0a6d7f..1c1d6f8b68 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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