diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f4caf92490..6c8deb17fe 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -567,6 +567,11 @@ impl BottomPane { self.status_timer.reset(elapsed); } + pub(crate) fn set_status_timer_origin(&mut self, started_at: Option) { + self.status_timer.display_started_at = started_at; + self.request_redraw(); + } + pub fn skills(&self) -> Option<&Vec> { self.composer.skills() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c7562326e1..0780e64a68 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -395,6 +395,7 @@ mod permissions_menu; pub(crate) use self::permissions_menu::auto_review_available; pub(crate) use self::permissions_menu::cyber_model_approval_reviewer; mod backend_banners; +mod compaction; mod protocol; mod protocol_requests; mod rate_limits; diff --git a/codex-rs/tui/src/chatwidget/compaction.rs b/codex-rs/tui/src/chatwidget/compaction.rs new file mode 100644 index 0000000000..e05180a9ee --- /dev/null +++ b/codex-rs/tui/src/chatwidget/compaction.rs @@ -0,0 +1,57 @@ +//! Live compaction status. Its wall clock is separate from the turn's running time, +//! and only a matching live completion contributes a duration to the transcript. + +use super::*; + +pub(super) const COMPACTION_HEADER: &str = "Compacting context"; +pub(super) const COMPACTION_DETAILS: &str = "Making room to continue."; + +#[derive(Debug)] +pub(super) struct ActiveCompaction { + pub(super) id: String, + pub(super) started_at: Instant, +} + +impl ChatWidget { + pub(super) fn on_context_compaction_started(&mut self, id: String, elapsed: Duration) { + if self + .status_state + .compaction + .as_ref() + .is_some_and(|active| active.id == id) + { + return; + } + self.flush_answer_stream_with_separator(); + let now = Instant::now(); + let started_at = now.checked_sub(elapsed).unwrap_or(now); + self.status_state.compaction = Some(ActiveCompaction { id, started_at }); + self.bottom_pane.set_status_timer_origin(Some(started_at)); + self.bottom_pane.ensure_status_indicator(); + self.set_status_header(COMPACTION_HEADER.to_string()); + } + + pub(super) fn clear_context_compaction(&mut self) { + if self.status_state.compaction.take().is_some() { + self.bottom_pane + .set_status_timer_origin(/*started_at*/ None); + self.set_status_header("Working".to_string()); + } + } + + pub(super) fn on_context_compaction_completed(&mut self, id: &str, from_replay: bool) { + let mut message = "Context compacted".to_string(); + if let Some(active) = self.status_state.compaction.as_ref() + && active.id == id + { + if !from_replay { + let elapsed = crate::status_indicator_widget::fmt_elapsed_compact( + active.started_at.elapsed().as_secs(), + ); + message = format!("Context compacted · {elapsed}"); + } + self.clear_context_compaction(); + } + self.add_info_message(message, /*hint*/ None); + } +} diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 48083eb293..f512275180 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -70,7 +70,7 @@ impl ChatWidget { self.handle_turn_completed_notification(notification, replay_kind); } ServerNotification::ItemStarted(notification) => { - self.handle_item_started_notification(notification, replay_kind.is_some()); + self.handle_item_started_notification(notification, replay_kind); } ServerNotification::ItemCompleted(notification) => { self.handle_item_completed_notification(notification, replay_kind); @@ -352,9 +352,24 @@ impl ChatWidget { fn handle_item_started_notification( &mut self, notification: ItemStartedNotification, - from_replay: bool, + replay_kind: Option, ) { match notification.item { + ThreadItem::ContextCompaction { id } + if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) => + { + // Buffered starts reconstruct an in-flight compaction when switching tasks. + let elapsed = if replay_kind == Some(ReplayKind::ThreadSnapshot) { + let elapsed_ms = chrono::Utc::now() + .timestamp_millis() + .saturating_sub(notification.started_at_ms) + .max(0); + Duration::from_millis(elapsed_ms as u64) + } else { + Duration::ZERO + }; + self.on_context_compaction_started(id, elapsed); + } item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item), ThreadItem::FileChange { id: _, changes, .. } => { self.on_patch_apply_begin(file_update_changes_to_display(changes)); @@ -387,7 +402,7 @@ impl ChatWidget { reasoning_effort, agents_states, }), - ThreadItem::EnteredReviewMode { review, .. } if !from_replay => { + ThreadItem::EnteredReviewMode { review, .. } if replay_kind.is_none() => { self.enter_review_mode_with_hint(review, /*from_replay*/ false); } _ => {} diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index cb2c8d84cd..1d085e6e73 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -198,8 +198,8 @@ impl ChatWidget { ThreadItem::ExitedReviewMode { .. } => { self.exit_review_mode_after_item(); } - ThreadItem::ContextCompaction { .. } => { - self.add_info_message("Context compacted".to_string(), /*hint*/ None); + ThreadItem::ContextCompaction { id } => { + self.on_context_compaction_completed(&id, from_replay); } ThreadItem::FunctionCallOutput { name, diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 8bd59824ce..51996da624 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -269,6 +269,13 @@ impl ChatWidget { if !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); } + self.bottom_pane.ensure_status_indicator(); + self.set_status( + compaction::COMPACTION_HEADER.to_string(), + Some(compaction::COMPACTION_DETAILS.to_string()), + StatusDetailsCapitalization::Preserve, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); self.input_queue.user_turn_pending_start = true; self.app_event_tx.compact(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_completed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_completed.snap new file mode 100644 index 0000000000..2ed0854f8a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_completed.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/compaction_tests.rs +expression: normalize_compaction_snapshot(lines_to_single_string(&lines)) +--- +• Context compacted · diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_running.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_running.snap new file mode 100644 index 0000000000..b24fd26b94 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_running.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/compaction_tests.rs +expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 80))" +--- +• Compacting context ( • esc to interrupt) + └ Making room to continue. + + +› Ask Codex to do anything + + gpt-5.6-sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_running_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_running_narrow.snap new file mode 100644 index 0000000000..f857139645 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compaction_running_narrow.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/compaction_tests.rs +expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 40))" +--- +• Compacting context ( • esc to i… + └ Making room to continue. + + +› Ask Codex to do anything + + gpt-5.6-sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manual_compaction_pending.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manual_compaction_pending.snap new file mode 100644 index 0000000000..b24fd26b94 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__manual_compaction_pending.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/compaction_tests.rs +expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 80))" +--- +• Compacting context ( • esc to interrupt) + └ Making room to continue. + + +› 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 607fbf8588..db9c3f9411 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -18,6 +18,19 @@ impl ChatWidget { details_capitalization: StatusDetailsCapitalization, details_max_lines: usize, ) -> bool { + // Follow-up input and background activity must not obscure compaction. + // Retry errors still get their own status until the next notification. + let (header, details, details_max_lines) = if self.status_state.compaction.is_some() + && self.status_state.retry_status_header.is_none() + { + ( + compaction::COMPACTION_HEADER.to_string(), + Some(compaction::COMPACTION_DETAILS.to_string()), + STATUS_DETAILS_DEFAULT_MAX_LINES, + ) + } else { + (header, details, details_max_lines) + }; let details = details .filter(|details| !details.is_empty()) .map(|details| { diff --git a/codex-rs/tui/src/chatwidget/status_state.rs b/codex-rs/tui/src/chatwidget/status_state.rs index 1d0945bd74..ec78b3f2e6 100644 --- a/codex-rs/tui/src/chatwidget/status_state.rs +++ b/codex-rs/tui/src/chatwidget/status_state.rs @@ -110,6 +110,7 @@ impl PendingGuardianReviewStatus { #[derive(Debug)] pub(super) struct StatusState { + pub(super) compaction: Option, pub(super) current_status: StatusIndicatorState, pub(super) pending_guardian_review_status: PendingGuardianReviewStatus, pub(super) terminal_title_status_kind: TerminalTitleStatusKind, @@ -120,6 +121,7 @@ pub(super) struct StatusState { impl Default for StatusState { fn default() -> Self { Self { + compaction: None, current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d59533eb29..cd27b0fcf0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -235,6 +235,8 @@ mod app_server; mod approval_requests; #[path = "tests/backend_banners_tests.rs"] mod backend_banners_tests; +#[path = "tests/compaction_tests.rs"] +mod compaction_tests; mod composer_submission; #[path = "tests/config_errors_tests.rs"] mod config_errors; diff --git a/codex-rs/tui/src/chatwidget/tests/compaction_tests.rs b/codex-rs/tui/src/chatwidget/tests/compaction_tests.rs new file mode 100644 index 0000000000..07e2d8eee6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/compaction_tests.rs @@ -0,0 +1,229 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn normalize_compaction_snapshot(text: String) -> String { + let elapsed = regex_lite::Regex::new(r"\b\d+(?:h \d+m \d+s|m \d+s|s)\b").unwrap(); + elapsed + .replace_all(&normalize_snapshot_paths(text), "") + .into_owned() +} + +fn compaction_started(id: &str) -> ServerNotification { + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: chrono::Utc::now().timestamp_millis(), + item: AppServerThreadItem::ContextCompaction { id: id.to_string() }, + }) +} + +fn compaction_completed(id: &str) -> ServerNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 0, + item: AppServerThreadItem::ContextCompaction { id: id.to_string() }, + }) +} + +#[tokio::test] +async fn compaction_status_survives_follow_up_and_preserves_turn_time() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + handle_turn_started(&mut chat, "turn-1"); + chat.bottom_pane + .reset_status_timer(Duration::from_secs(/*secs*/ 600)); + chat.on_agent_message_delta("Previous commentary\n".to_string()); + chat.on_commit_tick(); + assert!(!chat.bottom_pane.status_indicator_visible()); + chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None); + chat.on_commit_tick(); + assert!(chat.bottom_pane.status_indicator_visible()); + + let started_at = Instant::now() - Duration::from_secs(/*secs*/ 83); + chat.status_state.compaction.as_mut().unwrap().started_at = started_at; + chat.bottom_pane.set_status_timer_origin(Some(started_at)); + // Repeated start notifications must not restart the displayed timer. + chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None); + assert_eq!( + chat.status_state.compaction.as_ref().unwrap().started_at, + started_at + ); + assert_chatwidget_snapshot!( + "compaction_running", + normalize_compaction_snapshot(render_bottom_popup(&chat, /*width*/ 80)) + ); + assert_chatwidget_snapshot!( + "compaction_running_narrow", + normalize_compaction_snapshot(render_bottom_popup(&chat, /*width*/ 40)) + ); + + chat.handle_composer_input_result( + InputResult::Submitted { + text: "keep going".to_string(), + text_elements: Vec::new(), + }, + /*had_modal_or_popup*/ false, + ); + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. }); + assert_eq!(chat.input_queue.pending_steers.len(), 1); + assert_eq!( + chat.bottom_pane.status_widget().unwrap().header(), + "Compacting context" + ); + drain_insert_history(&mut rx); + + chat.handle_server_notification(compaction_completed("compact-1"), /*replay_kind*/ None); + let history = drain_insert_history(&mut rx); + let lines: Vec<_> = history.into_iter().flatten().collect(); + assert_chatwidget_snapshot!( + "compaction_completed", + normalize_compaction_snapshot(lines_to_single_string(&lines)) + ); + assert!(chat.status_state.compaction.is_none()); + assert_eq!( + chat.bottom_pane.status_widget().unwrap().header(), + "Working" + ); + assert!(chat.bottom_pane.status_elapsed().unwrap() >= Duration::from_secs(/*secs*/ 600)); +} + +#[tokio::test] +async fn manual_compaction_shows_status_before_backend_events() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.dispatch_command(SlashCommand::Compact); + assert_chatwidget_snapshot!( + "manual_compaction_pending", + normalize_compaction_snapshot(render_bottom_popup(&chat, /*width*/ 80)) + ); + assert!(chat.handle_turn_start_rejection("Could not start compaction".to_string())); + assert!(!chat.bottom_pane.status_indicator_visible()); +} + +#[tokio::test] +async fn compaction_status_clears_when_turn_ends_without_item_completion() { + for status in [ + AppServerTurnStatus::Completed, + AppServerTurnStatus::Interrupted, + AppServerTurnStatus::Failed, + ] { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + handle_turn_started(&mut chat, "turn-1"); + chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None); + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: app_server_turn( + "turn-1", status, /*duration_ms*/ None, /*error*/ None, + ), + }), + /*replay_kind*/ None, + ); + assert!(chat.status_state.compaction.is_none()); + assert!(!chat.bottom_pane.status_indicator_visible()); + let history = drain_insert_history(&mut rx); + let lines: Vec<_> = history.into_iter().flatten().collect(); + assert!(!lines_to_single_string(&lines).contains("Context compacted")); + handle_turn_started(&mut chat, "turn-2"); + assert_eq!( + chat.bottom_pane.status_widget().unwrap().header(), + "Working" + ); + } +} + +#[tokio::test] +async fn compaction_history_does_not_start_a_timer_or_finish_live_compaction() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.handle_server_notification( + compaction_started("old"), + Some(ReplayKind::ResumeInitialMessages), + ); + assert!(chat.status_state.compaction.is_none()); + assert!(!chat.bottom_pane.status_indicator_visible()); + handle_turn_started(&mut chat, "turn-1"); + chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None); + chat.handle_server_notification( + compaction_completed("old"), + Some(ReplayKind::ThreadSnapshot), + ); + assert_eq!( + chat.status_state.compaction.as_ref().unwrap().id, + "compact-1" + ); + let history = drain_insert_history(&mut rx); + let lines: Vec<_> = history.into_iter().flatten().collect(); + assert_eq!(lines_to_single_string(&lines).trim(), "• Context compacted"); +} + +#[tokio::test] +async fn compaction_snapshot_restores_elapsed_time_and_clears_on_replayed_completion() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + handle_turn_started(&mut chat, "turn-1"); + let ServerNotification::ItemStarted(mut started) = compaction_started("compact-1") else { + unreachable!(); + }; + started.started_at_ms -= 12_000; + chat.handle_server_notification( + ServerNotification::ItemStarted(started), + Some(ReplayKind::ThreadSnapshot), + ); + assert_eq!( + chat.bottom_pane.status_widget().unwrap().header(), + "Compacting context" + ); + assert!( + chat.status_state + .compaction + .as_ref() + .unwrap() + .started_at + .elapsed() + >= Duration::from_secs(/*secs*/ 12) + ); + + chat.handle_server_notification( + compaction_completed("compact-1"), + Some(ReplayKind::ThreadSnapshot), + ); + assert!(chat.status_state.compaction.is_none()); + assert_eq!( + chat.bottom_pane.status_widget().unwrap().header(), + "Working" + ); + let lines: Vec<_> = drain_insert_history(&mut rx) + .into_iter() + .flatten() + .collect(); + assert_eq!(lines_to_single_string(&lines).trim(), "• Context compacted"); +} + +#[tokio::test] +async fn compaction_retry_status_returns_to_compacting() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + handle_turn_started(&mut chat, "turn-1"); + chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None); + chat.handle_server_notification( + ServerNotification::Error(ErrorNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + will_retry: true, + error: codex_app_server_protocol::TurnError { + message: "Reconnecting".to_string(), + codex_error_info: None, + additional_details: None, + misalignment: None, + }, + }), + /*replay_kind*/ None, + ); + assert_eq!( + chat.bottom_pane.status_widget().unwrap().header(), + "Reconnecting" + ); + chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None); + assert_eq!( + chat.bottom_pane.status_widget().unwrap().header(), + "Compacting context" + ); +} diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 2d3467476d..5f2ea9ed28 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -68,6 +68,7 @@ impl ChatWidget { // Raw reasoning uses the same flow as summarized reasoning pub(super) fn on_task_started(&mut self) { + self.clear_context_compaction(); self.input_queue.user_turn_pending_start = false; self.reset_safety_buffering_for_turn_start(); self.turn_lifecycle.start(Instant::now()); @@ -176,6 +177,7 @@ impl ChatWidget { self.refresh_thread_usage_after_turn(); } // Mark task stopped and request redraw now that all content is in history. + self.clear_context_compaction(); self.status_state.pending_status_indicator_restore = false; self.input_queue.user_turn_pending_start = false; self.clear_active_hook_cell(); @@ -313,6 +315,7 @@ impl ChatWidget { /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup /// and should continue to drive the bottom-pane running indicator while it is in progress. pub(super) fn finalize_turn(&mut self) { + self.clear_context_compaction(); self.clear_safety_buffering(); // Drop preview-only stream tail content on any termination path before // failed-cell finalization, so transient tail cells are never persisted. diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 893bdef5c4..19b0fa2cb0 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -211,7 +211,10 @@ impl StatusIndicator<'_> { fn lines(&self, width: u16) -> Vec> { let row = self.row; let now = Instant::now(); - let elapsed_duration = self.timer.elapsed_at(now); + let elapsed_duration = self.timer.display_started_at.map_or_else( + || self.timer.elapsed_at(now), + |started_at| now.saturating_duration_since(started_at), + ); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); let motion_mode = MotionMode::from_animations_enabled(row.animations_enabled); @@ -280,10 +283,15 @@ impl Renderable for StatusIndicator<'_> { if area.is_empty() { return; } - if self.row.animations_enabled { + if self.row.animations_enabled || self.timer.display_started_at.is_some() { + let interval_ms = if self.row.animations_enabled { + 32 + } else { + 1_000 + }; self.row .frame_requester - .schedule_frame_in(Duration::from_millis(32)); + .schedule_frame_in(Duration::from_millis(interval_ms)); } Paragraph::new(Text::from(self.lines(area.width))).render(area, buf); } diff --git a/codex-rs/tui/src/status_indicator_widget/timer.rs b/codex-rs/tui/src/status_indicator_widget/timer.rs index 5cb3a2c69d..8820e129be 100644 --- a/codex-rs/tui/src/status_indicator_widget/timer.rs +++ b/codex-rs/tui/src/status_indicator_widget/timer.rs @@ -1,10 +1,13 @@ //! Pause-aware status time owned independently of the optional status row. +//! A phase can override the displayed clock without changing turn accounting. use std::time::Duration; use std::time::Instant; #[derive(Debug)] pub(crate) struct StatusTimer { + /// Optional wall-clock origin for the displayed phase, without resetting turn accounting. + pub(crate) display_started_at: Option, pub(super) elapsed_running: Duration, pub(super) last_resume_at: Instant, pub(super) is_paused: bool, @@ -13,6 +16,7 @@ pub(crate) struct StatusTimer { impl Default for StatusTimer { fn default() -> Self { Self { + display_started_at: None, elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), is_paused: false,