diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 92e388407c..408d5655b0 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -22,6 +22,7 @@ mod schema; mod symbol_info_tool; mod terminal_tool; mod thinking_tool; +mod ui; mod web_search_tool; use std::sync::Arc; diff --git a/crates/assistant_tools/src/ui.rs b/crates/assistant_tools/src/ui.rs new file mode 100644 index 0000000000..a8ff923ef5 --- /dev/null +++ b/crates/assistant_tools/src/ui.rs @@ -0,0 +1,3 @@ +mod tool_call_card_header; + +pub use tool_call_card_header::*; diff --git a/crates/assistant_tools/src/ui/tool_call_card_header.rs b/crates/assistant_tools/src/ui/tool_call_card_header.rs new file mode 100644 index 0000000000..e6219a151f --- /dev/null +++ b/crates/assistant_tools/src/ui/tool_call_card_header.rs @@ -0,0 +1,102 @@ +use gpui::{Animation, AnimationExt, App, IntoElement, pulsating_between}; +use std::time::Duration; +use ui::{Tooltip, prelude::*}; + +/// A reusable header component for tool call cards. +#[derive(IntoElement)] +pub struct ToolCallCardHeader { + icon: IconName, + primary_text: SharedString, + secondary_text: Option, + is_loading: bool, + error: Option, +} + +impl ToolCallCardHeader { + pub fn new(icon: IconName, primary_text: impl Into) -> Self { + Self { + icon, + primary_text: primary_text.into(), + secondary_text: None, + is_loading: false, + error: None, + } + } + + pub fn with_secondary_text(mut self, text: impl Into) -> Self { + self.secondary_text = Some(text.into()); + self + } + + pub fn loading(mut self) -> Self { + self.is_loading = true; + self + } + + pub fn with_error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } +} + +impl RenderOnce for ToolCallCardHeader { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let font_size = rems(0.8125); + let secondary_text = self.secondary_text; + + h_flex() + .id("tool-label-container") + .gap_1p5() + .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()) + .gap_1p5() + .text_size(font_size) + .map(|this| { + if let Some(error) = &self.error { + this.child(format!("{} failed", self.primary_text)).child( + IconButton::new("error_info", IconName::Warning) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Warning) + .tooltip(Tooltip::text(error.clone())), + ) + } else { + this.child(self.primary_text.clone()) + } + }) + .when_some(secondary_text, |this, secondary_text| { + this.child( + div() + .size(px(3.)) + .rounded_full() + .bg(cx.theme().colors().text), + ) + .child(div().text_size(font_size).child(secondary_text.clone())) + }) + .with_animation( + "loading-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.6, 1.)), + move |this, delta| { + if self.is_loading { + this.opacity(delta) + } else { + this + } + }, + ), + ) + } +} diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index 39ac7bd427..b64b9a78c7 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -1,13 +1,11 @@ use std::{sync::Arc, time::Duration}; use crate::schema::json_schema_for; +use crate::ui::ToolCallCardHeader; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus}; -use futures::{FutureExt, TryFutureExt}; -use gpui::{ - Animation, AnimationExt, App, AppContext, Context, Entity, IntoElement, Task, Window, - pulsating_between, -}; +use futures::{Future, FutureExt, TryFutureExt}; +use gpui::{App, AppContext, Context, Entity, IntoElement, Task, Window}; use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; use project::Project; use schemars::JsonSchema; @@ -47,7 +45,7 @@ impl Tool for WebSearchTool { } fn ui_text(&self, _input: &serde_json::Value) -> String { - "Web Search".to_string() + "Searching the Web".to_string() } fn run( @@ -115,61 +113,30 @@ impl ToolCard for WebSearchToolCard { _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let header = h_flex() - .id("tool-label-container") - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .child( - Icon::new(IconName::Globe) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(match self.response.as_ref() { - Some(Ok(response)) => { - let text: SharedString = if response.citations.len() == 1 { - "1 result".into() - } else { - format!("{} results", response.citations.len()).into() - }; - h_flex() - .gap_1p5() - .child(Label::new("Searched the Web").size(LabelSize::Small)) - .child( - div() - .size(px(3.)) - .rounded_full() - .bg(cx.theme().colors().text), - ) - .child(Label::new(text).size(LabelSize::Small)) - .into_any_element() - } - Some(Err(error)) => div() - .id("web-search-error") - .child(Label::new("Web Search failed").size(LabelSize::Small)) - .tooltip(Tooltip::text(error.to_string())) - .into_any_element(), - - None => Label::new("Searching the Web…") - .size(LabelSize::Small) - .with_animation( - "web-search-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.)), - |label, delta| label.alpha(delta), - ) - .into_any_element(), - }) - .into_any(); + let header = match self.response.as_ref() { + Some(Ok(response)) => { + let text: SharedString = if response.citations.len() == 1 { + "1 result".into() + } else { + format!("{} results", response.citations.len()).into() + }; + ToolCallCardHeader::new(IconName::Globe, "Searched the Web") + .with_secondary_text(text) + } + Some(Err(error)) => { + ToolCallCardHeader::new(IconName::Globe, "Web Search").with_error(error.to_string()) + } + None => ToolCallCardHeader::new(IconName::Globe, "Searching the Web").loading(), + }; let content = self.response.as_ref().and_then(|response| match response { Ok(response) => { Some( v_flex() + .overflow_hidden() .ml_1p5() - .pl_1p5() + .pl(px(5.)) .border_l_1() .border_color(cx.theme().colors().border_variant) .gap_1() @@ -209,7 +176,7 @@ impl ToolCard for WebSearchToolCard { Err(_) => None, }); - v_flex().my_2().gap_1().child(header).children(content) + v_flex().mb_3().gap_1().child(header).children(content) } }