Make advanced reasoning selection explicit in the TUI (#32746)

## Why

`Max` and `Ultra` consume usage limits faster than standard reasoning levels, so they should not be selected accidentally while navigating the normal effort scale.

## What changed

- Move `Max` and `Ultra` behind a `More reasoning…` entry with a dedicated warning and descriptions.
- Keep the reasoning shortcuts from silently increasing into advanced efforts.
- Apply `Ultra` to the active conversation without changing defaults for new threads, while preserving it across mode switches and thread resumes.
- Record applied thread settings in thread metadata, including explicit clearing of reasoning effort, so resumed threads restore their latest model settings unless the user supplied an override.

## Testing

- Add TUI coverage for the advanced picker, shortcuts, Plan mode, configuration defaults, and resumed conversations.
- Add app-server and thread-store coverage for persisting, clearing, and restoring model and reasoning settings.

GitOrigin-RevId: 6708c7c2e8d38f491bf63000ea34f83476afdfe6
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-07-13 09:40:51 +00:00
1 parent 2a4942c081
commit 769a5de257
27 files changed
+1447 -92

No files matched your search

+160 -52
View File
@@ -100,22 +100,39 @@ impl ChatWidget {
let description =
(!preset.description.is_empty()).then_some(preset.description.clone());
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.clone()),
);
let actions = self.model_selection_actions(
model.clone(),
Some(preset.default_reasoning_effort.clone()),
should_prompt_plan_mode_scope,
);
let requires_advanced_selection =
Self::is_advanced_reasoning_effort(&preset.default_reasoning_effort)
|| preset
.supported_reasoning_efforts
.iter()
.any(|option| Self::is_advanced_reasoning_effort(&option.effort));
let actions: Vec<SelectionAction> = if requires_advanced_selection {
let preset_for_action = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReasoningPopup {
model: preset_for_action.clone(),
});
})]
} else {
let should_prompt_plan_mode_scope = self
.should_prompt_plan_mode_reasoning_scope(
model.as_str(),
Some(preset.default_reasoning_effort.clone()),
);
self.model_selection_actions(
model.clone(),
Some(preset.default_reasoning_effort.clone()),
should_prompt_plan_mode_scope,
)
};
SelectionItem {
name: model.clone(),
description,
is_current: model.as_str() == current_model,
is_default: preset.is_default,
actions,
dismiss_on_select: true,
dismiss_on_select: !requires_advanced_selection,
dismiss_parent_on_child_accept: requires_advanced_selection,
..Default::default()
}
})
@@ -225,20 +242,24 @@ impl ChatWidget {
.as_ref()
.and_then(|effort| self.ultra_reasoning_concurrency_warning(effort));
vec![Box::new(move |tx| {
if should_prompt_plan_mode_scope {
if effort_for_action == Some(ReasoningEffortConfig::Ultra) {
tx.send(AppEvent::ApplyAdvancedReasoning {
model: model_for_action.clone(),
effort: ReasoningEffortConfig::Ultra,
});
} else if should_prompt_plan_mode_scope {
tx.send(AppEvent::OpenPlanReasoningScopePrompt {
model: model_for_action.clone(),
effort: effort_for_action.clone(),
});
return;
} else {
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone()));
tx.send(AppEvent::PersistModelSelection {
model: model_for_action.clone(),
effort: effort_for_action.clone(),
});
}
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone()));
tx.send(AppEvent::PersistModelSelection {
model: model_for_action.clone(),
effort: effort_for_action.clone(),
});
if let Some(warning) = warning.clone() {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(warning),
@@ -372,10 +393,13 @@ impl ChatWidget {
});
}
/// Open a popup to choose the reasoning effort (stage 2) for the given model.
/// Open a popup to choose the standard reasoning effort for the given model.
///
/// Max and Ultra require an explicit second step so expensive efforts cannot
/// be selected accidentally while moving through the normal effort scale.
pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) {
let default_effort = preset.default_reasoning_effort;
let supported = preset.supported_reasoning_efforts;
let default_effort = preset.default_reasoning_effort.clone();
let supported = &preset.supported_reasoning_efforts;
let in_plan_mode =
self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan;
@@ -400,15 +424,18 @@ impl ChatWidget {
|| preset.model.starts_with("gpt-5.1-codex-max")
|| preset.model.starts_with("gpt-5.2");
let mut choices: Vec<ReasoningEffortConfig> = supported
let mut all_choices: Vec<ReasoningEffortConfig> = supported
.iter()
.map(|option| option.effort.clone())
.collect();
if choices.is_empty() {
choices.push(default_effort.clone());
if all_choices.is_empty() {
all_choices.push(default_effort.clone());
}
let (choices, advanced_choices): (Vec<_>, Vec<_>) = all_choices
.into_iter()
.partition(|effort| !Self::is_advanced_reasoning_effort(effort));
if choices.len() == 1 {
if choices.len() == 1 && advanced_choices.is_empty() {
let selected_effort = choices.first().cloned();
let selected_model = preset.model;
if self
@@ -427,9 +454,7 @@ impl ChatWidget {
let default_choice = choices
.contains(&default_effort)
.then(|| default_effort.clone())
.or_else(|| choices.first().cloned())
.or(Some(default_effort));
.then(|| default_effort.clone());
let model_slug = preset.model.to_string();
let is_current_model = self.current_model() == preset.model.as_str();
@@ -443,7 +468,7 @@ impl ChatWidget {
self.effective_reasoning_effort()
}
} else {
default_choice.clone()
default_choice.clone().or_else(|| choices.first().cloned())
};
let selection_choice = highlight_choice.clone().or_else(|| default_choice.clone());
let initial_selected_idx = choices
@@ -452,7 +477,6 @@ impl ChatWidget {
let mut items: Vec<SelectionItem> = Vec::new();
for choice in choices.iter() {
let effort = choice.clone();
let warning = self.ultra_reasoning_concurrency_warning(&effort);
let mut effort_label = Self::reasoning_effort_label(&effort);
if Some(choice) == default_choice.as_ref() {
effort_label.push_str(" (default)");
@@ -476,32 +500,16 @@ impl ChatWidget {
None
};
let model_for_action = model_slug.clone();
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.clone(),
});
} else {
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(choice_effort.clone()));
tx.send(AppEvent::PersistModelSelection {
model: model_for_action.clone(),
effort: choice_effort.clone(),
});
if let Some(warning) = warning.clone() {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(warning),
)));
}
}
})];
let actions = self.model_selection_actions(
model_slug.clone(),
choice_effort,
should_prompt_plan_mode_scope,
);
items.push(SelectionItem {
name: effort_label,
@@ -514,6 +522,36 @@ impl ChatWidget {
});
}
if !advanced_choices.is_empty() {
let advanced_label = advanced_choices
.iter()
.map(Self::reasoning_effort_label)
.collect::<Vec<_>>()
.join(" and ");
let verb = if advanced_choices.len() == 1 {
"consumes"
} else {
"consume"
};
let preset_for_action = preset;
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAdvancedReasoningPopup {
model: preset_for_action.clone(),
});
})];
items.push(SelectionItem {
name: "More reasoning…".to_string(),
description: Some(format!("{advanced_label} {verb} usage limits faster")),
is_current: is_current_model
&& highlight_choice
.as_ref()
.is_some_and(Self::is_advanced_reasoning_effort),
actions,
dismiss_parent_on_child_accept: true,
..Default::default()
});
}
let mut header = ColumnRenderable::new();
header.push(Line::from(
format!("Select Reasoning Level for {model_slug}").bold(),
@@ -528,6 +566,76 @@ impl ChatWidget {
});
}
/// Open the explicit Max/Ultra effort picker for the given model.
pub(crate) fn open_advanced_reasoning_popup(&mut self, preset: ModelPreset) {
let mut choices = preset
.supported_reasoning_efforts
.iter()
.map(|option| option.effort.clone())
.filter(Self::is_advanced_reasoning_effort)
.collect::<Vec<_>>();
if choices.is_empty()
&& Self::is_advanced_reasoning_effort(&preset.default_reasoning_effort)
{
choices.push(preset.default_reasoning_effort.clone());
}
choices.sort_by_key(|effort| matches!(effort, ReasoningEffortConfig::Ultra));
if choices.is_empty() {
return;
}
let model_slug = preset.model.to_string();
let is_current_model = self.current_model() == preset.model.as_str();
let highlight_choice = is_current_model
.then(|| self.effective_reasoning_effort())
.flatten();
let mut items = Vec::new();
for effort in choices {
let description = match &effort {
ReasoningEffortConfig::Max => {
"For difficult problems when quality matters more than speed · higher usage"
}
ReasoningEffortConfig::Ultra => {
"For demanding work using multiple agents · highest usage"
}
_ => unreachable!("advanced choices are limited to Max and Ultra"),
};
let should_prompt_plan_mode_scope = self
.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), Some(effort.clone()));
let actions = self.model_selection_actions(
model_slug.clone(),
Some(effort.clone()),
should_prompt_plan_mode_scope,
);
items.push(SelectionItem {
name: Self::reasoning_effort_label(&effort),
description: Some(description.to_string()),
is_current: is_current_model && Some(&effort) == highlight_choice.as_ref(),
actions,
dismiss_on_select: true,
..Default::default()
});
}
let mut header = ColumnRenderable::new();
header.push(Line::from("Advanced Reasoning".bold()));
header.push(Line::from("⚠ Consumes usage limits faster".cyan()));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(super) fn is_advanced_reasoning_effort(effort: &ReasoningEffortConfig) -> bool {
matches!(
effort,
ReasoningEffortConfig::Max | ReasoningEffortConfig::Ultra
)
}
pub(super) fn reasoning_effort_label(effort: &ReasoningEffortConfig) -> String {
match effort {
ReasoningEffortConfig::None => "None".to_string(),
@@ -10,7 +10,8 @@
//! 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.
//! advertised order. Raising never silently crosses into Max or Ultra; those
//! efforts require the explicit advanced-reasoning picker.
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ModelPreset;
@@ -109,6 +110,34 @@ impl ChatWidget {
return true;
};
if direction == ReasoningShortcutDirection::Raise
&& Self::is_advanced_reasoning_effort(&next_effort)
{
let advanced_label = choices
.iter()
.filter(|effort| Self::is_advanced_reasoning_effort(effort))
.map(Self::reasoning_effort_label)
.collect::<Vec<_>>()
.join(" and ");
let verb = if advanced_label.contains(" and ") {
"are"
} else {
"is"
};
let model_path = if current_model.starts_with("codex-auto-") {
current_model
} else {
format!("All models → {current_model}")
};
self.add_info_message(
format!(
"{advanced_label} {verb} available under /model → {model_path} → More reasoning…"
),
/*hint*/ None,
);
return true;
}
if self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan {
let warning = self.ultra_reasoning_concurrency_warning(&next_effort);
self.app_event_tx
@@ -136,11 +165,13 @@ impl ChatWidget {
}
fn reasoning_choices(preset: &ModelPreset) -> Vec<ReasoningEffortConfig> {
let mut choices: Vec<ReasoningEffortConfig> = preset
let (mut choices, mut advanced_choices): (Vec<_>, Vec<_>) = preset
.supported_reasoning_efforts
.iter()
.map(|option| option.effort.clone())
.collect();
.partition(|effort| !ChatWidget::is_advanced_reasoning_effort(effort));
advanced_choices.sort_by_key(|effort| matches!(effort, ReasoningEffortConfig::Ultra));
choices.extend(advanced_choices);
if choices.is_empty() {
choices.push(preset.default_reasoning_effort.clone());
}
@@ -83,6 +83,9 @@ impl ChatWidget {
Some(session.reasoning_effort.clone()),
/*developer_instructions*/ None,
);
if session.reasoning_effort == Some(ReasoningEffortConfig::Ultra) {
self.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Ultra));
}
match session.collaboration_mode.as_deref() {
Some(collaboration_mode) => {
self.set_effective_collaboration_mode(collaboration_mode.clone());
-1
View File
@@ -345,7 +345,6 @@ impl ChatWidget {
)
}
#[allow(dead_code)] // Used in tests
pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode {
&self.current_collaboration_mode
}
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Reasoning Level for gpt-5.4
› 1. More reasoning… Ultra consumes usage limits faster
Press enter to confirm or esc to go back
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Advanced Reasoning
⚠ Consumes usage limits faster
1. Max For difficult problems when quality matters more than
speed · higher usage
› 2. Ultra (current) For demanding work using multiple agents · highest usage
Press enter to confirm or esc to go back
@@ -6,8 +6,8 @@ expression: popup
1. Low Fast responses with lighter reasoning
2. Medium (default) Balances speed and reasoning depth for everyday tasks
3. Max Maximum available reasoning
› 4. High (current) Greater reasoning depth for complex problems
5. Extra high Extra high reasoning depth for complex problems
› 3. High (current) Greater reasoning depth for complex problems
4. Extra high Extra high reasoning depth for complex problems
5. More reasoning… Max and Ultra consume usage limits faster
Press enter to confirm or esc to go back
@@ -75,6 +75,43 @@ async fn resumed_initial_messages_render_history() {
);
}
#[tokio::test]
async fn restored_conversation_ultra_remains_selected_after_switching_to_plan() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High));
chat.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-5.4".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::Ultra),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: None,
});
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
assert_eq!(
chat.current_reasoning_effort(),
Some(ReasoningEffortConfig::Ultra)
);
}
#[tokio::test]
async fn replayed_user_messages_seed_composer_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -427,6 +427,52 @@ async fn reasoning_shortcut_in_plan_mode_updates_plan_override_without_prompt_or
);
}
#[tokio::test]
async fn advanced_reasoning_selection_in_plan_mode_uses_expected_scope() {
for effort in [ReasoningEffortConfig::Ultra, ReasoningEffortConfig::Max] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
let _ = drain_insert_history(&mut rx);
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.supported_reasoning_efforts = vec![ReasoningEffortPreset {
effort: effort.clone(),
description: "Advanced reasoning".to_string(),
}];
chat.open_advanced_reasoning_popup(preset);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
if effort == ReasoningEffortConfig::Ultra {
assert!(events.iter().any(|event| matches!(
event,
AppEvent::ApplyAdvancedReasoning {
model,
effort: ReasoningEffortConfig::Ultra,
} if model == "gpt-5.4"
)));
assert!(events.iter().all(|event| !matches!(
event,
AppEvent::OpenPlanReasoningScopePrompt { .. }
| AppEvent::PersistPlanModeReasoningEffort(_)
| AppEvent::PersistModelSelection { .. }
)));
} else {
assert!(events.iter().any(|event| matches!(
event,
AppEvent::OpenPlanReasoningScopePrompt {
model,
effort: Some(ReasoningEffortConfig::Max),
} if model == "gpt-5.4"
)));
}
}
}
#[tokio::test]
async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
@@ -3196,12 +3196,40 @@ async fn model_reasoning_selection_popup_snapshot() {
description: "Maximum available reasoning".to_string(),
},
);
preset
.supported_reasoning_efforts
.push(ReasoningEffortPreset {
effort: ReasoningEffortConfig::Ultra,
description: "Ultra 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_advanced_reasoning_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_reasoning_effort(Some(ReasoningEffortConfig::Ultra));
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.supported_reasoning_efforts.extend([
ReasoningEffortPreset {
effort: ReasoningEffortConfig::Ultra,
description: "Ultra reasoning".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffortConfig::Max,
description: "Maximum available reasoning".to_string(),
},
]);
chat.open_advanced_reasoning_popup(preset);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("model_advanced_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;
@@ -3262,11 +3290,21 @@ async fn select_ultra_with_multi_agent_thread_limit(max_threads: usize) -> (bool
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let advanced_preset = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event {
AppEvent::OpenAdvancedReasoningPopup { model } => Some(model),
_ => None,
});
chat.open_advanced_reasoning_popup(advanced_preset.expect("advanced reasoning popup"));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let mut selected_ultra = false;
let mut warnings = Vec::new();
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Ultra)) => {
AppEvent::ApplyAdvancedReasoning {
effort: ReasoningEffortConfig::Ultra,
..
} => {
selected_ultra = true;
}
AppEvent::InsertHistoryCell(cell) => {
@@ -3299,6 +3337,38 @@ async fn ultra_reasoning_selection_skips_warning_below_threshold() {
assert_eq!(below_threshold, (true, Vec::new()));
}
#[tokio::test]
async fn max_reasoning_selection_persists_model_selection() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.supported_reasoning_efforts = vec![ReasoningEffortPreset {
effort: ReasoningEffortConfig::Max,
description: "Maximum reasoning".to_string(),
}];
chat.open_advanced_reasoning_popup(preset);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(events.iter().any(|event| matches!(
event,
AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Max))
)));
assert!(events.iter().any(|event| matches!(
event,
AppEvent::PersistModelSelection {
model,
effort: Some(ReasoningEffortConfig::Max),
} if model == "gpt-5.4"
)));
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::ApplyAdvancedReasoning { .. }))
);
}
#[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;
@@ -3421,6 +3491,96 @@ async fn reasoning_shortcut_is_ignored_with_model_popup_open() {
);
}
#[tokio::test]
async fn reasoning_up_shortcut_does_not_silently_enter_advanced_effort() {
for (model, model_path) in [
("gpt-5.4", "All models → gpt-5.4"),
("codex-auto-test", "codex-auto-test"),
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.id = model.to_string();
preset.model = model.to_string();
preset.display_name = model.to_string();
preset.supported_reasoning_efforts.extend([
ReasoningEffortPreset {
effort: ReasoningEffortConfig::Max,
description: "Maximum reasoning".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffortConfig::Ultra,
description: "Ultra reasoning".to_string(),
},
]);
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset]));
chat.set_model(model);
for effort in [ReasoningEffortConfig::XHigh, ReasoningEffortConfig::Max] {
chat.set_reasoning_effort(Some(effort));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(events.iter().all(|event| !matches!(
event,
AppEvent::UpdateReasoningEffort(_) | AppEvent::ApplyAdvancedReasoning { .. }
)));
let messages = events
.into_iter()
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 140)))
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
messages,
vec![format!(
"• Max and Ultra are available under /model → {model_path} → More reasoning…\n"
)]
);
}
}
}
#[tokio::test]
async fn reasoning_down_shortcut_can_leave_advanced_effort() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.supported_reasoning_efforts.extend([
ReasoningEffortPreset {
effort: ReasoningEffortConfig::Ultra,
description: "Ultra reasoning".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffortConfig::Max,
description: "Maximum reasoning".to_string(),
},
]);
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset]));
for (current, expected) in [
(ReasoningEffortConfig::Ultra, ReasoningEffortConfig::Max),
(ReasoningEffortConfig::Max, ReasoningEffortConfig::XHigh),
] {
chat.set_reasoning_effort(Some(current));
chat.handle_key_event(KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(events.iter().any(|event| matches!(
event,
AppEvent::UpdateReasoningEffort(Some(effort)) if effort == &expected
)));
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::PersistModelSelection { .. }))
);
}
}
#[tokio::test]
async fn reasoning_popup_shows_extra_high_with_space() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
@@ -3488,6 +3648,60 @@ async fn single_reasoning_option_skips_selection() {
);
}
#[tokio::test]
async fn advanced_only_reasoning_option_requires_explicit_selection() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.default_reasoning_effort = ReasoningEffortConfig::Ultra;
preset.supported_reasoning_efforts = vec![ReasoningEffortPreset {
effort: ReasoningEffortConfig::Ultra,
description: "Ultra reasoning".to_string(),
}];
chat.open_reasoning_popup(preset);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("advanced_only_reasoning_selection_popup", popup);
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(events.iter().all(|event| !matches!(
event,
AppEvent::UpdateReasoningEffort(_)
| AppEvent::ApplyAdvancedReasoning { .. }
| AppEvent::PersistModelSelection { .. }
)));
}
#[tokio::test]
async fn auto_model_advertising_advanced_effort_opens_reasoning_picker() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut preset = get_available_model(&chat, "gpt-5.4");
preset.id = "codex-auto-test".to_string();
preset.model = "codex-auto-test".to_string();
preset.display_name = "codex-auto-test".to_string();
preset.default_reasoning_effort = ReasoningEffortConfig::Medium;
preset
.supported_reasoning_efforts
.push(ReasoningEffortPreset {
effort: ReasoningEffortConfig::Ultra,
description: "Ultra reasoning".to_string(),
});
chat.open_model_popup_with_presets(vec![preset]);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(events.iter().all(|event| !matches!(
event,
AppEvent::UpdateReasoningEffort(_)
| AppEvent::ApplyAdvancedReasoning { .. }
| AppEvent::PersistModelSelection { .. }
)));
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::OpenReasoningPopup { .. }))
);
}
#[tokio::test]
async fn feedback_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;