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:
ZacharyZhang-NY committed 2026-09-26 08:00:21 -04:00
1 parent 85b01098a1
commit 1db0a0db44
13 files changed
+817 -33

No files matched your search

+7
View File
@@ -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};
+210
View File
@@ -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,
)),
)
}
}
+125
View File
@@ -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]
);
}
+241
View File
@@ -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))
})
}
}