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
parent ffa6944587
commit e502f0b52d
8 changed files with 470 additions and 0 deletions

View File

@@ -775,6 +775,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
let mut change_mode = Line::from("");
let mut reasoning_down = Line::from("");
let mut reasoning_up = Line::from("");
for descriptor in SHORTCUTS {
if let Some(text) = descriptor.overlay_entry(state) {
@@ -791,6 +793,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
ShortcutId::Quit => quit = text,
ShortcutId::ShowTranscript => show_transcript = text,
ShortcutId::ChangeMode => change_mode = text,
ShortcutId::ReasoningDown => reasoning_down = text,
ShortcutId::ReasoningUp => reasoning_up = text,
}
}
}
@@ -806,6 +810,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
edit_previous,
history_search,
quit,
reasoning_down,
reasoning_up,
];
if change_mode.width() > 0 {
ordered.push(change_mode);
@@ -891,6 +897,8 @@ enum ShortcutId {
Quit,
ShowTranscript,
ChangeMode,
ReasoningDown,
ReasoningUp,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1082,6 +1090,24 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " to change mode",
},
ShortcutDescriptor {
id: ShortcutId::ReasoningDown,
bindings: &[ShortcutBinding {
key: key_hint::alt(KeyCode::Char(',')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " reasoning down",
},
ShortcutDescriptor {
id: ShortcutId::ReasoningUp,
bindings: &[ShortcutBinding {
key: key_hint::alt(KeyCode::Char('.')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " reasoning up",
},
];
#[cfg(test)]

View File

@@ -15,4 +15,5 @@ expression: terminal.backend()
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "

View File

@@ -7,5 +7,6 @@ expression: terminal.backend()
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" shift + tab to change mode "
" ctrl + t to view transcript "

View File

@@ -7,4 +7,5 @@ expression: terminal.backend()
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "

View File

@@ -379,6 +379,7 @@ use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE;
mod realtime;
use self::realtime::RealtimeConversationUiState;
use self::realtime::RenderedUserMessageEvent;
mod reasoning_shortcuts;
mod side;
mod status_surfaces;
use self::status_surfaces::CachedProjectRootName;
@@ -5276,6 +5277,13 @@ impl ChatWidget {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.handle_reasoning_shortcut(key_event) {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
return;
}
match key_event {
// Ctrl+O - copy last agent response from the main view.
KeyEvent {

View File

@@ -0,0 +1,287 @@
//! 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 current efforts are not normalized eagerly; the
//! next shortcut moves to the nearest supported effort in the requested
//! direction.
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use strum::IntoEnumIterator;
use super::ChatWidget;
use crate::app_event::AppEvent;
/// Direction requested by a reasoning-level shortcut.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ReasoningShortcutDirection {
Lower,
Raise,
}
impl ReasoningShortcutDirection {
fn from_key_event(key_event: KeyEvent) -> Option<Self> {
if key_event.kind != KeyEventKind::Press || key_event.modifiers != KeyModifiers::ALT {
return None;
}
match key_event.code {
KeyCode::Char(',') => Some(Self::Lower),
KeyCode::Char('.') => Some(Self::Raise),
_ => None,
}
}
fn bound_message(self, effort: ReasoningEffortConfig) -> String {
let label = ChatWidget::reasoning_effort_label(effort).to_lowercase();
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 Some(direction) = ReasoningShortcutDirection::from_key_event(key_event) 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 current_effort = self
.effective_reasoning_effort()
.unwrap_or(preset.default_reasoning_effort);
let Some(next_effort) = next_reasoning_effort(&choices, Some(current_effort), 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::new();
for effort in ReasoningEffortConfig::iter() {
if preset
.supported_reasoning_efforts
.iter()
.any(|option| option.effort == effort)
{
choices.push(effort);
}
}
if choices.is_empty() {
choices.push(preset.default_reasoning_effort);
}
choices
}
fn next_reasoning_effort(
choices: &[ReasoningEffortConfig],
current_effort: Option<ReasoningEffortConfig>,
direction: ReasoningShortcutDirection,
) -> Option<ReasoningEffortConfig> {
let current_effort = current_effort?;
if choices.is_empty() {
return None;
}
let current_rank = effort_rank(current_effort);
match direction {
ReasoningShortcutDirection::Lower => choices
.iter()
.rev()
.copied()
.find(|choice| effort_rank(*choice) < current_rank),
ReasoningShortcutDirection::Raise => choices
.iter()
.copied()
.find(|choice| effort_rank(*choice) > current_rank),
}
}
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,
}
}
#[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_skips_to_supported_level_from_unsupported_current() {
let choices = vec![ReasoningEffortConfig::Low, ReasoningEffortConfig::High];
assert_eq!(
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Raise,
),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
next_reasoning_effort(
&choices,
Some(ReasoningEffortConfig::Medium),
ReasoningShortcutDirection::Lower,
),
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
);
}
}

View File

@@ -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;

View File

@@ -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;