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