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