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

957 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #2435: Support changing reasoning effort
- URL: https://github.com/openai/codex/pull/2435
- Author: aibrahim-oai
- Created: 2025-08-18 21:10:51 UTC
- Updated: 2025-08-19 17:55:15 UTC
- Changes: +428/-17, Files changed: 16, Commits: 8
## Description
https://github.com/user-attachments/assets/50198ee8-5915-47a3-bb71-69af65add1ef
Building up on #2431 #2428
## Full Diff
```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
- Created: 2025-08-18 21:53:16 UTC | Link: https://github.com/openai/codex/pull/2435#discussion_r2283576403
```diff
@@ -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
- Created: 2025-08-18 21:58:48 UTC | Link: https://github.com/openai/codex/pull/2435#discussion_r2283585843
```diff
@@ -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?
- Created: 2025-08-18 21:59:18 UTC | Link: https://github.com/openai/codex/pull/2435#discussion_r2283586553
```diff
@@ -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.