From 2ddac81e43425680d4f5e04e7a68eda2ae6b6083 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 22 Apr 2026 13:48:33 -0300 Subject: [PATCH] feat(tui): make reasoning shortcuts configurable --- codex-rs/config/src/tui_keymap.rs | 12 ++- codex-rs/core/config.schema.json | 53 +++++++++++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 8 ++ codex-rs/tui/src/bottom_pane/footer.rs | 80 ++++++++++++++----- codex-rs/tui/src/chatwidget.rs | 8 ++ .../tui/src/chatwidget/reasoning_shortcuts.rs | 57 ++++++++++--- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + codex-rs/tui/src/keymap.rs | 51 +++++++++++- codex-rs/tui/src/keymap_setup.rs | 4 +- codex-rs/tui/src/keymap_setup/actions.rs | 6 ++ codex-rs/tui/src/keymap_setup/picker.rs | 2 + ...__tests__keymap_picker_all_tab_search.snap | 4 +- ...ap_setup__tests__keymap_picker_custom.snap | 36 ++++----- ...p__tests__keymap_picker_first_actions.snap | 10 +-- ...ap_setup__tests__keymap_picker_narrow.snap | 38 ++++----- ...ymap_setup__tests__keymap_picker_wide.snap | 36 ++++----- docs/config.md | 10 ++- docs/default-keymap.toml | 8 +- docs/keymap-action-matrix.md | 8 +- docs/tui-keymap.md | 4 +- 20 files changed, 327 insertions(+), 109 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index fd9061cd7f..a4bf558be7 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -29,7 +29,7 @@ use std::collections::BTreeMap; pub enum TuiKeymapPreset { /// Pointer alias to the latest shipped preset. /// - /// Today this resolves to `v3`. + /// Today this resolves to `v4`. #[default] Latest, /// Frozen initial configurable keymap defaults. @@ -38,6 +38,8 @@ pub enum TuiKeymapPreset { V2, /// Current defaults with the copy shortcut exposed through keymap config. V3, + /// Current defaults with reasoning shortcuts exposed through keymap config. + V4, } /// Normalized string representation of a keybinding (for example `ctrl-a`). @@ -116,6 +118,10 @@ pub struct TuiGlobalKeymap { pub toggle_shortcuts: Option, /// Toggle Vim mode for the composer input. pub toggle_vim_mode: Option, + /// Lower the active reasoning level when possible. + pub reasoning_down: Option, + /// Raise the active reasoning level when possible. + pub reasoning_up: Option, } /// Chat context keybindings. These override corresponding `global` actions. @@ -126,6 +132,10 @@ pub struct TuiChatKeymap { pub edit_previous_message: Option, /// Confirm editing the selected previous message. pub confirm_edit_previous_message: Option, + /// Lower the active reasoning level when possible. + pub reasoning_down: Option, + /// Raise the active reasoning level when possible. + pub reasoning_up: Option, } /// Composer context keybindings. These override corresponding `global` actions. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 11a4f1a8a5..ca06a05428 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2204,7 +2204,9 @@ }, "chat": { "confirm_edit_previous_message": null, - "edit_previous_message": null + "edit_previous_message": null, + "reasoning_down": null, + "reasoning_up": null }, "composer": { "queue": null, @@ -2236,6 +2238,8 @@ "open_external_editor": null, "open_transcript": null, "queue": null, + "reasoning_down": null, + "reasoning_up": null, "submit": null, "toggle_shortcuts": null, "toggle_vim_mode": null @@ -2458,6 +2462,22 @@ } ], "description": "In an empty composer, begin or advance \"edit previous message\" flow." + }, + "reasoning_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Lower the active reasoning level when possible." + }, + "reasoning_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Raise the active reasoning level when possible." } }, "type": "object" @@ -2680,6 +2700,22 @@ ], "description": "Queue the current composer draft while a task is running." }, + "reasoning_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Lower the active reasoning level when possible." + }, + "reasoning_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Raise the active reasoning level when possible." + }, "submit": { "allOf": [ { @@ -2734,7 +2770,9 @@ ], "default": { "confirm_edit_previous_message": null, - "edit_previous_message": null + "edit_previous_message": null, + "reasoning_down": null, + "reasoning_up": null } }, "composer": { @@ -2787,6 +2825,8 @@ "open_external_editor": null, "open_transcript": null, "queue": null, + "reasoning_down": null, + "reasoning_up": null, "submit": null, "toggle_shortcuts": null, "toggle_vim_mode": null @@ -2912,7 +2952,7 @@ "description": "Versioned keymap defaults.", "oneOf": [ { - "description": "Pointer alias to the latest shipped preset.\n\nToday this resolves to `v3`.", + "description": "Pointer alias to the latest shipped preset.\n\nToday this resolves to `v4`.", "enum": [ "latest" ], @@ -2938,6 +2978,13 @@ "v3" ], "type": "string" + }, + { + "description": "Current defaults with reasoning shortcuts exposed through keymap config.", + "enum": [ + "v4" + ], + "type": "string" } ] }, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e66381e920..66e8fde985 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -397,6 +397,8 @@ pub(crate) struct ChatComposer { footer_external_editor_key: Option, footer_edit_previous_key: Option, footer_show_transcript_key: Option, + footer_reasoning_down_key: Option, + footer_reasoning_up_key: Option, } #[derive(Clone, Debug)] @@ -554,6 +556,8 @@ impl ChatComposer { footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), footer_edit_previous_key: Some(key_hint::plain(KeyCode::Esc)), footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), + footer_reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + footer_reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -636,6 +640,8 @@ impl ChatComposer { self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor); self.footer_edit_previous_key = primary_binding(&keymap.chat.edit_previous_message); self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); + self.footer_reasoning_down_key = primary_binding(&keymap.chat.reasoning_down); + self.footer_reasoning_up_key = primary_binding(&keymap.chat.reasoning_up); } pub fn set_collaboration_mode_indicator( @@ -3318,6 +3324,8 @@ impl ChatComposer { is_wsl, status_line_value: self.status_line_value.clone(), status_line_enabled: self.status_line_enabled, + reasoning_down_key: self.footer_reasoning_down_key, + reasoning_up_key: self.footer_reasoning_up_key, active_agent_label: self.active_agent_label.clone(), } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index d323a76fed..aa9dbbc132 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -76,6 +76,8 @@ pub(crate) struct FooterProps { pub(crate) quit_shortcut_key: KeyBinding, pub(crate) status_line_value: Option>, pub(crate) status_line_enabled: bool, + pub(crate) reasoning_down_key: Option, + pub(crate) reasoning_up_key: Option, /// Active thread label shown when the footer is rendering contextual information instead of an /// instructional hint. /// @@ -622,6 +624,8 @@ fn footer_from_props_lines( esc_backtrack_hint: props.esc_backtrack_hint, is_wsl: props.is_wsl, collaboration_modes_enabled: props.collaboration_modes_enabled, + reasoning_down_key: props.reasoning_down_key, + reasoning_up_key: props.reasoning_up_key, }; shortcut_overlay_lines(state) } @@ -739,6 +743,8 @@ struct ShortcutsState { esc_backtrack_hint: bool, is_wsl: bool, collaboration_modes_enabled: bool, + reasoning_down_key: Option, + reasoning_up_key: Option, } fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { @@ -791,11 +797,15 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, ShortcutId::ChangeMode => change_mode = text, - ShortcutId::ReasoningDown => reasoning_down = text, - ShortcutId::ReasoningUp => reasoning_up = text, } } } + if let Some(key) = state.reasoning_down_key { + reasoning_down = Line::from(vec![key.into(), " reasoning down".into()]); + } + if let Some(key) = state.reasoning_up_key { + reasoning_up = Line::from(vec![key.into(), " reasoning up".into()]); + } let mut ordered = vec![ commands, @@ -899,8 +909,6 @@ enum ShortcutId { Quit, ShowTranscript, ChangeMode, - ReasoningDown, - ReasoningUp, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1092,24 +1100,6 @@ 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)] @@ -1376,6 +1366,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1392,6 +1384,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1408,6 +1402,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1424,6 +1420,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1440,6 +1438,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1456,6 +1456,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1472,6 +1474,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1488,6 +1492,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, Some(72), @@ -1506,6 +1512,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, /*percent*/ None, @@ -1524,6 +1532,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }, ); @@ -1538,6 +1548,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1565,6 +1577,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1585,6 +1599,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1600,6 +1616,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1615,6 +1633,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1630,6 +1650,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, // command timed out / empty status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1651,6 +1673,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1672,6 +1696,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1696,6 +1722,8 @@ mod tests { "Status line content that should truncate before the mode indicator".to_string(), )), status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1717,6 +1745,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: None, status_line_enabled: false, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: Some("Robie [explorer]".to_string()), }; @@ -1732,6 +1762,8 @@ mod tests { quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: Some("Robie [explorer]".to_string()), }; @@ -1753,6 +1785,8 @@ mod tests { .to_string(), )), status_line_enabled: true, + reasoning_down_key: Some(key_hint::alt(KeyCode::Char(','))), + reasoning_up_key: Some(key_hint::alt(KeyCode::Char('.'))), active_agent_label: None, }; @@ -1807,6 +1841,8 @@ mod tests { esc_backtrack_hint: false, is_wsl, collaboration_modes_enabled: false, + reasoning_down_key: None, + reasoning_up_key: None, }) .expect("shortcut binding") .key; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 57e61b89dc..ba8ddc470c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -355,6 +355,7 @@ use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::keymap::ChatKeymap; use crate::keymap::RuntimeKeymap; use crate::keymap_setup; #[cfg(test)] @@ -824,6 +825,7 @@ pub(crate) struct ChatWidget { /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, copy_last_response_binding: Vec, + chat_keymap: ChatKeymap, /// Raw markdown of the most recently completed agent response that /// survived any local thread rollback. last_agent_markdown: Option, @@ -5162,6 +5164,10 @@ impl ChatWidget { .as_ref() .map(|keymap| keymap.app.copy.clone()) .unwrap_or_else(|| RuntimeKeymap::defaults().app.copy); + let chat_keymap = runtime_keymap + .as_ref() + .map(|keymap| keymap.chat.clone()) + .unwrap_or_else(|| RuntimeKeymap::defaults().chat); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -5205,6 +5211,7 @@ impl ChatWidget { plan_stream_controller: None, clipboard_lease: None, copy_last_response_binding, + chat_keymap, running_commands: HashMap::new(), collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), @@ -7845,6 +7852,7 @@ impl ChatWidget { ) { self.config.tui_keymap = keymap_config; self.copy_last_response_binding = runtime_keymap.app.copy.clone(); + self.chat_keymap = runtime_keymap.chat.clone(); self.bottom_pane.set_keymap_bindings(runtime_keymap); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index ef37caaa89..00428b00a6 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -15,14 +15,14 @@ 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; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ChatKeymap; /// Direction requested by a reasoning-level shortcut. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -32,15 +32,17 @@ pub(super) enum ReasoningShortcutDirection { } impl ReasoningShortcutDirection { - fn from_key_event(key_event: KeyEvent) -> Option { - if key_event.kind != KeyEventKind::Press || key_event.modifiers != KeyModifiers::ALT { + fn from_key_event(key_event: KeyEvent, keymap: &ChatKeymap) -> Option { + if key_event.kind != KeyEventKind::Press { return None; } - match key_event.code { - KeyCode::Char(',') => Some(Self::Lower), - KeyCode::Char('.') => Some(Self::Raise), - _ => None, + if keymap.reasoning_down.is_pressed(key_event) { + Some(Self::Lower) + } else if keymap.reasoning_up.is_pressed(key_event) { + Some(Self::Raise) + } else { + None } } @@ -66,7 +68,9 @@ impl ChatWidget { /// 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 { + let Some(direction) = + ReasoningShortcutDirection::from_key_event(key_event, &self.chat_keymap) + else { return false; }; @@ -176,8 +180,43 @@ fn effort_rank(effort: ReasoningEffortConfig) -> i32 { #[cfg(test)] mod tests { use super::*; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; + #[test] + fn reasoning_shortcut_direction_uses_configured_chat_bindings() { + let keymap = ChatKeymap { + edit_previous_message: Vec::new(), + confirm_edit_previous_message: Vec::new(), + reasoning_down: vec![crate::key_hint::ctrl(KeyCode::Char(','))], + reasoning_up: vec![crate::key_hint::ctrl(KeyCode::Char('.'))], + }; + + assert_eq!( + ReasoningShortcutDirection::from_key_event( + KeyEvent::new(KeyCode::Char(','), KeyModifiers::CONTROL), + &keymap, + ), + Some(ReasoningShortcutDirection::Lower) + ); + assert_eq!( + ReasoningShortcutDirection::from_key_event( + KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL), + &keymap, + ), + Some(ReasoningShortcutDirection::Raise) + ); + assert_eq!( + ReasoningShortcutDirection::from_key_event( + KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT), + &keymap, + ), + None + ); + } + #[test] fn next_reasoning_effort_raises_from_default_anchor() { let choices = vec![ diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 9211edc16b..ef24f74b36 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -212,6 +212,7 @@ pub(super) async fn make_chatwidget_manual( plan_stream_controller: None, clipboard_lease: None, copy_last_response_binding: RuntimeKeymap::defaults().app.copy, + chat_keymap: RuntimeKeymap::defaults().chat, pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, last_agent_markdown: None, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 5ae82babf7..f442f72e38 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -33,7 +33,7 @@ use std::collections::HashMap; /// /// 1. Context-specific binding (`tui.keymap.`). /// 2. `tui.keymap.global` for actions that support global fallback. -/// 3. Built-in preset defaults (`latest` currently points to `v3`). +/// 3. Built-in preset defaults (`latest` currently points to `v4`). #[derive(Clone, Debug)] pub(crate) struct RuntimeKeymap { pub(crate) app: AppKeymap, @@ -72,6 +72,10 @@ pub(crate) struct ChatKeymap { pub(crate) edit_previous_message: Vec, /// Confirm edit-previous selection. pub(crate) confirm_edit_previous_message: Vec, + /// Lower the active reasoning level when possible. + pub(crate) reasoning_down: Vec, + /// Raise the active reasoning level when possible. + pub(crate) reasoning_up: Vec, } /// Composer-level keybindings validated in the second app-scope conflict pass. @@ -376,6 +380,8 @@ impl RuntimeKeymap { chat, confirm_edit_previous_message ), + reasoning_down: resolve_with_global!(keymap, defaults, chat, reasoning_down), + reasoning_up: resolve_with_global!(keymap, defaults, chat, reasoning_up), }; let composer = ComposerKeymap { @@ -521,7 +527,8 @@ impl RuntimeKeymap { fn defaults_for_preset(preset: TuiKeymapPreset) -> Self { match preset { - TuiKeymapPreset::Latest | TuiKeymapPreset::V3 => Self::defaults_v3(), + TuiKeymapPreset::Latest | TuiKeymapPreset::V4 => Self::defaults_v4(), + TuiKeymapPreset::V3 => Self::defaults_v3(), TuiKeymapPreset::V2 => Self::defaults_v2(), TuiKeymapPreset::V1 => Self::defaults_v1(), } @@ -543,6 +550,8 @@ impl RuntimeKeymap { chat: ChatKeymap { edit_previous_message: default_bindings![plain(KeyCode::Esc)], confirm_edit_previous_message: default_bindings![plain(KeyCode::Enter)], + reasoning_down: default_bindings![], + reasoning_up: default_bindings![], }, composer: ComposerKeymap { submit: default_bindings![plain(KeyCode::Enter)], @@ -751,6 +760,17 @@ impl RuntimeKeymap { defaults } + /// Current keymap defaults for preset `v4`. + /// + /// This keeps earlier presets intact while moving reasoning-step shortcuts + /// into configurable keymap defaults. + fn defaults_v4() -> Self { + let mut defaults = Self::defaults_v3(); + defaults.chat.reasoning_down = default_bindings![alt(KeyCode::Char(','))]; + defaults.chat.reasoning_up = default_bindings![alt(KeyCode::Char('.'))]; + defaults + } + /// Reject ambiguous bindings in scopes that are evaluated together. /// /// We validate in multiple passes because runtime handling has mixed @@ -782,6 +802,8 @@ impl RuntimeKeymap { "confirm_edit_previous_message", self.chat.confirm_edit_previous_message.as_slice(), ), + ("reasoning_down", self.chat.reasoning_down.as_slice()), + ("reasoning_up", self.chat.reasoning_up.as_slice()), ], )?; @@ -1281,6 +1303,19 @@ mod tests { assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]); } + #[test] + fn default_latest_reasoning_bindings_are_alt_comma_and_alt_period() { + let runtime = RuntimeKeymap::defaults(); + assert_eq!( + runtime.chat.reasoning_down, + vec![key_hint::alt(KeyCode::Char(','))] + ); + assert_eq!( + runtime.chat.reasoning_up, + vec![key_hint::alt(KeyCode::Char('.'))] + ); + } + #[test] fn invalid_global_copy_binding_reports_global_path() { let mut keymap = TuiKeymap::default(); @@ -1303,6 +1338,18 @@ mod tests { ); } + #[test] + fn resolves_reasoning_binding_from_global_fallback() { + let mut keymap = TuiKeymap::default(); + keymap.global.reasoning_up = Some(one("ctrl-.")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + assert_eq!( + runtime.chat.reasoning_up, + vec![key_hint::ctrl(KeyCode::Char('.'))] + ); + } + #[test] fn rejects_conflicting_editor_bindings() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 5e26c0b1c9..3f6f1f329b 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -773,7 +773,7 @@ mod tests { line.push_str(symbol); } } - line + line.trim_end().to_string() }) .collect::>() .join("\n") @@ -880,6 +880,8 @@ mod tests { "Global.open_external_editor", "Global.copy", "Global.toggle_vim_mode", + "Chat.reasoning_down", + "Chat.reasoning_up", "Editor.delete_backward_word", "Editor.delete_forward_word", "Editor.move_word_left", diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index defa9346a9..6c33777775 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -38,6 +38,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), action("chat", "Chat", "edit_previous_message", "Begin or advance edit-previous-message when the composer is empty."), action("chat", "Chat", "confirm_edit_previous_message", "Confirm the selected previous message to edit."), + action("chat", "Chat", "reasoning_down", "Lower the active reasoning level when possible."), + action("chat", "Chat", "reasoning_up", "Raise the active reasoning level when possible."), action("composer", "Composer", "submit", "Submit the current composer draft."), action("composer", "Composer", "queue", "Queue the draft while a task is running."), action("composer", "Composer", "toggle_shortcuts", "Show or hide the composer shortcut overlay."), @@ -152,6 +154,8 @@ pub(super) fn binding_slot<'a>( ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("chat", "edit_previous_message") => Some(&mut keymap.chat.edit_previous_message), ("chat", "confirm_edit_previous_message") => Some(&mut keymap.chat.confirm_edit_previous_message), + ("chat", "reasoning_down") => Some(&mut keymap.chat.reasoning_down), + ("chat", "reasoning_up") => Some(&mut keymap.chat.reasoning_up), ("composer", "submit") => Some(&mut keymap.composer.submit), ("composer", "queue") => Some(&mut keymap.composer.queue), ("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts), @@ -254,6 +258,8 @@ pub(super) fn bindings_for_action<'a>( ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("chat", "edit_previous_message") => Some(runtime_keymap.chat.edit_previous_message.as_slice()), ("chat", "confirm_edit_previous_message") => Some(runtime_keymap.chat.confirm_edit_previous_message.as_slice()), + ("chat", "reasoning_down") => Some(runtime_keymap.chat.reasoning_down.as_slice()), + ("chat", "reasoning_up") => Some(runtime_keymap.chat.reasoning_up.as_slice()), ("composer", "submit") => Some(runtime_keymap.composer.submit.as_slice()), ("composer", "queue") => Some(runtime_keymap.composer.queue.as_slice()), ("composer", "toggle_shortcuts") => Some(runtime_keymap.composer.toggle_shortcuts.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 6c13f67c61..6d6a1009bb 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -61,6 +61,8 @@ const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("global", "open_external_editor"), ("global", "copy"), ("global", "toggle_vim_mode"), + ("chat", "reasoning_down"), + ("chat", "reasoning_up"), ("editor", "delete_backward_word"), ("editor", "delete_forward_word"), ("editor", "move_word_left"), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 3e00b75962..f5441f94f8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -8,9 +8,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Edit Previous Message | esc | Chat edit_previous_message Edit Previous Message Begin or advance edit-previous-message when the composer is empty. esc Default Confirm Edit Previous Message | enter | Chat confirm_edit_previous_message Confirm Edit Previous Message Confirm the selected previous message to edit. enter Default +Reasoning Down | alt-, | Chat reasoning_down Reasoning Down Lower the active reasoning level when possible. alt-, Default +Reasoning Up | alt-. | Chat reasoning_up Reasoning Up Raise the active reasoning level when possible. alt-. Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default Insert Newline | ctrl-j, ctrl-m, enter, shift-enter | Editor insert_newline Insert Newline Insert a newline in the editor. ctrl-j, ctrl-m, enter, shift-enter Default -Move Left | left, ctrl-b | Editor move_left Move Left Move the cursor left. left, ctrl-b Default -Move Right | right, ctrl-f | Editor move_right Move Right Move the cursor right. right, ctrl-f Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index b9f4d2c0c9..83d7edc38f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -2,22 +2,22 @@ source: tui/src/keymap_setup.rs expression: "render_picker(params, 120)" --- - - Keymap - All configurable shortcuts. - 91 actions, 1 customized, 1 unbound. - - [All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval - - Type to search shortcuts -› Global Open Transcript ctrl-t - Global Open External Editor ctrl-g - Global Copy ctrl-o - Global - Toggle Vim Mode unbound - Chat Edit Previous Message esc - Chat Confirm Edit Previous Message enter - Composer * Submit ctrl-enter - Composer Queue tab - - Open Transcript: Open the transcript overlay. + + Keymap + All configurable shortcuts. + 93 actions, 1 customized, 1 unbound. + + [All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global - Toggle Vim Mode unbound + Chat Edit Previous Message esc + Chat Confirm Edit Previous Message enter + Chat Reasoning Down alt-, + Chat Reasoning Up alt-. + + Open Transcript: Open the transcript overlay. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index ddacc4f0d7..5dafce1474 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (91 selectable) -tab: Common (19 selectable) +tab: All (93 selectable) +tab: Common (21 selectable) tab: Customized (0) (0 selectable) tab: Unbound (1) (1 selectable) -tab: App (6 selectable) +tab: App (8 selectable) tab: Composer (3 selectable) tab: Editor (16 selectable) tab: Vim (34 selectable) @@ -18,9 +18,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Edit Previous Message | esc | Chat edit_previous_message Edit Previous Message Begin or advance edit-previous-message when the composer is empty. esc Default Confirm Edit Previous Message | enter | Chat confirm_edit_previous_message Confirm Edit Previous Message Confirm the selected previous message to edit. enter Default +Reasoning Down | alt-, | Chat reasoning_down Reasoning Down Lower the active reasoning level when possible. alt-, Default +Reasoning Up | alt-. | Chat reasoning_up Reasoning Up Raise the active reasoning level when possible. alt-. Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default Insert Newline | ctrl-j, ctrl-m, enter, shift-enter | Editor insert_newline Insert Newline Insert a newline in the editor. ctrl-j, ctrl-m, enter, shift-enter Default -Move Left | left, ctrl-b | Editor move_left Move Left Move the cursor left. left, ctrl-b Default -Move Right | right, ctrl-f | Editor move_right Move Right Move the cursor right. right, ctrl-f Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index dd178813b9..8d1a8fcdf9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -2,23 +2,23 @@ source: tui/src/keymap_setup.rs expression: "render_picker(params, 78)" --- - - Keymap - All configurable shortcuts. - 91 actions, 0 customized, 1 unbound. - - [All] Common Customized (0) Unbound (1) App Composer Editor Vim - Navigation Approval - - Type to search shortcuts -› Global Open Transcript ctrl-t - Global Open External Editor ctrl-g - Global Copy ctrl-o - Global - Toggle Vim Mode unbound - Chat Edit Previous Message esc - Chat Confirm Edit Previous Message enter - Composer Submit enter - Composer Queue tab - - Open Transcript: Open the transcript overlay. + + Keymap + All configurable shortcuts. + 93 actions, 0 customized, 1 unbound. + + [All] Common Customized (0) Unbound (1) App Composer Editor Vim + Navigation Approval + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global - Toggle Vim Mode unbound + Chat Edit Previous Message esc + Chat Confirm Edit Previous Message enter + Chat Reasoning Down alt-, + Chat Reasoning Up alt-. + + Open Transcript: Open the transcript overlay. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 97ce17185a..d7cd3440ee 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -2,22 +2,22 @@ source: tui/src/keymap_setup.rs expression: "render_picker(params, 120)" --- - - Keymap - All configurable shortcuts. - 91 actions, 0 customized, 1 unbound. - - [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval - - Type to search shortcuts -› Global Open Transcript ctrl-t - Global Open External Editor ctrl-g - Global Copy ctrl-o - Global - Toggle Vim Mode unbound - Chat Edit Previous Message esc - Chat Confirm Edit Previous Message enter - Composer Submit enter - Composer Queue tab - - Open Transcript: Open the transcript overlay. + + Keymap + All configurable shortcuts. + 93 actions, 0 customized, 1 unbound. + + [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global - Toggle Vim Mode unbound + Chat Edit Previous Message esc + Chat Confirm Edit Previous Message enter + Chat Reasoning Down alt-, + Chat Reasoning Up alt-. + + Open Transcript: Open the transcript overlay. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/docs/config.md b/docs/config.md index 9b55d8eab5..8bb590d1ce 100644 --- a/docs/config.md +++ b/docs/config.md @@ -36,8 +36,9 @@ Precedence is applied in this order (highest first): ### Presets -- `latest`: moving alias for the newest preset; today `latest -> v3` -- `v3`: current defaults, exposing `copy = "ctrl-o"` in the keymap +- `latest`: moving alias for the newest preset; today `latest -> v4` +- `v4`: current defaults, exposing reasoning shortcuts (`alt-,` / `alt-.`) in the keymap +- `v3`: exposes `copy = "ctrl-o"` in the keymap - `v2`: restores `alt-d` as a `delete_forward_word` alias - `v1`: frozen initial configurable keymap defaults @@ -50,8 +51,9 @@ TODO(docs): mirror this preset migration note on developers.openai.com. - `global`: `open_transcript`, `open_external_editor`, `copy`, `edit_previous_message`, `confirm_edit_previous_message`, `submit`, `queue`, `toggle_shortcuts`, - `toggle_vim_mode` -- `chat`: `edit_previous_message`, `confirm_edit_previous_message` + `toggle_vim_mode`, `reasoning_down`, `reasoning_up` +- `chat`: `edit_previous_message`, `confirm_edit_previous_message`, + `reasoning_down`, `reasoning_up` - `composer`: `submit`, `queue`, `toggle_shortcuts` - `editor`: `insert_newline`, `move_left`, `move_right`, `move_up`, `move_down`, `move_word_left`, `move_word_right`, `move_line_start`, `move_line_end`, diff --git a/docs/default-keymap.toml b/docs/default-keymap.toml index 66ac45c952..c3921d7f5a 100644 --- a/docs/default-keymap.toml +++ b/docs/default-keymap.toml @@ -18,7 +18,7 @@ [tui.keymap] # Preset alias for built-in defaults. -# "latest" currently points to "v3". +# "latest" currently points to "v4". preset = "latest" [tui.keymap.global] @@ -42,11 +42,17 @@ toggle_shortcuts = ["?", "shift-?"] # Toggle Vim mode for the composer input. # Empty list keeps this unbound by default. toggle_vim_mode = [] +# Lower active reasoning level when possible. +reasoning_down = "alt-," +# Raise active reasoning level when possible. +reasoning_up = "alt-." [tui.keymap.chat] # Overrides [tui.keymap.global] for chat-only behavior. edit_previous_message = "esc" confirm_edit_previous_message = "enter" +reasoning_down = "alt-," +reasoning_up = "alt-." [tui.keymap.composer] # Overrides [tui.keymap.global] for composer behavior. diff --git a/docs/keymap-action-matrix.md b/docs/keymap-action-matrix.md index 68353da9b4..010d51dab9 100644 --- a/docs/keymap-action-matrix.md +++ b/docs/keymap-action-matrix.md @@ -7,14 +7,14 @@ For runtime behavior, safety invariants, and testing guidance, see ## Preset behavior - `latest` is an alias to the newest shipped preset. -- Today, `latest -> v3`. +- Today, `latest -> v4`. - To keep stable behavior over time, pin `preset = "v1"`. ## Precedence 1. `tui.keymap..` 2. `tui.keymap.global.` (chat/composer fallback actions only) -3. Preset default (`v3` today) +3. Preset default (`v4` today) ## Default `v1` Compatibility Notes @@ -43,11 +43,15 @@ For runtime behavior, safety invariants, and testing guidance, see - `queue`: queue current draft while a task is running - `toggle_shortcuts`: toggle composer shortcut overlay - `toggle_vim_mode`: toggle Vim mode for composer input +- `reasoning_down`: lower active reasoning level when possible +- `reasoning_up`: raise active reasoning level when possible ### `chat` - `edit_previous_message`: chat override for edit-previous flow - `confirm_edit_previous_message`: chat override for edit confirmation +- `reasoning_down`: chat override for lowering active reasoning level +- `reasoning_up`: chat override for raising active reasoning level ### `composer` diff --git a/docs/tui-keymap.md b/docs/tui-keymap.md index e34beb6cf6..72090ba733 100644 --- a/docs/tui-keymap.md +++ b/docs/tui-keymap.md @@ -85,8 +85,8 @@ at `https://github.com/openai/codex/blob/main/docs/default-keymap.toml`. Preset semantics: 1. `latest` is an alias to the newest shipped preset. -2. `v3` is the current baseline; `v1` and `v2` are frozen for historical behavior. -3. Today, `latest -> v3`. +2. `v4` is the current baseline; `v1`, `v2`, and `v3` are frozen for historical behavior. +3. Today, `latest -> v4`. User guidance: