agent: Render path search results with ToolCard (#28894)

Implementing the `ToolCard` for the path_search tool. It also adds the
"jump to file" functionality if you expand the results.

Release Notes:

- N/A

---------

Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: Agus Zubiaga <hi@aguz.me>
This commit is contained in:
Danilo Leal 2025-04-25 14:42:51 -03:00 committed by GitHub
parent 3aa313010f
commit c3570fbcf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 285 additions and 35 deletions

1
Cargo.lock generated
View file

@ -735,7 +735,6 @@ dependencies = [
"web_search", "web_search",
"workspace", "workspace",
"workspace-hack", "workspace-hack",
"worktree",
"zed_llm_client", "zed_llm_client",
] ]

View file

@ -37,9 +37,8 @@ serde_json.workspace = true
ui.workspace = true ui.workspace = true
util.workspace = true util.workspace = true
web_search.workspace = true web_search.workspace = true
workspace.workspace = true
workspace-hack.workspace = true workspace-hack.workspace = true
worktree.workspace = true workspace.workspace = true
zed_llm_client.workspace = true zed_llm_client.workspace = true
[dev-dependencies] [dev-dependencies]

View file

@ -1,15 +1,21 @@
use crate::schema::json_schema_for; use crate::{schema::json_schema_for, ui::ToolCallCardHeader};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use assistant_tool::{ActionLog, Tool, ToolResult}; use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; use editor::Editor;
use futures::channel::oneshot::{self, Receiver};
use gpui::{
AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window,
};
use language;
use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
use project::Project; use project::Project;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{cmp, fmt::Write as _, path::PathBuf, sync::Arc}; use std::fmt::Write;
use ui::IconName; use std::{cmp, path::PathBuf, sync::Arc};
use util::paths::PathMatcher; use ui::{Disclosure, Tooltip, prelude::*};
use worktree::Snapshot; use util::{ResultExt, paths::PathMatcher};
use workspace::Workspace;
#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct FindPathToolInput { pub struct FindPathToolInput {
@ -29,7 +35,7 @@ pub struct FindPathToolInput {
/// Optional starting position for paginated results (0-based). /// Optional starting position for paginated results (0-based).
/// When not provided, starts from the beginning. /// When not provided, starts from the beginning.
#[serde(default)] #[serde(default)]
pub offset: u32, pub offset: usize,
} }
const RESULTS_PER_PAGE: usize = 50; const RESULTS_PER_PAGE: usize = 50;
@ -77,13 +83,20 @@ impl Tool for FindPathTool {
Ok(input) => (input.offset, input.glob), Ok(input) => (input.offset, input.glob),
Err(err) => return Task::ready(Err(anyhow!(err))).into(), Err(err) => return Task::ready(Err(anyhow!(err))).into(),
}; };
let offset = offset as usize;
let task = search_paths(&glob, project, cx); let (sender, receiver) = oneshot::channel();
cx.background_spawn(async move {
let matches = task.await?; let card = cx.new(|cx| FindPathToolCard::new(glob.clone(), receiver, cx));
let paginated_matches = &matches[cmp::min(offset, matches.len())
let search_paths_task = search_paths(&glob, project, cx);
let task = cx.background_spawn(async move {
let matches = search_paths_task.await?;
let paginated_matches: &[PathBuf] = &matches[cmp::min(offset, matches.len())
..cmp::min(offset + RESULTS_PER_PAGE, matches.len())]; ..cmp::min(offset + RESULTS_PER_PAGE, matches.len())];
sender.send(paginated_matches.to_vec()).log_err();
if matches.is_empty() { if matches.is_empty() {
Ok("No matches found".to_string()) Ok("No matches found".to_string())
} else { } else {
@ -102,8 +115,12 @@ impl Tool for FindPathTool {
} }
Ok(message) Ok(message)
} }
}) });
.into()
ToolResult {
output: task,
card: Some(card.into()),
}
} }
} }
@ -115,7 +132,7 @@ fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Resu
Ok(matcher) => matcher, Ok(matcher) => matcher,
Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
}; };
let snapshots: Vec<Snapshot> = project let snapshots: Vec<_> = project
.read(cx) .read(cx)
.worktrees(cx) .worktrees(cx)
.map(|worktree| worktree.read(cx).snapshot()) .map(|worktree| worktree.read(cx).snapshot())
@ -135,6 +152,209 @@ fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Resu
}) })
} }
struct FindPathToolCard {
paths: Vec<PathBuf>,
expanded: bool,
glob: String,
_receiver_task: Option<Task<Result<()>>>,
}
impl FindPathToolCard {
fn new(glob: String, receiver: Receiver<Vec<PathBuf>>, cx: &mut Context<Self>) -> Self {
let _receiver_task = cx.spawn(async move |this, cx| {
let paths = receiver.await?;
this.update(cx, |this, _cx| {
this.paths = paths;
})
.log_err();
Ok(())
});
Self {
paths: Vec::new(),
expanded: false,
glob,
_receiver_task: Some(_receiver_task),
}
}
}
impl ToolCard for FindPathToolCard {
fn render(
&mut self,
_status: &ToolUseStatus,
_window: &mut Window,
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let matches_label: SharedString = if self.paths.len() == 0 {
"No matches".into()
} else if self.paths.len() == 1 {
"1 match".into()
} else {
format!("{} matches", self.paths.len()).into()
};
let glob_label = self.glob.to_string();
let content = if !self.paths.is_empty() && self.expanded {
Some(
v_flex()
.relative()
.ml_1p5()
.px_1p5()
.gap_0p5()
.border_l_1()
.border_color(cx.theme().colors().border_variant)
.children(self.paths.iter().enumerate().map(|(index, path)| {
let path_clone = path.clone();
let workspace_clone = workspace.clone();
let button_label = path.to_string_lossy().to_string();
Button::new(("path", index), button_label)
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::XSmall)
.icon_position(IconPosition::End)
.label_size(LabelSize::Small)
.color(Color::Muted)
.tooltip(Tooltip::text("Jump to File"))
.on_click(move |_, window, cx| {
workspace_clone
.update(cx, |workspace, cx| {
let path = PathBuf::from(&path_clone);
let Some(project_path) = workspace
.project()
.read(cx)
.find_project_path(&path, cx)
else {
return;
};
let open_task = workspace.open_path(
project_path,
None,
true,
window,
cx,
);
window
.spawn(cx, async move |cx| {
let item = open_task.await?;
if let Some(active_editor) =
item.downcast::<Editor>()
{
active_editor
.update_in(cx, |editor, window, cx| {
editor.go_to_singleton_buffer_point(
language::Point::new(0, 0),
window,
cx,
);
})
.log_err();
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
})
.ok();
})
}))
.into_any(),
)
} else {
None
};
v_flex()
.mb_2()
.gap_1()
.child(
ToolCallCardHeader::new(IconName::SearchCode, matches_label)
.with_code_path(glob_label)
.disclosure_slot(
Disclosure::new("path-search-disclosure", self.expanded)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.disabled(self.paths.is_empty())
.on_click(cx.listener(move |this, _, _, _cx| {
this.expanded = !this.expanded;
})),
),
)
.children(content)
}
}
impl Component for FindPathTool {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn sort_name() -> &'static str {
"FindPathTool"
}
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let successful_card = cx.new(|_| FindPathToolCard {
paths: vec![
PathBuf::from("src/main.rs"),
PathBuf::from("src/lib.rs"),
PathBuf::from("tests/test.rs"),
],
expanded: true,
glob: "*.rs".to_string(),
_receiver_task: None,
});
let empty_card = cx.new(|_| FindPathToolCard {
paths: Vec::new(),
expanded: false,
glob: "*.nonexistent".to_string(),
_receiver_task: None,
});
Some(
v_flex()
.gap_6()
.children(vec![example_group(vec![
single_example(
"With Paths",
div()
.size_full()
.child(successful_card.update(cx, |tool, cx| {
tool.render(
&ToolUseStatus::Finished("".into()),
window,
WeakEntity::new_invalid(),
cx,
)
.into_any_element()
}))
.into_any_element(),
),
single_example(
"No Paths",
div()
.size_full()
.child(empty_card.update(cx, |tool, cx| {
tool.render(
&ToolUseStatus::Finished("".into()),
window,
WeakEntity::new_invalid(),
cx,
)
.into_any_element()
}))
.into_any_element(),
),
])])
.into_any_element(),
)
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View file

@ -1,4 +1,4 @@
use gpui::{Animation, AnimationExt, App, IntoElement, pulsating_between}; use gpui::{Animation, AnimationExt, AnyElement, App, IntoElement, pulsating_between};
use std::time::Duration; use std::time::Duration;
use ui::{Tooltip, prelude::*}; use ui::{Tooltip, prelude::*};
@ -8,6 +8,8 @@ pub struct ToolCallCardHeader {
icon: IconName, icon: IconName,
primary_text: SharedString, primary_text: SharedString,
secondary_text: Option<SharedString>, secondary_text: Option<SharedString>,
code_path: Option<SharedString>,
disclosure_slot: Option<AnyElement>,
is_loading: bool, is_loading: bool,
error: Option<String>, error: Option<String>,
} }
@ -18,6 +20,8 @@ impl ToolCallCardHeader {
icon, icon,
primary_text: primary_text.into(), primary_text: primary_text.into(),
secondary_text: None, secondary_text: None,
code_path: None,
disclosure_slot: None,
is_loading: false, is_loading: false,
error: None, error: None,
} }
@ -28,6 +32,16 @@ impl ToolCallCardHeader {
self self
} }
pub fn with_code_path(mut self, text: impl Into<SharedString>) -> Self {
self.code_path = Some(text.into());
self
}
pub fn disclosure_slot(mut self, element: impl IntoElement) -> Self {
self.disclosure_slot = Some(element.into_any_element());
self
}
pub fn loading(mut self) -> Self { pub fn loading(mut self) -> Self {
self.is_loading = true; self.is_loading = true;
self self
@ -42,26 +56,36 @@ impl ToolCallCardHeader {
impl RenderOnce for ToolCallCardHeader { impl RenderOnce for ToolCallCardHeader {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let font_size = rems(0.8125); let font_size = rems(0.8125);
let line_height = window.line_height();
let secondary_text = self.secondary_text; let secondary_text = self.secondary_text;
let code_path = self.code_path;
let bullet_divider = || {
div()
.size(px(3.))
.rounded_full()
.bg(cx.theme().colors().text)
};
h_flex() h_flex()
.id("tool-label-container") .id("tool-label-container")
.gap_1p5() .gap_2()
.max_w_full() .max_w_full()
.overflow_x_scroll() .overflow_x_scroll()
.opacity(0.8) .opacity(0.8)
.child( .child(
h_flex().h(window.line_height()).justify_center().child( h_flex()
.h(line_height)
.gap_1p5()
.text_size(font_size)
.child(
h_flex().h(line_height).justify_center().child(
Icon::new(self.icon) Icon::new(self.icon)
.size(IconSize::XSmall) .size(IconSize::XSmall)
.color(Color::Muted), .color(Color::Muted),
), ),
) )
.child(
h_flex()
.h(window.line_height())
.gap_1p5()
.text_size(font_size)
.map(|this| { .map(|this| {
if let Some(error) = &self.error { if let Some(error) = &self.error {
this.child(format!("{} failed", self.primary_text)).child( this.child(format!("{} failed", self.primary_text)).child(
@ -76,14 +100,16 @@ impl RenderOnce for ToolCallCardHeader {
} }
}) })
.when_some(secondary_text, |this, secondary_text| { .when_some(secondary_text, |this, secondary_text| {
this.child( this.child(bullet_divider())
div()
.size(px(3.))
.rounded_full()
.bg(cx.theme().colors().text),
)
.child(div().text_size(font_size).child(secondary_text.clone())) .child(div().text_size(font_size).child(secondary_text.clone()))
}) })
.when_some(code_path, |this, code_path| {
this.child(bullet_divider()).child(
Label::new(code_path.clone())
.size(LabelSize::Small)
.inline_code(cx),
)
})
.with_animation( .with_animation(
"loading-label", "loading-label",
Animation::new(Duration::from_secs(2)) Animation::new(Duration::from_secs(2))
@ -98,5 +124,11 @@ impl RenderOnce for ToolCallCardHeader {
}, },
), ),
) )
.when_some(self.disclosure_slot, |container, disclosure_slot| {
container
.group("disclosure")
.justify_between()
.child(div().visible_on_hover("disclosure").child(disclosure_slot))
})
} }
} }