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
This commit is contained in:
Nate Butler 2025-05-05 10:22:36 -04:00 committed by GitHub
parent 1c44cabaea
commit a72ade8762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 116 additions and 24 deletions

View file

@ -34,13 +34,15 @@ use rules_library::{RulesLibrary, open_rules_library};
use settings::{Settings, update_settings_file}; use settings::{Settings, update_settings_file};
use time::UtcOffset; use time::UtcOffset;
use ui::{ use ui::{
Banner, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, Banner, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip,
prelude::*,
}; };
use util::ResultExt as _; use util::ResultExt as _;
use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::dock::{DockPosition, Panel, PanelEvent};
use workspace::{CollaboratorId, Workspace}; use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::OpenConfiguration; use zed_actions::agent::OpenConfiguration;
use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus}; use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
use zed_llm_client::UsageLimit;
use crate::active_thread::{ActiveThread, ActiveThreadEvent}; use crate::active_thread::{ActiveThread, ActiveThreadEvent};
use crate::agent_diff::AgentDiff; use crate::agent_diff::AgentDiff;
@ -1369,6 +1371,8 @@ impl AssistantPanel {
let thread = active_thread.thread().read(cx); let thread = active_thread.thread().read(cx);
let thread_id = thread.id().clone(); let thread_id = thread.id().clone();
let is_empty = active_thread.is_empty(); 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 { let show_token_count = match &self.active_view {
ActiveView::Thread { .. } => !is_empty, ActiveView::Thread { .. } => !is_empty,
@ -1454,30 +1458,74 @@ impl AssistantPanel {
.anchor(Corner::TopRight) .anchor(Corner::TopRight)
.with_handle(self.assistant_dropdown_menu_handle.clone()) .with_handle(self.assistant_dropdown_menu_handle.clone())
.menu(move |window, cx| { .menu(move |window, cx| {
Some(ContextMenu::build(window, cx, |menu, _window, _cx| { Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| {
menu.when(!is_empty, |menu| { menu = menu
menu.action( .action("New Thread", NewThread::default().boxed_clone())
"Start New From Summary", .action("New Text Thread", NewTextThread.boxed_clone())
Box::new(NewThread { .when(!is_empty, |menu| {
from_thread_id: Some(thread_id.clone()), 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("Add Custom Server…", Box::new(AddContextServer))
}) .separator();
.action("New Text Thread", NewTextThread.boxed_clone())
.action("Rules Library", Box::new(OpenRulesLibrary::default())) if let Some(usage) = last_usage {
.action("Settings", Box::new(OpenConfiguration)) menu = menu
.separator() .header_with_link("Prompt Usage", "Manage", account_url.clone())
.header("MCPs") .custom_entry(
.action( move |_window, cx| {
"View Server Extensions", let used_percentage = match usage.limit {
Box::new(zed_actions::Extensions { UsageLimit::Limited(limit) => {
category_filter: Some( Some((usage.amount as f32 / limit as f32) * 100.)
zed_actions::ExtensionCategoryFilter::ContextServers, }
), UsageLimit::Unlimited => None,
}), };
)
.action("Add Custom Server", Box::new(AddContextServer)) 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
})) }))
}); });

View file

@ -16,6 +16,8 @@ use super::Tooltip;
pub enum ContextMenuItem { pub enum ContextMenuItem {
Separator, Separator,
Header(SharedString), Header(SharedString),
/// title, link_label, link_url
HeaderWithLink(SharedString, SharedString, SharedString), // This could be folded into header
Label(SharedString), Label(SharedString),
Entry(ContextMenuEntry), Entry(ContextMenuEntry),
CustomEntry { CustomEntry {
@ -332,6 +334,20 @@ impl ContextMenu {
self 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 { pub fn separator(mut self) -> Self {
self.items.push(ContextMenuItem::Separator); self.items.push(ContextMenuItem::Separator);
self self
@ -788,6 +804,25 @@ impl ContextMenu {
ContextMenuItem::Header(header) => ListSubHeader::new(header.clone()) ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
.inset(true) .inset(true)
.into_any_element(), .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) ContextMenuItem::Label(label) => ListItem::new(ix)
.inset(true) .inset(true)
.disabled(true) .disabled(true)
@ -1057,6 +1092,7 @@ impl ContextMenuItem {
fn is_selectable(&self) -> bool { fn is_selectable(&self) -> bool {
match self { match self {
ContextMenuItem::Header(_) ContextMenuItem::Header(_)
| ContextMenuItem::HeaderWithLink(_, _, _)
| ContextMenuItem::Separator | ContextMenuItem::Separator
| ContextMenuItem::Label { .. } => false, | ContextMenuItem::Label { .. } => false,
ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled, ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,

View file

@ -5,6 +5,7 @@ use crate::{Icon, IconName, IconSize, Label, h_flex};
pub struct ListSubHeader { pub struct ListSubHeader {
label: SharedString, label: SharedString,
start_slot: Option<IconName>, start_slot: Option<IconName>,
end_slot: Option<AnyElement>,
inset: bool, inset: bool,
selected: bool, selected: bool,
} }
@ -14,6 +15,7 @@ impl ListSubHeader {
Self { Self {
label: label.into(), label: label.into(),
start_slot: None, start_slot: None,
end_slot: None,
inset: false, inset: false,
selected: false, selected: false,
} }
@ -24,6 +26,11 @@ impl ListSubHeader {
self 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 { pub fn inset(mut self, inset: bool) -> Self {
self.inset = inset; self.inset = inset;
self self
@ -73,7 +80,8 @@ impl RenderOnce for ListSubHeader {
.color(Color::Muted) .color(Color::Muted)
.size(LabelSize::Small), .size(LabelSize::Small),
), ),
), )
.children(self.end_slot),
) )
} }
} }