message_editor.rs

  1use crate::acp::completion_provider::ContextPickerCompletionProvider;
  2use crate::acp::{MessageHistory, 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, Subscription, Task,
 14    TextStyle, WeakEntity,
 15};
 16use language::Language;
 17use language::{Buffer, BufferSnapshot};
 18use parking_lot::Mutex;
 19use project::{CompletionIntent, Project};
 20use settings::Settings;
 21use std::path::Path;
 22use std::rc::Rc;
 23use std::{cell::RefCell, sync::Arc};
 24use theme::ThemeSettings;
 25use ui::{
 26    ActiveTheme, App, IconName, InteractiveElement, IntoElement, ParentElement, Render,
 27    SharedString, Styled, TextSize, Window, div,
 28};
 29use util::ResultExt;
 30use workspace::Workspace;
 31use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
 32
 33pub const MIN_EDITOR_LINES: usize = 4;
 34pub const MAX_EDITOR_LINES: usize = 8;
 35
 36pub struct MessageEditor {
 37    editor: Entity<Editor>,
 38    project: Entity<Project>,
 39    mention_set: Arc<Mutex<MentionSet>>,
 40    history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
 41    message_set_from_history: Option<BufferSnapshot>,
 42    _subscription: Subscription,
 43}
 44
 45pub enum MessageEditorEvent {
 46    Chat,
 47}
 48
 49impl EventEmitter<MessageEditorEvent> for MessageEditor {}
 50
 51impl MessageEditor {
 52    pub fn new(
 53        workspace: WeakEntity<Workspace>,
 54        project: Entity<Project>,
 55        history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
 56        window: &mut Window,
 57        cx: &mut Context<Self>,
 58    ) -> Self {
 59        let language = Language::new(
 60            language::LanguageConfig {
 61                completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
 62                ..Default::default()
 63            },
 64            None,
 65        );
 66
 67        let mention_set = Arc::new(Mutex::new(MentionSet::default()));
 68        let editor = cx.new(|cx| {
 69            let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
 70            let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 71
 72            let mut editor = Editor::new(
 73                editor::EditorMode::AutoHeight {
 74                    min_lines: MIN_EDITOR_LINES,
 75                    max_lines: Some(MAX_EDITOR_LINES),
 76                },
 77                buffer,
 78                None,
 79                window,
 80                cx,
 81            );
 82            editor.set_placeholder_text("Message the agent - @ to include files", cx);
 83            editor.set_show_indent_guides(false, cx);
 84            editor.set_soft_wrap();
 85            editor.set_use_modal_editing(true);
 86            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
 87                mention_set.clone(),
 88                workspace,
 89                cx.weak_entity(),
 90            ))));
 91            editor.set_context_menu_options(ContextMenuOptions {
 92                min_entries_visible: 12,
 93                max_entries_visible: 12,
 94                placement: Some(ContextMenuPlacement::Above),
 95            });
 96            editor
 97        });
 98        let message_editor_subscription = cx.subscribe(&editor, |this, editor, event, cx| {
 99            if let editor::EditorEvent::BufferEdited = &event {
100                let buffer = editor
101                    .read(cx)
102                    .buffer()
103                    .read(cx)
104                    .as_singleton()
105                    .unwrap()
106                    .read(cx)
107                    .snapshot();
108                if let Some(message) = this.message_set_from_history.clone()
109                    && message.version() != buffer.version()
110                {
111                    this.message_set_from_history = None;
112                }
113
114                if this.message_set_from_history.is_none() {
115                    this.history.borrow_mut().reset_position();
116                }
117            }
118        });
119
120        Self {
121            editor,
122            project,
123            mention_set,
124            history,
125            message_set_from_history: None,
126            _subscription: message_editor_subscription,
127        }
128    }
129
130    pub fn is_empty(&self, cx: &App) -> bool {
131        self.editor.read(cx).is_empty(cx)
132    }
133
134    pub fn contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
135        let contents = self.mention_set.lock().contents(self.project.clone(), cx);
136        let editor = self.editor.clone();
137
138        cx.spawn(async move |_, cx| {
139            let contents = contents.await?;
140
141            editor.update(cx, |editor, cx| {
142                let mut ix = 0;
143                let mut chunks: Vec<acp::ContentBlock> = Vec::new();
144                let text = editor.text(cx);
145                editor.display_map.update(cx, |map, cx| {
146                    let snapshot = map.snapshot(cx);
147                    for (crease_id, crease) in snapshot.crease_snapshot.creases() {
148                        // Skip creases that have been edited out of the message buffer.
149                        if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
150                            continue;
151                        }
152
153                        if let Some(mention) = contents.get(&crease_id) {
154                            let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
155                            if crease_range.start > ix {
156                                chunks.push(text[ix..crease_range.start].into());
157                            }
158                            chunks.push(acp::ContentBlock::Resource(acp::EmbeddedResource {
159                                annotations: None,
160                                resource: acp::EmbeddedResourceResource::TextResourceContents(
161                                    acp::TextResourceContents {
162                                        mime_type: None,
163                                        text: mention.content.clone(),
164                                        uri: mention.uri.to_uri(),
165                                    },
166                                ),
167                            }));
168                            ix = crease_range.end;
169                        }
170                    }
171
172                    if ix < text.len() {
173                        let last_chunk = text[ix..].trim_end();
174                        if !last_chunk.is_empty() {
175                            chunks.push(last_chunk.into());
176                        }
177                    }
178                });
179
180                chunks
181            })
182        })
183    }
184
185    pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
186        self.editor.update(cx, |editor, cx| {
187            editor.clear(window, cx);
188            editor.remove_creases(self.mention_set.lock().drain(), cx)
189        });
190    }
191
192    fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
193        cx.emit(MessageEditorEvent::Chat)
194    }
195
196    pub fn insert_dragged_files(
197        &self,
198        paths: Vec<project::ProjectPath>,
199        window: &mut Window,
200        cx: &mut Context<Self>,
201    ) {
202        let buffer = self.editor.read(cx).buffer().clone();
203        let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else {
204            return;
205        };
206        let Some(buffer) = buffer.read(cx).as_singleton() else {
207            return;
208        };
209        for path in paths {
210            let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
211                continue;
212            };
213            let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
214                continue;
215            };
216
217            let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
218            let path_prefix = abs_path
219                .file_name()
220                .unwrap_or(path.path.as_os_str())
221                .display()
222                .to_string();
223            let completion = ContextPickerCompletionProvider::completion_for_path(
224                path,
225                &path_prefix,
226                false,
227                entry.is_dir(),
228                excerpt_id,
229                anchor..anchor,
230                self.editor.clone(),
231                self.mention_set.clone(),
232                self.project.clone(),
233                cx,
234            );
235
236            self.editor.update(cx, |message_editor, cx| {
237                message_editor.edit(
238                    [(
239                        multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
240                        completion.new_text,
241                    )],
242                    cx,
243                );
244            });
245            if let Some(confirm) = completion.confirm.clone() {
246                confirm(CompletionIntent::Complete, window, cx);
247            }
248        }
249    }
250
251    pub fn set_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
252        self.editor.update(cx, |editor, cx| {
253            if expanded {
254                editor.set_mode(EditorMode::Full {
255                    scale_ui_elements_with_buffer_font_size: false,
256                    show_active_line_background: false,
257                    sized_by_content: false,
258                })
259            } else {
260                editor.set_mode(EditorMode::AutoHeight {
261                    min_lines: MIN_EDITOR_LINES,
262                    max_lines: Some(MAX_EDITOR_LINES),
263                })
264            }
265            cx.notify()
266        });
267    }
268
269    fn previous_history_message(
270        &mut self,
271        _: &PreviousHistoryMessage,
272        window: &mut Window,
273        cx: &mut Context<Self>,
274    ) {
275        if self.message_set_from_history.is_none() && !self.editor.read(cx).is_empty(cx) {
276            self.editor.update(cx, |editor, cx| {
277                editor.move_up(&Default::default(), window, cx);
278            });
279            return;
280        }
281
282        self.message_set_from_history = Self::set_draft_message(
283            self.editor.clone(),
284            self.mention_set.clone(),
285            self.project.clone(),
286            self.history
287                .borrow_mut()
288                .prev()
289                .map(|blocks| blocks.as_slice()),
290            window,
291            cx,
292        );
293    }
294
295    fn next_history_message(
296        &mut self,
297        _: &NextHistoryMessage,
298        window: &mut Window,
299        cx: &mut Context<Self>,
300    ) {
301        if self.message_set_from_history.is_none() {
302            self.editor.update(cx, |editor, cx| {
303                editor.move_down(&Default::default(), window, cx);
304            });
305            return;
306        }
307
308        let mut history = self.history.borrow_mut();
309        let next_history = history.next();
310
311        let set_draft_message = Self::set_draft_message(
312            self.editor.clone(),
313            self.mention_set.clone(),
314            self.project.clone(),
315            Some(
316                next_history
317                    .map(|blocks| blocks.as_slice())
318                    .unwrap_or_else(|| &[]),
319            ),
320            window,
321            cx,
322        );
323        // If we reset the text to an empty string because we ran out of history,
324        // we don't want to mark it as coming from the history
325        self.message_set_from_history = if next_history.is_some() {
326            set_draft_message
327        } else {
328            None
329        };
330    }
331
332    fn set_draft_message(
333        message_editor: Entity<Editor>,
334        mention_set: Arc<Mutex<MentionSet>>,
335        project: Entity<Project>,
336        message: Option<&[acp::ContentBlock]>,
337        window: &mut Window,
338        cx: &mut Context<Self>,
339    ) -> Option<BufferSnapshot> {
340        cx.notify();
341
342        let message = message?;
343
344        let mut text = String::new();
345        let mut mentions = Vec::new();
346
347        for chunk in message {
348            match chunk {
349                acp::ContentBlock::Text(text_content) => {
350                    text.push_str(&text_content.text);
351                }
352                acp::ContentBlock::Resource(acp::EmbeddedResource {
353                    resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
354                    ..
355                }) => {
356                    if let Some(ref mention @ MentionUri::File(ref abs_path)) =
357                        MentionUri::parse(&resource.uri).log_err()
358                    {
359                        let project_path = project
360                            .read(cx)
361                            .project_path_for_absolute_path(&abs_path, cx);
362                        let start = text.len();
363                        let content = mention.to_uri();
364                        text.push_str(&content);
365                        let end = text.len();
366                        if let Some(project_path) = project_path {
367                            let filename: SharedString = project_path
368                                .path
369                                .file_name()
370                                .unwrap_or_default()
371                                .to_string_lossy()
372                                .to_string()
373                                .into();
374                            mentions.push((start..end, project_path, filename));
375                        }
376                    }
377                }
378                acp::ContentBlock::Image(_)
379                | acp::ContentBlock::Audio(_)
380                | acp::ContentBlock::Resource(_)
381                | acp::ContentBlock::ResourceLink(_) => {}
382            }
383        }
384
385        let snapshot = message_editor.update(cx, |editor, cx| {
386            editor.set_text(text, window, cx);
387            editor.buffer().read(cx).snapshot(cx)
388        });
389
390        for (range, project_path, filename) in mentions {
391            let crease_icon_path = if project_path.path.is_dir() {
392                FileIcons::get_folder_icon(false, cx)
393                    .unwrap_or_else(|| IconName::Folder.path().into())
394            } else {
395                FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx)
396                    .unwrap_or_else(|| IconName::File.path().into())
397            };
398
399            let anchor = snapshot.anchor_before(range.start);
400            if let Some(project_path) = project.read(cx).absolute_path(&project_path, cx) {
401                let crease_id = crate::context_picker::insert_crease_for_mention(
402                    anchor.excerpt_id,
403                    anchor.text_anchor,
404                    range.end - range.start,
405                    filename,
406                    crease_icon_path,
407                    message_editor.clone(),
408                    window,
409                    cx,
410                );
411
412                if let Some(crease_id) = crease_id {
413                    mention_set.lock().insert(crease_id, project_path);
414                }
415            }
416        }
417
418        let snapshot = snapshot.as_singleton().unwrap().2.clone();
419        Some(snapshot)
420    }
421
422    #[cfg(test)]
423    pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
424        self.editor.update(cx, |editor, cx| {
425            editor.set_text(text, window, cx);
426        });
427    }
428}
429
430impl Focusable for MessageEditor {
431    fn focus_handle(&self, cx: &App) -> FocusHandle {
432        self.editor.focus_handle(cx)
433    }
434}
435
436impl Render for MessageEditor {
437    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
438        div()
439            .key_context("MessageEditor")
440            .on_action(cx.listener(Self::previous_history_message))
441            .on_action(cx.listener(Self::next_history_message))
442            .on_action(cx.listener(Self::chat))
443            .flex_1()
444            .child({
445                let settings = ThemeSettings::get_global(cx);
446                let font_size = TextSize::Small
447                    .rems(cx)
448                    .to_pixels(settings.agent_font_size(cx));
449                let line_height = settings.buffer_line_height.value() * font_size;
450
451                let text_style = TextStyle {
452                    color: cx.theme().colors().text,
453                    font_family: settings.buffer_font.family.clone(),
454                    font_fallbacks: settings.buffer_font.fallbacks.clone(),
455                    font_features: settings.buffer_font.features.clone(),
456                    font_size: font_size.into(),
457                    line_height: line_height.into(),
458                    ..Default::default()
459                };
460
461                EditorElement::new(
462                    &self.editor,
463                    EditorStyle {
464                        background: cx.theme().colors().editor_background,
465                        local_player: cx.theme().players().local(),
466                        text: text_style,
467                        syntax: cx.theme().syntax().clone(),
468                        ..Default::default()
469                    },
470                )
471            })
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use std::{cell::RefCell, path::Path, rc::Rc};
478
479    use agent_client_protocol as acp;
480    use fs::FakeFs;
481    use gpui::{AppContext, TestAppContext};
482    use lsp::{CompletionContext, CompletionTriggerKind};
483    use pretty_assertions::assert_matches;
484    use project::{CompletionIntent, Project};
485    use serde_json::json;
486    use util::path;
487    use workspace::Workspace;
488
489    use crate::acp::{
490        MessageHistory, message_editor::MessageEditor, thread_view::tests::init_test,
491    };
492
493    #[gpui::test]
494    async fn test_at_mention_history(cx: &mut TestAppContext) {
495        init_test(cx);
496
497        let history = Rc::new(RefCell::new(MessageHistory::default()));
498        let fs = FakeFs::new(cx.executor());
499        fs.insert_tree("/project", json!({"file": ""})).await;
500        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
501
502        let (workspace, cx) =
503            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
504
505        let message_editor = cx.update(|window, cx| {
506            cx.new(|cx| {
507                MessageEditor::new(
508                    workspace.downgrade(),
509                    project.clone(),
510                    history.clone(),
511                    window,
512                    cx,
513                )
514            })
515        });
516        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
517
518        cx.run_until_parked();
519
520        let excerpt_id = editor.update(cx, |editor, cx| {
521            editor
522                .buffer()
523                .read(cx)
524                .excerpt_ids()
525                .into_iter()
526                .next()
527                .unwrap()
528        });
529        let completions = editor.update_in(cx, |editor, window, cx| {
530            editor.set_text("Hello @", window, cx);
531            let buffer = editor.buffer().read(cx).as_singleton().unwrap();
532            let completion_provider = editor.completion_provider().unwrap();
533            completion_provider.completions(
534                excerpt_id,
535                &buffer,
536                text::Anchor::MAX,
537                CompletionContext {
538                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
539                    trigger_character: Some("@".into()),
540                },
541                window,
542                cx,
543            )
544        });
545        let [_, completion]: [_; 2] = completions
546            .await
547            .unwrap()
548            .into_iter()
549            .flat_map(|response| response.completions)
550            .collect::<Vec<_>>()
551            .try_into()
552            .unwrap();
553
554        editor.update_in(cx, |editor, window, cx| {
555            let snapshot = editor.buffer().read(cx).snapshot(cx);
556            let start = snapshot
557                .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
558                .unwrap();
559            let end = snapshot
560                .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
561                .unwrap();
562            editor.edit([(start..end, completion.new_text)], cx);
563            (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
564        });
565
566        cx.run_until_parked();
567
568        let content = message_editor
569            .update(cx, |message_editor, cx| message_editor.contents(cx))
570            .await
571            .unwrap();
572        assert_eq!(content.len(), 2);
573        assert_matches!(&content[0], &acp::ContentBlock::Text(_));
574        assert_matches!(&content[1], &acp::ContentBlock::Resource(_));
575
576        history.borrow_mut().push(content);
577        message_editor.update_in(cx, |message_editor, window, cx| {
578            message_editor.clear(window, cx);
579            message_editor.previous_history_message(&Default::default(), window, cx);
580        });
581
582        let content = message_editor
583            .update(cx, |message_editor, cx| message_editor.contents(cx))
584            .await
585            .unwrap();
586        assert_eq!(content.len(), 2);
587        assert_matches!(&content[0], &acp::ContentBlock::Text(_));
588        assert_matches!(&content[1], &acp::ContentBlock::Resource(_));
589    }
590
591    #[gpui::test]
592    async fn test_at_mention_removal(cx: &mut TestAppContext) {
593        init_test(cx);
594
595        let history = Rc::new(RefCell::new(MessageHistory::default()));
596        let fs = FakeFs::new(cx.executor());
597        fs.insert_tree("/project", json!({"file": ""})).await;
598        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
599
600        let (workspace, cx) =
601            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
602
603        let message_editor = cx.update(|window, cx| {
604            cx.new(|cx| {
605                MessageEditor::new(
606                    workspace.downgrade(),
607                    project.clone(),
608                    history.clone(),
609                    window,
610                    cx,
611                )
612            })
613        });
614        let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
615
616        cx.run_until_parked();
617
618        let excerpt_id = editor.update(cx, |editor, cx| {
619            editor
620                .buffer()
621                .read(cx)
622                .excerpt_ids()
623                .into_iter()
624                .next()
625                .unwrap()
626        });
627        let completions = editor.update_in(cx, |editor, window, cx| {
628            editor.set_text("Hello @", window, cx);
629            let buffer = editor.buffer().read(cx).as_singleton().unwrap();
630            let completion_provider = editor.completion_provider().unwrap();
631            completion_provider.completions(
632                excerpt_id,
633                &buffer,
634                text::Anchor::MAX,
635                CompletionContext {
636                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
637                    trigger_character: Some("@".into()),
638                },
639                window,
640                cx,
641            )
642        });
643        let [_, completion]: [_; 2] = completions
644            .await
645            .unwrap()
646            .into_iter()
647            .flat_map(|response| response.completions)
648            .collect::<Vec<_>>()
649            .try_into()
650            .unwrap();
651
652        editor.update_in(cx, |editor, window, cx| {
653            let snapshot = editor.buffer().read(cx).snapshot(cx);
654            let start = snapshot
655                .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
656                .unwrap();
657            let end = snapshot
658                .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
659                .unwrap();
660            editor.edit([(start..end, completion.new_text)], cx);
661            (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
662        });
663
664        cx.run_until_parked();
665
666        // Backspace over the inserted crease (and the following space).
667        editor.update_in(cx, |editor, window, cx| {
668            editor.backspace(&Default::default(), window, cx);
669            editor.backspace(&Default::default(), window, cx);
670        });
671
672        let content = message_editor
673            .update_in(cx, |message_editor, _window, cx| {
674                message_editor.contents(cx)
675            })
676            .await
677            .unwrap();
678
679        // We don't send a resource link for the deleted crease.
680        pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
681    }
682}