mirror of
https://github.com/openai/codex.git
synced 2026-10-21 09:54:56 +00:00
Show live context compaction status in the TUI (#42319)
## Why Context compaction was only reported after it completed, so the TUI did not show when compaction was actively making room to continue. ## What changed - Show `Compacting context` as soon as manual or server-driven compaction starts, with an elapsed timer independent of the current turn timer. - Preserve the compaction status across follow-up input and transient activity, while still allowing retry errors to take precedence. - Restore in-progress compaction timing from thread snapshots and add the live duration to the completion message. - Clear stale compaction state when the turn starts, completes, or terminates. ## Testing Added TUI tests covering manual and server-driven compaction, narrow rendering, follow-up input, replay and task switching, retry status, timer preservation, and turn termination. GitOrigin-RevId: 804e1bc553315da24ff1f99462e8e282818cada3
This commit is contained in:
@@ -567,6 +567,11 @@ impl BottomPane {
|
||||
self.status_timer.reset(elapsed);
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_timer_origin(&mut self, started_at: Option<Instant>) {
|
||||
self.status_timer.display_started_at = started_at;
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
|
||||
self.composer.skills()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<ReplayKind>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/compaction_tests.rs
|
||||
expression: normalize_compaction_snapshot(lines_to_single_string(&lines))
|
||||
---
|
||||
• Context compacted · <elapsed>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/compaction_tests.rs
|
||||
expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 80))"
|
||||
---
|
||||
• Compacting context (<elapsed> • esc to interrupt)
|
||||
└ Making room to continue.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
gpt-5.6-sol default · /tmp/project
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/compaction_tests.rs
|
||||
expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 40))"
|
||||
---
|
||||
• Compacting context (<elapsed> • esc to i…
|
||||
└ Making room to continue.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
gpt-5.6-sol default · /tmp/project
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/compaction_tests.rs
|
||||
expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 80))"
|
||||
---
|
||||
• Compacting context (<elapsed> • esc to interrupt)
|
||||
└ Making room to continue.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
gpt-5.6-sol default · /tmp/project
|
||||
@@ -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| {
|
||||
|
||||
@@ -110,6 +110,7 @@ impl PendingGuardianReviewStatus {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct StatusState {
|
||||
pub(super) compaction: Option<super::compaction::ActiveCompaction>,
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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), "<elapsed>")
|
||||
.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"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -211,7 +211,10 @@ impl StatusIndicator<'_> {
|
||||
fn lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Instant>,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user