1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use crate::assistant_model_selector::{AssistantModelSelector, ModelType};
5use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
6use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
7use crate::ui::{
8 AnimatedLabel, MaxModeTooltip,
9 preview::{AgentPreview, UsageCallout},
10};
11use buffer_diff::BufferDiff;
12use client::UserStore;
13use collections::{HashMap, HashSet};
14use editor::actions::{MoveUp, Paste};
15use editor::{
16 AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorEvent,
17 EditorMode, EditorStyle, MultiBuffer,
18};
19use feature_flags::{FeatureFlagAppExt, NewBillingFeatureFlag};
20use file_icons::FileIcons;
21use fs::Fs;
22use futures::future::Shared;
23use futures::{FutureExt as _, future};
24use gpui::{
25 Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription,
26 Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
27};
28use language::{Buffer, Language};
29use language_model::{ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage};
30use language_model_selector::ToggleModelSelector;
31use multi_buffer;
32use project::Project;
33use prompt_store::PromptStore;
34use proto::Plan;
35use settings::Settings;
36use std::time::Duration;
37use theme::ThemeSettings;
38use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
39use util::{ResultExt as _, maybe};
40use workspace::{CollaboratorId, Workspace};
41use zed_llm_client::CompletionMode;
42
43use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
44use crate::context_store::ContextStore;
45use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
46use crate::profile_selector::ProfileSelector;
47use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
48use crate::thread_store::{TextThreadStore, ThreadStore};
49use crate::{
50 ActiveThread, AgentDiffPane, Chat, ExpandMessageEditor, Follow, NewThread, OpenAgentDiff,
51 RemoveAllContext, ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
52};
53
54#[derive(RegisterComponent)]
55pub struct MessageEditor {
56 thread: Entity<Thread>,
57 incompatible_tools_state: Entity<IncompatibleToolsState>,
58 editor: Entity<Editor>,
59 workspace: WeakEntity<Workspace>,
60 project: Entity<Project>,
61 user_store: Entity<UserStore>,
62 context_store: Entity<ContextStore>,
63 prompt_store: Option<Entity<PromptStore>>,
64 context_strip: Entity<ContextStrip>,
65 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
66 model_selector: Entity<AssistantModelSelector>,
67 last_loaded_context: Option<ContextLoadResult>,
68 load_context_task: Option<Shared<Task<()>>>,
69 profile_selector: Entity<ProfileSelector>,
70 edits_expanded: bool,
71 editor_is_expanded: bool,
72 last_estimated_token_count: Option<usize>,
73 update_token_count_task: Option<Task<()>>,
74 _subscriptions: Vec<Subscription>,
75}
76
77const MAX_EDITOR_LINES: usize = 8;
78
79pub(crate) fn create_editor(
80 workspace: WeakEntity<Workspace>,
81 context_store: WeakEntity<ContextStore>,
82 thread_store: WeakEntity<ThreadStore>,
83 text_thread_store: WeakEntity<TextThreadStore>,
84 window: &mut Window,
85 cx: &mut App,
86) -> Entity<Editor> {
87 let language = Language::new(
88 language::LanguageConfig {
89 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
90 ..Default::default()
91 },
92 None,
93 );
94
95 let editor = cx.new(|cx| {
96 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
97 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
98 let mut editor = Editor::new(
99 editor::EditorMode::AutoHeight {
100 max_lines: MAX_EDITOR_LINES,
101 },
102 buffer,
103 None,
104 window,
105 cx,
106 );
107 editor.set_placeholder_text("Message the agent – @ to include context", cx);
108 editor.set_show_indent_guides(false, cx);
109 editor.set_soft_wrap();
110 editor.set_context_menu_options(ContextMenuOptions {
111 min_entries_visible: 12,
112 max_entries_visible: 12,
113 placement: Some(ContextMenuPlacement::Above),
114 });
115 editor.register_addon(ContextCreasesAddon::new());
116 editor
117 });
118
119 let editor_entity = editor.downgrade();
120 editor.update(cx, |editor, _| {
121 editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
122 workspace,
123 context_store,
124 Some(thread_store),
125 Some(text_thread_store),
126 editor_entity,
127 None,
128 ))));
129 });
130 editor
131}
132
133impl MessageEditor {
134 pub fn new(
135 fs: Arc<dyn Fs>,
136 workspace: WeakEntity<Workspace>,
137 user_store: Entity<UserStore>,
138 context_store: Entity<ContextStore>,
139 prompt_store: Option<Entity<PromptStore>>,
140 thread_store: WeakEntity<ThreadStore>,
141 text_thread_store: WeakEntity<TextThreadStore>,
142 thread: Entity<Thread>,
143 window: &mut Window,
144 cx: &mut Context<Self>,
145 ) -> Self {
146 let context_picker_menu_handle = PopoverMenuHandle::default();
147 let model_selector_menu_handle = PopoverMenuHandle::default();
148
149 let editor = create_editor(
150 workspace.clone(),
151 context_store.downgrade(),
152 thread_store.clone(),
153 text_thread_store.clone(),
154 window,
155 cx,
156 );
157
158 let context_strip = cx.new(|cx| {
159 ContextStrip::new(
160 context_store.clone(),
161 workspace.clone(),
162 Some(thread_store.clone()),
163 Some(text_thread_store.clone()),
164 context_picker_menu_handle.clone(),
165 SuggestContextKind::File,
166 window,
167 cx,
168 )
169 });
170
171 let incompatible_tools =
172 cx.new(|cx| IncompatibleToolsState::new(thread.read(cx).tools().clone(), cx));
173
174 let subscriptions = vec![
175 cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
176 cx.subscribe(&editor, |this, _, event, cx| match event {
177 EditorEvent::BufferEdited => this.handle_message_changed(cx),
178 _ => {}
179 }),
180 cx.observe(&context_store, |this, _, cx| {
181 // When context changes, reload it for token counting.
182 let _ = this.reload_context(cx);
183 }),
184 cx.observe(&thread.read(cx).action_log().clone(), |_, _, cx| {
185 cx.notify()
186 }),
187 ];
188
189 let model_selector = cx.new(|cx| {
190 AssistantModelSelector::new(
191 fs.clone(),
192 model_selector_menu_handle,
193 editor.focus_handle(cx),
194 ModelType::Default(thread.clone()),
195 window,
196 cx,
197 )
198 });
199
200 Self {
201 editor: editor.clone(),
202 project: thread.read(cx).project().clone(),
203 user_store,
204 thread,
205 incompatible_tools_state: incompatible_tools.clone(),
206 workspace,
207 context_store,
208 prompt_store,
209 context_strip,
210 context_picker_menu_handle,
211 load_context_task: None,
212 last_loaded_context: None,
213 model_selector,
214 edits_expanded: false,
215 editor_is_expanded: false,
216 profile_selector: cx
217 .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
218 last_estimated_token_count: None,
219 update_token_count_task: None,
220 _subscriptions: subscriptions,
221 }
222 }
223
224 pub fn context_store(&self) -> &Entity<ContextStore> {
225 &self.context_store
226 }
227
228 pub fn expand_message_editor(
229 &mut self,
230 _: &ExpandMessageEditor,
231 _window: &mut Window,
232 cx: &mut Context<Self>,
233 ) {
234 self.set_editor_is_expanded(!self.editor_is_expanded, cx);
235 }
236
237 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
238 self.editor_is_expanded = is_expanded;
239 self.editor.update(cx, |editor, _| {
240 if self.editor_is_expanded {
241 editor.set_mode(EditorMode::Full {
242 scale_ui_elements_with_buffer_font_size: false,
243 show_active_line_background: false,
244 sized_by_content: false,
245 })
246 } else {
247 editor.set_mode(EditorMode::AutoHeight {
248 max_lines: MAX_EDITOR_LINES,
249 })
250 }
251 });
252 cx.notify();
253 }
254
255 fn toggle_context_picker(
256 &mut self,
257 _: &ToggleContextPicker,
258 window: &mut Window,
259 cx: &mut Context<Self>,
260 ) {
261 self.context_picker_menu_handle.toggle(window, cx);
262 }
263
264 pub fn remove_all_context(
265 &mut self,
266 _: &RemoveAllContext,
267 _window: &mut Window,
268 cx: &mut Context<Self>,
269 ) {
270 self.context_store.update(cx, |store, _cx| store.clear());
271 cx.notify();
272 }
273
274 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
275 if self.is_editor_empty(cx) {
276 return;
277 }
278
279 self.thread.update(cx, |thread, cx| {
280 thread.cancel_editing(cx);
281 });
282
283 if self.thread.read(cx).is_generating() {
284 self.stop_current_and_send_new_message(window, cx);
285 return;
286 }
287
288 self.set_editor_is_expanded(false, cx);
289 self.send_to_model(window, cx);
290
291 cx.notify();
292 }
293
294 fn is_editor_empty(&self, cx: &App) -> bool {
295 self.editor.read(cx).text(cx).trim().is_empty()
296 }
297
298 fn send_to_model(&mut self, window: &mut Window, cx: &mut Context<Self>) {
299 let Some(ConfiguredModel { model, provider }) = self
300 .thread
301 .update(cx, |thread, cx| thread.get_or_init_configured_model(cx))
302 else {
303 return;
304 };
305
306 if provider.must_accept_terms(cx) {
307 cx.notify();
308 return;
309 }
310
311 let (user_message, user_message_creases) = self.editor.update(cx, |editor, cx| {
312 let creases = extract_message_creases(editor, cx);
313 let text = editor.text(cx);
314 editor.clear(window, cx);
315 (text, creases)
316 });
317
318 self.last_estimated_token_count.take();
319 cx.emit(MessageEditorEvent::EstimatedTokenCount);
320
321 let thread = self.thread.clone();
322 let git_store = self.project.read(cx).git_store().clone();
323 let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
324 let context_task = self.reload_context(cx);
325 let window_handle = window.window_handle();
326
327 cx.spawn(async move |_this, cx| {
328 let (checkpoint, loaded_context) = future::join(checkpoint, context_task).await;
329 let loaded_context = loaded_context.unwrap_or_default();
330
331 thread
332 .update(cx, |thread, cx| {
333 thread.insert_user_message(
334 user_message,
335 loaded_context,
336 checkpoint.ok(),
337 user_message_creases,
338 cx,
339 );
340 })
341 .log_err();
342
343 thread
344 .update(cx, |thread, cx| {
345 thread.advance_prompt_id();
346 thread.send_to_model(model, Some(window_handle), cx);
347 })
348 .log_err();
349 })
350 .detach();
351 }
352
353 fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
354 self.thread.update(cx, |thread, cx| {
355 thread.cancel_editing(cx);
356 });
357
358 let cancelled = self.thread.update(cx, |thread, cx| {
359 thread.cancel_last_completion(Some(window.window_handle()), cx)
360 });
361
362 if cancelled {
363 self.set_editor_is_expanded(false, cx);
364 self.send_to_model(window, cx);
365 }
366 }
367
368 fn handle_context_strip_event(
369 &mut self,
370 _context_strip: &Entity<ContextStrip>,
371 event: &ContextStripEvent,
372 window: &mut Window,
373 cx: &mut Context<Self>,
374 ) {
375 match event {
376 ContextStripEvent::PickerDismissed
377 | ContextStripEvent::BlurredEmpty
378 | ContextStripEvent::BlurredDown => {
379 let editor_focus_handle = self.editor.focus_handle(cx);
380 window.focus(&editor_focus_handle);
381 }
382 ContextStripEvent::BlurredUp => {}
383 }
384 }
385
386 fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
387 if self.context_picker_menu_handle.is_deployed() {
388 cx.propagate();
389 } else {
390 self.context_strip.focus_handle(cx).focus(window);
391 }
392 }
393
394 fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
395 let images = cx
396 .read_from_clipboard()
397 .map(|item| {
398 item.into_entries()
399 .filter_map(|entry| {
400 if let ClipboardEntry::Image(image) = entry {
401 Some(image)
402 } else {
403 None
404 }
405 })
406 .collect::<Vec<_>>()
407 })
408 .unwrap_or_default();
409
410 if images.is_empty() {
411 return;
412 }
413 cx.stop_propagation();
414
415 self.context_store.update(cx, |store, cx| {
416 for image in images {
417 store.add_image_instance(Arc::new(image), cx);
418 }
419 });
420 }
421
422 fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) {
423 self.edits_expanded = true;
424 AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
425 cx.notify();
426 }
427
428 fn handle_file_click(
429 &self,
430 buffer: Entity<Buffer>,
431 window: &mut Window,
432 cx: &mut Context<Self>,
433 ) {
434 if let Ok(diff) =
435 AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx)
436 {
437 let path_key = multi_buffer::PathKey::for_buffer(&buffer, cx);
438 diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx));
439 }
440 }
441
442 fn render_max_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
443 if !cx.has_flag::<NewBillingFeatureFlag>() {
444 return None;
445 }
446
447 let thread = self.thread.read(cx);
448 let model = thread.configured_model();
449 if !model?.model.supports_max_mode() {
450 return None;
451 }
452
453 let active_completion_mode = thread.completion_mode();
454
455 Some(
456 IconButton::new("max-mode", IconName::ZedMaxMode)
457 .icon_size(IconSize::Small)
458 .icon_color(Color::Muted)
459 .toggle_state(active_completion_mode == CompletionMode::Max)
460 .on_click(cx.listener(move |this, _event, _window, cx| {
461 this.thread.update(cx, |thread, _cx| {
462 thread.set_completion_mode(match active_completion_mode {
463 CompletionMode::Max => CompletionMode::Normal,
464 CompletionMode::Normal => CompletionMode::Max,
465 });
466 });
467 }))
468 .tooltip(|_, cx| cx.new(MaxModeTooltip::new).into())
469 .into_any_element(),
470 )
471 }
472
473 fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
474 let following = self
475 .workspace
476 .read_with(cx, |workspace, _| {
477 workspace.is_being_followed(CollaboratorId::Agent)
478 })
479 .unwrap_or(false);
480
481 IconButton::new("follow-agent", IconName::Crosshair)
482 .icon_size(IconSize::Small)
483 .icon_color(Color::Muted)
484 .toggle_state(following)
485 .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
486 .tooltip(move |window, cx| {
487 if following {
488 Tooltip::for_action("Stop Following Agent", &Follow, window, cx)
489 } else {
490 Tooltip::with_meta(
491 "Follow Agent",
492 Some(&Follow),
493 "Track the agent's location as it reads and edits files.",
494 window,
495 cx,
496 )
497 }
498 })
499 .on_click(cx.listener(move |this, _, window, cx| {
500 this.workspace
501 .update(cx, |workspace, cx| {
502 if following {
503 workspace.unfollow(CollaboratorId::Agent, window, cx);
504 } else {
505 workspace.follow(CollaboratorId::Agent, window, cx);
506 }
507 })
508 .ok();
509 }))
510 }
511
512 fn render_editor(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
513 let thread = self.thread.read(cx);
514 let model = thread.configured_model();
515
516 let editor_bg_color = cx.theme().colors().editor_background;
517 let is_generating = thread.is_generating();
518 let focus_handle = self.editor.focus_handle(cx);
519
520 let is_model_selected = model.is_some();
521 let is_editor_empty = self.is_editor_empty(cx);
522
523 let incompatible_tools = model
524 .as_ref()
525 .map(|model| {
526 self.incompatible_tools_state.update(cx, |state, cx| {
527 state
528 .incompatible_tools(&model.model, cx)
529 .iter()
530 .cloned()
531 .collect::<Vec<_>>()
532 })
533 })
534 .unwrap_or_default();
535
536 let is_editor_expanded = self.editor_is_expanded;
537 let expand_icon = if is_editor_expanded {
538 IconName::Minimize
539 } else {
540 IconName::Maximize
541 };
542
543 v_flex()
544 .key_context("MessageEditor")
545 .on_action(cx.listener(Self::chat))
546 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
547 this.profile_selector
548 .read(cx)
549 .menu_handle()
550 .toggle(window, cx);
551 }))
552 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
553 this.model_selector
554 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
555 }))
556 .on_action(cx.listener(Self::toggle_context_picker))
557 .on_action(cx.listener(Self::remove_all_context))
558 .on_action(cx.listener(Self::move_up))
559 .on_action(cx.listener(Self::expand_message_editor))
560 .capture_action(cx.listener(Self::paste))
561 .gap_2()
562 .p_2()
563 .bg(editor_bg_color)
564 .border_t_1()
565 .border_color(cx.theme().colors().border)
566 .child(
567 h_flex()
568 .items_start()
569 .justify_between()
570 .child(self.context_strip.clone())
571 .child(
572 h_flex()
573 .gap_1()
574 .when(focus_handle.is_focused(window), |this| {
575 this.child(
576 IconButton::new("toggle-height", expand_icon)
577 .icon_size(IconSize::XSmall)
578 .icon_color(Color::Muted)
579 .tooltip({
580 let focus_handle = focus_handle.clone();
581 move |window, cx| {
582 let expand_label = if is_editor_expanded {
583 "Minimize Message Editor".to_string()
584 } else {
585 "Expand Message Editor".to_string()
586 };
587
588 Tooltip::for_action_in(
589 expand_label,
590 &ExpandMessageEditor,
591 &focus_handle,
592 window,
593 cx,
594 )
595 }
596 })
597 .on_click(cx.listener(|_, _, window, cx| {
598 window
599 .dispatch_action(Box::new(ExpandMessageEditor), cx);
600 })),
601 )
602 }),
603 ),
604 )
605 .child(
606 v_flex()
607 .size_full()
608 .gap_4()
609 .when(is_editor_expanded, |this| {
610 this.h(vh(0.8, window)).justify_between()
611 })
612 .child(
613 div()
614 .min_h_16()
615 .when(is_editor_expanded, |this| this.h_full())
616 .child({
617 let settings = ThemeSettings::get_global(cx);
618 let font_size = TextSize::Small
619 .rems(cx)
620 .to_pixels(settings.agent_font_size(cx));
621 let line_height = settings.buffer_line_height.value() * font_size;
622
623 let text_style = TextStyle {
624 color: cx.theme().colors().text,
625 font_family: settings.buffer_font.family.clone(),
626 font_fallbacks: settings.buffer_font.fallbacks.clone(),
627 font_features: settings.buffer_font.features.clone(),
628 font_size: font_size.into(),
629 line_height: line_height.into(),
630 ..Default::default()
631 };
632
633 EditorElement::new(
634 &self.editor,
635 EditorStyle {
636 background: editor_bg_color,
637 local_player: cx.theme().players().local(),
638 text: text_style,
639 syntax: cx.theme().syntax().clone(),
640 ..Default::default()
641 },
642 )
643 .into_any()
644 }),
645 )
646 .child(
647 h_flex()
648 .flex_none()
649 .justify_between()
650 .child(
651 h_flex()
652 .gap_1()
653 .child(self.render_follow_toggle(cx))
654 .children(self.render_max_mode_toggle(cx)),
655 )
656 .child(
657 h_flex()
658 .gap_1()
659 .when(!incompatible_tools.is_empty(), |this| {
660 this.child(
661 IconButton::new(
662 "tools-incompatible-warning",
663 IconName::Warning,
664 )
665 .icon_color(Color::Warning)
666 .icon_size(IconSize::Small)
667 .tooltip({
668 move |_, cx| {
669 cx.new(|_| IncompatibleToolsTooltip {
670 incompatible_tools: incompatible_tools
671 .clone(),
672 })
673 .into()
674 }
675 }),
676 )
677 })
678 .child(self.profile_selector.clone())
679 .child(self.model_selector.clone())
680 .map({
681 let focus_handle = focus_handle.clone();
682 move |parent| {
683 if is_generating {
684 parent
685 .when(is_editor_empty, |parent| {
686 parent.child(
687 IconButton::new(
688 "stop-generation",
689 IconName::StopFilled,
690 )
691 .icon_color(Color::Error)
692 .style(ButtonStyle::Tinted(
693 ui::TintColor::Error,
694 ))
695 .tooltip(move |window, cx| {
696 Tooltip::for_action(
697 "Stop Generation",
698 &editor::actions::Cancel,
699 window,
700 cx,
701 )
702 })
703 .on_click({
704 let focus_handle =
705 focus_handle.clone();
706 move |_event, window, cx| {
707 focus_handle.dispatch_action(
708 &editor::actions::Cancel,
709 window,
710 cx,
711 );
712 }
713 })
714 .with_animation(
715 "pulsating-label",
716 Animation::new(
717 Duration::from_secs(2),
718 )
719 .repeat()
720 .with_easing(pulsating_between(
721 0.4, 1.0,
722 )),
723 |icon_button, delta| {
724 icon_button.alpha(delta)
725 },
726 ),
727 )
728 })
729 .when(!is_editor_empty, |parent| {
730 parent.child(
731 IconButton::new(
732 "send-message",
733 IconName::Send,
734 )
735 .icon_color(Color::Accent)
736 .style(ButtonStyle::Filled)
737 .disabled(!is_model_selected)
738 .on_click({
739 let focus_handle =
740 focus_handle.clone();
741 move |_event, window, cx| {
742 focus_handle.dispatch_action(
743 &Chat, window, cx,
744 );
745 }
746 })
747 .tooltip(move |window, cx| {
748 Tooltip::for_action(
749 "Stop and Send New Message",
750 &Chat,
751 window,
752 cx,
753 )
754 }),
755 )
756 })
757 } else {
758 parent.child(
759 IconButton::new("send-message", IconName::Send)
760 .icon_color(Color::Accent)
761 .style(ButtonStyle::Filled)
762 .disabled(
763 is_editor_empty || !is_model_selected,
764 )
765 .on_click({
766 let focus_handle = focus_handle.clone();
767 move |_event, window, cx| {
768 focus_handle.dispatch_action(
769 &Chat, window, cx,
770 );
771 }
772 })
773 .when(
774 !is_editor_empty && is_model_selected,
775 |button| {
776 button.tooltip(move |window, cx| {
777 Tooltip::for_action(
778 "Send", &Chat, window, cx,
779 )
780 })
781 },
782 )
783 .when(is_editor_empty, |button| {
784 button.tooltip(Tooltip::text(
785 "Type a message to submit",
786 ))
787 })
788 .when(!is_model_selected, |button| {
789 button.tooltip(Tooltip::text(
790 "Select a model to continue",
791 ))
792 }),
793 )
794 }
795 }
796 }),
797 ),
798 ),
799 )
800 }
801
802 fn render_changed_buffers(
803 &self,
804 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
805 window: &mut Window,
806 cx: &mut Context<Self>,
807 ) -> Div {
808 let focus_handle = self.editor.focus_handle(cx);
809
810 let editor_bg_color = cx.theme().colors().editor_background;
811 let border_color = cx.theme().colors().border;
812 let active_color = cx.theme().colors().element_selected;
813 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
814
815 let is_edit_changes_expanded = self.edits_expanded;
816 let is_generating = self.thread.read(cx).is_generating();
817
818 v_flex()
819 .mt_1()
820 .mx_2()
821 .bg(bg_edit_files_disclosure)
822 .border_1()
823 .border_b_0()
824 .border_color(border_color)
825 .rounded_t_md()
826 .shadow(smallvec::smallvec![gpui::BoxShadow {
827 color: gpui::black().opacity(0.15),
828 offset: point(px(1.), px(-1.)),
829 blur_radius: px(3.),
830 spread_radius: px(0.),
831 }])
832 .child(
833 h_flex()
834 .id("edits-container")
835 .cursor_pointer()
836 .p_1p5()
837 .justify_between()
838 .when(is_edit_changes_expanded, |this| {
839 this.border_b_1().border_color(border_color)
840 })
841 .on_click(
842 cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)),
843 )
844 .child(
845 h_flex()
846 .gap_1()
847 .child(
848 Disclosure::new("edits-disclosure", is_edit_changes_expanded)
849 .on_click(cx.listener(|this, _ev, _window, cx| {
850 this.edits_expanded = !this.edits_expanded;
851 cx.notify();
852 })),
853 )
854 .map(|this| {
855 if is_generating {
856 this.child(
857 AnimatedLabel::new(format!(
858 "Editing {} {}",
859 changed_buffers.len(),
860 if changed_buffers.len() == 1 {
861 "file"
862 } else {
863 "files"
864 }
865 ))
866 .size(LabelSize::Small),
867 )
868 } else {
869 this.child(
870 Label::new("Edits")
871 .size(LabelSize::Small)
872 .color(Color::Muted),
873 )
874 .child(
875 Label::new("•").size(LabelSize::XSmall).color(Color::Muted),
876 )
877 .child(
878 Label::new(format!(
879 "{} {}",
880 changed_buffers.len(),
881 if changed_buffers.len() == 1 {
882 "file"
883 } else {
884 "files"
885 }
886 ))
887 .size(LabelSize::Small)
888 .color(Color::Muted),
889 )
890 }
891 }),
892 )
893 .child(
894 Button::new("review", "Review Changes")
895 .label_size(LabelSize::Small)
896 .key_binding(
897 KeyBinding::for_action_in(
898 &OpenAgentDiff,
899 &focus_handle,
900 window,
901 cx,
902 )
903 .map(|kb| kb.size(rems_from_px(12.))),
904 )
905 .on_click(cx.listener(|this, _, window, cx| {
906 this.handle_review_click(window, cx)
907 })),
908 ),
909 )
910 .when(is_edit_changes_expanded, |parent| {
911 parent.child(
912 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
913 |(index, (buffer, _diff))| {
914 let file = buffer.read(cx).file()?;
915 let path = file.path();
916
917 let parent_label = path.parent().and_then(|parent| {
918 let parent_str = parent.to_string_lossy();
919
920 if parent_str.is_empty() {
921 None
922 } else {
923 Some(
924 Label::new(format!(
925 "/{}{}",
926 parent_str,
927 std::path::MAIN_SEPARATOR_STR
928 ))
929 .color(Color::Muted)
930 .size(LabelSize::XSmall)
931 .buffer_font(cx),
932 )
933 }
934 });
935
936 let name_label = path.file_name().map(|name| {
937 Label::new(name.to_string_lossy().to_string())
938 .size(LabelSize::XSmall)
939 .buffer_font(cx)
940 });
941
942 let file_icon = FileIcons::get_icon(&path, cx)
943 .map(Icon::from_path)
944 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
945 .unwrap_or_else(|| {
946 Icon::new(IconName::File)
947 .color(Color::Muted)
948 .size(IconSize::Small)
949 });
950
951 let hover_color = cx
952 .theme()
953 .colors()
954 .element_background
955 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
956
957 let overlay_gradient = linear_gradient(
958 90.,
959 linear_color_stop(editor_bg_color, 1.),
960 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
961 );
962
963 let overlay_gradient_hover = linear_gradient(
964 90.,
965 linear_color_stop(hover_color, 1.),
966 linear_color_stop(hover_color.opacity(0.2), 0.),
967 );
968
969 let element = h_flex()
970 .group("edited-code")
971 .id(("file-container", index))
972 .cursor_pointer()
973 .relative()
974 .py_1()
975 .pl_2()
976 .pr_1()
977 .gap_2()
978 .justify_between()
979 .bg(cx.theme().colors().editor_background)
980 .hover(|style| style.bg(hover_color))
981 .when(index < changed_buffers.len() - 1, |parent| {
982 parent.border_color(border_color).border_b_1()
983 })
984 .child(
985 h_flex()
986 .id("file-name")
987 .pr_8()
988 .gap_1p5()
989 .max_w_full()
990 .overflow_x_scroll()
991 .child(file_icon)
992 .child(
993 h_flex()
994 .gap_0p5()
995 .children(name_label)
996 .children(parent_label),
997 ), // TODO: Implement line diff
998 // .child(Label::new("+").color(Color::Created))
999 // .child(Label::new("-").color(Color::Deleted)),
1000 )
1001 .child(
1002 div().visible_on_hover("edited-code").child(
1003 Button::new("review", "Review")
1004 .label_size(LabelSize::Small)
1005 .on_click({
1006 let buffer = buffer.clone();
1007 cx.listener(move |this, _, window, cx| {
1008 this.handle_file_click(
1009 buffer.clone(),
1010 window,
1011 cx,
1012 );
1013 })
1014 }),
1015 ),
1016 )
1017 .child(
1018 div()
1019 .id("gradient-overlay")
1020 .absolute()
1021 .h_5_6()
1022 .w_12()
1023 .bottom_0()
1024 .right(px(52.))
1025 .bg(overlay_gradient)
1026 .group_hover("edited-code", |style| {
1027 style.bg(overlay_gradient_hover)
1028 }),
1029 )
1030 .on_click({
1031 let buffer = buffer.clone();
1032 cx.listener(move |this, _, window, cx| {
1033 this.handle_file_click(buffer.clone(), window, cx);
1034 })
1035 });
1036
1037 Some(element)
1038 },
1039 )),
1040 )
1041 })
1042 }
1043
1044 fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
1045 if !cx.has_flag::<NewBillingFeatureFlag>() {
1046 return None;
1047 }
1048
1049 let user_store = self.user_store.read(cx);
1050
1051 let ubb_enable = user_store
1052 .usage_based_billing_enabled()
1053 .map_or(false, |enabled| enabled);
1054
1055 if ubb_enable {
1056 return None;
1057 }
1058
1059 let plan = user_store
1060 .current_plan()
1061 .map(|plan| match plan {
1062 Plan::Free => zed_llm_client::Plan::Free,
1063 Plan::ZedPro => zed_llm_client::Plan::ZedPro,
1064 Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial,
1065 })
1066 .unwrap_or(zed_llm_client::Plan::Free);
1067 let usage = self.thread.read(cx).last_usage().or_else(|| {
1068 maybe!({
1069 let amount = user_store.model_request_usage_amount()?;
1070 let limit = user_store.model_request_usage_limit()?.variant?;
1071
1072 Some(RequestUsage {
1073 amount: amount as i32,
1074 limit: match limit {
1075 proto::usage_limit::Variant::Limited(limited) => {
1076 zed_llm_client::UsageLimit::Limited(limited.limit as i32)
1077 }
1078 proto::usage_limit::Variant::Unlimited(_) => {
1079 zed_llm_client::UsageLimit::Unlimited
1080 }
1081 },
1082 })
1083 })
1084 })?;
1085
1086 Some(
1087 div()
1088 .child(UsageCallout::new(plan, usage))
1089 .line_height(line_height),
1090 )
1091 }
1092
1093 fn render_token_limit_callout(
1094 &self,
1095 line_height: Pixels,
1096 token_usage_ratio: TokenUsageRatio,
1097 cx: &mut Context<Self>,
1098 ) -> Option<Div> {
1099 if !cx.has_flag::<NewBillingFeatureFlag>() {
1100 return None;
1101 }
1102
1103 let title = if token_usage_ratio == TokenUsageRatio::Exceeded {
1104 "Thread reached the token limit"
1105 } else {
1106 "Thread reaching the token limit soon"
1107 };
1108
1109 let message = "Start a new thread from a summary to continue the conversation.";
1110
1111 let icon = if token_usage_ratio == TokenUsageRatio::Exceeded {
1112 Icon::new(IconName::X)
1113 .color(Color::Error)
1114 .size(IconSize::XSmall)
1115 } else {
1116 Icon::new(IconName::Warning)
1117 .color(Color::Warning)
1118 .size(IconSize::XSmall)
1119 };
1120
1121 Some(
1122 div()
1123 .child(ui::Callout::multi_line(
1124 title,
1125 message,
1126 icon,
1127 "Start New Thread",
1128 Box::new(cx.listener(|this, _, window, cx| {
1129 let from_thread_id = Some(this.thread.read(cx).id().clone());
1130 window.dispatch_action(Box::new(NewThread { from_thread_id }), cx);
1131 })),
1132 ))
1133 .line_height(line_height),
1134 )
1135 }
1136
1137 pub fn last_estimated_token_count(&self) -> Option<usize> {
1138 self.last_estimated_token_count
1139 }
1140
1141 pub fn is_waiting_to_update_token_count(&self) -> bool {
1142 self.update_token_count_task.is_some()
1143 }
1144
1145 fn reload_context(&mut self, cx: &mut Context<Self>) -> Task<Option<ContextLoadResult>> {
1146 let load_task = cx.spawn(async move |this, cx| {
1147 let Ok(load_task) = this.update(cx, |this, cx| {
1148 let new_context = this.context_store.read_with(cx, |context_store, cx| {
1149 context_store.new_context_for_thread(this.thread.read(cx), None)
1150 });
1151 load_context(new_context, &this.project, &this.prompt_store, cx)
1152 }) else {
1153 return;
1154 };
1155 let result = load_task.await;
1156 this.update(cx, |this, cx| {
1157 this.last_loaded_context = Some(result);
1158 this.load_context_task = None;
1159 this.message_or_context_changed(false, cx);
1160 })
1161 .ok();
1162 });
1163 // Replace existing load task, if any, causing it to be cancelled.
1164 let load_task = load_task.shared();
1165 self.load_context_task = Some(load_task.clone());
1166 cx.spawn(async move |this, cx| {
1167 load_task.await;
1168 this.read_with(cx, |this, _cx| this.last_loaded_context.clone())
1169 .ok()
1170 .flatten()
1171 })
1172 }
1173
1174 fn handle_message_changed(&mut self, cx: &mut Context<Self>) {
1175 self.message_or_context_changed(true, cx);
1176 }
1177
1178 fn message_or_context_changed(&mut self, debounce: bool, cx: &mut Context<Self>) {
1179 cx.emit(MessageEditorEvent::Changed);
1180 self.update_token_count_task.take();
1181
1182 let Some(model) = self.thread.read(cx).configured_model() else {
1183 self.last_estimated_token_count.take();
1184 return;
1185 };
1186
1187 let editor = self.editor.clone();
1188
1189 self.update_token_count_task = Some(cx.spawn(async move |this, cx| {
1190 if debounce {
1191 cx.background_executor()
1192 .timer(Duration::from_millis(200))
1193 .await;
1194 }
1195
1196 let token_count = if let Some(task) = this
1197 .update(cx, |this, cx| {
1198 let loaded_context = this
1199 .last_loaded_context
1200 .as_ref()
1201 .map(|context_load_result| &context_load_result.loaded_context);
1202 let message_text = editor.read(cx).text(cx);
1203
1204 if message_text.is_empty()
1205 && loaded_context.map_or(true, |loaded_context| loaded_context.is_empty())
1206 {
1207 return None;
1208 }
1209
1210 let mut request_message = LanguageModelRequestMessage {
1211 role: language_model::Role::User,
1212 content: Vec::new(),
1213 cache: false,
1214 };
1215
1216 if let Some(loaded_context) = loaded_context {
1217 loaded_context.add_to_request_message(&mut request_message);
1218 }
1219
1220 if !message_text.is_empty() {
1221 request_message
1222 .content
1223 .push(MessageContent::Text(message_text));
1224 }
1225
1226 let request = language_model::LanguageModelRequest {
1227 thread_id: None,
1228 prompt_id: None,
1229 mode: None,
1230 messages: vec![request_message],
1231 tools: vec![],
1232 stop: vec![],
1233 temperature: None,
1234 };
1235
1236 Some(model.model.count_tokens(request, cx))
1237 })
1238 .ok()
1239 .flatten()
1240 {
1241 task.await.log_err()
1242 } else {
1243 Some(0)
1244 };
1245
1246 this.update(cx, |this, cx| {
1247 if let Some(token_count) = token_count {
1248 this.last_estimated_token_count = Some(token_count);
1249 cx.emit(MessageEditorEvent::EstimatedTokenCount);
1250 }
1251 this.update_token_count_task.take();
1252 })
1253 .ok();
1254 }));
1255 }
1256}
1257
1258pub fn extract_message_creases(
1259 editor: &mut Editor,
1260 cx: &mut Context<'_, Editor>,
1261) -> Vec<MessageCrease> {
1262 let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
1263 let mut contexts_by_crease_id = editor
1264 .addon_mut::<ContextCreasesAddon>()
1265 .map(std::mem::take)
1266 .unwrap_or_default()
1267 .into_inner()
1268 .into_iter()
1269 .flat_map(|(key, creases)| {
1270 let context = key.0;
1271 creases
1272 .into_iter()
1273 .map(move |(id, _)| (id, context.clone()))
1274 })
1275 .collect::<HashMap<_, _>>();
1276 // Filter the addon's list of creases based on what the editor reports,
1277 // since the addon might have removed creases in it.
1278 let creases = editor.display_map.update(cx, |display_map, cx| {
1279 display_map
1280 .snapshot(cx)
1281 .crease_snapshot
1282 .creases()
1283 .filter_map(|(id, crease)| {
1284 Some((
1285 id,
1286 (
1287 crease.range().to_offset(&buffer_snapshot),
1288 crease.metadata()?.clone(),
1289 ),
1290 ))
1291 })
1292 .map(|(id, (range, metadata))| {
1293 let context = contexts_by_crease_id.remove(&id);
1294 MessageCrease {
1295 range,
1296 metadata,
1297 context,
1298 }
1299 })
1300 .collect()
1301 });
1302 creases
1303}
1304
1305impl EventEmitter<MessageEditorEvent> for MessageEditor {}
1306
1307pub enum MessageEditorEvent {
1308 EstimatedTokenCount,
1309 Changed,
1310}
1311
1312impl Focusable for MessageEditor {
1313 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1314 self.editor.focus_handle(cx)
1315 }
1316}
1317
1318impl Render for MessageEditor {
1319 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1320 let thread = self.thread.read(cx);
1321 let token_usage_ratio = thread
1322 .total_token_usage()
1323 .map_or(TokenUsageRatio::Normal, |total_token_usage| {
1324 total_token_usage.ratio()
1325 });
1326
1327 let action_log = self.thread.read(cx).action_log();
1328 let changed_buffers = action_log.read(cx).changed_buffers(cx);
1329
1330 let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
1331
1332 v_flex()
1333 .size_full()
1334 .when(changed_buffers.len() > 0, |parent| {
1335 parent.child(self.render_changed_buffers(&changed_buffers, window, cx))
1336 })
1337 .child(self.render_editor(window, cx))
1338 .children({
1339 let usage_callout = self.render_usage_callout(line_height, cx);
1340
1341 if usage_callout.is_some() {
1342 usage_callout
1343 } else if token_usage_ratio != TokenUsageRatio::Normal {
1344 self.render_token_limit_callout(line_height, token_usage_ratio, cx)
1345 } else {
1346 None
1347 }
1348 })
1349 }
1350}
1351
1352pub fn insert_message_creases(
1353 editor: &mut Editor,
1354 message_creases: &[MessageCrease],
1355 context_store: &Entity<ContextStore>,
1356 window: &mut Window,
1357 cx: &mut Context<'_, Editor>,
1358) {
1359 let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
1360 let creases = message_creases
1361 .iter()
1362 .map(|crease| {
1363 let start = buffer_snapshot.anchor_after(crease.range.start);
1364 let end = buffer_snapshot.anchor_before(crease.range.end);
1365 crease_for_mention(
1366 crease.metadata.label.clone(),
1367 crease.metadata.icon_path.clone(),
1368 start..end,
1369 cx.weak_entity(),
1370 )
1371 })
1372 .collect::<Vec<_>>();
1373 let ids = editor.insert_creases(creases.clone(), cx);
1374 editor.fold_creases(creases, false, window, cx);
1375 if let Some(addon) = editor.addon_mut::<ContextCreasesAddon>() {
1376 for (crease, id) in message_creases.iter().zip(ids) {
1377 if let Some(context) = crease.context.as_ref() {
1378 let key = AgentContextKey(context.clone());
1379 addon.add_creases(
1380 context_store,
1381 key,
1382 vec![(id, crease.metadata.label.clone())],
1383 cx,
1384 );
1385 }
1386 }
1387 }
1388}
1389impl Component for MessageEditor {
1390 fn scope() -> ComponentScope {
1391 ComponentScope::Agent
1392 }
1393
1394 fn description() -> Option<&'static str> {
1395 Some(
1396 "The composer experience of the Agent Panel. This interface handles context, composing messages, switching profiles, models and more.",
1397 )
1398 }
1399}
1400
1401impl AgentPreview for MessageEditor {
1402 fn agent_preview(
1403 workspace: WeakEntity<Workspace>,
1404 active_thread: Entity<ActiveThread>,
1405 window: &mut Window,
1406 cx: &mut App,
1407 ) -> Option<AnyElement> {
1408 if let Some(workspace) = workspace.upgrade() {
1409 let fs = workspace.read(cx).app_state().fs.clone();
1410 let user_store = workspace.read(cx).app_state().user_store.clone();
1411 let project = workspace.read(cx).project().clone();
1412 let weak_project = project.downgrade();
1413 let context_store = cx.new(|_cx| ContextStore::new(weak_project, None));
1414 let active_thread = active_thread.read(cx);
1415 let thread = active_thread.thread().clone();
1416 let thread_store = active_thread.thread_store().clone();
1417 let text_thread_store = active_thread.text_thread_store().clone();
1418
1419 let default_message_editor = cx.new(|cx| {
1420 MessageEditor::new(
1421 fs,
1422 workspace.downgrade(),
1423 user_store,
1424 context_store,
1425 None,
1426 thread_store.downgrade(),
1427 text_thread_store.downgrade(),
1428 thread,
1429 window,
1430 cx,
1431 )
1432 });
1433
1434 Some(
1435 v_flex()
1436 .gap_4()
1437 .children(vec![single_example(
1438 "Default Message Editor",
1439 div()
1440 .w(px(540.))
1441 .pt_12()
1442 .bg(cx.theme().colors().panel_background)
1443 .border_1()
1444 .border_color(cx.theme().colors().border)
1445 .child(default_message_editor.clone())
1446 .into_any_element(),
1447 )])
1448 .into_any_element(),
1449 )
1450 } else {
1451 None
1452 }
1453 }
1454}
1455
1456register_agent_preview!(MessageEditor);