From a72ade87629720eebb49af38b23684c1429f5686 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 5 May 2025 10:22:36 -0400 Subject: [PATCH] Show prompt usage in agent overflow menu (#29922) This PR adds prompt usage information, and easy access to managing your account, to the agent overflow menu: ![CleanShot 2025-05-05 at 10 04 20@2x](https://github.com/user-attachments/assets/337a1a0b-6f71-49a0-9fe7-4fbf2ec1fc27) Currently this UI will only show after making a request. We'll work on eagerly getting the usage info later. Release Notes: - Added current prompt usage information to the agent menu (`...`) for Zed AI users --- crates/agent/src/assistant_panel.rs | 94 ++++++++++++++----- crates/ui/src/components/context_menu.rs | 36 +++++++ .../ui/src/components/list/list_sub_header.rs | 10 +- 3 files changed, 116 insertions(+), 24 deletions(-) diff --git a/crates/agent/src/assistant_panel.rs b/crates/agent/src/assistant_panel.rs index 4017bced75..4596005b29 100644 --- a/crates/agent/src/assistant_panel.rs +++ b/crates/agent/src/assistant_panel.rs @@ -34,13 +34,15 @@ use rules_library::{RulesLibrary, open_rules_library}; use settings::{Settings, update_settings_file}; use time::UtcOffset; use ui::{ - Banner, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, + Banner, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip, + prelude::*, }; use util::ResultExt as _; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::OpenConfiguration; use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus}; +use zed_llm_client::UsageLimit; use crate::active_thread::{ActiveThread, ActiveThreadEvent}; use crate::agent_diff::AgentDiff; @@ -1369,6 +1371,8 @@ impl AssistantPanel { let thread = active_thread.thread().read(cx); let thread_id = thread.id().clone(); let is_empty = active_thread.is_empty(); + let last_usage = active_thread.thread().read(cx).last_usage(); + let account_url = zed_urls::account_url(cx); let show_token_count = match &self.active_view { ActiveView::Thread { .. } => !is_empty, @@ -1454,30 +1458,74 @@ impl AssistantPanel { .anchor(Corner::TopRight) .with_handle(self.assistant_dropdown_menu_handle.clone()) .menu(move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.when(!is_empty, |menu| { - menu.action( - "Start New From Summary", - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), + Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| { + menu = menu + .action("New Thread", NewThread::default().boxed_clone()) + .action("New Text Thread", NewTextThread.boxed_clone()) + .when(!is_empty, |menu| { + menu.action( + "New From Summary", + Box::new(NewThread { + from_thread_id: Some(thread_id.clone()), + }), + ) + }) + .separator(); + + menu = menu + .header("MCP Servers") + .action( + "View Server Extensions", + Box::new(zed_actions::Extensions { + category_filter: Some( + zed_actions::ExtensionCategoryFilter::ContextServers, + ), }), ) - .separator() - }) - .action("New Text Thread", NewTextThread.boxed_clone()) - .action("Rules Library", Box::new(OpenRulesLibrary::default())) - .action("Settings", Box::new(OpenConfiguration)) - .separator() - .header("MCPs") - .action( - "View Server Extensions", - Box::new(zed_actions::Extensions { - category_filter: Some( - zed_actions::ExtensionCategoryFilter::ContextServers, - ), - }), - ) - .action("Add Custom Server", Box::new(AddContextServer)) + .action("Add Custom Server…", Box::new(AddContextServer)) + .separator(); + + if let Some(usage) = last_usage { + menu = menu + .header_with_link("Prompt Usage", "Manage", account_url.clone()) + .custom_entry( + move |_window, cx| { + let used_percentage = match usage.limit { + UsageLimit::Limited(limit) => { + Some((usage.amount as f32 / limit as f32) * 100.) + } + UsageLimit::Unlimited => None, + }; + + h_flex() + .flex_1() + .gap_1p5() + .children(used_percentage.map(|percent| { + ProgressBar::new("usage", percent, 100., cx) + })) + .child( + Label::new(match usage.limit { + UsageLimit::Limited(limit) => { + format!("{} / {limit}", usage.amount) + } + UsageLimit::Unlimited => { + format!("{} / ∞", usage.amount) + } + }) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + }, + move |_, cx| cx.open_url(&zed_urls::account_url(cx)), + ) + .separator() + } + + menu = menu + .action("Rules…", Box::new(OpenRulesLibrary::default())) + .action("Settings", Box::new(OpenConfiguration)); + menu })) }); diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 4f6703c566..191e26ec73 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -16,6 +16,8 @@ use super::Tooltip; pub enum ContextMenuItem { Separator, Header(SharedString), + /// title, link_label, link_url + HeaderWithLink(SharedString, SharedString, SharedString), // This could be folded into header Label(SharedString), Entry(ContextMenuEntry), CustomEntry { @@ -332,6 +334,20 @@ impl ContextMenu { self } + pub fn header_with_link( + mut self, + title: impl Into, + link_label: impl Into, + link_url: impl Into, + ) -> Self { + self.items.push(ContextMenuItem::HeaderWithLink( + title.into(), + link_label.into(), + link_url.into(), + )); + self + } + pub fn separator(mut self) -> Self { self.items.push(ContextMenuItem::Separator); self @@ -788,6 +804,25 @@ impl ContextMenu { ContextMenuItem::Header(header) => ListSubHeader::new(header.clone()) .inset(true) .into_any_element(), + ContextMenuItem::HeaderWithLink(header, label, url) => { + let url = url.clone(); + let link_id = ElementId::Name(format!("link-{}", url).into()); + ListSubHeader::new(header.clone()) + .inset(true) + .end_slot( + Button::new(link_id, label.clone()) + .color(Color::Muted) + .label_size(LabelSize::Small) + .size(ButtonSize::None) + .style(ButtonStyle::Transparent) + .on_click(move |_, _, cx| { + let url = url.clone(); + cx.open_url(&url); + }) + .into_any_element(), + ) + .into_any_element() + } ContextMenuItem::Label(label) => ListItem::new(ix) .inset(true) .disabled(true) @@ -1057,6 +1092,7 @@ impl ContextMenuItem { fn is_selectable(&self) -> bool { match self { ContextMenuItem::Header(_) + | ContextMenuItem::HeaderWithLink(_, _, _) | ContextMenuItem::Separator | ContextMenuItem::Label { .. } => false, ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled, diff --git a/crates/ui/src/components/list/list_sub_header.rs b/crates/ui/src/components/list/list_sub_header.rs index a1f04af576..e6f5abfe0a 100644 --- a/crates/ui/src/components/list/list_sub_header.rs +++ b/crates/ui/src/components/list/list_sub_header.rs @@ -5,6 +5,7 @@ use crate::{Icon, IconName, IconSize, Label, h_flex}; pub struct ListSubHeader { label: SharedString, start_slot: Option, + end_slot: Option, inset: bool, selected: bool, } @@ -14,6 +15,7 @@ impl ListSubHeader { Self { label: label.into(), start_slot: None, + end_slot: None, inset: false, selected: false, } @@ -24,6 +26,11 @@ impl ListSubHeader { self } + pub fn end_slot(mut self, end_slot: AnyElement) -> Self { + self.end_slot = Some(end_slot); + self + } + pub fn inset(mut self, inset: bool) -> Self { self.inset = inset; self @@ -73,7 +80,8 @@ impl RenderOnce for ListSubHeader { .color(Color::Muted) .size(LabelSize::Small), ), - ), + ) + .children(self.end_slot), ) } }