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