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