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