diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index e41ec860ee8..e063cac0e8e 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -115,6 +115,8 @@ pub struct TuiGlobalKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiChatKeymap { + /// Interrupt the active turn. + pub interrupt_turn: Option, /// Decrease the active reasoning effort. pub decrease_reasoning_effort: Option, /// Increase the active reasoning effort. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f8ffe9c8324..ab855d733bd 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2766,7 +2766,8 @@ "chat": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null }, "composer": { "history_search_next": null, @@ -3094,6 +3095,14 @@ } ], "description": "Increase the active reasoning effort." + }, + "interrupt_turn": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Interrupt the active turn." } }, "type": "object" @@ -3405,7 +3414,8 @@ "default": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null } }, "composer": { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 65770b61ae8..1fcc7635c07 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -25,7 +25,9 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; use crate::keymap::RuntimeKeymap; +use crate::keymap::primary_binding; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; @@ -352,6 +354,12 @@ impl BottomPane { pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { self.keymap = keymap.clone(); self.composer.set_keymap_bindings(keymap); + let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn); + self.pending_input_preview + .set_interrupt_binding(interrupt_binding); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding(interrupt_binding); + } self.request_redraw(); } @@ -617,13 +625,12 @@ impl BottomPane { .and_then(parse_slash_name) .is_some_and(|(name, _, _)| name == "agent"); - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. + // If a task is running and a status line is visible, allow the + // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. - if key_event.code == KeyCode::Esc - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + if self.keymap.chat.interrupt_turn.is_pressed(key_event) && self.is_task_running - && !is_agent_command + && !(is_agent_command && key_event.code == KeyCode::Esc) && !self.composer.popup_active() && !self.composer_should_handle_vim_insert_escape(key_event) && let Some(status) = &self.status @@ -989,6 +996,7 @@ impl BottomPane { } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(/*visible*/ true); + status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); } self.sync_status_inline_message(); self.request_redraw(); @@ -1017,6 +1025,9 @@ impl BottomPane { self.frame_requester.clone(), self.animations_enabled, )); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); + } self.sync_status_inline_message(); self.request_redraw(); } @@ -2731,6 +2742,30 @@ mod tests { ); } + #[test] + fn remapped_interrupt_turn_uses_configured_key_including_agent_drafts() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + pane.set_keymap_bindings(&keymap); + pane.set_task_running(/*running*/ true); + pane.insert_str("/agent "); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + rx.try_recv().is_err(), + "expected Esc to remain local after remapping interruption" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected configured key to interrupt while `/agent` is being edited" + ); + } + #[test] fn selection_view_esc_respects_remapped_list_cancel() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 34e7d7d390d..96cc1bdfd8c 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -15,7 +15,7 @@ use crate::wrapping::adaptive_wrap_lines; /// The widget renders pending steers first, then rejected steers that will be /// resubmitted at end of turn, then ordinary queued user messages. Pending /// steers explain that they will be submitted after the next tool/result -/// boundary unless the user presses Esc to interrupt and send them +/// boundary unless the user invokes the interrupt binding to send them /// immediately. The edit hint at the bottom only appears when there are actual /// queued user inputs to pop back into the composer. Because some terminals /// intercept certain modifier-key combinations, the displayed binding is @@ -27,6 +27,8 @@ pub(crate) struct PendingInputPreview { /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. edit_binding: Option, + /// Key combination rendered for immediately interrupting and sending steers. + interrupt_binding: Option, } const PREVIEW_LINE_LIMIT: usize = 3; @@ -38,6 +40,7 @@ impl PendingInputPreview { rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: Some(key_hint::alt(KeyCode::Up)), + interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), } } @@ -48,6 +51,10 @@ impl PendingInputPreview { self.edit_binding = binding; } + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + self.interrupt_binding = binding; + } + fn push_truncated_preview_lines( lines: &mut Vec>, wrapped: Vec>, @@ -81,16 +88,15 @@ impl PendingInputPreview { let mut lines = vec![]; if !self.pending_steers.is_empty() { - Self::push_section_header( - &mut lines, - width, - Line::from(vec![ - "Messages to be submitted after next tool call".into(), + let mut header = vec!["Messages to be submitted after next tool call".into()]; + if let Some(interrupt_binding) = self.interrupt_binding { + header.extend(vec![ " (press ".dim(), - key_hint::plain(KeyCode::Esc).into(), + interrupt_binding.into(), " to interrupt and send immediately)".dim(), - ]), - ); + ]); + } + Self::push_section_header(&mut lines, width, Line::from(header)); for steer in &self.pending_steers { let wrapped = adaptive_wrap_lines( @@ -327,6 +333,21 @@ mod tests { assert_snapshot!("render_one_pending_steer", format!("{buf:?}")); } + #[test] + fn render_one_pending_steer_with_remapped_interrupt_binding() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)))); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_pending_steer_with_remapped_interrupt_binding", + format!("{buf:?}") + ); + } + #[test] fn render_pending_steers_above_queued_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 16baedc097f..d328d9f1fd3 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -143,6 +143,7 @@ pub(crate) struct RequestUserInputOverlay { pending_submission_draft: Option, confirm_unanswered: Option, composer_submit_keys: Vec, + interrupt_turn_keys: Vec, list_keymap: ListKeymap, } @@ -198,6 +199,7 @@ impl RequestUserInputOverlay { pending_submission_draft: None, confirm_unanswered: None, composer_submit_keys: keymap.composer.submit.clone(), + interrupt_turn_keys: keymap.chat.interrupt_turn.clone(), list_keymap: keymap.list, }; overlay.reset_for_request(); @@ -506,8 +508,15 @@ impl RequestUserInputOverlay { tips.push(FooterTip::new("ctrl + p / ctrl + n change question")); } } - if !(self.has_options() && notes_visible) { - tips.push(FooterTip::new("esc to interrupt")); + if let Some(interrupt_key) = self.interrupt_turn_keys.first() + && !(self.has_options() + && notes_visible + && *interrupt_key == crate::key_hint::plain(KeyCode::Esc)) + { + tips.push(FooterTip::new(format!( + "{} to interrupt", + interrupt_key.display_label() + ))); } tips } @@ -1056,11 +1065,12 @@ impl BottomPaneView for RequestUserInputOverlay { return; } - if matches!(key_event.code, KeyCode::Esc) { - if self.has_options() && self.notes_ui_visible() { - self.clear_notes_and_focus_options(); - return; - } + if matches!(key_event.code, KeyCode::Esc) && self.has_options() && self.notes_ui_visible() { + self.clear_notes_and_focus_options(); + return; + } + + if self.interrupt_turn_keys.is_pressed(key_event) { // TODO: Emit interrupted request_user_input results (including committed answers) // once core supports persisting them reliably without follow-up turn issues. self.app_event_tx.interrupt(); @@ -2043,6 +2053,40 @@ mod tests { ); } + #[test] + fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() { + let (tx, mut rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec![ + "tab or esc to clear notes", + "enter to submit answer", + "f12 to interrupt", + ] + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::F(12))); + + assert_eq!(overlay.done, true); + expect_interrupt_only(&mut rx); + } + #[test] fn tab_opens_notes_when_option_selected() { let (tx, _rx) = test_sender(); @@ -3171,6 +3215,26 @@ mod tests { ); } + #[test] + fn request_user_input_freeform_remapped_interrupt_snapshot() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform_remapped_interrupt", + render_snapshot(&overlay, area) + ); + } + #[test] fn request_user_input_multi_question_first_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap new file mode 100644 index 00000000000..0b26b354e33 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + enter to submit answer | f12 to interrupt diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap new file mode 100644 index 00000000000..f8a3caa1bf0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 3 }, + content: [ + "• Messages to be submitted after next tool call ", + " (press f12 to interrupt and send immediately) ", + " ↳ Please continue. ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 0819bd6c030..d9451fc9d57 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -112,8 +112,7 @@ impl ChatWidget { return; } - if matches!(key_event.code, KeyCode::Esc) - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + if self.chat_keymap.interrupt_turn.is_pressed(key_event) && !self.input_queue.pending_steers.is_empty() && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index bfe563b94c2..23487af437a 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -953,6 +953,32 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() { assert!(chat.input_queue.submit_pending_steers_after_interrupt); } +#[tokio::test] +async fn pending_steer_interrupt_uses_remapped_binding() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + chat.chat_keymap = keymap.chat.clone(); + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.bottom_pane.set_task_running(/*running*/ true); + chat.input_queue + .pending_steers + .push_back(pending_steer("queued steer")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!chat.input_queue.submit_pending_steers_after_interrupt); + assert!(op_rx.try_recv().is_err()); + + chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert!(chat.input_queue.submit_pending_steers_after_interrupt); +} + #[tokio::test] async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 8e6d40c764c..7bb554a257c 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -78,6 +78,8 @@ pub(crate) struct AppKeymap { /// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { + /// Interrupt the active turn. + pub(crate) interrupt_turn: Vec, /// Decrease the active reasoning effort. pub(crate) decrease_reasoning_effort: Vec, /// Increase the active reasoning effort. @@ -420,6 +422,11 @@ impl RuntimeKeymap { }; let chat = ChatKeymap { + interrupt_turn: resolve_bindings( + keymap.chat.interrupt_turn.as_ref(), + &defaults.chat.interrupt_turn, + "tui.keymap.chat.interrupt_turn", + )?, decrease_reasoning_effort: resolve_bindings( keymap.chat.decrease_reasoning_effort.as_ref(), &defaults.chat.decrease_reasoning_effort, @@ -878,6 +885,7 @@ impl RuntimeKeymap { toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], }, chat: ChatKeymap { + interrupt_turn: default_bindings![plain(KeyCode::Esc)], decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], increase_reasoning_effort: default_bindings![alt(KeyCode::Char('.'))], edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], @@ -1127,6 +1135,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1169,6 +1178,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1197,6 +1207,11 @@ impl RuntimeKeymap { ), ], MAIN_RESERVED_BINDINGS, + [( + "chat.interrupt_turn", + "fixed.backtrack", + key_hint::plain(KeyCode::Esc), + )], )?; validate_no_shadow_with_allowed_overlaps( @@ -1249,6 +1264,18 @@ impl RuntimeKeymap { )], )?; + // The request-user-input overlay consumes turn interruption before + // configurable question navigation reaches its list handler. + validate_no_shadow_with_allowed_overlaps( + "request_user_input", + [("chat.interrupt_turn", self.chat.interrupt_turn.as_slice())], + [ + ("list.move_left", self.list.move_left.as_slice()), + ("list.move_right", self.list.move_right.as_slice()), + ], + [], + )?; + // While the composer is focused, these main-surface handlers always // consume matching keys before the event reaches the textarea editor. validate_no_shadow_with_allowed_overlaps( @@ -1261,6 +1288,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1522,6 +1550,7 @@ impl RuntimeKeymap { ("close_transcript", self.pager.close_transcript.as_slice()), ], TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, + [], )?; validate_unique( @@ -1675,10 +1704,11 @@ See the Codex keymap documentation for supported actions and examples." Ok(()) } -fn validate_no_reserved( +fn validate_no_reserved( context: &str, pairs: [(&'static str, &[KeyBinding]); N], reserved: &[(&'static str, KeyBinding)], + allowed_overlaps: [(&'static str, &'static str, KeyBinding); A], ) -> Result<(), String> { for (action, bindings) in pairs { for binding in bindings { @@ -1687,6 +1717,15 @@ fn validate_no_reserved( .iter() .find(|(_, reserved_binding)| reserved_binding.parts() == key) { + if allowed_overlaps.iter().any( + |(allowed_action, allowed_reserved_action, allowed_binding)| { + *allowed_action == action + && *allowed_reserved_action == *reserved_action + && allowed_binding.parts() == key + }, + ) { + continue; + } return Err(format!( "Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \ Set a different key in `~/.codex/config.toml` and retry. \ @@ -2076,6 +2115,10 @@ mod tests { vec![key_hint::ctrl(KeyCode::Char('l'))] ); assert_eq!(runtime.app.toggle_fast_mode, Vec::new()); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::Esc)] + ); assert_eq!( runtime.chat.decrease_reasoning_effort, vec![key_hint::alt(KeyCode::Char(','))] @@ -2498,6 +2541,44 @@ mod tests { expect_conflict(&keymap, "composer.submit", "fixed.paste_image"); } + #[test] + fn interrupt_turn_allows_backtrack_escape_and_can_be_remapped_or_unbound() { + let mut keymap = TuiKeymap::default(); + let runtime = RuntimeKeymap::from_config(&keymap).expect("default keymap should parse"); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::Esc)] + ); + + keymap.chat.interrupt_turn = Some(one("f12")); + let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped keymap should parse"); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::F(12))] + ); + + keymap.chat.interrupt_turn = Some(KeybindingsSpec::Many(vec![])); + let runtime = RuntimeKeymap::from_config(&keymap).expect("unbound keymap should parse"); + assert!(runtime.chat.interrupt_turn.is_empty()); + } + + #[test] + fn interrupt_turn_rejects_other_fixed_shortcuts() { + let mut keymap = TuiKeymap::default(); + keymap.chat.interrupt_turn = Some(one("ctrl-v")); + + expect_conflict(&keymap, "chat.interrupt_turn", "fixed.paste_image"); + } + + #[test] + fn interrupt_turn_rejects_request_user_input_question_navigation_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.chat.interrupt_turn = Some(one("f12")); + keymap.list.move_right = Some(one("f12")); + + expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right"); + } + #[test] fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index f67ea20cb06..8c1292aeb12 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1009,6 +1009,7 @@ mod tests { actions, vec![ "Composer.submit", + "Chat.interrupt_turn", "Editor.insert_newline", "Composer.queue", "Global.open_external_editor", diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index c8815c8c984..87d65b225f9 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -93,6 +93,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), + action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -234,6 +235,7 @@ pub(super) fn binding_slot<'a>( ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), ("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output), + ("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -357,6 +359,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), + ("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 52ec4a4b8b1..883f9f898f8 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -60,6 +60,7 @@ struct KeymapContextTab { const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("composer", "submit"), + ("chat", "interrupt_turn"), ("editor", "insert_newline"), ("composer", "queue"), ("global", "toggle_fast_mode"), 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 4e388179a32..06efc646a86 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. Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default +Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left 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 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 e212226ef5c..6c0d008aadb 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 @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 106 actions, 1 customized, 2 unbound. + 107 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. 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_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 22c0537540d..b7384d1c4aa 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 107 actions, 0 customized, 3 unbound. + 108 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug @@ -17,6 +17,6 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global - Toggle Fast Mode unbound Global Toggle Raw Output alt-r - Chat Decrease Reasoning Effort alt-, + Chat Interrupt Turn esc 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 4a88465ba84..53ccba7e372 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 (106 selectable) -tab: Common (19 selectable) +tab: All (107 selectable) +tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) -tab: App (9 selectable) +tab: App (10 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (47 selectable) @@ -19,9 +19,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default +Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left 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 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 26af4bd57ae..3b9ebb7a6a0 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 @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 106 actions, 0 customized, 2 unbound. + 107 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -17,7 +17,7 @@ expression: "render_picker(params, 78)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. 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 36f6a1d04cb..c5efeb30e56 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 @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 106 actions, 0 customized, 2 unbound. + 107 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap new file mode 100644 index 00000000000..ac32ebe0462 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"Working (0s • f12 to interrupt) " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index dabe0053583..0438c206d5b 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -20,6 +20,7 @@ use unicode_width::UnicodeWidthStr; use crate::app_event_sender::AppEventSender; use crate::key_hint; +use crate::key_hint::KeyBinding; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; @@ -49,6 +50,7 @@ pub(crate) struct StatusIndicatorWidget { /// Optional suffix rendered after the elapsed/interrupt segment. inline_message: Option, show_interrupt_hint: bool, + interrupt_binding: Option, elapsed_running: Duration, last_resume_at: Instant, @@ -87,6 +89,7 @@ impl StatusIndicatorWidget { details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, inline_message: None, show_interrupt_hint: true, + interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), is_paused: false, @@ -125,7 +128,7 @@ impl StatusIndicatorWidget { }); } - /// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`. + /// Update the inline suffix text shown after the elapsed/interrupt hint. /// /// Callers should provide plain, already-contextualized text. Passing /// verbose status prose here can cause frequent width truncation and hide @@ -150,6 +153,10 @@ impl StatusIndicatorWidget { self.show_interrupt_hint = visible; } + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + self.interrupt_binding = binding; + } + pub(crate) fn pause_timer(&mut self) { self.pause_timer_at(Instant::now()); } @@ -257,10 +264,12 @@ impl Renderable for StatusIndicatorWidget { if !spans.is_empty() { spans.push(" ".into()); } - if self.show_interrupt_hint { + if self.show_interrupt_hint + && let Some(interrupt_binding) = self.interrupt_binding + { spans.extend(vec![ format!("({pretty_elapsed} • ").dim(), - key_hint::plain(KeyCode::Esc).into(), + interrupt_binding.into(), " to interrupt)".dim(), ]); } else { @@ -405,6 +414,26 @@ mod tests { assert!(line.starts_with("Working (0s • esc to interrupt)")); } + #[test] + fn renders_remapped_interrupt_hint() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new( + tx, + crate::tui::FrameRequester::test_dummy(), + /*animations_enabled*/ false, + ); + w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)))); + w.is_paused = true; + w.elapsed_running = Duration::ZERO; + + let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal"); + terminal + .draw(|f| w.render(f.area(), f.buffer_mut())) + .expect("draw"); + insta::assert_snapshot!(terminal.backend()); + } + #[test] fn timer_pauses_when_requested() { let (tx_raw, _rx) = unbounded_channel::();