Cargo.lock 🔗
@@ -735,7 +735,6 @@ dependencies = [
"web_search",
"workspace",
"workspace-hack",
- "worktree",
"zed_llm_client",
]
Danilo Leal , Richard Feldman , and Agus Zubiaga created
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>
Cargo.lock | 1
crates/assistant_tools/Cargo.toml | 3
crates/assistant_tools/src/find_path_tool.rs | 252 +++++++++++
crates/assistant_tools/src/ui/tool_call_card_header.rs | 64 ++
4 files changed, 285 insertions(+), 35 deletions(-)
@@ -735,7 +735,6 @@ dependencies = [
"web_search",
"workspace",
"workspace-hack",
- "worktree",
"zed_llm_client",
]
@@ -37,9 +37,8 @@ serde_json.workspace = true
ui.workspace = true
util.workspace = true
web_search.workspace = true
-workspace.workspace = true
workspace-hack.workspace = true
-worktree.workspace = true
+workspace.workspace = true
zed_llm_client.workspace = true
[dev-dependencies]
@@ -1,15 +1,21 @@
-use crate::schema::json_schema_for;
+use crate::{schema::json_schema_for, ui::ToolCallCardHeader};
use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
-use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
+use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
+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 project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use std::{cmp, fmt::Write as _, path::PathBuf, sync::Arc};
-use ui::IconName;
-use util::paths::PathMatcher;
-use worktree::Snapshot;
+use std::fmt::Write;
+use std::{cmp, path::PathBuf, sync::Arc};
+use ui::{Disclosure, Tooltip, prelude::*};
+use util::{ResultExt, paths::PathMatcher};
+use workspace::Workspace;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct FindPathToolInput {
@@ -29,7 +35,7 @@ pub struct FindPathToolInput {
/// Optional starting position for paginated results (0-based).
/// When not provided, starts from the beginning.
#[serde(default)]
- pub offset: u32,
+ pub offset: usize,
}
const RESULTS_PER_PAGE: usize = 50;
@@ -77,13 +83,20 @@ impl Tool for FindPathTool {
Ok(input) => (input.offset, input.glob),
Err(err) => return Task::ready(Err(anyhow!(err))).into(),
};
- let offset = offset as usize;
- let task = search_paths(&glob, project, cx);
- cx.background_spawn(async move {
- let matches = task.await?;
- let paginated_matches = &matches[cmp::min(offset, matches.len())
+
+ let (sender, receiver) = oneshot::channel();
+
+ let card = cx.new(|cx| FindPathToolCard::new(glob.clone(), receiver, cx));
+
+ 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())];
+ sender.send(paginated_matches.to_vec()).log_err();
+
if matches.is_empty() {
Ok("No matches found".to_string())
} else {
@@ -102,8 +115,12 @@ impl Tool for FindPathTool {
}
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,
Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
};
- let snapshots: Vec<Snapshot> = project
+ let snapshots: Vec<_> = project
.read(cx)
.worktrees(cx)
.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)]
mod test {
use super::*;
@@ -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 ui::{Tooltip, prelude::*};
@@ -8,6 +8,8 @@ pub struct ToolCallCardHeader {
icon: IconName,
primary_text: SharedString,
secondary_text: Option<SharedString>,
+ code_path: Option<SharedString>,
+ disclosure_slot: Option<AnyElement>,
is_loading: bool,
error: Option<String>,
}
@@ -18,6 +20,8 @@ impl ToolCallCardHeader {
icon,
primary_text: primary_text.into(),
secondary_text: None,
+ code_path: None,
+ disclosure_slot: None,
is_loading: false,
error: None,
}
@@ -28,6 +32,16 @@ impl ToolCallCardHeader {
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 {
self.is_loading = true;
self
@@ -42,26 +56,36 @@ impl ToolCallCardHeader {
impl RenderOnce for ToolCallCardHeader {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let font_size = rems(0.8125);
+ let line_height = window.line_height();
+
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()
.id("tool-label-container")
- .gap_1p5()
+ .gap_2()
.max_w_full()
.overflow_x_scroll()
.opacity(0.8)
- .child(
- h_flex().h(window.line_height()).justify_center().child(
- Icon::new(self.icon)
- .size(IconSize::XSmall)
- .color(Color::Muted),
- ),
- )
.child(
h_flex()
- .h(window.line_height())
+ .h(line_height)
.gap_1p5()
.text_size(font_size)
+ .child(
+ h_flex().h(line_height).justify_center().child(
+ Icon::new(self.icon)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ ),
+ )
.map(|this| {
if let Some(error) = &self.error {
this.child(format!("{} failed", self.primary_text)).child(
@@ -76,13 +100,15 @@ impl RenderOnce for ToolCallCardHeader {
}
})
.when_some(secondary_text, |this, secondary_text| {
- this.child(
- div()
- .size(px(3.))
- .rounded_full()
- .bg(cx.theme().colors().text),
+ this.child(bullet_divider())
+ .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),
)
- .child(div().text_size(font_size).child(secondary_text.clone()))
})
.with_animation(
"loading-label",
@@ -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))
+ })
}
}