Files
codex/prs/bolinfest/PR-2435.md
2025-09-02 15:17:45 -07:00

33 KiB
Raw Blame History

PR #2435: Support changing reasoning effort

Description

https://github.com/user-attachments/assets/50198ee8-5915-47a3-bb71-69af65add1ef

Building up on #2431 #2428

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 265857533f..80809434e3 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -858,6 +858,7 @@ dependencies = [
  "anyhow",
  "assert_cmd",
  "codex-arg0",
+ "codex-common",
  "codex-core",
  "codex-login",
  "codex-protocol",
diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs
index 8595262cc0..dc684c21d8 100644
--- a/codex-rs/common/src/lib.rs
+++ b/codex-rs/common/src/lib.rs
@@ -29,3 +29,5 @@ mod config_summary;
 pub use config_summary::create_config_summary_entries;
 // Shared fuzzy matcher (used by TUI selection popups and other UI filtering)
 pub mod fuzzy_match;
+// Shared model presets used by TUI and MCP server
+pub mod model_presets;
diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs
new file mode 100644
index 0000000000..16ec9be199
--- /dev/null
+++ b/codex-rs/common/src/model_presets.rs
@@ -0,0 +1,54 @@
+use codex_core::protocol_config_types::ReasoningEffort;
+
+/// A simple preset pairing a model slug with a reasoning effort.
+#[derive(Debug, Clone, Copy)]
+pub struct ModelPreset {
+    /// Stable identifier for the preset.
+    pub id: &'static str,
+    /// Display label shown in UIs.
+    pub label: &'static str,
+    /// Short human description shown next to the label in UIs.
+    pub description: &'static str,
+    /// Model slug (e.g., "gpt-5").
+    pub model: &'static str,
+    /// Reasoning effort to apply for this preset.
+    pub effort: ReasoningEffort,
+}
+
+/// Built-in list of model presets that pair a model with a reasoning effort.
+///
+/// Keep this UI-agnostic so it can be reused by both TUI and MCP server.
+pub fn builtin_model_presets() -> &'static [ModelPreset] {
+    // Order reflects effort from minimal to high.
+    const PRESETS: &[ModelPreset] = &[
+        ModelPreset {
+            id: "gpt-5-minimal",
+            label: "gpt-5 minimal",
+            description: "— Fastest responses with very limited reasoning; ideal for coding, instructions, or lightweight tasks.",
+            model: "gpt-5",
+            effort: ReasoningEffort::Minimal,
+        },
+        ModelPreset {
+            id: "gpt-5-low",
+            label: "gpt-5 low",
+            description: "— Balances speed with some reasoning; useful for straightforward queries and short explanations.",
+            model: "gpt-5",
+            effort: ReasoningEffort::Low,
+        },
+        ModelPreset {
+            id: "gpt-5-medium",
+            label: "gpt-5 medium",
+            description: "— Default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks.",
+            model: "gpt-5",
+            effort: ReasoningEffort::Medium,
+        },
+        ModelPreset {
+            id: "gpt-5-high",
+            label: "gpt-5 high",
+            description: "— Maximizes reasoning depth for complex or ambiguous problems.",
+            model: "gpt-5",
+            effort: ReasoningEffort::High,
+        },
+    ];
+    PRESETS
+}
diff --git a/codex-rs/config.md b/codex-rs/config.md
index 0d5df17cc8..80ef1466c4 100644
--- a/codex-rs/config.md
+++ b/codex-rs/config.md
@@ -217,17 +217,14 @@ Users can specify config values at multiple levels. Order of precedence is as fo
 
 ## model_reasoning_effort
 
-If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to:
+If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to:
 
+- `"minimal"`
 - `"low"`
 - `"medium"` (default)
 - `"high"`
 
-To disable reasoning, set `model_reasoning_effort` to `"none"` in your config:
-
-```toml
-model_reasoning_effort = "none"  # disable reasoning
-```
+Note: to minimize reasoning, choose `"minimal"`.
 
 ## model_reasoning_summary
 
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index f20912b22e..760729d827 100644
--- a/codex-rs/core/src/config.rs
+++ b/codex-rs/core/src/config.rs
@@ -140,8 +140,8 @@ pub struct Config {
     /// When this program is invoked, arg0 will be set to `codex-linux-sandbox`.
     pub codex_linux_sandbox_exe: Option<PathBuf>,
 
-    /// If not "none", the value to use for `reasoning.effort` when making a
-    /// request using the Responses API.
+    /// Value to use for `reasoning.effort` when making a request using the
+    /// Responses API.
     pub model_reasoning_effort: ReasoningEffort,
 
     /// If not "none", the value to use for `reasoning.summary` when making a
diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml
index 3f5c2a0fa2..44a3907159 100644
--- a/codex-rs/mcp-server/Cargo.toml
+++ b/codex-rs/mcp-server/Cargo.toml
@@ -17,6 +17,7 @@ workspace = true
 [dependencies]
 anyhow = "1"
 codex-arg0 = { path = "../arg0" }
+codex-common = { path = "../common" }
 codex-core = { path = "../core" }
 codex-login = { path = "../login" }
 codex-protocol = { path = "../protocol" }
diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs
index 1c88e9cbdd..3e543358bb 100644
--- a/codex-rs/protocol/src/config_types.rs
+++ b/codex-rs/protocol/src/config_types.rs
@@ -1,10 +1,13 @@
 use serde::Deserialize;
 use serde::Serialize;
 use strum_macros::Display;
+use strum_macros::EnumIter;
 use ts_rs::TS;
 
 /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
-#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)]
+#[derive(
+    Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS, EnumIter,
+)]
 #[serde(rename_all = "lowercase")]
 #[strum(serialize_all = "lowercase")]
 pub enum ReasoningEffort {
@@ -13,8 +16,6 @@ pub enum ReasoningEffort {
     #[default]
     Medium,
     High,
-    /// Option to disable reasoning.
-    None,
 }
 
 /// A summary of the reasoning performed by the model. This can be useful for
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 810d6cb13c..ffb3d7cacb 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -382,6 +382,11 @@ impl App<'_> {
                             self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
                         }
                     }
+                    SlashCommand::Model => {
+                        if let AppState::Chat { widget } = &mut self.app_state {
+                            widget.open_model_popup();
+                        }
+                    }
                     SlashCommand::Quit => {
                         break;
                     }
@@ -499,6 +504,16 @@ impl App<'_> {
                         widget.apply_file_search_result(query, matches);
                     }
                 }
+                AppEvent::UpdateReasoningEffort(effort) => {
+                    if let AppState::Chat { widget } = &mut self.app_state {
+                        widget.set_reasoning_effort(effort);
+                    }
+                }
+                AppEvent::UpdateModel(model) => {
+                    if let AppState::Chat { widget } = &mut self.app_state {
+                        widget.set_model(model);
+                    }
+                }
             }
         }
         terminal.clear()?;
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
index 1afffd756a..1780dbc710 100644
--- a/codex-rs/tui/src/app_event.rs
+++ b/codex-rs/tui/src/app_event.rs
@@ -6,6 +6,7 @@ use std::time::Duration;
 
 use crate::app::ChatWidgetArgs;
 use crate::slash_command::SlashCommand;
+use codex_core::protocol_config_types::ReasoningEffort;
 
 #[allow(clippy::large_enum_variant)]
 #[derive(Debug)]
@@ -63,4 +64,10 @@ pub(crate) enum AppEvent {
     /// Onboarding: result of login_with_chatgpt.
     OnboardingAuthComplete(Result<(), String>),
     OnboardingComplete(ChatWidgetArgs),
+
+    /// Update the current reasoning effort in the running app and widget.
+    UpdateReasoningEffort(ReasoningEffort),
+
+    /// Update the current model slug in the running app and widget.
+    UpdateModel(String),
 }
diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs
index b7a203e9fd..9ae7ada81a 100644
--- a/codex-rs/tui/src/bottom_pane/command_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/command_popup.rs
@@ -71,6 +71,8 @@ impl CommandPopup {
             for (_, cmd) in self.all_commands.iter() {
                 out.push((cmd, None, 0));
             }
+            // Keep the original presentation order when no filter is applied.
+            return out;
         } else {
             for (_, cmd) in self.all_commands.iter() {
                 if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
@@ -78,6 +80,7 @@ impl CommandPopup {
                 }
             }
         }
+        // When filtering, sort by ascending score and then by command for stability.
         out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command())));
         out
     }
@@ -128,7 +131,7 @@ impl WidgetRef for CommandPopup {
                 })
                 .collect()
         };
-        render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
+        render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false);
     }
 }
 
diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
index a811a22a8c..f046a2f144 100644
--- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
@@ -134,9 +134,9 @@ impl WidgetRef for &FileSearchPopup {
 
         if self.waiting && rows_all.is_empty() {
             // Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
-            render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS);
+            render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS, false);
         } else {
-            render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
+            render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false);
         }
     }
 }
diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs
new file mode 100644
index 0000000000..3b03eb9c03
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs
@@ -0,0 +1,250 @@
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use crossterm::event::KeyModifiers;
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::Widget;
+
+use crate::app_event_sender::AppEventSender;
+
+use super::BottomPane;
+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::render_rows;
+
+/// One selectable item in the generic selection list.
+pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
+
+pub(crate) struct SelectionItem {
+    pub name: String,
+    pub description: Option<String>,
+    pub is_current: bool,
+    pub actions: Vec<SelectionAction>,
+}
+
+pub(crate) struct ListSelectionView {
+    title: String,
+    subtitle: Option<String>,
+    footer_hint: Option<String>,
+    items: Vec<SelectionItem>,
+    state: ScrollState,
+    complete: bool,
+    app_event_tx: AppEventSender,
+}
+
+impl ListSelectionView {
+    fn dim_prefix_span() -> Span<'static> {
+        Span::styled("▌ ", Style::default().add_modifier(Modifier::DIM))
+    }
+
+    fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
+        let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
+        para.render(area, buf);
+    }
+    pub fn new(
+        title: String,
+        subtitle: Option<String>,
+        footer_hint: Option<String>,
+        items: Vec<SelectionItem>,
+        app_event_tx: AppEventSender,
+    ) -> Self {
+        let mut s = Self {
+            title,
+            subtitle,
+            footer_hint,
+            items,
+            state: ScrollState::new(),
+            complete: false,
+            app_event_tx,
+        };
+        let len = s.items.len();
+        if let Some(idx) = s.items.iter().position(|it| it.is_current) {
+            s.state.selected_idx = Some(idx);
+        }
+        s.state.clamp_selection(len);
+        s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+        s
+    }
+
+    fn move_up(&mut self) {
+        let len = self.items.len();
+        self.state.move_up_wrap(len);
+        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+    }
+
+    fn move_down(&mut self) {
+        let len = self.items.len();
+        self.state.move_down_wrap(len);
+        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+    }
+
+    fn accept(&mut self) {
+        if let Some(idx) = self.state.selected_idx {
+            if let Some(item) = self.items.get(idx) {
+                for act in &item.actions {
+                    act(&self.app_event_tx);
+                }
+                self.complete = true;
+            }
+        } else {
+            self.complete = true;
+        }
+    }
+
+    fn cancel(&mut self) {
+        // Close the popup without performing any actions.
+        self.complete = true;
+    }
+}
+
+impl BottomPaneView<'_> for ListSelectionView {
+    fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
+        match key_event {
+            KeyEvent {
+                code: KeyCode::Up, ..
+            } => self.move_up(),
+            KeyEvent {
+                code: KeyCode::Down,
+                ..
+            } => self.move_down(),
+            KeyEvent {
+                code: KeyCode::Esc, ..
+            } => self.cancel(),
+            KeyEvent {
+                code: KeyCode::Enter,
+                modifiers: KeyModifiers::NONE,
+                ..
+            } => self.accept(),
+            _ => {}
+        }
+    }
+
+    fn is_complete(&self) -> bool {
+        self.complete
+    }
+
+    fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent {
+        self.complete = true;
+        CancellationEvent::Handled
+    }
+
+    fn desired_height(&self, _width: u16) -> u16 {
+        let rows = (self.items.len()).clamp(1, MAX_POPUP_ROWS);
+        // +1 for the title row, +1 for optional subtitle, +1 for optional footer
+        let mut height = rows as u16 + 1;
+        if self.subtitle.is_some() {
+            // +1 for subtitle, +1 for a blank spacer line beneath it
+            height = height.saturating_add(2);
+        }
+        if self.footer_hint.is_some() {
+            height = height.saturating_add(2);
+        }
+        height
+    }
+
+    fn render(&self, area: Rect, buf: &mut Buffer) {
+        if area.height == 0 || area.width == 0 {
+            return;
+        }
+
+        let title_area = Rect {
+            x: area.x,
+            y: area.y,
+            width: area.width,
+            height: 1,
+        };
+
+        let title_spans: Vec<Span<'static>> = vec![
+            Self::dim_prefix_span(),
+            Span::styled(
+                self.title.clone(),
+                Style::default().add_modifier(Modifier::BOLD),
+            ),
+        ];
+        let title_para = Paragraph::new(Line::from(title_spans));
+        title_para.render(title_area, buf);
+
+        let mut next_y = area.y.saturating_add(1);
+        if let Some(sub) = &self.subtitle {
+            let subtitle_area = Rect {
+                x: area.x,
+                y: next_y,
+                width: area.width,
+                height: 1,
+            };
+            let subtitle_spans: Vec<Span<'static>> = vec![
+                Self::dim_prefix_span(),
+                Span::styled(sub.clone(), Style::default().add_modifier(Modifier::DIM)),
+            ];
+            let subtitle_para = Paragraph::new(Line::from(subtitle_spans));
+            subtitle_para.render(subtitle_area, buf);
+            // Render the extra spacer line with the dimmed prefix to align with title/subtitle
+            let spacer_area = Rect {
+                x: area.x,
+                y: next_y.saturating_add(1),
+                width: area.width,
+                height: 1,
+            };
+            Self::render_dim_prefix_line(spacer_area, buf);
+            next_y = next_y.saturating_add(2);
+        }
+
+        let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
+        let rows_area = Rect {
+            x: area.x,
+            y: next_y,
+            width: area.width,
+            height: area
+                .height
+                .saturating_sub(next_y.saturating_sub(area.y))
+                .saturating_sub(footer_reserved),
+        };
+
+        let rows: Vec<GenericDisplayRow> = self
+            .items
+            .iter()
+            .enumerate()
+            .map(|(i, it)| {
+                let is_selected = self.state.selected_idx == Some(i);
+                let prefix = if is_selected { '>' } else { ' ' };
+                let name_with_marker = if it.is_current {
+                    format!("{} (current)", it.name)
+                } else {
+                    it.name.clone()
+                };
+                let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
+                GenericDisplayRow {
+                    name: display_name,
+                    match_indices: None,
+                    is_current: it.is_current,
+                    description: it.description.clone(),
+                }
+            })
+            .collect();
+        if rows_area.height > 0 {
+            render_rows(rows_area, buf, &rows, &self.state, MAX_POPUP_ROWS, true);
+        }
+
+        if let Some(hint) = &self.footer_hint {
+            let footer_area = Rect {
+                x: area.x,
+                y: area.y + area.height - 1,
+                width: area.width,
+                height: 1,
+            };
+            let footer_para = Paragraph::new(Line::from(Span::styled(
+                hint.clone(),
+                Style::default().add_modifier(Modifier::DIM),
+            )));
+            footer_para.render(footer_area, buf);
+        }
+    }
+}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index c05e9e9437..b27ea6e945 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -17,6 +17,7 @@ mod chat_composer;
 mod chat_composer_history;
 mod command_popup;
 mod file_search_popup;
+mod list_selection_view;
 mod popup_consts;
 mod scroll_state;
 mod selection_popup_common;
@@ -33,6 +34,8 @@ pub(crate) use chat_composer::ChatComposer;
 pub(crate) use chat_composer::InputResult;
 
 use approval_modal_view::ApprovalModalView;
+pub(crate) use list_selection_view::SelectionAction;
+pub(crate) use list_selection_view::SelectionItem;
 use status_indicator_view::StatusIndicatorView;
 
 /// Pane displayed in the lower half of the chat UI.
@@ -212,6 +215,26 @@ impl BottomPane<'_> {
         }
     }
 
+    /// Show a generic list selection view with the provided items.
+    pub(crate) fn show_selection_view(
+        &mut self,
+        title: String,
+        subtitle: Option<String>,
+        footer_hint: Option<String>,
+        items: Vec<SelectionItem>,
+    ) {
+        let view = list_selection_view::ListSelectionView::new(
+            title,
+            subtitle,
+            footer_hint,
+            items,
+            self.app_event_tx.clone(),
+        );
+        self.active_view = Some(Box::new(view));
+        self.status_view_active = false;
+        self.request_redraw();
+    }
+
     /// Update the live status text shown while a task is running.
     /// If a modal view is active (i.e., not the status indicator), this is a noop.
     pub(crate) fn update_status_text(&mut self, text: String) {
diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
index 6098a957da..a83ec15182 100644
--- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
+++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
@@ -34,6 +34,7 @@ pub(crate) fn render_rows(
     rows_all: &[GenericDisplayRow],
     state: &ScrollState,
     max_results: usize,
+    _dim_non_selected: bool,
 ) {
     let mut rows: Vec<Row> = Vec::new();
     if rows_all.is_empty() {
@@ -69,7 +70,7 @@ pub(crate) fn render_rows(
             let GenericDisplayRow {
                 name,
                 match_indices,
-                is_current,
+                is_current: _is_current,
                 description,
             } = row;
 
@@ -104,8 +105,6 @@ pub(crate) fn render_rows(
                         .fg(Color::Cyan)
                         .add_modifier(Modifier::BOLD),
                 );
-            } else if *is_current {
-                cell = cell.style(Style::default().fg(Color::Cyan));
             }
             rows.push(Row::new(vec![cell]));
         }
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index bbb99f52e2..360a9f8ef1 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -45,6 +45,8 @@ use crate::bottom_pane::BottomPane;
 use crate::bottom_pane::BottomPaneParams;
 use crate::bottom_pane::CancellationEvent;
 use crate::bottom_pane::InputResult;
+use crate::bottom_pane::SelectionAction;
+use crate::bottom_pane::SelectionItem;
 use crate::history_cell;
 use crate::history_cell::CommandOutput;
 use crate::history_cell::ExecCell;
@@ -58,7 +60,10 @@ mod agent;
 use self::agent::spawn_agent;
 use crate::streaming::controller::AppEventHistorySink;
 use crate::streaming::controller::StreamController;
+use codex_common::model_presets::ModelPreset;
+use codex_common::model_presets::builtin_model_presets;
 use codex_core::ConversationManager;
+use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
 use codex_file_search::FileMatch;
 use uuid::Uuid;
 
@@ -687,6 +692,57 @@ impl ChatWidget<'_> {
         ));
     }
 
+    /// 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();
+        let current_effort = self.config.model_reasoning_effort;
+        let presets: &[ModelPreset] = builtin_model_presets();
+
+        let mut items: Vec<SelectionItem> = Vec::new();
+        for preset in presets.iter() {
+            let name = preset.label.to_string();
+            let description = Some(preset.description.to_string());
+            let is_current = preset.model == current_model && preset.effort == current_effort;
+            let model_slug = preset.model.to_string();
+            let effort = preset.effort;
+            let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
+                tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
+                    cwd: None,
+                    approval_policy: None,
+                    sandbox_policy: None,
+                    model: Some(model_slug.clone()),
+                    effort: Some(effort),
+                    summary: None,
+                }));
+                tx.send(AppEvent::UpdateModel(model_slug.clone()));
+                tx.send(AppEvent::UpdateReasoningEffort(effort));
+            })];
+            items.push(SelectionItem {
+                name,
+                description,
+                is_current,
+                actions,
+            });
+        }
+
+        self.bottom_pane.show_selection_view(
+            "Select model and reasoning level".to_string(),
+            Some("Switch between OpenAI models for this and future Codex CLI session".to_string()),
+            Some("Press Enter to confirm or Esc to go back".to_string()),
+            items,
+        );
+    }
+
+    /// Set the reasoning effort in the widget's config copy.
+    pub(crate) fn set_reasoning_effort(&mut self, effort: ReasoningEffortConfig) {
+        self.config.model_reasoning_effort = effort;
+    }
+
+    /// Set the model in the widget's config copy.
+    pub(crate) fn set_model(&mut self, model: String) {
+        self.config.model = model;
+    }
+
     pub(crate) fn add_mcp_output(&mut self) {
         if self.config.mcp_servers.is_empty() {
             self.add_to_history(&history_cell::empty_mcp_output());
diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs
index 56a6c316b5..d572895abb 100644
--- a/codex-rs/tui/src/slash_command.rs
+++ b/codex-rs/tui/src/slash_command.rs
@@ -12,6 +12,7 @@ use strum_macros::IntoStaticStr;
 pub enum SlashCommand {
     // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
     // more frequently used commands should be listed first.
+    Model,
     New,
     Init,
     Compact,
@@ -36,6 +37,7 @@ impl SlashCommand {
             SlashCommand::Diff => "show git diff (including untracked files)",
             SlashCommand::Mention => "mention a file",
             SlashCommand::Status => "show current session configuration and token usage",
+            SlashCommand::Model => "choose a model preset (model + reasoning effort)",
             SlashCommand::Mcp => "list configured MCP tools",
             SlashCommand::Logout => "log out of Codex",
             #[cfg(debug_assertions)]

Review Comments

codex-rs/protocol/src/config_types.rs

@@ -13,8 +16,6 @@ pub enum ReasoningEffort {
     #[default]
     Medium,
     High,
-    /// Option to disable reasoning.

Need to update config.md to reflect this change.

codex-rs/tui/src/bottom_pane/list_selection_view.rs

@@ -0,0 +1,127 @@
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use crossterm::event::KeyModifiers;
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+
+use crate::app_event_sender::AppEventSender;
+
+use super::BottomPane;
+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::render_rows;
+
+/// One selectable item in the generic selection list.
+pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
+
+pub(crate) struct SelectionItem {
+    pub name: String,
+    pub description: Option<String>,
+    pub is_current: bool,
+    pub actions: Vec<SelectionAction>,
+}
+
+pub(crate) struct ListSelectionView {
+    items: Vec<SelectionItem>,
+    state: ScrollState,
+    complete: bool,
+    app_event_tx: AppEventSender,
+}
+
+impl ListSelectionView {
+    pub fn new(items: Vec<SelectionItem>, app_event_tx: AppEventSender) -> Self {
+        let mut s = Self {
+            items,
+            state: ScrollState::new(),
+            complete: false,
+            app_event_tx,
+        };
+        let len = s.items.len();
+        // Default selection to the first item that matches the current config choice.
+        if let Some(idx) = s.items.iter().position(|it| it.is_current) {
+            s.state.selected_idx = Some(idx);
+        }
+        s.state.clamp_selection(len);
+        s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+        s
+    }
+
+    fn move_up(&mut self) {
+        let len = self.items.len();
+        self.state.move_up_wrap(len);
+        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+    }
+
+    fn move_down(&mut self) {
+        let len = self.items.len();
+        self.state.move_down_wrap(len);
+        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+    }
+
+    fn accept(&mut self) {
+        if let Some(idx) = self.state.selected_idx {
+            if let Some(item) = self.items.get(idx) {
+                for act in &item.actions {
+                    act(&self.app_event_tx);
+                }
+                self.complete = true;
+            }
+        } else {
+            self.complete = true;
+        }
+    }
+}
+
+impl BottomPaneView<'_> for ListSelectionView {
+    fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
+        match key_event {
+            KeyEvent {
+                code: KeyCode::Up, ..
+            } => self.move_up(),
+            KeyEvent {
+                code: KeyCode::Down,
+                ..
+            } => self.move_down(),
+            KeyEvent {
+                code: KeyCode::Esc, ..
+            } => self.accept(),

cancel?

@@ -0,0 +1,127 @@
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use crossterm::event::KeyModifiers;
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+
+use crate::app_event_sender::AppEventSender;
+
+use super::BottomPane;
+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::render_rows;
+
+/// One selectable item in the generic selection list.
+pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
+
+pub(crate) struct SelectionItem {
+    pub name: String,
+    pub description: Option<String>,
+    pub is_current: bool,
+    pub actions: Vec<SelectionAction>,
+}
+
+pub(crate) struct ListSelectionView {
+    items: Vec<SelectionItem>,
+    state: ScrollState,
+    complete: bool,
+    app_event_tx: AppEventSender,
+}
+
+impl ListSelectionView {
+    pub fn new(items: Vec<SelectionItem>, app_event_tx: AppEventSender) -> Self {
+        let mut s = Self {
+            items,
+            state: ScrollState::new(),
+            complete: false,
+            app_event_tx,
+        };
+        let len = s.items.len();
+        // Default selection to the first item that matches the current config choice.
+        if let Some(idx) = s.items.iter().position(|it| it.is_current) {
+            s.state.selected_idx = Some(idx);
+        }
+        s.state.clamp_selection(len);
+        s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+        s
+    }
+
+    fn move_up(&mut self) {
+        let len = self.items.len();
+        self.state.move_up_wrap(len);
+        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+    }
+
+    fn move_down(&mut self) {
+        let len = self.items.len();
+        self.state.move_down_wrap(len);
+        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
+    }
+
+    fn accept(&mut self) {
+        if let Some(idx) = self.state.selected_idx {
+            if let Some(item) = self.items.get(idx) {
+                for act in &item.actions {
+                    act(&self.app_event_tx);
+                }
+                self.complete = true;
+            }
+        } else {
+            self.complete = true;
+        }
+    }
+}
+
+impl BottomPaneView<'_> for ListSelectionView {
+    fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
+        match key_event {
+            KeyEvent {
+                code: KeyCode::Up, ..
+            } => self.move_up(),
+            KeyEvent {
+                code: KeyCode::Down,
+                ..
+            } => self.move_down(),
+            KeyEvent {
+                code: KeyCode::Esc, ..
+            } => self.accept(),
+            KeyEvent {
+                code: KeyCode::Enter,
+                modifiers: KeyModifiers::NONE,
+                ..
+            } => self.accept(),
+            _ => {}
+        }
+    }
+
+    fn is_complete(&self) -> bool {
+        self.complete
+    }
+
+    fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent {
+        self.complete = true;
+        CancellationEvent::Handled
+    }
+
+    fn desired_height(&self, _width: u16) -> u16 {
+        let rows = (self.items.len()).clamp(1, MAX_POPUP_ROWS);
+        rows as u16
+    }
+
+    fn render(&self, area: Rect, buf: &mut Buffer) {
+        let rows: Vec<GenericDisplayRow> = self

Please add a title.