Skip to main content

gpui_ai/
context_meter.rs

1//! Context-window usage: how much of the model's window a conversation has
2//! consumed, as a ring, a bar, or plain text, with a hover breakdown.
3//!
4//! The meter changes tone as the window fills — calm below 65%, warning to
5//! 85%, danger above — and exposes the same numbers semantically so the
6//! level never depends on color alone.
7
8use 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
21/// Fraction of the window at which the meter turns to the warning tone.
22const ELEVATED_AT: f32 = 0.65;
23/// Fraction of the window at which the meter turns to the danger tone.
24const CRITICAL_AT: f32 = 0.85;
25
26/// How full the context window is, in the three bands people act on.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum UsageLevel {
29    /// Plenty of room.
30    Comfortable,
31    /// Worth keeping an eye on.
32    Elevated,
33    /// Close to the limit; the application should offer a remedy.
34    Critical,
35}
36
37/// Application-measured token usage for one context window.
38#[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    /// Creates a usage snapshot from consumed tokens and the window size.
51    pub fn new(used: u64, limit: u64) -> Self {
52        Self {
53            used,
54            limit,
55            ..Self::default()
56        }
57    }
58
59    /// Records input (prompt) tokens for the breakdown.
60    pub fn input(mut self, tokens: u64) -> Self {
61        self.input = Some(tokens);
62        self
63    }
64
65    /// Records output (completion) tokens for the breakdown.
66    pub fn output(mut self, tokens: u64) -> Self {
67        self.output = Some(tokens);
68        self
69    }
70
71    /// Records reasoning tokens for the breakdown.
72    pub fn reasoning(mut self, tokens: u64) -> Self {
73        self.reasoning = Some(tokens);
74        self
75    }
76
77    /// Records cached (prompt-cache hit) tokens for the breakdown.
78    pub fn cached(mut self, tokens: u64) -> Self {
79        self.cached = Some(tokens);
80        self
81    }
82
83    /// Records an application-formatted cost line (for example `$0.42`).
84    pub fn cost(mut self, cost: impl Into<SharedString>) -> Self {
85        self.cost = Some(cost.into());
86        self
87    }
88
89    /// Used tokens as a fraction of the limit, clamped to `0.0..=1.0`.
90    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    /// Used tokens as a whole percentage.
98    pub fn percent(&self) -> u8 {
99        (self.fraction() * 100.0).round() as u8
100    }
101
102    /// The band the current usage falls in.
103    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    /// Returns the consumed token count.
115    pub fn used(&self) -> u64 {
116        self.used
117    }
118
119    /// Returns the window size in tokens.
120    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
142/// Formats a token count compactly: `912`, `84.3K`, `1.2M`.
143pub 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/// How the meter draws its value.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
165pub enum ContextMeterVariant {
166    /// A small ring next to the percentage.
167    #[default]
168    Ring,
169    /// A horizontal bar with the percentage.
170    Bar,
171    /// Text only: `42% · 84.3K / 200K`.
172    Text,
173}
174
175/// A context-window usage readout.
176///
177/// # Example
178///
179/// ```
180/// # use gpui_ai::prelude::*;
181/// ContextMeter::new(
182///     "context",
183///     &ContextUsage::new(84_300, 200_000)
184///         .input(61_000)
185///         .output(19_800)
186///         .cached(3_500)
187///         .cost("$0.42"),
188/// )
189/// .variant(ContextMeterVariant::Ring);
190/// ```
191#[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    /// Creates a meter from a usage snapshot.
202    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    /// Sets how the value is drawn (default: ring).
213    pub fn variant(mut self, variant: ContextMeterVariant) -> Self {
214        self.variant = variant;
215        self
216    }
217
218    /// Overrides the accessible name (default "Context usage").
219    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        // The label yields and the value holds. A row that states something
247        // and its number has one part that can be shortened without losing
248        // the point and one that cannot: a truncated heading still leaves a
249        // readable row, while a dropped number leaves a row that says
250        // nothing. Neither could shrink before, so a long label pushed the
251        // number out of the row, where it was clipped.
252        .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        // The drawn geometry retargets from the previous controlled usage at
274        // the standard tempo; the first render starts at the supplied value,
275        // and the numeric readout stays controlled — the number never
276        // counts. Reduced motion snaps through the transition contract.
277        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            // The breakdown is data the application already holds, so it
376            // reveals rather than loads: the crate's popup policy sets the
377            // wait, where upstream's own default is 600ms.
378            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        // The semantic root stays one named progress indicator regardless of
438        // whether a hover breakdown wraps the visual.
439        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}