From 2f3ce8f2df8e728efb5284e1b0d9290b1414f979 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 23 Jul 2026 21:10:08 +0300 Subject: [PATCH] feat: Add a Shift+Enter key binding to codewith that submits ALL currently-queued messages into the stream at once (distinct f --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 12 +- codex-rs/tui/src/app/thread_routing.rs | 5 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 18 + codex-rs/tui/src/bottom_pane/mod.rs | 14 + .../src/bottom_pane/pending_input_preview.rs | 42 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/constructor.rs | 5 + codex-rs/tui/src/chatwidget/input_flow.rs | 100 +++++ codex-rs/tui/src/chatwidget/input_restore.rs | 22 +- codex-rs/tui/src/chatwidget/interaction.rs | 16 + codex-rs/tui/src/chatwidget/keymap_picker.rs | 3 + .../tui/src/chatwidget/tests/review_mode.rs | 413 ++++++++++++++++++ codex-rs/tui/src/keymap.rs | 97 +++- codex-rs/tui/src/keymap_setup.rs | 1 + codex-rs/tui/src/keymap_setup/actions.rs | 3 + codex-rs/tui/src/keymap_setup/picker.rs | 1 + ...__tests__keymap_picker_all_tab_search.snap | 2 +- ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 8 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 23 files changed, 753 insertions(+), 20 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 2953f6e068..a2232c2fe8 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -122,6 +122,8 @@ pub struct TuiGlobalKeymap { pub struct TuiChatKeymap { /// Interrupt the active turn. pub interrupt_turn: Option, + /// Flush all queued follow-up messages into the active turn as one steer. + pub flush_queued_messages: 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 cf442ca622..91e71e3753 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3114,6 +3114,7 @@ "chat": { "decrease_reasoning_effort": null, "edit_queued_message": null, + "flush_queued_messages": null, "increase_reasoning_effort": null, "interrupt_turn": null }, @@ -3446,6 +3447,14 @@ ], "description": "Edit the most recently queued message." }, + "flush_queued_messages": { + "anyOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Flush all queued follow-up messages into the active turn as one steer." + }, "increase_reasoning_effort": { "anyOf": [ { @@ -3780,6 +3789,7 @@ "default": { "decrease_reasoning_effort": null, "edit_queued_message": null, + "flush_queued_messages": null, "increase_reasoning_effort": null, "interrupt_turn": null } @@ -5764,4 +5774,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 0f5d0a8dad..659b0b1e57 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -539,7 +539,10 @@ impl App { if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { - if !self.chat_widget.enqueue_rejected_steer() { + if !self + .chat_widget + .enqueue_rejected_steer_matching_items(items) + { self.chat_widget.add_error_message(turn_error.message); } return Ok(true); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bc15f0a7d5..b004630842 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -412,6 +412,7 @@ pub(crate) struct ComposerDraftSnapshot { pub(crate) remote_image_urls: Vec, pub(crate) mention_bindings: Vec, pub(crate) pending_pastes: Vec<(String, String)>, + cursor: usize, } const FOOTER_SPACING_HEIGHT: u16 = 0; @@ -1476,9 +1477,26 @@ impl ChatComposer { remote_image_urls: self.remote_image_urls(), mention_bindings: self.mention_bindings(), pending_pastes: self.pending_pastes(), + cursor: self.current_cursor(), } } + pub(crate) fn restore_draft_snapshot(&mut self, snapshot: ComposerDraftSnapshot) { + self.restore_draft(ComposerDraft { + text: snapshot.text, + text_elements: snapshot.text_elements, + local_image_paths: snapshot + .local_images + .into_iter() + .map(|image| image.path) + .collect(), + remote_image_urls: snapshot.remote_image_urls, + mention_bindings: snapshot.mention_bindings, + pending_pastes: snapshot.pending_pastes, + cursor: snapshot.cursor, + }); + } + #[cfg(test)] pub(crate) fn local_image_paths(&self) -> Vec { self.attachments.local_image_paths() diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c42ae3b04f..d7472c25fa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -473,6 +473,12 @@ impl BottomPane { self.request_redraw(); } + /// Update the key hint for immediately submitting every queued follow-up. + pub(crate) fn set_queued_message_flush_binding(&mut self, binding: Option) { + self.pending_input_preview.set_flush_binding(binding); + self.request_redraw(); + } + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.composer.set_vim_enabled(enabled); self.request_redraw(); @@ -858,6 +864,14 @@ impl BottomPane { self.composer.draft_snapshot() } + pub(crate) fn restore_composer_draft_snapshot( + &mut self, + snapshot: chat_composer::ComposerDraftSnapshot, + ) { + self.composer.restore_draft_snapshot(snapshot); + self.request_redraw(); + } + #[cfg(test)] pub(crate) fn composer_text_elements(&self) -> Vec { self.composer.text_elements() 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 96cc1bdfd8..f0880a697f 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -18,8 +18,8 @@ use crate::wrapping::adaptive_wrap_lines; /// 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 -/// configurable via [`set_edit_binding`](Self::set_edit_binding). +/// intercept certain modifier-key combinations, the displayed bindings are +/// supplied by the resolved runtime keymap. pub(crate) struct PendingInputPreview { pub pending_steers: Vec, pub rejected_steers: Vec, @@ -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 submitting all queued follow-ups now. + flush_binding: Option, /// Key combination rendered for immediately interrupting and sending steers. interrupt_binding: Option, } @@ -40,6 +42,7 @@ impl PendingInputPreview { rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: Some(key_hint::alt(KeyCode::Up)), + flush_binding: None, interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), } } @@ -51,6 +54,10 @@ impl PendingInputPreview { self.edit_binding = binding; } + pub(crate) fn set_flush_binding(&mut self, binding: Option) { + self.flush_binding = binding; + } + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { self.interrupt_binding = binding; } @@ -151,6 +158,19 @@ impl PendingInputPreview { } } + if (!self.rejected_steers.is_empty() || !self.queued_messages.is_empty()) + && let Some(flush_binding) = self.flush_binding + { + lines.push( + Line::from(vec![ + " ".into(), + flush_binding.into(), + " submit all now".into(), + ]) + .dim(), + ); + } + if !self.queued_messages.is_empty() && let Some(edit_binding) = self.edit_binding { @@ -205,6 +225,7 @@ mod tests { fn render_one_message() { let mut queue = PendingInputPreview::new(); queue.queued_messages.push("Hello, world!".to_string()); + queue.set_flush_binding(Some(key_hint::shift(KeyCode::Enter))); let width = 40; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -302,7 +323,7 @@ mod tests { let height = queue.desired_height(width); assert_eq!( height, 3, - "expected header, one message row, and hint row for URL-like token" + "expected header, one message row, and one hint row for URL-like token" ); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -348,6 +369,21 @@ mod tests { ); } + #[test] + fn render_queued_message_with_remapped_flush_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Please continue.".to_string()); + queue.set_flush_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_queued_message_with_remapped_flush_binding", + format!("{buf:?}") + ); + } + #[test] fn render_pending_steers_above_queued_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ed20236f14..62090d3e6d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -179,6 +179,7 @@ use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::TextElement; use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index c801660a8d..9e462bfd5c 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -295,6 +295,11 @@ impl ChatWidget { widget .bottom_pane .set_queued_message_edit_binding(widget.queued_message_edit_hint_binding); + widget + .bottom_pane + .set_queued_message_flush_binding( + widget.chat_keymap.flush_queued_messages.first().copied(), + ); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 579d97fbbb..ec422f9e08 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -98,6 +98,106 @@ impl ChatWidget { } } + /// Merge every queued follow-up visible at this keypress into one active-turn steer. + /// + /// Rejected steers retain dequeue priority, followed by locally queued messages in FIFO + /// order. Queued slash and shell actions are intentionally literalized into the merged steer, + /// and shell escape handling stays disabled so no queued text executes locally. Cloning all + /// four deques preserves their message/history alignment until the merged submission is + /// accepted, while leaving already-submitted pending steers and the live composer draft + /// untouched. + pub(super) fn flush_queued_messages(&mut self) { + let queued_message_count = self.input_queue.queued_user_messages.len(); + let queued_history_count = self.input_queue.queued_user_message_history_records.len(); + let rejected_messages = self + .input_queue + .rejected_steers_queue + .iter() + .cloned() + .collect::>(); + let mut rejected_history_records = self + .input_queue + .rejected_steer_history_records + .iter() + .cloned() + .collect::>(); + rejected_history_records.resize( + rejected_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + + let queued_messages = self + .input_queue + .queued_user_messages + .iter() + .cloned() + .collect::>(); + let mut queued_history_records = self + .input_queue + .queued_user_message_history_records + .iter() + .cloned() + .collect::>(); + queued_history_records.resize( + queued_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + + let mut messages = rejected_messages + .into_iter() + .zip(rejected_history_records) + .collect::>(); + messages.extend( + queued_messages + .into_iter() + .zip(queued_history_records) + .map(|(message, history_record)| (message.into_user_message(), history_record)), + ); + let (message, history_record) = merge_user_messages_with_history_record(messages); + let actual_chars = message.text.chars().count(); + if actual_chars > MAX_USER_INPUT_TEXT_CHARS { + self.add_error_message(format!( + "Queued messages exceed the maximum combined length of \ + {MAX_USER_INPUT_TEXT_CHARS} characters ({actual_chars} provided). \ + Edit or remove queued messages before submitting them together." + )); + return; + } + let composer_snapshot = self.bottom_pane.composer_draft_snapshot(); + let accepted = self.resubmit_queued_user_message_with_history_record( + message, + history_record, + ShellEscapePolicy::Disallow, + ); + if accepted { + self.input_queue.rejected_steers_queue.clear(); + self.input_queue.rejected_steer_history_records.clear(); + // A successful resubmit can queue the merged message at the front while session, + // auth, or usage-limit state is being resolved. Remove only the original entries + // from the back so that accepted replacement survives. + let queued_message_retained_count = self + .input_queue + .queued_user_messages + .len() + .saturating_sub(queued_message_count); + self.input_queue + .queued_user_messages + .truncate(queued_message_retained_count); + let queued_history_retained_count = self + .input_queue + .queued_user_message_history_records + .len() + .saturating_sub(queued_history_count); + self.input_queue + .queued_user_message_history_records + .truncate(queued_history_retained_count); + } else { + self.bottom_pane + .restore_composer_draft_snapshot(composer_snapshot); + } + self.refresh_pending_input_preview(); + } + /// If idle and there are queued inputs, submit exactly one to start the next turn. pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { if self.input_queue.suppress_queue_autosend { diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 391cdf596e..e68f672bb4 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -114,8 +114,28 @@ impl ChatWidget { } } + pub(crate) fn enqueue_rejected_steer_matching_items(&mut self, items: &[UserInput]) -> bool { + let compare_key = Self::pending_steer_compare_key_from_items(items); + let Some(index) = self + .input_queue + .pending_steers + .iter() + .position(|pending| pending.compare_key == compare_key) + else { + tracing::warn!( + "received active-turn-not-steerable response without a matching pending steer" + ); + return false; + }; + self.enqueue_rejected_steer_at(index) + } + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { - let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else { + self.enqueue_rejected_steer_at(0) + } + + fn enqueue_rejected_steer_at(&mut self, index: usize) -> bool { + let Some(pending_steer) = self.input_queue.pending_steers.remove(index) else { tracing::warn!( "received active-turn-not-steerable error without a matching pending steer" ); diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 094baa0b02..299c7085ac 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -124,6 +124,22 @@ impl ChatWidget { return; } + if self.turn_lifecycle.agent_turn_running + && self + .chat_keymap + .flush_queued_messages + .is_pressed(key_event) + && self.bottom_pane.no_modal_or_popup_active() + { + if key_event.kind == KeyEventKind::Repeat { + return; + } + if key_event.kind == KeyEventKind::Press && self.has_queued_follow_up_messages() { + self.flush_queued_messages(); + return; + } + } + if self.chat_keymap.interrupt_turn.is_pressed(key_event) && !self.input_queue.pending_steers.is_empty() && self.bottom_pane.is_task_running() diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 4546ff114e..db6a16af8f 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -176,6 +176,9 @@ impl ChatWidget { ); self.bottom_pane .set_queued_message_edit_binding(self.queued_message_edit_hint_binding); + self.bottom_pane.set_queued_message_flush_binding( + self.chat_keymap.flush_queued_messages.first().copied(), + ); self.bottom_pane.set_keymap_bindings(runtime_keymap); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c5873a50fd..f1714883b8 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -721,6 +721,419 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() assert!(lines_to_single_string(&second_insert[0]).contains("second follow-up")); } +#[tokio::test] +async fn shift_enter_flushes_queued_messages_as_one_steer_during_final_stream() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta("Final answer line\n".to_string()); + + chat.bottom_pane + .set_composer_text("first queued".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + chat.bottom_pane + .set_composer_text("second queued".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + chat.bottom_pane + .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + let expected_text = "first queued\nsecond queued"; + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: expected_text.to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected one merged queued-message steer, got {other:?}"), + } + assert_no_submit_op(&mut op_rx); + assert!(chat.input_queue.rejected_steers_queue.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert_eq!(chat.input_queue.pending_steers.len(), 1); + assert_eq!( + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, + expected_text + ); + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); + assert!(drain_insert_history(&mut rx).is_empty()); +} + +#[tokio::test] +async fn shift_enter_flushes_rejected_before_queued_and_preserves_existing_pending_steers() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.input_queue + .pending_steers + .push_back(pending_steer("already pending")); + chat.input_queue + .rejected_steers_queue + .push_back(UserMessage::from("rejected first")); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("queued second").into()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "rejected first\nqueued second".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected one merged queued-message steer, got {other:?}"), + } + assert_no_submit_op(&mut op_rx); + assert!(chat.input_queue.rejected_steers_queue.is_empty()); + assert!( + chat.input_queue + .rejected_steer_history_records + .is_empty() + ); + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert!( + chat.input_queue + .queued_user_message_history_records + .is_empty() + ); + assert_eq!( + chat.input_queue + .pending_steers + .iter() + .map(|pending| pending.user_message.text.as_str()) + .collect::>(), + vec!["already pending", "rejected first\nqueued second"] + ); +} + +#[tokio::test] +async fn rejected_shift_enter_flush_requeues_only_the_merged_steer() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.input_queue + .pending_steers + .push_back(pending_steer("already pending")); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("first queued").into()); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("second queued").into()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else { + panic!("expected one merged queued-message steer"); + }; + assert!(chat.enqueue_rejected_steer_matching_items(&items)); + + assert_eq!( + chat.input_queue + .pending_steers + .iter() + .map(|pending| pending.user_message.text.as_str()) + .collect::>(), + vec!["already pending"] + ); + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert_eq!( + chat.input_queue.rejected_steers_queue, + VecDeque::from([UserMessage::from("first queued\nsecond queued")]) + ); +} + +#[tokio::test] +async fn rejected_steer_matching_items_prefers_first_duplicate_compare_key() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut first = pending_steer("duplicate"); + first.user_message.text_elements = vec![TextElement::new( + (0.."duplicate".len()).into(), + Some("first metadata".to_string()), + )]; + first.history_record = UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: "first history".to_string(), + text_elements: Vec::new(), + }); + let mut second = pending_steer("duplicate"); + second.history_record = UserMessageHistoryRecord::Override(UserMessageHistoryOverride { + text: "second history".to_string(), + text_elements: Vec::new(), + }); + let expected_rejected = (first.user_message.clone(), first.history_record.clone()); + let expected_pending = (second.user_message.clone(), second.history_record.clone()); + chat.input_queue.pending_steers.extend([first, second]); + + assert!(chat.enqueue_rejected_steer_matching_items(&[UserInput::Text { + text: "duplicate".to_string(), + text_elements: Vec::new(), + }])); + + assert_eq!( + ( + chat.input_queue.rejected_steers_queue.pop_front().unwrap(), + chat.input_queue + .rejected_steer_history_records + .pop_front() + .unwrap(), + ), + expected_rejected + ); + assert_eq!(chat.input_queue.pending_steers.len(), 1); + let remaining = chat.input_queue.pending_steers.front().unwrap(); + assert_eq!( + (&remaining.user_message, &remaining.history_record), + (&expected_pending.0, &expected_pending.1) + ); +} + +#[tokio::test] +async fn shift_enter_repeat_and_release_do_not_flush_queued_messages() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("queued").into()); + chat.bottom_pane + .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); + + for kind in [KeyEventKind::Repeat, KeyEventKind::Release] { + chat.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::SHIFT, + kind, + )); + } + + assert_no_submit_op(&mut op_rx); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); + assert!(chat.input_queue.pending_steers.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); +} + +#[tokio::test] +async fn shift_enter_without_queued_messages_still_inserts_newline() { + for (text, active) in [("active", true), ("idle", false)] { + let (mut chat, _rx, mut op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + if active { + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + } + chat.bottom_pane + .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + assert_no_submit_op(&mut op_rx); + assert_eq!(chat.bottom_pane.composer_text(), format!("{text}\n")); + } +} + +#[tokio::test] +async fn accepted_shift_enter_flush_preserves_front_requeued_merged_message() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = None; + chat.input_queue + .rejected_steers_queue + .push_back(UserMessage::from("rejected first")); + chat.input_queue + .rejected_steer_history_records + .push_back(UserMessageHistoryRecord::UserMessageText); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("queued second").into()); + chat.input_queue + .queued_user_message_history_records + .push_back(UserMessageHistoryRecord::UserMessageText); + + chat.flush_queued_messages(); + + assert_no_submit_op(&mut op_rx); + assert!(chat.input_queue.rejected_steers_queue.is_empty()); + assert!( + chat.input_queue + .rejected_steer_history_records + .is_empty() + ); + assert_eq!( + chat.input_queue.queued_user_messages, + VecDeque::from([QueuedUserMessage::new_with_shell_escape_policy( + UserMessage::from("rejected first\nqueued second"), + QueuedInputAction::Plain, + ShellEscapePolicy::Disallow, + )]) + ); + assert_eq!( + chat.input_queue.queued_user_message_history_records, + VecDeque::from([UserMessageHistoryRecord::UserMessageText]) + ); + assert!(chat.input_queue.pending_steers.is_empty()); +} + +#[tokio::test] +async fn unavailable_model_shift_enter_flush_preserves_queued_and_composer_state() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.set_model(""); + chat.input_queue + .pending_steers + .push_back(pending_steer("already pending")); + chat.input_queue + .rejected_steers_queue + .push_back(UserMessage::from("rejected first")); + chat.input_queue + .rejected_steer_history_records + .push_back(UserMessageHistoryRecord::Override( + UserMessageHistoryOverride { + text: "rejected history".to_string(), + text_elements: Vec::new(), + }, + )); + chat.input_queue.queued_user_messages.push_back( + QueuedUserMessage::new( + UserMessage::from("queued second"), + QueuedInputAction::ParseSlash, + ), + ); + chat.input_queue + .queued_user_message_history_records + .push_back(UserMessageHistoryRecord::Override( + UserMessageHistoryOverride { + text: "queued history".to_string(), + text_elements: Vec::new(), + }, + )); + chat.bottom_pane + .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); + + let input_before = chat.capture_thread_input_state(); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + assert_no_submit_op(&mut op_rx); + assert_eq!(chat.capture_thread_input_state(), input_before); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!( + lines_to_single_string(&inserted[0]).contains("Thread model is unavailable."), + "expected unavailable-model error" + ); +} + +#[tokio::test] +async fn failed_shift_enter_submit_preserves_queued_messages() { + let (mut chat, _rx, op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("queued").into()); + drop(op_rx); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["queued".to_string()] + ); + assert!(chat.input_queue.pending_steers.is_empty()); +} + +#[tokio::test] +async fn shift_enter_literalizes_mixed_queued_actions_into_one_steer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + for (text, action) in [ + ("/compact", QueuedInputAction::ParseSlash), + ("!echo should-not-run", QueuedInputAction::RunShell), + ("plain follow-up", QueuedInputAction::Plain), + ] { + chat.input_queue + .queued_user_messages + .push_back(QueuedUserMessage::new(UserMessage::from(text), action)); + chat.input_queue + .queued_user_message_history_records + .push_back(UserMessageHistoryRecord::UserMessageText); + } + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + let mut user_turn_items = Vec::new(); + while let Ok(op) = op_rx.try_recv() { + match op { + Op::UserTurn { items, .. } => user_turn_items.push(items), + Op::RunUserShellCommand { command } => { + panic!("queued shell action executed instead of being literalized: {command}") + } + Op::Compact => { + panic!("queued slash action executed instead of being literalized") + } + other => panic!("unexpected op during mixed queued-message flush: {other:?}"), + } + } + assert_eq!( + user_turn_items, + vec![vec![UserInput::Text { + text: "/compact\n!echo should-not-run\nplain follow-up".to_string(), + text_elements: Vec::new(), + }]] + ); + while let Ok(event) = rx.try_recv() { + assert!( + !matches!(event, AppEvent::CodexOp(Op::Compact)), + "queued slash action executed instead of being literalized" + ); + } + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert!( + chat.input_queue + .queued_user_message_history_records + .is_empty() + ); + assert_eq!(chat.input_queue.pending_steers.len(), 1); +} + +#[tokio::test] +async fn oversized_shift_enter_flush_preserves_queued_and_composer_state() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("a".repeat(MAX_USER_INPUT_TEXT_CHARS)).into()); + chat.input_queue + .queued_user_messages + .push_back(UserMessage::from("b").into()); + chat.bottom_pane + .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + assert_no_submit_op(&mut op_rx); + assert_eq!(chat.input_queue.queued_user_messages.len(), 2); + assert!(chat.input_queue.pending_steers.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!( + lines_to_single_string(&inserted[0]).contains("maximum combined length"), + "expected an actionable combined-length error" + ); +} + #[tokio::test] async fn manual_interrupt_restores_pending_steers_to_composer() { let (mut chat, mut rx, mut 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 efd1be3adb..5b025aa771 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -84,6 +84,8 @@ pub(crate) struct AppKeymap { pub(crate) struct ChatKeymap { /// Interrupt the active turn. pub(crate) interrupt_turn: Vec, + /// Flush all queued follow-up messages into the active turn as one steer. + pub(crate) flush_queued_messages: Vec, /// Decrease the active reasoning effort. pub(crate) decrease_reasoning_effort: Vec, /// Increase the active reasoning effort. @@ -437,6 +439,11 @@ impl RuntimeKeymap { &defaults.chat.interrupt_turn, "tui.keymap.chat.interrupt_turn", )?, + flush_queued_messages: resolve_bindings( + keymap.chat.flush_queued_messages.as_ref(), + &defaults.chat.flush_queued_messages, + "tui.keymap.chat.flush_queued_messages", + )?, decrease_reasoning_effort: resolve_bindings( keymap.chat.decrease_reasoning_effort.as_ref(), &defaults.chat.decrease_reasoning_effort, @@ -761,6 +768,12 @@ impl RuntimeKeymap { chat.increase_reasoning_effort .retain(|binding| *binding != key_hint::shift(KeyCode::Up)); } + if keymap.chat.flush_queued_messages.is_none() + && configured_main_surface_alias_is_used(keymap, "shift-enter") + { + chat.flush_queued_messages + .retain(|binding| *binding != key_hint::shift(KeyCode::Enter)); + } let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), @@ -931,6 +944,7 @@ impl RuntimeKeymap { }, chat: ChatKeymap { interrupt_turn: default_bindings![plain(KeyCode::Esc)], + flush_queued_messages: default_bindings![shift(KeyCode::Enter)], decrease_reasoning_effort: default_bindings![ alt(KeyCode::Char(',')), shift(KeyCode::Down) @@ -1189,6 +1203,10 @@ impl RuntimeKeymap { ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ("cycle_permissions", self.app.cycle_permissions.as_slice()), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), + ( + "chat.flush_queued_messages", + self.chat.flush_queued_messages.as_slice(), + ), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1233,6 +1251,10 @@ impl RuntimeKeymap { ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ("cycle_permissions", self.app.cycle_permissions.as_slice()), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), + ( + "chat.flush_queued_messages", + self.chat.flush_queued_messages.as_slice(), + ), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1344,6 +1366,10 @@ 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.flush_queued_messages", + self.chat.flush_queued_messages.as_slice(), + ), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1411,11 +1437,18 @@ impl RuntimeKeymap { ("editor.kill_line_end", self.editor.kill_line_end.as_slice()), ("editor.yank", self.editor.yank.as_slice()), ], - [( - "composer.submit", - "editor.insert_newline", - key_hint::plain(KeyCode::Enter), - )], + [ + ( + "composer.submit", + "editor.insert_newline", + key_hint::plain(KeyCode::Enter), + ), + ( + "chat.flush_queued_messages", + "editor.insert_newline", + key_hint::shift(KeyCode::Enter), + ), + ], )?; validate_unique( @@ -2109,6 +2142,16 @@ mod tests { assert!(err.contains("editor.insert_newline")); } + #[test] + fn rejects_non_default_flush_queued_messages_editor_overlap() { + let mut keymap = TuiKeymap::default(); + keymap.chat.flush_queued_messages = Some(one("alt-enter")); + + let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); + assert!(err.contains("chat.flush_queued_messages")); + assert!(err.contains("editor.insert_newline")); + } + #[test] fn rejects_shadowing_editor_binding_from_outer_main_handler() { let mut keymap = TuiKeymap::default(); @@ -2341,6 +2384,33 @@ mod tests { ); } + #[test] + fn configured_main_surface_binding_prunes_flush_queued_messages_fallback_alias() { + let mut keymap = TuiKeymap::default(); + keymap.composer.submit = Some(one("shift-enter")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.composer.submit, + vec![key_hint::shift(KeyCode::Enter)] + ); + assert!(runtime.chat.flush_queued_messages.is_empty()); + } + + #[test] + fn explicit_flush_queued_messages_binding_still_conflicts_with_composer_binding() { + let mut keymap = TuiKeymap::default(); + keymap.composer.submit = Some(one("shift-enter")); + keymap.chat.flush_queued_messages = Some(one("shift-enter")); + + expect_conflict( + &keymap, + "chat.flush_queued_messages", + "composer.submit", + ); + } + #[test] fn explicit_reasoning_binding_still_conflicts_with_editor_binding() { let mut keymap = TuiKeymap::default(); @@ -2885,6 +2955,23 @@ mod tests { ); } + #[test] + fn default_flush_queued_messages_uses_allowed_shift_enter_overlap() { + let runtime = + RuntimeKeymap::from_config(&TuiKeymap::default()).expect("default keymap should parse"); + + assert_eq!( + runtime.chat.flush_queued_messages, + vec![key_hint::shift(KeyCode::Enter)] + ); + assert!( + runtime + .editor + .insert_newline + .contains(&key_hint::shift(KeyCode::Enter)) + ); + } + #[test] fn default_editor_delete_forward_word_includes_alt_d() { let runtime = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 08e982f1b3..1e029d0b52 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1015,6 +1015,7 @@ mod tests { vec![ "Composer.submit", "Chat.interrupt_turn", + "Chat.flush_queued_messages", "Editor.insert_newline", "Composer.queue", "Global.cycle_permissions", diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 703e1f40c7..4887394bf6 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -95,6 +95,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), action("global", "Global", "cycle_permissions", "Cycle to the next permissions preset."), action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."), + action("chat", "Chat", "flush_queued_messages", "Flush queued follow-ups into the active turn as one steer."), 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."), @@ -239,6 +240,7 @@ pub(super) fn binding_slot<'a>( ("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output), ("global", "cycle_permissions") => Some(&mut keymap.global.cycle_permissions), ("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn), + ("chat", "flush_queued_messages") => Some(&mut keymap.chat.flush_queued_messages), ("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), @@ -365,6 +367,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), ("global", "cycle_permissions") => Some(runtime_keymap.app.cycle_permissions.as_slice()), ("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()), + ("chat", "flush_queued_messages") => Some(runtime_keymap.chat.flush_queued_messages.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 1b28aabcaa..a917ccc664 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -61,6 +61,7 @@ struct KeymapContextTab { const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("composer", "submit"), ("chat", "interrupt_turn"), + ("chat", "flush_queued_messages"), ("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 f778419bd3..272ba2438f 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 @@ -10,7 +10,7 @@ Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim comp Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Cycle Permissions | shift-tab | Global cycle_permissions Cycle Permissions Cycle to the next permissions preset. shift-tab Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default +Flush Queued Messages | shift-enter | Chat flush_queued_messages Flush Queued Messages Flush queued follow-ups into the active turn as one steer. shift-enter Default Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up 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 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 2fcf05a296..2276f85373 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. - 109 actions, 1 customized, 2 unbound. + 110 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug 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 f60d917496..c9963b82d9 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. - 110 actions, 0 customized, 3 unbound. + 111 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug 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 6b4f0889ef..9f71a40139 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 (109 selectable) -tab: Common (21 selectable) +tab: All (110 selectable) +tab: Common (22 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) -tab: App (11 selectable) +tab: App (12 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (48 selectable) @@ -21,7 +21,7 @@ Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim comp Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default Cycle Permissions | shift-tab | Global cycle_permissions Cycle Permissions Cycle to the next permissions preset. shift-tab Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default +Flush Queued Messages | shift-enter | Chat flush_queued_messages Flush Queued Messages Flush queued follow-ups into the active turn as one steer. shift-enter Default Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up 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 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 cbaf5f080f..ba175b97d6 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. - 109 actions, 0 customized, 2 unbound. + 110 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug 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 ab67c48b73..fc6f656026 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. - 109 actions, 0 customized, 2 unbound. + 110 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug