From 1db0a0db44b10af52961647b3707ecc04a2594fc 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:00:21 -0400 Subject: [PATCH] T24a: Agent, tool calls - ToolCallCard: a step mark, the tool in mono, what it touched and how long it took; a press opens its arguments and result as code, an error in red - ToolCallGroup folds several calls under one line - ToolApprovalDialog over overlays::Dialog: Deny or Run, one answer; Escape, the scrim and the close deny - PermissionPrompt: deny, always allow or allow once, in place - chat::StepState gains Failed; chat::step_mark draws a step's mark for search steps and tool calls alike - typography::format::took reads ms, seconds or minutes - gallery: an Agent page with calls in each state, a group, the dialog and the prompt --- AGENTS.md | 1 + examples/gallery/pages/agent/mod.rs | 34 ++++ examples/gallery/pages/agent/tools.rs | 131 ++++++++++++++ examples/gallery/pages/mod.rs | 2 + src/agent/mod.rs | 7 + src/agent/permission.rs | 210 ++++++++++++++++++++++ src/agent/tests.rs | 125 +++++++++++++ src/agent/tools.rs | 241 ++++++++++++++++++++++++++ src/chat/mod.rs | 1 + src/chat/search.rs | 67 +++---- src/lib.rs | 1 + src/typography/format.rs | 22 +++ tasks/ch21-30.md | 8 +- 13 files changed, 817 insertions(+), 33 deletions(-) create mode 100644 examples/gallery/pages/agent/mod.rs create mode 100644 examples/gallery/pages/agent/tools.rs create mode 100644 src/agent/mod.rs create mode 100644 src/agent/permission.rs create mode 100644 src/agent/tests.rs create mode 100644 src/agent/tools.rs diff --git a/AGENTS.md b/AGENTS.md index 57c9c61..4ea3be0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui 0.2.2 never clears `debug_bounds`: a test sees an element appear, not leave. - The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence. - A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked. +- A search step, a tool call or an agent's step says where it stands with `chat::StepState` and draws it with `chat::step_mark`. ## Decisions diff --git a/examples/gallery/pages/agent/mod.rs b/examples/gallery/pages/agent/mod.rs new file mode 100644 index 0000000..e3ef183 --- /dev/null +++ b/examples/gallery/pages/agent/mod.rs @@ -0,0 +1,34 @@ +mod tools; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +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.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::DownAt("agent-call", 60.0, 18.0), + Step::UpAt("agent-call", 60.0, 18.0), + Step::Wait(300), + Step::Shot("tool-call"), + Step::Click("agent-approve-open"), + Step::Wait(300), + Step::Shot("approval"), + Step::Key("escape"), + Step::Wait(200), +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(tools::calls(window, cx)) + .child(tools::approval(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/agent/tools.rs b/examples/gallery/pages/agent/tools.rs new file mode 100644 index 0000000..0249fd2 --- /dev/null +++ b/examples/gallery/pages/agent/tools.rs @@ -0,0 +1,131 @@ +use std::time::Duration; + +use ely_gpui_component::{ + agent::{Permission, PermissionPrompt, ToolApprovalDialog, ToolCallCard, ToolCallGroup}, + buttons::{Button, ButtonVariant}, + chat::StepState, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const READ: &str = r#"{ "path": "src/theme/palette.rs" }"#; +const PALETTE: &str = "pub const ACCENT_LIFT: f32 = 0.18;\npub const SURFACE_LIFT: f32 = 0.04;"; +const SEARCH: &str = "{\n \"query\": \"oklch lift contrast\",\n \"limit\": 5\n}"; +const TESTS: &str = r#"{ "command": "cargo test --lib theme" }"#; +const WRITE: &str = + "{\n \"path\": \"src/theme/lift.rs\",\n \"content\": \"pub fn lift(color, by) -> Hsla\"\n}"; + +pub fn calls(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + section( + "ToolCallCard / ToolCallGroup", + "Each call an assistant makes: a mark for where it stands, the tool, what it touched and how long it took; a press opens its arguments and what came back. Several calls fold under one line.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_2() + .child(probe( + "agent-call", + div().w(px(560.)).child( + ToolCallCard::new("agent-read", "read_file", StepState::Done) + .summary("src/theme/palette.rs") + .took(Duration::from_millis(340)) + .arguments(READ) + .result(PALETTE), + ), + )) + .child( + ToolCallCard::new("agent-search", "web_search", StepState::Working) + .summary("oklch lift contrast") + .arguments(SEARCH), + ) + .child( + ToolCallCard::new("agent-tests", "run_tests", StepState::Failed) + .summary("cargo test --lib theme") + .took(Duration::from_millis(12_400)) + .arguments(TESTS) + .result("2 tests failed: lift_keeps_hue, dark_lifts_further"), + ) + .child( + ToolCallCard::new("agent-write", "write_file", StepState::Waiting) + .summary("src/theme/lift.rs") + .arguments(WRITE), + ) + .child( + ToolCallGroup::new("agent-group", "Read 3 files") + .child(ToolCallCard::new("agent-group-1", "read_file", StepState::Done).summary("src/theme/mod.rs").took(Duration::from_millis(120))) + .child(ToolCallCard::new("agent-group-2", "read_file", StepState::Done).summary("src/theme/tokens.rs").took(Duration::from_millis(95))) + .child(ToolCallCard::new("agent-group-3", "read_file", StepState::Done).summary("src/motion/curve.rs").took(Duration::from_millis(80))), + ), + ) +} + +pub fn approval(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let asking = keep("agent-asking", || false, window, cx); + let heard = keep("agent-heard", || None::, window, cx); + let allowed = keep("agent-allowed", || None::, window, cx); + let (open, now_heard, now_allowed) = + (*asking.read(cx), heard.read(cx).clone(), *allowed.read(cx)); + let (opener, closer) = (asking.clone(), asking); + let theme = cx.theme(); + let note = |text: String| { + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(text) + }; + section( + "ToolApprovalDialog / PermissionPrompt", + "Asking first: a dialog before one call runs, with its arguments, where Escape denies; and a prompt in place that allows once, always, or not at all.", + cx, + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .child(probe( + "agent-approve-open", + Button::new("agent-approve", "Review the call…") + .variant(ButtonVariant::Secondary) + .on_click(move |_, _, cx| set(&opener, true, cx)), + )) + .children(now_heard.map(|text| note(text.to_string()))), + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_2() + .child( + PermissionPrompt::new("agent-permission", "Let the assistant run commands in this folder?", move |answer, _, cx| { + set(&allowed, Some(answer), cx) + }) + .detail("cargo test --lib theme"), + ) + .children(now_allowed.map(|answer| { + note(match answer { + Permission::Once => "Allowed once.".to_string(), + Permission::Always => "Always allowed in this folder.".to_string(), + Permission::Deny => "Denied.".to_string(), + }) + })), + ) + .children(open.then(|| { + ToolApprovalDialog::new("agent-approval", "write_file", WRITE, move |approved, _, cx| { + let verdict = if approved { "Ran write_file." } else { "Denied write_file." }; + set(&heard, Some(verdict.into()), cx); + set(&closer, false, cx) + }) + .detail("Writes a new file in src/theme.") + })) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index e049cff..34ed265 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,3 +1,4 @@ +mod agent; mod buttons; mod charts; mod chat; @@ -61,6 +62,7 @@ pub const ALL: &[Page] = &[ documents::PAGE, collab::PAGE, chat::PAGE, + agent::PAGE, theme::PAGE, ]; diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..88960ab --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,7 @@ +mod permission; +#[cfg(all(test, feature = "test-support"))] +mod tests; +mod tools; + +pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog}; +pub use tools::{ToolCallCard, ToolCallGroup}; diff --git a/src/agent/permission.rs b/src/agent/permission.rs new file mode 100644 index 0000000..773fd11 --- /dev/null +++ b/src/agent/permission.rs @@ -0,0 +1,210 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + chat::CodeBlock, + overlays::Dialog, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, +}; + +type OnAnswer = Rc; + +/// Asks before a tool runs: what it will do and its arguments, then Deny or Run. Escape, the scrim and Deny refuse it. Render it while the call waits. +#[derive(IntoElement)] +pub struct ToolApprovalDialog { + id: ElementId, + tool: SharedString, + arguments: SharedString, + detail: Option, + on_answer: OnAnswer, +} + +impl ToolApprovalDialog { + /// `arguments` as JSON; `on_answer` gets whether the call may run. + pub fn new( + id: impl Into, + tool: impl Into, + arguments: impl Into, + on_answer: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + tool: tool.into(), + arguments: arguments.into(), + detail: None, + on_answer: Rc::new(on_answer), + } + } + + /// A line on what the call will do. + pub fn detail(mut self, text: impl Into) -> Self { + self.detail = Some(text.into()); + self + } +} + +impl RenderOnce for ToolApprovalDialog { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let approved = Rc::new(Cell::new(false)); + let (answer, tool, chosen) = (self.on_answer, self.tool.clone(), approved.clone()); + let (deny, run) = ((self.id.clone(), "deny"), (self.id.clone(), "run")); + Dialog::new( + self.id.clone(), + format!("Run {}?", self.tool), + move |window, cx| { + let approved = chosen.get(); + log::info!( + "tool approval: {tool} {}", + if approved { "runs" } else { "denied" } + ); + answer(approved, window, cx) + }, + ) + .when_some(self.detail, |dialog, detail| dialog.detail(detail)) + .child(CodeBlock::new((self.id.clone(), "arguments"), self.arguments).language("json")) + .action(move |close| { + Button::new(deny, "Deny") + .variant(ButtonVariant::Secondary) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |close| { + Button::new(run, "Run") + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| { + approved.set(true); + close(window, cx) + }) + }) + } +} + +/// An answer to a request for permission. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Permission { + Once, + Always, + Deny, +} + +type OnPermission = Rc; + +/// Asks in place whether the agent may do something: allow once, always allow, or deny. +#[derive(IntoElement)] +pub struct PermissionPrompt { + id: ElementId, + title: SharedString, + detail: Option, + on_answer: OnPermission, +} + +impl PermissionPrompt { + pub fn new( + id: impl Into, + title: impl Into, + on_answer: impl Fn(Permission, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + detail: None, + on_answer: Rc::new(on_answer), + } + } + + /// What the permission covers, such as the command or the folder. + pub fn detail(mut self, text: impl Into) -> Self { + self.detail = Some(text.into()); + self + } +} + +impl RenderOnce for PermissionPrompt { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let choice = |key: &'static str, label: &'static str, variant, answer: Permission| { + let on_answer = self.on_answer.clone(); + Button::new((self.id.clone(), key), label) + .variant(variant) + .on_click(move |_, window, cx| { + log::info!("permission: {answer:?}"); + on_answer(answer, window, cx) + }) + }; + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Icon::new(IconName::Shield) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(self.title.clone()), + ), + ) + .children(self.detail.clone().map(|detail| { + div().w_full().flex().child( + div() + .flex_1() + .min_w_0() + .px_2() + .py_1p5() + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .font_family(theme.mono_family.clone()) + .text_color(colors.fg_muted) + .child(detail), + ) + })) + .child( + div() + .flex() + .flex_wrap() + .justify_end() + .gap_2() + .child(choice( + "deny", + "Deny", + ButtonVariant::Ghost, + Permission::Deny, + )) + .child(choice( + "always", + "Always allow", + ButtonVariant::Secondary, + Permission::Always, + )) + .child(choice( + "once", + "Allow once", + ButtonVariant::Primary, + Permission::Once, + )), + ) + } +} diff --git a/src/agent/tests.rs b/src/agent/tests.rs new file mode 100644 index 0000000..6396cf9 --- /dev/null +++ b/src/agent/tests.rs @@ -0,0 +1,125 @@ +use gpui::{ + Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, +}; + +use super::{Permission, PermissionPrompt, ToolApprovalDialog}; +use crate::{forms, primitives::FocusNext, theme::Theme}; + +/// An approval dialog while open, and the answers it gave. +struct Asking { + open: bool, + answers: Vec, +} + +impl Render for Asking { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + div().size_full().children(self.open.then(|| { + ToolApprovalDialog::new( + "approve", + "write_file", + r#"{ "path": "lift.rs" }"#, + move |approved, _, cx| { + view.update(cx, |asking, cx| { + asking.answers.push(approved); + asking.open = false; + cx.notify(); + }) + }, + ) + })) + } +} + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +/// Presses and releases `key`, a frame apart. +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn asking(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (view, cx) = cx.add_window_view(|_, _| Asking { + open: true, + answers: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (view, cx) +} + +#[gpui::test] +fn escape_denies_the_call(cx: &mut TestAppContext) { + let (view, cx) = asking(cx); + press("escape", cx); + assert_eq!( + view.read_with(cx, |asking, _| asking.answers.clone()), + [false] + ); +} + +#[gpui::test] +fn run_approves_the_call_once(cx: &mut TestAppContext) { + let (view, cx) = asking(cx); + press("tab", cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert_eq!( + view.read_with(cx, |asking, _| asking.answers.clone()), + [true] + ); +} + +/// A permission prompt and the answers it gave. +struct Prompting { + answers: Vec, +} + +impl Render for Prompting { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + div().w_full().child( + PermissionPrompt::new("permission", "Run commands here?", move |answer, _, cx| { + view.update(cx, |prompting, _| prompting.answers.push(answer)) + }) + .detail("cargo test"), + ) + } +} + +#[gpui::test] +fn the_prompt_walks_deny_always_then_once(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let (view, cx) = cx.add_window_view(|_, _| Prompting { + answers: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + for _ in 0..3 { + cx.update(|window, _| window.focus_next()); + } + press("enter", cx); + assert_eq!( + view.read_with(cx, |prompting, _| prompting.answers.clone()), + [Permission::Once] + ); +} diff --git a/src/agent/tools.rs b/src/agent/tools.rs new file mode 100644 index 0000000..97ca5c6 --- /dev/null +++ b/src/agent/tools.rs @@ -0,0 +1,241 @@ +use std::time::Duration; + +use gpui::{ + AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement, + Styled, Window, div, prelude::*, transparent_black, +}; +use smallvec::SmallVec; + +use crate::{ + chat::{CodeBlock, StepState, step_mark}, + primitives::{Disclosure, FocusRing, Icon, IconName, Severity}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::{Ellipsis, format::took, tabular}, +}; + +/// A row that opens a body through `toggle`: a Tab stop while it has one. +fn header(id: ElementId, toggle: Option>, cx: &App) -> Stateful
{ + let theme = cx.theme(); + div() + .id(id.clone()) + .w_full() + .flex() + .items_center() + .gap_2() + .px_3() + .py_2() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(transparent_black()) + .when_some(toggle, |row, state| { + row.tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| { + state.update(cx, |open, cx| { + *open = !*open; + log::info!("tool call: open {open}"); + cx.notify(); + }) + }) + }) +} + +/// One call to a tool: its status, name, what it touched and how long it took; opened, its arguments and what came back. +#[derive(IntoElement)] +pub struct ToolCallCard { + id: ElementId, + name: SharedString, + status: StepState, + summary: Option, + took: Option, + arguments: Option, + result: Option, +} + +impl ToolCallCard { + pub fn new(id: impl Into, name: impl Into, status: StepState) -> Self { + Self { + id: id.into(), + name: name.into(), + status, + summary: None, + took: None, + arguments: None, + result: None, + } + } + + /// A few words on what the call touched, such as a path or a query. + pub fn summary(mut self, text: impl Into) -> Self { + self.summary = Some(text.into()); + self + } + + pub fn took(mut self, took: Duration) -> Self { + self.took = Some(took); + self + } + + /// The arguments, as JSON. + pub fn arguments(mut self, json: impl Into) -> Self { + self.arguments = Some(json.into()); + self + } + + /// What came back, or the error when the call failed. + pub fn result(mut self, text: impl Into) -> Self { + self.result = Some(text.into()); + self + } +} + +impl RenderOnce for ToolCallCard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false); + let open = *state.read(cx); + let opens = self.arguments.is_some() || self.result.is_some(); + let failed = self.status == StepState::Failed; + let theme = cx.theme(); + let colors = theme.colors.clone(); + let label = |text: &'static str| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_subtle) + .child(text) + }; + div() + .w_full() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .child( + header( + (self.id.clone(), "header").into(), + opens.then_some(state), + cx, + ) + .child(step_mark((self.id.clone(), "mark").into(), self.status, cx)) + .child( + div() + .flex_none() + .font_family(theme.mono_family.clone()) + .text_color(colors.fg) + .child(self.name), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_muted) + .children(self.summary.map(Ellipsis::new)), + ) + .children(self.took.map(|spent| { + tabular(div().flex_none().text_color(colors.fg_subtle)).child(took(spent)) + })) + .when(opens, |row| { + row.child( + Disclosure::new((self.id.clone(), "chevron"), open).size(IconSize::Sm), + ) + }), + ) + .when(open && opens, |card| { + card.child( + div() + .flex() + .flex_col() + .gap_2() + .px_3() + .pt_1() + .pb_3() + .children(self.arguments.map(|json| { + div() + .flex() + .flex_col() + .gap_1() + .child(label("Arguments")) + .child( + CodeBlock::new((self.id.clone(), "arguments"), json) + .language("json"), + ) + })) + .children(self.result.map(|text| { + div() + .flex() + .flex_col() + .gap_1() + .child(label(if failed { "Error" } else { "Result" })) + .child(if failed { + div() + .text_color(Severity::Danger.color(&colors)) + .child(text) + .into_any_element() + } else { + CodeBlock::new((self.id.clone(), "result"), text) + .into_any_element() + }) + })), + ) + }) + } +} + +/// Several tool calls under one line, shut until opened, such as "Read 3 files". +#[derive(IntoElement)] +pub struct ToolCallGroup { + id: ElementId, + label: SharedString, + calls: SmallVec<[AnyElement; 4]>, +} + +impl ToolCallGroup { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + calls: SmallVec::new(), + } + } +} + +impl ParentElement for ToolCallGroup { + fn extend(&mut self, elements: impl IntoIterator) { + self.calls.extend(elements); + } +} + +impl RenderOnce for ToolCallGroup { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false); + let open = *state.read(cx); + 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( + header((self.id.clone(), "header").into(), Some(state), cx) + .px_1() + .py_0p5() + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::Wrench) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ) + .child(div().flex_1().min_w_0().child(Ellipsis::new(self.label))) + .child(Disclosure::new((self.id.clone(), "chevron"), open).size(IconSize::Sm)), + ) + .when(open, |group| { + group.child(div().flex().flex_col().gap_2().pl_3().children(self.calls)) + }) + } +} diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 756363b..2ff2256 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -37,6 +37,7 @@ pub use play::{AudioMessage, VideoMessage}; pub use projects::{ ConversationExport, Project, ProjectKnowledgePanel, ProjectList, SharedConversationView, }; +pub(crate) use search::step_mark; pub use search::{DocumentChunkPreview, SearchProgress, StepState, WebResultCard}; pub use status::{ ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator, diff --git a/src/chat/search.rs b/src/chat/search.rs index 1f53ece..71db9ad 100644 --- a/src/chat/search.rs +++ b/src/chat/search.rs @@ -1,9 +1,9 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton, - ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, StyledText, - Window, div, prelude::*, transparent_black, + AnyElement, App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, + StyledText, Window, div, prelude::*, transparent_black, }; use super::cite::Source; @@ -15,12 +15,43 @@ use crate::{ typography::{Ellipsis, tabular}, }; -/// Where a search step stands. +/// Where a step stands: a search's, a tool call's or an agent's. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StepState { Waiting, Working, Done, + 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 { + let theme = cx.theme(); + let colors = &theme.colors; + 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::Working => Spinner::new(id).size(IconSize::Sm).into_any_element(), + StepState::Waiting => div() + .size(theme.status_dot()) + .rounded_full() + .bg(colors.fg_subtle) + .into_any_element(), + }; + div() + .flex_none() + .size(theme.icon_size(IconSize::Sm)) + .flex() + .items_center() + .justify_center() + .child(mark) + .into_any_element() } /// A search as it runs, step by step: what it looks for, what it reads, each step waiting, working or done. @@ -59,41 +90,19 @@ impl RenderOnce for SearchProgress { .into_iter() .enumerate() .map(|(ix, (label, state))| { - let mark = match state { - StepState::Done => Icon::new(IconName::Check) - .size(IconSize::Sm) - .color(colors.success) - .into_any_element(), - StepState::Working => { - Spinner::new((self.id.clone(), format!("step-{ix}"))) - .size(IconSize::Sm) - .into_any_element() - } - StepState::Waiting => div() - .size(theme.status_dot()) - .rounded_full() - .bg(colors.fg_subtle) - .into_any_element(), - }; + let id = (self.id.clone(), format!("step-{ix}")).into(); div() .flex() .items_center() .gap_2() - .child( - div() - .flex_none() - .size(theme.icon_size(IconSize::Sm)) - .flex() - .items_center() - .justify_center() - .child(mark), - ) + .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), ) diff --git a/src/lib.rs b/src/lib.rs index d4d5279..f09d118 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent; mod assets; pub mod buttons; diff --git a/src/typography/format.rs b/src/typography/format.rs index 03cce25..8544995 100644 --- a/src/typography/format.rs +++ b/src/typography/format.rs @@ -136,6 +136,17 @@ pub fn duration(seconds: u64, style: DurationStyle) -> String { } } +/// How long a step took: "340 ms", "1.2 s", "4m 5s". +pub fn took(spent: std::time::Duration) -> String { + let ms = spent.as_millis() as u64; + let tenths = (ms + 50) / 100; + match ms { + 0..1_000 => format!("{ms} ms"), + _ if tenths < 600 => format!("{}.{} s", tenths / 10, tenths % 10), + _ => duration((ms + 500) / 1_000, DurationStyle::Compact), + } +} + /// Digits kept past a step's first; float noise sits near the sixteenth. const DIGITS: usize = 6; @@ -204,6 +215,17 @@ pub fn datetime(at: Timestamp, zone: &TimeZone, pattern: &str) -> Result