diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 65df3e6f96..3d566356e2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -462,8 +462,17 @@ pub(crate) struct ChatWidget { is_review_mode: bool, // Snapshot of token usage to restore after review mode exits. pre_review_token_info: Option>, - // Whether to add a final message separator after the last message + // Whether the next streamed assistant content should be preceded by a final message separator. + // + // This is set whenever we insert a visible history cell that conceptually belongs to a turn. + // The separator itself is only rendered if the turn recorded "work" activity (see + // `had_work_activity`). needs_final_message_separator: bool, + // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). + // + // This gates rendering of the "Worked for …" separator so purely conversational turns don't + // show an empty divider. It is reset when the separator is emitted. + had_work_activity: bool, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback @@ -1348,13 +1357,19 @@ impl ChatWidget { self.flush_active_cell(); if self.stream_controller.is_none() { - if self.needs_final_message_separator { + // If the previous turn inserted non-stream history (exec output, patch status, MCP + // calls), render a separator before starting the next streamed assistant message. + if self.needs_final_message_separator && self.had_work_activity { let elapsed_seconds = self .bottom_pane .status_widget() .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; + self.had_work_activity = false; + } else if self.needs_final_message_separator { + // Reset the flag even if we don't show separator (no work was done) + self.needs_final_message_separator = false; } self.stream_controller = Some(StreamController::new( self.last_rendered_width.get().map(|w| w.saturating_sub(2)), @@ -1423,6 +1438,8 @@ impl ChatWidget { self.request_redraw(); } } + // Mark that actual work was done (command executed) + self.had_work_activity = true; } pub(crate) fn handle_patch_apply_end_now( @@ -1434,6 +1451,8 @@ impl ChatWidget { if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } + // Mark that actual work was done (patch applied) + self.had_work_activity = true; } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -1599,6 +1618,8 @@ impl ChatWidget { if let Some(extra) = extra_cell { self.add_boxed_history(extra); } + // Mark that actual work was done (MCP tool call) + self.had_work_activity = true; } pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { @@ -1687,6 +1708,7 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + had_work_activity: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -1784,6 +1806,7 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + had_work_activity: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e0490e2465..40d30c8956 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -438,6 +438,7 @@ async fn make_chatwidget_manual( is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + had_work_activity: false, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index cfafb6da3a..c060d20cd9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1666,10 +1666,16 @@ pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box< } #[derive(Debug)] +/// A visual divider between turns, optionally showing how long the assistant "worked for". +/// +/// This separator is only emitted for turns that performed concrete work (e.g., running commands, +/// applying patches, making MCP tool calls), so purely conversational turns do not show an empty +/// divider. pub struct FinalMessageSeparator { elapsed_seconds: Option, } impl FinalMessageSeparator { + /// Creates a separator; `elapsed_seconds` typically comes from the status indicator timer. pub(crate) fn new(elapsed_seconds: Option) -> Self { Self { elapsed_seconds } } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 80ec93628a..5facd756f7 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -405,8 +405,17 @@ pub(crate) struct ChatWidget { is_review_mode: bool, // Snapshot of token usage to restore after review mode exits. pre_review_token_info: Option>, - // Whether to add a final message separator after the last message + // Whether the next streamed assistant content should be preceded by a final message separator. + // + // This is set whenever we insert a visible history cell that conceptually belongs to a turn. + // The separator itself is only rendered if the turn recorded "work" activity (see + // `had_work_activity`). needs_final_message_separator: bool, + // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). + // + // This gates rendering of the "Worked for …" separator so purely conversational turns don't + // show an empty divider. It is reset when the separator is emitted. + had_work_activity: bool, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback @@ -1150,14 +1159,20 @@ impl ChatWidget { self.flush_active_cell(); if self.stream_controller.is_none() { - if self.needs_final_message_separator { + // If the previous turn inserted non-stream history (exec output, patch status, MCP + // calls), render a separator before starting the next streamed assistant message. + if self.needs_final_message_separator && self.had_work_activity { let elapsed_seconds = self .bottom_pane .status_widget() .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; + self.had_work_activity = false; needs_redraw = true; + } else if self.needs_final_message_separator { + // Reset the flag even if we don't show separator (no work was done) + self.needs_final_message_separator = false; } // Streaming must not capture the current viewport width: width-derived wraps are // applied later, at render time, so the transcript can reflow on resize. @@ -1228,6 +1243,8 @@ impl ChatWidget { self.request_redraw(); } } + // Mark that actual work was done (command executed) + self.had_work_activity = true; } pub(crate) fn handle_patch_apply_end_now( @@ -1239,6 +1256,8 @@ impl ChatWidget { if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } + // Mark that actual work was done (patch applied) + self.had_work_activity = true; } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -1404,6 +1423,8 @@ impl ChatWidget { if let Some(extra) = extra_cell { self.add_boxed_history(extra); } + // Mark that actual work was done (MCP tool call) + self.had_work_activity = true; } pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { @@ -1490,6 +1511,7 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + had_work_activity: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -1585,6 +1607,7 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + had_work_activity: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index f6cb457bf2..22871c39d7 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -424,6 +424,7 @@ async fn make_chatwidget_manual( is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + had_work_activity: false, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 6f795a5be5..e3334a0681 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -1736,10 +1736,16 @@ pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box< } #[derive(Debug)] +/// A visual divider between turns, optionally showing how long the assistant "worked for". +/// +/// This separator is only emitted for turns that performed concrete work (e.g., running commands, +/// applying patches, making MCP tool calls), so purely conversational turns do not show an empty +/// divider. pub struct FinalMessageSeparator { elapsed_seconds: Option, } impl FinalMessageSeparator { + /// Creates a separator; `elapsed_seconds` typically comes from the status indicator timer. pub(crate) fn new(elapsed_seconds: Option) -> Self { Self { elapsed_seconds } }