diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2237c678c7..5003f23048 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -632,7 +632,11 @@ impl ChatWidget { if let Some(controller) = self.stream_controller.as_mut() { let (cell, is_idle) = controller.on_commit_tick(); if let Some(cell) = cell { - self.bottom_pane.hide_status_indicator(); + // Don't hide the status indicator during streaming if there are queued messages + // This ensures queued messages remain visible to the user + if self.queued_user_messages.is_empty() { + self.bottom_pane.hide_status_indicator(); + } self.add_boxed_history(cell); } if is_idle { @@ -665,7 +669,11 @@ impl ChatWidget { fn handle_stream_finished(&mut self) { if self.task_complete_pending { - self.bottom_pane.hide_status_indicator(); + // Only hide the status indicator if there are no queued messages + // This ensures queued messages remain visible until the task completes + if self.queued_user_messages.is_empty() { + self.bottom_pane.hide_status_indicator(); + } self.task_complete_pending = false; } // A completed stream indicates non-exec content was just inserted. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0ab31daa32..3d94ed15d0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2344,3 +2344,73 @@ printf 'fenced within fenced\n' assert_snapshot!(term.backend().vt100().screen().contents()); } + +#[test] +fn queued_messages_remain_visible_during_streaming() { + // Test for GitHub issue #4446: Messages sent while final output text is streamed + // should be properly queued and visible, not injected into the streamed text. + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + + // Start a task to enable queuing + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + + // Start streaming some content + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "This is streaming content".into(), + }), + }); + + // Simulate a user sending a message during streaming + chat.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // The message should be queued, not submitted immediately + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.queued_user_messages.front().unwrap().text, "hello"); + + // No operation should have been sent to the backend yet + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + // Continue streaming and commit some content + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "\n".into(), + }), + }); + + // Drive commit ticks to process the streaming content + chat.on_commit_tick(); + + // The queued message should still be visible and not have been submitted + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.queued_user_messages.front().unwrap().text, "hello"); + + // Still no operation should have been sent + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + // Complete the task + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskComplete(TaskCompleteEvent { + last_agent_message: Some("Task completed".into()), + }), + }); + + // Now the queued message should be submitted + assert!(chat.queued_user_messages.is_empty()); + + // Drain rx to avoid unused warnings + let _ = drain_insert_history(&mut rx); +}