From 2d78db57a8fb007a741b12ce7b347c1127d17688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 08:25:51 -0400 Subject: [PATCH] T24b: Agent, progress - AgentStepList: an agent's steps down a data_display::Timeline rail, marked by chat::step_mark, with detail and time - AgentPlan: items waiting, at work or done, with a count; done items go quiet and struck through - chat::step_mark reads motion::changes: a check or cross that arrives pops in, still on first paint - AgentStatus: a pill for planning, working, waiting, done or failed - AgentProgress: the step at work, done of total, the time so far and a bar split by step; progress past its total fails loud - SubAgentTree is lists::Tree with each agent's state as its note - gallery: a plan the button advances, steps, statuses and a tree --- examples/gallery/pages/agent/mod.rs | 8 +- examples/gallery/pages/agent/progress.rs | 162 ++++++++++++ src/agent/mod.rs | 2 + src/agent/progress.rs | 317 +++++++++++++++++++++++ src/agent/tests.rs | 8 +- src/agent/tools.rs | 3 +- src/chat/search.rs | 82 +++--- tasks/ch21-30.md | 10 +- 8 files changed, 551 insertions(+), 41 deletions(-) create mode 100644 examples/gallery/pages/agent/progress.rs create mode 100644 src/agent/progress.rs diff --git a/examples/gallery/pages/agent/mod.rs b/examples/gallery/pages/agent/mod.rs index 11d392d..ce1dfdc 100644 --- a/examples/gallery/pages/agent/mod.rs +++ b/examples/gallery/pages/agent/mod.rs @@ -1,3 +1,4 @@ +mod progress; mod tools; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -9,7 +10,7 @@ pub const PAGE: Page = Page { number: 24, slug: "agent", title: "Agent", - summary: "An assistant at work: the tools it calls and what it asks before it acts.", + summary: "An assistant at work: the tools it calls, what it asks before it acts, and how far it has come.", render, script: SCRIPT, }; @@ -27,11 +28,16 @@ const SCRIPT: &[Step] = &[ Step::DownAt("agent-call", 60.0, 18.0), Step::UpAt("agent-call", 60.0, 18.0), Step::Wait(200), + Step::Click("agent-advance"), + Step::Wait(300), + Step::Shot("plan"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { div() .child(tools::calls(window, cx)) .child(tools::approval(window, cx)) + .child(progress::steps(window, cx)) + .child(progress::agents(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/agent/progress.rs b/examples/gallery/pages/agent/progress.rs new file mode 100644 index 0000000..ea93c4c --- /dev/null +++ b/examples/gallery/pages/agent/progress.rs @@ -0,0 +1,162 @@ +use std::time::Duration; + +use ely_gpui_component::{ + agent::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList}, + buttons::{Button, ButtonVariant}, + chat::StepState, + data_display::Tone, + lists::{Tree, TreeNode}, + primitives::IconName, +}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const PLAN: [&str; 5] = [ + "Read the theme's palette", + "Measure contrast in both themes", + "Lift the accents for dark", + "Run the theme tests", + "Write the release note", +]; + +/// Where each item of the plan stands once `done` have finished; the next one works. +fn plan(done: usize) -> Vec<(&'static str, StepState)> { + PLAN.iter() + .enumerate() + .map(|(ix, label)| { + let state = match ix.cmp(&done) { + std::cmp::Ordering::Less => StepState::Done, + std::cmp::Ordering::Equal => StepState::Working, + std::cmp::Ordering::Greater => StepState::Waiting, + }; + (*label, state) + }) + .collect() +} + +/// A step as the agent reports it. +fn step(title: &str, state: StepState, detail: Option<&str>, took: Option) -> AgentStep { + AgentStep { + title: title.to_string().into(), + state, + detail: detail.map(|text| text.to_string().into()), + took: took.map(Duration::from_millis), + } +} + +pub fn steps(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let done = keep("agent-plan-done", || 2_usize, window, cx); + let now = *done.read(cx); + let state = if now == PLAN.len() { + AgentState::Done + } else { + AgentState::Working + }; + let current = PLAN.get(now).copied().unwrap_or("All done"); + let steps = [ + step( + "Read src/theme/palette.rs", + StepState::Done, + None, + Some(340), + ), + step( + "Searched the web for oklch lift", + StepState::Done, + Some("Five sources, two on contrast in dark themes."), + Some(2_100), + ), + step( + "Ran the theme tests", + StepState::Failed, + Some("2 failed: lift_keeps_hue, dark_lifts_further"), + Some(12_400), + ), + step("Fixing the lift for dark", StepState::Working, None, None), + step("Run the tests again", StepState::Waiting, None, None), + ]; + section( + "AgentStepList / TaskTimeline / AgentPlan / TodoList / AgentStatus / AgentProgress", + "An agent's work as it goes: its steps down a rail, its plan ticking off as each item finishes, where it stands in a word, and how far it has come. The button finishes the next item.", + cx, + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .child(AgentStatus::new("agent-status", state)) + .child(AgentStatus::new("agent-status-waiting", AgentState::Waiting)) + .child(AgentStatus::new("agent-status-failed", AgentState::Failed)), + ) + .child( + div() + .flex() + .items_start() + .gap_8() + .child( + div() + .w(px(320.)) + .flex() + .flex_col() + .gap_5() + .child(AgentPlan::new("agent-plan", "Lift the dark theme", plan(now))) + .child( + AgentProgress::new("agent-progress", current, now, PLAN.len()) + .elapsed(Duration::from_secs(95 + 40 * now as u64)), + ), + ) + .child(div().w(px(360.)).child(AgentStepList::new("agent-steps", steps))), + ) + .child(probe( + "agent-advance", + Button::new("agent-advance", "Finish the next item") + .variant(ButtonVariant::Secondary) + .disabled(now == PLAN.len()) + .on_click(move |_, _, cx| { + let next = (*done.read(cx) + 1).min(PLAN.len()); + set(&done, next, cx) + }), + )) +} + +pub fn agents(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let agent = |key: &str, name: &str, state: StepState| { + let (note, tone) = match state { + StepState::Waiting => ("waiting", Tone::Neutral), + StepState::Working => ("working", Tone::Neutral), + StepState::Done => ("done", Tone::Neutral), + StepState::Failed => ("failed", Tone::Danger), + }; + TreeNode::new(key.to_string(), name.to_string()) + .icon(IconName::Bot) + .note(note) + .tone(tone) + }; + section( + "SubAgentTree", + "Agents an agent sent out, and theirs in turn, each with where it stands: lists::Tree, a node per agent with its state as its note, a failed one tinted.", + cx, + ) + .child( + div().w(px(420.)).h(px(220.)).child( + Tree::new( + "agent-tree", + [agent("lead", "Lead: lift the dark theme", StepState::Working).children([ + agent("research", "Research: contrast in dark themes", StepState::Done), + agent("code", "Code: change the lift", StepState::Working).children([ + agent("tests", "Tests: run the theme suite", StepState::Failed), + agent("fix", "Fix: dark lifts further", StepState::Working), + ]), + agent("notes", "Notes: write the release note", StepState::Waiting), + ])], + ) + .open(["lead", "code"]) + .size_full(), + ), + ) +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 88960ab..fbc04cc 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,7 +1,9 @@ mod permission; +mod progress; #[cfg(all(test, feature = "test-support"))] mod tests; mod tools; pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog}; +pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList}; pub use tools::{ToolCallCard, ToolCallGroup}; diff --git a/src/agent/progress.rs b/src/agent/progress.rs new file mode 100644 index 0000000..76f727f --- /dev/null +++ b/src/agent/progress.rs @@ -0,0 +1,317 @@ +use std::time::Duration; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, +}; + +use crate::{ + chat::{StepState, step_mark}, + data_display::{Timeline, TimelineItem}, + motion::{ProgressBar, Spinner}, + primitives::{Icon, IconName, Severity}, + theme::{ActiveTheme, IconSize, TextSize}, + typography::{Ellipsis, format::took, tabular}, +}; + +/// One step of an agent's work: what it did, where it stands, a line below, how long it took. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentStep { + pub title: SharedString, + pub state: StepState, + pub detail: Option, + pub took: Option, +} + +/// An agent's steps down a rail, each marked by where it stands. +#[derive(IntoElement)] +pub struct AgentStepList { + id: ElementId, + steps: Vec, +} + +impl AgentStepList { + pub fn new(id: impl Into, steps: impl IntoIterator) -> Self { + Self { + id: id.into(), + steps: steps.into_iter().collect(), + } + } +} + +impl RenderOnce for AgentStepList { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let marks: Vec = (self.steps.iter().enumerate()) + .map(|(ix, step)| { + step_mark( + (self.id.clone(), format!("step-{ix}")).into(), + step.state, + window, + cx, + ) + }) + .collect(); + let colors = cx.theme().colors.clone(); + self.steps + .into_iter() + .zip(marks) + .fold(Timeline::new(), |timeline, (step, mark)| { + let title = div() + .text_color(match step.state { + StepState::Waiting => colors.fg_subtle, + _ => colors.fg, + }) + .child(step.title); + let item = TimelineItem::new(title) + .marker(mark) + .when_some(step.took, |item, spent| item.time(took(spent))); + timeline.item(match step.detail { + Some(detail) => item.child(detail), + None => item, + }) + }) + } +} + +/// An agent's plan: items waiting, at work or done, with how many are done; a done item goes quiet and struck through. +#[derive(IntoElement)] +pub struct AgentPlan { + id: ElementId, + title: SharedString, + items: Vec<(SharedString, StepState)>, +} + +impl AgentPlan { + pub fn new( + id: impl Into, + title: impl Into, + items: impl IntoIterator, StepState)>, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + items: items + .into_iter() + .map(|(label, state)| (label.into(), state)) + .collect(), + } + } +} + +impl RenderOnce for AgentPlan { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let marks: Vec = (self.items.iter().enumerate()) + .map(|(ix, (_, state))| { + step_mark( + (self.id.clone(), format!("item-{ix}")).into(), + *state, + window, + cx, + ) + }) + .collect(); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let done = self + .items + .iter() + .filter(|(_, state)| *state == StepState::Done) + .count(); + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .child(Ellipsis::new(self.title)), + ) + .child( + tabular(div().flex_none().text_color(colors.fg_subtle)) + .child(format!("{done} of {}", self.items.len())), + ), + ) + .children( + self.items + .into_iter() + .zip(marks) + .map(|((label, state), mark)| { + div().flex().items_center().gap_2().child(mark).child( + div() + .flex_1() + .min_w_0() + .text_color(match state { + StepState::Waiting => colors.fg_muted, + StepState::Working => colors.fg, + StepState::Done => colors.fg_subtle, + StepState::Failed => Severity::Danger.color(&colors), + }) + .when(state == StepState::Done, |text| text.line_through()) + .child(label), + ) + }), + ) + } +} + +/// Where an agent stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentState { + Planning, + Working, + Waiting, + Done, + Failed, +} + +impl AgentState { + pub fn label(self) -> &'static str { + match self { + Self::Planning => "Planning", + Self::Working => "Working", + Self::Waiting => "Waiting for you", + Self::Done => "Done", + Self::Failed => "Failed", + } + } +} + +/// A pill that says where an agent stands, moving while it plans or works. +#[derive(IntoElement)] +pub struct AgentStatus { + id: ElementId, + state: AgentState, +} + +impl AgentStatus { + pub fn new(id: impl Into, state: AgentState) -> Self { + Self { + id: id.into(), + state, + } + } +} + +impl RenderOnce for AgentStatus { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let tone = match self.state { + AgentState::Planning | AgentState::Working => Severity::Info, + AgentState::Waiting => Severity::Warning, + AgentState::Done => Severity::Success, + AgentState::Failed => Severity::Danger, + }; + let ink = tone.color(&colors); + let mark = match self.state { + AgentState::Planning | AgentState::Working => Spinner::new((self.id.clone(), "mark")) + .size(IconSize::Xs) + .color(ink) + .into_any_element(), + AgentState::Waiting => Icon::new(IconName::Hand) + .size(IconSize::Xs) + .color(ink) + .into_any_element(), + AgentState::Done => Icon::new(IconName::Check) + .size(IconSize::Xs) + .color(ink) + .into_any_element(), + AgentState::Failed => Icon::new(IconName::X) + .size(IconSize::Xs) + .color(ink) + .into_any_element(), + }; + div() + .flex() + .items_center() + .gap_1p5() + .px_2() + .py_0p5() + .rounded_full() + .bg(tone.subtle(&colors)) + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(ink) + .child(mark) + .child(self.state.label()) + } +} + +/// How far an agent has come: the step at work, how many are done of how many, the time so far, and a bar split by step. +#[derive(IntoElement)] +pub struct AgentProgress { + id: ElementId, + current: SharedString, + done: usize, + total: usize, + elapsed: Option, +} + +impl AgentProgress { + pub fn new( + id: impl Into, + current: impl Into, + done: usize, + total: usize, + ) -> Self { + assert!(total > 0 && done <= total, "progress of {done} in {total}"); + Self { + id: id.into(), + current: current.into(), + done, + total, + elapsed: None, + } + } + + pub fn elapsed(mut self, elapsed: Duration) -> Self { + self.elapsed = Some(elapsed); + self + } +} + +impl RenderOnce for AgentProgress { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(Ellipsis::new(self.current)), + ) + .child( + tabular(div().flex_none().text_color(colors.fg_subtle)) + .child(format!("{} of {}", self.done, self.total)), + ) + .children(self.elapsed.map(|spent| { + tabular(div().flex_none().text_color(colors.fg_subtle)).child(took(spent)) + })), + ) + .child( + ProgressBar::new((self.id, "bar"), self.done as f32 / self.total as f32) + .segments(self.total), + ) + } +} diff --git a/src/agent/tests.rs b/src/agent/tests.rs index 6396cf9..3b0c7d3 100644 --- a/src/agent/tests.rs +++ b/src/agent/tests.rs @@ -3,7 +3,7 @@ use gpui::{ TestAppContext, VisualTestContext, Window, div, }; -use super::{Permission, PermissionPrompt, ToolApprovalDialog}; +use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog}; use crate::{forms, primitives::FocusNext, theme::Theme}; /// An approval dialog while open, and the answers it gave. @@ -123,3 +123,9 @@ fn the_prompt_walks_deny_always_then_once(cx: &mut TestAppContext) { [Permission::Once] ); } + +#[test] +#[should_panic(expected = "progress of 6 in 5")] +fn progress_past_its_total_fails_loud() { + let _ = AgentProgress::new("progress", "Writing", 6, 5); +} diff --git a/src/agent/tools.rs b/src/agent/tools.rs index c568115..19e7018 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -98,6 +98,7 @@ impl RenderOnce for ToolCallCard { let open = *state.read(cx); let opens = self.arguments.is_some() || self.result.is_some(); let failed = self.status == StepState::Failed; + let mark = step_mark((self.id.clone(), "mark").into(), self.status, window, cx); let theme = cx.theme(); let colors = theme.colors.clone(); let label = |text: &'static str| { @@ -120,7 +121,7 @@ impl RenderOnce for ToolCallCard { opens.then_some(state), cx, ) - .child(step_mark((self.id.clone(), "mark").into(), self.status, cx)) + .child(mark) .child( div() .min_w_0() diff --git a/src/chat/search.rs b/src/chat/search.rs index 71db9ad..7bb9270 100644 --- a/src/chat/search.rs +++ b/src/chat/search.rs @@ -1,15 +1,15 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - AnyElement, App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, - MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, - StyledText, Window, div, prelude::*, transparent_black, + Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, HighlightStyle, Hsla, + InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*, transparent_black, }; use super::cite::Source; use crate::{ forms::Run, - motion::Spinner, + motion::{self, Spinner}, primitives::{FocusRing, Icon, IconName}, theme::{ActiveTheme, IconSize, Radius, TextSize}, typography::{Ellipsis, tabular}, @@ -24,19 +24,31 @@ pub enum StepState { Failed, } -/// A step's mark in an icon's box: a dot waiting, a spinner working, a check done, a cross failed. -pub(crate) fn step_mark(id: ElementId, state: StepState, cx: &App) -> AnyElement { +/// A step's mark in an icon's box: a dot waiting, a spinner working, a check done, a cross failed. A check or cross that arrives pops in. +pub(crate) fn step_mark( + id: ElementId, + state: StepState, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let changes = motion::changes((id.clone(), "changes"), state, window, cx); let theme = cx.theme(); let colors = &theme.colors; + let pop = |icon: IconName, ink: Hsla| { + let icon = Icon::new(icon).size(IconSize::Sm).color(ink); + if changes == 0 { + return icon.into_any_element(); + } + icon.with_animation( + (id.clone(), format!("pop-{changes}")), + Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic), + move |icon, t| icon.scale(0.6 + 0.4 * t).color(ink.opacity(t)), + ) + .into_any_element() + }; let mark = match state { - StepState::Done => Icon::new(IconName::Check) - .size(IconSize::Sm) - .color(colors.success) - .into_any_element(), - StepState::Failed => Icon::new(IconName::X) - .size(IconSize::Sm) - .color(colors.danger) - .into_any_element(), + StepState::Done => pop(IconName::Check, colors.success), + StepState::Failed => pop(IconName::X, colors.danger), StepState::Working => Spinner::new(id).size(IconSize::Sm).into_any_element(), StepState::Waiting => div() .size(theme.status_dot()) @@ -77,7 +89,17 @@ impl SearchProgress { } impl RenderOnce for SearchProgress { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let marks: Vec = (self.steps.iter().enumerate()) + .map(|(ix, (_, state))| { + step_mark( + (self.id.clone(), format!("step-{ix}")).into(), + *state, + window, + cx, + ) + }) + .collect(); let theme = cx.theme(); let colors = theme.colors.clone(); div() @@ -88,24 +110,18 @@ impl RenderOnce for SearchProgress { .children( self.steps .into_iter() - .enumerate() - .map(|(ix, (label, state))| { - let id = (self.id.clone(), format!("step-{ix}")).into(); - div() - .flex() - .items_center() - .gap_2() - .child(step_mark(id, state, cx)) - .child( - div() - .text_color(match state { - StepState::Waiting => colors.fg_subtle, - StepState::Working => colors.fg, - StepState::Done => colors.fg_muted, - StepState::Failed => colors.danger, - }) - .child(label), - ) + .zip(marks) + .map(|((label, state), mark)| { + div().flex().items_center().gap_2().child(mark).child( + div() + .text_color(match state { + StepState::Waiting => colors.fg_subtle, + StepState::Working => colors.fg, + StepState::Done => colors.fg_muted, + StepState::Failed => colors.danger, + }) + .child(label), + ) }), ) } diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 9397e0f..4aa3219 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -171,11 +171,11 @@ ## T24b Agent · Progress -- [ ] AgentStepList / TaskTimeline -- [ ] AgentPlan / TodoList -- [ ] AgentStatus -- [ ] AgentProgress -- [ ] SubAgentTree +- [x] AgentStepList / TaskTimeline → `agent::AgentStepList`, over `data_display::Timeline` +- [x] AgentPlan / TodoList → `agent::AgentPlan`, marks that pop in as items finish +- [x] AgentStatus +- [x] AgentProgress +- [x] SubAgentTree → `lists::Tree`, a node per agent with its state as note and tone ## T24c Agent · Changes