Skip to main content

ragrig_gpui/
agent_bar.rs

1//! The agent component status bar.
2
3use std::{rc::Rc, time::Duration};
4
5use gpui_kit::{
6    Anchor, Animation, AnimationExt as _, AnyElement, App, Div, ElementId, FontWeight, Hsla,
7    IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window,
8    assets::IconName,
9    component::{
10        ActiveTheme as _, Sizable as _,
11        button::{Button, ButtonVariants as _},
12        h_flex,
13        popover::Popover,
14        v_flex,
15    },
16    div,
17    prelude::FluentBuilder as _,
18    px,
19};
20
21use crate::model::{AgentComponent, ComponentStatus};
22
23/// What the user asked the agent bar to do.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum AgentBarEvent {
26    /// Open the configuration surface for a component id.
27    Configure(String),
28    /// Open the general agent settings.
29    OpenSettings,
30}
31
32type AgentHandler = Rc<dyn Fn(AgentBarEvent, &mut Window, &mut App)>;
33
34/// A row of pipeline components with status dots and detail popovers.
35///
36/// Each component opens a popover anchored to its icon. The popover shows the
37/// label, status, and detail line, and emits [`AgentBarEvent::Configure`] when
38/// the host should open its configuration.
39#[derive(IntoElement)]
40pub struct AgentBar {
41    id: SharedString,
42    components: Vec<AgentComponent>,
43    on_event: Option<AgentHandler>,
44}
45
46impl AgentBar {
47    /// A bar with a unique element-ID prefix.
48    pub fn new(
49        id: impl Into<SharedString>,
50        components: impl IntoIterator<Item = AgentComponent>,
51    ) -> Self {
52        Self {
53            id: id.into(),
54            components: components.into_iter().collect(),
55            on_event: None,
56        }
57    }
58
59    /// Receive configuration and settings requests.
60    pub fn on_event(
61        mut self,
62        handler: impl Fn(AgentBarEvent, &mut Window, &mut App) + 'static,
63    ) -> Self {
64        self.on_event = Some(Rc::new(handler));
65        self
66    }
67}
68
69impl RenderOnce for AgentBar {
70    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
71        let handler = self.on_event;
72        let prefix = self.id;
73
74        let components = self
75            .components
76            .into_iter()
77            .enumerate()
78            .map(|(index, component)| {
79                component_button(&prefix, index, component, handler.clone(), cx)
80            })
81            .collect::<Vec<_>>();
82
83        let settings_handler = handler;
84        let settings_id = ElementId::Name(format!("{prefix}-settings").into());
85        h_flex()
86            .items_center()
87            .gap_1()
88            .px_2()
89            .py_1()
90            .children(components)
91            .child(div().flex_1())
92            .child(
93                Button::new(settings_id)
94                    .icon(IconName::Settings)
95                    .ghost()
96                    .small()
97                    .tooltip("Agent settings")
98                    .on_click(move |_, window, cx| {
99                        if let Some(handler) = &settings_handler {
100                            handler(AgentBarEvent::OpenSettings, window, cx);
101                        }
102                    }),
103            )
104    }
105}
106
107fn component_button(
108    prefix: &SharedString,
109    index: usize,
110    component: AgentComponent,
111    handler: Option<AgentHandler>,
112    cx: &mut App,
113) -> Div {
114    let theme = cx.theme();
115    let color = status_color(component.status, cx);
116    let popover_id = ElementId::Name(format!("{prefix}-{index}-popover").into());
117    let trigger_id = ElementId::Name(format!("{prefix}-{index}-trigger").into());
118    let pulse_id = ElementId::Name(format!("{prefix}-{index}-pulse").into());
119
120    let tooltip = if let Some(detail) = &component.detail {
121        format!(
122            "{} · {}\n{}",
123            component.label,
124            component.status.label(),
125            detail
126        )
127    } else {
128        format!("{} · {}", component.label, component.status.label())
129    };
130
131    let content_component = component.clone();
132    let content_handler = handler.clone();
133    let content_prefix = prefix.clone();
134    let popover = Popover::new(popover_id)
135        .anchor(Anchor::TopLeft)
136        .trigger(
137            Button::new(trigger_id)
138                .icon(component.kind.icon())
139                .ghost()
140                .small()
141                .tooltip(tooltip),
142        )
143        .content(move |_, _, cx| {
144            let popover_entity = cx.entity();
145            let configure_handler = content_handler.clone();
146            let configure_id =
147                ElementId::Name(format!("{content_prefix}-{index}-configure").into());
148            let component = content_component.clone();
149            let id = component.id.clone();
150            let configure_popover = popover_entity.clone();
151
152            v_flex()
153                .w(px(280.))
154                .p_3()
155                .gap_2()
156                .child(
157                    h_flex()
158                        .gap_2()
159                        .child(div().child(component.kind.icon()))
160                        .child(
161                            div()
162                                .text_sm()
163                                .font_weight(FontWeight::SEMIBOLD)
164                                .child(component.label.clone()),
165                        ),
166                )
167                .child(
168                    div()
169                        .text_sm()
170                        .text_color(status_color(component.status, cx))
171                        .child(component.status.label()),
172                )
173                .when_some(component.detail.clone(), |card, detail| {
174                    card.child(
175                        div()
176                            .text_sm()
177                            .text_color(cx.theme().muted_foreground)
178                            .child(detail),
179                    )
180                })
181                .child(
182                    Button::new(configure_id)
183                        .label("Configure")
184                        .outline()
185                        .small()
186                        .on_click(move |_, window, cx| {
187                            if let Some(handler) = &configure_handler {
188                                handler(AgentBarEvent::Configure(id.clone()), window, cx);
189                            }
190                            configure_popover.update(cx, |state, cx| state.dismiss(window, cx));
191                        }),
192                )
193        });
194
195    let dot = div()
196        .absolute()
197        .bottom(px(1.))
198        .right(px(1.))
199        .size(px(7.))
200        .rounded_full()
201        .border_1()
202        .border_color(theme.background)
203        .bg(color);
204
205    let dot: AnyElement = if component.status.is_busy() {
206        dot.with_animation(
207            pulse_id,
208            Animation::new(Duration::from_millis(900)).repeat(),
209            |dot, delta| dot.opacity(0.35 + 0.65 * (1.0 - delta)),
210        )
211        .into_any_element()
212    } else {
213        dot.into_any_element()
214    };
215
216    div().relative().child(popover).child(dot)
217}
218
219fn status_color(status: ComponentStatus, cx: &App) -> Hsla {
220    let theme = cx.theme();
221    match status {
222        ComponentStatus::Inactive => theme.muted_foreground,
223        ComponentStatus::Ready => theme.success,
224        ComponentStatus::Busy => theme.info,
225        ComponentStatus::Error => theme.danger,
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::model::ComponentKind;
233
234    #[test]
235    fn a_bar_collects_components() {
236        let bar = AgentBar::new(
237            "agent",
238            [
239                AgentComponent::new("chat", ComponentKind::Chat, "DeepSeek"),
240                AgentComponent::new("embed", ComponentKind::Embedder, "nomic"),
241            ],
242        );
243        assert_eq!(bar.components.len(), 2);
244    }
245}