Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/config/src/tui_keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ pub struct TuiGlobalKeymap {
pub struct TuiChatKeymap {
/// Interrupt the active turn.
pub interrupt_turn: Option<KeybindingsSpec>,
/// Flush all queued follow-up messages into the active turn as one steer.
pub flush_queued_messages: Option<KeybindingsSpec>,
/// Decrease the active reasoning effort.
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
/// Increase the active reasoning effort.
Expand Down
12 changes: 11 additions & 1 deletion codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3114,6 +3114,7 @@
"chat": {
"decrease_reasoning_effort": null,
"edit_queued_message": null,
"flush_queued_messages": null,
"increase_reasoning_effort": null,
"interrupt_turn": null
},
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -3780,6 +3789,7 @@
"default": {
"decrease_reasoning_effort": null,
"edit_queued_message": null,
"flush_queued_messages": null,
"increase_reasoning_effort": null,
"interrupt_turn": null
}
Expand Down Expand Up @@ -5764,4 +5774,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
41 changes: 41 additions & 0 deletions codex-rs/tui/src/chatwidget/input_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,47 @@ 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. Taking all four deques together preserves their message/history alignment while
/// leaving already-submitted pending steers and the live composer draft untouched.
pub(super) fn flush_queued_messages(&mut self) {
let rejected_messages = std::mem::take(&mut self.input_queue.rejected_steers_queue);
let mut rejected_history_records =
std::mem::take(&mut self.input_queue.rejected_steer_history_records);
rejected_history_records.resize(
rejected_messages.len(),
UserMessageHistoryRecord::UserMessageText,
);

let queued_messages = std::mem::take(&mut self.input_queue.queued_user_messages);
let mut queued_history_records =
std::mem::take(&mut self.input_queue.queued_user_message_history_records);
queued_history_records.resize(
queued_messages.len(),
UserMessageHistoryRecord::UserMessageText,
);

let mut messages = rejected_messages
.into_iter()
.zip(rejected_history_records)
.collect::<Vec<_>>();
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);
self.resubmit_queued_user_message_with_history_record(
message,
history_record,
ShellEscapePolicy::Disallow,
);
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 {
Expand Down
16 changes: 16 additions & 0 deletions codex-rs/tui/src/chatwidget/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
153 changes: 153 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/review_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,159 @@ 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(PendingSteer {
user_message: UserMessage::from("already pending"),
history_record: UserMessageHistoryRecord::UserMessageText,
compare_key: PendingSteerCompareKey {
message: "already pending".to_string(),
image_count: 0,
},
});
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<_>>(),
vec!["already pending", "rejected first\nqueued second"]
);
}

#[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() {
let (mut active_chat, _active_rx, mut active_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
active_chat.thread_id = Some(ThreadId::new());
active_chat.on_task_started();
active_chat
.bottom_pane
.set_composer_text("active".to_string(), Vec::new(), Vec::new());

active_chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));

assert_no_submit_op(&mut active_op_rx);
assert_eq!(active_chat.bottom_pane.composer_text(), "active\n");

let (mut idle_chat, _idle_rx, mut idle_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
idle_chat
.bottom_pane
.set_composer_text("idle".to_string(), Vec::new(), Vec::new());

idle_chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));

assert_no_submit_op(&mut idle_op_rx);
assert_eq!(idle_chat.bottom_pane.composer_text(), "idle\n");
}

#[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;
Expand Down
Loading
Loading