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}