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