1use gpui::{
22 AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
23 prelude::FluentBuilder, px,
24};
25use gpui_kit_semantics::{NodeSpec, Role, Semantic};
26use gpui_kit_theme::{ActiveTheme, Space, TextTone, Theme, TypeScale};
27
28use crate::display::badge::Tone;
29use crate::display::progress::ProgressBar;
30use crate::display::status::StatusDot;
31use crate::foundation::{Ident, StyledExt, text};
32use crate::strings::{ActiveStrings, StringKey};
33
34const RAIL: f32 = 16.0;
36
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
43pub enum StepState {
44 #[default]
45 Pending,
46 Running,
47 Done,
48 Failed(SharedString),
50 Skipped(SharedString),
52}
53
54impl StepState {
55 pub fn as_str(&self) -> &'static str {
57 match self {
58 Self::Pending => "pending",
59 Self::Running => "running",
60 Self::Done => "done",
61 Self::Failed(_) => "failed",
62 Self::Skipped(_) => "skipped",
63 }
64 }
65
66 pub fn reason(&self) -> Option<&SharedString> {
68 match self {
69 Self::Failed(reason) | Self::Skipped(reason) => Some(reason),
70 _ => None,
71 }
72 }
73
74 pub fn tone(&self) -> Tone {
75 match self {
76 Self::Pending => Tone::Neutral,
77 Self::Running => Tone::Accent,
78 Self::Done => Tone::Success,
79 Self::Failed(_) => Tone::Danger,
80 Self::Skipped(_) => Tone::Warning,
81 }
82 }
83
84 fn key(&self) -> StringKey {
85 match self {
86 Self::Pending => StringKey::AgentPending,
87 Self::Running => StringKey::AgentRunning,
88 Self::Done => StringKey::AgentDone,
89 Self::Failed(_) => StringKey::AgentFailed,
90 Self::Skipped(_) => StringKey::AgentSkipped,
91 }
92 }
93}
94
95pub struct Step {
97 id: SharedString,
98 title: SharedString,
99 state: StepState,
100 body: Option<AnyElement>,
101}
102
103impl std::fmt::Debug for Step {
104 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 formatter
106 .debug_struct("Step")
107 .field("id", &self.id)
108 .field("title", &self.title)
109 .field("state", &self.state)
110 .field("has_body", &self.body.is_some())
111 .finish()
112 }
113}
114
115impl Step {
116 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
117 Self {
118 id: id.into(),
119 title: title.into(),
120 state: StepState::Pending,
121 body: None,
122 }
123 }
124
125 pub fn state(mut self, state: StepState) -> Self {
126 self.state = state;
127 self
128 }
129
130 pub fn body(mut self, body: impl IntoElement) -> Self {
133 self.body = Some(body.into_any_element());
134 self
135 }
136
137 pub fn id(&self) -> &SharedString {
138 &self.id
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum RunLength {
149 #[default]
151 Known,
152 Unknown,
154}
155
156impl RunLength {
157 pub fn as_str(self) -> &'static str {
158 match self {
159 Self::Known => "known",
160 Self::Unknown => "unknown",
161 }
162 }
163}
164
165#[derive(IntoElement)]
167pub struct StepList {
168 ident: Ident,
169 steps: Vec<Step>,
170 length: RunLength,
171}
172
173impl std::fmt::Debug for StepList {
174 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 formatter
176 .debug_struct("StepList")
177 .field("ident", &self.ident)
178 .field("steps", &self.steps.len())
179 .field("length", &self.length)
180 .finish()
181 }
182}
183
184impl StepList {
185 pub fn new(ident: impl Into<Ident>) -> Self {
186 Self {
187 ident: ident.into(),
188 steps: Vec::new(),
189 length: RunLength::Known,
190 }
191 }
192
193 pub fn step(mut self, step: Step) -> Self {
194 self.steps.push(step);
195 self
196 }
197
198 pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self {
199 self.steps.extend(steps);
200 self
201 }
202
203 pub fn length(mut self, length: RunLength) -> Self {
205 self.length = length;
206 self
207 }
208
209 fn done(&self) -> usize {
210 self.steps
211 .iter()
212 .filter(|step| matches!(step.state, StepState::Done))
213 .count()
214 }
215}
216
217impl RenderOnce for StepList {
218 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
219 let theme = cx.theme().clone();
220 let ident = self.ident.clone();
221 let total = self.steps.len();
222 let done = self.done();
223
224 let summary = ProgressBar::new(ident.child("progress"));
228 let summary = match self.length {
229 RunLength::Known => summary.count(done, total),
230 RunLength::Unknown => summary.display(if done == 1 {
231 cx.strings().text(StringKey::AgentStepsDoneOne)
232 } else {
233 cx.strings()
234 .format(StringKey::AgentStepsDoneMany, &[&done.to_string()])
235 }),
236 };
237
238 let last = total.saturating_sub(1);
239 let mut run = div().w_full().column().gap_token(&theme, Space::Md);
240 for (index, step) in self.steps.into_iter().enumerate() {
241 run = run.child(step_element(&ident, &theme, step, index < last, cx));
242 }
243
244 div()
245 .w_full()
246 .column()
247 .gap_token(&theme, Space::Md)
248 .child(summary)
249 .child(run)
250 .semantic_in(
251 cx,
252 NodeSpec::new(ident.semantic_id(), Role::List)
253 .value(total.to_string())
256 .busy(matches!(self.length, RunLength::Unknown)),
257 )
258 }
259}
260
261fn step_element(
262 list: &Ident,
263 theme: &Theme,
264 step: Step,
265 continues: bool,
266 cx: &mut App,
267) -> AnyElement {
268 let ident = list.child(step.id.as_ref());
269 let running = matches!(step.state, StepState::Running);
270
271 let rail = div()
272 .w(px(RAIL))
273 .flex_none()
274 .column()
275 .items_center()
276 .child(div().mt(px(4.0)).child({
277 let dot = StatusDot::new(step.state.tone());
278 if running {
281 dot.busy(ident.child("mark"))
282 } else {
283 dot
284 }
285 }))
286 .when(continues, |element| {
287 element.child(
288 div()
289 .mt(px(4.0))
290 .w(px(theme.borders.hairline))
291 .flex_1()
292 .min_h(px(theme.space(Space::Md)))
293 .bg(theme.colors.hairline),
294 )
295 });
296
297 let reason = step.state.reason().cloned().map(|reason| {
298 let state = step.state.as_str();
299 text(theme, TypeScale::Body, reason.clone())
300 .text_color(step.state.tone().color(theme))
301 .semantic_in(
302 cx,
303 NodeSpec::new(ident.child("reason").semantic_id(), Role::Status)
304 .parent(ident.semantic_id())
305 .text(reason)
308 .value(state),
309 )
310 });
311
312 div()
313 .row()
314 .items_start()
315 .w_full()
316 .gap_token(theme, Space::Sm)
317 .child(rail)
318 .child(
319 div()
320 .column()
321 .flex_1()
322 .min_w_0()
323 .gap(px(2.0))
324 .child(
325 div()
326 .row()
327 .gap_token(theme, Space::Sm)
328 .child(
329 text(theme, TypeScale::Label, step.title.clone())
330 .flex_1()
331 .min_w_0(),
332 )
333 .child(
334 text(
335 theme,
336 TypeScale::Caption,
337 cx.strings().text(step.state.key()),
338 )
339 .flex_none()
340 .text_tone(theme, TextTone::Faint),
341 ),
342 )
343 .children(reason)
344 .children(
345 step.body
346 .map(|body| div().mt_token(theme, Space::Xs).child(body)),
347 ),
348 )
349 .semantic_in(
350 cx,
351 NodeSpec::new(ident.semantic_id(), Role::Row)
352 .parent(list.semantic_id())
353 .text(step.title.clone())
354 .value(step.state.as_str())
355 .busy(running),
356 )
357 .into_any_element()
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn every_state_publishes_its_own_name() {
366 let names = [
367 StepState::Pending.as_str(),
368 StepState::Running.as_str(),
369 StepState::Done.as_str(),
370 StepState::Failed("boom".into()).as_str(),
371 StepState::Skipped("nothing to do".into()).as_str(),
372 ];
373 let mut unique = names.to_vec();
374 unique.sort_unstable();
375 unique.dedup();
376 assert_eq!(unique.len(), names.len());
377 }
378
379 #[test]
380 fn only_a_state_the_host_explained_carries_a_reason() {
381 assert!(StepState::Done.reason().is_none());
382 assert_eq!(
383 StepState::Skipped("nothing to do".into())
384 .reason()
385 .map(SharedString::to_string),
386 Some("nothing to do".to_string())
387 );
388 }
389}