ai_setting_item.rs

  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}