feat(tui): shortcuts to change reasoning level temporarily (#18866)

## Summary

Adds main-chat shortcuts for changing reasoning effort one step at a
time:

- `Alt+,` lowers reasoning (has the `<` arrow on the key)
- `Alt+.` raises reasoning (similarly, has the `>` arrow)

The shortcut updates the active session only. It does not persist the
selected reasoning level as the default for future sessions. In Plan
mode, it applies temporarily to Plan mode without opening the
global-vs-Plan scope prompt.

## Details

The shortcut uses the active model preset to decide which reasoning
levels are valid. If the current session has no explicit reasoning
effort, it starts from the model default. Each keypress moves to the
next supported level in the requested direction.

The shortcut only runs from the main chat surface. If a popup or modal
is open, input remains owned by that UI.

In Plan mode, the shortcut updates the in-memory Plan reasoning override
directly. The model/reasoning picker still keeps the existing scope
prompt for explicit picker changes.

## Notes

Ctrl-plus and Ctrl-minus were considered, but terminals do not deliver
those combinations consistently, so this PR uses Alt shortcuts instead.

If the current effort is unsupported by the selected model, the shortcut
skips to the nearest supported level in the requested direction. If
there is no valid step, it shows the existing boundary message.

## Tests

- `cargo test -p codex-tui reasoning_shortcuts`
- `cargo test -p codex-tui reasoning_effort`
- `cargo test -p codex-tui reasoning_shortcut`
- `cargo test -p codex-tui footer_snapshots`
- `cargo test -p codex-tui`
- `just fix -p codex-tui`
- `./tools/argument-comment-lint/run.py -p codex-tui -- --tests`

---------

Co-authored-by: Eric Traut <etraut@openai.com>
This commit is contained in:
Felipe Coury
2026-04-21 18:04:03 -03:00
committed by GitHub
co-authored by Eric Traut
parent ffa6944587
commit e502f0b52d
8 changed files with 470 additions and 0 deletions
@@ -259,6 +259,53 @@ async fn reasoning_selection_in_plan_mode_matching_plan_effort_but_different_glo
);
}
#[tokio::test]
async fn reasoning_shortcut_in_plan_mode_updates_plan_override_without_prompt_or_persist() {
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);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
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::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
)),
"expected plan reasoning override update event; events: {events:?}"
);
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })),
"expected no Plan reasoning scope prompt event; events: {events:?}"
);
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::PersistPlanModeReasoningEffort(_))),
"expected no Plan reasoning persistence event; events: {events:?}"
);
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),
"expected no global model persistence event; events: {events:?}"
);
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::UpdateReasoningEffort(_))),
"expected no global reasoning update event; events: {events:?}"
);
}
#[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;
@@ -2077,6 +2077,105 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() {
assert_chatwidget_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup);
}
#[tokio::test]
async fn alt_period_raises_reasoning_effort() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
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::UpdateModel(model) if model == "gpt-5.4")),
"expected model update event; events: {events:?}"
);
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::High))
)),
"expected reasoning update event; events: {events:?}"
);
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),
"expected no model persistence event; events: {events:?}"
);
}
#[tokio::test]
async fn alt_comma_lowers_reasoning_effort() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
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(ReasoningEffortConfig::Low))
)),
"expected reasoning update event; events: {events:?}"
);
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),
"expected no model persistence event; events: {events:?}"
);
}
#[tokio::test]
async fn reasoning_shortcut_clears_armed_quit_shortcut() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
chat.arm_quit_shortcut(key_hint::ctrl(KeyCode::Char('c')));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT));
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
assert!(chat.quit_shortcut_expires_at.is_none());
assert!(chat.quit_shortcut_key.is_none());
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::Exit(_))),
"did not expect reasoning shortcut to quit; events: {events:?}"
);
}
#[tokio::test]
async fn reasoning_shortcut_is_ignored_with_model_popup_open() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
chat.open_model_popup();
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(_))),
"did not expect reasoning update while popup is active; events: {events:?}"
);
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),
"did not expect model persistence while popup is active; events: {events:?}"
);
}
#[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;