From 06bd9005abf5304b381b909f43846dfed2768cbd Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 8 Jan 2026 16:33:38 -0800 Subject: [PATCH] agent: Add turn statistics to agent panel (#46390) 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. --- 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(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index fdf4770b3d11f42283adfedb0ec4818726fe7bcc..9e7737ff1c2471666fba9e59eef01f0f7427b021 100644 --- a/assets/settings/default.json +++ b/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, diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 9f6a9625d1d63050d3c2f534f1d9288d277711ab..221357418760cbb306a5f8ca5d1726e857915543 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/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 { diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 908c5edf47e6f4e7873e0f37d0a70c69d4f1717b..389f1e7624bd5a94196a10d0395c3bbea7ac6fbc 100644 --- a/crates/agent/src/tests/mod.rs +++ b/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, }) ); }); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0095c572e1f7539b5ad3f00f0fba684565198541..dcb7712da74b2ad7830f7ec69e594805d079c179 100644 --- a/crates/agent/src/thread.rs +++ b/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, }) } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 77b428b47317e2cacce3fb0111b9d55cc64ec3ac..fe24a66a6cda0d648c052447f3fdede7ee2ae3e6 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/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), } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 067d23a6df87d551866c51d7b97f37c54825da52..1e17d79a0af7fe9465185068fa24e9944225852e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/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, skip_queue_processing_count: usize, user_interrupted_generation: bool, + turn_tokens: Option, + last_turn_tokens: Option, + turn_started_at: Option, + last_turn_duration: Option, + turn_generation: usize, + _turn_timer_task: Option>, } 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) -> 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, @@ -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, Vec>)>>>, + window: &mut Window, + cx: &mut Context, + ) { + 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) { @@ -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 diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 99fee5a877c8fd393ba5660580767488dc9ab0e5..703f3bab7b924a7cbb1df16d65b9d285ab9296e5 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/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| { diff --git a/crates/settings/src/settings_content/agent.rs b/crates/settings/src/settings_content/agent.rs index 7c97ff88b0539908db10a0ec37fb44afcafb8c1b..c152e0545eac1af9bede1f63b4329176b672aadc 100644 --- a/crates/settings/src/settings_content/agent.rs +++ b/crates/settings/src/settings_content/agent.rs @@ -122,6 +122,10 @@ pub struct AgentSettingsContent { /// /// Default: 4 pub message_editor_min_lines: Option, + /// Whether to show turn statistics (elapsed time during generation, final turn duration). + /// + /// Default: false + pub show_turn_stats: Option, /// 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.) diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 372ed1fae34412f1453e7cc6c4056b24a6c1603f..87ed27955dc7e4b672aee1ae2fad8d74b395b3ba 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/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, + }), ] } diff --git a/crates/util/src/time.rs b/crates/util/src/time.rs index 365de6f432b47311ae9b8c4d5ad36ff1f2852942..092d447dabca64e3c789ebd75b96a22c8226a293 100644 --- a/crates/util/src/time.rs +++ b/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))); } }