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