Preserve TUI status timing when the status row is hidden (#41913)

## Why

Streaming can temporarily hide the status row, but its elapsed time is still
needed when the row returns and when the final `Worked for` duration falls back
to the local clock. Approval prompts must also keep that clock paused.

## What changed

- Own the pause-aware status timer in the bottom pane independently of the
  optional status row.
- Preserve elapsed time and modal pause state while hiding or restoring the row,
  and reset the clock for new turns and Windows sandbox setup.
- Use the independent timer as the fallback for the final turn duration.

## Testing

Add timer unit coverage and TUI tests for hidden rows, outstanding approvals,
fallback turn durations, and fresh Windows sandbox setup clocks.

GitOrigin-RevId: a77931e465d5aedd4a8cd606a3a3117d1bb37ce7
This commit is contained in:
Ian MacLeod
2026-08-31 21:19:22 +00:00
committed by copyberry
parent 5f79a92e39
commit 17e8101699
10 changed files with 295 additions and 147 deletions
+79 -17
View File
@@ -249,6 +249,8 @@ pub(crate) struct BottomPane {
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
inline_banner: Option<actionable_banner::InlineBanner>,
/// Streaming may drop the row without losing its elapsed time or modal pause.
status_timer: crate::status_indicator_widget::StatusTimer,
/// Unified exec session summary source.
///
/// When a status row exists, this summary is mirrored inline in that row;
@@ -320,6 +322,7 @@ impl BottomPane {
is_task_running: false,
status: None,
inline_banner: None,
status_timer: crate::status_indicator_widget::StatusTimer::default(),
unified_exec_footer: UnifiedExecFooter::new(),
pending_input_preview: PendingInputPreview::new(),
pending_thread_approvals: PendingThreadApprovals::new(),
@@ -546,6 +549,15 @@ impl BottomPane {
self.status.as_ref()
}
pub(crate) fn status_elapsed(&self) -> Option<Duration> {
self.is_task_running
.then(|| self.status_timer.elapsed_at(Instant::now()))
}
pub(crate) fn reset_status_timer(&mut self, elapsed: Duration) {
self.status_timer.reset(elapsed);
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.composer.skills()
}
@@ -1054,7 +1066,7 @@ impl BottomPane {
#[cfg(test)]
pub(crate) fn status_indicator_visible(&self) -> bool {
self.status.is_some()
self.status_widget().is_some()
}
#[cfg(test)]
@@ -1085,6 +1097,7 @@ impl BottomPane {
if running {
if !was_running {
self.status_timer.reset(Duration::ZERO);
if self.status.is_none() {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
@@ -1121,11 +1134,13 @@ impl BottomPane {
pub(crate) fn ensure_status_indicator(&mut self) {
if self.status.is_none() {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
));
self.status.get_or_insert_with(|| {
StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
)
});
if let Some(status) = self.status.as_mut() {
status.set_interrupt_binding(
self.keymap
@@ -1453,7 +1468,7 @@ impl BottomPane {
&& !(is_agent_command && key_event.code == KeyCode::Esc)
&& self.no_modal_or_popup_active()
&& !self.composer_should_handle_vim_insert_escape(key_event)
&& self.status.is_some()
&& self.status_widget().is_some()
}
pub(crate) fn terminal_title_requires_action(&self) -> bool {
@@ -1714,15 +1729,12 @@ impl BottomPane {
}
fn pause_status_timer_for_modal(&mut self) {
if let Some(status) = self.status.as_mut() {
status.pause_timer();
}
self.status_timer.pause_at(Instant::now());
}
fn resume_status_timer_after_modal(&mut self) {
if let Some(status) = self.status.as_mut() {
status.resume_timer();
}
self.status_timer.resume_at(Instant::now());
self.request_redraw();
}
/// Height (terminal rows) required by the current bottom pane.
@@ -1854,12 +1866,15 @@ impl BottomPane {
{
flex.push(/*flex*/ 0, RenderableItem::Borrowed(banner));
}
if let Some(status) = &self.status {
flex.push(/*flex*/ 0, RenderableItem::Borrowed(status));
if let Some(status) = self.status_widget() {
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(status.with_timer(&self.status_timer))),
);
}
// Avoid double-surfacing the same summary and avoid adding an extra
// row while the status line is already visible.
if self.status.is_none() && !self.unified_exec_footer.is_empty() {
if self.status_widget().is_none() && !self.unified_exec_footer.is_empty() {
flex.push(
/*flex*/ 0,
RenderableItem::Borrowed(&self.unified_exec_footer),
@@ -1870,7 +1885,7 @@ impl BottomPane {
|| !self.pending_input_preview.pending_steers.is_empty()
|| !self.pending_input_preview.rejected_steers.is_empty();
let has_status_or_footer =
self.status.is_some() || !self.unified_exec_footer.is_empty();
self.status_widget().is_some() || !self.unified_exec_footer.is_empty();
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
if has_inline_previews && has_status_or_footer {
flex.push(/*flex*/ 0, RenderableItem::Owned("".into()));
@@ -2663,6 +2678,53 @@ mod tests {
let bufs = snapshot_buffer(&buf);
assert!(bufs.contains("• Working"), "expected Working header");
pane.reset_status_timer(Duration::from_secs(/*secs*/ 42));
pane.hide_status_indicator();
pane.pause_status_timer_for_modal();
let paused = pane.status_timer.elapsed_at(Instant::now());
pane.ensure_status_indicator();
assert_snapshot!(
"status_timer_survives_hidden_row",
render_snapshot(&pane, area)
);
assert_eq!(
pane.status_timer
.elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)),
paused
);
pane.resume_status_timer_after_modal();
assert!(
pane.status_timer
.elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10))
>= paused + Duration::from_secs(/*secs*/ 10)
);
pane.set_task_running(/*running*/ false);
pane.set_task_running(/*running*/ true);
assert!(pane.status_timer.elapsed_at(Instant::now()) < paused);
}
#[test]
fn turn_start_keeps_an_outstanding_approval_paused() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let mut pane = test_pane(AppEventSender::new(tx));
// MCP startup can already own the running row when an agent turn starts.
pane.set_task_running(/*running*/ true);
pane.push_approval_request(exec_request(), &Features::default());
pane.hide_status_indicator();
pane.set_task_running(/*running*/ true);
pane.reset_status_timer(Duration::ZERO);
pane.ensure_status_indicator();
let later = Instant::now() + Duration::from_secs(/*secs*/ 120);
assert_eq!(pane.status_timer.elapsed_at(later), Duration::ZERO);
pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(pane.no_modal_or_popup_active());
assert!(
pane.status_timer
.elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10))
>= Duration::from_secs(/*secs*/ 10)
);
}
#[test]
@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/mod.rs
assertion_line: 2570
expression: "render_snapshot(&pane, area)"
---
• Working (42s • esc to interrupt)
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/permissions.rs
assertion_line: 610
expression: "setup.replace(&chat.config.cwd.display().to_string(), \"[CWD]\")"
---
• Setting up sandbox... (0s)
└ Hang tight, this may take a few minutes
› Input disabled until setup completes.
gpt-5.6-sol default · [CWD]
+34 -26
View File
@@ -875,35 +875,43 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
#[tokio::test]
async fn final_worked_for_uses_cumulative_turn_duration_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "turn-1");
for duration_ms in [Some(125_000), None] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "turn-1");
let exec = begin_exec_with_source(
&mut chat,
"call-1",
"echo preparing",
ExecCommandSource::Agent,
);
end_exec(&mut chat, exec, "preparing\n", "", /*exit_code*/ 0);
let exec = begin_exec_with_source(
&mut chat,
"call-1",
"echo preparing",
ExecCommandSource::Agent,
);
end_exec(&mut chat, exec, "preparing\n", "", /*exit_code*/ 0);
complete_assistant_message(
&mut chat,
"msg-final",
"Final response.",
Some(MessagePhase::FinalAnswer),
);
handle_turn_completed(&mut chat, "turn-1", Some(125_000));
chat.bottom_pane
.reset_status_timer(Duration::from_secs(/*secs*/ 125));
handle_agent_message_delta(&mut chat, "Final response.\n");
chat.on_commit_tick();
assert!(!chat.bottom_pane.status_indicator_visible());
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert!(
combined.contains("Worked for 2m 05s"),
"expected final separator to use cumulative turn duration, got:\n{combined}"
);
assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined);
complete_assistant_message(
&mut chat,
"msg-final",
"Final response.",
Some(MessagePhase::FinalAnswer),
);
handle_turn_completed(&mut chat, "turn-1", duration_ms);
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert!(
combined.contains("Worked for 2m 05s"),
"expected final separator to use cumulative turn duration, got:\n{combined}"
);
assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined);
}
}
#[tokio::test]
@@ -598,6 +598,28 @@ async fn fragmented_terminal_response_cannot_acknowledge_world_writable_warning(
));
}
#[tokio::test]
async fn windows_sandbox_setup_starts_a_fresh_status_clock() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane
.reset_status_timer(Duration::from_secs(/*secs*/ 125));
chat.show_windows_sandbox_setup_status();
let setup = render_bottom_popup(&chat, /*width*/ 80);
assert!(setup.contains("Setting up sandbox"));
assert!(setup.contains("(0s"));
assert_chatwidget_snapshot!(
"windows_sandbox_setup_fresh_clock",
setup.replace(&chat.config.cwd.display().to_string(), "[CWD]")
);
chat.clear_windows_sandbox_setup_status();
chat.bottom_pane.set_task_running(/*running*/ true);
let working = render_bottom_popup(&chat, /*width*/ 80);
assert!(working.contains("Working"));
assert!(!working.contains("Setting up sandbox"));
}
#[tokio::test]
async fn required_windows_sandbox_setup_defers_configured_initial_prompt() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+3 -2
View File
@@ -82,6 +82,7 @@ impl ChatWidget {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.update_task_running_state();
self.bottom_pane.reset_status_timer(Duration::ZERO);
self.status_state.retry_status_header = None;
self.clear_active_hook_cell();
self.status_state.pending_status_indicator_restore = false;
@@ -156,8 +157,8 @@ impl ChatWidget {
.map(|duration_ms| duration_ms / 1_000)
.or_else(|| {
self.bottom_pane
.status_widget()
.map(crate::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds)
.status_elapsed()
.map(|elapsed| elapsed.as_secs())
})
} else {
None
@@ -479,7 +479,7 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", test))]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
// While elevated sandbox setup runs, prevent typing so the user doesn't
// accidentally queue messages that will run under an unexpected mode.
@@ -487,6 +487,7 @@ impl ChatWidget {
/*enabled*/ false,
Some("Input disabled until setup completes.".to_string()),
);
self.bottom_pane.reset_status_timer(Duration::ZERO);
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ false);
@@ -499,11 +500,11 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(any(target_os = "windows", test)))]
#[allow(dead_code)]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", test))]
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
self.bottom_pane
.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
@@ -511,6 +512,6 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(any(target_os = "windows", test)))]
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
}
+38 -98
View File
@@ -1,6 +1,6 @@
//! A live task status row rendered above the composer while the agent is busy.
//!
//! The row owns spinner timing, the optional interrupt hint, and short inline
//! The row renders a separately owned clock, the optional interrupt hint, and short inline
//! context (for example, the unified-exec background-process summary). Keeping
//! these pieces on one line avoids vertical layout churn in the bottom pane.
@@ -32,6 +32,9 @@ use crate::tui::FrameRequester;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
mod timer;
pub(crate) use timer::StatusTimer;
pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3;
const DETAILS_PREFIX: &str = " └ ";
@@ -52,9 +55,6 @@ pub(crate) struct StatusIndicatorWidget {
show_interrupt_hint: bool,
interrupt_binding: Option<ShortcutHint>,
elapsed_running: Duration,
last_resume_at: Instant,
is_paused: bool,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
@@ -90,10 +90,6 @@ impl StatusIndicatorWidget {
inline_message: None,
show_interrupt_hint: true,
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,
app_event_tx,
frame_requester,
animations_enabled,
@@ -156,45 +152,8 @@ impl StatusIndicatorWidget {
self.interrupt_binding = binding;
}
pub(crate) fn pause_timer(&mut self) {
self.pause_timer_at(Instant::now());
}
pub(crate) fn resume_timer(&mut self) {
self.resume_timer_at(Instant::now());
}
pub(crate) fn pause_timer_at(&mut self, now: Instant) {
if self.is_paused {
return;
}
self.elapsed_running += now.saturating_duration_since(self.last_resume_at);
self.is_paused = true;
}
pub(crate) fn resume_timer_at(&mut self, now: Instant) {
if !self.is_paused {
return;
}
self.last_resume_at = now;
self.is_paused = false;
self.frame_requester.schedule_frame();
}
fn elapsed_duration_at(&self, now: Instant) -> Duration {
let mut elapsed = self.elapsed_running;
if !self.is_paused {
elapsed += now.saturating_duration_since(self.last_resume_at);
}
elapsed
}
fn elapsed_seconds_at(&self, now: Instant) -> u64 {
self.elapsed_duration_at(now).as_secs()
}
pub fn elapsed_seconds(&self) -> u64 {
self.elapsed_seconds_at(Instant::now())
pub(crate) fn with_timer<'a>(&'a self, timer: &'a StatusTimer) -> impl Renderable + 'a {
StatusIndicator { row: self, timer }
}
/// Wrap the details text into a fixed width and return the lines, truncating if necessary.
@@ -230,9 +189,14 @@ impl StatusIndicatorWidget {
}
}
impl Renderable for StatusIndicatorWidget {
struct StatusIndicator<'a> {
row: &'a StatusIndicatorWidget,
timer: &'a StatusTimer,
}
impl Renderable for StatusIndicator<'_> {
fn desired_height(&self, width: u16) -> u16 {
1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0)
1 + u16::try_from(self.row.wrapped_details_lines(width).len()).unwrap_or(0)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -240,31 +204,32 @@ impl Renderable for StatusIndicatorWidget {
return;
}
if self.animations_enabled {
let row = self.row;
if row.animations_enabled {
// Schedule next animation frame.
self.frame_requester
row.frame_requester
.schedule_frame_in(Duration::from_millis(32));
}
let now = Instant::now();
let elapsed_duration = self.elapsed_duration_at(now);
let elapsed_duration = self.timer.elapsed_at(now);
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled);
let motion_mode = MotionMode::from_animations_enabled(row.animations_enabled);
let mut spans = Vec::with_capacity(5);
if let Some(indicator) = activity_indicator(
Some(self.last_resume_at),
Some(self.timer.last_resume_at),
motion_mode,
ReducedMotionIndicator::Hidden,
) {
spans.push(indicator);
spans.push(" ".into());
}
spans.extend(shimmer_text(&self.header, motion_mode));
spans.extend(shimmer_text(&row.header, motion_mode));
if !spans.is_empty() {
spans.push(" ".into());
}
if self.show_interrupt_hint
&& let Some(interrupt_binding) = self.interrupt_binding
if row.show_interrupt_hint
&& let Some(interrupt_binding) = row.interrupt_binding
{
spans.extend(vec![
format!("({pretty_elapsed} • ").dim(),
@@ -274,7 +239,7 @@ impl Renderable for StatusIndicatorWidget {
} else {
spans.push(format!("({pretty_elapsed})").dim());
}
if let Some(message) = &self.inline_message {
if let Some(message) = &row.inline_message {
// Keep optional context after elapsed/interrupt text so that core
// interrupt affordances stay in a fixed visual location.
spans.push(" · ".dim());
@@ -288,7 +253,7 @@ impl Renderable for StatusIndicatorWidget {
));
if area.height > 1 {
// If there is enough space, add the details lines below the header.
let details = self.wrapped_details_lines(area.width);
let details = row.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
}
@@ -304,8 +269,6 @@ mod tests {
use crate::app_event_sender::AppEventSender;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::mpsc::unbounded_channel;
use pretty_assertions::assert_eq;
@@ -328,6 +291,7 @@ mod tests {
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let timer = StatusTimer::default();
let w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
@@ -337,7 +301,7 @@ mod tests {
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
@@ -346,6 +310,7 @@ mod tests {
fn renders_truncated() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let timer = StatusTimer::default();
let w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
@@ -355,7 +320,7 @@ mod tests {
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
@@ -377,14 +342,14 @@ mod tests {
w.set_interrupt_hint_visible(/*visible*/ false);
// Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable.
w.is_paused = true;
w.elapsed_running = Duration::ZERO;
let mut timer = StatusTimer::default();
timer.pause_at(timer.last_resume_at);
// Prefix is 4 columns, so a width of 30 yields a content width of 26: one column
// short of fitting the whole phrase (27 cols), forcing exactly one wrap without ellipsis.
let mut terminal = Terminal::new(TestBackend::new(30, 3)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
@@ -393,17 +358,17 @@ mod tests {
fn renders_without_spinner_when_animations_disabled() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(
let w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
/*animations_enabled*/ false,
);
w.is_paused = true;
w.elapsed_running = Duration::ZERO;
let mut timer = StatusTimer::default();
timer.pause_at(timer.last_resume_at);
let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
.expect("draw");
let line = terminal.backend().buffer().content()[..80]
.iter()
@@ -423,41 +388,16 @@ mod tests {
/*animations_enabled*/ false,
);
w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into()));
w.is_paused = true;
w.elapsed_running = Duration::ZERO;
let mut timer = StatusTimer::default();
timer.pause_at(timer.last_resume_at);
let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut widget = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
/*animations_enabled*/ true,
);
let baseline = Instant::now();
widget.last_resume_at = baseline;
let before_pause = widget.elapsed_seconds_at(baseline + Duration::from_secs(5));
assert_eq!(before_pause, 5);
widget.pause_timer_at(baseline + Duration::from_secs(5));
let paused_elapsed = widget.elapsed_seconds_at(baseline + Duration::from_secs(10));
assert_eq!(paused_elapsed, before_pause);
widget.resume_timer_at(baseline + Duration::from_secs(10));
let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13));
assert_eq!(after_resume, before_pause + 3);
}
#[test]
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -0,0 +1,57 @@
//! Pause-aware status time owned independently of the optional status row.
use std::time::Duration;
use std::time::Instant;
#[derive(Debug)]
pub(crate) struct StatusTimer {
pub(super) elapsed_running: Duration,
pub(super) last_resume_at: Instant,
pub(super) is_paused: bool,
}
impl Default for StatusTimer {
fn default() -> Self {
Self {
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,
}
}
}
impl StatusTimer {
pub(crate) fn reset(&mut self, elapsed: Duration) {
self.elapsed_running = elapsed;
self.last_resume_at = Instant::now();
// A turn can start while an MCP elicitation or approval is still open.
// Reset elapsed time without changing that modal's pause state.
}
pub(crate) fn pause_at(&mut self, now: Instant) {
if !self.is_paused {
self.elapsed_running += now.saturating_duration_since(self.last_resume_at);
self.is_paused = true;
}
}
pub(crate) fn resume_at(&mut self, now: Instant) {
if self.is_paused {
self.last_resume_at = now;
self.is_paused = false;
}
}
pub(crate) fn elapsed_at(&self, now: Instant) -> Duration {
self.elapsed_running
+ if self.is_paused {
Duration::ZERO
} else {
now.saturating_duration_since(self.last_resume_at)
}
}
}
#[cfg(test)]
#[path = "timer_tests.rs"]
mod tests;
@@ -0,0 +1,34 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn pauses_and_resumes_without_counting_wait_time() {
let baseline = Instant::now();
let mut timer = StatusTimer {
last_resume_at: baseline,
..StatusTimer::default()
};
timer.pause_at(baseline + Duration::from_secs(5));
timer.pause_at(baseline + Duration::from_secs(8));
assert_eq!(
timer.elapsed_at(baseline + Duration::from_secs(10)),
Duration::from_secs(5)
);
timer.resume_at(baseline + Duration::from_secs(10));
timer.resume_at(baseline + Duration::from_secs(12));
assert_eq!(
timer.elapsed_at(baseline + Duration::from_secs(13)),
Duration::from_secs(8)
);
timer.reset(Duration::from_secs(24));
assert_eq!(
timer.elapsed_at(timer.last_resume_at),
Duration::from_secs(24)
);
timer.pause_at(timer.last_resume_at);
timer.reset(Duration::ZERO);
assert_eq!(
timer.elapsed_at(timer.last_resume_at + Duration::from_secs(/*secs*/ 120)),
Duration::ZERO
);
}