1use crate::motion::MotionTokens;
9use crate::theme::SemanticStyledExt as _;
10use gpui::{
11 App, ElementId, FontWeight, Hsla, InteractiveElement as _, IntoElement, ParentElement as _,
12 RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
13 Window, div, prelude::FluentBuilder as _, relative,
14};
15use gpui_base::motion::{Transition, transition};
16use gpui_component::{
17 ActiveTheme as _, Sizable as _, StyledExt as _, h_flex, hover_card::HoverCard,
18 progress::ProgressCircle, v_flex,
19};
20
21const ELEVATED_AT: f32 = 0.65;
23const CRITICAL_AT: f32 = 0.85;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum UsageLevel {
29 Comfortable,
31 Elevated,
33 Critical,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct ContextUsage {
40 used: u64,
41 limit: u64,
42 input: Option<u64>,
43 output: Option<u64>,
44 reasoning: Option<u64>,
45 cached: Option<u64>,
46 cost: Option<SharedString>,
47}
48
49impl ContextUsage {
50 pub fn new(used: u64, limit: u64) -> Self {
52 Self {
53 used,
54 limit,
55 ..Self::default()
56 }
57 }
58
59 pub fn input(mut self, tokens: u64) -> Self {
61 self.input = Some(tokens);
62 self
63 }
64
65 pub fn output(mut self, tokens: u64) -> Self {
67 self.output = Some(tokens);
68 self
69 }
70
71 pub fn reasoning(mut self, tokens: u64) -> Self {
73 self.reasoning = Some(tokens);
74 self
75 }
76
77 pub fn cached(mut self, tokens: u64) -> Self {
79 self.cached = Some(tokens);
80 self
81 }
82
83 pub fn cost(mut self, cost: impl Into<SharedString>) -> Self {
85 self.cost = Some(cost.into());
86 self
87 }
88
89 pub fn fraction(&self) -> f32 {
91 if self.limit == 0 {
92 return 0.0;
93 }
94 (self.used as f64 / self.limit as f64).clamp(0.0, 1.0) as f32
95 }
96
97 pub fn percent(&self) -> u8 {
99 (self.fraction() * 100.0).round() as u8
100 }
101
102 pub fn level(&self) -> UsageLevel {
104 let fraction = self.fraction();
105 if fraction >= CRITICAL_AT {
106 UsageLevel::Critical
107 } else if fraction >= ELEVATED_AT {
108 UsageLevel::Elevated
109 } else {
110 UsageLevel::Comfortable
111 }
112 }
113
114 pub fn used(&self) -> u64 {
116 self.used
117 }
118
119 pub fn limit(&self) -> u64 {
121 self.limit
122 }
123
124 fn has_breakdown(&self) -> bool {
125 self.input.is_some()
126 || self.output.is_some()
127 || self.reasoning.is_some()
128 || self.cached.is_some()
129 || self.cost.is_some()
130 }
131
132 fn summary(&self) -> String {
133 format!(
134 "{} of {} tokens used, {}%",
135 format_tokens(self.used),
136 format_tokens(self.limit),
137 self.percent()
138 )
139 }
140}
141
142pub fn format_tokens(tokens: u64) -> String {
144 if tokens >= 1_000_000 {
145 let millions = tokens as f64 / 1_000_000.0;
146 if millions >= 10.0 {
147 format!("{millions:.0}M")
148 } else {
149 format!("{millions:.1}M")
150 }
151 } else if tokens >= 1_000 {
152 let thousands = tokens as f64 / 1_000.0;
153 if thousands >= 100.0 {
154 format!("{thousands:.0}K")
155 } else {
156 format!("{thousands:.1}K")
157 }
158 } else {
159 tokens.to_string()
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
165pub enum ContextMeterVariant {
166 #[default]
168 Ring,
169 Bar,
171 Text,
173}
174
175#[derive(IntoElement)]
192pub struct ContextMeter {
193 id: ElementId,
194 style: StyleRefinement,
195 usage: ContextUsage,
196 variant: ContextMeterVariant,
197 label: SharedString,
198}
199
200impl ContextMeter {
201 pub fn new(id: impl Into<ElementId>, usage: &ContextUsage) -> Self {
203 Self {
204 id: id.into(),
205 style: StyleRefinement::default(),
206 usage: usage.clone(),
207 variant: ContextMeterVariant::default(),
208 label: "Context usage".into(),
209 }
210 }
211
212 pub fn variant(mut self, variant: ContextMeterVariant) -> Self {
214 self.variant = variant;
215 self
216 }
217
218 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
220 self.label = label.into();
221 self
222 }
223
224 fn tone(&self, cx: &App) -> Hsla {
225 match self.usage.level() {
226 UsageLevel::Comfortable => cx.theme().primary,
227 UsageLevel::Elevated => cx.theme().warning,
228 UsageLevel::Critical => cx.theme().danger,
229 }
230 }
231}
232
233impl Styled for ContextMeter {
234 fn style(&mut self) -> &mut StyleRefinement {
235 &mut self.style
236 }
237}
238
239fn breakdown_row(label: &'static str, value: String, cx: &App) -> impl IntoElement {
240 let tokens = cx.theme().semantic_tokens();
241 h_flex()
242 .w_full()
243 .justify_between()
244 .gap(tokens.spacing.lg)
245 .text_token(tokens.typography.xs)
246 .child(
253 div()
254 .min_w_0()
255 .truncate()
256 .text_color(cx.theme().muted_foreground)
257 .child(label),
258 )
259 .child(
260 div()
261 .flex_none()
262 .font_family(cx.theme().mono_font_family.clone())
263 .text_color(cx.theme().foreground)
264 .child(value),
265 )
266}
267
268impl RenderOnce for ContextMeter {
269 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
270 let tokens = cx.theme().semantic_tokens();
271 let color = self.tone(cx);
272 let percent = self.usage.percent();
273 let fraction = transition(
278 (self.id.clone(), "context-fill"),
279 self.usage.fraction(),
280 Transition::new(crate::motion::retarget_duration(
281 cx,
282 MotionTokens::read(cx).standard(),
283 ))
284 .ease(crate::motion::ease_in_out_quart),
285 window,
286 cx,
287 );
288 let summary: SharedString = self.usage.summary().into();
289 let percent_text = format!("{percent}%");
290 let ratio_text = format!(
291 "{} / {}",
292 format_tokens(self.usage.used),
293 format_tokens(self.usage.limit)
294 );
295 let root_id = self.id.clone();
296
297 let readout = match self.variant {
298 ContextMeterVariant::Ring => h_flex()
299 .items_center()
300 .gap(tokens.spacing.xs)
301 .child(
302 ProgressCircle::new((root_id.clone(), "ring"))
303 .value(fraction * 100.0)
304 .color(color)
305 .large(),
306 )
307 .child(
308 div()
309 .text_token(tokens.typography.xs)
310 .font_family(cx.theme().mono_font_family.clone())
311 .font_weight(FontWeight::MEDIUM)
312 .text_color(cx.theme().foreground)
313 .child(percent_text),
314 )
315 .into_any_element(),
316 ContextMeterVariant::Bar => h_flex()
317 .items_center()
318 .gap(tokens.spacing.sm)
319 .child(
320 div()
321 .w(tokens.spacing.xxl * 3.0)
322 .h(tokens.spacing.xs)
323 .rounded(tokens.radius.full)
324 .bg(cx.theme().muted)
325 .child(
326 div()
327 .h_full()
328 .w(relative(fraction))
329 .rounded(tokens.radius.full)
330 .bg(color),
331 ),
332 )
333 .child(
334 div()
335 .text_token(tokens.typography.xs)
336 .font_family(cx.theme().mono_font_family.clone())
337 .font_weight(FontWeight::MEDIUM)
338 .text_color(cx.theme().foreground)
339 .child(percent_text),
340 )
341 .into_any_element(),
342 ContextMeterVariant::Text => h_flex()
343 .items_center()
344 .gap(tokens.spacing.xs)
345 .text_token(tokens.typography.xs)
346 .font_family(cx.theme().mono_font_family.clone())
347 .child(
348 div()
349 .font_weight(FontWeight::MEDIUM)
350 .text_color(color)
351 .child(percent_text),
352 )
353 .child(div().text_color(cx.theme().muted_foreground).child("·"))
354 .child(
355 div()
356 .text_color(cx.theme().muted_foreground)
357 .child(ratio_text.clone()),
358 )
359 .into_any_element(),
360 };
361
362 let visual = h_flex()
363 .id((root_id.clone(), "meter"))
364 .flex_none()
365 .items_center()
366 .px(tokens.spacing.xs)
367 .py(tokens.spacing.xxs)
368 .rounded(tokens.radius.sm)
369 .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
370 .child(readout);
371
372 let content = if self.usage.has_breakdown() {
373 let usage = self.usage.clone();
374 let label = self.label.clone();
375 let popup = crate::popup::PopupTokens::read(cx);
379 HoverCard::new((root_id.clone(), "breakdown"))
380 .open_delay(popup.hover_open_delay())
381 .close_delay(popup.hover_close_delay())
382 .trigger(visual)
383 .content(move |_, _, cx| {
384 let tokens = cx.theme().semantic_tokens();
385 v_flex()
386 .gap(tokens.spacing.xs)
387 .min_w(tokens.spacing.xxl * 5.0)
388 .child(crate::surface::subtitle(label.clone(), cx))
389 .child(
390 div()
391 .h(tokens.spacing.xs)
392 .w_full()
393 .rounded(tokens.radius.full)
394 .bg(cx.theme().muted)
395 .child(
396 div()
397 .h_full()
398 .w(relative(usage.fraction()))
399 .rounded(tokens.radius.full)
400 .bg(match usage.level() {
401 UsageLevel::Comfortable => cx.theme().primary,
402 UsageLevel::Elevated => cx.theme().warning,
403 UsageLevel::Critical => cx.theme().danger,
404 }),
405 ),
406 )
407 .child(breakdown_row(
408 "Used",
409 format!(
410 "{} / {}",
411 format_tokens(usage.used),
412 format_tokens(usage.limit)
413 ),
414 cx,
415 ))
416 .when_some(usage.input, |this, input| {
417 this.child(breakdown_row("Input", format_tokens(input), cx))
418 })
419 .when_some(usage.output, |this, output| {
420 this.child(breakdown_row("Output", format_tokens(output), cx))
421 })
422 .when_some(usage.reasoning, |this, reasoning| {
423 this.child(breakdown_row("Reasoning", format_tokens(reasoning), cx))
424 })
425 .when_some(usage.cached, |this, cached| {
426 this.child(breakdown_row("Cached", format_tokens(cached), cx))
427 })
428 .when_some(usage.cost.clone(), |this, cost| {
429 this.child(breakdown_row("Cost", cost.to_string(), cx))
430 })
431 })
432 .into_any_element()
433 } else {
434 visual.into_any_element()
435 };
436
437 div()
440 .id(root_id)
441 .role(Role::ProgressIndicator)
442 .aria_label(self.label.clone())
443 .aria_description(summary)
444 .aria_min_numeric_value(0.)
445 .aria_max_numeric_value(100.)
446 .aria_numeric_value(f64::from(percent))
447 .flex_none()
448 .child(content)
449 .refine_style(&self.style)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn token_counts_format_compactly() {
459 assert_eq!(format_tokens(912), "912");
460 assert_eq!(format_tokens(1_000), "1.0K");
461 assert_eq!(format_tokens(84_300), "84.3K");
462 assert_eq!(format_tokens(200_000), "200K");
463 assert_eq!(format_tokens(1_200_000), "1.2M");
464 assert_eq!(format_tokens(12_000_000), "12M");
465 }
466
467 #[test]
468 fn fraction_is_clamped_and_levels_follow_the_thresholds() {
469 assert_eq!(ContextUsage::new(0, 0).fraction(), 0.0);
470 assert_eq!(ContextUsage::new(500, 100).fraction(), 1.0);
471 assert_eq!(ContextUsage::new(50, 100).level(), UsageLevel::Comfortable);
472 assert_eq!(ContextUsage::new(65, 100).level(), UsageLevel::Elevated);
473 assert_eq!(ContextUsage::new(84, 100).level(), UsageLevel::Elevated);
474 assert_eq!(ContextUsage::new(85, 100).level(), UsageLevel::Critical);
475 assert_eq!(ContextUsage::new(84_300, 200_000).percent(), 42);
476 }
477}