From 3625b887fac260cfce3b4c39e50d925f786a5427 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 05:05:30 -0500 Subject: [PATCH] feat(ui): humanize_duration handles hours, days, and weeks (#447) Long-running sessions (multi-hour cycles, multi-day automations) were rendering with the seconds/minutes-only formatter, so a two-day session showed as `2880m 0s` and `/goal` status used Rust's Debug Duration form (`188415.234s`). `humanize_duration` now walks through w/d/h/m/s and caps the output at two units so it stays compact in headers and notifications: * `45s`, `1m 12s`, `59m 59s` (existing seconds/minutes path) * `1h`, `2h 2m`, `3h 12m` (was `192m 30s`) * `1d`, `1d 1h`, `2d 5h` (the multi-day case from the issue) * `1w`, `1w 1d`, `3w 2d` (long-running automation) The two-tier rule drops sub-minute precision once you're past the hour boundary; the goal is "is this a couple of hours or days," not stopwatch precision. `/goal` status now wires through this formatter so multi-day goal-elapsed times read as `2d 3h` instead of the previous `188415.234s` Debug form. The notification system was the existing caller and picks up the new format automatically. Tests: 4 test functions in `notifications.rs` covering the four formatting bands (s/m, h/m, d/h, w/d) plus the boundary cases on each unit. --- CHANGELOG.md | 6 ++ crates/tui/src/commands/goal.rs | 5 +- crates/tui/src/tui/notifications.rs | 107 +++++++++++++++++++++++++--- 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4611c2fc..13cbbb4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 without having to discover them from `docs/SUBAGENTS.md`. Adds the long-form aliases (`builder` / `validator` / `tester`) on `agent_assign` for parity with the alias map. +- **Multi-day duration formatting** (#447) — `humanize_duration` + now caps at two units and promotes through h/d/w boundaries. + Long-running sessions render as `2d 3h` instead of `188415s`, + and the previous "192m 30s" cycle output becomes `3h 12m`. The + `/goal` status line picks up the same formatter so multi-day + goal-elapsed times stay readable. - **RLM tool family** (#512) — `rlm` tool cards map to `ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm" wording cleaned out of docs / comments / tests. diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 5e22abb5..22e35866 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -30,10 +30,13 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { _ => { // Show current goal if let Some(ref obj) = app.goal.goal_objective { + // #447: render long elapsed times as `2d 3h` rather + // than Rust's default Debug `Duration` (which produces + // `188415.234s` or similar for multi-day goals). let elapsed = app .goal .goal_started_at - .map(|t| format!("{:?}", t.elapsed())) + .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); let budget_str = app .goal diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 6206ca7f..d42df7e8 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -118,22 +118,67 @@ pub fn notify_done( notify_done_to(method, in_tmux, msg, threshold, elapsed, &mut io::stdout()); } -/// Return a human-readable duration string, e.g. `"1m 12s"` or `"45s"`. +/// Return a human-readable duration string, capped at two units so +/// it stays compact in headers and notifications. +/// +/// Examples: +/// * `"45s"`, `"1m"`, `"1m 12s"` +/// * `"1h"`, `"3h 12m"` (#447 — was previously `"192m"` form) +/// * `"1d"`, `"2d 5h"` (#447 — multi-day sessions/cycles) +/// * `"1w"`, `"3w 2d"` (#447 — long-running automations) +/// +/// The output drops the secondary unit when it's zero, so `"1h"` +/// rather than `"1h 0m"`. Sub-minute precision is dropped at the +/// hour mark and above; the goal is "is this a couple of hours or +/// a couple of days," not stopwatch accuracy. #[must_use] pub fn humanize_duration(d: Duration) -> String { + const MINUTE: u64 = 60; + const HOUR: u64 = 60 * MINUTE; + const DAY: u64 = 24 * HOUR; + const WEEK: u64 = 7 * DAY; + let total = d.as_secs(); if total == 0 { return "0s".to_string(); } - let minutes = total / 60; - let seconds = total % 60; - if minutes == 0 { - format!("{seconds}s") - } else if seconds == 0 { - format!("{minutes}m") - } else { - format!("{minutes}m {seconds}s") + if total >= WEEK { + let w = total / WEEK; + let days = (total % WEEK) / DAY; + return if days == 0 { + format!("{w}w") + } else { + format!("{w}w {days}d") + }; } + if total >= DAY { + let days = total / DAY; + let h = (total % DAY) / HOUR; + return if h == 0 { + format!("{days}d") + } else { + format!("{days}d {h}h") + }; + } + if total >= HOUR { + let h = total / HOUR; + let m = (total % HOUR) / MINUTE; + return if m == 0 { + format!("{h}h") + } else { + format!("{h}h {m}m") + }; + } + if total >= MINUTE { + let m = total / MINUTE; + let s = total % MINUTE; + return if s == 0 { + format!("{m}m") + } else { + format!("{m}m {s}s") + }; + } + format!("{total}s") } #[cfg(test)] @@ -246,11 +291,51 @@ mod tests { } #[test] - fn humanize_duration_formats_correctly() { + fn humanize_duration_seconds_and_minutes() { assert_eq!(humanize_duration(Duration::from_secs(0)), "0s"); assert_eq!(humanize_duration(Duration::from_secs(45)), "45s"); assert_eq!(humanize_duration(Duration::from_secs(60)), "1m"); assert_eq!(humanize_duration(Duration::from_secs(72)), "1m 12s"); - assert_eq!(humanize_duration(Duration::from_secs(3661)), "61m 1s"); + // 59m 59s — still under the hour boundary. + assert_eq!(humanize_duration(Duration::from_secs(3599)), "59m 59s"); + } + + #[test] + fn humanize_duration_promotes_to_hours_at_one_hour() { + // 3661s = 1h 1m 1s — under the new format the seconds fall + // off; we keep just the top two units at the hour mark. + assert_eq!(humanize_duration(Duration::from_secs(3661)), "1h 1m"); + assert_eq!(humanize_duration(Duration::from_secs(3600)), "1h"); + assert_eq!(humanize_duration(Duration::from_secs(7200)), "2h"); + assert_eq!(humanize_duration(Duration::from_secs(7320)), "2h 2m"); + // 3h 12m — the previous "192m 30s" case that motivated #447. + assert_eq!(humanize_duration(Duration::from_secs(11_550)), "3h 12m"); + } + + #[test] + fn humanize_duration_handles_multi_day_sessions() { + // Exactly one day. + assert_eq!(humanize_duration(Duration::from_secs(86_400)), "1d"); + // 1d 1h. + assert_eq!(humanize_duration(Duration::from_secs(90_000)), "1d 1h"); + // 2d 5h — the two-tier rule drops minutes/seconds. + assert_eq!( + humanize_duration(Duration::from_secs(2 * 86_400 + 5 * 3600 + 17 * 60)), + "2d 5h" + ); + } + + #[test] + fn humanize_duration_promotes_to_weeks_after_seven_days() { + assert_eq!(humanize_duration(Duration::from_secs(604_800)), "1w"); + assert_eq!( + humanize_duration(Duration::from_secs(604_800 + 86_400)), + "1w 1d" + ); + // 3w 2d — long-running automation case. + assert_eq!( + humanize_duration(Duration::from_secs(3 * 604_800 + 2 * 86_400 + 17 * 3600)), + "3w 2d" + ); } }