diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 4d72e27af8..7b205e662f 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,9 +1,10 @@ use serde::Deserialize; use serde::Serialize; use strum_macros::Display; +use strum_macros::EnumIter; /// 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)] +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, EnumIter)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ReasoningEffort { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 08889b66b4..95c80cb3c7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -416,6 +416,13 @@ impl App<'_> { widget.add_status_output(); } } + SlashCommand::Effort => { + if self.config.model_family.supports_reasoning_summaries { + if let AppState::Chat { widget } = &mut self.app_state { + widget.open_reasoning_effort_popup(); + } + } + } #[cfg(debug_assertions)] SlashCommand::TestApproval => { use codex_core::protocol::EventMsg; diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index 0cfc24d780..3630e21588 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -99,6 +99,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane)); assert!(view.queue.is_empty()); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c801d6fd2a..24d4d6ab4c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -21,6 +21,8 @@ use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; +use super::choice_popup::ChoicePayload; +use super::choice_popup::ChoicePopup; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; @@ -28,6 +30,8 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; +use codex_core::protocol::Op; +use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -61,6 +65,7 @@ pub(crate) struct ChatComposer { token_usage_info: Option, has_focus: bool, placeholder_text: String, + show_reasoning_commands: bool, } /// Popup state – at most one can be visible at any time. @@ -68,6 +73,7 @@ enum ActivePopup { None, Command(CommandPopup), File(FileSearchPopup), + Choice(ChoicePopup), } impl ChatComposer { @@ -76,6 +82,7 @@ impl ChatComposer { app_event_tx: AppEventSender, enhanced_keys_supported: bool, placeholder_text: String, + show_reasoning_commands: bool, ) -> Self { let use_shift_enter_hint = enhanced_keys_supported; @@ -93,6 +100,7 @@ impl ChatComposer { token_usage_info: None, has_focus: has_input_focus, placeholder_text, + show_reasoning_commands, } } @@ -102,6 +110,7 @@ impl ChatComposer { ActivePopup::None => 1u16, ActivePopup::Command(c) => c.calculate_required_height(), ActivePopup::File(c) => c.calculate_required_height(), + ActivePopup::Choice(c) => c.calculate_required_height(), } } @@ -109,6 +118,7 @@ impl ChatComposer { let popup_height = match &self.active_popup { ActivePopup::Command(popup) => popup.calculate_required_height(), ActivePopup::File(popup) => popup.calculate_required_height(), + ActivePopup::Choice(popup) => popup.calculate_required_height(), ActivePopup::None => 1, }; let [textarea_rect, _] = @@ -211,15 +221,22 @@ impl ChatComposer { let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::Choice(_) => self.handle_key_event_with_choice_popup(key_event), ActivePopup::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); + match self.active_popup { + ActivePopup::Command(_) => { + self.dismissed_file_popup_token = None; + } + ActivePopup::File(_) | ActivePopup::None => { + self.sync_file_search_popup(); + } + ActivePopup::Choice(_) => { + // Do not clobber a generic choice popup with file-search sync. + } } result @@ -335,6 +352,60 @@ impl ChatComposer { } } + /// Handle key events when a generic choice popup is visible. + fn handle_key_event_with_choice_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let ActivePopup::Choice(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if let Some(sel) = popup.selected_payload() { + match sel { + ChoicePayload::ReasoningEffort(effort) => { + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: Some(*effort), + summary: None, + })); + } + } + self.active_popup = ActivePopup::None; + return (InputResult::None, true); + } + (InputResult::None, false) + } + input => self.handle_input_basic(input), + } + } + /// Extract the `@token` that the cursor is currently positioned on, if any. /// /// The returned string **does not** include the leading `@`. @@ -562,6 +633,7 @@ impl ChatComposer { _ => { if input_starts_with_slash { let mut command_popup = CommandPopup::new(); + command_popup.filter_for_capabilities(self.show_reasoning_commands); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -618,6 +690,12 @@ impl ChatComposer { fn set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } + + /// Open a choice popup to select a reasoning effort value. + pub(crate) fn open_reasoning_effort_popup(&mut self, current: ReasoningEffortConfig) { + let popup = ChoicePopup::new_reasoning_effort(current); + self.active_popup = ActivePopup::Choice(popup); + } } impl WidgetRef for &ChatComposer { @@ -625,6 +703,7 @@ impl WidgetRef for &ChatComposer { let popup_height = match &self.active_popup { ActivePopup::Command(popup) => popup.calculate_required_height(), ActivePopup::File(popup) => popup.calculate_required_height(), + ActivePopup::Choice(popup) => popup.calculate_required_height(), ActivePopup::None => 1, }; let [textarea_rect, popup_rect] = @@ -636,6 +715,9 @@ impl WidgetRef for &ChatComposer { ActivePopup::File(popup) => { popup.render_ref(popup_rect, buf); } + ActivePopup::Choice(popup) => { + popup.render_ref(popup_rect, buf); + } ActivePopup::None => { let bottom_line_rect = popup_rect; let key_hint_style = Style::default().fg(Color::Cyan); @@ -887,8 +969,13 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); @@ -911,8 +998,13 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); let needs_redraw = composer.handle_paste(large.clone()); @@ -941,8 +1033,13 @@ mod tests { let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); composer.handle_paste(large); assert_eq!(composer.pending_pastes.len(), 1); @@ -983,6 +1080,7 @@ mod tests { sender.clone(), false, "Ask Codex to do anything".to_string(), + false, ); if let Some(text) = input { @@ -1021,8 +1119,13 @@ mod tests { let (tx, rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Type the slash command. for ch in [ @@ -1065,8 +1168,13 @@ mod tests { let (tx, rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); @@ -1105,8 +1213,13 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Define test cases: (paste content, is_large) let test_cases = [ @@ -1179,8 +1292,13 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Define test cases: (content, is_large) let test_cases = [ @@ -1246,8 +1364,13 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = - ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Define test cases: (cursor_position_from_end, expected_pending_count) let test_cases = [ diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index b7a203e9fd..4ebe4c4c65 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -25,6 +25,15 @@ impl CommandPopup { } } + /// Filter commands based on runtime capabilities (e.g., hide reasoning-related + /// commands when the current model does not support them). + pub(crate) fn filter_for_capabilities(&mut self, show_reasoning_commands: bool) { + if !show_reasoning_commands { + self.all_commands + .retain(|(_, c)| !matches!(c, SlashCommand::Effort)); + } + } + /// Update the filter string based on the current composer text. The text /// passed in is expected to start with a leading '/'. Everything after the /// *first* '/" on the *first* line becomes the active filter that is used diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c05e9e9437..a068216bd0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -16,6 +16,7 @@ mod bottom_pane_view; mod chat_composer; mod chat_composer_history; mod command_popup; +mod choice_popup; mod file_search_popup; mod popup_consts; mod scroll_state; @@ -59,6 +60,8 @@ pub(crate) struct BottomPaneParams { pub(crate) has_input_focus: bool, pub(crate) enhanced_keys_supported: bool, pub(crate) placeholder_text: String, + /// Whether to show reasoning-related slash commands. + pub(crate) show_reasoning_commands: bool, } impl BottomPane<'_> { @@ -71,6 +74,7 @@ impl BottomPane<'_> { params.app_event_tx.clone(), enhanced_keys_supported, params.placeholder_text, + params.show_reasoning_commands, ), active_view: None, app_event_tx: params.app_event_tx, @@ -297,6 +301,16 @@ impl BottomPane<'_> { self.composer.on_file_search_result(query, matches); self.request_redraw(); } + + /// Open a popup to choose the reasoning effort and apply the chosen + /// override on selection. + pub(crate) fn open_reasoning_effort_popup( + &mut self, + current: codex_core::protocol_config_types::ReasoningEffort, + ) { + self.composer.open_reasoning_effort_popup(current); + self.request_redraw(); + } } impl WidgetRef for &BottomPane<'_> { @@ -355,6 +369,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); @@ -373,6 +388,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); // Create an approval modal (active view). @@ -402,6 +418,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); // Start a running task so the status indicator replaces the composer. @@ -452,6 +469,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); // Begin a task: show initial status. @@ -484,6 +502,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); // Activate spinner (status view replaces composer) with no live ring. @@ -536,6 +555,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); pane.set_task_running(true); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 88b816d976..df6f665178 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -507,6 +507,7 @@ impl ChatWidget<'_> { has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder, + show_reasoning_commands: config.model_family.supports_reasoning_summaries, }), active_exec_cell: None, config: config.clone(), @@ -685,6 +686,12 @@ impl ChatWidget<'_> { )); } + /// Open a popup to choose the model reasoning effort. + pub(crate) fn open_reasoning_effort_popup(&mut self) { + let current = self.config.model_reasoning_effort; + self.bottom_pane.open_reasoning_effort_popup(current); + } + /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 192ab3c6ae..31fc295dd2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -125,6 +125,7 @@ fn make_chatwidget_manual() -> ( has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + show_reasoning_commands: false, }); let widget = ChatWidget { app_event_tx, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 71d60237cf..fdfef7b0fe 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -18,6 +18,8 @@ pub enum SlashCommand { Diff, Mention, Status, + /// Choose model reasoning effort (only shown when supported by model family). + Effort, Logout, Quit, #[cfg(debug_assertions)] @@ -35,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::Effort => "choose model reasoning effort (low/medium/high/minimal/none)", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] SlashCommand::TestApproval => "test approval request",