1use std::sync::Arc;
2
3use editor::actions::MoveUp;
4use editor::{Editor, EditorElement, EditorEvent, EditorStyle};
5use fs::Fs;
6use gpui::{
7 pulsating_between, Animation, AnimationExt, AppContext, DismissEvent, FocusableView, Model,
8 Subscription, TextStyle, View, WeakModel, WeakView,
9};
10use language_model::{LanguageModelRegistry, LanguageModelRequestTool};
11use language_model_selector::LanguageModelSelector;
12use rope::Point;
13use settings::Settings;
14use std::time::Duration;
15use theme::ThemeSettings;
16use ui::{
17 prelude::*, ButtonLike, KeyBinding, PopoverMenu, PopoverMenuHandle, Switch, TintColor, Tooltip,
18};
19use workspace::Workspace;
20
21use crate::assistant_model_selector::AssistantModelSelector;
22use crate::context_picker::{ConfirmBehavior, ContextPicker};
23use crate::context_store::{refresh_context_store_text, ContextStore};
24use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
25use crate::thread::{RequestKind, Thread};
26use crate::thread_store::ThreadStore;
27use crate::{Chat, ChatMode, RemoveAllContext, ToggleContextPicker, ToggleModelSelector};
28
29pub struct MessageEditor {
30 thread: Model<Thread>,
31 editor: View<Editor>,
32 context_store: Model<ContextStore>,
33 context_strip: View<ContextStrip>,
34 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
35 inline_context_picker: View<ContextPicker>,
36 inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
37 model_selector: View<AssistantModelSelector>,
38 model_selector_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
39 use_tools: bool,
40 _subscriptions: Vec<Subscription>,
41}
42
43impl MessageEditor {
44 pub fn new(
45 fs: Arc<dyn Fs>,
46 workspace: WeakView<Workspace>,
47 thread_store: WeakModel<ThreadStore>,
48 thread: Model<Thread>,
49 cx: &mut ViewContext<Self>,
50 ) -> Self {
51 let context_store = cx.new_model(|_cx| ContextStore::new(workspace.clone()));
52 let context_picker_menu_handle = PopoverMenuHandle::default();
53 let inline_context_picker_menu_handle = PopoverMenuHandle::default();
54 let model_selector_menu_handle = PopoverMenuHandle::default();
55
56 let editor = cx.new_view(|cx| {
57 let mut editor = Editor::auto_height(10, cx);
58 editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
59 editor.set_show_indent_guides(false, cx);
60
61 editor
62 });
63
64 let inline_context_picker = cx.new_view(|cx| {
65 ContextPicker::new(
66 workspace.clone(),
67 Some(thread_store.clone()),
68 context_store.downgrade(),
69 editor.downgrade(),
70 ConfirmBehavior::Close,
71 cx,
72 )
73 });
74
75 let context_strip = cx.new_view(|cx| {
76 ContextStrip::new(
77 context_store.clone(),
78 workspace.clone(),
79 editor.downgrade(),
80 Some(thread_store.clone()),
81 context_picker_menu_handle.clone(),
82 SuggestContextKind::File,
83 cx,
84 )
85 });
86
87 let subscriptions = vec![
88 cx.subscribe(&editor, Self::handle_editor_event),
89 cx.subscribe(
90 &inline_context_picker,
91 Self::handle_inline_context_picker_event,
92 ),
93 cx.subscribe(&context_strip, Self::handle_context_strip_event),
94 ];
95
96 Self {
97 thread,
98 editor: editor.clone(),
99 context_store,
100 context_strip,
101 context_picker_menu_handle,
102 inline_context_picker,
103 inline_context_picker_menu_handle,
104 model_selector: cx.new_view(|cx| {
105 AssistantModelSelector::new(
106 fs,
107 model_selector_menu_handle.clone(),
108 editor.focus_handle(cx),
109 cx,
110 )
111 }),
112 model_selector_menu_handle,
113 use_tools: false,
114 _subscriptions: subscriptions,
115 }
116 }
117
118 fn toggle_model_selector(&mut self, _: &ToggleModelSelector, cx: &mut ViewContext<Self>) {
119 self.model_selector_menu_handle.toggle(cx)
120 }
121
122 fn toggle_chat_mode(&mut self, _: &ChatMode, cx: &mut ViewContext<Self>) {
123 self.use_tools = !self.use_tools;
124 cx.notify();
125 }
126
127 fn toggle_context_picker(&mut self, _: &ToggleContextPicker, cx: &mut ViewContext<Self>) {
128 self.context_picker_menu_handle.toggle(cx);
129 }
130
131 pub fn remove_all_context(&mut self, _: &RemoveAllContext, cx: &mut ViewContext<Self>) {
132 self.context_store.update(cx, |store, _cx| store.clear());
133 cx.notify();
134 }
135
136 fn chat(&mut self, _: &Chat, cx: &mut ViewContext<Self>) {
137 self.send_to_model(RequestKind::Chat, cx);
138 }
139
140 fn is_editor_empty(&self, cx: &AppContext) -> bool {
141 self.editor.read(cx).text(cx).is_empty()
142 }
143
144 fn is_model_selected(&self, cx: &AppContext) -> bool {
145 LanguageModelRegistry::read_global(cx)
146 .active_model()
147 .is_some()
148 }
149
150 fn send_to_model(&mut self, request_kind: RequestKind, cx: &mut ViewContext<Self>) {
151 let provider = LanguageModelRegistry::read_global(cx).active_provider();
152 if provider
153 .as_ref()
154 .map_or(false, |provider| provider.must_accept_terms(cx))
155 {
156 cx.notify();
157 return;
158 }
159
160 let model_registry = LanguageModelRegistry::read_global(cx);
161 let Some(model) = model_registry.active_model() else {
162 return;
163 };
164
165 let user_message = self.editor.update(cx, |editor, cx| {
166 let text = editor.text(cx);
167 editor.clear(cx);
168 text
169 });
170
171 let refresh_task = refresh_context_store_text(self.context_store.clone(), cx);
172
173 let thread = self.thread.clone();
174 let context_store = self.context_store.clone();
175 let use_tools = self.use_tools;
176 cx.spawn(move |_, mut cx| async move {
177 refresh_task.await;
178 thread
179 .update(&mut cx, |thread, cx| {
180 let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
181 thread.insert_user_message(user_message, context, cx);
182 let mut request = thread.to_completion_request(request_kind, cx);
183
184 if use_tools {
185 request.tools = thread
186 .tools()
187 .tools(cx)
188 .into_iter()
189 .map(|tool| LanguageModelRequestTool {
190 name: tool.name(),
191 description: tool.description(),
192 input_schema: tool.input_schema(),
193 })
194 .collect();
195 }
196
197 thread.stream_completion(request, model, cx)
198 })
199 .ok();
200 })
201 .detach();
202 }
203
204 fn handle_editor_event(
205 &mut self,
206 editor: View<Editor>,
207 event: &EditorEvent,
208 cx: &mut ViewContext<Self>,
209 ) {
210 match event {
211 EditorEvent::SelectionsChanged { .. } => {
212 editor.update(cx, |editor, cx| {
213 let snapshot = editor.buffer().read(cx).snapshot(cx);
214 let newest_cursor = editor.selections.newest::<Point>(cx).head();
215 if newest_cursor.column > 0 {
216 let behind_cursor = Point::new(newest_cursor.row, newest_cursor.column - 1);
217 let char_behind_cursor = snapshot.chars_at(behind_cursor).next();
218 if char_behind_cursor == Some('@') {
219 self.inline_context_picker_menu_handle.show(cx);
220 }
221 }
222 });
223 }
224 _ => {}
225 }
226 }
227
228 fn handle_inline_context_picker_event(
229 &mut self,
230 _inline_context_picker: View<ContextPicker>,
231 _event: &DismissEvent,
232 cx: &mut ViewContext<Self>,
233 ) {
234 let editor_focus_handle = self.editor.focus_handle(cx);
235 cx.focus(&editor_focus_handle);
236 }
237
238 fn handle_context_strip_event(
239 &mut self,
240 _context_strip: View<ContextStrip>,
241 event: &ContextStripEvent,
242 cx: &mut ViewContext<Self>,
243 ) {
244 match event {
245 ContextStripEvent::PickerDismissed
246 | ContextStripEvent::BlurredEmpty
247 | ContextStripEvent::BlurredDown => {
248 let editor_focus_handle = self.editor.focus_handle(cx);
249 cx.focus(&editor_focus_handle);
250 }
251 ContextStripEvent::BlurredUp => {}
252 }
253 }
254
255 fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
256 if self.context_picker_menu_handle.is_deployed()
257 || self.inline_context_picker_menu_handle.is_deployed()
258 {
259 cx.propagate();
260 } else {
261 cx.focus_view(&self.context_strip);
262 }
263 }
264}
265
266impl FocusableView for MessageEditor {
267 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
268 self.editor.focus_handle(cx)
269 }
270}
271
272impl Render for MessageEditor {
273 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
274 let font_size = TextSize::Default.rems(cx);
275 let line_height = font_size.to_pixels(cx.rem_size()) * 1.5;
276 let focus_handle = self.editor.focus_handle(cx);
277 let inline_context_picker = self.inline_context_picker.clone();
278 let bg_color = cx.theme().colors().editor_background;
279 let is_streaming_completion = self.thread.read(cx).is_streaming();
280 let button_width = px(64.);
281 let is_model_selected = self.is_model_selected(cx);
282 let is_editor_empty = self.is_editor_empty(cx);
283 let submit_label_color = if is_editor_empty {
284 Color::Muted
285 } else {
286 Color::Default
287 };
288
289 v_flex()
290 .key_context("MessageEditor")
291 .on_action(cx.listener(Self::chat))
292 .on_action(cx.listener(Self::toggle_model_selector))
293 .on_action(cx.listener(Self::toggle_context_picker))
294 .on_action(cx.listener(Self::remove_all_context))
295 .on_action(cx.listener(Self::move_up))
296 .on_action(cx.listener(Self::toggle_chat_mode))
297 .size_full()
298 .gap_2()
299 .p_2()
300 .bg(bg_color)
301 .child(self.context_strip.clone())
302 .child(
303 v_flex()
304 .gap_4()
305 .child({
306 let settings = ThemeSettings::get_global(cx);
307 let text_style = TextStyle {
308 color: cx.theme().colors().text,
309 font_family: settings.ui_font.family.clone(),
310 font_features: settings.ui_font.features.clone(),
311 font_size: font_size.into(),
312 font_weight: settings.ui_font.weight,
313 line_height: line_height.into(),
314 ..Default::default()
315 };
316
317 EditorElement::new(
318 &self.editor,
319 EditorStyle {
320 background: bg_color,
321 local_player: cx.theme().players().local(),
322 text: text_style,
323 ..Default::default()
324 },
325 )
326 })
327 .child(
328 PopoverMenu::new("inline-context-picker")
329 .menu(move |cx| {
330 inline_context_picker.update(cx, |this, cx| {
331 this.init(cx);
332 });
333
334 Some(inline_context_picker.clone())
335 })
336 .attach(gpui::Corner::TopLeft)
337 .anchor(gpui::Corner::BottomLeft)
338 .offset(gpui::Point {
339 x: px(0.0),
340 y: px(-ThemeSettings::clamp_font_size(
341 ThemeSettings::get_global(cx).ui_font_size,
342 )
343 .0 * 2.0)
344 - px(4.0),
345 })
346 .with_handle(self.inline_context_picker_menu_handle.clone()),
347 )
348 .child(
349 h_flex()
350 .justify_between()
351 .child(
352 Switch::new("use-tools", self.use_tools.into())
353 .label("Tools")
354 .on_click(cx.listener(|this, selection, _cx| {
355 this.use_tools = match selection {
356 ToggleState::Selected => true,
357 ToggleState::Unselected
358 | ToggleState::Indeterminate => false,
359 };
360 }))
361 .key_binding(KeyBinding::for_action_in(
362 &ChatMode,
363 &focus_handle,
364 cx,
365 )),
366 )
367 .child(h_flex().gap_1().child(self.model_selector.clone()).child(
368 if is_streaming_completion {
369 ButtonLike::new("cancel-generation")
370 .width(button_width.into())
371 .style(ButtonStyle::Tinted(TintColor::Accent))
372 .child(
373 h_flex()
374 .w_full()
375 .justify_between()
376 .child(
377 Label::new("Cancel")
378 .size(LabelSize::Small)
379 .with_animation(
380 "pulsating-label",
381 Animation::new(Duration::from_secs(2))
382 .repeat()
383 .with_easing(pulsating_between(
384 0.4, 0.8,
385 )),
386 |label, delta| label.alpha(delta),
387 ),
388 )
389 .children(
390 KeyBinding::for_action_in(
391 &editor::actions::Cancel,
392 &focus_handle,
393 cx,
394 )
395 .map(|binding| binding.into_any_element()),
396 ),
397 )
398 .on_click(move |_event, cx| {
399 focus_handle
400 .dispatch_action(&editor::actions::Cancel, cx);
401 })
402 } else {
403 ButtonLike::new("submit-message")
404 .width(button_width.into())
405 .style(ButtonStyle::Filled)
406 .disabled(is_editor_empty || !is_model_selected)
407 .child(
408 h_flex()
409 .w_full()
410 .justify_between()
411 .child(
412 Label::new("Submit")
413 .size(LabelSize::Small)
414 .color(submit_label_color),
415 )
416 .children(
417 KeyBinding::for_action_in(
418 &Chat,
419 &focus_handle,
420 cx,
421 )
422 .map(|binding| binding.into_any_element()),
423 ),
424 )
425 .on_click(move |_event, cx| {
426 focus_handle.dispatch_action(&Chat, cx);
427 })
428 .when(is_editor_empty, |button| {
429 button.tooltip(|cx| {
430 Tooltip::text("Type a message to submit", cx)
431 })
432 })
433 .when(!is_model_selected, |button| {
434 button.tooltip(|cx| {
435 Tooltip::text("Select a model to continue", cx)
436 })
437 })
438 },
439 )),
440 ),
441 )
442 }
443}