From 103adcdf2d05fac262e67f244360ab1aef2f58ee Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Thu, 25 Sep 2025 10:07:27 -0700
Subject: [PATCH] fix: esc w/ queued messages overwrites draft in composer
(#4237)
Instead of overwriting the contents of the composer when pressing
Esc when there's a queued message, prepend the queued
message(s) to the composer draft.
---
codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 -
codex-rs/tui/src/bottom_pane/mod.rs | 1 -
codex-rs/tui/src/chatwidget.rs | 10 +++++-
codex-rs/tui/src/chatwidget/tests.rs | 34 +++++++++++++++++++
4 files changed, 43 insertions(+), 3 deletions(-)
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 65203b51..eb654815 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -264,7 +264,6 @@ impl ChatComposer {
}
/// Get the current composer text.
- #[cfg(test)]
pub(crate) fn current_text(&self) -> String {
self.textarea.text().to_string()
}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index fc9f06c3..bd26581b 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -262,7 +262,6 @@ impl BottomPane {
}
/// Get the current composer text (for tests and programmatic checks).
- #[cfg(test)]
pub(crate) fn composer_text(&self) -> String {
self.composer.current_text()
}
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index ec749f4b..26d813a8 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -427,12 +427,20 @@ impl ChatWidget {
// If any messages were queued during the task, restore them into the composer.
if !self.queued_user_messages.is_empty() {
- let combined = self
+ let queued_text = self
.queued_user_messages
.iter()
.map(|m| m.text.clone())
.collect::>()
.join("\n");
+ let existing_text = self.bottom_pane.composer_text();
+ let combined = if existing_text.is_empty() {
+ queued_text
+ } else if queued_text.is_empty() {
+ existing_text
+ } else {
+ format!("{queued_text}\n{existing_text}")
+ };
self.bottom_pane.set_composer_text(combined);
// Clear the queue and update the status indicator list.
self.queued_user_messages.clear();
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 7805e07e..18bc515b 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -1299,6 +1299,40 @@ fn interrupt_restores_queued_messages_into_composer() {
let _ = drain_insert_history(&mut rx);
}
+#[test]
+fn interrupt_prepends_queued_messages_before_existing_composer_text() {
+ let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual();
+
+ chat.bottom_pane.set_task_running(true);
+ chat.bottom_pane
+ .set_composer_text("current draft".to_string());
+
+ chat.queued_user_messages
+ .push_back(UserMessage::from("first queued".to_string()));
+ chat.queued_user_messages
+ .push_back(UserMessage::from("second queued".to_string()));
+ chat.refresh_queued_user_messages();
+
+ chat.handle_codex_event(Event {
+ id: "turn-1".into(),
+ msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
+ reason: TurnAbortReason::Interrupted,
+ }),
+ });
+
+ assert_eq!(
+ chat.bottom_pane.composer_text(),
+ "first queued\nsecond queued\ncurrent draft"
+ );
+ assert!(chat.queued_user_messages.is_empty());
+ assert!(
+ op_rx.try_recv().is_err(),
+ "unexpected outbound op after interrupt"
+ );
+
+ let _ = drain_insert_history(&mut rx);
+}
+
// Snapshot test: ChatWidget at very small heights (idle)
// Ensures overall layout behaves when terminal height is extremely constrained.
#[test]