Skip to main content

vtcode_commons/
message_timestamp.rs

1//! Wall-clock timestamps and turn durations for the final agent response of a
2//! turn.
3//!
4//! Each completed turn carries one short local-time footer (e.g. `· 3:12 PM`,
5//! optionally `· 3:12 PM · 13m 24s`) after its final agent message, so the
6//! clock marks the turn's summary point instead of repeating on every
7//! intermediate response. Formatting lives here so the TUI transcript, plain
8//! CLI output, and future desktop/export surfaces share one shape.
9
10use std::time::Duration;
11
12use chrono::{DateTime, Local};
13
14/// Format a local timestamp the way agent footers display it (`3:12 PM`).
15///
16/// Twelve-hour clock without seconds or leading zeros, matching the chat-style
17/// reference. Takes the instant as a parameter so tests stay deterministic.
18pub fn format_message_time(now: DateTime<Local>) -> String {
19    now.format("%-I:%M %p").to_string()
20}
21
22/// Format a turn duration the way footers and outcome lines display it.
23///
24/// Compact clock symbols with zero parts omitted: `42s`, `13m 24s`, `1h 5m`.
25/// The desktop app renders the same tiers through its locale catalogs; this
26/// plain-symbol shape serves the CLI surfaces that have no i18n layer.
27pub fn format_turn_elapsed(duration: Duration) -> String {
28    const MINUTE: u64 = 60;
29    const HOUR: u64 = 3600;
30    let seconds = duration.as_secs();
31    if seconds < MINUTE {
32        return format!("{seconds}s");
33    }
34    if seconds < HOUR {
35        let minutes = seconds / MINUTE;
36        let rest = seconds % MINUTE;
37        if rest == 0 {
38            return format!("{minutes}m");
39        }
40        return format!("{minutes}m {rest}s");
41    }
42    let hours = seconds / HOUR;
43    let minutes = (seconds % HOUR) / MINUTE;
44    if minutes == 0 {
45        return format!("{hours}h");
46    }
47    format!("{hours}h {minutes}m")
48}
49
50/// Build the full footer line for a turn's final agent response
51/// (`· 3:12 PM`, plus `· 13m 24s` when the caller measured the turn).
52///
53/// The leading middot marks the line as chrome in no-color output where
54/// dimming is unavailable; the elapsed middot keeps both halves one chrome
55/// unit so reflow and tail-matching treat them together.
56pub fn message_timestamp_footer(now: DateTime<Local>, elapsed: Option<Duration>) -> String {
57    match elapsed {
58        Some(duration) => format!("· {} · {}", format_message_time(now), format_turn_elapsed(duration)),
59        None => format!("· {}", format_message_time(now)),
60    }
61}
62
63/// Build the footer for the current local time, without an elapsed half.
64pub fn current_message_timestamp_footer() -> String {
65    message_timestamp_footer(Local::now(), None)
66}
67
68/// Build the footer for the current local time with the measured turn
69/// duration appended when the caller has one.
70pub fn current_message_timestamp_footer_with_elapsed(elapsed: Option<Duration>) -> String {
71    message_timestamp_footer(Local::now(), elapsed)
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use chrono::TimeZone;
78
79    fn local_at(hour: u32, minute: u32) -> DateTime<Local> {
80        Local
81            .with_ymd_and_hms(2026, 11, 2, hour, minute, 0)
82            .single()
83            .expect("fixture timestamp must be unambiguous")
84    }
85
86    #[test]
87    fn formats_afternoon_without_leading_zero() {
88        assert_eq!(format_message_time(local_at(15, 12)), "3:12 PM");
89    }
90
91    #[test]
92    fn formats_single_digit_morning_hour() {
93        assert_eq!(format_message_time(local_at(9, 5)), "9:05 AM");
94    }
95
96    #[test]
97    fn formats_double_digit_morning_hour() {
98        assert_eq!(format_message_time(local_at(11, 55)), "11:55 AM");
99    }
100
101    #[test]
102    fn midnight_is_twelve_am_not_zero() {
103        assert_eq!(format_message_time(local_at(0, 5)), "12:05 AM");
104    }
105
106    #[test]
107    fn noon_is_twelve_pm_not_zero() {
108        assert_eq!(format_message_time(local_at(12, 0)), "12:00 PM");
109    }
110
111    #[test]
112    fn footer_prefixes_time_with_middot() {
113        assert_eq!(message_timestamp_footer(local_at(15, 12), None), "· 3:12 PM");
114    }
115
116    #[test]
117    fn footer_appends_elapsed_after_the_clock() {
118        assert_eq!(message_timestamp_footer(local_at(15, 12), Some(Duration::from_secs(804))), "· 3:12 PM · 13m 24s");
119        assert_eq!(message_timestamp_footer(local_at(15, 12), Some(Duration::from_secs(3900))), "· 3:12 PM · 1h 5m");
120    }
121
122    #[test]
123    fn current_footer_with_elapsed_keeps_one_chrome_unit() {
124        let footer = current_message_timestamp_footer_with_elapsed(Some(Duration::from_secs(59)));
125        let time = footer.strip_prefix("· ").expect("current footer must carry the middot");
126        let mut halves = time.split(" · ");
127        let clock = halves.next().expect("clock half must exist");
128        assert!(clock.contains(':'), "clock half must show clock time: {footer:?}");
129        assert!(clock.ends_with("AM") || clock.ends_with("PM"), "clock half must show meridiem: {footer:?}");
130        assert_eq!(halves.next(), Some("59s"), "elapsed half must follow the clock");
131        assert!(halves.next().is_none(), "footer must stay one clock plus one duration");
132    }
133
134    #[test]
135    fn elapsed_symbols_omit_zero_parts() {
136        assert_eq!(format_turn_elapsed(Duration::from_secs(0)), "0s");
137        assert_eq!(format_turn_elapsed(Duration::from_secs(59)), "59s");
138        assert_eq!(format_turn_elapsed(Duration::from_secs(60)), "1m");
139        assert_eq!(format_turn_elapsed(Duration::from_secs(90)), "1m 30s");
140        assert_eq!(format_turn_elapsed(Duration::from_secs(3600)), "1h");
141        assert_eq!(format_turn_elapsed(Duration::from_secs(3900)), "1h 5m");
142    }
143
144    #[test]
145    fn current_footer_has_footer_shape() {
146        let footer = current_message_timestamp_footer();
147        let time = footer.strip_prefix("· ").expect("current footer must carry the middot");
148        assert!(time.contains(':'), "current footer must show clock time: {footer:?}");
149        assert!(time.ends_with("AM") || time.ends_with("PM"), "current footer must show meridiem: {footer:?}");
150    }
151}