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