[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:
Ahmed Ibrahim
2026-06-04 13:36:24 -07:00
committed by GitHub
Unverified
parent 4a70e0ac1b
commit 8ac304c299
97 changed files with 685 additions and 581 deletions
+1 -1
View File
@@ -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));
}
+80 -79
View File
@@ -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 });
}
+4 -2
View File
@@ -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(&current_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 == &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(),
};
}
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![
+2 -2
View File
@@ -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();
+7 -11
View File
@@ -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);
@@ -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;