Skip to main content

linesmith_core/segments/
session_duration.rs

1//! Session duration segment: renders elapsed session time in compact
2//! `Nh Nm` / `Nm Ns` / `Ns` form. The `total_duration_ms` field lives
3//! inside the input schema's `cost` block, so the segment hides
4//! whenever `cost` is absent.
5
6use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
7use crate::data_context::DataContext;
8use crate::theme::Role;
9
10pub struct SessionDurationSegment;
11
12/// Same bucket as `cost` (192) and the `tokens_*` family: passive
13/// metadata that yields first under width pressure. Position order
14/// breaks the tie among segments at this priority.
15const PRIORITY: u8 = 192;
16
17impl Segment for SessionDurationSegment {
18    fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
19        let Some(cost) = ctx.status.cost.as_ref() else {
20            crate::lsm_debug!("session_duration: status.cost absent; hiding");
21            return Ok(None);
22        };
23        let Some(ms) = cost.total_duration_ms else {
24            crate::lsm_debug!("session_duration: total_duration_ms null; hiding");
25            return Ok(None);
26        };
27        Ok(Some(
28            RenderedSegment::new(format_duration(ms)).with_role(Role::Muted),
29        ))
30    }
31
32    fn defaults(&self) -> SegmentDefaults {
33        SegmentDefaults::with_priority(PRIORITY)
34    }
35}
36
37fn format_duration(ms: u64) -> String {
38    let total_secs = ms / 1000;
39    let h = total_secs / 3600;
40    let m = (total_secs % 3600) / 60;
41    let s = total_secs % 60;
42    if h > 0 {
43        if m > 0 {
44            format!("{h}h {m}m")
45        } else {
46            format!("{h}h")
47        }
48    } else if m > 0 {
49        if s > 0 {
50            format!("{m}m {s}s")
51        } else {
52            format!("{m}m")
53        }
54    } else {
55        format!("{s}s")
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::input::{CostMetrics, ModelInfo, StatusContext, Tool, WorkspaceInfo};
63    use std::path::PathBuf;
64    use std::sync::Arc;
65
66    fn rc() -> RenderContext {
67        RenderContext::new(80)
68    }
69
70    fn ctx(cost: Option<CostMetrics>) -> DataContext {
71        DataContext::new(StatusContext {
72            tool: Tool::ClaudeCode,
73            model: Some(ModelInfo {
74                display_name: "X".into(),
75            }),
76            workspace: Some(WorkspaceInfo {
77                project_dir: PathBuf::from("/repo"),
78                git_worktree: None,
79            }),
80            context_window: None,
81            cost,
82            effort: None,
83            vim: None,
84            output_style: None,
85            agent_name: None,
86            version: None,
87            raw: Arc::new(serde_json::Value::Null),
88        })
89    }
90
91    fn cost_of(duration_ms: u64) -> CostMetrics {
92        CostMetrics {
93            total_cost_usd: Some(0.0),
94            total_duration_ms: Some(duration_ms),
95            total_api_duration_ms: Some(0),
96            total_lines_added: Some(0),
97            total_lines_removed: Some(0),
98        }
99    }
100
101    fn render_for(duration_ms: u64) -> Option<RenderedSegment> {
102        SessionDurationSegment
103            .render(&ctx(Some(cost_of(duration_ms))), &rc())
104            .unwrap()
105    }
106
107    #[test]
108    fn renders_zero_seconds() {
109        assert_eq!(
110            render_for(0),
111            Some(RenderedSegment::new("0s").with_role(Role::Muted))
112        );
113    }
114
115    #[test]
116    fn renders_sub_minute_as_seconds_only() {
117        assert_eq!(render_for(5_000).unwrap().text(), "5s");
118        assert_eq!(render_for(59_999).unwrap().text(), "59s");
119    }
120
121    #[test]
122    fn renders_exact_minute_without_trailing_zero_seconds() {
123        assert_eq!(render_for(60_000).unwrap().text(), "1m");
124    }
125
126    #[test]
127    fn renders_minutes_and_seconds() {
128        assert_eq!(render_for(65_000).unwrap().text(), "1m 5s");
129        assert_eq!(render_for(125_500).unwrap().text(), "2m 5s");
130    }
131
132    #[test]
133    fn renders_just_below_an_hour() {
134        // 3_599_999ms is one ms shy of an hour; integer division gives
135        // 3599s = 59m 59s, the upper edge of the minute tier.
136        assert_eq!(render_for(3_599_999).unwrap().text(), "59m 59s");
137    }
138
139    #[test]
140    fn renders_exact_hour_without_trailing_zero_minutes() {
141        assert_eq!(render_for(3_600_000).unwrap().text(), "1h");
142    }
143
144    #[test]
145    fn renders_hours_and_minutes_drops_seconds() {
146        // 1h 2m 5s — seconds intentionally dropped at the hour tier so
147        // the segment stays compact under width pressure.
148        assert_eq!(render_for(3_725_000).unwrap().text(), "1h 2m");
149    }
150
151    #[test]
152    fn renders_long_sessions_at_hour_resolution() {
153        assert_eq!(render_for(86_400_000).unwrap().text(), "24h");
154        assert_eq!(render_for(91_800_000).unwrap().text(), "25h 30m");
155    }
156
157    #[test]
158    fn renders_hour_with_seconds_only_drops_them() {
159        // 3_630_000ms = 1h 0m 30s. Seconds must drop at the hour tier
160        // even when minutes are zero; pulling them back into the
161        // m == 0 branch (e.g. `format!("{h}h {s}s")`) would render
162        // `"1h 30s"` instead.
163        assert_eq!(render_for(3_630_000).unwrap().text(), "1h");
164    }
165
166    #[test]
167    fn renders_with_muted_role() {
168        assert_eq!(render_for(5_000).unwrap().style().role, Some(Role::Muted));
169    }
170
171    #[test]
172    fn hidden_when_cost_absent() {
173        assert_eq!(
174            SessionDurationSegment.render(&ctx(None), &rc()).unwrap(),
175            None
176        );
177    }
178
179    #[test]
180    fn defaults_use_expected_priority() {
181        assert_eq!(SessionDurationSegment.defaults().priority, PRIORITY);
182    }
183
184    #[test]
185    fn sub_second_durations_floor_to_zero_seconds() {
186        // Rounding-down across the 1-second boundary is the natural
187        // behavior of integer division; pin it so a future refactor
188        // doesn't switch to nearest-second and produce "1s" for 999ms.
189        assert_eq!(render_for(999).unwrap().text(), "0s");
190    }
191}