message_editor.rs

  1use crate::acp::completion_provider::ContextPickerCompletionProvider;
  2use crate::acp::completion_provider::MentionSet;
  3use acp_thread::MentionUri;
  4use agent_client_protocol as acp;
  5use anyhow::Result;
  6use collections::HashSet;
  7use editor::{
  8    AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
  9    EditorStyle, MultiBuffer,
 10};
 11use file_icons::FileIcons;
 12use gpui::{
 13    AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Task, TextStyle, WeakEntity,
 14};
 15use language::Buffer;
 16use language::Language;
 17use parking_lot::Mutex;
 18use project::{CompletionIntent, Project};
 19use settings::Settings;
 20use std::path::Path;
 21use std::rc::Rc;
 22use std::sync::Arc;
 23use theme::ThemeSettings;
 24use ui::{
 25    ActiveTheme, App, IconName, InteractiveElement, IntoElement, ParentElement, Render,
 26    SharedString, Styled, TextSize, Window, div,
 27};
 28use util::ResultExt;
 29use workspace::Workspace;
 30use zed_actions::agent::Chat;
 31
 32pub struct MessageEditor {
 33    editor: Entity<Editor>,
 34    project: Entity<Project>,
 35    mention_set: Arc<Mutex<MentionSet>>,
 36}
 37
 38pub enum MessageEditorEvent {
 39    Send,
 40    Cancel,
 41}
 42
 43impl EventEmitter<MessageEditorEvent> for MessageEditor {}
 44
 45impl MessageEditor {
 46    pub fn new(
 47        workspace: WeakEntity<Workspace>,
 48        project: Entity<Project>,
 49        mode: EditorMode,
 50        window: &mut Window,
 51        cx: &mut Context<Self>,
 52    ) -> Self {
 53        let language = Language::new(
 54            language::LanguageConfig {
 55                completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
 56                ..Default::default()
 57            },
 58            None,
 59        );
 60
 61        let mention_set = Arc::new(Mutex::new(MentionSet::default()));
 62        let editor = cx.new(|cx| {
 63            let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
 64            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 65
 66            let mut editor = Editor::new(mode, buffer, None, window, cx);
 67            editor.set_placeholder_text("Message the agent - @ to include files", cx);
 68            editor.set_show_indent_guides(false, cx);
 69            editor.set_soft_wrap();
 70            editor.set_use_modal_editing(true);
 71            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
 72                mention_set.clone(),
 73                workspace,
 74                cx.weak_entity(),
 75            ))));
 76            editor.set_context_menu_options(ContextMenuOptions {
 77                min_entries_visible: 12,
 78                max_entries_visible: 12,
 79                placement: Some(ContextMenuPlacement::Above),
 80            });
 81            editor
 82        });
 83
 84        Self {
 85            editor,
 86            project,
 87            mention_set,
 88        }
 89    }
 90
 91    pub fn is_empty(&self, cx: &App) -> bool {
 92        self.editor.read(cx).is_empty(cx)
 93    }
 94
 95    pub fn contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
 96        let contents = self.mention_set.lock().contents(self.project.clone(), cx);
 97        let editor = self.editor.clone();
 98
 99        cx.spawn(async move |_, cx| {
100            let contents = contents.await?;
101
102            editor.update(cx, |editor, cx| {
103                let mut ix = 0;
104                let mut chunks: Vec<acp::ContentBlock> = Vec::new();
105                let text = editor.text(cx);
106                editor.display_map.update(cx, |map, cx| {
107                    let snapshot = map.snapshot(cx);
108                    for (crease_id, crease) in snapshot.crease_snapshot.creases() {
109                        // Skip creases that have been edited out of the message buffer.
110                        if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
111                            continue;
112                        }
113
114                        if let Some(mention) = contents.get(&crease_id) {
115                            let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
116                            if crease_range.start > ix {
117                                chunks.push(text[ix..crease_range.start].into());
118                            }
119                            chunks.push(acp::ContentBlock::Resource(acp::EmbeddedResource {
120                                annotations: None,
121                                resource: acp::EmbeddedResourceResource::TextResourceContents(
122                                    acp::TextResourceContents {
123                                        mime_type: None,
124                                        text: mention.content.clone(),
125                                        uri: mention.uri.to_uri(),
126                                    },
127                                ),
128                            }));
129                            ix = crease_range.end;
130                        }
131                    }
132
133                    if ix < text.len() {
134                        let last_chunk = text[ix..].trim_end();
135                        if !last_chunk.is_empty() {
136                            chunks.push(last_chunk.into());
137                        }
138                    }
139                });
140
141                chunks
142            })
143        })
144    }
145
146    pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
147        self.editor.update(cx, |editor, cx| {
148            editor.clear(window, cx);
149            editor.remove_creases(self.mention_set.lock().drain(), cx)
150        });
151    }
152
153    fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
154        cx.emit(MessageEditorEvent::Send)
155    }
156
157    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
158        cx.emit(MessageEditorEvent::Cancel)
159    }
160
161    pub fn insert_dragged_files(
162        &self,
163        paths: Vec<project::ProjectPath>,
164        window: &mut Window,
165        cx: &mut Context<Self>,
166    ) {
167        let buffer = self.editor.read(cx).buffer().clone();
168        let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else {
169            return;
170        };
171        let Some(buffer) = buffer.read(cx).as_singleton() else {
172            return;
173        };
174        for path in paths {
175            let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
176                continue;
177            };
178            let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
179                continue;
180            };
181
182            let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
183            let path_prefix = abs_path
184                .file_name()
185                .unwrap_or(path.path.as_os_str())
186                .display()
187                .to_string();
188            let completion = ContextPickerCompletionProvider::completion_for_path(
189                path,
190                &path_prefix,
191                false,
192                entry.is_dir(),
193                excerpt_id,
194                anchor..anchor,
195                self.editor.clone(),
196                self.mention_set.clone(),
197                self.project.clone(),
198                cx,
199            );
200
201            self.editor.update(cx, |message_editor, cx| {
202                message_editor.edit(
203                    [(
204                        multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
205                        completion.new_text,
206                    )],
207                    cx,
208                );
209            });
210            if let Some(confirm) = completion.confirm.clone() {
211                confirm(CompletionIntent::Complete, window, cx);
212            }
213        }
214    }
215
216    pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
217        self.editor.update(cx, |editor, cx| {
218            editor.set_mode(mode);
219            cx.notify()
220        });
221    }
222
223    pub fn set_message(
224        &mut self,
225        message: &[acp::ContentBlock],
226        window: &mut Window,
227        cx: &mut Context<Self>,
228    ) {
229        let mut text = String::new();
230        let mut mentions = Vec::new();
231
232        for chunk in message {
233            match chunk {
234                acp::ContentBlock::Text(text_content) => {
235                    text.push_str(&text_content.text);
236                }
237                acp::ContentBlock::Resource(acp::EmbeddedResource {
238                    resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
239                    ..
240                }) => {
241                    if let Some(mention) = MentionUri::parse(&resource.uri).log_err() {
242                        let project_path = self
243                            .project
244                            .read(cx)
245                            .project_path_for_absolute_path(&abs_path, cx);
246                        let start = text.len();
247                        write!(text, "{}", mention.as_link());
248                        let end = text.len();
249                        mentions.push((start..end, project_path, filename));
250                    }
251                }
252                acp::ContentBlock::Image(_)
253                | acp::ContentBlock::Audio(_)
254                | acp::ContentBlock::Resource(_)
255                | acp::ContentBlock::ResourceLink(_) => {}
256            }
257        }
258
259        let snapshot = self.editor.update(cx, |editor, cx| {
260            editor.set_text(text, window, cx);
261            editor.buffer().read(cx).snapshot(cx)
262        });
263
264        self.mention_set.lock().clear();
265        for (range, project_path, filename) in mentions {
266            let crease_icon_path = if project_path.path.is_dir() {
267                FileIcons::get_folder_icon(false, cx)
268                    .unwrap_or_else(|| IconName::Folder.path().into())
269            } else {
270                FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx)
271                    .unwrap_or_else(|| IconName::File.path().into())
272            };
273
274            let anchor = snapshot.anchor_before(range.start);
275            if let Some(project_path) = self.project.read(cx).absolute_path(&project_path, cx) {
276                let crease_id = crate::context_picker::insert_crease_for_mention(
277                    anchor.excerpt_id,
278                    anchor.text_anchor,
279                    range.end - range.start,
280                    filename,
281                    crease_icon_path,
282                    self.editor.clone(),
283                    window,
284                    cx,
285                );
286
287                if let Some(crease_id) = crease_id {
288                    self.mention_set.lock().insert(crease_id, project_path);
289                }
290            }
291        }
292        cx.notify();
293    }
294
295    #[cfg(test)]
296    pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
297        self.editor.update(cx, |editor, cx| {
298            editor.set_text(text, window, cx);
299        });
300    }
301}
302
303impl Focusable for MessageEditor {
304    fn focus_handle(&self, cx: &App) -> FocusHandle {
305        self.editor.focus_handle(cx)
306    }
307}
308
309impl Render for MessageEditor {
310    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
311        div()
312            .key_context("MessageEditor")
313            .on_action(cx.listener(Self::chat))
314            .on_action(cx.listener(Self::cancel))
315            .flex_1()
316            .child({
317                let settings = ThemeSettings::get_global(cx);
318                let font_size = TextSize::Small
319                    .rems(cx)
320                    .to_pixels(settings.agent_font_size(cx));
321                let line_height = settings.buffer_line_height.value() * font_size;
322
323                let text_style = TextStyle {
324                    color: cx.theme().colors().text,
325                    font_family: settings.buffer_font.family.clone(),
326                    font_fallbacks: settings.buffer_font.fallbacks.clone(),
327                    font_features: settings.buffer_font.features.clone(),
328                    font_size: font_size.into(),
329                    line_height: line_height.into(),
330                    ..Default::default()
331                };
332
333                EditorElement::new(
334                    &self.editor,
335                    EditorStyle {
336                        background: cx.theme().colors().editor_background,
337                        local_player: cx.theme().players().local(),
338                        text: text_style,
339                        syntax: cx.theme().syntax().clone(),
340                        ..Default::default()
341                    },
342                )
343            })
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use std::path::Path;
350
351    use agent_client_protocol as acp;
352    use editor::EditorMode;
353    use fs::FakeFs;
354    use gpui::{AppContext, TestAppContext};
355    use lsp::{CompletionContext, CompletionTriggerKind};
356    use project::{CompletionIntent, Project};
357    use serde_json::json;
358    use util::path;
359    use workspace::Workspace;
360
361    use crate::acp::{message_editor::MessageEditor, thread_view::tests::init_test};
362
363    #[gpui::test]
364    async fn test_at_mention_removal(cx: &mut TestAppContext) {
365        init_test(cx);
366
367        let fs = FakeFs::new(cx.executor());
368        fs.insert_tree("/project", json!({"file": ""})).await;
369        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
370
371        let (workspace, cx) =
372            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
373
374        let message_editor = cx.update(|window, cx| {
375            cx.new(|cx| {
376                MessageEditor::new(
377                    workspace.downgrade(),
378                    project.clone(),
379                    EditorMode::AutoHeight {
380                        min_lines: 1,
381                        max_lines: None,
382                    },
383                    window,
384                    cx,
385                )
386            })
387        });
388        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
389
390        cx.run_until_parked();
391
392        let excerpt_id = editor.update(cx, |editor, cx| {
393            editor
394                .buffer()
395                .read(cx)
396                .excerpt_ids()
397                .into_iter()
398                .next()
399                .unwrap()
400        });
401        let completions = editor.update_in(cx, |editor, window, cx| {
402            editor.set_text("Hello @", window, cx);
403            let buffer = editor.buffer().read(cx).as_singleton().unwrap();
404            let completion_provider = editor.completion_provider().unwrap();
405            completion_provider.completions(
406                excerpt_id,
407                &buffer,
408                text::Anchor::MAX,
409                CompletionContext {
410                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
411                    trigger_character: Some("@".into()),
412                },
413                window,
414                cx,
415            )
416        });
417        let [_, completion]: [_; 2] = completions
418            .await
419            .unwrap()
420            .into_iter()
421            .flat_map(|response| response.completions)
422            .collect::<Vec<_>>()
423            .try_into()
424            .unwrap();
425
426        editor.update_in(cx, |editor, window, cx| {
427            let snapshot = editor.buffer().read(cx).snapshot(cx);
428            let start = snapshot
429                .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
430                .unwrap();
431            let end = snapshot
432                .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
433                .unwrap();
434            editor.edit([(start..end, completion.new_text)], cx);
435            (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
436        });
437
438        cx.run_until_parked();
439
440        // Backspace over the inserted crease (and the following space).
441        editor.update_in(cx, |editor, window, cx| {
442            editor.backspace(&Default::default(), window, cx);
443            editor.backspace(&Default::default(), window, cx);
444        });
445
446        let content = message_editor
447            .update_in(cx, |message_editor, _window, cx| {
448                message_editor.contents(cx)
449            })
450            .await
451            .unwrap();
452
453        // We don't send a resource link for the deleted crease.
454        pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
455    }
456}