From feb1d37798f806d3d38909f8101de4b3f851a4dc Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 1 Apr 2025 17:44:20 +0200 Subject: [PATCH] assistant2: Allow clicking on `@mentions` (#27846) https://github.com/user-attachments/assets/f6f7c115-5c40-48f9-a099-2b691993967b Release Notes: - N/A --- crates/assistant2/src/active_thread.rs | 147 ++++++++++++++++-- crates/assistant2/src/context_picker.rs | 78 +++++++++- .../src/context_picker/completion_provider.rs | 74 ++++++--- crates/assistant2/src/thread.rs | 6 + 4 files changed, 266 insertions(+), 39 deletions(-) diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index e1f7c644b8..37283f8590 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -1,5 +1,6 @@ use crate::AssistantPanel; use crate::context::{AssistantContext, ContextId}; +use crate::context_picker::MentionLink; use crate::thread::{ LastRestoreCheckpoint, MessageId, MessageSegment, RequestKind, Thread, ThreadError, ThreadEvent, ThreadFeedback, @@ -7,8 +8,10 @@ use crate::thread::{ use crate::thread_store::ThreadStore; use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus}; use crate::ui::{AddedContext, AgentNotification, AgentNotificationEvent, ContextPill}; +use anyhow::Context as _; use assistant_settings::{AssistantSettings, NotifyWhenAgentWaiting}; use collections::HashMap; +use editor::scroll::Autoscroll; use editor::{Editor, MultiBuffer}; use gpui::{ AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength, @@ -63,6 +66,7 @@ impl RenderedMessage { fn from_segments( segments: &[MessageSegment], language_registry: Arc, + workspace: WeakEntity, window: &Window, cx: &mut App, ) -> Self { @@ -71,12 +75,18 @@ impl RenderedMessage { segments: Vec::with_capacity(segments.len()), }; for segment in segments { - this.push_segment(segment, window, cx); + this.push_segment(segment, workspace.clone(), window, cx); } this } - fn append_thinking(&mut self, text: &String, window: &Window, cx: &mut App) { + fn append_thinking( + &mut self, + text: &String, + workspace: WeakEntity, + window: &Window, + cx: &mut App, + ) { if let Some(RenderedMessageSegment::Thinking { content, scroll_handle, @@ -88,13 +98,25 @@ impl RenderedMessage { scroll_handle.scroll_to_bottom(); } else { self.segments.push(RenderedMessageSegment::Thinking { - content: render_markdown(text.into(), self.language_registry.clone(), window, cx), + content: render_markdown( + text.into(), + self.language_registry.clone(), + workspace, + window, + cx, + ), scroll_handle: ScrollHandle::default(), }); } } - fn append_text(&mut self, text: &String, window: &Window, cx: &mut App) { + fn append_text( + &mut self, + text: &String, + workspace: WeakEntity, + window: &Window, + cx: &mut App, + ) { if let Some(RenderedMessageSegment::Text(markdown)) = self.segments.last_mut() { markdown.update(cx, |markdown, cx| markdown.append(text, cx)); } else { @@ -102,21 +124,35 @@ impl RenderedMessage { .push(RenderedMessageSegment::Text(render_markdown( SharedString::from(text), self.language_registry.clone(), + workspace, window, cx, ))); } } - fn push_segment(&mut self, segment: &MessageSegment, window: &Window, cx: &mut App) { + fn push_segment( + &mut self, + segment: &MessageSegment, + workspace: WeakEntity, + window: &Window, + cx: &mut App, + ) { let rendered_segment = match segment { MessageSegment::Thinking(text) => RenderedMessageSegment::Thinking { - content: render_markdown(text.into(), self.language_registry.clone(), window, cx), + content: render_markdown( + text.into(), + self.language_registry.clone(), + workspace, + window, + cx, + ), scroll_handle: ScrollHandle::default(), }, MessageSegment::Text(text) => RenderedMessageSegment::Text(render_markdown( text.into(), self.language_registry.clone(), + workspace, window, cx, )), @@ -136,6 +172,7 @@ enum RenderedMessageSegment { fn render_markdown( text: SharedString, language_registry: Arc, + workspace: WeakEntity, window: &Window, cx: &mut App, ) -> Entity { @@ -210,7 +247,80 @@ fn render_markdown( ..Default::default() }; - cx.new(|cx| Markdown::new(text, markdown_style, Some(language_registry), None, cx)) + cx.new(|cx| { + Markdown::new(text, markdown_style, Some(language_registry), None, cx).open_url( + move |text, window, cx| { + open_markdown_link(text, workspace.clone(), window, cx); + }, + ) + }) +} + +fn open_markdown_link( + text: SharedString, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let Some(workspace) = workspace.upgrade() else { + cx.open_url(&text); + return; + }; + + match MentionLink::try_parse(&text, &workspace, cx) { + Some(MentionLink::File(path, entry)) => workspace.update(cx, |workspace, cx| { + if entry.is_dir() { + workspace.project().update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry.id)); + }) + } else { + workspace + .open_path(path, None, true, window, cx) + .detach_and_log_err(cx); + } + }), + Some(MentionLink::Symbol(path, symbol_name)) => { + let open_task = workspace.update(cx, |workspace, cx| { + workspace.open_path(path, None, true, window, cx) + }); + window + .spawn(cx, async move |cx| { + let active_editor = open_task + .await? + .downcast::() + .context("Item is not an editor")?; + active_editor.update_in(cx, |editor, window, cx| { + let symbol_range = editor + .buffer() + .read(cx) + .snapshot(cx) + .outline(None) + .and_then(|outline| { + outline + .find_most_similar(&symbol_name) + .map(|(_, item)| item.range.clone()) + }) + .context("Could not find matching symbol")?; + + editor.change_selections(Some(Autoscroll::center()), window, cx, |s| { + s.select_anchor_ranges([symbol_range.start..symbol_range.start]) + }); + anyhow::Ok(()) + }) + }) + .detach_and_log_err(cx); + } + Some(MentionLink::Thread(thread_id)) => workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel + .open_thread(&thread_id, window, cx) + .detach_and_log_err(cx) + }); + } + }), + None => cx.open_url(&text), + } } struct EditMessageState { @@ -318,8 +428,13 @@ impl ActiveThread { self.messages.push(*id); self.list_state.splice(old_len..old_len, 1); - let rendered_message = - RenderedMessage::from_segments(segments, self.language_registry.clone(), window, cx); + let rendered_message = RenderedMessage::from_segments( + segments, + self.language_registry.clone(), + self.workspace.clone(), + window, + cx, + ); self.rendered_messages_by_id.insert(*id, rendered_message); } @@ -334,8 +449,13 @@ impl ActiveThread { return; }; self.list_state.splice(index..index + 1, 1); - let rendered_message = - RenderedMessage::from_segments(segments, self.language_registry.clone(), window, cx); + let rendered_message = RenderedMessage::from_segments( + segments, + self.language_registry.clone(), + self.workspace.clone(), + window, + cx, + ); self.rendered_messages_by_id.insert(*id, rendered_message); } @@ -360,6 +480,7 @@ impl ActiveThread { render_markdown( tool_label.into(), self.language_registry.clone(), + self.workspace.clone(), window, cx, ), @@ -401,12 +522,12 @@ impl ActiveThread { } ThreadEvent::StreamedAssistantText(message_id, text) => { if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) { - rendered_message.append_text(text, window, cx); + rendered_message.append_text(text, self.workspace.clone(), window, cx); } } ThreadEvent::StreamedAssistantThinking(message_id, text) => { if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) { - rendered_message.append_thinking(text, window, cx); + rendered_message.append_thinking(text, self.workspace.clone(), window, cx); } } ThreadEvent::MessageAdded(message_id) => { diff --git a/crates/assistant2/src/context_picker.rs b/crates/assistant2/src/context_picker.rs index bfbda8f1d7..ca25da1515 100644 --- a/crates/assistant2/src/context_picker.rs +++ b/crates/assistant2/src/context_picker.rs @@ -16,7 +16,7 @@ use gpui::{ App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, }; use multi_buffer::MultiBufferRow; -use project::ProjectPath; +use project::{Entry, ProjectPath}; use symbol_context_picker::SymbolContextPicker; use thread_context_picker::{ThreadContextEntry, render_thread_context_entry}; use ui::{ @@ -30,6 +30,7 @@ use crate::context_picker::fetch_context_picker::FetchContextPicker; use crate::context_picker::file_context_picker::FileContextPicker; use crate::context_picker::thread_context_picker::ThreadContextPicker; use crate::context_store::ContextStore; +use crate::thread::ThreadId; use crate::thread_store::ThreadStore; #[derive(Debug, Clone, Copy)] @@ -568,6 +569,7 @@ pub(crate) fn insert_crease_for_mention( return; }; + let start = start.bias_right(&snapshot); let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); let placeholder = FoldPlaceholder { @@ -677,3 +679,77 @@ fn fold_toggle( .into_any_element() } } + +pub enum MentionLink { + File(ProjectPath, Entry), + Symbol(ProjectPath, String), + Thread(ThreadId), +} + +impl MentionLink { + pub fn for_file(file_name: &str, full_path: &str) -> String { + format!("[@{}](file:{})", file_name, full_path) + } + + pub fn for_symbol(symbol_name: &str, full_path: &str) -> String { + format!("[@{}](symbol:{}:{})", symbol_name, full_path, symbol_name) + } + + pub fn for_fetch(url: &str) -> String { + format!("[@{}]({})", url, url) + } + + pub fn for_thread(thread: &ThreadContextEntry) -> String { + format!("[@{}](thread:{})", thread.summary, thread.id) + } + + pub fn try_parse(link: &str, workspace: &Entity, cx: &App) -> Option { + fn extract_project_path_from_link( + path: &str, + workspace: &Entity, + cx: &App, + ) -> Option { + let path = PathBuf::from(path); + let worktree_name = path.iter().next()?; + let path: PathBuf = path.iter().skip(1).collect(); + let worktree_id = workspace + .read(cx) + .visible_worktrees(cx) + .find(|worktree| worktree.read(cx).root_name() == worktree_name) + .map(|worktree| worktree.read(cx).id())?; + Some(ProjectPath { + worktree_id, + path: path.into(), + }) + } + + let (prefix, link, target) = { + let mut parts = link.splitn(3, ':'); + let prefix = parts.next(); + let link = parts.next(); + let target = parts.next(); + (prefix, link, target) + }; + + match (prefix, link, target) { + (Some("file"), Some(path), _) => { + let project_path = extract_project_path_from_link(path, workspace, cx)?; + let entry = workspace + .read(cx) + .project() + .read(cx) + .entry_for_path(&project_path, cx)?; + Some(MentionLink::File(project_path, entry)) + } + (Some("symbol"), Some(path), Some(symbol_name)) => { + let project_path = extract_project_path_from_link(path, workspace, cx)?; + Some(MentionLink::Symbol(project_path, symbol_name.to_string())) + } + (Some("thread"), Some(thread_id), _) => { + let thread_id = ThreadId::from(thread_id); + Some(MentionLink::Thread(thread_id)) + } + _ => None, + } + } +} diff --git a/crates/assistant2/src/context_picker/completion_provider.rs b/crates/assistant2/src/context_picker/completion_provider.rs index 0e54352619..f1b11fff47 100644 --- a/crates/assistant2/src/context_picker/completion_provider.rs +++ b/crates/assistant2/src/context_picker/completion_provider.rs @@ -24,7 +24,9 @@ use crate::thread_store::ThreadStore; use super::fetch_context_picker::fetch_url_content; use super::thread_context_picker::ThreadContextEntry; -use super::{ContextPickerMode, recent_context_picker_entries, supported_context_picker_modes}; +use super::{ + ContextPickerMode, MentionLink, recent_context_picker_entries, supported_context_picker_modes, +}; pub struct ContextPickerCompletionProvider { workspace: WeakEntity, @@ -154,7 +156,7 @@ impl ContextPickerCompletionProvider { } else { IconName::MessageBubbles }; - let new_text = format!("@thread {}", thread_entry.summary); + let new_text = MentionLink::for_thread(&thread_entry); let new_text_len = new_text.len(); Completion { old_range: source_range.clone(), @@ -198,7 +200,7 @@ impl ContextPickerCompletionProvider { context_store: Entity, http_client: Arc, ) -> Completion { - let new_text = format!("@fetch {}", url_to_fetch); + let new_text = MentionLink::for_fetch(&url_to_fetch); let new_text_len = new_text.len(); Completion { old_range: source_range.clone(), @@ -279,7 +281,7 @@ impl ContextPickerCompletionProvider { crease_icon_path.clone() }; - let new_text = format!("@file {}", full_path); + let new_text = MentionLink::for_file(&file_name, &full_path); let new_text_len = new_text.len(); Completion { old_range: source_range.clone(), @@ -326,17 +328,22 @@ impl ContextPickerCompletionProvider { .read(cx) .root_name(); - let (file_name, _) = super::file_context_picker::extract_file_name_and_directory( + let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( &symbol.path.path, path_prefix, ); + let full_path = if let Some(directory) = directory { + format!("{}{}", directory, file_name) + } else { + file_name.to_string() + }; let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); let mut label = CodeLabel::plain(symbol.name.clone(), None); label.push_str(" ", None); label.push_str(&file_name, comment_id); - let new_text = format!("@symbol {}:{}", file_name, symbol.name); + let new_text = MentionLink::for_symbol(&symbol.name, &full_path); let new_text_len = new_text.len(); Some(Completion { old_range: source_range.clone(), @@ -400,7 +407,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { }; let snapshot = buffer.read(cx).snapshot(); - let source_range = snapshot.anchor_after(state.source_range.start) + let source_range = snapshot.anchor_before(state.source_range.start) ..snapshot.anchor_before(state.source_range.end); let thread_store = self.thread_store.clone(); @@ -646,6 +653,15 @@ impl MentionCompletion { if last_mention_start >= line.len() { return Some(Self::default()); } + if last_mention_start > 0 + && line + .chars() + .nth(last_mention_start - 1) + .map_or(false, |c| !c.is_whitespace()) + { + return None; + } + let rest_of_line = &line[last_mention_start + 1..]; let mut mode = None; @@ -746,6 +762,8 @@ mod tests { argument: Some("main.rs".to_string()), }) ); + + assert_eq!(MentionCompletion::try_parse("test@", 0), None); } #[gpui::test] @@ -914,44 +932,50 @@ mod tests { }); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt",); + assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt)",); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 25)] + vec![Point::new(0, 6)..Point::new(0, 36)] ); }); cx.simulate_input(" "); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt ",); + assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt) ",); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 25)] + vec![Point::new(0, 6)..Point::new(0, 36)] ); }); cx.simulate_input("Ipsum "); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt Ipsum ",); + assert_eq!( + editor.text(cx), + "Lorem [@one.txt](file:dir/a/one.txt) Ipsum ", + ); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 25)] + vec![Point::new(0, 6)..Point::new(0, 36)] ); }); cx.simulate_input("@file "); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt Ipsum @file ",); + assert_eq!( + editor.text(cx), + "Lorem [@one.txt](file:dir/a/one.txt) Ipsum @file ", + ); assert!(editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 25)] + vec![Point::new(0, 6)..Point::new(0, 36)] ); }); @@ -964,14 +988,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt" + "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)" ); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 25), - Point::new(0, 32)..Point::new(0, 53) + Point::new(0, 6)..Point::new(0, 36), + Point::new(0, 43)..Point::new(0, 77) ] ); }); @@ -981,14 +1005,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt\n@" + "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n@" ); assert!(editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 25), - Point::new(0, 32)..Point::new(0, 53) + Point::new(0, 6)..Point::new(0, 36), + Point::new(0, 43)..Point::new(0, 77) ] ); }); @@ -1002,15 +1026,15 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt\n@file dir/b/six.txt" + "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n[@six.txt](file:dir/b/six.txt)" ); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 25), - Point::new(0, 32)..Point::new(0, 53), - Point::new(1, 0)..Point::new(1, 19) + Point::new(0, 6)..Point::new(0, 36), + Point::new(0, 43)..Point::new(0, 77), + Point::new(1, 0)..Point::new(1, 30) ] ); }); diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index ed2b22306b..99d517b4af 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -58,6 +58,12 @@ impl std::fmt::Display for ThreadId { } } +impl From<&str> for ThreadId { + fn from(value: &str) -> Self { + Self(value.into()) + } +} + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] pub struct MessageId(pub(crate) usize);