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
This commit is contained in:
13 files changed
+817
-33
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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::<SharedString>, window, cx);
|
||||
let allowed = keep("agent-allowed", || None::<Permission>, 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.")
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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};
|
||||
@@ -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<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<SharedString>,
|
||||
on_answer: OnAnswer,
|
||||
}
|
||||
|
||||
impl ToolApprovalDialog {
|
||||
/// `arguments` as JSON; `on_answer` gets whether the call may run.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
tool: impl Into<SharedString>,
|
||||
arguments: impl Into<SharedString>,
|
||||
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<SharedString>) -> 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<dyn Fn(Permission, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<SharedString>,
|
||||
on_answer: OnPermission,
|
||||
}
|
||||
|
||||
impl PermissionPrompt {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
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<SharedString>) -> 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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<bool>,
|
||||
}
|
||||
|
||||
impl Render for Asking {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Asking>, &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<Permission>,
|
||||
}
|
||||
|
||||
impl Render for Prompting {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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]
|
||||
);
|
||||
}
|
||||
@@ -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<Entity<bool>>, cx: &App) -> Stateful<Div> {
|
||||
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<SharedString>,
|
||||
took: Option<Duration>,
|
||||
arguments: Option<SharedString>,
|
||||
result: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl ToolCallCard {
|
||||
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, 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<SharedString>) -> 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<SharedString>) -> 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<SharedString>) -> 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<ElementId>, label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
calls: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ToolCallGroup {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+38
-29
@@ -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),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod agent;
|
||||
mod assets;
|
||||
|
||||
pub mod buttons;
|
||||
|
||||
@@ -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<String,
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn took_reads_in_the_unit_that_fits() {
|
||||
let took = |ms| took(std::time::Duration::from_millis(ms));
|
||||
assert_eq!(took(340), "340 ms");
|
||||
assert_eq!(took(999), "999 ms");
|
||||
assert_eq!(took(1_000), "1.0 s");
|
||||
assert_eq!(took(12_449), "12.4 s");
|
||||
assert_eq!(took(59_960), "1m 0s", "rounding never shows 60.0 s");
|
||||
assert_eq!(took(65_000), "1m 5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_read_to_the_places_they_need() {
|
||||
assert_eq!(
|
||||
|
||||
+4
-4
@@ -164,10 +164,10 @@
|
||||
|
||||
## T24a Agent · Tool calls — `src/agent`
|
||||
|
||||
- [ ] ToolCallCard
|
||||
- [ ] ToolCallGroup
|
||||
- [ ] ToolApprovalDialog
|
||||
- [ ] PermissionPrompt
|
||||
- [x] ToolCallCard
|
||||
- [x] ToolCallGroup
|
||||
- [x] ToolApprovalDialog
|
||||
- [x] PermissionPrompt
|
||||
|
||||
## T24b Agent · Progress
|
||||
|
||||
|
||||
Reference in new issue
Block a user