ui/components/ai/
agent_thread_toolbar.rs1use gpui::SharedString;
2
3use crate::Tooltip;
4use crate::prelude::*;
5
6#[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 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#[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 pub fn right_slot(mut self, element: impl IntoElement) -> Self {
95 self.right_slot = Some(element.into_any_element());
96 self
97 }
98
99 pub fn usage(mut self, usage: Vec<(String, String)>) -> Self {
102 self.usage = Some(usage);
103 self
104 }
105
106 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}