Skip to main content

gpui_kit/agent/
cost.rs

1//! What a run has cost, and how much of a context window it has used.
2//!
3//! # An unlabelled estimate cannot be built
4//!
5//! [`Quantity`] has no constructor that takes a number on its own. Every one
6//! of them names the [`Basis`] in the same call —
7//! [`Quantity::measured`] or [`Quantity::estimated`] — and the wording is
8//! derived from that basis, so a number reaches a screen with the fact that it
9//! was estimated attached or it does not reach a screen at all. The label is
10//! in the drawn text, in the mark beside it, and in the node's published
11//! value, because a reader who saw the number on one surface and not the other
12//! would draw the wrong conclusion on the second one.
13//!
14//! # Unavailable is not zero
15//!
16//! [`Reading::Unavailable`] is a state, not a quantity. Nothing about it is
17//! drawn as a number, no proportion is computed from it, and the node
18//! publishes the refusal rather than a value.
19//!
20//! # An unknown limit draws no proportion
21//!
22//! [`Limit::Unknown`] means nobody stated a ceiling. A proportion of an
23//! unknown total is invented, so [`ContextGauge`] draws no fill and publishes
24//! no range, exactly as [`ProgressBar`](crate::display::progress::ProgressBar) refuses
25//! to claim a position for work whose extent is unknown. It does not fall back
26//! to the indeterminate sweep either: that sweep means "in flight", and a
27//! reading of what has been used so far is not in flight.
28//!
29//! # Numbers are the caller's
30//!
31//! Currency, token counts, grouping and the position of a unit are locale
32//! work, which this crate does not do — the same reason
33//! [`Timeline`](crate::display::timeline::Timeline) takes times as finished strings. A
34//! [`Quantity`] therefore carries both the caller's already-formatted wording
35//! *and*, separately, the bare number, which is used for one thing only:
36//! working out the proportion of a known limit. Nothing here ever turns a
37//! number into text.
38
39use gpui::{
40    App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
41    prelude::FluentBuilder, px, relative,
42};
43use gpui_kit_semantics::{NodeSpec, Role, Semantic};
44use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, TypeScale};
45
46use crate::display::badge::{Badge, Tone};
47use crate::foundation::{Ident, StyledExt, text};
48use crate::strings::{ActiveStrings, StringKey};
49
50/// How a number was arrived at.
51///
52/// There is no third variant meaning "unspecified": a caller that cannot say
53/// which of these two it has does not have a number, it has
54/// [`Reading::Unavailable`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Basis {
57    /// Counted by whoever owns the meter.
58    Measured,
59    /// Worked out from something else, and said so everywhere it appears.
60    Estimated,
61}
62
63impl Basis {
64    /// The stable name published in the semantic tree.
65    pub fn name(self) -> &'static str {
66        match self {
67            Self::Measured => "measured",
68            Self::Estimated => "estimated",
69        }
70    }
71
72    pub fn is_estimate(self) -> bool {
73        matches!(self, Self::Estimated)
74    }
75}
76
77/// A number, the caller's wording for it, and how it was arrived at.
78#[derive(Debug, Clone, PartialEq)]
79pub struct Quantity {
80    basis: Basis,
81    amount: f64,
82    text: SharedString,
83}
84
85impl Quantity {
86    /// A counted number. `text` is the caller's own formatting of it.
87    pub fn measured(amount: f64, text: impl Into<SharedString>) -> Self {
88        Self {
89            basis: Basis::Measured,
90            amount,
91            text: text.into(),
92        }
93    }
94
95    /// An estimate. Every rendering of it says so.
96    pub fn estimated(amount: f64, text: impl Into<SharedString>) -> Self {
97        Self {
98            basis: Basis::Estimated,
99            amount,
100            text: text.into(),
101        }
102    }
103
104    pub fn basis(&self) -> Basis {
105        self.basis
106    }
107
108    /// The bare number, used only for proportions against a known limit.
109    pub fn amount(&self) -> f64 {
110        self.amount
111    }
112
113    /// The caller's wording, without the estimate label.
114    pub fn text(&self) -> &SharedString {
115        &self.text
116    }
117
118    /// What a reader reads and what the node publishes: the caller's wording,
119    /// carrying its basis when the basis is one a reader must know about.
120    pub fn display(&self, cx: &App) -> SharedString {
121        cx.strings().format(
122            match self.basis {
123                Basis::Measured => StringKey::CostMeasured,
124                Basis::Estimated => StringKey::CostEstimated,
125            },
126            &[&self.text],
127        )
128    }
129}
130
131/// What is known about a number right now.
132#[derive(Debug, Clone, PartialEq)]
133pub enum Reading {
134    /// A number, with its basis.
135    Known(Quantity),
136    /// Nobody could say. Never drawn as zero, and never counted into a
137    /// proportion.
138    Unavailable { reason: Option<SharedString> },
139}
140
141impl Reading {
142    pub fn measured(amount: f64, text: impl Into<SharedString>) -> Self {
143        Self::Known(Quantity::measured(amount, text))
144    }
145
146    pub fn estimated(amount: f64, text: impl Into<SharedString>) -> Self {
147        Self::Known(Quantity::estimated(amount, text))
148    }
149
150    pub fn unavailable() -> Self {
151        Self::Unavailable { reason: None }
152    }
153
154    /// Unavailable, in the host's own words, which are shown verbatim.
155    pub fn unavailable_because(reason: impl Into<SharedString>) -> Self {
156        Self::Unavailable {
157            reason: Some(reason.into()),
158        }
159    }
160
161    pub fn quantity(&self) -> Option<&Quantity> {
162        match self {
163            Self::Known(quantity) => Some(quantity),
164            Self::Unavailable { .. } => None,
165        }
166    }
167
168    /// The stable name published in the semantic tree.
169    pub fn name(&self) -> &'static str {
170        match self {
171            Self::Known(quantity) => quantity.basis().name(),
172            Self::Unavailable { .. } => "unavailable",
173        }
174    }
175
176    fn display(&self, cx: &App) -> SharedString {
177        match self {
178            Self::Known(quantity) => quantity.display(cx),
179            Self::Unavailable { reason } => reason
180                .clone()
181                .unwrap_or_else(|| cx.strings().text(StringKey::CostUnavailable)),
182        }
183    }
184
185    fn is_estimate(&self) -> bool {
186        self.quantity()
187            .is_some_and(|quantity| quantity.basis().is_estimate())
188    }
189}
190
191/// The ceiling a gauge measures against.
192#[derive(Debug, Clone, PartialEq, Default)]
193pub enum Limit {
194    /// A stated ceiling, which may itself be an estimate.
195    Known(Quantity),
196    /// Nobody stated one. No proportion is drawn.
197    #[default]
198    Unknown,
199}
200
201impl Limit {
202    pub fn measured(amount: f64, text: impl Into<SharedString>) -> Self {
203        Self::Known(Quantity::measured(amount, text))
204    }
205
206    pub fn estimated(amount: f64, text: impl Into<SharedString>) -> Self {
207        Self::Known(Quantity::estimated(amount, text))
208    }
209
210    pub fn quantity(&self) -> Option<&Quantity> {
211        match self {
212            Self::Known(quantity) => Some(quantity),
213            Self::Unknown => None,
214        }
215    }
216
217    pub fn is_known(&self) -> bool {
218        matches!(self, Self::Known(_))
219    }
220}
221
222/// When a reading was last verified, for a reading that is no longer current.
223///
224/// Marking a value stale and saying when it was from is one call, because a
225/// value marked stale without a date is a warning nobody can act on. The
226/// wording of the date is the caller's, as in
227/// [`Timeline`](crate::display::timeline::Timeline).
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct LastVerified(SharedString);
230
231impl LastVerified {
232    pub fn at(when: impl Into<SharedString>) -> Self {
233        Self(when.into())
234    }
235
236    fn sentence(&self, cx: &App) -> SharedString {
237        cx.strings().format(StringKey::CostLastVerified, &[&self.0])
238    }
239}
240
241/// One line of a [`CostMeter`].
242#[derive(Debug, Clone, PartialEq)]
243pub struct CostLine {
244    id: SharedString,
245    label: SharedString,
246    reading: Reading,
247    stale: Option<LastVerified>,
248}
249
250impl CostLine {
251    pub fn new(
252        id: impl Into<SharedString>,
253        label: impl Into<SharedString>,
254        reading: Reading,
255    ) -> Self {
256        Self {
257            id: id.into(),
258            label: label.into(),
259            reading,
260            stale: None,
261        }
262    }
263
264    /// The refresh that should have replaced this reading did not. The value
265    /// stays on screen and says when it was from.
266    pub fn stale(mut self, verified: LastVerified) -> Self {
267        self.stale = Some(verified);
268        self
269    }
270
271    pub fn id(&self) -> &SharedString {
272        &self.id
273    }
274
275    pub fn reading(&self) -> &Reading {
276        &self.reading
277    }
278}
279
280/// What a run has cost so far, line by line.
281#[derive(Debug, Clone, IntoElement)]
282pub struct CostMeter {
283    ident: Ident,
284    label: Option<SharedString>,
285    lines: Vec<CostLine>,
286}
287
288impl CostMeter {
289    pub fn new(ident: impl Into<Ident>) -> Self {
290        Self {
291            ident: ident.into(),
292            label: None,
293            lines: Vec::new(),
294        }
295    }
296
297    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
298        self.label = Some(label.into());
299        self
300    }
301
302    pub fn line(mut self, line: CostLine) -> Self {
303        self.lines.push(line);
304        self
305    }
306
307    pub fn lines(mut self, lines: impl IntoIterator<Item = CostLine>) -> Self {
308        self.lines.extend(lines);
309        self
310    }
311}
312
313impl RenderOnce for CostMeter {
314    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
315        let theme = cx.theme().clone();
316        let rows: Vec<_> = self
317            .lines
318            .iter()
319            .map(|line| {
320                let ident = self.ident.child(line.id.as_ref());
321                let display = line.reading.display(cx);
322                let unavailable = line.reading.quantity().is_none();
323
324                div()
325                    .column()
326                    .w_full()
327                    .gap_token(&theme, Space::Xs)
328                    .child(
329                        div()
330                            .row()
331                            .w_full()
332                            .justify_between()
333                            .gap_token(&theme, Space::Sm)
334                            .child(
335                                text(&theme, TypeScale::Label, line.label.clone())
336                                    .text_tone(&theme, TextTone::Muted),
337                            )
338                            .child(
339                                div()
340                                    .row()
341                                    .gap_token(&theme, Space::Sm)
342                                    .child(
343                                        text(&theme, TypeScale::Label, display.clone()).text_tone(
344                                            &theme,
345                                            if unavailable {
346                                                TextTone::Faint
347                                            } else {
348                                                TextTone::Primary
349                                            },
350                                        ),
351                                    )
352                                    .when(line.reading.is_estimate(), |element| {
353                                        element.child(estimate_mark(&ident, cx))
354                                    }),
355                            ),
356                    )
357                    .children(
358                        line.stale
359                            .as_ref()
360                            .map(|verified| stale_line(&ident, &theme, verified.sentence(cx), cx)),
361                    )
362                    .semantic_in(
363                        cx,
364                        NodeSpec::new(ident.semantic_id(), Role::Status)
365                            .text(line.label.clone())
366                            .value(display)
367                            .parent(self.ident.semantic_id()),
368                    )
369            })
370            .collect();
371
372        div()
373            .column()
374            .w_full()
375            .gap_token(&theme, Space::Sm)
376            .p_token(&theme, Space::Md)
377            .radius(&theme, Radius::Card)
378            .frame(&theme, Surface::Panel, Elevation::Raised)
379            .when_some(self.label.clone(), |element, label| {
380                element.child(
381                    text(&theme, TypeScale::Caption, label).text_tone(&theme, TextTone::Faint),
382                )
383            })
384            .children(rows)
385            .semantic_in(
386                cx,
387                NodeSpec::new(self.ident.semantic_id(), Role::Group)
388                    .when_label(self.label)
389                    .value(SharedString::from(self.lines.len().to_string())),
390            )
391    }
392}
393
394/// How much of a context window a run has used.
395///
396/// The gauge draws a proportion only when it has both a reading and a limit,
397/// and says which of the two it is missing when it does not.
398#[derive(Debug, Clone, IntoElement)]
399pub struct ContextGauge {
400    ident: Ident,
401    label: Option<SharedString>,
402    used: Reading,
403    limit: Limit,
404    stale: Option<LastVerified>,
405}
406
407impl ContextGauge {
408    /// `used` carries its own basis, so a gauge cannot be given a bare number.
409    pub fn new(ident: impl Into<Ident>, used: Reading) -> Self {
410        Self {
411            ident: ident.into(),
412            label: None,
413            used,
414            limit: Limit::Unknown,
415            stale: None,
416        }
417    }
418
419    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
420        self.label = Some(label.into());
421        self
422    }
423
424    pub fn limit(mut self, limit: Limit) -> Self {
425        self.limit = limit;
426        self
427    }
428
429    /// The reading is the last verified one, from the moment stated.
430    pub fn stale(mut self, verified: LastVerified) -> Self {
431        self.stale = Some(verified);
432        self
433    }
434
435    /// The proportion, when there is one to draw.
436    ///
437    /// `None` whenever the limit is unknown or the reading is unavailable: a
438    /// proportion of an unknown total, or of a number nobody has, is invented.
439    pub fn fraction(&self) -> Option<f32> {
440        let used = self.used.quantity()?;
441        let limit = self.limit.quantity()?;
442        (limit.amount() > 0.0).then(|| (used.amount() / limit.amount()).clamp(0.0, 1.0) as f32)
443    }
444}
445
446impl RenderOnce for ContextGauge {
447    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
448        let theme = cx.theme().clone();
449        let fraction = self.fraction();
450        let used_display = self.used.display(cx);
451        let unavailable = self.used.quantity().is_none();
452        let estimated = self.used.is_estimate()
453            || self
454                .limit
455                .quantity()
456                .is_some_and(|limit| limit.basis().is_estimate());
457
458        let reading = match self.limit.quantity() {
459            Some(limit) => cx.strings().format(
460                StringKey::CountOfTotal,
461                &[&used_display, &limit.display(cx)],
462            ),
463            None => used_display.clone(),
464        };
465
466        let spec = match fraction {
467            Some(fraction) => NodeSpec::new(self.ident.semantic_id(), Role::Progress)
468                .range(0.0, 1.0, fraction)
469                .value(reading.clone()),
470            // No range, so no `Progress`: a range role without a range is a
471            // node claiming a position it does not have.
472            None => NodeSpec::new(self.ident.semantic_id(), Role::Status)
473                .value(reading.clone())
474                .invalid(false),
475        };
476        let spec = match self.label.clone() {
477            Some(label) => spec.text(label),
478            None => spec,
479        };
480
481        let limit_note = (!self.limit.is_known()).then(|| {
482            let words = cx.strings().text(StringKey::ContextUnknownLimit);
483            text(&theme, TypeScale::Caption, words.clone())
484                .text_tone(&theme, TextTone::Faint)
485                .semantic_in(
486                    cx,
487                    NodeSpec::new(self.ident.child("limit").semantic_id(), Role::Text)
488                        .text(words)
489                        .value(SharedString::new_static("unknown-limit"))
490                        .parent(self.ident.semantic_id()),
491                )
492        });
493
494        div()
495            .column()
496            .w_full()
497            .gap_token(&theme, Space::Xs)
498            .child(
499                div()
500                    .row()
501                    .w_full()
502                    .justify_between()
503                    .gap_token(&theme, Space::Sm)
504                    .children(self.label.clone().map(|label| {
505                        text(&theme, TypeScale::Label, label).text_tone(&theme, TextTone::Muted)
506                    }))
507                    .child(
508                        div()
509                            .row()
510                            .gap_token(&theme, Space::Sm)
511                            .child(text(&theme, TypeScale::Label, reading).text_tone(
512                                &theme,
513                                if unavailable {
514                                    TextTone::Faint
515                                } else {
516                                    TextTone::Primary
517                                },
518                            ))
519                            .when(estimated, |element| {
520                                element.child(estimate_mark(&self.ident, cx))
521                            }),
522                    ),
523            )
524            .child(
525                div()
526                    .relative()
527                    .w_full()
528                    .h(px(4.0))
529                    .rounded_full()
530                    .overflow_hidden()
531                    .bg(theme.colors.hairline_strong)
532                    // Only a known proportion is drawn. An unknown limit gets
533                    // the empty track and the note beneath it, not a sweep:
534                    // a sweep would say the number is being worked out.
535                    .when_some(fraction, |element, fraction| {
536                        element.child(
537                            div()
538                                .absolute()
539                                .left_0()
540                                .top_0()
541                                .bottom_0()
542                                .rounded_full()
543                                .bg(theme.colors.accent)
544                                .w(relative(fraction)),
545                        )
546                    }),
547            )
548            .children(limit_note)
549            .children(
550                self.stale
551                    .as_ref()
552                    .map(|verified| stale_line(&self.ident, &theme, verified.sentence(cx), cx)),
553            )
554            .semantic_in(cx, spec)
555    }
556}
557
558/// The mark that says a number was not counted, drawn beside every estimate.
559fn estimate_mark(ident: &Ident, cx: &App) -> Badge {
560    Badge::new(cx.strings().text(StringKey::CostEstimateMark))
561        .tone(Tone::Info)
562        .id(ident.child("estimate"))
563}
564
565fn stale_line(
566    ident: &Ident,
567    theme: &gpui_kit_theme::Theme,
568    sentence: SharedString,
569    cx: &App,
570) -> impl IntoElement {
571    text(theme, TypeScale::Caption, sentence.clone())
572        .text_color(theme.colors.warning)
573        .semantic_in(
574            cx,
575            NodeSpec::new(ident.child("stale").semantic_id(), Role::Status)
576                .text(sentence)
577                .value(SharedString::new_static("stale"))
578                .parent(ident.semantic_id()),
579        )
580}
581
582trait NodeSpecExt {
583    fn when_label(self, label: Option<SharedString>) -> Self;
584}
585
586impl NodeSpecExt for NodeSpec {
587    fn when_label(self, label: Option<SharedString>) -> Self {
588        match label {
589            Some(label) => self.text(label),
590            None => self,
591        }
592    }
593}