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 {}