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:
parent
3aa313010f
commit
c3570fbcf3
4 changed files with 285 additions and 35 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -735,7 +735,6 @@ dependencies = [
|
||||||
"web_search",
|
"web_search",
|
||||||
"workspace",
|
"workspace",
|
||||||
"workspace-hack",
|
"workspace-hack",
|
||||||
"worktree",
|
|
||||||
"zed_llm_client",
|
"zed_llm_client",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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::*;
|
||||||
|
|
|
@ -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(
|
|
||||||
h_flex().h(window.line_height()).justify_center().child(
|
|
||||||
Icon::new(self.icon)
|
|
||||||
.size(IconSize::XSmall)
|
|
||||||
.color(Color::Muted),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.h(window.line_height())
|
.h(line_height)
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.text_size(font_size)
|
.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| {
|
.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,13 +100,15 @@ 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()
|
.child(div().text_size(font_size).child(secondary_text.clone()))
|
||||||
.size(px(3.))
|
})
|
||||||
.rounded_full()
|
.when_some(code_path, |this, code_path| {
|
||||||
.bg(cx.theme().colors().text),
|
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(
|
.with_animation(
|
||||||
"loading-label",
|
"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))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue