1use std::sync::Arc;
2
3use collections::HashSet;
4use editor::actions::MoveUp;
5use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
6use fs::Fs;
7use git::ExpandCommitEditor;
8use git_ui::git_panel;
9use gpui::{
10 point, Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
11 WeakEntity,
12};
13use language_model::LanguageModelRegistry;
14use language_model_selector::ToggleModelSelector;
15use project::Project;
16use settings::Settings;
17use std::time::Duration;
18use theme::ThemeSettings;
19use ui::{
20 prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip,
21};
22use vim_mode_setting::VimModeSetting;
23use workspace::Workspace;
24
25use crate::assistant_model_selector::AssistantModelSelector;
26use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider};
27use crate::context_store::{refresh_context_store_text, ContextStore};
28use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
29use crate::profile_selector::ProfileSelector;
30use crate::thread::{RequestKind, Thread};
31use crate::thread_store::ThreadStore;
32use crate::{
33 Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker, ToggleProfileSelector,
34};
35
36pub struct MessageEditor {
37 thread: Entity<Thread>,
38 editor: Entity<Editor>,
39 #[allow(dead_code)]
40 workspace: WeakEntity<Workspace>,
41 project: Entity<Project>,
42 context_store: Entity<ContextStore>,
43 context_strip: Entity<ContextStrip>,
44 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
45 inline_context_picker: Entity<ContextPicker>,
46 inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
47 model_selector: Entity<AssistantModelSelector>,
48 profile_selector: Entity<ProfileSelector>,
49 _subscriptions: Vec<Subscription>,
50}
51
52impl MessageEditor {
53 pub fn new(
54 fs: Arc<dyn Fs>,
55 workspace: WeakEntity<Workspace>,
56 context_store: Entity<ContextStore>,
57 thread_store: WeakEntity<ThreadStore>,
58 thread: Entity<Thread>,
59 window: &mut Window,
60 cx: &mut Context<Self>,
61 ) -> Self {
62 let context_picker_menu_handle = PopoverMenuHandle::default();
63 let inline_context_picker_menu_handle = PopoverMenuHandle::default();
64 let model_selector_menu_handle = PopoverMenuHandle::default();
65
66 let editor = cx.new(|cx| {
67 let mut editor = Editor::auto_height(10, window, cx);
68 editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
69 editor.set_show_indent_guides(false, cx);
70 editor.set_context_menu_options(ContextMenuOptions {
71 min_entries_visible: 12,
72 max_entries_visible: 12,
73 placement: Some(ContextMenuPlacement::Above),
74 });
75
76 editor
77 });
78
79 let editor_entity = editor.downgrade();
80 editor.update(cx, |editor, _| {
81 editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
82 workspace.clone(),
83 context_store.downgrade(),
84 Some(thread_store.clone()),
85 editor_entity,
86 ))));
87 });
88
89 let inline_context_picker = cx.new(|cx| {
90 ContextPicker::new(
91 workspace.clone(),
92 Some(thread_store.clone()),
93 context_store.downgrade(),
94 ConfirmBehavior::Close,
95 window,
96 cx,
97 )
98 });
99
100 let context_strip = cx.new(|cx| {
101 ContextStrip::new(
102 context_store.clone(),
103 workspace.clone(),
104 Some(thread_store.clone()),
105 context_picker_menu_handle.clone(),
106 SuggestContextKind::File,
107 window,
108 cx,
109 )
110 });
111
112 let subscriptions = vec![
113 cx.subscribe_in(
114 &inline_context_picker,
115 window,
116 Self::handle_inline_context_picker_event,
117 ),
118 cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
119 ];
120
121 Self {
122 editor: editor.clone(),
123 project: thread.read(cx).project().clone(),
124 thread,
125 workspace,
126 context_store,
127 context_strip,
128 context_picker_menu_handle,
129 inline_context_picker,
130 inline_context_picker_menu_handle,
131 model_selector: cx.new(|cx| {
132 AssistantModelSelector::new(
133 fs.clone(),
134 model_selector_menu_handle,
135 editor.focus_handle(cx),
136 window,
137 cx,
138 )
139 }),
140 profile_selector: cx
141 .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
142 _subscriptions: subscriptions,
143 }
144 }
145
146 fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context<Self>) {
147 cx.notify();
148 }
149
150 fn toggle_context_picker(
151 &mut self,
152 _: &ToggleContextPicker,
153 window: &mut Window,
154 cx: &mut Context<Self>,
155 ) {
156 self.context_picker_menu_handle.toggle(window, cx);
157 }
158 pub fn remove_all_context(
159 &mut self,
160 _: &RemoveAllContext,
161 _window: &mut Window,
162 cx: &mut Context<Self>,
163 ) {
164 self.context_store.update(cx, |store, _cx| store.clear());
165 cx.notify();
166 }
167
168 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
169 if self.is_editor_empty(cx) {
170 return;
171 }
172
173 if self.thread.read(cx).is_generating() {
174 return;
175 }
176
177 self.send_to_model(RequestKind::Chat, window, cx);
178 }
179
180 fn is_editor_empty(&self, cx: &App) -> bool {
181 self.editor.read(cx).text(cx).is_empty()
182 }
183
184 fn is_model_selected(&self, cx: &App) -> bool {
185 LanguageModelRegistry::read_global(cx)
186 .active_model()
187 .is_some()
188 }
189
190 fn send_to_model(
191 &mut self,
192 request_kind: RequestKind,
193 window: &mut Window,
194 cx: &mut Context<Self>,
195 ) {
196 let provider = LanguageModelRegistry::read_global(cx).active_provider();
197 if provider
198 .as_ref()
199 .map_or(false, |provider| provider.must_accept_terms(cx))
200 {
201 cx.notify();
202 return;
203 }
204
205 let model_registry = LanguageModelRegistry::read_global(cx);
206 let Some(model) = model_registry.active_model() else {
207 return;
208 };
209
210 let user_message = self.editor.update(cx, |editor, cx| {
211 let text = editor.text(cx);
212 editor.clear(window, cx);
213 text
214 });
215
216 let refresh_task =
217 refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx);
218
219 let system_prompt_context_task = self.thread.read(cx).load_system_prompt_context(cx);
220
221 let thread = self.thread.clone();
222 let context_store = self.context_store.clone();
223 let checkpoint = self.project.read(cx).git_store().read(cx).checkpoint(cx);
224 cx.spawn(async move |_, cx| {
225 let checkpoint = checkpoint.await.ok();
226 refresh_task.await;
227 let (system_prompt_context, load_error) = system_prompt_context_task.await;
228 thread
229 .update(cx, |thread, cx| {
230 thread.set_system_prompt_context(system_prompt_context);
231 if let Some(load_error) = load_error {
232 cx.emit(ThreadEvent::ShowError(load_error));
233 }
234 })
235 .ok();
236 thread
237 .update(cx, |thread, cx| {
238 let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
239 thread.insert_user_message(user_message, context, checkpoint, cx);
240 thread.send_to_model(model, request_kind, cx);
241 })
242 .ok();
243 })
244 .detach();
245 }
246
247 fn handle_inline_context_picker_event(
248 &mut self,
249 _inline_context_picker: &Entity<ContextPicker>,
250 _event: &DismissEvent,
251 window: &mut Window,
252 cx: &mut Context<Self>,
253 ) {
254 let editor_focus_handle = self.editor.focus_handle(cx);
255 window.focus(&editor_focus_handle);
256 }
257
258 fn handle_context_strip_event(
259 &mut self,
260 _context_strip: &Entity<ContextStrip>,
261 event: &ContextStripEvent,
262 window: &mut Window,
263 cx: &mut Context<Self>,
264 ) {
265 match event {
266 ContextStripEvent::PickerDismissed
267 | ContextStripEvent::BlurredEmpty
268 | ContextStripEvent::BlurredDown => {
269 let editor_focus_handle = self.editor.focus_handle(cx);
270 window.focus(&editor_focus_handle);
271 }
272 ContextStripEvent::BlurredUp => {}
273 }
274 }
275
276 fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
277 if self.context_picker_menu_handle.is_deployed()
278 || self.inline_context_picker_menu_handle.is_deployed()
279 {
280 cx.propagate();
281 } else {
282 self.context_strip.focus_handle(cx).focus(window);
283 }
284 }
285}
286
287impl Focusable for MessageEditor {
288 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
289 self.editor.focus_handle(cx)
290 }
291}
292
293impl Render for MessageEditor {
294 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
295 let font_size = TextSize::Default.rems(cx);
296 let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
297
298 let focus_handle = self.editor.focus_handle(cx);
299 let inline_context_picker = self.inline_context_picker.clone();
300
301 let empty_thread = self.thread.read(cx).is_empty();
302 let is_generating = self.thread.read(cx).is_generating();
303 let is_model_selected = self.is_model_selected(cx);
304 let is_editor_empty = self.is_editor_empty(cx);
305 let submit_label_color = if is_editor_empty {
306 Color::Muted
307 } else {
308 Color::Default
309 };
310
311 let vim_mode_enabled = VimModeSetting::get_global(cx).0;
312 let platform = PlatformStyle::platform();
313 let linux = platform == PlatformStyle::Linux;
314 let windows = platform == PlatformStyle::Windows;
315 let button_width = if linux || windows || vim_mode_enabled {
316 px(82.)
317 } else {
318 px(64.)
319 };
320
321 let project = self.thread.read(cx).project();
322 let changed_files = if let Some(repository) = project.read(cx).active_repository(cx) {
323 repository.read(cx).cached_status().count()
324 } else {
325 0
326 };
327
328 let border_color = cx.theme().colors().border;
329 let active_color = cx.theme().colors().element_selected;
330 let editor_bg_color = cx.theme().colors().editor_background;
331 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
332
333 let edit_files_container = || {
334 h_flex()
335 .mx_2()
336 .py_1()
337 .pl_2p5()
338 .pr_1()
339 .bg(bg_edit_files_disclosure)
340 .border_1()
341 .border_color(border_color)
342 .justify_between()
343 .flex_wrap()
344 };
345
346 v_flex()
347 .size_full()
348 .when(is_generating, |parent| {
349 let focus_handle = self.editor.focus_handle(cx).clone();
350 parent.child(
351 h_flex().py_3().w_full().justify_center().child(
352 h_flex()
353 .flex_none()
354 .pl_2()
355 .pr_1()
356 .py_1()
357 .bg(editor_bg_color)
358 .border_1()
359 .border_color(cx.theme().colors().border_variant)
360 .rounded_lg()
361 .shadow_md()
362 .gap_1()
363 .child(
364 Icon::new(IconName::ArrowCircle)
365 .size(IconSize::XSmall)
366 .color(Color::Muted)
367 .with_animation(
368 "arrow-circle",
369 Animation::new(Duration::from_secs(2)).repeat(),
370 |icon, delta| {
371 icon.transform(gpui::Transformation::rotate(
372 gpui::percentage(delta),
373 ))
374 },
375 ),
376 )
377 .child(
378 Label::new("Generating…")
379 .size(LabelSize::XSmall)
380 .color(Color::Muted),
381 )
382 .child(ui::Divider::vertical())
383 .child(
384 Button::new("cancel-generation", "Cancel")
385 .label_size(LabelSize::XSmall)
386 .key_binding(
387 KeyBinding::for_action_in(
388 &editor::actions::Cancel,
389 &focus_handle,
390 window,
391 cx,
392 )
393 .map(|kb| kb.size(rems_from_px(10.))),
394 )
395 .on_click(move |_event, window, cx| {
396 focus_handle.dispatch_action(
397 &editor::actions::Cancel,
398 window,
399 cx,
400 );
401 }),
402 ),
403 ),
404 )
405 })
406 .when(
407 changed_files > 0 && !is_generating && !empty_thread,
408 |parent| {
409 parent.child(
410 edit_files_container()
411 .border_b_0()
412 .rounded_t_md()
413 .shadow(smallvec::smallvec![gpui::BoxShadow {
414 color: gpui::black().opacity(0.15),
415 offset: point(px(1.), px(-1.)),
416 blur_radius: px(3.),
417 spread_radius: px(0.),
418 }])
419 .child(
420 h_flex()
421 .gap_2()
422 .child(Label::new("Edits").size(LabelSize::XSmall))
423 .child(div().size_1().rounded_full().bg(border_color))
424 .child(
425 Label::new(format!(
426 "{} {}",
427 changed_files,
428 if changed_files == 1 { "file" } else { "files" }
429 ))
430 .size(LabelSize::XSmall),
431 ),
432 )
433 .child(
434 h_flex()
435 .gap_1()
436 .child(
437 Button::new("panel", "Open Git Panel")
438 .label_size(LabelSize::XSmall)
439 .key_binding({
440 let focus_handle = focus_handle.clone();
441 KeyBinding::for_action_in(
442 &git_panel::ToggleFocus,
443 &focus_handle,
444 window,
445 cx,
446 )
447 .map(|kb| kb.size(rems_from_px(10.)))
448 })
449 .on_click(|_ev, _window, cx| {
450 cx.defer(|cx| {
451 cx.dispatch_action(&git_panel::ToggleFocus)
452 });
453 }),
454 )
455 .child(
456 Button::new("review", "Review Diff")
457 .label_size(LabelSize::XSmall)
458 .key_binding({
459 let focus_handle = focus_handle.clone();
460 KeyBinding::for_action_in(
461 &git_ui::project_diff::Diff,
462 &focus_handle,
463 window,
464 cx,
465 )
466 .map(|kb| kb.size(rems_from_px(10.)))
467 })
468 .on_click(|_event, _window, cx| {
469 cx.defer(|cx| {
470 cx.dispatch_action(&git_ui::project_diff::Diff)
471 });
472 }),
473 )
474 .child(
475 Button::new("commit", "Commit Changes")
476 .label_size(LabelSize::XSmall)
477 .key_binding({
478 let focus_handle = focus_handle.clone();
479 KeyBinding::for_action_in(
480 &ExpandCommitEditor,
481 &focus_handle,
482 window,
483 cx,
484 )
485 .map(|kb| kb.size(rems_from_px(10.)))
486 })
487 .on_click(|_event, _window, cx| {
488 cx.defer(|cx| {
489 cx.dispatch_action(&ExpandCommitEditor)
490 });
491 }),
492 ),
493 ),
494 )
495 },
496 )
497 .when(
498 changed_files > 0 && !is_generating && empty_thread,
499 |parent| {
500 parent.child(
501 edit_files_container()
502 .mb_2()
503 .rounded_md()
504 .child(
505 h_flex()
506 .gap_2()
507 .child(Label::new("Consider committing your changes before starting a fresh thread").size(LabelSize::XSmall))
508 .child(div().size_1().rounded_full().bg(border_color))
509 .child(
510 Label::new(format!(
511 "{} {}",
512 changed_files,
513 if changed_files == 1 { "file" } else { "files" }
514 ))
515 .size(LabelSize::XSmall),
516 ),
517 )
518 .child(
519 h_flex()
520 .gap_1()
521 .child(
522 Button::new("review", "Review Diff")
523 .label_size(LabelSize::XSmall)
524 .key_binding({
525 let focus_handle = focus_handle.clone();
526 KeyBinding::for_action_in(
527 &git_ui::project_diff::Diff,
528 &focus_handle,
529 window,
530 cx,
531 )
532 .map(|kb| kb.size(rems_from_px(10.)))
533 })
534 .on_click(|_event, _window, cx| {
535 cx.defer(|cx| {
536 cx.dispatch_action(&git_ui::project_diff::Diff)
537 });
538 }),
539 )
540 .child(
541 Button::new("commit", "Commit Changes")
542 .label_size(LabelSize::XSmall)
543 .key_binding({
544 let focus_handle = focus_handle.clone();
545 KeyBinding::for_action_in(
546 &ExpandCommitEditor,
547 &focus_handle,
548 window,
549 cx,
550 )
551 .map(|kb| kb.size(rems_from_px(10.)))
552 })
553 .on_click(|_event, _window, cx| {
554 cx.defer(|cx| {
555 cx.dispatch_action(&ExpandCommitEditor)
556 });
557 }),
558 ),
559 ),
560 )
561 },
562 )
563 .child(
564 v_flex()
565 .key_context("MessageEditor")
566 .on_action(cx.listener(Self::chat))
567 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
568 this.profile_selector.read(cx).menu_handle().toggle(window, cx);
569 }))
570 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
571 this.model_selector
572 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
573 }))
574 .on_action(cx.listener(Self::toggle_context_picker))
575 .on_action(cx.listener(Self::remove_all_context))
576 .on_action(cx.listener(Self::move_up))
577 .on_action(cx.listener(Self::toggle_chat_mode))
578 .gap_2()
579 .p_2()
580 .bg(editor_bg_color)
581 .border_t_1()
582 .border_color(cx.theme().colors().border)
583 .child(h_flex().justify_between().child(self.context_strip.clone()))
584 .child(
585 v_flex()
586 .gap_5()
587 .child({
588 let settings = ThemeSettings::get_global(cx);
589 let text_style = TextStyle {
590 color: cx.theme().colors().text,
591 font_family: settings.ui_font.family.clone(),
592 font_fallbacks: settings.ui_font.fallbacks.clone(),
593 font_features: settings.ui_font.features.clone(),
594 font_size: font_size.into(),
595 font_weight: settings.ui_font.weight,
596 line_height: line_height.into(),
597 ..Default::default()
598 };
599
600 EditorElement::new(
601 &self.editor,
602 EditorStyle {
603 background: editor_bg_color,
604 local_player: cx.theme().players().local(),
605 text: text_style,
606 syntax: cx.theme().syntax().clone(),
607 ..Default::default()
608 },
609 )
610 })
611 .child(
612 PopoverMenu::new("inline-context-picker")
613 .menu(move |window, cx| {
614 inline_context_picker.update(cx, |this, cx| {
615 this.init(window, cx);
616 });
617
618 Some(inline_context_picker.clone())
619 })
620 .attach(gpui::Corner::TopLeft)
621 .anchor(gpui::Corner::BottomLeft)
622 .offset(gpui::Point {
623 x: px(0.0),
624 y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2)
625 - px(4.0),
626 })
627 .with_handle(self.inline_context_picker_menu_handle.clone()),
628 )
629 .child(
630 h_flex()
631 .justify_between()
632 .child(h_flex().gap_2().child(self.profile_selector.clone()))
633 .child(
634 h_flex().gap_1().child(self.model_selector.clone()).child(
635 ButtonLike::new("submit-message")
636 .width(button_width.into())
637 .style(ButtonStyle::Filled)
638 .disabled(
639 is_editor_empty
640 || !is_model_selected
641 || is_generating,
642 )
643 .child(
644 h_flex()
645 .w_full()
646 .justify_between()
647 .child(
648 Label::new("Submit")
649 .size(LabelSize::Small)
650 .color(submit_label_color),
651 )
652 .children(
653 KeyBinding::for_action_in(
654 &Chat,
655 &focus_handle,
656 window,
657 cx,
658 )
659 .map(|binding| {
660 binding
661 .when(vim_mode_enabled, |kb| {
662 kb.size(rems_from_px(12.))
663 })
664 .into_any_element()
665 }),
666 ),
667 )
668 .on_click(move |_event, window, cx| {
669 focus_handle.dispatch_action(&Chat, window, cx);
670 })
671 .when(is_editor_empty, |button| {
672 button.tooltip(Tooltip::text(
673 "Type a message to submit",
674 ))
675 })
676 .when(is_generating, |button| {
677 button.tooltip(Tooltip::text(
678 "Cancel to submit a new message",
679 ))
680 })
681 .when(!is_model_selected, |button| {
682 button.tooltip(Tooltip::text(
683 "Select a model to continue",
684 ))
685 }),
686 ),
687 ),
688 ),
689 ),
690 )
691 }
692}