mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
33 KiB
33 KiB
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 --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 no‑op.
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
@@ -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
@@ -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
@@ -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.