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::{TextStyle, Theme, ThemeExt, Typeset};
8
9use crate::{stack, 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    /// [`Theme::element_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        stack::row()
49            .px(px(STEP_PAD_X))
50            .py(px(STEP_PAD_Y))
51            .cursor_pointer()
52            .child(
53                // Tinted here rather than on the row: gpui reads an svg's colour
54                // off its own style, so a colour set on the parent never arrives.
55                crate::icons::icon(icon)
56                    .size(px(14.0))
57                    .text_color(if failed {
58                        theme.danger
59                    } else {
60                        theme.text_muted
61                    }),
62            )
63            .child(
64                div()
65                    .flex_none()
66                    .text_style(TextStyle::Callout)
67                    .text_color(theme.text)
68                    .child(title.into()),
69            )
70            .when_some(detail, |row, detail| {
71                row.child(
72                    div()
73                        .min_w_0()
74                        .truncate()
75                        .text_style(TextStyle::Callout)
76                        .text_color(theme.text_muted)
77                        .child(detail),
78                )
79            })
80            .child(
81                div()
82                    .ml_auto()
83                    .flex_none()
84                    .flex()
85                    .flex_row()
86                    .items_center()
87                    .gap(px(6.0))
88                    .when_some(meta, |cluster, meta| {
89                        cluster.child(
90                            div()
91                                .font_family(theme.font_mono.clone())
92                                .text_style(TextStyle::Callout)
93                                .text_color(theme.text_muted)
94                                .child(meta),
95                        )
96                    })
97                    .when_some(expanded, |cluster, expanded| {
98                        cluster.child(Layout::disclosure(theme, expanded))
99                    }),
100            )
101    }
102
103    /// What a [`Self::step_row`] opens onto: its output, verbatim.
104    ///
105    /// Monospaced and capped, because the thing being shown is a program's
106    /// stdout and the row it hangs off is one line tall — a 900-line stack
107    /// trace pushing the next step off screen is the failure this cap exists
108    /// for. Past the cap it scrolls, which is why it takes an id.
109    ///
110    /// No scrollbar: the wheel reaches it regardless, and a bar would need a
111    /// `ScrollHandle` and a `ScrollbarState` from every caller for a box that
112    /// is usually four lines long. Wrap it in `div().relative()` with
113    /// [`crate::scroll::scrollbar`] over it if a particular one earns the bar.
114    fn step_output(
115        &self,
116        id: impl Into<gpui::ElementId>,
117        text: impl Into<SharedString>,
118    ) -> gpui::Stateful<Div> {
119        let theme = self.theme();
120        div()
121            .id(id)
122            .max_h(px(STEP_OUTPUT_MAX))
123            .overflow_y_scroll()
124            .border_t_1()
125            .border_color(theme.border)
126            .px(px(STEP_PAD_X))
127            .py(px(STEP_PAD_Y))
128            .font_family(theme.font_mono.clone())
129            .text_style(TextStyle::Callout)
130            .text_color(theme.text_muted)
131            .child(text.into())
132    }
133
134    /// The dismissible red error strip (`flex items-start gap-2 rounded-xl
135    /// border border-red-400/20 bg-red-400/[0.06] text-red-300/90` with a
136    /// leading `DangerTriangle mt-0.5 size-4`).
137    fn error_strip(&self, message: impl Into<SharedString>) -> Div {
138        let theme = self.theme();
139        let red = theme.danger; // red-400
140        let red_text = theme.danger_muted; // red-300
141        div()
142            .mt(px(16.0))
143            .px(px(16.0))
144            .py(px(12.0))
145            .rounded(px(Theme::surface_radius()))
146            .border_1()
147            .border_color(red.opacity(0.2))
148            .bg(red.opacity(0.06))
149            .text_style(TextStyle::Callout)
150            .text_color(red_text.opacity(0.9))
151            .flex()
152            .flex_row()
153            .items_start()
154            .gap(px(Theme::SPACE))
155            .child(
156                div().flex_none().mt(px(2.0)).child(
157                    crate::icons::icon(crate::icons::status::DANGER_TRIANGLE)
158                        .size(px(16.0))
159                        .text_color(red_text.opacity(0.9)),
160                ),
161            )
162            .child(div().min_w_0().child(message.into()))
163    }
164
165    /// The amber warning strip (`flex items-start gap-2 border-amber-400/20
166    /// bg-amber-400/[0.06] text-amber-200/90` with a leading `DangerTriangle
167    /// mt-0.5 size-3.5`).
168    fn warning_strip(&self, message: impl Into<SharedString>) -> Div {
169        let theme = self.theme();
170        let amber = theme.warning; // amber-400
171        let amber_text = theme.warning_muted; // amber-200
172        div()
173            .mt(px(8.0))
174            .px(px(16.0))
175            .py(px(10.0))
176            .rounded(px(Theme::surface_radius()))
177            .border_1()
178            .border_color(amber.opacity(0.2))
179            .bg(amber.opacity(0.06))
180            .text_style(TextStyle::Callout)
181            .text_color(amber_text.opacity(0.9))
182            .flex()
183            .flex_row()
184            .items_start()
185            .gap(px(Theme::SPACE))
186            .child(
187                div().flex_none().mt(px(2.0)).child(
188                    crate::icons::icon(crate::icons::status::DANGER_TRIANGLE)
189                        .size(px(14.0))
190                        .text_color(amber_text.opacity(0.9)),
191                ),
192            )
193            .child(div().min_w_0().child(message.into()))
194    }
195}
196
197impl Status for Theme {}