Skip to main content

ui/widgets/
status.rs

1//! Status surfaces — the step row and its output, the alert strips, and the
2//! status bar.
3//!
4//! A catalog trait, like every widget group: import it to unlock
5//! `theme.step_row(..)`, `theme.step_output(..)`, `theme.error_strip(..)`.
6
7use std::path::Path;
8
9use gpui::{ClipboardItem, Div, ElementId, SharedString, Stateful, div, prelude::*, px};
10use icons::Icon;
11use theme::{TextStyle, Theme, ThemeExt, Typeset};
12
13use crate::{stack, tooltip::Tooltip, widgets::Layout};
14
15/// Padding inside a [`Status::step_row`] and the [`Status::step_output`] under
16/// it — the two have to agree or the output's first character sits left of the
17/// title.
18const STEP_PAD_X: f32 = 10.0;
19const STEP_PAD_Y: f32 = 6.0;
20/// How tall an output may get before it scrolls (`ToolCard.svelte`'s
21/// `max-h-64`).
22const STEP_OUTPUT_MAX: f32 = 256.0;
23
24pub trait Status: ThemeExt {
25    /// One operation, as a row: an icon, what it was, and how it went.
26    ///
27    /// A tool call in a transcript, a step in a CI run, a file in a migration —
28    /// the shape is the same everywhere, which is why this takes strings and
29    /// not a type that knows what any of them mean. `detail` is the truncating
30    /// middle (a query, a path, a `· 3` count), `meta` the right-aligned figure
31    /// that never truncates (a duration, a size, a row count).
32    ///
33    /// `expanded` is `None` when there is nothing under the row, and the
34    /// chevron is simply absent — a disclosure that opens onto nothing is worse
35    /// than no disclosure. `Some` renders it, and the caller owns the flag.
36    ///
37    /// Returns a plain `Div` like the rest of this module: the caller adds
38    /// `.id(..)` and `.on_click(..)` **to this row**, never to a wrapper around
39    /// it, or the hitbox ends up narrower than what it paints. Hover is
40    /// caller-owned (gpui panics on a second hover); the default wash is
41    /// [`Theme::element_hover`].
42    fn step_row(
43        &self,
44        icon: impl Into<Icon>,
45        title: impl Into<SharedString>,
46        detail: Option<SharedString>,
47        meta: Option<SharedString>,
48        failed: bool,
49        expanded: Option<bool>,
50    ) -> Div {
51        let theme = self.theme();
52        stack::row()
53            .px(px(STEP_PAD_X))
54            .py(px(STEP_PAD_Y))
55            .cursor_pointer()
56            .child(
57                // Tinted here rather than on the row: gpui reads an svg's colour
58                // off its own style, so a colour set on the parent never arrives.
59                crate::icons::icon(icon)
60                    .size(px(14.0))
61                    .text_color(if failed {
62                        theme.danger
63                    } else {
64                        theme.text_muted
65                    }),
66            )
67            .child(
68                div()
69                    .flex_none()
70                    .text_style(TextStyle::Callout)
71                    .text_color(theme.text)
72                    .child(title.into()),
73            )
74            .when_some(detail, |row, detail| {
75                row.child(
76                    div()
77                        .min_w_0()
78                        .truncate()
79                        .text_style(TextStyle::Callout)
80                        .text_color(theme.text_muted)
81                        .child(detail),
82                )
83            })
84            .child(
85                div()
86                    .ml_auto()
87                    .flex_none()
88                    .flex()
89                    .flex_row()
90                    .items_center()
91                    .gap(px(6.0))
92                    .when_some(meta, |cluster, meta| {
93                        cluster.child(
94                            div()
95                                .font_family(theme.font_mono.clone())
96                                .text_style(TextStyle::Callout)
97                                .text_color(theme.text_muted)
98                                .child(meta),
99                        )
100                    })
101                    .when_some(expanded, |cluster, expanded| {
102                        cluster.child(Layout::disclosure(theme, expanded))
103                    }),
104            )
105    }
106
107    /// What a [`Self::step_row`] opens onto: its output, verbatim.
108    ///
109    /// Monospaced and capped, because the thing being shown is a program's
110    /// stdout and the row it hangs off is one line tall — a 900-line stack
111    /// trace pushing the next step off screen is the failure this cap exists
112    /// for. Past the cap it scrolls, which is why it takes an id.
113    ///
114    /// No scrollbar: the wheel reaches it regardless, and a bar would need a
115    /// `ScrollHandle` and a `ScrollbarState` from every caller for a box that
116    /// is usually four lines long. Wrap it in `div().relative()` with
117    /// [`crate::scroll::scrollbar`] over it if a particular one earns the bar.
118    ///
119    /// A wheel over the box is the box's, and never the page's
120    /// ([`crate::scroll::contain_wheel`]): gpui hands one to every pane under
121    /// the pointer, and this one hangs off a row in a page that scrolls too.
122    fn step_output(
123        &self,
124        id: impl Into<gpui::ElementId>,
125        text: impl Into<SharedString>,
126    ) -> gpui::Stateful<Div> {
127        let theme = self.theme();
128        crate::scroll::contain_wheel(
129            crate::scroll::pane(id, crate::scroll::Axes::Vertical),
130            crate::scroll::Axes::Vertical,
131        )
132        .max_h(px(STEP_OUTPUT_MAX))
133        .border_t_1()
134        .border_color(theme.border)
135        .px(px(STEP_PAD_X))
136        .py(px(STEP_PAD_Y))
137        .font_family(theme.font_mono.clone())
138        .text_style(TextStyle::Callout)
139        .text_color(theme.text_muted)
140        .child(text.into())
141    }
142
143    /// The red error strip (`flex items-start gap-2 rounded-xl
144    /// border border-red-400/20 bg-red-400/[0.06] text-red-300/90` with a
145    /// leading `TriangleAlert mt-0.5 size-4`). No dismiss control; the caller
146    /// adds one as a child.
147    fn error_strip(&self, message: impl Into<SharedString>) -> Div {
148        let theme = self.theme();
149        let red = theme.danger; // red-400
150        let red_text = theme.danger_muted; // red-300
151        div()
152            .mt(px(16.0))
153            .px(px(16.0))
154            .py(px(12.0))
155            .rounded(px(Theme::surface_radius()))
156            .border_1()
157            .border_color(red.opacity(0.2))
158            .bg(red.opacity(0.06))
159            .text_style(TextStyle::Callout)
160            .text_color(red_text.opacity(0.9))
161            .flex()
162            .flex_row()
163            .items_start()
164            .gap(px(Theme::SPACE))
165            .child(
166                div().flex_none().mt(px(2.0)).child(
167                    crate::icons::icon(crate::icons::glyph::TriangleAlert)
168                        .size(px(16.0))
169                        .text_color(red_text.opacity(0.9)),
170                ),
171            )
172            .child(div().min_w_0().child(message.into()))
173    }
174
175    /// The amber warning strip (`flex items-start gap-2 border-amber-400/20
176    /// bg-amber-400/[0.06] text-amber-200/90` with a leading `TriangleAlert
177    /// mt-0.5 size-3.5`).
178    fn warning_strip(&self, message: impl Into<SharedString>) -> Div {
179        let theme = self.theme();
180        let amber = theme.warning; // amber-400
181        let amber_text = theme.warning_muted; // amber-200
182        div()
183            .mt(px(8.0))
184            .px(px(16.0))
185            .py(px(10.0))
186            .rounded(px(Theme::surface_radius()))
187            .border_1()
188            .border_color(amber.opacity(0.2))
189            .bg(amber.opacity(0.06))
190            .text_style(TextStyle::Callout)
191            .text_color(amber_text.opacity(0.9))
192            .flex()
193            .flex_row()
194            .items_start()
195            .gap(px(Theme::SPACE))
196            .child(
197                div().flex_none().mt(px(2.0)).child(
198                    crate::icons::icon(crate::icons::glyph::TriangleAlert)
199                        .size(px(14.0))
200                        .text_color(amber_text.opacity(0.9)),
201                ),
202            )
203            .child(div().min_w_0().child(message.into()))
204    }
205
206    /// A pane's bottom row: 30px under a top border, in muted `Caption` text.
207    fn status_bar(&self) -> Div {
208        let theme = self.theme();
209        div()
210            .h(px(30.0))
211            .flex_none()
212            .flex()
213            .items_center()
214            .gap(px(8.0))
215            .px(px(8.0))
216            .border_t_1()
217            .border_color(theme.border)
218            .text_style(TextStyle::Caption)
219            .text_color(theme.text_muted)
220    }
221
222    /// A [`Self::status_bar`] cell showing `label` for `path`. It takes the
223    /// room left, truncates, tooltips the full path and copies it on click.
224    fn status_path(
225        &self,
226        id: impl Into<ElementId>,
227        path: &Path,
228        label: impl Into<SharedString>,
229    ) -> Stateful<Div> {
230        let full = path.display().to_string();
231        let tip: SharedString = format!("{full}\nClick to copy path").into();
232        div()
233            .id(id)
234            .flex_1()
235            .min_w_0()
236            .truncate()
237            .cursor_pointer()
238            .tooltip(move |window, cx| Tooltip::text(tip.clone(), window, cx))
239            .on_click(move |_, _, cx| {
240                cx.write_to_clipboard(ClipboardItem::new_string(full.clone()))
241            })
242            .child(label.into())
243    }
244}
245
246impl Status for Theme {}