tui: canonicalize pending nudge matching

This commit is contained in:
Charles Cunningham
2026-03-02 16:14:31 -08:00
parent 6bea79630c
commit 507ea2c601
3 changed files with 100 additions and 25 deletions
+16 -20
View File
@@ -3443,29 +3443,18 @@ impl ChatWidget {
else {
return;
};
let has_user_content = !user_message.text.is_empty()
|| !user_message.local_images.is_empty()
|| !user_message.remote_image_urls.is_empty();
let has_images = !user_message.local_images.is_empty()
|| !user_message.remote_image_urls.is_empty();
let should_preview_as_pending_nudge = self.is_session_configured()
&& (self.is_plan_streaming_in_tui() || self.stream_controller.is_some())
&& (!user_message.text.is_empty()
|| !user_message.local_images.is_empty()
|| !user_message.remote_image_urls.is_empty())
&& self.bottom_pane.is_task_running()
&& !self.is_review_mode
&& has_user_content
&& user_message.text.strip_prefix('!').is_none()
&& ((user_message.local_images.is_empty()
&& user_message.remote_image_urls.is_empty())
|| self.current_model_supports_images());
&& (!has_images || self.current_model_supports_images());
if should_preview_as_pending_nudge {
self.pending_nudges.push_back(
Self::rendered_user_message_event_from_parts(
user_message.text.clone(),
user_message.text_elements.clone(),
user_message
.local_images
.iter()
.map(|image| image.path.clone())
.collect(),
user_message.remote_image_urls.clone(),
),
);
self.refresh_pending_input_preview();
self.submit_user_message_without_local_history(user_message);
} else if self.is_session_configured() {
// Submitted is only emitted when steer is enabled.
@@ -4256,6 +4245,8 @@ impl ChatWidget {
} else {
None
};
let pending_nudge =
(!render_in_history).then(|| Self::rendered_user_message_event_from_inputs(&items));
let personality = self
.config
.personality
@@ -4295,6 +4286,11 @@ impl ChatWidget {
});
}
if let Some(pending_nudge) = pending_nudge {
self.pending_nudges.push_back(pending_nudge);
self.refresh_pending_input_preview();
}
// Show replayable user content in conversation history.
if render_in_history && !text.is_empty() {
let local_image_paths = local_images
+15
View File
@@ -1,4 +1,6 @@
use super::*;
use codex_core::parse_turn_item;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
@@ -87,6 +89,19 @@ impl ChatWidget {
)
}
pub(super) fn rendered_user_message_event_from_inputs(
items: &[UserInput],
) -> RenderedUserMessageEvent {
let response_item = ResponseInputItem::from(items.to_vec()).into();
let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) else {
unreachable!("user inputs must round-trip into a user-message turn item");
};
let EventMsg::UserMessage(event) = user_message.as_legacy_event() else {
unreachable!("UserMessageItem::as_legacy_event must return UserMessage");
};
Self::rendered_user_message_event_from_event(&event)
}
pub(super) fn should_render_realtime_user_message_event(
&self,
event: &UserMessageEvent,
+69 -5
View File
@@ -1832,14 +1832,20 @@ fn drain_insert_history(
}
fn raw_user_message_event(text: &str) -> Event {
let response_item: codex_protocol::models::ResponseItem =
codex_protocol::models::ResponseInputItem::from(vec![UserInput::Text {
raw_user_message_event_for_inputs(
format!("raw-user-{text}"),
vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}])
.into();
}],
)
}
fn raw_user_message_event_for_inputs(id: String, items: Vec<UserInput>) -> Event {
let response_item: codex_protocol::models::ResponseItem =
codex_protocol::models::ResponseInputItem::from(items).into();
Event {
id: format!("raw-user-{text}"),
id,
msg: EventMsg::RawResponseItem(RawResponseItemEvent {
item: response_item,
}),
@@ -3507,6 +3513,36 @@ async fn steer_enter_uses_pending_nudges_while_plan_stream_is_active() {
assert!(lines_to_single_string(&inserted[0]).contains("queued submission"));
}
#[tokio::test]
async fn steer_enter_uses_pending_nudges_while_turn_is_running_without_streaming() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.bottom_pane
.set_composer_text("queued while running".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_nudges.len(), 1);
assert_eq!(
chat.pending_nudges.front().unwrap().message(),
"queued while running"
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { .. } => {}
other => panic!("expected Op::UserTurn, got {other:?}"),
}
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(raw_user_message_event("queued while running"));
assert!(chat.pending_nudges.is_empty());
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert!(lines_to_single_string(&inserted[0]).contains("queued while running"));
}
#[tokio::test]
async fn steer_enter_uses_pending_nudges_while_final_answer_stream_is_active() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
@@ -3543,6 +3579,34 @@ async fn steer_enter_uses_pending_nudges_while_final_answer_stream_is_active() {
assert!(lines_to_single_string(&inserted[0]).contains("queued while streaming"));
}
#[tokio::test]
async fn raw_response_item_with_canonicalized_user_payload_clears_pending_nudge() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let items = vec![
UserInput::Text {
text: "hello".to_string(),
text_elements: vec![TextElement::new((1..4).into(), Some("ell".to_string()))],
},
UserInput::Text {
text: " world".to_string(),
text_elements: vec![TextElement::new((1..6).into(), Some("world".to_string()))],
},
];
chat.pending_nudges
.push_back(ChatWidget::rendered_user_message_event_from_inputs(&items));
chat.refresh_pending_input_preview();
chat.handle_codex_event(raw_user_message_event_for_inputs(
"raw-user-canonicalized".to_string(),
items,
));
assert!(chat.pending_nudges.is_empty());
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert!(lines_to_single_string(&inserted[0]).contains("hello world"));
}
#[tokio::test]
async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;