From dbb69dd163bae11cf00c44c05f51fcf710bff4a2 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 25 Feb 2026 21:20:32 -0800 Subject: [PATCH] tui: submit pending nudges after message boundaries --- codex-rs/tui/src/bottom_pane/mod.rs | 16 ++- .../src/bottom_pane/queued_user_messages.rs | 80 ++++++++++-- ...ages__tests__render_one_pending_nudge.snap | 14 ++ ..._pending_nudges_above_queued_messages.snap | 27 ++++ codex-rs/tui/src/chatwidget.rs | 120 ++++++++++++++---- codex-rs/tui/src/chatwidget/tests.rs | 110 +++++++++++----- 6 files changed, 293 insertions(+), 74 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_pending_nudge.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_pending_nudges_above_queued_messages.snap diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ac903d996b..aaad1be0c2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -770,7 +770,12 @@ impl BottomPane { } /// Update the queued messages preview shown above the composer. - pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { + pub(crate) fn set_queued_user_messages( + &mut self, + queued: Vec, + pending_nudges: Vec, + ) { + self.queued_user_messages.pending_nudges = pending_nudges; self.queued_user_messages.messages = queued; self.request_redraw(); } @@ -1014,7 +1019,8 @@ impl BottomPane { flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer)); } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); - let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_queued_messages = !self.queued_user_messages.messages.is_empty() + || !self.queued_user_messages.pending_nudges.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_queued_messages; @@ -1401,7 +1407,7 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()], Vec::new()); let width = 48; let height = pane.desired_height(width); @@ -1428,7 +1434,7 @@ mod tests { }); pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()], Vec::new()); pane.hide_status_indicator(); let width = 48; @@ -1456,7 +1462,7 @@ mod tests { }); pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()], Vec::new()); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs index 30a525f457..450b27dbdb 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -10,13 +10,15 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays a list of user messages queued while a turn is in progress. +/// Widget that displays pending nudges plus user messages queued while a turn is in progress. /// -/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the -/// user how to pop the most recent queued message back into the composer. -/// Because some terminals intercept certain modifier-key combinations, the -/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding). +/// The widget shows pending nudges first, then queued user messages. It only +/// shows the edit hint at the bottom (e.g. "⌥ + ↑ edit") when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct QueuedUserMessages { + pub pending_nudges: Vec, pub messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. @@ -26,6 +28,7 @@ pub(crate) struct QueuedUserMessages { impl QueuedUserMessages { pub(crate) fn new() -> Self { Self { + pending_nudges: Vec::new(), messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } @@ -39,12 +42,30 @@ impl QueuedUserMessages { } fn as_renderable(&self, width: u16) -> Box { - if self.messages.is_empty() || width < 4 { + if (self.pending_nudges.is_empty() && self.messages.is_empty()) || width < 4 { return Box::new(()); } let mut lines = vec![]; + for nudge in &self.pending_nudges { + let wrapped = adaptive_wrap_lines( + nudge + .lines() + .map(|line| format!("pending nudge: {line}").dim()), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ! ".dim())) + .subsequent_indent(Line::from(" ")), + ); + let len = wrapped.len(); + for line in wrapped.into_iter().take(3) { + lines.push(line); + } + if len > 3 { + lines.push(Line::from(" …".dim())); + } + } + for message in &self.messages { let wrapped = adaptive_wrap_lines( message.lines().map(|line| line.dim().italic()), @@ -61,14 +82,16 @@ impl QueuedUserMessages { } } - lines.push( - Line::from(vec![ - " ".into(), - self.edit_binding.into(), - " edit".into(), - ]) - .dim(), - ); + if !self.messages.is_empty() { + lines.push( + Line::from(vec![ + " ".into(), + self.edit_binding.into(), + " edit".into(), + ]) + .dim(), + ); + } Paragraph::new(lines).into() } @@ -202,4 +225,33 @@ mod tests { "expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}" ); } + + #[test] + fn render_one_pending_nudge() { + let mut queue = QueuedUserMessages::new(); + queue.pending_nudges.push("Implement the plan.".to_string()); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_one_pending_nudge", format!("{buf:?}")); + } + + #[test] + fn render_pending_nudges_above_queued_messages() { + let mut queue = QueuedUserMessages::new(); + queue.pending_nudges.push("Implement the plan.".to_string()); + queue + .pending_nudges + .push("Summarize the diff after the tool call.".to_string()); + queue.messages.push("Queued follow-up question".to_string()); + let width = 52; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_pending_nudges_above_queued_messages", + format!("{buf:?}") + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_pending_nudge.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_pending_nudge.snap new file mode 100644 index 0000000000..b4867002a0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_pending_nudge.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 1 }, + content: [ + " ! pending nudge: Implement the plan. ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_pending_nudges_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_pending_nudges_above_queued_messages.snap new file mode 100644 index 0000000000..2d45112e6f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_pending_nudges_above_queued_messages.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/queued_user_messages.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 52, height: 5 }, + content: [ + " ! pending nudge: Implement the plan. ", + " ! pending nudge: Summarize the diff after the tool", + " call. ", + " ↳ Queued follow-up question ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 9, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4598c7ab87..02575c5476 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -617,6 +617,19 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // Nudges deferred while a turn is in progress. + // + // Once the current streamed assistant message is done rendering, we + // interrupt the running turn (if needed) and then submit the pending + // nudges as fresh follow-up input. `on_task_complete` still drains this + // queue when the turn finishes naturally before an interrupt is needed. + pending_nudges: VecDeque, + // True after we have requested an automatic interrupt so a pending nudge + // can be submitted as a fresh turn. + pending_nudge_interrupt_requested: bool, + // Number of legacy `EventMsg::AgentMessage` events to ignore because the + // corresponding `item.completed` already finalized the streamed message. + pending_suppressed_legacy_agent_messages: usize, /// Terminal-appropriate keybinding for popping the most-recently queued /// message back into the composer. Determined once at construction time via /// [`queued_message_edit_binding_for_terminal`] and propagated to @@ -1289,6 +1302,10 @@ impl ChatWidget { } fn on_agent_message(&mut self, message: String) { + if self.pending_suppressed_legacy_agent_messages > 0 { + self.pending_suppressed_legacy_agent_messages -= 1; + return; + } // If we have a stream_controller, then the final agent message is redundant and will be a // duplicate of what has already been streamed. if self.stream_controller.is_none() && !message.is_empty() { @@ -1481,8 +1498,12 @@ impl ChatWidget { self.unified_exec_wait_streak = None; self.request_redraw(); - if !from_replay && self.queued_user_messages.is_empty() { - self.maybe_prompt_plan_implementation(); + if !from_replay { + self.pending_nudge_interrupt_requested = false; + let drained_pending_nudges = self.maybe_send_pending_nudges(); + if !drained_pending_nudges && self.queued_user_messages.is_empty() { + self.maybe_prompt_plan_implementation(); + } } // Keep this flag for replayed completion events so a subsequent live TurnComplete can // still show the prompt once after thread switch replay. @@ -1769,6 +1790,8 @@ impl ChatWidget { self.add_to_history(history_cell::new_warning_event(message)); self.request_redraw(); + self.pending_nudge_interrupt_requested = false; + self.maybe_send_pending_nudges(); self.maybe_send_next_queued_input(); } @@ -1777,6 +1800,8 @@ impl ChatWidget { self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); + self.pending_nudge_interrupt_requested = false; + self.maybe_send_pending_nudges(); // After an error ends the turn, try sending the next queued input. self.maybe_send_next_queued_input(); } @@ -1858,19 +1883,23 @@ impl ChatWidget { /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { + let interrupted_for_pending_nudge = + std::mem::take(&mut self.pending_nudge_interrupt_requested); // Finalize, log a gentle prompt, and clear running state. self.finalize_turn(); if reason == TurnAbortReason::Interrupted { self.clear_unified_exec_processes(); } - if reason != TurnAbortReason::ReviewEnded { + if !interrupted_for_pending_nudge && reason != TurnAbortReason::ReviewEnded { self.add_to_history(history_cell::new_error_event( "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), )); } - if let Some(combined) = self.drain_queued_messages_for_restore() { + if interrupted_for_pending_nudge { + self.maybe_send_pending_nudges(); + } else if let Some(combined) = self.drain_queued_messages_for_restore() { self.restore_user_message_to_composer(combined); self.refresh_queued_user_messages(); } @@ -2348,6 +2377,11 @@ impl ChatWidget { /// returns once stream queues are idle. Final-answer completion (or absent /// phase for legacy models) clears the flag to preserve historical behavior. fn on_agent_message_item_completed(&mut self, item: AgentMessageItem) { + if self.stream_controller.is_some() { + self.pending_suppressed_legacy_agent_messages += item.content.len(); + self.flush_answer_stream_with_separator(); + self.handle_stream_finished(); + } self.pending_status_indicator_restore = match item.phase { // Models that don't support preambles only output AgentMessageItems on turn completion. Some(MessagePhase::FinalAnswer) | None => false, @@ -2431,6 +2465,9 @@ impl ChatWidget { } // A completed stream indicates non-exec content was just inserted. self.flush_interrupt_queue(); + if self.agent_turn_running { + self.maybe_send_pending_nudges(); + } } #[inline] @@ -2913,6 +2950,9 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + pending_nudges: VecDeque::new(), + pending_nudge_interrupt_requested: false, + pending_suppressed_legacy_agent_messages: 0, queued_message_edit_binding, show_welcome_banner: is_first_run, startup_tooltip_override, @@ -3096,6 +3136,9 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + pending_nudges: VecDeque::new(), + pending_nudge_interrupt_requested: false, + pending_suppressed_legacy_agent_messages: 0, queued_message_edit_binding, show_welcome_banner: is_first_run, startup_tooltip_override, @@ -3260,6 +3303,9 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + pending_nudges: VecDeque::new(), + pending_nudge_interrupt_requested: false, + pending_suppressed_legacy_agent_messages: 0, queued_message_edit_binding, show_welcome_banner: false, startup_tooltip_override: None, @@ -3429,15 +3475,13 @@ impl ChatWidget { else { return; }; - // Submissions during active final-answer streaming can race with turn - // completion and strand the UI in a running state. Queue those inputs instead - // of injecting immediately; `on_task_complete()` drains this FIFO via - // `maybe_send_next_queued_input()`, so no typed prompt is dropped. - let should_submit_now = self.is_session_configured() - && !self.is_plan_streaming_in_tui() - && self.stream_controller.is_none(); - if should_submit_now { - // Submitted is emitted when user submits. + let should_defer_as_pending_nudge = self.is_session_configured() + && (self.is_plan_streaming_in_tui() || self.stream_controller.is_some()); + if should_defer_as_pending_nudge { + self.pending_nudges.push_back(user_message); + self.refresh_queued_user_messages(); + } else if self.is_session_configured() { + // Submitted is only emitted when steer is enabled. // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); @@ -4565,6 +4609,9 @@ impl ChatWidget { let item = event.item; if let codex_protocol::items::TurnItem::Plan(plan_item) = &item { self.on_plan_item_completed(plan_item.text.clone()); + if !from_replay { + self.maybe_send_pending_nudges(); + } } if let codex_protocol::items::TurnItem::AgentMessage(item) = item { self.on_agent_message_item_completed(item); @@ -4716,6 +4763,28 @@ impl ChatWidget { self.refresh_queued_user_messages(); } + fn maybe_send_pending_nudges(&mut self) -> bool { + if self.stream_controller.is_some() || self.is_plan_streaming_in_tui() { + return false; + } + if self.pending_nudges.is_empty() { + return false; + } + if self.agent_turn_running { + if !self.pending_nudge_interrupt_requested { + self.pending_nudge_interrupt_requested = true; + self.submit_op(Op::Interrupt); + } + return true; + } + self.pending_nudge_interrupt_requested = false; + while let Some(pending_nudge) = self.pending_nudges.pop_front() { + self.submit_user_message(pending_nudge); + } + self.refresh_queued_user_messages(); + true + } + /// Rebuild and update the queued user messages from the current queue. fn refresh_queued_user_messages(&mut self) { let messages: Vec = self @@ -4723,7 +4792,13 @@ impl ChatWidget { .iter() .map(|m| m.text.clone()) .collect(); - self.bottom_pane.set_queued_user_messages(messages); + let pending_nudges: Vec = self + .pending_nudges + .iter() + .map(|nudge| nudge.text.clone()) + .collect(); + self.bottom_pane + .set_queued_user_messages(messages, pending_nudges); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { @@ -7539,6 +7614,13 @@ impl ChatWidget { { collaboration_mode.reasoning_effort = Some(Some(effort)); } + let user_message = UserMessage { + text, + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }; if self.agent_turn_running && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) { @@ -7548,15 +7630,7 @@ impl ChatWidget { return; } self.set_collaboration_mask(collaboration_mode); - let should_queue = self.is_plan_streaming_in_tui(); - let user_message = UserMessage { - text, - local_images: Vec::new(), - remote_image_urls: Vec::new(), - text_elements: Vec::new(), - mention_bindings: Vec::new(), - }; - if should_queue { + if self.is_plan_streaming_in_tui() { self.queue_user_message(user_message); } else { self.submit_user_message(user_message); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index bfd7b4c446..843190958d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1708,6 +1708,9 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + pending_nudges: VecDeque::new(), + pending_nudge_interrupt_requested: false, + pending_suppressed_legacy_agent_messages: 0, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), suppress_session_configured_redraw: false, pending_notification: None, @@ -1755,6 +1758,17 @@ fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { } } +fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + loop { + match op_rx.try_recv() { + Ok(Op::Interrupt) => return, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected an interrupt op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"), + } + } +} + fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { while let Ok(op) = op_rx.try_recv() { assert!( @@ -3447,7 +3461,7 @@ async fn unified_exec_begin_restores_working_status_snapshot() { } #[tokio::test] -async fn enter_queues_while_plan_stream_is_active() { +async fn steer_enter_uses_pending_nudges_while_plan_stream_is_active() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); @@ -3463,16 +3477,45 @@ async fn enter_queues_while_plan_stream_is_active() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_nudges.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.pending_nudges.front().unwrap().text, "queued submission" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + chat.handle_codex_event(Event { + id: "item-plan".into(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + item: TurnItem::Plan(PlanItem { + id: "plan-1".to_string(), + text: "- Step 1".to_string(), + }), + }), + }); + + next_interrupt_op(&mut op_rx); + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert!(chat.pending_nudges.is_empty()); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued submission".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn after plan stream completion, got {other:?}"), + } } #[tokio::test] -async fn steer_enter_queues_while_final_answer_stream_is_active() { +async fn steer_enter_uses_pending_nudges_while_final_answer_stream_is_active() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); @@ -3487,20 +3530,29 @@ async fn steer_enter_queues_while_final_answer_stream_is_active() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_nudges.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.pending_nudges.front().unwrap().text, "queued while streaming" ); assert_no_submit_op(&mut op_rx); - // Once final output ends, the queued input must be submitted automatically. - chat.on_task_complete(None, false); + complete_assistant_message( + &mut chat, + "msg-commentary", + "Final answer line\n", + Some(MessagePhase::Commentary), + ); - assert!(chat.queued_user_messages.is_empty()); + next_interrupt_op(&mut op_rx); + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert!(chat.pending_nudges.is_empty()); match next_submit_op(&mut op_rx) { Op::UserTurn { .. } => {} - other => panic!("expected Op::UserTurn after stream completion, got {other:?}"), + other => panic!("expected Op::UserTurn after message completion, got {other:?}"), } } @@ -3520,19 +3572,22 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() .set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 2); - assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "first follow-up" - ); - assert_eq!( - chat.queued_user_messages.back().unwrap().text, - "second follow-up" - ); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_nudges.len(), 2); + assert_eq!(chat.pending_nudges.front().unwrap().text, "first follow-up"); + assert_eq!(chat.pending_nudges.back().unwrap().text, "second follow-up"); assert_no_submit_op(&mut op_rx); - // Completion must recover by submitting the oldest queued prompt first. - chat.on_task_complete(None, false); + complete_assistant_message( + &mut chat, + "msg-commentary", + "Final answer line\n", + Some(MessagePhase::Commentary), + ); + + next_interrupt_op(&mut op_rx); + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); let first_items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, @@ -3545,16 +3600,7 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() text_elements: Vec::new(), }] ); - assert_eq!(chat.queued_user_messages.len(), 1); - assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "second follow-up" - ); - - // A subsequent turn lifecycle should continue draining remaining queued prompts, proving - // the widget did not enter a permanently stuck state. - chat.on_task_started(); - chat.on_task_complete(None, false); + assert!(chat.pending_nudges.is_empty()); let second_items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, @@ -3567,7 +3613,7 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() text_elements: Vec::new(), }] ); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.pending_nudges.is_empty()); } #[tokio::test]