diff --git a/Cargo.lock b/Cargo.lock index 82c9756d46..7c51eaf766 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -735,7 +735,6 @@ dependencies = [ "web_search", "workspace", "workspace-hack", - "worktree", "zed_llm_client", ] diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml index 59d2048b86..df9edc4a4d 100644 --- a/crates/assistant_tools/Cargo.toml +++ b/crates/assistant_tools/Cargo.toml @@ -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] diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index 3b2133bd60..c2202b445a 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -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, cx: &mut App) -> Task matcher, Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), }; - let snapshots: Vec = 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, cx: &mut App) -> Task, + expanded: bool, + glob: String, + _receiver_task: Option>>, +} + +impl FindPathToolCard { + fn new(glob: String, receiver: Receiver>, cx: &mut Context) -> 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, + cx: &mut Context, + ) -> 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::() + { + 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 { + 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::*; diff --git a/crates/assistant_tools/src/ui/tool_call_card_header.rs b/crates/assistant_tools/src/ui/tool_call_card_header.rs index e6219a151f..a19ea8f2b7 100644 --- a/crates/assistant_tools/src/ui/tool_call_card_header.rs +++ b/crates/assistant_tools/src/ui/tool_call_card_header.rs @@ -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, + code_path: Option, + disclosure_slot: Option, is_loading: bool, error: Option, } @@ -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) -> 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)) + }) } }