diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cae47cb322..fe352f0103 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2008,9 +2008,7 @@ async fn run_turn( // at a seemingly frozen screen. sess.notify_stream_error( &sub_id, - format!( - "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" - ), + format!("Re-connecting... {retries}/{max_retries}"), ) .await; diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index d35df99c8d..d43e3abcbb 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -136,9 +136,7 @@ async fn run_compact_task_inner( let delay = backoff(retries); sess.notify_stream_error( &sub_id, - format!( - "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" - ), + format!("Re-connecting... {retries}/{max_retries}"), ) .await; tokio::time::sleep(delay).await; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1ae32e5122..b73909473d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1203,6 +1203,11 @@ pub struct StreamErrorEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +pub struct StreamInfoEvent { + pub message: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 973b0e3ba2..aeaf239f32 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -240,6 +240,10 @@ pub(crate) struct ChatWidget { reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, + // Current status header shown in the status indicator. + current_status_header: String, + // Previous status header to restore after a transient stream retry. + retry_status_header: Option, conversation_id: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured @@ -303,6 +307,14 @@ impl ChatWidget { } } + fn set_status_header(&mut self, header: String) { + if self.current_status_header == header { + return; + } + self.current_status_header = header.clone(); + self.bottom_pane.update_status_header(header); + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane @@ -352,7 +364,7 @@ impl ChatWidget { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. - self.bottom_pane.update_status_header(header); + self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. } @@ -386,6 +398,8 @@ impl ChatWidget { fn on_task_started(&mut self) { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); + self.retry_status_header = None; + self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.request_redraw(); @@ -621,9 +635,10 @@ impl ChatWidget { } fn on_stream_error(&mut self, message: String) { - // Show stream errors in the transcript so users see retry/backoff info. - self.add_to_history(history_cell::new_stream_error_event(message)); - self.request_redraw(); + if self.retry_status_header.is_none() { + self.retry_status_header = Some(self.current_status_header.clone()); + } + self.set_status_header(message); } /// Periodic tick to commit at most one queued line to history with a small delay, @@ -928,6 +943,8 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, @@ -991,6 +1008,8 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2055445059..d4cf4c7b20 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -276,6 +276,8 @@ fn make_chatwidget_manual() -> ( interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, conversation_id: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, @@ -2044,9 +2046,10 @@ fn plan_update_renders_history_cell() { } #[test] -fn stream_error_is_rendered_to_history() { +fn stream_error_updates_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); - let msg = "stream error: stream disconnected before completion: idle timeout waiting for SSE; retrying 1/5 in 211ms…"; + chat.bottom_pane.set_task_running(true); + let msg = "Re-connecting... 2/5"; chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::StreamError(StreamErrorEvent { @@ -2055,11 +2058,15 @@ fn stream_error_is_rendered_to_history() { }); let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected a history cell for StreamError"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!(blob.contains('⚠')); - assert!(blob.contains("stream error:")); - assert!(blob.contains("idle timeout waiting for SSE")); + assert!( + cells.is_empty(), + "expected no history cell for StreamError event" + ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), msg); } #[test] diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 151712b6b0..f23ccc48f4 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -943,11 +943,6 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } -pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell { - let lines: Vec> = vec![vec![padded_emoji("⚠️").into(), message.dim()].into()]; - PlainHistoryCell { lines } -} - /// Render a user‑friendly plan update styled like a checkbox todo list. pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell { let UpdatePlanArgs { explanation, plan } = update; diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index ce4f6eabd8..4bcc5cf267 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -103,6 +103,11 @@ impl StatusIndicatorWidget { } } + #[cfg(test)] + pub(crate) fn header(&self) -> &str { + &self.header + } + /// Replace the queued messages displayed beneath the header. pub(crate) fn set_queued_messages(&mut self, queued: Vec) { self.queued_messages = queued;