feat(footer): cumulative session-elapsed indicator (#448)

Adds `App::session_started_at: Instant` (set at construction) and
a low-priority `worked Nh Mm` chip in the footer's right cluster
that surfaces session age once it crosses 60s.

* `footer_worked_chip(elapsed)` returns empty spans for the first
  minute of a session so a fresh launch doesn't render a noisy
  ticker. Above the threshold it reuses the multi-day
  `humanize_duration` helper (#447) so the band promotion stays
  consistent: `1m`, `3h 12m`, `2d 5h`, `1w 2d`.
* The chip slots in last in `auxiliary_spans`, which means under
  narrow widths it's the first thing the priority-drop loop
  removes — the existing chips (coherence / agents / replay /
  cache / mcp) keep their slots.
* `FooterProps` carries a captured `worked: Vec<Span<'static>>`
  built at props-build time (matches the existing `retry`
  capture pattern). Render stays pure, tests can pin a known
  state without relying on wall-clock.

Tests:
  3 new tests in `tui/widgets/footer.rs` — chip hidden under 60s,
  chip rendered with humanized labels at 60s / 3h 12m / 2d 5h
  bands. The existing `from_app_idle_state` test gains a
  `worked.is_empty()` assertion (the test app is freshly
  constructed, well under the 60s threshold).
This commit is contained in:
Hunter Bown
2026-05-03 05:17:01 -05:00
parent 6dfb10f321
commit 5e83f073b1
3 changed files with 85 additions and 0 deletions
+8
View File
@@ -131,6 +131,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
including the existing `low_motion` / `calm_mode` /
`show_thinking` / `show_tool_details` toggles for
screen-reader users.
- **Cumulative session-elapsed footer chip** (#448) — a
low-priority `worked 3h 12m` chip in the footer's right
cluster shows session age once it crosses 60s. Hidden during
the first minute of a launch so a fresh start doesn't flash a
ticker. Drops first under narrow widths so the existing chips
(coherence / agents / replay / cache / mcp) keep their slots.
Sampled at props-build time (matches the `retry` capture
pattern) so render stays pure for tests.
- **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.
+7
View File
@@ -743,6 +743,12 @@ pub struct App {
pub submit_pending_steers_after_interrupt: bool,
/// Start time for current turn
pub turn_started_at: Option<Instant>,
/// When this `App` instance was constructed (#448). Used to render
/// the footer's `worked Nh Mm` indicator. Resets per launch — we
/// deliberately don't try to persist across full restarts because
/// "since I sat down" is the more useful framing than wall-clock
/// session age.
pub session_started_at: Instant,
/// Current runtime turn id (if known).
pub runtime_turn_id: Option<String>,
/// Current runtime turn status (if known).
@@ -1174,6 +1180,7 @@ impl App {
rejected_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
turn_started_at: None,
session_started_at: Instant::now(),
runtime_turn_id: None,
runtime_turn_status: None,
workspace_context: None,
+70
View File
@@ -55,6 +55,11 @@ pub struct FooterProps {
/// MCP server health chip spans (empty when no MCP servers configured).
/// Populated lazily — see [`footer_mcp_chip`]. (#502)
pub mcp: Vec<Span<'static>>,
/// Cumulative session-elapsed chip spans ("worked 3h 12m"). Empty
/// for the first minute of a session so a fresh launch doesn't
/// flash a `worked 5s` indicator. Populated by [`footer_worked_chip`]
/// from `App::session_started_at`. (#448)
pub worked: Vec<Span<'static>>,
/// Snapshot of the global retry-status surface (#499). Sampled once
/// at props-build time and rendered as a foreground banner on the
/// left of the footer when active. Captured here (rather than read
@@ -178,6 +183,27 @@ pub fn footer_agents_chip(running: usize, locale: Locale) -> Vec<Span<'static>>
)]
}
/// Build the cumulative-elapsed chip ("worked 3h 12m") for the
/// footer's right cluster (#448). Hidden during the first minute of
/// a session so a fresh launch doesn't render a noisy `worked 5s`
/// indicator that immediately starts ticking. Above the threshold,
/// reuses [`crate::tui::notifications::humanize_duration`] for
/// consistent w/d/h/m formatting.
#[must_use]
pub fn footer_worked_chip(elapsed: std::time::Duration) -> Vec<Span<'static>> {
if elapsed < std::time::Duration::from_secs(60) {
return Vec::new();
}
let label = format!(
"worked {}",
crate::tui::notifications::humanize_duration(elapsed)
);
vec![Span::styled(
label,
Style::default().fg(palette::TEXT_MUTED),
)]
}
/// Build the "MCP M/N" health chip (#502) from the user's stored
/// snapshot. `connected` is the number of servers currently reachable;
/// `configured` is the number declared in the user's MCP config. When
@@ -241,6 +267,9 @@ impl FooterProps {
.as_ref()
.map(|s| s.servers.iter().filter(|server| server.connected).count());
let mcp = footer_mcp_chip(mcp_connected, mcp_configured);
// #448: cumulative-elapsed chip. Sampled at props-build time
// (matches the `retry` capture pattern) so render is pure.
let worked = footer_worked_chip(app.session_started_at.elapsed());
Self {
model: app.model.clone(),
mode_label,
@@ -255,6 +284,7 @@ impl FooterProps {
reasoning_replay,
cache,
mcp,
worked,
cost,
toast,
working_strip_frame: None,
@@ -299,6 +329,11 @@ impl FooterWidget {
&self.props.reasoning_replay,
&self.props.cache,
&self.props.mcp,
// `worked` is the lowest-priority chip — drops first under
// narrow widths (the priority loop below removes from the
// tail). `cost` is steady info and stays in the left
// cluster where the eye finds it without scanning.
&self.props.worked,
]
.into_iter()
.filter(|spans| !spans.is_empty())
@@ -673,9 +708,44 @@ mod tests {
assert!(props.cache.is_empty());
assert!(props.cost.is_empty());
assert!(props.reasoning_replay.is_empty());
// #448: fresh apps don't get a `worked` chip until the
// session has been alive for >= 60s. A test app built right
// before this assertion is well under that threshold.
assert!(props.worked.is_empty());
assert!(props.toast.is_none());
}
#[test]
fn footer_worked_chip_hidden_below_one_minute() {
use std::time::Duration;
for secs in [0, 1, 30, 59] {
let chip = super::footer_worked_chip(Duration::from_secs(secs));
assert!(
chip.is_empty(),
"worked chip must be hidden at {secs}s; got {chip:?}"
);
}
}
#[test]
fn footer_worked_chip_shows_humanized_label_above_threshold() {
use std::time::Duration;
// 1 minute on the dot — boundary, must render.
let chip = super::footer_worked_chip(Duration::from_secs(60));
let text: String = chip.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "worked 1m");
// 3h 12m — the issue's golden example.
let chip = super::footer_worked_chip(Duration::from_secs(11_550));
let text: String = chip.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "worked 3h 12m");
// Multi-day session — exercises the d/h band.
let chip = super::footer_worked_chip(Duration::from_secs(2 * 86_400 + 5 * 3600));
let text: String = chip.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "worked 2d 5h");
}
#[test]
fn from_app_loading_state_uses_thinking_label_and_warning_color() {
let app = make_app();