Skip to content

Commit 3e71218

Browse files
committed
fix(tui): prioritize pending input on escape
1 parent 27a61a5 commit 3e71218

5 files changed

Lines changed: 103 additions & 14 deletions

File tree

codex-rs/tui/src/app/input.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ impl App {
254254
pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
255255
self.chat_widget.is_normal_backtrack_mode()
256256
&& self.chat_widget.composer_is_empty()
257+
&& !self.chat_widget.has_pending_or_queued_input()
257258
&& !self.chat_widget.should_handle_vim_insert_escape(key_event)
258259
}
259260

codex-rs/tui/src/app/tests.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5299,6 +5299,56 @@ async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() {
52995299
assert!(app.should_handle_backtrack_esc(esc));
53005300
}
53015301

5302+
#[tokio::test]
5303+
async fn backtrack_esc_does_not_steal_queued_follow_up() {
5304+
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
5305+
let thread_id = ThreadId::new();
5306+
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
5307+
app.chat_widget.handle_thread_session(session.clone());
5308+
app.chat_widget.handle_server_notification(
5309+
turn_started_notification(thread_id, "turn-1"),
5310+
/*replay_kind*/ None,
5311+
);
5312+
app.chat_widget.handle_server_notification(
5313+
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
5314+
/*replay_kind*/ None,
5315+
);
5316+
app.chat_widget
5317+
.apply_external_edit("queued follow-up".to_string());
5318+
app.chat_widget
5319+
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
5320+
let input_state = app
5321+
.chat_widget
5322+
.capture_thread_input_state()
5323+
.expect("expected queued follow-up state");
5324+
5325+
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
5326+
make_chatwidget_manual_with_sender().await;
5327+
app.chat_widget = chat_widget;
5328+
app.chat_widget.handle_thread_session(session.clone());
5329+
while new_op_rx.try_recv().is_ok() {}
5330+
app.replay_thread_snapshot(
5331+
ThreadEventSnapshot {
5332+
session: None,
5333+
turns: Vec::new(),
5334+
events: vec![ThreadBufferedEvent::Notification(
5335+
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
5336+
)],
5337+
input_state: Some(input_state),
5338+
},
5339+
/*resume_restored_queue*/ false,
5340+
);
5341+
5342+
let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE);
5343+
5344+
assert_eq!(
5345+
app.chat_widget.queued_user_message_texts(),
5346+
vec!["queued follow-up".to_string()]
5347+
);
5348+
assert!(app.chat_widget.composer_is_empty());
5349+
assert!(!app.should_handle_backtrack_esc(esc));
5350+
}
5351+
53025352
#[tokio::test]
53035353
async fn session_summary_skips_when_no_usage_or_resume_hint() {
53045354
assert!(

codex-rs/tui/src/chatwidget.rs

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3245,16 +3245,7 @@ impl ChatWidget {
32453245
// interrupted turn reaches the UI, so any unacknowledged steers still
32463246
// tracked here must be restored locally instead of waiting for a later commit.
32473247
if send_pending_steers_immediately {
3248-
let pending_steers = self
3249-
.pending_steers
3250-
.drain(..)
3251-
.map(|pending| (pending.user_message, pending.history_record))
3252-
.collect::<Vec<_>>();
3253-
if !pending_steers.is_empty() {
3254-
let (user_message, history_record) =
3255-
merge_user_messages_with_history_record(pending_steers);
3256-
self.submit_user_message_with_history_record(user_message, history_record);
3257-
} else {
3248+
if !self.submit_pending_steers_immediately() {
32583249
self.maybe_send_next_queued_input();
32593250
}
32603251
} else if let Some(combined) = self.drain_pending_messages_for_restore() {
@@ -3265,6 +3256,23 @@ impl ChatWidget {
32653256
self.request_redraw();
32663257
}
32673258

3259+
fn submit_pending_steers_immediately(&mut self) -> bool {
3260+
let pending_steers = self
3261+
.pending_steers
3262+
.drain(..)
3263+
.map(|pending| (pending.user_message, pending.history_record))
3264+
.collect::<Vec<_>>();
3265+
if pending_steers.is_empty() {
3266+
return false;
3267+
}
3268+
3269+
let (user_message, history_record) =
3270+
merge_user_messages_with_history_record(pending_steers);
3271+
let submitted = self.submit_user_message_with_history_record(user_message, history_record);
3272+
self.refresh_pending_input_preview();
3273+
submitted
3274+
}
3275+
32683276
/// Merge pending steers, queued drafts, and the current composer state into a single message.
32693277
///
32703278
/// Each pending message numbers attachments from `[Image #1]` relative to its own remote
@@ -5317,12 +5325,14 @@ impl ChatWidget {
53175325
&& self.bottom_pane.no_modal_or_popup_active()
53185326
&& !self.should_handle_vim_insert_escape(key_event)
53195327
{
5320-
self.submit_pending_steers_after_interrupt = true;
53215328
if self.is_cancellable_work_active() {
5329+
self.submit_pending_steers_after_interrupt = true;
53225330
if !self.submit_op(AppCommand::interrupt()) {
53235331
self.submit_pending_steers_after_interrupt = false;
53245332
}
5325-
} else if !self.maybe_send_next_queued_input() {
5333+
} else if !self.submit_pending_steers_immediately()
5334+
&& !self.maybe_send_next_queued_input()
5335+
{
53265336
self.submit_pending_steers_after_interrupt = false;
53275337
}
53285338
return;
@@ -10797,6 +10807,10 @@ impl ChatWidget {
1079710807
self.bottom_pane.is_normal_backtrack_mode()
1079810808
}
1079910809

10810+
pub(crate) fn has_pending_or_queued_input(&self) -> bool {
10811+
!self.pending_steers.is_empty() || self.has_queued_follow_up_messages()
10812+
}
10813+
1080010814
pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool {
1080110815
self.bottom_pane
1080210816
.composer_should_handle_vim_insert_escape(key_event)

codex-rs/tui/src/chatwidget/tests/composer_submission.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,7 @@ async fn empty_enter_during_task_does_not_queue() {
879879
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
880880

881881
// Simulate running task so submissions would normally be queued.
882-
chat.bottom_pane.set_task_running(/*running*/ true);
882+
chat.on_task_started();
883883

884884
// Press Enter with an empty composer.
885885
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
@@ -893,7 +893,7 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
893893
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
894894
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
895895

896-
chat.bottom_pane.set_task_running(/*running*/ true);
896+
chat.on_task_started();
897897
chat.pending_steers.push_back(pending_steer("queued steer"));
898898
chat.toggle_vim_mode_and_notify();
899899
chat.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));

codex-rs/tui/src/chatwidget/tests/review_mode.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,30 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d
798798
);
799799
}
800800

801+
#[tokio::test]
802+
async fn esc_submits_pending_steers_when_no_cancellable_work_is_active() {
803+
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
804+
chat.thread_id = Some(ThreadId::new());
805+
chat.pending_steers.push_back(pending_steer("first steer"));
806+
chat.pending_steers.push_back(pending_steer("second steer"));
807+
chat.refresh_pending_input_preview();
808+
809+
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
810+
811+
match next_submit_op(&mut op_rx) {
812+
Op::UserTurn { items, .. } => assert_eq!(
813+
items,
814+
vec![UserInput::Text {
815+
text: "first steer\nsecond steer".to_string(),
816+
text_elements: Vec::new(),
817+
}]
818+
),
819+
other => panic!("expected merged pending steers to submit, got {other:?}"),
820+
}
821+
assert!(chat.pending_steers.is_empty());
822+
assert!(!chat.submit_pending_steers_after_interrupt);
823+
}
824+
801825
#[tokio::test]
802826
async fn esc_with_pending_steers_overrides_agent_command_interrupt_behavior() {
803827
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

0 commit comments

Comments
 (0)