agent: Add turn statistics to agent panel (#46390)

Mikayla Maki created

I really enjoyed this feature in Claude Code. Helps me get a sense of
how effortful something is.

Release Notes:

- Added a "show_turn_stats" setting, default to false, that shows the
timer and the number of tokens down.

Change summary

assets/settings/default.json                  |   4 
crates/acp_thread/src/acp_thread.rs           |  18 +
crates/agent/src/tests/mod.rs                 |   4 
crates/agent/src/thread.rs                    |   1 
crates/agent_settings/src/agent_settings.rs   |   2 
crates/agent_ui/src/acp/thread_view.rs        | 343 ++++++++++++++------
crates/agent_ui/src/agent_ui.rs               |   1 
crates/settings/src/settings_content/agent.rs |   4 
crates/settings_ui/src/page_data.rs           |  20 +
crates/util/src/time.rs                       |  34 -
10 files changed, 295 insertions(+), 136 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1140,6 +1140,10 @@
     //
     // Default: 4
     "message_editor_min_lines": 4,
+    // Whether to show turn statistics (elapsed time during generation, final turn duration).
+    //
+    // Default: false
+    "show_turn_stats": false,
   },
   // Whether the screen sharing icon is shown in the os status bar.
   "show_call_status_icon": true,

crates/acp_thread/src/acp_thread.rs 🔗

@@ -835,6 +835,7 @@ impl PlanEntry {
 pub struct TokenUsage {
     pub max_tokens: u64,
     pub used_tokens: u64,
+    pub output_tokens: u64,
 }
 
 impl TokenUsage {
@@ -1186,6 +1187,23 @@ impl AcpThread {
         false
     }
 
+    pub fn has_in_progress_tool_calls(&self) -> bool {
+        for entry in self.entries.iter().rev() {
+            match entry {
+                AgentThreadEntry::UserMessage(_) => return false,
+                AgentThreadEntry::ToolCall(ToolCall {
+                    status: ToolCallStatus::InProgress | ToolCallStatus::Pending,
+                    ..
+                }) => {
+                    return true;
+                }
+                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+            }
+        }
+
+        false
+    }
+
     pub fn used_tools_since_last_user_message(&self) -> bool {
         for entry in self.entries.iter().rev() {
             match entry {

crates/agent/src/tests/mod.rs 🔗

@@ -2395,6 +2395,7 @@ async fn test_truncate_first_message(cx: &mut TestAppContext) {
             Some(acp_thread::TokenUsage {
                 used_tokens: 32_000 + 16_000,
                 max_tokens: 1_000_000,
+                output_tokens: 16_000,
             })
         );
     });
@@ -2454,6 +2455,7 @@ async fn test_truncate_first_message(cx: &mut TestAppContext) {
             Some(acp_thread::TokenUsage {
                 used_tokens: 40_000 + 20_000,
                 max_tokens: 1_000_000,
+                output_tokens: 20_000,
             })
         );
     });
@@ -2502,6 +2504,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) {
                 Some(acp_thread::TokenUsage {
                     used_tokens: 32_000 + 16_000,
                     max_tokens: 1_000_000,
+                    output_tokens: 16_000,
                 })
             );
         });
@@ -2556,6 +2559,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) {
             Some(acp_thread::TokenUsage {
                 used_tokens: 40_000 + 20_000,
                 max_tokens: 1_000_000,
+                output_tokens: 20_000,
             })
         );
     });

crates/agent/src/thread.rs 🔗

@@ -1119,6 +1119,7 @@ impl Thread {
         Some(acp_thread::TokenUsage {
             max_tokens: model.max_token_count_for_mode(self.completion_mode.into()),
             used_tokens: usage.total_tokens(),
+            output_tokens: usage.output_tokens,
         })
     }
 

crates/agent_settings/src/agent_settings.rs 🔗

@@ -49,6 +49,7 @@ pub struct AgentSettings {
     pub expand_terminal_card: bool,
     pub use_modifier_to_send: bool,
     pub message_editor_min_lines: usize,
+    pub show_turn_stats: bool,
     pub tool_permissions: ToolPermissions,
 }
 
@@ -256,6 +257,7 @@ impl Settings for AgentSettings {
             expand_terminal_card: agent.expand_terminal_card.unwrap(),
             use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
             message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
+            show_turn_stats: agent.show_turn_stats.unwrap(),
             tool_permissions: compile_tool_permissions(agent.tool_permissions),
         }
     }

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -54,6 +54,7 @@ use ui::{
     DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip,
     WithScrollbar, prelude::*, right_click_menu,
 };
+use util::defer;
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
 use zed_actions::agent::{Chat, ToggleModelSelector};
@@ -76,6 +77,9 @@ use crate::{
     SendNextQueuedMessage, ToggleBurnMode, ToggleProfileSelector,
 };
 
+const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(1);
+const TOKEN_THRESHOLD: u64 = 1;
+
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
 enum ThreadFeedback {
     Positive,
@@ -309,6 +313,12 @@ pub struct AcpThreadView {
     message_queue: Vec<QueuedMessage>,
     skip_queue_processing_count: usize,
     user_interrupted_generation: bool,
+    turn_tokens: Option<u64>,
+    last_turn_tokens: Option<u64>,
+    turn_started_at: Option<Instant>,
+    last_turn_duration: Option<Duration>,
+    turn_generation: usize,
+    _turn_timer_task: Option<Task<()>>,
 }
 
 struct QueuedMessage {
@@ -481,6 +491,12 @@ impl AcpThreadView {
             message_queue: Vec::new(),
             skip_queue_processing_count: 0,
             user_interrupted_generation: false,
+            turn_tokens: None,
+            last_turn_tokens: None,
+            turn_started_at: None,
+            last_turn_duration: None,
+            turn_generation: 0,
+            _turn_timer_task: None,
         }
     }
 
@@ -497,6 +513,11 @@ impl AcpThreadView {
         self.available_commands.replace(vec![]);
         self.new_server_version_available.take();
         self.message_queue.clear();
+        self.turn_tokens = None;
+        self.last_turn_tokens = None;
+        self.turn_started_at = None;
+        self.last_turn_duration = None;
+        self._turn_timer_task = None;
         cx.notify();
     }
 
@@ -1297,6 +1318,43 @@ impl AcpThreadView {
         .detach();
     }
 
+    fn start_turn(&mut self, cx: &mut Context<Self>) -> usize {
+        self.turn_generation += 1;
+        let generation = self.turn_generation;
+        self.turn_started_at = Some(Instant::now());
+        self.last_turn_duration = None;
+        self.last_turn_tokens = None;
+        self.turn_tokens = Some(0);
+        self._turn_timer_task = Some(cx.spawn(async move |this, cx| {
+            loop {
+                cx.background_executor().timer(Duration::from_secs(1)).await;
+                if this.update(cx, |_, cx| cx.notify()).is_err() {
+                    break;
+                }
+            }
+        }));
+        generation
+    }
+
+    fn stop_turn(&mut self, generation: usize) {
+        if self.turn_generation != generation {
+            return;
+        }
+        self.last_turn_duration = self.turn_started_at.take().map(|started| started.elapsed());
+        self.last_turn_tokens = self.turn_tokens.take();
+        self._turn_timer_task = None;
+    }
+
+    fn update_turn_tokens(&mut self, cx: &App) {
+        if let Some(thread) = self.thread() {
+            if let Some(usage) = thread.read(cx).token_usage() {
+                if let Some(ref mut tokens) = self.turn_tokens {
+                    *tokens += usage.output_tokens;
+                }
+            }
+        }
+    }
+
     fn send_impl(
         &mut self,
         message_editor: Entity<MessageEditor>,
@@ -1320,12 +1378,6 @@ impl AcpThreadView {
         self.editing_message.take();
         self.thread_feedback.clear();
 
-        let Some(thread) = self.thread() else {
-            return;
-        };
-        let session_id = thread.read(cx).session_id().clone();
-        let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
-        let thread = thread.downgrade();
         if self.should_be_following {
             self.workspace
                 .update(cx, |workspace, cx| {
@@ -1334,6 +1386,38 @@ impl AcpThreadView {
                 .ok();
         }
 
+        let contents_task = cx.spawn_in(window, async move |this, cx| {
+            let (contents, tracked_buffers) = contents.await?;
+
+            if contents.is_empty() {
+                return Ok(None);
+            }
+
+            this.update_in(cx, |this, window, cx| {
+                this.message_editor.update(cx, |message_editor, cx| {
+                    message_editor.clear(window, cx);
+                });
+            })?;
+
+            Ok(Some((contents, tracked_buffers)))
+        });
+
+        self.send_content(contents_task, window, cx);
+    }
+
+    fn send_content(
+        &mut self,
+        contents_task: Task<anyhow::Result<Option<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(thread) = self.thread() else {
+            return;
+        };
+        let session_id = thread.read(cx).session_id().clone();
+        let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
+        let thread = thread.downgrade();
+
         self.is_loading_contents = true;
         let model_id = self.current_model_id(cx);
         let mode_id = self.current_mode_id(cx);
@@ -1345,20 +1429,29 @@ impl AcpThreadView {
         .detach();
 
         let task = cx.spawn_in(window, async move |this, cx| {
-            let (contents, tracked_buffers) = contents.await?;
-
-            if contents.is_empty() {
+            let Some((contents, tracked_buffers)) = contents_task.await? else {
                 return Ok(());
-            }
+            };
 
-            this.update_in(cx, |this, window, cx| {
+            let generation = this.update_in(cx, |this, _window, cx| {
                 this.in_flight_prompt = Some(contents.clone());
+                let generation = this.start_turn(cx);
                 this.set_editor_is_expanded(false, cx);
                 this.scroll_to_bottom(cx);
-                this.message_editor.update(cx, |message_editor, cx| {
-                    message_editor.clear(window, cx);
-                });
+                generation
             })?;
+
+            let _stop_turn = defer({
+                let this = this.clone();
+                let mut cx = cx.clone();
+                move || {
+                    this.update(&mut cx, |this, cx| {
+                        this.stop_turn(generation);
+                        cx.notify();
+                    })
+                    .ok();
+                }
+            });
             let turn_start_time = Instant::now();
             let send = thread.update(cx, |thread, cx| {
                 thread.action_log().update(cx, |action_log, cx| {
@@ -1380,6 +1473,7 @@ impl AcpThreadView {
             })?;
             let res = send.await;
             let turn_time_ms = turn_start_time.elapsed().as_millis();
+            drop(_stop_turn);
             let status = if res.is_ok() {
                 this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
                 "success"
@@ -1495,100 +1589,23 @@ impl AcpThreadView {
         // Ensure we don't end up with multiple concurrent generations
         let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
 
-        let session_id = thread.read(cx).session_id().clone();
-        let agent_telemetry_id = thread.read(cx).connection().telemetry_id();
-        let thread = thread.downgrade();
-
         let should_be_following = self.should_be_following;
         let workspace = self.workspace.clone();
 
-        self.is_loading_contents = true;
-        let model_id = self.current_model_id(cx);
-        let mode_id = self.current_mode_id(cx);
-        let guard = cx.new(|_| ());
-
-        cx.observe_release(&guard, |this, _guard, cx| {
-            this.is_loading_contents = false;
-            cx.notify();
-        })
-        .detach();
-
-        let task = cx.spawn_in(window, async move |this, cx| {
+        let contents_task = cx.spawn_in(window, async move |_this, cx| {
             cancelled.await;
-            this.update_in(cx, |this, window, cx| {
-                if should_be_following {
-                    workspace
-                        .update(cx, |workspace, cx| {
-                            workspace.follow(CollaboratorId::Agent, window, cx);
-                        })
-                        .ok();
-                }
-
-                this.in_flight_prompt = Some(content.clone());
-                this.set_editor_is_expanded(false, cx);
-                this.scroll_to_bottom(cx);
-            })?;
-
-            let turn_start_time = Instant::now();
-            let send = thread.update(cx, |thread, cx| {
-                thread.action_log().update(cx, |action_log, cx| {
-                    for buffer in tracked_buffers {
-                        action_log.buffer_read(buffer, cx)
-                    }
-                });
-                drop(guard);
-
-                telemetry::event!(
-                    "Agent Message Sent",
-                    agent = agent_telemetry_id,
-                    session = session_id,
-                    model = model_id,
-                    mode = mode_id
-                );
-
-                thread.send(content, cx)
-            })?;
-
-            let res = send.await;
-            let turn_time_ms = turn_start_time.elapsed().as_millis();
-            let status = if res.is_ok() {
-                this.update(cx, |this, _| this.in_flight_prompt.take()).ok();
-                "success"
-            } else {
-                "failure"
-            };
+            if should_be_following {
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.follow(CollaboratorId::Agent, window, cx);
+                    })
+                    .ok();
+            }
 
-            telemetry::event!(
-                "Agent Turn Completed",
-                agent = agent_telemetry_id,
-                session = session_id,
-                model = model_id,
-                mode = mode_id,
-                status,
-                turn_time_ms,
-            );
-            res
+            Ok(Some((content, tracked_buffers)))
         });
 
-        cx.spawn(async move |this, cx| {
-            if let Err(err) = task.await {
-                this.update(cx, |this, cx| {
-                    this.handle_thread_error(err, cx);
-                })
-                .ok();
-            } else {
-                this.update(cx, |this, cx| {
-                    this.should_be_following = this
-                        .workspace
-                        .update(cx, |workspace, _| {
-                            workspace.is_being_followed(CollaboratorId::Agent)
-                        })
-                        .unwrap_or_default();
-                })
-                .ok();
-            }
-        })
-        .detach();
+        self.send_content(contents_task, window, cx);
     }
 
     fn cancel_editing(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
@@ -1838,7 +1855,9 @@ impl AcpThreadView {
                 self.prompt_capabilities
                     .replace(thread.read(cx).prompt_capabilities());
             }
-            AcpThreadEvent::TokenUsageUpdated => {}
+            AcpThreadEvent::TokenUsageUpdated => {
+                self.update_turn_tokens(cx);
+            }
             AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
                 let mut available_commands = available_commands.clone();
 
@@ -2606,7 +2625,7 @@ impl AcpThreadView {
                 .child(primary)
                 .map(|this| {
                     if needs_confirmation {
-                        this.child(self.render_generating(true))
+                        this.child(self.render_generating(true, cx))
                     } else {
                         this.child(self.render_thread_controls(&thread, cx))
                     }
@@ -5899,28 +5918,83 @@ impl AcpThreadView {
         }
     }
 
-    fn render_generating(&self, confirmation: bool) -> impl IntoElement {
+    fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement {
+        let show_stats = AgentSettings::get_global(cx).show_turn_stats;
+        let elapsed_label = show_stats
+            .then(|| {
+                self.turn_started_at.and_then(|started_at| {
+                    let elapsed = started_at.elapsed();
+                    (elapsed > STOPWATCH_THRESHOLD).then(|| duration_alt_display(elapsed))
+                })
+            })
+            .flatten();
+
+        let is_waiting = confirmation
+            || self
+                .thread()
+                .is_some_and(|thread| thread.read(cx).has_in_progress_tool_calls());
+
+        let turn_tokens_label = elapsed_label
+            .is_some()
+            .then(|| {
+                self.turn_tokens
+                    .filter(|&tokens| tokens > TOKEN_THRESHOLD)
+                    .map(|tokens| crate::text_thread_editor::humanize_token_count(tokens))
+            })
+            .flatten();
+
+        let arrow_icon = if is_waiting {
+            IconName::ArrowUp
+        } else {
+            IconName::ArrowDown
+        };
+
         h_flex()
             .id("generating-spinner")
             .py_2()
             .px(rems_from_px(22.))
+            .gap_2()
             .map(|this| {
                 if confirmation {
-                    this.gap_2()
-                        .child(
-                            h_flex()
-                                .w_2()
-                                .child(SpinnerLabel::sand().size(LabelSize::Small)),
-                        )
-                        .child(
+                    this.child(
+                        h_flex()
+                            .w_2()
+                            .child(SpinnerLabel::sand().size(LabelSize::Small)),
+                    )
+                    .child(
+                        div().min_w(rems(8.)).child(
                             LoadingLabel::new("Waiting Confirmation")
                                 .size(LabelSize::Small)
                                 .color(Color::Muted),
-                        )
+                        ),
+                    )
                 } else {
                     this.child(SpinnerLabel::new().size(LabelSize::Small))
                 }
             })
+            .when_some(elapsed_label, |this, elapsed| {
+                this.child(
+                    Label::new(elapsed)
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                )
+            })
+            .when_some(turn_tokens_label, |this, tokens| {
+                this.child(
+                    h_flex()
+                        .gap_0p5()
+                        .child(
+                            Icon::new(arrow_icon)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
+                        .child(
+                            Label::new(format!("{} tokens", tokens))
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        ),
+                )
+            })
             .into_any_element()
     }
 
@@ -5931,7 +6005,7 @@ impl AcpThreadView {
     ) -> impl IntoElement {
         let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
         if is_generating {
-            return self.render_generating(false).into_any_element();
+            return self.render_generating(false, cx).into_any_element();
         }
 
         let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
@@ -5965,6 +6039,35 @@ impl AcpThreadView {
                 this.scroll_to_top(cx);
             }));
 
+        let show_stats = AgentSettings::get_global(cx).show_turn_stats;
+        let last_turn_clock = show_stats
+            .then(|| {
+                self.last_turn_duration
+                    .filter(|&duration| duration > STOPWATCH_THRESHOLD)
+                    .map(|duration| {
+                        Label::new(duration_alt_display(duration))
+                            .size(LabelSize::Small)
+                            .color(Color::Muted)
+                    })
+            })
+            .flatten();
+
+        let last_turn_tokens = last_turn_clock
+            .is_some()
+            .then(|| {
+                self.last_turn_tokens
+                    .filter(|&tokens| tokens > TOKEN_THRESHOLD)
+                    .map(|tokens| {
+                        Label::new(format!(
+                            "{} tokens",
+                            crate::text_thread_editor::humanize_token_count(tokens)
+                        ))
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                    })
+            })
+            .flatten();
+
         let mut container = h_flex()
             .w_full()
             .py_2()
@@ -5972,7 +6075,19 @@ impl AcpThreadView {
             .gap_px()
             .opacity(0.6)
             .hover(|s| s.opacity(1.))
-            .justify_end();
+            .justify_end()
+            .when(
+                last_turn_tokens.is_some() || last_turn_clock.is_some(),
+                |this| {
+                    this.child(
+                        h_flex()
+                            .gap_1()
+                            .px_1()
+                            .when_some(last_turn_tokens, |this, label| this.child(label))
+                            .when_some(last_turn_clock, |this, label| this.child(label)),
+                    )
+                },
+            );
 
         if AgentSettings::get_global(cx).enable_feedback
             && self

crates/agent_ui/src/agent_ui.rs 🔗

@@ -483,6 +483,7 @@ mod tests {
             use_modifier_to_send: true,
             message_editor_min_lines: 1,
             tool_permissions: Default::default(),
+            show_turn_stats: false,
         };
 
         cx.update(|cx| {

crates/settings/src/settings_content/agent.rs 🔗

@@ -122,6 +122,10 @@ pub struct AgentSettingsContent {
     ///
     /// Default: 4
     pub message_editor_min_lines: Option<usize>,
+    /// Whether to show turn statistics (elapsed time during generation, final turn duration).
+    ///
+    /// Default: false
+    pub show_turn_stats: Option<bool>,
     /// Per-tool permission rules for granular control over which tool actions require confirmation.
     ///
     /// This setting only applies to the native Zed agent. External agent servers (Claude Code, Gemini CLI, etc.)

crates/settings_ui/src/page_data.rs 🔗

@@ -6584,7 +6584,7 @@ fn ai_page() -> SettingsPage {
         ]
     }
 
-    fn agent_configuration_section() -> [SettingsPageItem; 10] {
+    fn agent_configuration_section() -> [SettingsPageItem; 11] {
         [
             SettingsPageItem::SectionHeader("Agent Configuration"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -6773,6 +6773,24 @@ fn ai_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Show Turn Stats",
+                description: "Whether to show turn statistics like elapsed time during generation and final turn duration.",
+                field: Box::new(SettingField {
+                    json_path: Some("agent.show_turn_stats"),
+                    pick: |settings_content| {
+                        settings_content.agent.as_ref()?.show_turn_stats.as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .agent
+                            .get_or_insert_default()
+                            .show_turn_stats = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
         ]
     }
 

crates/util/src/time.rs 🔗

@@ -1,24 +1,16 @@
 use std::time::Duration;
 
 pub fn duration_alt_display(duration: Duration) -> String {
-    if duration < Duration::from_secs(60) {
-        format!("{}s", duration.as_secs())
-    } else {
-        duration_clock_format(duration)
-    }
-}
-
-fn duration_clock_format(duration: Duration) -> String {
     let hours = duration.as_secs() / 3600;
     let minutes = (duration.as_secs() % 3600) / 60;
     let seconds = duration.as_secs() % 60;
 
     if hours > 0 {
-        format!("{hours}:{minutes:02}:{seconds:02}")
+        format!("{hours}h {minutes}m {seconds}s")
     } else if minutes > 0 {
-        format!("{minutes}:{seconds:02}")
+        format!("{minutes}m {seconds}s")
     } else {
-        format!("{seconds}")
+        format!("{seconds}s")
     }
 }
 
@@ -27,15 +19,15 @@ mod tests {
     use super::*;
 
     #[test]
-    fn test_duration_to_clock_format() {
-        use duration_clock_format as f;
-        assert_eq!("0", f(Duration::from_secs(0)));
-        assert_eq!("59", f(Duration::from_secs(59)));
-        assert_eq!("1:00", f(Duration::from_secs(60)));
-        assert_eq!("10:00", f(Duration::from_secs(600)));
-        assert_eq!("1:00:00", f(Duration::from_secs(3600)));
-        assert_eq!("3:02:01", f(Duration::from_secs(3600 * 3 + 60 * 2 + 1)));
-        assert_eq!("23:59:59", f(Duration::from_secs(3600 * 24 - 1)));
-        assert_eq!("100:00:00", f(Duration::from_secs(3600 * 100)));
+    fn test_duration_alt_display() {
+        use duration_alt_display as f;
+        assert_eq!("0s", f(Duration::from_secs(0)));
+        assert_eq!("59s", f(Duration::from_secs(59)));
+        assert_eq!("1m 0s", f(Duration::from_secs(60)));
+        assert_eq!("10m 0s", f(Duration::from_secs(600)));
+        assert_eq!("1h 0m 0s", f(Duration::from_secs(3600)));
+        assert_eq!("3h 2m 1s", f(Duration::from_secs(3600 * 3 + 60 * 2 + 1)));
+        assert_eq!("23h 59m 59s", f(Duration::from_secs(3600 * 24 - 1)));
+        assert_eq!("100h 0m 0s", f(Duration::from_secs(3600 * 100)));
     }
 }