Codex Handbook
tui/src/chatwidget/reasoning_shortcuts.rs 302 lines
//! Keyboard shortcuts for stepping the active model's reasoning effort.//!//! The main chat surface treats `Alt+,` and `Alt+.` as small adjustments to the//! current model configuration. This module keeps that behavior separate from//! the larger `ChatWidget` key dispatcher while still reusing the same//! model-selection and Plan-mode scope paths as the settings popups.//!//! The shortcut state machine is deliberately narrow: it only handles key//! presses when no modal or popup owns input, it anchors unset reasoning to the//! current model preset's default, and it walks only efforts advertised by the//! active model. Unsupported efforts anchor to the model default, or the first//! advertised effort when the default is absent, before stepping through the//! advertised order.use codex_protocol::config_types::ModeKind;use codex_protocol::openai_models::ModelPreset;use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;use crossterm::event::KeyEvent;use super::ChatWidget;use crate::app_event::AppEvent;use crate::key_hint::KeyBindingListExt;/// Direction requested by a reasoning-level shortcut.#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub(super) enum ReasoningShortcutDirection {    Lower,    Raise,}impl ReasoningShortcutDirection {    fn bound_message(self, effort: &ReasoningEffortConfig) -> String {        let label = ChatWidget::reasoning_effort_sentence_label(effort);        match self {            Self::Lower => format!("Reasoning is already at the lowest level ({label})."),            Self::Raise => format!("Reasoning is already at the highest level ({label})."),        }    }}impl ChatWidget {    /// Handles main-surface reasoning shortcuts before general key dispatch.    ///    /// Returning `true` means the key was recognized as a reasoning shortcut and    /// fully handled, even if handling only produced an informational message at    /// a boundary. Returning `false` leaves the key available to the normal chat    /// input flow, which is important while a popup or modal has focus.    ///    /// Callers should route recognized shortcuts through this method rather than    /// directly mutating reasoning state. It applies normal-mode changes without    /// persisting them. In Plan mode, shortcuts apply only to the active    /// Plan-mode override and skip the global-vs-Plan scope prompt.    pub(super) fn handle_reasoning_shortcut(&mut self, key_event: KeyEvent) -> bool {        let direction = if self            .chat_keymap            .decrease_reasoning_effort            .is_pressed(key_event)        {            ReasoningShortcutDirection::Lower        } else if self            .chat_keymap            .increase_reasoning_effort            .is_pressed(key_event)        {            ReasoningShortcutDirection::Raise        } else {            return false;        };        if !self.bottom_pane.no_modal_or_popup_active() {            return false;        }        if !self.is_session_configured() {            self.add_info_message(                "Reasoning shortcuts are disabled until startup completes.".to_string(),                /*hint*/ None,            );            return true;        }        let current_model = self.current_model().to_string();        let Some(preset) = self.current_model_preset() else {            self.add_info_message(                format!("Reasoning shortcuts are unavailable for {current_model}."),                /*hint*/ None,            );            return true;        };        let choices = reasoning_choices(&preset);        let configured_effort = self            .effective_reasoning_effort()            .unwrap_or_else(|| preset.default_reasoning_effort.clone());        let current_effort = if choices.contains(&configured_effort) {            configured_effort        } else if choices.contains(&preset.default_reasoning_effort) {            preset.default_reasoning_effort        } else {            choices                .first()                .cloned()                .unwrap_or(preset.default_reasoning_effort)        };        let Some(next_effort) =            next_reasoning_effort(&choices, Some(current_effort.clone()), direction)        else {            self.add_info_message(direction.bound_message(&current_effort), /*hint*/ None);            return true;        };        if self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan {            self.app_event_tx                .send(AppEvent::UpdatePlanModeReasoningEffort(Some(next_effort)));        } else {            self.apply_model_and_effort_without_persist(current_model, Some(next_effort));        }        true    }    fn current_model_preset(&self) -> Option<ModelPreset> {        let current_model = self.current_model();        self.model_catalog            .try_list_models()            .ok()?            .into_iter()            .find(|preset| preset.model == current_model)    }}fn reasoning_choices(preset: &ModelPreset) -> Vec<ReasoningEffortConfig> {    let mut choices: Vec<ReasoningEffortConfig> = preset        .supported_reasoning_efforts        .iter()        .map(|option| option.effort.clone())        .collect();    if choices.is_empty() {        choices.push(preset.default_reasoning_effort.clone());    }    choices}fn next_reasoning_effort(    choices: &[ReasoningEffortConfig],    current_effort: Option<ReasoningEffortConfig>,    direction: ReasoningShortcutDirection,) -> Option<ReasoningEffortConfig> {    let current_effort = current_effort?;    if let Some(current_index) = choices.iter().position(|choice| choice == &current_effort) {        return match direction {            ReasoningShortcutDirection::Lower => current_index                .checked_sub(1)                .and_then(|index| choices.get(index))                .cloned(),            ReasoningShortcutDirection::Raise => choices.get(current_index + 1).cloned(),        };    }    None}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    #[test]    fn next_reasoning_effort_raises_from_default_anchor() {        let choices = vec![            ReasoningEffortConfig::Low,            ReasoningEffortConfig::Medium,            ReasoningEffortConfig::High,            ReasoningEffortConfig::XHigh,        ];        assert_eq!(            next_reasoning_effort(                &choices,                Some(ReasoningEffortConfig::Medium),                ReasoningShortcutDirection::Raise,            ),            Some(ReasoningEffortConfig::High)        );    }    #[test]    fn next_reasoning_effort_lowers_from_default_anchor() {        let choices = vec![            ReasoningEffortConfig::Low,            ReasoningEffortConfig::Medium,            ReasoningEffortConfig::High,        ];        assert_eq!(            next_reasoning_effort(                &choices,                Some(ReasoningEffortConfig::Medium),                ReasoningShortcutDirection::Lower,            ),            Some(ReasoningEffortConfig::Low)        );    }    #[test]    fn next_reasoning_effort_does_not_infer_position_for_unsupported_current() {        let choices = vec![ReasoningEffortConfig::Low, ReasoningEffortConfig::High];        assert_eq!(            (                next_reasoning_effort(                    &choices,                    Some(ReasoningEffortConfig::Medium),                    ReasoningShortcutDirection::Raise,                ),                next_reasoning_effort(                    &choices,                    Some(ReasoningEffortConfig::Medium),                    ReasoningShortcutDirection::Lower,                ),            ),            (None, None)        );    }    #[test]    fn next_reasoning_effort_uses_advertised_order_for_custom_levels() {        let custom_effort = ReasoningEffortConfig::Custom("max".to_string());        let choices = vec![            ReasoningEffortConfig::High,            ReasoningEffortConfig::Low,            custom_effort.clone(),        ];        assert_eq!(            (                next_reasoning_effort(                    &choices,                    Some(ReasoningEffortConfig::High),                    ReasoningShortcutDirection::Raise,                ),                next_reasoning_effort(                    &choices,                    Some(custom_effort),                    ReasoningShortcutDirection::Lower,                ),            ),            (                Some(ReasoningEffortConfig::Low),                Some(ReasoningEffortConfig::Low),            )        );    }    #[test]    fn next_reasoning_effort_clamps_at_bounds() {        let choices = vec![            ReasoningEffortConfig::Low,            ReasoningEffortConfig::Medium,            ReasoningEffortConfig::High,        ];        assert_eq!(            next_reasoning_effort(                &choices,                Some(ReasoningEffortConfig::Low),                ReasoningShortcutDirection::Lower,            ),            None        );        assert_eq!(            next_reasoning_effort(                &choices,                Some(ReasoningEffortConfig::High),                ReasoningShortcutDirection::Raise,            ),            None        );    }    #[test]    fn next_reasoning_effort_single_option_is_noop() {        let choices = vec![ReasoningEffortConfig::High];        assert_eq!(            next_reasoning_effort(                &choices,                Some(ReasoningEffortConfig::High),                ReasoningShortcutDirection::Raise,            ),            None        );        assert_eq!(            next_reasoning_effort(                &choices,                Some(ReasoningEffortConfig::High),                ReasoningShortcutDirection::Lower,            ),            None        );    }}