diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3115a09afd..934dc64912 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index a599037c1b..38c3d1c4ea 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1c53da37d4..5a726b23c3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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) -> 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;