Skip to main content

ui/components/ai/
agent_thread_toolbar.rs

1use gpui::SharedString;
2
3use crate::Tooltip;
4use crate::prelude::*;
5
6/// Plain, protocol-agnostic aggregate usage figures for an agent thread:
7/// cumulative token count and cost, plus an optional itemized `breakdown`
8/// (e.g. per-request figures) shown in a tooltip on hover.
9#[derive(Debug, Clone, Default, PartialEq)]
10pub struct UsageDisplay {
11    pub tokens: Option<u64>,
12    pub cost: Option<SharedString>,
13    pub breakdown: Vec<(SharedString, SharedString)>,
14}
15
16impl UsageDisplay {
17    pub fn new() -> Self {
18        Self::default()
19    }
20
21    pub fn tokens(mut self, tokens: u64) -> Self {
22        self.tokens = Some(tokens);
23        self
24    }
25
26    pub fn cost(mut self, cost: impl Into<SharedString>) -> Self {
27        self.cost = Some(cost.into());
28        self
29    }
30
31    /// Adds an itemized `(label, value)` row shown in the hover tooltip
32    /// (e.g. `("Input", "8.2k tokens")`, `("Output", "4.2k tokens")`).
33    pub fn breakdown_item(
34        mut self,
35        label: impl Into<SharedString>,
36        value: impl Into<SharedString>,
37    ) -> Self {
38        self.breakdown.push((label.into(), value.into()));
39        self
40    }
41
42    fn summary_label(&self) -> Option<SharedString> {
43        let mut parts = Vec::new();
44        if let Some(tokens) = self.tokens {
45            parts.push(format_token_count(tokens));
46        }
47        if let Some(cost) = &self.cost {
48            parts.push(cost.to_string());
49        }
50        if parts.is_empty() {
51            None
52        } else {
53            Some(parts.join(" · ").into())
54        }
55    }
56}
57
58fn format_token_count(tokens: u64) -> String {
59    if tokens >= 1_000 {
60        format!("{:.1}k tokens", tokens as f64 / 1000.0)
61    } else {
62        format!("{tokens} tokens")
63    }
64}
65
66/// Toolbar shown atop an agent chat thread: a title, an optional right-hand
67/// slot (typically an [`AgentModelSelector`](super::AgentModelSelector)), and
68/// optional usage/cost figures.
69///
70/// `usage` is a list of `(label, value)` pairs (e.g. `("Tokens", "12.4k")`,
71/// `("Cost", "$0.08")`) supplied by the caller. This is intentionally decoupled
72/// from any `boltz-acpx` usage/cost type — `ui` must not depend on `acpx`.
73#[derive(IntoElement, RegisterComponent)]
74pub struct AgentThreadToolbar {
75    id: ElementId,
76    title: SharedString,
77    right_slot: Option<AnyElement>,
78    usage: Option<Vec<(String, String)>>,
79    usage_display: Option<UsageDisplay>,
80}
81
82impl AgentThreadToolbar {
83    pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
84        Self {
85            id: id.into(),
86            title: title.into(),
87            right_slot: None,
88            usage: None,
89            usage_display: None,
90        }
91    }
92
93    /// Right-hand slot, typically a model selector.
94    pub fn right_slot(mut self, element: impl IntoElement) -> Self {
95        self.right_slot = Some(element.into_any_element());
96        self
97    }
98
99    /// Usage/cost figures as `(label, value)` pairs, e.g.
100    /// `[("Tokens", "12.4k"), ("Cost", "$0.08")]`.
101    pub fn usage(mut self, usage: Vec<(String, String)>) -> Self {
102        self.usage = Some(usage);
103        self
104    }
105
106    /// A compact token+cost summary (e.g. "12.4k tokens · $0.08") with a
107    /// hover tooltip showing the full [`UsageDisplay::breakdown`].
108    pub fn usage_summary(mut self, usage: UsageDisplay) -> Self {
109        self.usage_display = Some(usage);
110        self
111    }
112}
113
114impl RenderOnce for AgentThreadToolbar {
115    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
116        let toolbar_id = self.id.clone();
117        h_flex()
118            .id(self.id)
119            .w_full()
120            .justify_between()
121            .items_center()
122            .gap_2()
123            .px_2()
124            .py_1p5()
125            .border_b_1()
126            .border_color(cx.theme().colors().border_variant)
127            .child(
128                h_flex()
129                    .gap_3()
130                    .min_w_0()
131                    .child(Label::new(self.title).truncate())
132                    .when_some(self.usage, |this, usage| {
133                        this.children(usage.into_iter().map(|(label, value)| {
134                            h_flex()
135                                .gap_1()
136                                .flex_shrink_0()
137                                .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
138                                .child(Label::new(value).size(LabelSize::Small))
139                        }))
140                    })
141                    .when_some(
142                        self.usage_display.and_then(|usage| {
143                            usage
144                                .summary_label()
145                                .map(|summary| (summary, usage.breakdown))
146                        }),
147                        |this, (summary, breakdown)| {
148                            this.child(
149                                h_flex()
150                                    .id((toolbar_id, "usage"))
151                                    .gap_1()
152                                    .flex_shrink_0()
153                                    .child(
154                                        Label::new(summary)
155                                            .size(LabelSize::Small)
156                                            .color(Color::Muted),
157                                    )
158                                    .when(!breakdown.is_empty(), |el| {
159                                        el.tooltip(Tooltip::element(move |_, _| {
160                                            v_flex()
161                                                .gap_1()
162                                                .children(breakdown.iter().cloned().map(
163                                                    |(label, value)| {
164                                                        h_flex()
165                                                            .gap_2()
166                                                            .justify_between()
167                                                            .child(
168                                                                Label::new(label)
169                                                                    .size(LabelSize::Small)
170                                                                    .color(Color::Muted),
171                                                            )
172                                                            .child(
173                                                                Label::new(value)
174                                                                    .size(LabelSize::Small),
175                                                            )
176                                                    },
177                                                ))
178                                                .into_any_element()
179                                        }))
180                                    }),
181                            )
182                        },
183                    ),
184            )
185            .when_some(self.right_slot, |this, slot| this.child(slot))
186    }
187}
188
189impl Component for AgentThreadToolbar {
190    fn scope() -> ComponentScope {
191        ComponentScope::Agent
192    }
193
194    fn description() -> Option<&'static str> {
195        Some(
196            "The toolbar shown atop an agent chat thread: title, a right-hand \
197             slot for a model selector, and optional usage/cost figures.",
198        )
199    }
200
201    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
202        Some(
203            v_flex()
204                .w_96()
205                .gap_4()
206                .child(single_example(
207                    "Title only",
208                    AgentThreadToolbar::new("toolbar-1", "New Thread").into_any_element(),
209                ))
210                .child(single_example(
211                    "With usage",
212                    AgentThreadToolbar::new("toolbar-2", "Refactor auth module")
213                        .usage(vec![
214                            ("Tokens".to_string(), "12.4k".to_string()),
215                            ("Cost".to_string(), "$0.08".to_string()),
216                        ])
217                        .into_any_element(),
218                ))
219                .child(single_example(
220                    "With usage summary + hover breakdown",
221                    AgentThreadToolbar::new("toolbar-3", "Fix flaky test")
222                        .usage_summary(
223                            UsageDisplay::new()
224                                .tokens(12_400)
225                                .cost("$0.08")
226                                .breakdown_item("Input", "8.2k tokens")
227                                .breakdown_item("Output", "4.2k tokens")
228                                .breakdown_item("Cache read", "1.1k tokens"),
229                        )
230                        .into_any_element(),
231                ))
232                .child(single_example(
233                    "With right slot",
234                    AgentThreadToolbar::new("toolbar-4", "Fix flaky test")
235                        .right_slot(
236                            Button::new("toolbar-model", "claude-sonnet-4.6")
237                                .style(ButtonStyle::Subtle),
238                        )
239                        .usage(vec![("Cost".to_string(), "$0.02".to_string())])
240                        .into_any_element(),
241                ))
242                .into_any_element(),
243        )
244    }
245}