1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use crate::assistant_model_selector::ModelType;
5use buffer_diff::BufferDiff;
6use collections::HashSet;
7use editor::actions::MoveUp;
8use editor::{
9 ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, EditorStyle,
10 MultiBuffer,
11};
12use file_icons::FileIcons;
13use fs::Fs;
14use gpui::{
15 Animation, AnimationExt, App, Entity, Focusable, Subscription, TextStyle, WeakEntity,
16 linear_color_stop, linear_gradient, point, pulsating_between,
17};
18use language::{Buffer, Language};
19use language_model::{ConfiguredModel, LanguageModelRegistry};
20use language_model_selector::ToggleModelSelector;
21use multi_buffer;
22use project::Project;
23use settings::Settings;
24use std::time::Duration;
25use theme::ThemeSettings;
26use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
27use util::ResultExt as _;
28use workspace::Workspace;
29
30use crate::assistant_model_selector::AssistantModelSelector;
31use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
32use crate::context_store::{ContextStore, refresh_context_store_text};
33use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
34use crate::profile_selector::ProfileSelector;
35use crate::thread::{RequestKind, Thread, TokenUsageRatio};
36use crate::thread_store::ThreadStore;
37use crate::{
38 AgentDiff, Chat, ChatMode, ExpandMessageEditor, NewThread, OpenAgentDiff, RemoveAllContext,
39 ToggleContextPicker, ToggleProfileSelector,
40};
41
42pub struct MessageEditor {
43 thread: Entity<Thread>,
44 editor: Entity<Editor>,
45 #[allow(dead_code)]
46 workspace: WeakEntity<Workspace>,
47 project: Entity<Project>,
48 context_store: Entity<ContextStore>,
49 context_strip: Entity<ContextStrip>,
50 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
51 model_selector: Entity<AssistantModelSelector>,
52 profile_selector: Entity<ProfileSelector>,
53 edits_expanded: bool,
54 editor_is_expanded: bool,
55 waiting_for_summaries_to_send: bool,
56 _subscriptions: Vec<Subscription>,
57}
58
59const MAX_EDITOR_LINES: usize = 3;
60
61impl MessageEditor {
62 pub fn new(
63 fs: Arc<dyn Fs>,
64 workspace: WeakEntity<Workspace>,
65 context_store: Entity<ContextStore>,
66 thread_store: WeakEntity<ThreadStore>,
67 thread: Entity<Thread>,
68 window: &mut Window,
69 cx: &mut Context<Self>,
70 ) -> Self {
71 let context_picker_menu_handle = PopoverMenuHandle::default();
72 let model_selector_menu_handle = PopoverMenuHandle::default();
73
74 let language = Language::new(
75 language::LanguageConfig {
76 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
77 ..Default::default()
78 },
79 None,
80 );
81
82 let editor = cx.new(|cx| {
83 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
84 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
85 let mut editor = Editor::new(
86 editor::EditorMode::AutoHeight {
87 max_lines: MAX_EDITOR_LINES,
88 },
89 buffer,
90 None,
91 window,
92 cx,
93 );
94 editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
95 editor.set_show_indent_guides(false, cx);
96 editor.set_context_menu_options(ContextMenuOptions {
97 min_entries_visible: 12,
98 max_entries_visible: 12,
99 placement: Some(ContextMenuPlacement::Above),
100 });
101 editor
102 });
103
104 let editor_entity = editor.downgrade();
105 editor.update(cx, |editor, _| {
106 editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
107 workspace.clone(),
108 context_store.downgrade(),
109 Some(thread_store.clone()),
110 editor_entity,
111 ))));
112 });
113
114 let context_strip = cx.new(|cx| {
115 ContextStrip::new(
116 context_store.clone(),
117 workspace.clone(),
118 Some(thread_store.clone()),
119 context_picker_menu_handle.clone(),
120 SuggestContextKind::File,
121 window,
122 cx,
123 )
124 });
125
126 let subscriptions =
127 vec![cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event)];
128
129 Self {
130 editor: editor.clone(),
131 project: thread.read(cx).project().clone(),
132 thread,
133 workspace,
134 context_store,
135 context_strip,
136 context_picker_menu_handle,
137 model_selector: cx.new(|cx| {
138 AssistantModelSelector::new(
139 fs.clone(),
140 model_selector_menu_handle,
141 editor.focus_handle(cx),
142 ModelType::Default,
143 window,
144 cx,
145 )
146 }),
147 edits_expanded: false,
148 editor_is_expanded: false,
149 waiting_for_summaries_to_send: false,
150 profile_selector: cx
151 .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
152 _subscriptions: subscriptions,
153 }
154 }
155
156 fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context<Self>) {
157 cx.notify();
158 }
159
160 pub fn expand_message_editor(
161 &mut self,
162 _: &ExpandMessageEditor,
163 _window: &mut Window,
164 cx: &mut Context<Self>,
165 ) {
166 self.set_editor_is_expanded(!self.editor_is_expanded, cx);
167 }
168
169 fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
170 self.editor_is_expanded = is_expanded;
171 self.editor.update(cx, |editor, _| {
172 if self.editor_is_expanded {
173 editor.set_mode(EditorMode::Full {
174 scale_ui_elements_with_buffer_font_size: false,
175 show_active_line_background: false,
176 })
177 } else {
178 editor.set_mode(EditorMode::AutoHeight {
179 max_lines: MAX_EDITOR_LINES,
180 })
181 }
182 });
183 cx.notify();
184 }
185
186 fn toggle_context_picker(
187 &mut self,
188 _: &ToggleContextPicker,
189 window: &mut Window,
190 cx: &mut Context<Self>,
191 ) {
192 self.context_picker_menu_handle.toggle(window, cx);
193 }
194 pub fn remove_all_context(
195 &mut self,
196 _: &RemoveAllContext,
197 _window: &mut Window,
198 cx: &mut Context<Self>,
199 ) {
200 self.context_store.update(cx, |store, _cx| store.clear());
201 cx.notify();
202 }
203
204 fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
205 if self.is_editor_empty(cx) {
206 return;
207 }
208
209 if self.thread.read(cx).is_generating() {
210 return;
211 }
212
213 self.set_editor_is_expanded(false, cx);
214 self.send_to_model(RequestKind::Chat, window, cx);
215
216 cx.notify();
217 }
218
219 fn is_editor_empty(&self, cx: &App) -> bool {
220 self.editor.read(cx).text(cx).trim().is_empty()
221 }
222
223 fn is_model_selected(&self, cx: &App) -> bool {
224 LanguageModelRegistry::read_global(cx)
225 .default_model()
226 .is_some()
227 }
228
229 fn send_to_model(
230 &mut self,
231 request_kind: RequestKind,
232 window: &mut Window,
233 cx: &mut Context<Self>,
234 ) {
235 let model_registry = LanguageModelRegistry::read_global(cx);
236 let Some(ConfiguredModel { model, provider }) = model_registry.default_model() else {
237 return;
238 };
239
240 if provider.must_accept_terms(cx) {
241 cx.notify();
242 return;
243 }
244
245 let user_message = self.editor.update(cx, |editor, cx| {
246 let text = editor.text(cx);
247 editor.clear(window, cx);
248 text
249 });
250
251 let refresh_task =
252 refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx);
253
254 let thread = self.thread.clone();
255 let context_store = self.context_store.clone();
256 let git_store = self.project.read(cx).git_store().clone();
257 let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
258
259 cx.spawn(async move |this, cx| {
260 let checkpoint = checkpoint.await.ok();
261 refresh_task.await;
262
263 thread
264 .update(cx, |thread, cx| {
265 let context = context_store.read(cx).context().clone();
266 thread.insert_user_message(user_message, context, checkpoint, cx);
267 })
268 .log_err();
269
270 if let Some(wait_for_summaries) = context_store
271 .update(cx, |context_store, cx| context_store.wait_for_summaries(cx))
272 .log_err()
273 {
274 this.update(cx, |this, cx| {
275 this.waiting_for_summaries_to_send = true;
276 cx.notify();
277 })
278 .log_err();
279
280 wait_for_summaries.await;
281
282 this.update(cx, |this, cx| {
283 this.waiting_for_summaries_to_send = false;
284 cx.notify();
285 })
286 .log_err();
287 }
288
289 // Send to model after summaries are done
290 thread
291 .update(cx, |thread, cx| {
292 thread.send_to_model(model, request_kind, cx);
293 })
294 .log_err();
295 })
296 .detach();
297 }
298
299 fn handle_context_strip_event(
300 &mut self,
301 _context_strip: &Entity<ContextStrip>,
302 event: &ContextStripEvent,
303 window: &mut Window,
304 cx: &mut Context<Self>,
305 ) {
306 match event {
307 ContextStripEvent::PickerDismissed
308 | ContextStripEvent::BlurredEmpty
309 | ContextStripEvent::BlurredDown => {
310 let editor_focus_handle = self.editor.focus_handle(cx);
311 window.focus(&editor_focus_handle);
312 }
313 ContextStripEvent::BlurredUp => {}
314 }
315 }
316
317 fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
318 if self.context_picker_menu_handle.is_deployed() {
319 cx.propagate();
320 } else {
321 self.context_strip.focus_handle(cx).focus(window);
322 }
323 }
324
325 fn handle_review_click(&self, window: &mut Window, cx: &mut Context<Self>) {
326 AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
327 }
328
329 fn handle_file_click(
330 &self,
331 buffer: Entity<Buffer>,
332 window: &mut Window,
333 cx: &mut Context<Self>,
334 ) {
335 if let Ok(diff) = AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx)
336 {
337 let path_key = multi_buffer::PathKey::for_buffer(&buffer, cx);
338 diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx));
339 }
340 }
341
342 fn render_editor(
343 &self,
344 font_size: Rems,
345 line_height: Pixels,
346 window: &mut Window,
347 cx: &mut Context<Self>,
348 ) -> Div {
349 let thread = self.thread.read(cx);
350
351 let editor_bg_color = cx.theme().colors().editor_background;
352 let is_generating = thread.is_generating();
353 let focus_handle = self.editor.focus_handle(cx);
354
355 let is_model_selected = self.is_model_selected(cx);
356 let is_editor_empty = self.is_editor_empty(cx);
357
358 let is_editor_expanded = self.editor_is_expanded;
359 let expand_icon = if is_editor_expanded {
360 IconName::Minimize
361 } else {
362 IconName::Maximize
363 };
364
365 v_flex()
366 .key_context("MessageEditor")
367 .on_action(cx.listener(Self::chat))
368 .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
369 this.profile_selector
370 .read(cx)
371 .menu_handle()
372 .toggle(window, cx);
373 }))
374 .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
375 this.model_selector
376 .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
377 }))
378 .on_action(cx.listener(Self::toggle_context_picker))
379 .on_action(cx.listener(Self::remove_all_context))
380 .on_action(cx.listener(Self::move_up))
381 .on_action(cx.listener(Self::toggle_chat_mode))
382 .on_action(cx.listener(Self::expand_message_editor))
383 .gap_2()
384 .p_2()
385 .bg(editor_bg_color)
386 .border_t_1()
387 .border_color(cx.theme().colors().border)
388 .child(
389 h_flex()
390 .items_start()
391 .justify_between()
392 .child(self.context_strip.clone())
393 .child(
394 IconButton::new("toggle-height", expand_icon)
395 .icon_size(IconSize::XSmall)
396 .icon_color(Color::Muted)
397 .tooltip({
398 let focus_handle = focus_handle.clone();
399 move |window, cx| {
400 let expand_label = if is_editor_expanded {
401 "Minimize Message Editor".to_string()
402 } else {
403 "Expand Message Editor".to_string()
404 };
405
406 Tooltip::for_action_in(
407 expand_label,
408 &ExpandMessageEditor,
409 &focus_handle,
410 window,
411 cx,
412 )
413 }
414 })
415 .on_click(cx.listener(|_, _, window, cx| {
416 window.dispatch_action(Box::new(ExpandMessageEditor), cx);
417 })),
418 ),
419 )
420 .child(
421 v_flex()
422 .size_full()
423 .gap_4()
424 .when(is_editor_expanded, |this| {
425 this.h(vh(0.8, window)).justify_between()
426 })
427 .child(
428 div()
429 .min_h_16()
430 .when(is_editor_expanded, |this| this.h_full())
431 .child({
432 let settings = ThemeSettings::get_global(cx);
433
434 let text_style = TextStyle {
435 color: cx.theme().colors().text,
436 font_family: settings.buffer_font.family.clone(),
437 font_fallbacks: settings.buffer_font.fallbacks.clone(),
438 font_features: settings.buffer_font.features.clone(),
439 font_size: font_size.into(),
440 line_height: line_height.into(),
441 ..Default::default()
442 };
443
444 EditorElement::new(
445 &self.editor,
446 EditorStyle {
447 background: editor_bg_color,
448 local_player: cx.theme().players().local(),
449 text: text_style,
450 syntax: cx.theme().syntax().clone(),
451 ..Default::default()
452 },
453 )
454 .into_any()
455 }),
456 )
457 .child(
458 h_flex()
459 .flex_none()
460 .justify_between()
461 .child(h_flex().gap_2().child(self.profile_selector.clone()))
462 .child(h_flex().gap_1().child(self.model_selector.clone()).map({
463 let focus_handle = focus_handle.clone();
464 move |parent| {
465 if is_generating {
466 parent.child(
467 IconButton::new(
468 "stop-generation",
469 IconName::StopFilled,
470 )
471 .icon_color(Color::Error)
472 .style(ButtonStyle::Tinted(ui::TintColor::Error))
473 .tooltip(move |window, cx| {
474 Tooltip::for_action(
475 "Stop Generation",
476 &editor::actions::Cancel,
477 window,
478 cx,
479 )
480 })
481 .on_click({
482 let focus_handle = focus_handle.clone();
483 move |_event, window, cx| {
484 focus_handle.dispatch_action(
485 &editor::actions::Cancel,
486 window,
487 cx,
488 );
489 }
490 })
491 .with_animation(
492 "pulsating-label",
493 Animation::new(Duration::from_secs(2))
494 .repeat()
495 .with_easing(pulsating_between(0.4, 1.0)),
496 |icon_button, delta| icon_button.alpha(delta),
497 ),
498 )
499 } else {
500 parent.child(
501 IconButton::new("send-message", IconName::Send)
502 .icon_color(Color::Accent)
503 .style(ButtonStyle::Filled)
504 .disabled(
505 is_editor_empty
506 || !is_model_selected
507 || self.waiting_for_summaries_to_send,
508 )
509 .on_click({
510 let focus_handle = focus_handle.clone();
511 move |_event, window, cx| {
512 focus_handle
513 .dispatch_action(&Chat, window, cx);
514 }
515 })
516 .when(
517 !is_editor_empty && is_model_selected,
518 |button| {
519 button.tooltip(move |window, cx| {
520 Tooltip::for_action(
521 "Send", &Chat, window, cx,
522 )
523 })
524 },
525 )
526 .when(is_editor_empty, |button| {
527 button.tooltip(Tooltip::text(
528 "Type a message to submit",
529 ))
530 })
531 .when(!is_model_selected, |button| {
532 button.tooltip(Tooltip::text(
533 "Select a model to continue",
534 ))
535 }),
536 )
537 }
538 }
539 })),
540 ),
541 )
542 }
543
544 fn render_changed_buffers(
545 &self,
546 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
547 window: &mut Window,
548 cx: &mut Context<Self>,
549 ) -> Div {
550 let focus_handle = self.editor.focus_handle(cx);
551
552 let editor_bg_color = cx.theme().colors().editor_background;
553 let border_color = cx.theme().colors().border;
554 let active_color = cx.theme().colors().element_selected;
555 let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
556 let is_edit_changes_expanded = self.edits_expanded;
557
558 v_flex()
559 .mx_2()
560 .bg(bg_edit_files_disclosure)
561 .border_1()
562 .border_b_0()
563 .border_color(border_color)
564 .rounded_t_md()
565 .shadow(smallvec::smallvec![gpui::BoxShadow {
566 color: gpui::black().opacity(0.15),
567 offset: point(px(1.), px(-1.)),
568 blur_radius: px(3.),
569 spread_radius: px(0.),
570 }])
571 .child(
572 h_flex()
573 .id("edits-container")
574 .cursor_pointer()
575 .p_1p5()
576 .justify_between()
577 .when(is_edit_changes_expanded, |this| {
578 this.border_b_1().border_color(border_color)
579 })
580 .on_click(
581 cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)),
582 )
583 .child(
584 h_flex()
585 .gap_1()
586 .child(
587 Disclosure::new("edits-disclosure", is_edit_changes_expanded)
588 .on_click(cx.listener(|this, _ev, _window, cx| {
589 this.edits_expanded = !this.edits_expanded;
590 cx.notify();
591 })),
592 )
593 .child(
594 Label::new("Edits")
595 .size(LabelSize::Small)
596 .color(Color::Muted),
597 )
598 .child(Label::new("•").size(LabelSize::XSmall).color(Color::Muted))
599 .child(
600 Label::new(format!(
601 "{} {}",
602 changed_buffers.len(),
603 if changed_buffers.len() == 1 {
604 "file"
605 } else {
606 "files"
607 }
608 ))
609 .size(LabelSize::Small)
610 .color(Color::Muted),
611 ),
612 )
613 .child(
614 Button::new("review", "Review Changes")
615 .label_size(LabelSize::Small)
616 .key_binding(
617 KeyBinding::for_action_in(
618 &OpenAgentDiff,
619 &focus_handle,
620 window,
621 cx,
622 )
623 .map(|kb| kb.size(rems_from_px(12.))),
624 )
625 .on_click(cx.listener(|this, _, window, cx| {
626 this.handle_review_click(window, cx)
627 })),
628 ),
629 )
630 .when(is_edit_changes_expanded, |parent| {
631 parent.child(
632 v_flex().children(changed_buffers.into_iter().enumerate().flat_map(
633 |(index, (buffer, _diff))| {
634 let file = buffer.read(cx).file()?;
635 let path = file.path();
636
637 let parent_label = path.parent().and_then(|parent| {
638 let parent_str = parent.to_string_lossy();
639
640 if parent_str.is_empty() {
641 None
642 } else {
643 Some(
644 Label::new(format!(
645 "/{}{}",
646 parent_str,
647 std::path::MAIN_SEPARATOR_STR
648 ))
649 .color(Color::Muted)
650 .size(LabelSize::XSmall)
651 .buffer_font(cx),
652 )
653 }
654 });
655
656 let name_label = path.file_name().map(|name| {
657 Label::new(name.to_string_lossy().to_string())
658 .size(LabelSize::XSmall)
659 .buffer_font(cx)
660 });
661
662 let file_icon = FileIcons::get_icon(&path, cx)
663 .map(Icon::from_path)
664 .map(|icon| icon.color(Color::Muted).size(IconSize::Small))
665 .unwrap_or_else(|| {
666 Icon::new(IconName::File)
667 .color(Color::Muted)
668 .size(IconSize::Small)
669 });
670
671 let hover_color = cx
672 .theme()
673 .colors()
674 .element_background
675 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
676
677 let overlay_gradient = linear_gradient(
678 90.,
679 linear_color_stop(editor_bg_color, 1.),
680 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
681 );
682
683 let overlay_gradient_hover = linear_gradient(
684 90.,
685 linear_color_stop(hover_color, 1.),
686 linear_color_stop(hover_color.opacity(0.2), 0.),
687 );
688
689 let element = h_flex()
690 .group("edited-code")
691 .id(("file-container", index))
692 .cursor_pointer()
693 .relative()
694 .py_1()
695 .pl_2()
696 .pr_1()
697 .gap_2()
698 .justify_between()
699 .bg(cx.theme().colors().editor_background)
700 .hover(|style| style.bg(hover_color))
701 .when(index + 1 < changed_buffers.len(), |parent| {
702 parent.border_color(border_color).border_b_1()
703 })
704 .child(
705 h_flex()
706 .id("file-name")
707 .pr_8()
708 .gap_1p5()
709 .max_w_full()
710 .overflow_x_scroll()
711 .child(file_icon)
712 .child(
713 h_flex()
714 .gap_0p5()
715 .children(name_label)
716 .children(parent_label),
717 ) // TODO: show lines changed
718 .child(Label::new("+").color(Color::Created))
719 .child(Label::new("-").color(Color::Deleted)),
720 )
721 .child(
722 div().visible_on_hover("edited-code").child(
723 Button::new("review", "Review")
724 .label_size(LabelSize::Small)
725 .on_click({
726 let buffer = buffer.clone();
727 cx.listener(move |this, _, window, cx| {
728 this.handle_file_click(
729 buffer.clone(),
730 window,
731 cx,
732 );
733 })
734 }),
735 ),
736 )
737 .child(
738 div()
739 .id("gradient-overlay")
740 .absolute()
741 .h_5_6()
742 .w_12()
743 .bottom_0()
744 .right(px(52.))
745 .bg(overlay_gradient)
746 .group_hover("edited-code", |style| {
747 style.bg(overlay_gradient_hover)
748 }),
749 )
750 .on_click({
751 let buffer = buffer.clone();
752 cx.listener(move |this, _, window, cx| {
753 this.handle_file_click(buffer.clone(), window, cx);
754 })
755 });
756
757 Some(element)
758 },
759 )),
760 )
761 })
762 }
763
764 fn render_token_limit_callout(
765 &self,
766 line_height: Pixels,
767 token_usage_ratio: TokenUsageRatio,
768 cx: &mut Context<Self>,
769 ) -> Div {
770 let heading = if token_usage_ratio == TokenUsageRatio::Exceeded {
771 "Thread reached the token limit"
772 } else {
773 "Thread reaching the token limit soon"
774 };
775
776 h_flex()
777 .p_2()
778 .gap_2()
779 .flex_wrap()
780 .justify_between()
781 .bg(
782 if token_usage_ratio == TokenUsageRatio::Exceeded {
783 cx.theme().status().error_background.opacity(0.1)
784 } else {
785 cx.theme().status().warning_background.opacity(0.1)
786 })
787 .border_t_1()
788 .border_color(cx.theme().colors().border)
789 .child(
790 h_flex()
791 .gap_2()
792 .items_start()
793 .child(
794 h_flex()
795 .h(line_height)
796 .justify_center()
797 .child(
798 if token_usage_ratio == TokenUsageRatio::Exceeded {
799 Icon::new(IconName::X)
800 .color(Color::Error)
801 .size(IconSize::XSmall)
802 } else {
803 Icon::new(IconName::Warning)
804 .color(Color::Warning)
805 .size(IconSize::XSmall)
806 }
807 ),
808 )
809 .child(
810 v_flex()
811 .mr_auto()
812 .child(Label::new(heading).size(LabelSize::Small))
813 .child(
814 Label::new(
815 "Start a new thread from a summary to continue the conversation.",
816 )
817 .size(LabelSize::Small)
818 .color(Color::Muted),
819 ),
820 ),
821 )
822 .child(
823 Button::new("new-thread", "Start New Thread")
824 .on_click(cx.listener(|this, _, window, cx| {
825 let from_thread_id = Some(this.thread.read(cx).id().clone());
826
827 window.dispatch_action(Box::new(NewThread {
828 from_thread_id
829 }), cx);
830 }))
831 .icon(IconName::Plus)
832 .icon_position(IconPosition::Start)
833 .icon_size(IconSize::Small)
834 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
835 .label_size(LabelSize::Small),
836 )
837 }
838}
839
840impl Focusable for MessageEditor {
841 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
842 self.editor.focus_handle(cx)
843 }
844}
845
846impl Render for MessageEditor {
847 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
848 let thread = self.thread.read(cx);
849 let total_token_usage = thread.total_token_usage(cx);
850
851 let action_log = self.thread.read(cx).action_log();
852 let changed_buffers = action_log.read(cx).changed_buffers(cx);
853
854 let font_size = TextSize::Small.rems(cx);
855 let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
856
857 v_flex()
858 .size_full()
859 .when(self.waiting_for_summaries_to_send, |parent| {
860 parent.child(
861 h_flex().py_3().w_full().justify_center().child(
862 h_flex()
863 .flex_none()
864 .px_2()
865 .py_2()
866 .bg(cx.theme().colors().editor_background)
867 .border_1()
868 .border_color(cx.theme().colors().border_variant)
869 .rounded_lg()
870 .shadow_md()
871 .gap_1()
872 .child(
873 Icon::new(IconName::ArrowCircle)
874 .size(IconSize::XSmall)
875 .color(Color::Muted)
876 .with_animation(
877 "arrow-circle",
878 Animation::new(Duration::from_secs(2)).repeat(),
879 |icon, delta| {
880 icon.transform(gpui::Transformation::rotate(
881 gpui::percentage(delta),
882 ))
883 },
884 ),
885 )
886 .child(
887 Label::new("Summarizing context…")
888 .size(LabelSize::XSmall)
889 .color(Color::Muted),
890 ),
891 ),
892 )
893 })
894 .when(changed_buffers.len() > 0, |parent| {
895 parent.child(self.render_changed_buffers(&changed_buffers, window, cx))
896 })
897 .child(self.render_editor(font_size, line_height, window, cx))
898 .when(
899 total_token_usage.ratio != TokenUsageRatio::Normal,
900 |parent| {
901 parent.child(self.render_token_limit_callout(
902 line_height,
903 total_token_usage.ratio,
904 cx,
905 ))
906 },
907 )
908 }
909}