Show prompt usage in agent overflow menu (#29922)

Nate Butler created

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

Change summary

crates/agent/src/assistant_panel.rs              | 94 +++++++++++++----
crates/ui/src/components/context_menu.rs         | 36 ++++++
crates/ui/src/components/list/list_sub_header.rs | 10 +
3 files changed, 116 insertions(+), 24 deletions(-)

Detailed changes

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
                 }))
             });
 

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<SharedString>,
+        link_label: impl Into<SharedString>,
+        link_url: impl Into<SharedString>,
+    ) -> 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,

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<IconName>,
+    end_slot: Option<AnyElement>,
     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),
             )
     }
 }