1use anyhow::Result;
2use collections::{HashMap, HashSet};
3use editor::CompletionProvider;
4use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
5use gpui::{
6 Action, App, Bounds, Entity, EventEmitter, Focusable, PromptLevel, Subscription, Task,
7 TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, actions, point, size,
8 transparent_black,
9};
10use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
11use language_model::{
12 ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
13};
14use picker::{Picker, PickerDelegate};
15use release_channel::ReleaseChannel;
16use rope::Rope;
17use settings::Settings;
18use std::sync::Arc;
19use std::time::Duration;
20use theme::ThemeSettings;
21use ui::{
22 Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render,
23 SharedString, Styled, Tooltip, Window, div, prelude::*,
24};
25use util::{ResultExt, TryFutureExt};
26use workspace::Workspace;
27use zed_actions::assistant::InlineAssist;
28
29use prompt_store::*;
30
31pub fn init(cx: &mut App) {
32 prompt_store::init(cx);
33}
34
35actions!(
36 prompt_library,
37 [
38 NewPrompt,
39 DeletePrompt,
40 DuplicatePrompt,
41 ToggleDefaultPrompt
42 ]
43);
44
45const BUILT_IN_TOOLTIP_TEXT: &'static str = concat!(
46 "This prompt supports special functionality.\n",
47 "It's read-only, but you can remove it from your default prompt."
48);
49
50pub trait InlineAssistDelegate {
51 fn assist(
52 &self,
53 prompt_editor: &Entity<Editor>,
54 initial_prompt: Option<String>,
55 window: &mut Window,
56 cx: &mut Context<PromptLibrary>,
57 );
58
59 /// Returns whether the Assistant panel was focused.
60 fn focus_assistant_panel(
61 &self,
62 workspace: &mut Workspace,
63 window: &mut Window,
64 cx: &mut Context<Workspace>,
65 ) -> bool;
66}
67
68/// This function opens a new prompt library window if one doesn't exist already.
69/// If one exists, it brings it to the foreground.
70///
71/// Note that, when opening a new window, this waits for the PromptStore to be
72/// initialized. If it was initialized successfully, it returns a window handle
73/// to a prompt library.
74pub fn open_prompt_library(
75 language_registry: Arc<LanguageRegistry>,
76 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
77 make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
78 cx: &mut App,
79) -> Task<Result<WindowHandle<PromptLibrary>>> {
80 let store = PromptStore::global(cx);
81 cx.spawn(async move |cx| {
82 // We query windows in spawn so that all windows have been returned to GPUI
83 let existing_window = cx
84 .update(|cx| {
85 let existing_window = cx
86 .windows()
87 .into_iter()
88 .find_map(|window| window.downcast::<PromptLibrary>());
89 if let Some(existing_window) = existing_window {
90 existing_window
91 .update(cx, |_, window, _| window.activate_window())
92 .ok();
93
94 Some(existing_window)
95 } else {
96 None
97 }
98 })
99 .ok()
100 .flatten();
101
102 if let Some(existing_window) = existing_window {
103 return Ok(existing_window);
104 }
105
106 let store = store.await?;
107 cx.update(|cx| {
108 let app_id = ReleaseChannel::global(cx).app_id();
109 let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
110 cx.open_window(
111 WindowOptions {
112 titlebar: Some(TitlebarOptions {
113 title: Some("Prompt Library".into()),
114 appears_transparent: cfg!(target_os = "macos"),
115 traffic_light_position: Some(point(px(9.0), px(9.0))),
116 }),
117 app_id: Some(app_id.to_owned()),
118 window_bounds: Some(WindowBounds::Windowed(bounds)),
119 ..Default::default()
120 },
121 |window, cx| {
122 cx.new(|cx| {
123 PromptLibrary::new(
124 store,
125 language_registry,
126 inline_assist_delegate,
127 make_completion_provider,
128 window,
129 cx,
130 )
131 })
132 },
133 )
134 })?
135 })
136}
137
138pub struct PromptLibrary {
139 store: Arc<PromptStore>,
140 language_registry: Arc<LanguageRegistry>,
141 prompt_editors: HashMap<PromptId, PromptEditor>,
142 active_prompt_id: Option<PromptId>,
143 picker: Entity<Picker<PromptPickerDelegate>>,
144 pending_load: Task<()>,
145 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
146 make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
147 _subscriptions: Vec<Subscription>,
148}
149
150struct PromptEditor {
151 title_editor: Entity<Editor>,
152 body_editor: Entity<Editor>,
153 token_count: Option<usize>,
154 pending_token_count: Task<Option<()>>,
155 next_title_and_body_to_save: Option<(String, Rope)>,
156 pending_save: Option<Task<Option<()>>>,
157 _subscriptions: Vec<Subscription>,
158}
159
160struct PromptPickerDelegate {
161 store: Arc<PromptStore>,
162 selected_index: usize,
163 matches: Vec<PromptMetadata>,
164}
165
166enum PromptPickerEvent {
167 Selected { prompt_id: PromptId },
168 Confirmed { prompt_id: PromptId },
169 Deleted { prompt_id: PromptId },
170 ToggledDefault { prompt_id: PromptId },
171}
172
173impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
174
175impl PickerDelegate for PromptPickerDelegate {
176 type ListItem = ListItem;
177
178 fn match_count(&self) -> usize {
179 self.matches.len()
180 }
181
182 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
183 let text = if self.store.prompt_count() == 0 {
184 "No prompts.".into()
185 } else {
186 "No prompts found matching your search.".into()
187 };
188 Some(text)
189 }
190
191 fn selected_index(&self) -> usize {
192 self.selected_index
193 }
194
195 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
196 self.selected_index = ix;
197 if let Some(prompt) = self.matches.get(self.selected_index) {
198 cx.emit(PromptPickerEvent::Selected {
199 prompt_id: prompt.id,
200 });
201 }
202 }
203
204 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
205 "Search...".into()
206 }
207
208 fn update_matches(
209 &mut self,
210 query: String,
211 window: &mut Window,
212 cx: &mut Context<Picker<Self>>,
213 ) -> Task<()> {
214 let search = self.store.search(query);
215 let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
216 cx.spawn_in(window, async move |this, cx| {
217 let (matches, selected_index) = cx
218 .background_spawn(async move {
219 let matches = search.await;
220
221 let selected_index = prev_prompt_id
222 .and_then(|prev_prompt_id| {
223 matches.iter().position(|entry| entry.id == prev_prompt_id)
224 })
225 .unwrap_or(0);
226 (matches, selected_index)
227 })
228 .await;
229
230 this.update_in(cx, |this, window, cx| {
231 this.delegate.matches = matches;
232 this.delegate.set_selected_index(selected_index, window, cx);
233 cx.notify();
234 })
235 .ok();
236 })
237 }
238
239 fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
240 if let Some(prompt) = self.matches.get(self.selected_index) {
241 cx.emit(PromptPickerEvent::Confirmed {
242 prompt_id: prompt.id,
243 });
244 }
245 }
246
247 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
248
249 fn render_match(
250 &self,
251 ix: usize,
252 selected: bool,
253 _: &mut Window,
254 cx: &mut Context<Picker<Self>>,
255 ) -> Option<Self::ListItem> {
256 let prompt = self.matches.get(ix)?;
257 let default = prompt.default;
258 let prompt_id = prompt.id;
259 let element = ListItem::new(ix)
260 .inset(true)
261 .spacing(ListItemSpacing::Sparse)
262 .toggle_state(selected)
263 .child(h_flex().h_5().line_height(relative(1.)).child(Label::new(
264 prompt.title.clone().unwrap_or("Untitled".into()),
265 )))
266 .end_slot::<IconButton>(default.then(|| {
267 IconButton::new("toggle-default-prompt", IconName::SparkleFilled)
268 .toggle_state(true)
269 .icon_color(Color::Accent)
270 .shape(IconButtonShape::Square)
271 .tooltip(Tooltip::text("Remove from Default Prompt"))
272 .on_click(cx.listener(move |_, _, _, cx| {
273 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
274 }))
275 }))
276 .end_hover_slot(
277 h_flex()
278 .gap_2()
279 .child(if prompt_id.is_built_in() {
280 div()
281 .id("built-in-prompt")
282 .child(Icon::new(IconName::FileLock).color(Color::Muted))
283 .tooltip(move |window, cx| {
284 Tooltip::with_meta(
285 "Built-in prompt",
286 None,
287 BUILT_IN_TOOLTIP_TEXT,
288 window,
289 cx,
290 )
291 })
292 .into_any()
293 } else {
294 IconButton::new("delete-prompt", IconName::Trash)
295 .icon_color(Color::Muted)
296 .shape(IconButtonShape::Square)
297 .tooltip(Tooltip::text("Delete Prompt"))
298 .on_click(cx.listener(move |_, _, _, cx| {
299 cx.emit(PromptPickerEvent::Deleted { prompt_id })
300 }))
301 .into_any_element()
302 })
303 .child(
304 IconButton::new("toggle-default-prompt", IconName::Sparkle)
305 .toggle_state(default)
306 .selected_icon(IconName::SparkleFilled)
307 .icon_color(if default { Color::Accent } else { Color::Muted })
308 .shape(IconButtonShape::Square)
309 .tooltip(Tooltip::text(if default {
310 "Remove from Default Prompt"
311 } else {
312 "Add to Default Prompt"
313 }))
314 .on_click(cx.listener(move |_, _, _, cx| {
315 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
316 })),
317 ),
318 );
319 Some(element)
320 }
321
322 fn render_editor(
323 &self,
324 editor: &Entity<Editor>,
325 _: &mut Window,
326 cx: &mut Context<Picker<Self>>,
327 ) -> Div {
328 h_flex()
329 .bg(cx.theme().colors().editor_background)
330 .rounded_sm()
331 .overflow_hidden()
332 .flex_none()
333 .py_1()
334 .px_2()
335 .mx_1()
336 .child(editor.clone())
337 }
338}
339
340impl PromptLibrary {
341 fn new(
342 store: Arc<PromptStore>,
343 language_registry: Arc<LanguageRegistry>,
344 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
345 make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
346 window: &mut Window,
347 cx: &mut Context<Self>,
348 ) -> Self {
349 let delegate = PromptPickerDelegate {
350 store: store.clone(),
351 selected_index: 0,
352 matches: Vec::new(),
353 };
354
355 let picker = cx.new(|cx| {
356 let picker = Picker::uniform_list(delegate, window, cx)
357 .modal(false)
358 .max_height(None);
359 picker.focus(window, cx);
360 picker
361 });
362 Self {
363 store: store.clone(),
364 language_registry,
365 prompt_editors: HashMap::default(),
366 active_prompt_id: None,
367 pending_load: Task::ready(()),
368 inline_assist_delegate,
369 make_completion_provider,
370 _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)],
371 picker,
372 }
373 }
374
375 fn handle_picker_event(
376 &mut self,
377 _: &Entity<Picker<PromptPickerDelegate>>,
378 event: &PromptPickerEvent,
379 window: &mut Window,
380 cx: &mut Context<Self>,
381 ) {
382 match event {
383 PromptPickerEvent::Selected { prompt_id } => {
384 self.load_prompt(*prompt_id, false, window, cx);
385 }
386 PromptPickerEvent::Confirmed { prompt_id } => {
387 self.load_prompt(*prompt_id, true, window, cx);
388 }
389 PromptPickerEvent::ToggledDefault { prompt_id } => {
390 self.toggle_default_for_prompt(*prompt_id, window, cx);
391 }
392 PromptPickerEvent::Deleted { prompt_id } => {
393 self.delete_prompt(*prompt_id, window, cx);
394 }
395 }
396 }
397
398 pub fn new_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
399 // If we already have an untitled prompt, use that instead
400 // of creating a new one.
401 if let Some(metadata) = self.store.first() {
402 if metadata.title.is_none() {
403 self.load_prompt(metadata.id, true, window, cx);
404 return;
405 }
406 }
407
408 let prompt_id = PromptId::new();
409 let save = self.store.save(prompt_id, None, false, "".into());
410 self.picker
411 .update(cx, |picker, cx| picker.refresh(window, cx));
412 cx.spawn_in(window, async move |this, cx| {
413 save.await?;
414 this.update_in(cx, |this, window, cx| {
415 this.load_prompt(prompt_id, true, window, cx)
416 })
417 })
418 .detach_and_log_err(cx);
419 }
420
421 pub fn save_prompt(
422 &mut self,
423 prompt_id: PromptId,
424 window: &mut Window,
425 cx: &mut Context<Self>,
426 ) {
427 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
428
429 if prompt_id.is_built_in() {
430 return;
431 }
432
433 let prompt_metadata = self.store.metadata(prompt_id).unwrap();
434 let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
435 let title = prompt_editor.title_editor.read(cx).text(cx);
436 let body = prompt_editor.body_editor.update(cx, |editor, cx| {
437 editor
438 .buffer()
439 .read(cx)
440 .as_singleton()
441 .unwrap()
442 .read(cx)
443 .as_rope()
444 .clone()
445 });
446
447 let store = self.store.clone();
448 let executor = cx.background_executor().clone();
449
450 prompt_editor.next_title_and_body_to_save = Some((title, body));
451 if prompt_editor.pending_save.is_none() {
452 prompt_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
453 async move {
454 loop {
455 let title_and_body = this.update(cx, |this, _| {
456 this.prompt_editors
457 .get_mut(&prompt_id)?
458 .next_title_and_body_to_save
459 .take()
460 })?;
461
462 if let Some((title, body)) = title_and_body {
463 let title = if title.trim().is_empty() {
464 None
465 } else {
466 Some(SharedString::from(title))
467 };
468 store
469 .save(prompt_id, title, prompt_metadata.default, body)
470 .await
471 .log_err();
472 this.update_in(cx, |this, window, cx| {
473 this.picker
474 .update(cx, |picker, cx| picker.refresh(window, cx));
475 cx.notify();
476 })?;
477
478 executor.timer(SAVE_THROTTLE).await;
479 } else {
480 break;
481 }
482 }
483
484 this.update(cx, |this, _cx| {
485 if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
486 prompt_editor.pending_save = None;
487 }
488 })
489 }
490 .log_err()
491 .await
492 }));
493 }
494 }
495
496 pub fn delete_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
497 if let Some(active_prompt_id) = self.active_prompt_id {
498 self.delete_prompt(active_prompt_id, window, cx);
499 }
500 }
501
502 pub fn duplicate_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
503 if let Some(active_prompt_id) = self.active_prompt_id {
504 self.duplicate_prompt(active_prompt_id, window, cx);
505 }
506 }
507
508 pub fn toggle_default_for_active_prompt(
509 &mut self,
510 window: &mut Window,
511 cx: &mut Context<Self>,
512 ) {
513 if let Some(active_prompt_id) = self.active_prompt_id {
514 self.toggle_default_for_prompt(active_prompt_id, window, cx);
515 }
516 }
517
518 pub fn toggle_default_for_prompt(
519 &mut self,
520 prompt_id: PromptId,
521 window: &mut Window,
522 cx: &mut Context<Self>,
523 ) {
524 if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
525 self.store
526 .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
527 .detach_and_log_err(cx);
528 self.picker
529 .update(cx, |picker, cx| picker.refresh(window, cx));
530 cx.notify();
531 }
532 }
533
534 pub fn load_prompt(
535 &mut self,
536 prompt_id: PromptId,
537 focus: bool,
538 window: &mut Window,
539 cx: &mut Context<Self>,
540 ) {
541 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
542 if focus {
543 prompt_editor
544 .body_editor
545 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
546 }
547 self.set_active_prompt(Some(prompt_id), window, cx);
548 } else if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
549 let language_registry = self.language_registry.clone();
550 let prompt = self.store.load(prompt_id);
551 let make_completion_provider = self.make_completion_provider.clone();
552 self.pending_load = cx.spawn_in(window, async move |this, cx| {
553 let prompt = prompt.await;
554 let markdown = language_registry.language_for_name("Markdown").await;
555 this.update_in(cx, |this, window, cx| match prompt {
556 Ok(prompt) => {
557 let title_editor = cx.new(|cx| {
558 let mut editor = Editor::auto_width(window, cx);
559 editor.set_placeholder_text("Untitled", cx);
560 editor.set_text(prompt_metadata.title.unwrap_or_default(), window, cx);
561 if prompt_id.is_built_in() {
562 editor.set_read_only(true);
563 editor.set_show_edit_predictions(Some(false), window, cx);
564 }
565 editor
566 });
567 let body_editor = cx.new(|cx| {
568 let buffer = cx.new(|cx| {
569 let mut buffer = Buffer::local(prompt, cx);
570 buffer.set_language(markdown.log_err(), cx);
571 buffer.set_language_registry(language_registry);
572 buffer
573 });
574
575 let mut editor = Editor::for_buffer(buffer, None, window, cx);
576 if prompt_id.is_built_in() {
577 editor.set_read_only(true);
578 editor.set_show_edit_predictions(Some(false), window, cx);
579 }
580 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
581 editor.set_show_gutter(false, cx);
582 editor.set_show_wrap_guides(false, cx);
583 editor.set_show_indent_guides(false, cx);
584 editor.set_use_modal_editing(false);
585 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
586 editor.set_completion_provider(Some(make_completion_provider()));
587 if focus {
588 window.focus(&editor.focus_handle(cx));
589 }
590 editor
591 });
592 let _subscriptions = vec![
593 cx.subscribe_in(
594 &title_editor,
595 window,
596 move |this, editor, event, window, cx| {
597 this.handle_prompt_title_editor_event(
598 prompt_id, editor, event, window, cx,
599 )
600 },
601 ),
602 cx.subscribe_in(
603 &body_editor,
604 window,
605 move |this, editor, event, window, cx| {
606 this.handle_prompt_body_editor_event(
607 prompt_id, editor, event, window, cx,
608 )
609 },
610 ),
611 ];
612 this.prompt_editors.insert(
613 prompt_id,
614 PromptEditor {
615 title_editor,
616 body_editor,
617 next_title_and_body_to_save: None,
618 pending_save: None,
619 token_count: None,
620 pending_token_count: Task::ready(None),
621 _subscriptions,
622 },
623 );
624 this.set_active_prompt(Some(prompt_id), window, cx);
625 this.count_tokens(prompt_id, window, cx);
626 }
627 Err(error) => {
628 // TODO: we should show the error in the UI.
629 log::error!("error while loading prompt: {:?}", error);
630 }
631 })
632 .ok();
633 });
634 }
635 }
636
637 fn set_active_prompt(
638 &mut self,
639 prompt_id: Option<PromptId>,
640 window: &mut Window,
641 cx: &mut Context<Self>,
642 ) {
643 self.active_prompt_id = prompt_id;
644 self.picker.update(cx, |picker, cx| {
645 if let Some(prompt_id) = prompt_id {
646 if picker
647 .delegate
648 .matches
649 .get(picker.delegate.selected_index())
650 .map_or(true, |old_selected_prompt| {
651 old_selected_prompt.id != prompt_id
652 })
653 {
654 if let Some(ix) = picker
655 .delegate
656 .matches
657 .iter()
658 .position(|mat| mat.id == prompt_id)
659 {
660 picker.set_selected_index(ix, true, window, cx);
661 }
662 }
663 } else {
664 picker.focus(window, cx);
665 }
666 });
667 cx.notify();
668 }
669
670 pub fn delete_prompt(
671 &mut self,
672 prompt_id: PromptId,
673 window: &mut Window,
674 cx: &mut Context<Self>,
675 ) {
676 if let Some(metadata) = self.store.metadata(prompt_id) {
677 let confirmation = window.prompt(
678 PromptLevel::Warning,
679 &format!(
680 "Are you sure you want to delete {}",
681 metadata.title.unwrap_or("Untitled".into())
682 ),
683 None,
684 &["Delete", "Cancel"],
685 cx,
686 );
687
688 cx.spawn_in(window, async move |this, cx| {
689 if confirmation.await.ok() == Some(0) {
690 this.update_in(cx, |this, window, cx| {
691 if this.active_prompt_id == Some(prompt_id) {
692 this.set_active_prompt(None, window, cx);
693 }
694 this.prompt_editors.remove(&prompt_id);
695 this.store.delete(prompt_id).detach_and_log_err(cx);
696 this.picker
697 .update(cx, |picker, cx| picker.refresh(window, cx));
698 cx.notify();
699 })?;
700 }
701 anyhow::Ok(())
702 })
703 .detach_and_log_err(cx);
704 }
705 }
706
707 pub fn duplicate_prompt(
708 &mut self,
709 prompt_id: PromptId,
710 window: &mut Window,
711 cx: &mut Context<Self>,
712 ) {
713 if let Some(prompt) = self.prompt_editors.get(&prompt_id) {
714 const DUPLICATE_SUFFIX: &str = " copy";
715 let title_to_duplicate = prompt.title_editor.read(cx).text(cx);
716 let existing_titles = self
717 .prompt_editors
718 .iter()
719 .filter(|&(&id, _)| id != prompt_id)
720 .map(|(_, prompt_editor)| prompt_editor.title_editor.read(cx).text(cx))
721 .filter(|title| title.starts_with(&title_to_duplicate))
722 .collect::<HashSet<_>>();
723
724 let title = if existing_titles.is_empty() {
725 title_to_duplicate + DUPLICATE_SUFFIX
726 } else {
727 let mut i = 1;
728 loop {
729 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
730 if !existing_titles.contains(&new_title) {
731 break new_title;
732 }
733 i += 1;
734 }
735 };
736
737 let new_id = PromptId::new();
738 let body = prompt.body_editor.read(cx).text(cx);
739 let save = self
740 .store
741 .save(new_id, Some(title.into()), false, body.into());
742 self.picker
743 .update(cx, |picker, cx| picker.refresh(window, cx));
744 cx.spawn_in(window, async move |this, cx| {
745 save.await?;
746 this.update_in(cx, |prompt_library, window, cx| {
747 prompt_library.load_prompt(new_id, true, window, cx)
748 })
749 })
750 .detach_and_log_err(cx);
751 }
752 }
753
754 fn focus_active_prompt(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
755 if let Some(active_prompt) = self.active_prompt_id {
756 self.prompt_editors[&active_prompt]
757 .body_editor
758 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
759 cx.stop_propagation();
760 }
761 }
762
763 fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
764 self.picker
765 .update(cx, |picker, cx| picker.focus(window, cx));
766 }
767
768 pub fn inline_assist(
769 &mut self,
770 action: &InlineAssist,
771 window: &mut Window,
772 cx: &mut Context<Self>,
773 ) {
774 let Some(active_prompt_id) = self.active_prompt_id else {
775 cx.propagate();
776 return;
777 };
778
779 let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor;
780 let Some(ConfiguredModel { provider, .. }) =
781 LanguageModelRegistry::read_global(cx).inline_assistant_model()
782 else {
783 return;
784 };
785
786 let initial_prompt = action.prompt.clone();
787 if provider.is_authenticated(cx) {
788 self.inline_assist_delegate
789 .assist(prompt_editor, initial_prompt, window, cx);
790 } else {
791 for window in cx.windows() {
792 if let Some(workspace) = window.downcast::<Workspace>() {
793 let panel = workspace
794 .update(cx, |workspace, window, cx| {
795 window.activate_window();
796 self.inline_assist_delegate
797 .focus_assistant_panel(workspace, window, cx)
798 })
799 .ok();
800 if panel == Some(true) {
801 return;
802 }
803 }
804 }
805 }
806 }
807
808 fn move_down_from_title(
809 &mut self,
810 _: &editor::actions::MoveDown,
811 window: &mut Window,
812 cx: &mut Context<Self>,
813 ) {
814 if let Some(prompt_id) = self.active_prompt_id {
815 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
816 window.focus(&prompt_editor.body_editor.focus_handle(cx));
817 }
818 }
819 }
820
821 fn move_up_from_body(
822 &mut self,
823 _: &editor::actions::MoveUp,
824 window: &mut Window,
825 cx: &mut Context<Self>,
826 ) {
827 if let Some(prompt_id) = self.active_prompt_id {
828 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
829 window.focus(&prompt_editor.title_editor.focus_handle(cx));
830 }
831 }
832 }
833
834 fn handle_prompt_title_editor_event(
835 &mut self,
836 prompt_id: PromptId,
837 title_editor: &Entity<Editor>,
838 event: &EditorEvent,
839 window: &mut Window,
840 cx: &mut Context<Self>,
841 ) {
842 match event {
843 EditorEvent::BufferEdited => {
844 self.save_prompt(prompt_id, window, cx);
845 self.count_tokens(prompt_id, window, cx);
846 }
847 EditorEvent::Blurred => {
848 title_editor.update(cx, |title_editor, cx| {
849 title_editor.change_selections(None, window, cx, |selections| {
850 let cursor = selections.oldest_anchor().head();
851 selections.select_anchor_ranges([cursor..cursor]);
852 });
853 });
854 }
855 _ => {}
856 }
857 }
858
859 fn handle_prompt_body_editor_event(
860 &mut self,
861 prompt_id: PromptId,
862 body_editor: &Entity<Editor>,
863 event: &EditorEvent,
864 window: &mut Window,
865 cx: &mut Context<Self>,
866 ) {
867 match event {
868 EditorEvent::BufferEdited => {
869 self.save_prompt(prompt_id, window, cx);
870 self.count_tokens(prompt_id, window, cx);
871 }
872 EditorEvent::Blurred => {
873 body_editor.update(cx, |body_editor, cx| {
874 body_editor.change_selections(None, window, cx, |selections| {
875 let cursor = selections.oldest_anchor().head();
876 selections.select_anchor_ranges([cursor..cursor]);
877 });
878 });
879 }
880 _ => {}
881 }
882 }
883
884 fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
885 let Some(ConfiguredModel { model, .. }) =
886 LanguageModelRegistry::read_global(cx).default_model()
887 else {
888 return;
889 };
890 if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
891 let editor = &prompt.body_editor.read(cx);
892 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
893 let body = buffer.as_rope().clone();
894 prompt.pending_token_count = cx.spawn_in(window, async move |this, cx| {
895 async move {
896 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
897
898 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
899 let token_count = cx
900 .update(|_, cx| {
901 model.count_tokens(
902 LanguageModelRequest {
903 messages: vec![LanguageModelRequestMessage {
904 role: Role::System,
905 content: vec![body.to_string().into()],
906 cache: false,
907 }],
908 tools: Vec::new(),
909 stop: Vec::new(),
910 temperature: None,
911 },
912 cx,
913 )
914 })?
915 .await?;
916
917 this.update(cx, |this, cx| {
918 let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
919 prompt_editor.token_count = Some(token_count);
920 cx.notify();
921 })
922 }
923 .log_err()
924 .await
925 });
926 }
927 }
928
929 fn render_prompt_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
930 v_flex()
931 .id("prompt-list")
932 .capture_action(cx.listener(Self::focus_active_prompt))
933 .bg(cx.theme().colors().panel_background)
934 .h_full()
935 .px_1()
936 .w_1_3()
937 .overflow_x_hidden()
938 .child(
939 h_flex()
940 .p(DynamicSpacing::Base04.rems(cx))
941 .h_9()
942 .w_full()
943 .flex_none()
944 .justify_end()
945 .child(
946 IconButton::new("new-prompt", IconName::Plus)
947 .style(ButtonStyle::Transparent)
948 .shape(IconButtonShape::Square)
949 .tooltip(move |window, cx| {
950 Tooltip::for_action("New Prompt", &NewPrompt, window, cx)
951 })
952 .on_click(|_, window, cx| {
953 window.dispatch_action(Box::new(NewPrompt), cx);
954 }),
955 ),
956 )
957 .child(div().flex_grow().child(self.picker.clone()))
958 }
959
960 fn render_active_prompt(&mut self, cx: &mut Context<PromptLibrary>) -> gpui::Stateful<Div> {
961 div()
962 .w_2_3()
963 .h_full()
964 .id("prompt-editor")
965 .border_l_1()
966 .border_color(cx.theme().colors().border)
967 .bg(cx.theme().colors().editor_background)
968 .flex_none()
969 .min_w_64()
970 .children(self.active_prompt_id.and_then(|prompt_id| {
971 let prompt_metadata = self.store.metadata(prompt_id)?;
972 let prompt_editor = &self.prompt_editors[&prompt_id];
973 let focus_handle = prompt_editor.body_editor.focus_handle(cx);
974 let model = LanguageModelRegistry::read_global(cx)
975 .default_model()
976 .map(|default| default.model);
977 let settings = ThemeSettings::get_global(cx);
978
979 Some(
980 v_flex()
981 .id("prompt-editor-inner")
982 .size_full()
983 .relative()
984 .overflow_hidden()
985 .pl(DynamicSpacing::Base16.rems(cx))
986 .pt(DynamicSpacing::Base08.rems(cx))
987 .on_click(cx.listener(move |_, _, window, _| {
988 window.focus(&focus_handle);
989 }))
990 .child(
991 h_flex()
992 .group("active-editor-header")
993 .pr(DynamicSpacing::Base16.rems(cx))
994 .pt(DynamicSpacing::Base02.rems(cx))
995 .pb(DynamicSpacing::Base08.rems(cx))
996 .justify_between()
997 .child(
998 h_flex().gap_1().child(
999 div()
1000 .max_w_80()
1001 .on_action(cx.listener(Self::move_down_from_title))
1002 .border_1()
1003 .border_color(transparent_black())
1004 .rounded_sm()
1005 .group_hover("active-editor-header", |this| {
1006 this.border_color(
1007 cx.theme().colors().border_variant,
1008 )
1009 })
1010 .child(EditorElement::new(
1011 &prompt_editor.title_editor,
1012 EditorStyle {
1013 background: cx.theme().system().transparent,
1014 local_player: cx.theme().players().local(),
1015 text: TextStyle {
1016 color: cx
1017 .theme()
1018 .colors()
1019 .editor_foreground,
1020 font_family: settings
1021 .ui_font
1022 .family
1023 .clone(),
1024 font_features: settings
1025 .ui_font
1026 .features
1027 .clone(),
1028 font_size: HeadlineSize::Large
1029 .rems()
1030 .into(),
1031 font_weight: settings.ui_font.weight,
1032 line_height: relative(
1033 settings.buffer_line_height.value(),
1034 ),
1035 ..Default::default()
1036 },
1037 scrollbar_width: Pixels::ZERO,
1038 syntax: cx.theme().syntax().clone(),
1039 status: cx.theme().status().clone(),
1040 inlay_hints_style:
1041 editor::make_inlay_hints_style(cx),
1042 inline_completion_styles:
1043 editor::make_suggestion_styles(cx),
1044 ..EditorStyle::default()
1045 },
1046 )),
1047 ),
1048 )
1049 .child(
1050 h_flex()
1051 .h_full()
1052 .child(
1053 h_flex()
1054 .h_full()
1055 .gap(DynamicSpacing::Base16.rems(cx))
1056 .child(div()),
1057 )
1058 .child(
1059 h_flex()
1060 .h_full()
1061 .gap(DynamicSpacing::Base16.rems(cx))
1062 .children(prompt_editor.token_count.map(
1063 |token_count| {
1064 let token_count: SharedString =
1065 token_count.to_string().into();
1066 let label_token_count: SharedString =
1067 token_count.to_string().into();
1068
1069 h_flex()
1070 .id("token_count")
1071 .tooltip(move |window, cx| {
1072 let token_count =
1073 token_count.clone();
1074
1075 Tooltip::with_meta(
1076 format!(
1077 "{} tokens",
1078 token_count.clone()
1079 ),
1080 None,
1081 format!(
1082 "Model: {}",
1083 model
1084 .as_ref()
1085 .map(|model| model
1086 .name()
1087 .0)
1088 .unwrap_or_default()
1089 ),
1090 window,
1091 cx,
1092 )
1093 })
1094 .child(
1095 Label::new(format!(
1096 "{} tokens",
1097 label_token_count.clone()
1098 ))
1099 .color(Color::Muted),
1100 )
1101 },
1102 ))
1103 .child(if prompt_id.is_built_in() {
1104 div()
1105 .id("built-in-prompt")
1106 .child(
1107 Icon::new(IconName::FileLock)
1108 .color(Color::Muted),
1109 )
1110 .tooltip(move |window, cx| {
1111 Tooltip::with_meta(
1112 "Built-in prompt",
1113 None,
1114 BUILT_IN_TOOLTIP_TEXT,
1115 window,
1116 cx,
1117 )
1118 })
1119 .into_any()
1120 } else {
1121 IconButton::new(
1122 "delete-prompt",
1123 IconName::Trash,
1124 )
1125 .size(ButtonSize::Large)
1126 .style(ButtonStyle::Transparent)
1127 .shape(IconButtonShape::Square)
1128 .size(ButtonSize::Large)
1129 .tooltip(move |window, cx| {
1130 Tooltip::for_action(
1131 "Delete Prompt",
1132 &DeletePrompt,
1133 window,
1134 cx,
1135 )
1136 })
1137 .on_click(|_, window, cx| {
1138 window.dispatch_action(
1139 Box::new(DeletePrompt),
1140 cx,
1141 );
1142 })
1143 .into_any_element()
1144 })
1145 .child(
1146 IconButton::new(
1147 "duplicate-prompt",
1148 IconName::BookCopy,
1149 )
1150 .size(ButtonSize::Large)
1151 .style(ButtonStyle::Transparent)
1152 .shape(IconButtonShape::Square)
1153 .size(ButtonSize::Large)
1154 .tooltip(move |window, cx| {
1155 Tooltip::for_action(
1156 "Duplicate Prompt",
1157 &DuplicatePrompt,
1158 window,
1159 cx,
1160 )
1161 })
1162 .on_click(|_, window, cx| {
1163 window.dispatch_action(
1164 Box::new(DuplicatePrompt),
1165 cx,
1166 );
1167 }),
1168 )
1169 .child(
1170 IconButton::new(
1171 "toggle-default-prompt",
1172 IconName::Sparkle,
1173 )
1174 .style(ButtonStyle::Transparent)
1175 .toggle_state(prompt_metadata.default)
1176 .selected_icon(IconName::SparkleFilled)
1177 .icon_color(if prompt_metadata.default {
1178 Color::Accent
1179 } else {
1180 Color::Muted
1181 })
1182 .shape(IconButtonShape::Square)
1183 .size(ButtonSize::Large)
1184 .tooltip(Tooltip::text(
1185 if prompt_metadata.default {
1186 "Remove from Default Prompt"
1187 } else {
1188 "Add to Default Prompt"
1189 },
1190 ))
1191 .on_click(|_, window, cx| {
1192 window.dispatch_action(
1193 Box::new(ToggleDefaultPrompt),
1194 cx,
1195 );
1196 }),
1197 ),
1198 ),
1199 ),
1200 )
1201 .child(
1202 div()
1203 .on_action(cx.listener(Self::focus_picker))
1204 .on_action(cx.listener(Self::inline_assist))
1205 .on_action(cx.listener(Self::move_up_from_body))
1206 .flex_grow()
1207 .h_full()
1208 .child(prompt_editor.body_editor.clone()),
1209 ),
1210 )
1211 }))
1212 }
1213}
1214
1215impl Render for PromptLibrary {
1216 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1217 let ui_font = theme::setup_ui_font(window, cx);
1218 let theme = cx.theme().clone();
1219
1220 h_flex()
1221 .id("prompt-manager")
1222 .key_context("PromptLibrary")
1223 .on_action(cx.listener(|this, &NewPrompt, window, cx| this.new_prompt(window, cx)))
1224 .on_action(
1225 cx.listener(|this, &DeletePrompt, window, cx| {
1226 this.delete_active_prompt(window, cx)
1227 }),
1228 )
1229 .on_action(cx.listener(|this, &DuplicatePrompt, window, cx| {
1230 this.duplicate_active_prompt(window, cx)
1231 }))
1232 .on_action(cx.listener(|this, &ToggleDefaultPrompt, window, cx| {
1233 this.toggle_default_for_active_prompt(window, cx)
1234 }))
1235 .size_full()
1236 .overflow_hidden()
1237 .font(ui_font)
1238 .text_color(theme.colors().text)
1239 .child(self.render_prompt_list(cx))
1240 .map(|el| {
1241 if self.store.prompt_count() == 0 {
1242 el.child(
1243 v_flex()
1244 .w_2_3()
1245 .h_full()
1246 .items_center()
1247 .justify_center()
1248 .gap_4()
1249 .bg(cx.theme().colors().editor_background)
1250 .child(
1251 h_flex()
1252 .gap_2()
1253 .child(
1254 Icon::new(IconName::Book)
1255 .size(IconSize::Medium)
1256 .color(Color::Muted),
1257 )
1258 .child(
1259 Label::new("No prompts yet")
1260 .size(LabelSize::Large)
1261 .color(Color::Muted),
1262 ),
1263 )
1264 .child(
1265 h_flex()
1266 .child(h_flex())
1267 .child(
1268 v_flex()
1269 .gap_1()
1270 .child(Label::new("Create your first prompt:"))
1271 .child(
1272 Button::new("create-prompt", "New Prompt")
1273 .full_width()
1274 .key_binding(KeyBinding::for_action(
1275 &NewPrompt, window, cx,
1276 ))
1277 .on_click(|_, window, cx| {
1278 window.dispatch_action(
1279 NewPrompt.boxed_clone(),
1280 cx,
1281 )
1282 }),
1283 ),
1284 )
1285 .child(h_flex()),
1286 ),
1287 )
1288 } else {
1289 el.child(self.render_active_prompt(cx))
1290 }
1291 })
1292 }
1293}