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