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