diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index dc9758c4ba..944a2b8931 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -917,19 +917,22 @@ impl BottomPane { /// Update the status indicator header (defaults to "Working") and details below it. /// - /// Passing `None` clears any existing details. No-ops if the status indicator is not active. + /// Passing `None` clears any existing details. Returns whether the active status indicator + /// was updated and requested a redraw. pub(crate) fn update_status( &mut self, header: String, details: Option, details_capitalization: StatusDetailsCapitalization, details_max_lines: usize, - ) { + ) -> bool { if let Some(status) = self.status.as_mut() { status.update_header(header); status.update_details(details, details_capitalization, details_max_lines.max(1)); self.request_redraw(); + return true; } + false } /// Show the transient "press again to quit" hint for `key`. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5448bac1ff..4de4aa1ff0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -630,6 +630,8 @@ pub(crate) struct ChatWidget { interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header reasoning_buffer: String, + // Caches the first completed bold header so later deltas do not rescan the whole block. + reasoning_header: Option, // Preserves reasoning-summary part boundaries for transcript-only recording. reasoning_summary_parts: Vec, status_state: StatusState, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index ef248e94cd..eb979ceff1 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -179,6 +179,7 @@ impl ChatWidget { newly_installed_marketplace_tab_id: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), + reasoning_header: None, reasoning_summary_parts: Vec::new(), status_state: StatusState::default(), review: ReviewState::default(), diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 77ad911346..f26d50a2e7 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -42,6 +42,7 @@ impl ChatWidget { // Submitted is emitted when user submits. // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); + self.reasoning_header = None; self.reasoning_summary_parts.clear(); self.set_status_header(String::from("Working")); self.submit_user_message(user_message); diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 0f725d6999..877d2a846c 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -739,6 +739,7 @@ impl ChatWidget { ); if self.is_session_configured() { self.reasoning_buffer.clear(); + self.reasoning_header = None; self.reasoning_summary_parts.clear(); self.set_status_header(String::from("Working")); self.submit_user_message(user_message); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__reasoning_delta_restores_recreated_status_indicator.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__reasoning_delta_restores_recreated_status_indicator.snap new file mode 100644 index 0000000000..9201be6555 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__reasoning_delta_restores_recreated_status_indicator.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) +--- +" " +"• Checking files (0s • esc to interrupt) · 1 background terminal running · /ps …" +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.6-sol default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 2c58be9370..8bae8b64fb 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -9,14 +9,15 @@ use super::*; impl ChatWidget { /// Update the status indicator header and details. /// - /// Passing `None` clears any existing details. + /// Passing `None` clears any existing details. Returns whether the visible status indicator + /// requested a redraw. pub(super) fn set_status( &mut self, header: String, details: Option, details_capitalization: StatusDetailsCapitalization, details_max_lines: usize, - ) { + ) -> bool { let details = details .filter(|details| !details.is_empty()) .map(|details| { @@ -33,7 +34,7 @@ impl ChatWidget { details: details.clone(), details_max_lines, }); - self.bottom_pane.update_status( + let status_indicator_updated = self.bottom_pane.update_status( header, details, StatusDetailsCapitalization::Preserve, @@ -51,17 +52,19 @@ impl ChatWidget { if title_uses_status { self.refresh_status_surfaces(); } + status_indicator_updated } /// Convenience wrapper around [`Self::set_status`]; - /// updates the status indicator header and clears any existing details. - pub(super) fn set_status_header(&mut self, header: String) { + /// updates the status indicator header and clears any existing details, returning whether the + /// visible status indicator requested a redraw. + pub(super) fn set_status_header(&mut self, header: String) -> bool { self.set_status( header, /*details*/ None, StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, - ); + ) } /// Sets the currently rendered footer status-line value. diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index f64bab22fd..b5d2edd20d 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -7,7 +7,10 @@ use super::*; impl ChatWidget { pub(super) fn restore_reasoning_status_header(&mut self) { - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + if self.reasoning_header.is_none() { + self.reasoning_header = extract_first_bold(&self.reasoning_buffer); + } + if let Some(header) = self.reasoning_header.clone() { self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else if self.bottom_pane.is_task_running() { @@ -149,8 +152,10 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::StartCommitAnimation); self.run_catch_up_commit_tick(); } - self.sync_active_stream_tail(); - self.request_redraw(); + // Unterminated source is buffered by the controller and cannot change the visible tail. + if delta.contains('\n') && self.sync_active_stream_tail() { + self.request_redraw(); + } } pub(super) fn on_plan_item_completed(&mut self, text: String) { @@ -213,24 +218,41 @@ impl ChatWidget { self.reasoning_buffer.push_str(&delta); if self.safety_buffering_is_waiting() { - self.request_redraw(); return; } if self.unified_exec_wait_streak.is_some() { // Unified exec waiting should take precedence over reasoning-derived status headers. - self.request_redraw(); return; } - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - // Update the shimmer header to the extracted reasoning chunk header. - self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; - self.set_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. + if self.reasoning_header.is_none() { + self.reasoning_header = extract_first_bold(&self.reasoning_buffer); + } + let Some(header) = self.reasoning_header.as_deref() else { + // Fallback while we don't yet have a bold header: leave existing header as-is. + return; + }; + + let status = &self.status_state.current_status; + if self.status_state.terminal_title_status_kind == TerminalTitleStatusKind::Thinking + && status.header == header + && status.details.is_none() + && status.details_max_lines == STATUS_DETAILS_DEFAULT_MAX_LINES + && self + .bottom_pane + .status_widget() + .is_none_or(|status| status.header() == header) + { + return; + } + + // Update the shimmer header to the extracted reasoning chunk header. + let header = header.to_string(); + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + if !self.set_status_header(header) { + self.request_redraw(); } - self.request_redraw(); } pub(super) fn on_agent_reasoning_final(&mut self) { @@ -245,6 +267,7 @@ impl ChatWidget { self.add_boxed_history(cell); } self.reasoning_buffer.clear(); + self.reasoning_header = None; self.reasoning_summary_parts.clear(); self.request_redraw(); } @@ -255,6 +278,7 @@ impl ChatWidget { self.reasoning_summary_parts .push(std::mem::take(&mut self.reasoning_buffer)); } + self.reasoning_header = None; } pub(super) fn on_stream_error(&mut self, message: String, additional_details: Option) { @@ -352,7 +376,9 @@ impl ChatWidget { self.bottom_pane.hide_status_indicator(); self.add_boxed_history(cell); } - self.sync_active_stream_tail(); + if scope == CommitTickScope::AnyMode || outcome.has_controller { + self.sync_active_stream_tail(); + } if outcome.has_controller && outcome.all_idle { self.maybe_restore_status_indicator_after_stream_idle(); @@ -434,8 +460,10 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::StartCommitAnimation); self.run_catch_up_commit_tick(); } - self.sync_active_stream_tail(); - self.request_redraw(); + // Unterminated source is buffered by the controller and cannot change the visible tail. + if delta.contains('\n') && self.sync_active_stream_tail() { + self.request_redraw(); + } } pub(super) fn active_cell_is_stream_tail(&self) -> bool { @@ -450,47 +478,74 @@ impl ChatWidget { && self.active_cell_is_stream_tail() } - pub(super) fn sync_active_stream_tail(&mut self) { + pub(super) fn sync_active_stream_tail(&mut self) -> bool { if let Some(controller) = self.stream_controller.as_ref() { let tail_lines = controller.current_tail_lines(); if tail_lines.is_empty() { - self.clear_active_stream_tail(); - return; + return self.clear_active_stream_tail(); } self.bottom_pane.hide_status_indicator(); - self.transcript.active_cell = - Some(Box::new(history_cell::StreamingAgentTailCell::new( - tail_lines, - controller.tail_starts_stream(), - ))); + let cell = history_cell::StreamingAgentTailCell::new( + tail_lines, + controller.tail_starts_stream(), + ); + if self + .transcript + .active_cell + .as_ref() + .and_then(|active| { + active + .as_any() + .downcast_ref::() + }) + .is_some_and(|active| active == &cell) + { + return false; + } + self.transcript.active_cell = Some(Box::new(cell)); self.bump_active_cell_revision(); - return; + return true; } if let Some(controller) = self.plan_stream_controller.as_ref() { let tail_lines = controller.current_tail_display_lines(); if tail_lines.is_empty() { - self.clear_active_stream_tail(); - return; + return self.clear_active_stream_tail(); } self.bottom_pane.hide_status_indicator(); - self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new( + let cell = history_cell::StreamingPlanTailCell::new( tail_lines, !controller.tail_starts_stream(), - ))); + ); + if self + .transcript + .active_cell + .as_ref() + .and_then(|active| { + active + .as_any() + .downcast_ref::() + }) + .is_some_and(|active| active == &cell) + { + return false; + } + self.transcript.active_cell = Some(Box::new(cell)); self.bump_active_cell_revision(); - return; + return true; } - self.clear_active_stream_tail(); + self.clear_active_stream_tail() } - pub(super) fn clear_active_stream_tail(&mut self) { + pub(super) fn clear_active_stream_tail(&mut self) -> bool { if self.active_cell_is_stream_tail() { self.transcript.active_cell = None; self.bump_active_cell_revision(); + return true; } + false } } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 918c00c40d..b12d3ddce9 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -154,6 +154,7 @@ pub(super) async fn make_chatwidget_manual( model_override, /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + FrameRequester::test_dummy(), ) .await } @@ -162,6 +163,7 @@ pub(super) async fn make_chatwidget_manual_with_auth( model_override: Option<&str>, has_chatgpt_account: bool, has_codex_backend_auth: bool, + frame_requester: FrameRequester, ) -> ( ChatWidget, tokio::sync::mpsc::UnboundedReceiver, @@ -181,7 +183,7 @@ pub(super) async fn make_chatwidget_manual_with_auth( let model_catalog = test_model_catalog(&cfg); let common = ChatWidgetInit { config: cfg, - frame_requester: FrameRequester::test_dummy(), + frame_requester, app_event_tx, workspace_command_runner: None, initial_user_message: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index b37801ec37..079864104e 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1174,6 +1174,28 @@ async fn plan_completion_restores_status_indicator_after_streaming_plan_output() assert_eq!(chat.bottom_pane.is_task_running(), true); } +#[tokio::test] +async fn unterminated_plan_delta_does_not_redraw_unchanged_stream_tail() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + chat.on_plan_delta("| Step | Owner |\n".to_string()); + assert!(chat.active_cell_is_stream_tail()); + let revision = chat.transcript.active_cell_revision; + + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + chat.frame_requester = frame_requester; + chat.on_plan_delta("| partial".to_string()); + + assert_eq!(chat.transcript.active_cell_revision, revision); + assert!(matches!( + draw_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); +} + #[tokio::test] async fn submit_user_message_queues_while_compaction_turn_is_running() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 7d807b8001..04a303291c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1308,8 +1308,10 @@ async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() { #[tokio::test] async fn usage_command_runs_with_backend_auth_from_widget_init() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual_with_auth( - /*model_override*/ None, /*has_chatgpt_account*/ false, + /*model_override*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + FrameRequester::test_dummy(), ) .await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index e55364cae8..7c0e2e94da 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3767,6 +3767,190 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { ); } +#[tokio::test] +async fn unterminated_agent_delta_does_not_redraw_unchanged_stream_tail() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.handle_streaming_delta("| Step | Owner |\n".to_string()); + assert!(chat.active_cell_is_stream_tail()); + let revision = chat.transcript.active_cell_revision; + + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + chat.frame_requester = frame_requester; + chat.handle_streaming_delta("| partial".to_string()); + + assert_eq!(chat.transcript.active_cell_revision, revision); + assert!(matches!( + draw_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); +} + +#[tokio::test] +async fn newline_agent_delta_redraws_stream_tail_after_noop_catch_up() { + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth( + /*model_override*/ None, + /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ false, + frame_requester, + ) + .await; + chat.on_task_started(); + chat.handle_streaming_delta("Earlier line\n".to_string()); + chat.on_commit_tick(); + assert!(!chat.bottom_pane.status_indicator_visible()); + while draw_rx.try_recv().is_ok() {} + + chat.handle_streaming_delta("Intro line\n| Step | Owner |\n".to_string()); + + assert!(chat.active_cell_is_stream_tail()); + assert!( + draw_rx.try_recv().is_ok(), + "expected the changed assistant stream tail to schedule a redraw", + ); +} + +#[tokio::test] +async fn newline_plan_delta_redraws_stream_tail_after_noop_catch_up() { + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth( + /*model_override*/ Some("gpt-5"), + /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ false, + frame_requester, + ) + .await; + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + chat.on_task_started(); + chat.on_plan_delta("Earlier line\n".to_string()); + chat.on_commit_tick(); + assert!(!chat.bottom_pane.status_indicator_visible()); + while draw_rx.try_recv().is_ok() {} + + chat.on_plan_delta("Intro line\n| Step | Owner |\n".to_string()); + + assert!(chat.active_cell_is_stream_tail()); + assert!( + draw_rx.try_recv().is_ok(), + "expected the changed plan stream tail to schedule a redraw", + ); +} + +#[tokio::test] +async fn regular_commit_tick_clears_orphaned_plan_stream_tail() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + chat.on_task_started(); + chat.on_plan_delta("| Step | Owner |\n".to_string()); + assert!(chat.active_cell_is_stream_tail()); + + chat.on_task_started(); + assert!(chat.active_cell_is_stream_tail()); + chat.on_commit_tick(); + + assert!(!chat.active_cell_is_stream_tail()); +} + +#[tokio::test] +async fn reasoning_delta_redraws_only_when_header_becomes_visible() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + chat.frame_requester = frame_requester; + + chat.on_agent_reasoning_delta("still looking".to_string()); + assert!(matches!( + draw_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + assert_eq!(chat.reasoning_header, None); + + chat.on_agent_reasoning_delta(" **Checking".to_string()); + assert!(matches!( + draw_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + + chat.on_agent_reasoning_delta(" files**".to_string()); + assert!(draw_rx.try_recv().is_ok()); + assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files")); + + chat.on_agent_reasoning_delta(" and preparing a response".to_string()); + assert!(matches!( + draw_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); +} + +#[tokio::test] +async fn reasoning_delta_does_not_double_schedule_visible_status_redraw() { + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth( + /*model_override*/ None, + /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ false, + frame_requester, + ) + .await; + chat.on_task_started(); + assert!(chat.bottom_pane.status_indicator_visible()); + while draw_rx.try_recv().is_ok() {} + + chat.on_agent_reasoning_delta("**Checking files**".to_string()); + + assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files")); + assert!(draw_rx.try_recv().is_ok()); + assert!(matches!( + draw_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); +} + +#[tokio::test] +async fn reasoning_delta_restores_recreated_status_indicator_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.on_task_started(); + chat.on_agent_reasoning_delta("**Checking files**".to_string()); + + chat.on_agent_message_delta("Preamble line\n".to_string()); + chat.on_commit_tick(); + drain_insert_history(&mut rx); + assert!(!chat.bottom_pane.status_indicator_visible()); + + begin_unified_exec_startup(&mut chat, "call-1", "proc-1", "sleep 2"); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be recreated"); + assert_eq!(status.header(), "Working"); + + chat.on_agent_reasoning_delta(" and preparing a response".to_string()); + + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should remain visible"); + assert_eq!(status.header(), "Checking files"); + + let width: u16 = 80; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)) + .expect("create terminal"); + terminal.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height)); + terminal + .draw(|frame| chat.render(frame.area(), frame.buffer_mut())) + .expect("draw restored reasoning status"); + assert_chatwidget_snapshot!( + "reasoning_delta_restores_recreated_status_indicator", + normalized_backend_snapshot(terminal.backend()) + ); +} + #[tokio::test] async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index ffee8ccb04..b05cf716d4 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -82,6 +82,7 @@ impl ChatWidget { } self.reasoning_summary_parts.clear(); self.reasoning_buffer.clear(); + self.reasoning_header = None; self.set_ambient_pet_notification( crate::pets::PetNotificationKind::Running, /*body*/ None, diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index fda91de4f2..9afb55dfbd 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -438,8 +438,8 @@ impl HistoryCell for AgentMarkdownCell { /// /// During streaming, lines that have not yet been committed to scrollback because they belong to /// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on -/// every delta and cleared when the stream finalizes. -#[derive(Debug)] +/// deltas that change the visible tail and cleared when the stream finalizes. +#[derive(Debug, Eq, PartialEq)] pub(crate) struct StreamingAgentTailCell { lines: Vec, is_first_line: bool, diff --git a/codex-rs/tui/src/history_cell/plans.rs b/codex-rs/tui/src/history_cell/plans.rs index 6b644f9e36..81d7beb622 100644 --- a/codex-rs/tui/src/history_cell/plans.rs +++ b/codex-rs/tui/src/history_cell/plans.rs @@ -7,7 +7,7 @@ use super::*; /// The controller prepares the full styled plan lines because plan tails need the same header, /// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining /// preview-only during streaming. -#[derive(Debug)] +#[derive(Debug, Eq, PartialEq)] pub(crate) struct StreamingPlanTailCell { lines: Vec, is_stream_continuation: bool, diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 0438c206d5..f19e57b58b 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -139,7 +139,6 @@ impl StatusIndicatorWidget { .filter(|message| !message.is_empty()); } - #[cfg(test)] pub(crate) fn header(&self) -> &str { &self.header } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 7bf72f7cdf..3c94ac5089 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -105,6 +105,7 @@ mod tests { use codex_config::types::NotificationCondition; use ratatui::layout::Position; use ratatui::layout::Rect; + use ratatui::text::Line; #[test] fn unfocused_notification_condition_is_suppressed_when_focused() { @@ -170,6 +171,20 @@ mod tests { "expected stale cells inside the new viewport to be cleared, rows: {rows:?}" ); } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn inserting_history_lines_schedules_a_draw() { + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + let mut draw_rx = tui.draw_tx.subscribe(); + + tui.insert_history_lines(vec![Line::from("committed stream line")]); + tokio::time::advance(std::time::Duration::from_millis(20)).await; + + assert!(matches!( + tokio::time::timeout(std::time::Duration::from_millis(50), draw_rx.recv()).await, + Ok(Ok(())) + )); + } } pub fn set_modes() -> Result<()> { diff --git a/codex-rs/tui/src/tui/frame_requester.rs b/codex-rs/tui/src/tui/frame_requester.rs index d7e54d82cc..7b14131a56 100644 --- a/codex-rs/tui/src/tui/frame_requester.rs +++ b/codex-rs/tui/src/tui/frame_requester.rs @@ -133,6 +133,19 @@ mod tests { use tokio::time; use tokio_util::time::FutureExt; + impl FrameRequester { + /// Create a frame requester and expose its request channel for deterministic tests. + pub(crate) fn test_channel() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + ( + FrameRequester { + frame_schedule_tx: tx, + }, + rx, + ) + } + } + #[tokio::test(flavor = "current_thread", start_paused = true)] async fn test_schedule_frame_immediate_triggers_once() { let (draw_tx, mut draw_rx) = broadcast::channel(16);