Skip to main content

gpui_kit/agent/
step_list.rs

1//! An ordered run of steps, each with its own state and its own body.
2//!
3//! # A run of unknown length has no progress
4//!
5//! [`ProgressBar`] already draws the
6//! line this component has to keep: a bar reports a position only when the
7//! extent of the work is known, and an unknown extent is drawn as an unknown
8//! extent rather than as a bar that happens to be part full. A run whose steps
9//! are still arriving is exactly that case — nobody has counted the work — so
10//! [`RunLength::Unknown`] makes the summary indeterminate, publishes how many
11//! steps are done, and publishes no fraction at all. Inventing one would mean
12//! dividing by a total nobody established.
13//!
14//! # The list applies nothing
15//!
16//! Which step is running, which failed, and why one was skipped are the
17//! caller's facts. A step's reason is the host's own words, shown verbatim,
18//! and a step may carry any body the caller builds — usually a
19//! [`ToolCallCard`](crate::agent::tool_call::ToolCallCard).
20
21use 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
34/// How wide the column holding the rail and its dots is.
35const RAIL: f32 = 16.0;
36
37/// Where one step has got to.
38///
39/// A step that did not run and a step that ran and failed are different
40/// sentences, and both carry the host's reason rather than a word this crate
41/// chose.
42#[derive(Debug, Clone, PartialEq, Eq, Default)]
43pub enum StepState {
44    #[default]
45    Pending,
46    Running,
47    Done,
48    /// The step ran and did not succeed, in the host's own words.
49    Failed(SharedString),
50    /// The step never ran, and this is why.
51    Skipped(SharedString),
52}
53
54impl StepState {
55    /// The name the semantic node publishes.
56    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    /// The host's words, where the host said any.
67    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
95/// One step of a run.
96pub 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    /// What the step is made of, usually a
131    /// [`ToolCallCard`](crate::agent::tool_call::ToolCallCard).
132    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/// Whether the caller has counted the run.
143///
144/// This is [`PageTotal`](crate::navigation::pagination::PageTotal) for a run:
145/// a host streaming steps as they are decided knows only that more are coming,
146/// so that is all the list claims.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum RunLength {
149    /// Every step in the run is in this list.
150    #[default]
151    Known,
152    /// More steps will arrive and nobody knows how many.
153    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/// An ordered run of steps with a summary of how far it has got.
166#[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    /// Whether every step of the run is already in this list.
204    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        // A counted run reports a position; an uncounted one reports only what
225        // has finished, and the bar stays indeterminate rather than dividing
226        // by a total nobody has.
227        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                    // The count is what the list holds, which under an unknown
254                    // length is not the same as what the run contains.
255                    .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            // The step that is running is the one a reader is looking for, so
279            // it is the one that moves.
280            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                    // The host's words, and the state they belong to, so a
306                    // skipped step is never read as a failed one.
307                    .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}