Skip to main content

ui/widgets/
status.rs

1//! Status surfaces — the step row and its output, and the alert strips.
2//!
3//! A catalog trait, like every widget group: import it to unlock
4//! `theme.step_row(..)`, `theme.step_output(..)`, `theme.error_strip(..)`.
5
6use gpui::{Div, SharedString, div, prelude::*, px};
7use theme::{Theme, ThemeExt};
8
9use crate::widgets::Layout;
10
11/// Padding inside a [`Status::step_row`] and the [`Status::step_output`] under
12/// it — the two have to agree or the output's first character sits left of the
13/// title.
14const STEP_PAD_X: f32 = 10.0;
15const STEP_PAD_Y: f32 = 6.0;
16/// How tall an output may get before it scrolls (`ToolCard.svelte`'s
17/// `max-h-64`).
18const STEP_OUTPUT_MAX: f32 = 256.0;
19
20pub trait Status: ThemeExt {
21    /// One operation, as a row: an icon, what it was, and how it went.
22    ///
23    /// A tool call in a transcript, a step in a CI run, a file in a migration —
24    /// the shape is the same everywhere, which is why this takes strings and
25    /// not a type that knows what any of them mean. `detail` is the truncating
26    /// middle (a query, a path, a `· 3` count), `meta` the right-aligned figure
27    /// that never truncates (a duration, a size, a row count).
28    ///
29    /// `expanded` is `None` when there is nothing under the row, and the
30    /// chevron is simply absent — a disclosure that opens onto nothing is worse
31    /// than no disclosure. `Some` renders it, and the caller owns the flag.
32    ///
33    /// Returns a plain `Div` like the rest of this module: the caller adds
34    /// `.id(..)` and `.on_click(..)` **to this row**, never to a wrapper around
35    /// it, or the hitbox ends up narrower than what it paints. Hover is
36    /// caller-owned (gpui panics on a second hover); the default wash is
37    /// [`step_row_hover`](crate::widgets::step_row_hover).
38    fn step_row(
39        &self,
40        icon: &'static str,
41        title: impl Into<SharedString>,
42        detail: Option<SharedString>,
43        meta: Option<SharedString>,
44        failed: bool,
45        expanded: Option<bool>,
46    ) -> Div {
47        let theme = self.theme();
48        div()
49            .flex()
50            .flex_row()
51            .items_center()
52            .gap(px(8.0))
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_size(px(12.5))
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_size(px(12.0))
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_size(px(12.0))
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    fn step_output(
119        &self,
120        id: impl Into<gpui::ElementId>,
121        text: impl Into<SharedString>,
122    ) -> gpui::Stateful<Div> {
123        let theme = self.theme();
124        div()
125            .id(id)
126            .max_h(px(STEP_OUTPUT_MAX))
127            .overflow_y_scroll()
128            .border_t_1()
129            .border_color(theme.border)
130            .px(px(STEP_PAD_X))
131            .py(px(STEP_PAD_Y))
132            .font_family(theme.font_mono.clone())
133            .text_size(px(12.0))
134            .text_color(theme.text_muted)
135            .child(text.into())
136    }
137
138    /// The dismissible red error strip (`flex items-start gap-2 rounded-xl
139    /// border border-red-400/20 bg-red-400/[0.06] text-red-300/90` with a
140    /// leading `DangerTriangle mt-0.5 size-4`).
141    fn error_strip(&self, message: impl Into<SharedString>) -> Div {
142        let theme = self.theme();
143        let red = theme.danger; // red-400
144        let red_text = theme.danger_muted; // red-300
145        div()
146            .mt(px(16.0))
147            .px(px(16.0))
148            .py(px(12.0))
149            .rounded(px(Theme::surface_radius()))
150            .border_1()
151            .border_color(red.opacity(0.2))
152            .bg(red.opacity(0.06))
153            .text_size(px(12.5))
154            .text_color(red_text.opacity(0.9))
155            .flex()
156            .flex_row()
157            .items_start()
158            .gap(px(8.0))
159            .child(
160                div().flex_none().mt(px(2.0)).child(
161                    crate::icons::icon(crate::icons::DANGER_TRIANGLE)
162                        .size(px(16.0))
163                        .text_color(red_text.opacity(0.9)),
164                ),
165            )
166            .child(div().min_w_0().child(message.into()))
167    }
168
169    /// The amber warning strip (`flex items-start gap-2 border-amber-400/20
170    /// bg-amber-400/[0.06] text-amber-200/90` with a leading `DangerTriangle
171    /// mt-0.5 size-3.5`).
172    fn warning_strip(&self, message: impl Into<SharedString>) -> Div {
173        let theme = self.theme();
174        let amber = theme.warning; // amber-400
175        let amber_text = theme.warning_muted; // amber-200
176        div()
177            .mt(px(8.0))
178            .px(px(16.0))
179            .py(px(10.0))
180            .rounded(px(Theme::surface_radius()))
181            .border_1()
182            .border_color(amber.opacity(0.2))
183            .bg(amber.opacity(0.06))
184            .text_size(px(12.0))
185            .text_color(amber_text.opacity(0.9))
186            .flex()
187            .flex_row()
188            .items_start()
189            .gap(px(8.0))
190            .child(
191                div().flex_none().mt(px(2.0)).child(
192                    crate::icons::icon(crate::icons::DANGER_TRIANGLE)
193                        .size(px(14.0))
194                        .text_color(amber_text.opacity(0.9)),
195                ),
196            )
197            .child(div().min_w_0().child(message.into()))
198    }
199}
200
201impl Status for Theme {}