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