mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Support model-defined reasoning efforts (#26444)
## Summary - accept non-empty model-defined reasoning effort values while preserving built-in effort behavior - propagate the non-Copy effort type through core, app-server, TUI, telemetry, and persistence call sites - preserve string wire encoding and expose an open-string schema for clients - update model selection and shortcut behavior for model-advertised effort values ## Root cause `ReasoningEffort` gained a string-backed custom variant, so it could no longer implement `Copy` or rely on derived closed-enum serialization. Existing consumers still moved effort values from shared references and assumed a fixed built-in value set. ## Validation - `just fmt` - Local tests and compilation were not run per request; relying on CI.
This commit is contained in:
committed by
GitHub
Unverified
parent
4a70e0ac1b
commit
8ac304c299
@@ -166,7 +166,7 @@ impl ChatWidget {
|
||||
mut collaboration_mode: CollaborationModeMask,
|
||||
) {
|
||||
if collaboration_mode.mode == Some(ModeKind::Plan)
|
||||
&& let Some(effort) = self.config.plan_mode_reasoning_effort
|
||||
&& let Some(effort) = self.config.plan_mode_reasoning_effort.clone()
|
||||
{
|
||||
collaboration_mode.reasoning_effort = Some(Some(effort));
|
||||
}
|
||||
|
||||
@@ -100,11 +100,11 @@ impl ChatWidget {
|
||||
let model = preset.model.clone();
|
||||
let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope(
|
||||
model.as_str(),
|
||||
Some(preset.default_reasoning_effort),
|
||||
Some(preset.default_reasoning_effort.clone()),
|
||||
);
|
||||
let actions = Self::model_selection_actions(
|
||||
model.clone(),
|
||||
Some(preset.default_reasoning_effort),
|
||||
Some(preset.default_reasoning_effort.clone()),
|
||||
should_prompt_plan_mode_scope,
|
||||
);
|
||||
SelectionItem {
|
||||
@@ -222,16 +222,16 @@ impl ChatWidget {
|
||||
if should_prompt_plan_mode_scope {
|
||||
tx.send(AppEvent::OpenPlanReasoningScopePrompt {
|
||||
model: model_for_action.clone(),
|
||||
effort: effort_for_action,
|
||||
effort: effort_for_action.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone()));
|
||||
tx.send(AppEvent::PersistModelSelection {
|
||||
model: model_for_action.clone(),
|
||||
effort: effort_for_action,
|
||||
effort: effort_for_action.clone(),
|
||||
});
|
||||
})]
|
||||
}
|
||||
@@ -261,30 +261,34 @@ impl ChatWidget {
|
||||
model: String,
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
) {
|
||||
let reasoning_phrase = match effort {
|
||||
let reasoning_phrase = match effort.as_ref() {
|
||||
Some(ReasoningEffortConfig::None) => "no reasoning".to_string(),
|
||||
Some(selected_effort) => {
|
||||
format!(
|
||||
"{} reasoning",
|
||||
Self::reasoning_effort_label(selected_effort).to_lowercase()
|
||||
Self::reasoning_effort_sentence_label(selected_effort)
|
||||
)
|
||||
}
|
||||
None => "the selected reasoning".to_string(),
|
||||
};
|
||||
let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode.");
|
||||
let plan_reasoning_source = if let Some(plan_override) =
|
||||
self.config.plan_mode_reasoning_effort
|
||||
self.config.plan_mode_reasoning_effort.as_ref()
|
||||
{
|
||||
format!(
|
||||
"user-chosen Plan override ({})",
|
||||
Self::reasoning_effort_label(plan_override).to_lowercase()
|
||||
Self::reasoning_effort_sentence_label(plan_override)
|
||||
)
|
||||
} else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref())
|
||||
{
|
||||
match plan_mask.reasoning_effort.flatten() {
|
||||
match plan_mask
|
||||
.reasoning_effort
|
||||
.as_ref()
|
||||
.and_then(|effort| effort.as_ref())
|
||||
{
|
||||
Some(plan_effort) => format!(
|
||||
"built-in Plan default ({})",
|
||||
Self::reasoning_effort_label(plan_effort).to_lowercase()
|
||||
Self::reasoning_effort_sentence_label(plan_effort)
|
||||
),
|
||||
None => "built-in Plan default (no reasoning)".to_string(),
|
||||
}
|
||||
@@ -298,20 +302,21 @@ impl ChatWidget {
|
||||
|
||||
let plan_only_actions: Vec<SelectionAction> = vec![Box::new({
|
||||
let model = model.clone();
|
||||
let effort = effort.clone();
|
||||
move |tx| {
|
||||
tx.send(AppEvent::UpdateModel(model.clone()));
|
||||
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
|
||||
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort));
|
||||
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone()));
|
||||
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone()));
|
||||
}
|
||||
})];
|
||||
let all_modes_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::UpdateModel(model.clone()));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(effort));
|
||||
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
|
||||
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(effort.clone()));
|
||||
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone()));
|
||||
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone()));
|
||||
tx.send(AppEvent::PersistModelSelection {
|
||||
model: model.clone(),
|
||||
effort,
|
||||
effort: effort.clone(),
|
||||
});
|
||||
})];
|
||||
|
||||
@@ -344,7 +349,7 @@ impl ChatWidget {
|
||||
|
||||
/// Open a popup to choose the reasoning effort (stage 2) for the given model.
|
||||
pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) {
|
||||
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
|
||||
let default_effort = preset.default_reasoning_effort;
|
||||
let supported = preset.supported_reasoning_efforts;
|
||||
let in_plan_mode =
|
||||
self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan;
|
||||
@@ -362,7 +367,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let warning_text = warn_effort.map(|effort| {
|
||||
let warning_text = warn_effort.as_ref().map(|effort| {
|
||||
let effort_label = Self::reasoning_effort_label(effort);
|
||||
format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.")
|
||||
});
|
||||
@@ -370,30 +375,25 @@ impl ChatWidget {
|
||||
|| preset.model.starts_with("gpt-5.1-codex-max")
|
||||
|| preset.model.starts_with("gpt-5.2");
|
||||
|
||||
struct EffortChoice {
|
||||
stored: Option<ReasoningEffortConfig>,
|
||||
display: ReasoningEffortConfig,
|
||||
}
|
||||
let mut choices: Vec<EffortChoice> = Vec::new();
|
||||
for effort in ReasoningEffortConfig::iter() {
|
||||
if supported.iter().any(|option| option.effort == effort) {
|
||||
choices.push(EffortChoice {
|
||||
stored: Some(effort),
|
||||
display: effort,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut choices: Vec<ReasoningEffortConfig> = ReasoningEffortConfig::known_values()
|
||||
.filter(|effort| supported.iter().any(|option| option.effort == *effort))
|
||||
.collect();
|
||||
choices.extend(
|
||||
supported
|
||||
.iter()
|
||||
.filter(|option| option.effort.known_rank().is_none())
|
||||
.map(|option| option.effort.clone()),
|
||||
);
|
||||
if choices.is_empty() {
|
||||
choices.push(EffortChoice {
|
||||
stored: Some(default_effort),
|
||||
display: default_effort,
|
||||
});
|
||||
choices.push(default_effort.clone());
|
||||
}
|
||||
|
||||
if choices.len() == 1 {
|
||||
let selected_effort = choices.first().and_then(|c| c.stored);
|
||||
let selected_effort = choices.first().cloned();
|
||||
let selected_model = preset.model;
|
||||
if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) {
|
||||
if self
|
||||
.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort.clone())
|
||||
{
|
||||
self.app_event_tx
|
||||
.send(AppEvent::OpenPlanReasoningScopePrompt {
|
||||
model: selected_model,
|
||||
@@ -405,12 +405,10 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
let default_choice: Option<ReasoningEffortConfig> = choices
|
||||
.iter()
|
||||
.any(|choice| choice.stored == Some(default_effort))
|
||||
.then_some(Some(default_effort))
|
||||
.flatten()
|
||||
.or_else(|| choices.iter().find_map(|choice| choice.stored))
|
||||
let default_choice = choices
|
||||
.contains(&default_effort)
|
||||
.then(|| default_effort.clone())
|
||||
.or_else(|| choices.first().cloned())
|
||||
.or(Some(default_effort));
|
||||
|
||||
let model_slug = preset.model.to_string();
|
||||
@@ -419,40 +417,33 @@ impl ChatWidget {
|
||||
if in_plan_mode {
|
||||
self.config
|
||||
.plan_mode_reasoning_effort
|
||||
.or(self.effective_reasoning_effort())
|
||||
.clone()
|
||||
.or_else(|| self.effective_reasoning_effort())
|
||||
} else {
|
||||
self.effective_reasoning_effort()
|
||||
}
|
||||
} else {
|
||||
default_choice
|
||||
default_choice.clone()
|
||||
};
|
||||
let selection_choice = highlight_choice.or(default_choice);
|
||||
let selection_choice = highlight_choice.clone().or_else(|| default_choice.clone());
|
||||
let initial_selected_idx = choices
|
||||
.iter()
|
||||
.position(|choice| choice.stored == selection_choice)
|
||||
.or_else(|| {
|
||||
selection_choice
|
||||
.and_then(|effort| choices.iter().position(|choice| choice.display == effort))
|
||||
});
|
||||
.position(|choice| Some(choice) == selection_choice.as_ref());
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
for choice in choices.iter() {
|
||||
let effort = choice.display;
|
||||
let mut effort_label = Self::reasoning_effort_label(effort).to_string();
|
||||
if choice.stored == default_choice {
|
||||
let effort = choice.clone();
|
||||
let mut effort_label = Self::reasoning_effort_label(&effort);
|
||||
if Some(choice) == default_choice.as_ref() {
|
||||
effort_label.push_str(" (default)");
|
||||
}
|
||||
|
||||
let description = choice
|
||||
.stored
|
||||
.and_then(|effort| {
|
||||
supported
|
||||
.iter()
|
||||
.find(|option| option.effort == effort)
|
||||
.map(|option| option.description.to_string())
|
||||
})
|
||||
let description = supported
|
||||
.iter()
|
||||
.find(|option| option.effort == effort)
|
||||
.map(|option| option.description.to_string())
|
||||
.filter(|text| !text.is_empty());
|
||||
|
||||
let show_warning = warn_for_model && warn_effort == Some(effort);
|
||||
let show_warning = warn_for_model && warn_effort.as_ref() == Some(&effort);
|
||||
let selected_description = if show_warning {
|
||||
warning_text.as_ref().map(|warning_message| {
|
||||
description.as_ref().map_or_else(
|
||||
@@ -465,21 +456,23 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
let model_for_action = model_slug.clone();
|
||||
let choice_effort = choice.stored;
|
||||
let should_prompt_plan_mode_scope =
|
||||
self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort);
|
||||
let choice_effort = Some(effort);
|
||||
let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope(
|
||||
model_slug.as_str(),
|
||||
choice_effort.clone(),
|
||||
);
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
if should_prompt_plan_mode_scope {
|
||||
tx.send(AppEvent::OpenPlanReasoningScopePrompt {
|
||||
model: model_for_action.clone(),
|
||||
effort: choice_effort,
|
||||
effort: choice_effort.clone(),
|
||||
});
|
||||
} else {
|
||||
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(choice_effort));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(choice_effort.clone()));
|
||||
tx.send(AppEvent::PersistModelSelection {
|
||||
model: model_for_action.clone(),
|
||||
effort: choice_effort,
|
||||
effort: choice_effort.clone(),
|
||||
});
|
||||
}
|
||||
})];
|
||||
@@ -488,7 +481,7 @@ impl ChatWidget {
|
||||
name: effort_label,
|
||||
description,
|
||||
selected_description,
|
||||
is_current: is_current_model && choice.stored == highlight_choice,
|
||||
is_current: is_current_model && Some(choice) == highlight_choice.as_ref(),
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
@@ -509,14 +502,22 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str {
|
||||
pub(super) fn reasoning_effort_label(effort: &ReasoningEffortConfig) -> String {
|
||||
match effort {
|
||||
ReasoningEffortConfig::None => "None",
|
||||
ReasoningEffortConfig::Minimal => "Minimal",
|
||||
ReasoningEffortConfig::Low => "Low",
|
||||
ReasoningEffortConfig::Medium => "Medium",
|
||||
ReasoningEffortConfig::High => "High",
|
||||
ReasoningEffortConfig::XHigh => "Extra high",
|
||||
ReasoningEffortConfig::None => "None".to_string(),
|
||||
ReasoningEffortConfig::Minimal => "Minimal".to_string(),
|
||||
ReasoningEffortConfig::Low => "Low".to_string(),
|
||||
ReasoningEffortConfig::Medium => "Medium".to_string(),
|
||||
ReasoningEffortConfig::High => "High".to_string(),
|
||||
ReasoningEffortConfig::XHigh => "Extra high".to_string(),
|
||||
ReasoningEffortConfig::Custom(value) => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reasoning_effort_sentence_label(effort: &ReasoningEffortConfig) -> String {
|
||||
match effort {
|
||||
ReasoningEffortConfig::Custom(value) => value.clone(),
|
||||
effort => Self::reasoning_effort_label(effort).to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +532,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn apply_model_and_effort(&self, model: String, effort: Option<ReasoningEffortConfig>) {
|
||||
self.apply_model_and_effort_without_persist(model.clone(), effort);
|
||||
self.apply_model_and_effort_without_persist(model.clone(), effort.clone());
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PersistModelSelection { model, effort });
|
||||
}
|
||||
|
||||
@@ -343,14 +343,16 @@ impl ChatWidget {
|
||||
/*active_permission_profile*/ None,
|
||||
/*windows_sandbox_level*/ None,
|
||||
Some(switch_model_for_events.clone()),
|
||||
Some(Some(default_effort)),
|
||||
Some(Some(default_effort.clone())),
|
||||
/*summary*/ None,
|
||||
/*service_tier*/ None,
|
||||
/*collaboration_mode*/ None,
|
||||
/*personality*/ None,
|
||||
)));
|
||||
tx.send(AppEvent::UpdateModel(switch_model_for_events.clone()));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort)));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(Some(
|
||||
default_effort.clone(),
|
||||
)));
|
||||
})];
|
||||
|
||||
let keep_actions: Vec<SelectionAction> = Vec::new();
|
||||
|
||||
@@ -8,15 +8,14 @@
|
||||
//! 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 current efforts are not normalized eagerly; the
|
||||
//! next shortcut moves to the nearest supported effort in the requested
|
||||
//! direction.
|
||||
//! active model. Unsupported known efforts move to the nearest advertised known
|
||||
//! effort in the requested direction. Unknown efforts anchor to the model
|
||||
//! default 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 strum::IntoEnumIterator;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
@@ -30,8 +29,8 @@ pub(super) enum ReasoningShortcutDirection {
|
||||
}
|
||||
|
||||
impl ReasoningShortcutDirection {
|
||||
fn bound_message(self, effort: ReasoningEffortConfig) -> String {
|
||||
let label = ChatWidget::reasoning_effort_label(effort).to_lowercase();
|
||||
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})."),
|
||||
@@ -90,12 +89,23 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
let choices = reasoning_choices(&preset);
|
||||
let current_effort = self
|
||||
let configured_effort = self
|
||||
.effective_reasoning_effort()
|
||||
.unwrap_or(preset.default_reasoning_effort);
|
||||
let Some(next_effort) = next_reasoning_effort(&choices, Some(current_effort), direction)
|
||||
.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);
|
||||
self.add_info_message(direction.bound_message(¤t_effort), /*hint*/ None);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -120,18 +130,23 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn reasoning_choices(preset: &ModelPreset) -> Vec<ReasoningEffortConfig> {
|
||||
let mut choices = Vec::new();
|
||||
for effort in ReasoningEffortConfig::iter() {
|
||||
if preset
|
||||
let mut choices: Vec<ReasoningEffortConfig> = ReasoningEffortConfig::known_values()
|
||||
.filter(|effort| {
|
||||
preset
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|option| option.effort == *effort)
|
||||
})
|
||||
.collect();
|
||||
choices.extend(
|
||||
preset
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|option| option.effort == effort)
|
||||
{
|
||||
choices.push(effort);
|
||||
}
|
||||
}
|
||||
.filter(|option| option.effort.known_rank().is_none())
|
||||
.map(|option| option.effort.clone()),
|
||||
);
|
||||
if choices.is_empty() {
|
||||
choices.push(preset.default_reasoning_effort);
|
||||
choices.push(preset.default_reasoning_effort.clone());
|
||||
}
|
||||
choices
|
||||
}
|
||||
@@ -142,32 +157,51 @@ fn next_reasoning_effort(
|
||||
direction: ReasoningShortcutDirection,
|
||||
) -> Option<ReasoningEffortConfig> {
|
||||
let current_effort = current_effort?;
|
||||
if choices.is_empty() {
|
||||
return None;
|
||||
if let Some(current_index) = choices.iter().position(|choice| choice == ¤t_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(),
|
||||
};
|
||||
}
|
||||
|
||||
let current_rank = effort_rank(current_effort);
|
||||
match direction {
|
||||
let current_rank = current_effort.known_rank()?;
|
||||
let ranked_choice = match direction {
|
||||
ReasoningShortcutDirection::Lower => choices
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|choice| effort_rank(*choice) < current_rank),
|
||||
.filter_map(|choice| choice.known_rank().map(|rank| (rank, choice)))
|
||||
.filter(|(rank, _)| *rank < current_rank)
|
||||
.max_by_key(|(rank, _)| *rank)
|
||||
.map(|(_, choice)| choice.clone()),
|
||||
ReasoningShortcutDirection::Raise => choices
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|choice| effort_rank(*choice) > current_rank),
|
||||
.filter_map(|choice| choice.known_rank().map(|rank| (rank, choice)))
|
||||
.filter(|(rank, _)| *rank > current_rank)
|
||||
.min_by_key(|(rank, _)| *rank)
|
||||
.map(|(_, choice)| choice.clone()),
|
||||
};
|
||||
if let Some(ranked_choice) = ranked_choice {
|
||||
return Some(ranked_choice);
|
||||
}
|
||||
}
|
||||
|
||||
fn effort_rank(effort: ReasoningEffortConfig) -> i32 {
|
||||
match effort {
|
||||
ReasoningEffortConfig::None => 0,
|
||||
ReasoningEffortConfig::Minimal => 1,
|
||||
ReasoningEffortConfig::Low => 2,
|
||||
ReasoningEffortConfig::Medium => 3,
|
||||
ReasoningEffortConfig::High => 4,
|
||||
ReasoningEffortConfig::XHigh => 5,
|
||||
let nearest_known_index = choices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, choice)| {
|
||||
choice
|
||||
.known_rank()
|
||||
.map(|rank| (rank.abs_diff(current_rank), index))
|
||||
})
|
||||
.min_by_key(|(distance, _)| *distance)
|
||||
.map(|(_, index)| index)?;
|
||||
match direction {
|
||||
ReasoningShortcutDirection::Lower => nearest_known_index
|
||||
.checked_sub(1)
|
||||
.and_then(|index| choices.get(index))
|
||||
.cloned(),
|
||||
ReasoningShortcutDirection::Raise => choices.get(nearest_known_index + 1).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +269,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_reasoning_effort_reaches_custom_level_from_nearest_known_anchor() {
|
||||
let custom_effort = ReasoningEffortConfig::Custom("max".to_string());
|
||||
let choices = vec![ReasoningEffortConfig::Medium, custom_effort.clone()];
|
||||
|
||||
assert_eq!(
|
||||
next_reasoning_effort(
|
||||
&choices,
|
||||
Some(ReasoningEffortConfig::High),
|
||||
ReasoningShortcutDirection::Raise,
|
||||
),
|
||||
Some(custom_effort)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_reasoning_effort_clamps_at_bounds() {
|
||||
let choices = vec![
|
||||
|
||||
@@ -79,7 +79,7 @@ impl ChatWidget {
|
||||
let default_model = session.model.clone();
|
||||
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
|
||||
Some(default_model.clone()),
|
||||
Some(session.reasoning_effort),
|
||||
Some(session.reasoning_effort.clone()),
|
||||
/*developer_instructions*/ None,
|
||||
);
|
||||
match session.collaboration_mode.as_deref() {
|
||||
@@ -93,7 +93,7 @@ impl ChatWidget {
|
||||
Some(&default_model),
|
||||
);
|
||||
if let Some(mask) = self.active_collaboration_mask.as_mut() {
|
||||
mask.reasoning_effort = Some(session.reasoning_effort);
|
||||
mask.reasoning_effort = Some(session.reasoning_effort.clone());
|
||||
}
|
||||
self.update_collaboration_mode_indicator();
|
||||
self.refresh_plan_mode_nudge();
|
||||
|
||||
@@ -159,7 +159,7 @@ impl ChatWidget {
|
||||
/// so the footer reflects it without waiting for the next mode switch.
|
||||
/// Passing `None` resets to the Plan-mode preset default.
|
||||
pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.config.plan_mode_reasoning_effort = effort;
|
||||
self.config.plan_mode_reasoning_effort = effort.clone();
|
||||
if self.collaboration_modes_enabled()
|
||||
&& let Some(mask) = self.active_collaboration_mask.as_mut()
|
||||
&& mask.mode == Some(ModeKind::Plan)
|
||||
@@ -182,7 +182,7 @@ impl ChatWidget {
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
|
||||
/*model*/ None,
|
||||
Some(effort),
|
||||
Some(effort.clone()),
|
||||
/*developer_instructions*/ None,
|
||||
);
|
||||
if self.collaboration_modes_enabled()
|
||||
@@ -471,7 +471,7 @@ impl ChatWidget {
|
||||
let current_effort = self.current_collaboration_mode.reasoning_effort();
|
||||
self.active_collaboration_mask
|
||||
.as_ref()
|
||||
.and_then(|mask| mask.reasoning_effort)
|
||||
.and_then(|mask| mask.reasoning_effort.clone())
|
||||
.unwrap_or(current_effort)
|
||||
}
|
||||
|
||||
@@ -590,7 +590,7 @@ impl ChatWidget {
|
||||
name: mode_kind.display_name().to_string(),
|
||||
mode: Some(mode_kind),
|
||||
model: Some(settings.model.clone()),
|
||||
reasoning_effort: Some(settings.reasoning_effort),
|
||||
reasoning_effort: Some(settings.reasoning_effort.clone()),
|
||||
developer_instructions: Some(settings.developer_instructions),
|
||||
});
|
||||
self.update_collaboration_mode_indicator();
|
||||
@@ -712,7 +712,7 @@ impl ChatWidget {
|
||||
let previous_model = self.current_model().to_string();
|
||||
let previous_effort = self.effective_reasoning_effort();
|
||||
if mask.mode == Some(ModeKind::Plan)
|
||||
&& let Some(effort) = self.config.plan_mode_reasoning_effort
|
||||
&& let Some(effort) = self.config.plan_mode_reasoning_effort.clone()
|
||||
{
|
||||
mask.reasoning_effort = Some(Some(effort));
|
||||
}
|
||||
@@ -732,13 +732,9 @@ impl ChatWidget {
|
||||
{
|
||||
let mut message = format!("Model changed to {next_model}");
|
||||
if !next_model.starts_with("codex-auto-") {
|
||||
let reasoning_label = match next_effort {
|
||||
Some(ReasoningEffortConfig::Minimal) => "minimal",
|
||||
Some(ReasoningEffortConfig::Low) => "low",
|
||||
Some(ReasoningEffortConfig::Medium) => "medium",
|
||||
Some(ReasoningEffortConfig::High) => "high",
|
||||
Some(ReasoningEffortConfig::XHigh) => "xhigh",
|
||||
let reasoning_label = match next_effort.as_ref() {
|
||||
None | Some(ReasoningEffortConfig::None) => "default",
|
||||
Some(effort) => effort.as_str(),
|
||||
};
|
||||
message.push(' ');
|
||||
message.push_str(reasoning_label);
|
||||
|
||||
+1
@@ -8,5 +8,6 @@ expression: popup
|
||||
2. Medium (default) Balances speed and reasoning depth for everyday tasks
|
||||
› 3. High (current) Greater reasoning depth for complex problems
|
||||
4. Extra high Extra high reasoning depth for complex problems
|
||||
5. max Maximum available reasoning
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
@@ -212,7 +212,7 @@ impl ChatWidget {
|
||||
});
|
||||
let reasoning_effort_override = Some(
|
||||
self.effective_reasoning_effort()
|
||||
.or(self.config.model_reasoning_effort)
|
||||
.or_else(|| self.config.model_reasoning_effort.clone())
|
||||
.or(model_default_reasoning_effort),
|
||||
);
|
||||
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
|
||||
@@ -382,15 +382,11 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn status_line_reasoning_effort_label(
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
) -> &'static str {
|
||||
effort: Option<&ReasoningEffortConfig>,
|
||||
) -> String {
|
||||
match effort {
|
||||
Some(ReasoningEffortConfig::Minimal) => "minimal",
|
||||
Some(ReasoningEffortConfig::Low) => "low",
|
||||
Some(ReasoningEffortConfig::Medium) => "medium",
|
||||
Some(ReasoningEffortConfig::High) => "high",
|
||||
Some(ReasoningEffortConfig::XHigh) => "xhigh",
|
||||
None | Some(ReasoningEffortConfig::None) => "default",
|
||||
None | Some(ReasoningEffortConfig::None) => "default".to_string(),
|
||||
Some(effort) => effort.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +562,7 @@ impl ChatWidget {
|
||||
match item {
|
||||
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
|
||||
StatusLineItem::ModelWithReasoning => Some(self.model_with_reasoning_display_name()),
|
||||
StatusLineItem::Reasoning => Some(self.reasoning_display_name().to_string()),
|
||||
StatusLineItem::Reasoning => Some(self.reasoning_display_name()),
|
||||
StatusLineItem::CurrentDir => {
|
||||
Some(format_directory_display(
|
||||
self.status_line_cwd(),
|
||||
@@ -762,15 +762,16 @@ impl ChatWidget {
|
||||
/*max_chars*/ 32,
|
||||
)),
|
||||
TerminalTitleItem::Reasoning => Some(Self::truncate_terminal_title_part(
|
||||
self.reasoning_display_name().to_string(),
|
||||
self.reasoning_display_name(),
|
||||
/*max_chars*/ 32,
|
||||
)),
|
||||
TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_display_name(&self) -> &'static str {
|
||||
Self::status_line_reasoning_effort_label(self.effective_reasoning_effort())
|
||||
fn reasoning_display_name(&self) -> String {
|
||||
let effort = self.effective_reasoning_effort();
|
||||
Self::status_line_reasoning_effort_label(effort.as_ref())
|
||||
}
|
||||
|
||||
fn model_with_reasoning_display_name(&self) -> String {
|
||||
|
||||
@@ -2405,13 +2405,54 @@ async fn model_reasoning_selection_popup_snapshot() {
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
|
||||
let preset = get_available_model(&chat, "gpt-5.4");
|
||||
let mut preset = get_available_model(&chat, "gpt-5.4");
|
||||
preset
|
||||
.supported_reasoning_efforts
|
||||
.push(ReasoningEffortPreset {
|
||||
effort: ReasoningEffortConfig::Custom("max".to_string()),
|
||||
description: "Maximum available reasoning".to_string(),
|
||||
});
|
||||
chat.open_reasoning_popup(preset);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("model_reasoning_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_reasoning_selection_popup_applies_custom_effort() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
let custom_effort = ReasoningEffortConfig::Custom("max".to_string());
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
|
||||
|
||||
let mut preset = get_available_model(&chat, "gpt-5.4");
|
||||
preset
|
||||
.supported_reasoning_efforts
|
||||
.push(ReasoningEffortPreset {
|
||||
effort: custom_effort.clone(),
|
||||
description: "Maximum available reasoning".to_string(),
|
||||
});
|
||||
chat.open_reasoning_popup(preset);
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
let selected_effort_events = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::UpdateReasoningEffort(effort) => Some((None, effort)),
|
||||
AppEvent::PersistModelSelection { model, effort } => Some((Some(model), effort)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
selected_effort_events,
|
||||
vec![
|
||||
(None, Some(custom_effort.clone())),
|
||||
(Some("gpt-5.4".to_string()), Some(custom_effort)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_reasoning_selection_popup_extra_high_warning_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
|
||||
Reference in New Issue
Block a user