diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 26585753..80809434 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 8595262c..dc684c21 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 00000000..16ec9be1 --- /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 0d5df17c..80ef1466 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 f20912b2..760729d8 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, - /// 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 3f5c2a0f..44a39071 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 1c88e9cb..3e543358 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 810d6cb1..ffb3d7ca 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 1afffd75..1780dbc7 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 b7a203e9..9ae7ada8 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 a811a22a..f046a2f1 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 00000000..3b03eb9c --- /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; + +pub(crate) struct SelectionItem { + pub name: String, + pub description: Option, + pub is_current: bool, + pub actions: Vec, +} + +pub(crate) struct ListSelectionView { + title: String, + subtitle: Option, + footer_hint: Option, + items: Vec, + 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, + footer_hint: Option, + items: Vec, + 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> = 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> = 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 = 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 c05e9e94..b27ea6e9 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, + footer_hint: Option, + items: Vec, + ) { + 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 6098a957..a83ec151 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 = 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 bbb99f52..360a9f8e 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 = 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 = 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 56a6c316..d572895a 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)]