1use crate::{IconDecoration, IconDecorationKind, Tooltip, prelude::*};
2use gpui::{Animation, AnimationExt, SharedString, pulsating_between};
3use std::time::Duration;
4
5#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
6pub enum AiSettingItemStatus {
7 #[default]
8 Stopped,
9 Starting,
10 Running,
11 Error,
12 AuthRequired,
13 Authenticating,
14}
15
16impl AiSettingItemStatus {
17 fn tooltip_text(&self) -> &'static str {
18 match self {
19 Self::Stopped => "Server is stopped.",
20 Self::Starting => "Server is starting.",
21 Self::Running => "Server is active.",
22 Self::Error => "Server has an error.",
23 Self::AuthRequired => "Authentication required.",
24 Self::Authenticating => "Waiting for authorization…",
25 }
26 }
27
28 fn indicator_color(&self) -> Option<Color> {
29 match self {
30 Self::Stopped => None,
31 Self::Starting | Self::Authenticating => Some(Color::Muted),
32 Self::Running => Some(Color::Success),
33 Self::Error => Some(Color::Error),
34 Self::AuthRequired => Some(Color::Warning),
35 }
36 }
37
38 fn is_animated(&self) -> bool {
39 matches!(self, Self::Starting | Self::Authenticating)
40 }
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum AiSettingItemSource {
45 Extension,
46 Custom,
47 Registry,
48}
49
50impl AiSettingItemSource {
51 fn icon_name(&self) -> IconName {
52 match self {
53 Self::Extension => IconName::ZedSrcExtension,
54 Self::Custom => IconName::ZedSrcCustom,
55 Self::Registry => IconName::AcpRegistry,
56 }
57 }
58
59 fn tooltip_text(&self, label: &str) -> String {
60 match self {
61 Self::Extension => format!("{label} was installed from an extension."),
62 Self::Registry => format!("{label} was installed from the ACP registry."),
63 Self::Custom => format!("{label} was configured manually."),
64 }
65 }
66}
67
68/// A reusable setting item row for AI-related configuration lists.
69#[derive(IntoElement, RegisterComponent)]
70pub struct AiSettingItem {
71 id: ElementId,
72 status: AiSettingItemStatus,
73 source: AiSettingItemSource,
74 icon: Option<AnyElement>,
75 label: SharedString,
76 detail_label: Option<SharedString>,
77 actions: Vec<AnyElement>,
78 details: Option<AnyElement>,
79}
80
81impl AiSettingItem {
82 pub fn new(
83 id: impl Into<ElementId>,
84 label: impl Into<SharedString>,
85 status: AiSettingItemStatus,
86 source: AiSettingItemSource,
87 ) -> Self {
88 Self {
89 id: id.into(),
90 status,
91 source,
92 icon: None,
93 label: label.into(),
94 detail_label: None,
95 actions: Vec::new(),
96 details: None,
97 }
98 }
99
100 pub fn icon(mut self, element: impl IntoElement) -> Self {
101 self.icon = Some(element.into_any_element());
102 self
103 }
104
105 pub fn detail_label(mut self, detail: impl Into<SharedString>) -> Self {
106 self.detail_label = Some(detail.into());
107 self
108 }
109
110 pub fn action(mut self, element: impl IntoElement) -> Self {
111 self.actions.push(element.into_any_element());
112 self
113 }
114
115 pub fn details(mut self, element: impl IntoElement) -> Self {
116 self.details = Some(element.into_any_element());
117 self
118 }
119}
120
121impl RenderOnce for AiSettingItem {
122 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
123 let Self {
124 id,
125 status,
126 source,
127 icon,
128 label,
129 detail_label,
130 actions,
131 details,
132 } = self;
133
134 let source_id = format!("source-{}", id);
135 let icon_id = format!("icon-{}", id);
136 let status_tooltip = status.tooltip_text();
137 let source_tooltip = source.tooltip_text(&label);
138
139 let icon_element = icon.unwrap_or_else(|| {
140 let letter = label.chars().next().unwrap_or('?').to_ascii_uppercase();
141
142 h_flex()
143 .size_5()
144 .flex_none()
145 .justify_center()
146 .rounded_sm()
147 .border_1()
148 .border_color(cx.theme().colors().border_variant)
149 .bg(cx.theme().colors().element_active.opacity(0.2))
150 .child(
151 Label::new(SharedString::from(letter.to_string()))
152 .size(LabelSize::Small)
153 .color(Color::Muted)
154 .buffer_font(cx),
155 )
156 .into_any_element()
157 });
158
159 let icon_child = if status.is_animated() {
160 div()
161 .child(icon_element)
162 .with_animation(
163 format!("icon-pulse-{}", id),
164 Animation::new(Duration::from_secs(2))
165 .repeat()
166 .with_easing(pulsating_between(0.4, 0.8)),
167 |element, delta| element.opacity(delta),
168 )
169 .into_any_element()
170 } else {
171 icon_element.into_any_element()
172 };
173
174 let icon_container = div()
175 .id(icon_id)
176 .relative()
177 .flex_none()
178 .tooltip(Tooltip::text(status_tooltip))
179 .child(icon_child)
180 .when_some(status.indicator_color(), |this, color| {
181 this.child(
182 IconDecoration::new(
183 IconDecorationKind::Dot,
184 cx.theme().colors().panel_background,
185 cx,
186 )
187 .size(px(12.))
188 .color(color.color(cx))
189 .position(gpui::Point {
190 x: px(-3.),
191 y: px(-3.),
192 }),
193 )
194 });
195
196 v_flex()
197 .id(id)
198 .min_w_0()
199 .child(
200 h_flex()
201 .min_w_0()
202 .w_full()
203 .gap_1p5()
204 .justify_between()
205 .child(
206 h_flex()
207 .flex_1()
208 .min_w_0()
209 .gap_1p5()
210 .child(icon_container)
211 .child(Label::new(label).flex_shrink_0().truncate())
212 .child(
213 div()
214 .id(source_id)
215 .min_w_0()
216 .flex_none()
217 .tooltip(Tooltip::text(source_tooltip))
218 .child(
219 Icon::new(source.icon_name())
220 .size(IconSize::Small)
221 .color(Color::Muted),
222 ),
223 )
224 .when_some(detail_label, |this, detail| {
225 this.child(
226 Label::new(detail)
227 .color(Color::Muted)
228 .size(LabelSize::Small),
229 )
230 }),
231 )
232 .when(!actions.is_empty(), |this| {
233 this.child(h_flex().gap_0p5().flex_none().children(actions))
234 }),
235 )
236 .children(details)
237 }
238}
239
240impl Component for AiSettingItem {
241 fn scope() -> ComponentScope {
242 ComponentScope::Agent
243 }
244
245 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
246 let container = || {
247 v_flex()
248 .w_80()
249 .p_2()
250 .gap_2()
251 .border_1()
252 .border_color(cx.theme().colors().border_variant)
253 .bg(cx.theme().colors().panel_background)
254 };
255
256 let details_row = |icon_name: IconName, icon_color: Color, message: &str| {
257 h_flex()
258 .py_1()
259 .min_w_0()
260 .w_full()
261 .gap_2()
262 .justify_between()
263 .child(
264 h_flex()
265 .pr_4()
266 .min_w_0()
267 .w_full()
268 .gap_2()
269 .child(
270 Icon::new(icon_name)
271 .size(IconSize::XSmall)
272 .color(icon_color),
273 )
274 .child(
275 div().min_w_0().flex_1().child(
276 Label::new(SharedString::from(message.to_string()))
277 .color(Color::Muted)
278 .size(LabelSize::Small),
279 ),
280 ),
281 )
282 };
283
284 let examples = vec![
285 single_example(
286 "MCP server with letter avatar (running)",
287 container()
288 .child(
289 AiSettingItem::new(
290 "ext-mcp",
291 "Postgres",
292 AiSettingItemStatus::Running,
293 AiSettingItemSource::Extension,
294 )
295 .detail_label("3 tools")
296 .action(
297 IconButton::new("menu", IconName::Settings)
298 .icon_size(IconSize::Small)
299 .icon_color(Color::Muted),
300 )
301 .action(
302 IconButton::new("toggle", IconName::Check)
303 .icon_size(IconSize::Small)
304 .icon_color(Color::Muted),
305 ),
306 )
307 .into_any_element(),
308 ),
309 single_example(
310 "MCP server (stopped)",
311 container()
312 .child(AiSettingItem::new(
313 "custom-mcp",
314 "my-local-server",
315 AiSettingItemStatus::Stopped,
316 AiSettingItemSource::Custom,
317 ))
318 .into_any_element(),
319 ),
320 single_example(
321 "MCP server (starting, animated)",
322 container()
323 .child(AiSettingItem::new(
324 "starting-mcp",
325 "Context7",
326 AiSettingItemStatus::Starting,
327 AiSettingItemSource::Extension,
328 ))
329 .into_any_element(),
330 ),
331 single_example(
332 "Agent with icon (running)",
333 container()
334 .child(
335 AiSettingItem::new(
336 "ext-agent",
337 "Claude Agent",
338 AiSettingItemStatus::Running,
339 AiSettingItemSource::Extension,
340 )
341 .icon(
342 Icon::new(IconName::AiClaude)
343 .size(IconSize::Small)
344 .color(Color::Muted),
345 )
346 .action(
347 IconButton::new("restart", IconName::RotateCw)
348 .icon_size(IconSize::Small)
349 .icon_color(Color::Muted),
350 )
351 .action(
352 IconButton::new("delete", IconName::Trash)
353 .icon_size(IconSize::Small)
354 .icon_color(Color::Muted),
355 ),
356 )
357 .into_any_element(),
358 ),
359 single_example(
360 "Registry agent (starting, animated)",
361 container()
362 .child(
363 AiSettingItem::new(
364 "reg-agent",
365 "Devin Agent",
366 AiSettingItemStatus::Starting,
367 AiSettingItemSource::Registry,
368 )
369 .icon(
370 Icon::new(IconName::ZedAssistant)
371 .size(IconSize::Small)
372 .color(Color::Muted),
373 ),
374 )
375 .into_any_element(),
376 ),
377 single_example(
378 "Error with details",
379 container()
380 .child(
381 AiSettingItem::new(
382 "error-mcp",
383 "Amplitude",
384 AiSettingItemStatus::Error,
385 AiSettingItemSource::Extension,
386 )
387 .details(
388 details_row(
389 IconName::XCircle,
390 Color::Error,
391 "Failed to connect: connection refused",
392 )
393 .child(
394 Button::new("logout", "Log Out")
395 .style(ButtonStyle::Outlined)
396 .label_size(LabelSize::Small),
397 ),
398 ),
399 )
400 .into_any_element(),
401 ),
402 ];
403
404 Some(example_group(examples).vertical().into_any_element())
405 }
406}