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: Entity<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: Entity<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.read(cx).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.read(cx).search(query, cx);
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: Entity<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.read(cx).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.update(cx, |store, cx| {
410 store.save(prompt_id, None, false, "".into(), cx)
411 });
412 self.picker
413 .update(cx, |picker, cx| picker.refresh(window, cx));
414 cx.spawn_in(window, async move |this, cx| {
415 save.await?;
416 this.update_in(cx, |this, window, cx| {
417 this.load_prompt(prompt_id, true, window, cx)
418 })
419 })
420 .detach_and_log_err(cx);
421 }
422
423 pub fn save_prompt(
424 &mut self,
425 prompt_id: PromptId,
426 window: &mut Window,
427 cx: &mut Context<Self>,
428 ) {
429 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
430
431 if prompt_id.is_built_in() {
432 return;
433 }
434
435 let prompt_metadata = self.store.read(cx).metadata(prompt_id).unwrap();
436 let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
437 let title = prompt_editor.title_editor.read(cx).text(cx);
438 let body = prompt_editor.body_editor.update(cx, |editor, cx| {
439 editor
440 .buffer()
441 .read(cx)
442 .as_singleton()
443 .unwrap()
444 .read(cx)
445 .as_rope()
446 .clone()
447 });
448
449 let store = self.store.clone();
450 let executor = cx.background_executor().clone();
451
452 prompt_editor.next_title_and_body_to_save = Some((title, body));
453 if prompt_editor.pending_save.is_none() {
454 prompt_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
455 async move {
456 loop {
457 let title_and_body = this.update(cx, |this, _| {
458 this.prompt_editors
459 .get_mut(&prompt_id)?
460 .next_title_and_body_to_save
461 .take()
462 })?;
463
464 if let Some((title, body)) = title_and_body {
465 let title = if title.trim().is_empty() {
466 None
467 } else {
468 Some(SharedString::from(title))
469 };
470 cx.update(|_window, cx| {
471 store.update(cx, |store, cx| {
472 store.save(prompt_id, title, prompt_metadata.default, body, cx)
473 })
474 })?
475 .await
476 .log_err();
477 this.update_in(cx, |this, window, cx| {
478 this.picker
479 .update(cx, |picker, cx| picker.refresh(window, cx));
480 cx.notify();
481 })?;
482
483 executor.timer(SAVE_THROTTLE).await;
484 } else {
485 break;
486 }
487 }
488
489 this.update(cx, |this, _cx| {
490 if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
491 prompt_editor.pending_save = None;
492 }
493 })
494 }
495 .log_err()
496 .await
497 }));
498 }
499 }
500
501 pub fn delete_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
502 if let Some(active_prompt_id) = self.active_prompt_id {
503 self.delete_prompt(active_prompt_id, window, cx);
504 }
505 }
506
507 pub fn duplicate_active_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
508 if let Some(active_prompt_id) = self.active_prompt_id {
509 self.duplicate_prompt(active_prompt_id, window, cx);
510 }
511 }
512
513 pub fn toggle_default_for_active_prompt(
514 &mut self,
515 window: &mut Window,
516 cx: &mut Context<Self>,
517 ) {
518 if let Some(active_prompt_id) = self.active_prompt_id {
519 self.toggle_default_for_prompt(active_prompt_id, window, cx);
520 }
521 }
522
523 pub fn toggle_default_for_prompt(
524 &mut self,
525 prompt_id: PromptId,
526 window: &mut Window,
527 cx: &mut Context<Self>,
528 ) {
529 self.store.update(cx, move |store, cx| {
530 if let Some(prompt_metadata) = store.metadata(prompt_id) {
531 store
532 .save_metadata(
533 prompt_id,
534 prompt_metadata.title,
535 !prompt_metadata.default,
536 cx,
537 )
538 .detach_and_log_err(cx);
539 }
540 });
541 self.picker
542 .update(cx, |picker, cx| picker.refresh(window, cx));
543 cx.notify();
544 }
545
546 pub fn load_prompt(
547 &mut self,
548 prompt_id: PromptId,
549 focus: bool,
550 window: &mut Window,
551 cx: &mut Context<Self>,
552 ) {
553 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
554 if focus {
555 prompt_editor
556 .body_editor
557 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
558 }
559 self.set_active_prompt(Some(prompt_id), window, cx);
560 } else if let Some(prompt_metadata) = self.store.read(cx).metadata(prompt_id) {
561 let language_registry = self.language_registry.clone();
562 let prompt = self.store.read(cx).load(prompt_id, cx);
563 let make_completion_provider = self.make_completion_provider.clone();
564 self.pending_load = cx.spawn_in(window, async move |this, cx| {
565 let prompt = prompt.await;
566 let markdown = language_registry.language_for_name("Markdown").await;
567 this.update_in(cx, |this, window, cx| match prompt {
568 Ok(prompt) => {
569 let title_editor = cx.new(|cx| {
570 let mut editor = Editor::auto_width(window, cx);
571 editor.set_placeholder_text("Untitled", cx);
572 editor.set_text(prompt_metadata.title.unwrap_or_default(), window, cx);
573 if prompt_id.is_built_in() {
574 editor.set_read_only(true);
575 editor.set_show_edit_predictions(Some(false), window, cx);
576 }
577 editor
578 });
579 let body_editor = cx.new(|cx| {
580 let buffer = cx.new(|cx| {
581 let mut buffer = Buffer::local(prompt, cx);
582 buffer.set_language(markdown.log_err(), cx);
583 buffer.set_language_registry(language_registry);
584 buffer
585 });
586
587 let mut editor = Editor::for_buffer(buffer, None, window, cx);
588 if prompt_id.is_built_in() {
589 editor.set_read_only(true);
590 editor.set_show_edit_predictions(Some(false), window, cx);
591 }
592 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
593 editor.set_show_gutter(false, cx);
594 editor.set_show_wrap_guides(false, cx);
595 editor.set_show_indent_guides(false, cx);
596 editor.set_use_modal_editing(false);
597 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
598 editor.set_completion_provider(Some(make_completion_provider()));
599 if focus {
600 window.focus(&editor.focus_handle(cx));
601 }
602 editor
603 });
604 let _subscriptions = vec![
605 cx.subscribe_in(
606 &title_editor,
607 window,
608 move |this, editor, event, window, cx| {
609 this.handle_prompt_title_editor_event(
610 prompt_id, editor, event, window, cx,
611 )
612 },
613 ),
614 cx.subscribe_in(
615 &body_editor,
616 window,
617 move |this, editor, event, window, cx| {
618 this.handle_prompt_body_editor_event(
619 prompt_id, editor, event, window, cx,
620 )
621 },
622 ),
623 ];
624 this.prompt_editors.insert(
625 prompt_id,
626 PromptEditor {
627 title_editor,
628 body_editor,
629 next_title_and_body_to_save: None,
630 pending_save: None,
631 token_count: None,
632 pending_token_count: Task::ready(None),
633 _subscriptions,
634 },
635 );
636 this.set_active_prompt(Some(prompt_id), window, cx);
637 this.count_tokens(prompt_id, window, cx);
638 }
639 Err(error) => {
640 // TODO: we should show the error in the UI.
641 log::error!("error while loading prompt: {:?}", error);
642 }
643 })
644 .ok();
645 });
646 }
647 }
648
649 fn set_active_prompt(
650 &mut self,
651 prompt_id: Option<PromptId>,
652 window: &mut Window,
653 cx: &mut Context<Self>,
654 ) {
655 self.active_prompt_id = prompt_id;
656 self.picker.update(cx, |picker, cx| {
657 if let Some(prompt_id) = prompt_id {
658 if picker
659 .delegate
660 .matches
661 .get(picker.delegate.selected_index())
662 .map_or(true, |old_selected_prompt| {
663 old_selected_prompt.id != prompt_id
664 })
665 {
666 if let Some(ix) = picker
667 .delegate
668 .matches
669 .iter()
670 .position(|mat| mat.id == prompt_id)
671 {
672 picker.set_selected_index(ix, None, true, window, cx);
673 }
674 }
675 } else {
676 picker.focus(window, cx);
677 }
678 });
679 cx.notify();
680 }
681
682 pub fn delete_prompt(
683 &mut self,
684 prompt_id: PromptId,
685 window: &mut Window,
686 cx: &mut Context<Self>,
687 ) {
688 if let Some(metadata) = self.store.read(cx).metadata(prompt_id) {
689 let confirmation = window.prompt(
690 PromptLevel::Warning,
691 &format!(
692 "Are you sure you want to delete {}",
693 metadata.title.unwrap_or("Untitled".into())
694 ),
695 None,
696 &["Delete", "Cancel"],
697 cx,
698 );
699
700 cx.spawn_in(window, async move |this, cx| {
701 if confirmation.await.ok() == Some(0) {
702 this.update_in(cx, |this, window, cx| {
703 if this.active_prompt_id == Some(prompt_id) {
704 this.set_active_prompt(None, window, cx);
705 }
706 this.prompt_editors.remove(&prompt_id);
707 this.store
708 .update(cx, |store, cx| store.delete(prompt_id, cx))
709 .detach_and_log_err(cx);
710 this.picker
711 .update(cx, |picker, cx| picker.refresh(window, cx));
712 cx.notify();
713 })?;
714 }
715 anyhow::Ok(())
716 })
717 .detach_and_log_err(cx);
718 }
719 }
720
721 pub fn duplicate_prompt(
722 &mut self,
723 prompt_id: PromptId,
724 window: &mut Window,
725 cx: &mut Context<Self>,
726 ) {
727 if let Some(prompt) = self.prompt_editors.get(&prompt_id) {
728 const DUPLICATE_SUFFIX: &str = " copy";
729 let title_to_duplicate = prompt.title_editor.read(cx).text(cx);
730 let existing_titles = self
731 .prompt_editors
732 .iter()
733 .filter(|&(&id, _)| id != prompt_id)
734 .map(|(_, prompt_editor)| prompt_editor.title_editor.read(cx).text(cx))
735 .filter(|title| title.starts_with(&title_to_duplicate))
736 .collect::<HashSet<_>>();
737
738 let title = if existing_titles.is_empty() {
739 title_to_duplicate + DUPLICATE_SUFFIX
740 } else {
741 let mut i = 1;
742 loop {
743 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
744 if !existing_titles.contains(&new_title) {
745 break new_title;
746 }
747 i += 1;
748 }
749 };
750
751 let new_id = PromptId::new();
752 let body = prompt.body_editor.read(cx).text(cx);
753 let save = self.store.update(cx, |store, cx| {
754 store.save(new_id, Some(title.into()), false, body.into(), cx)
755 });
756 self.picker
757 .update(cx, |picker, cx| picker.refresh(window, cx));
758 cx.spawn_in(window, async move |this, cx| {
759 save.await?;
760 this.update_in(cx, |prompt_library, window, cx| {
761 prompt_library.load_prompt(new_id, true, window, cx)
762 })
763 })
764 .detach_and_log_err(cx);
765 }
766 }
767
768 fn focus_active_prompt(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
769 if let Some(active_prompt) = self.active_prompt_id {
770 self.prompt_editors[&active_prompt]
771 .body_editor
772 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
773 cx.stop_propagation();
774 }
775 }
776
777 fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
778 self.picker
779 .update(cx, |picker, cx| picker.focus(window, cx));
780 }
781
782 pub fn inline_assist(
783 &mut self,
784 action: &InlineAssist,
785 window: &mut Window,
786 cx: &mut Context<Self>,
787 ) {
788 let Some(active_prompt_id) = self.active_prompt_id else {
789 cx.propagate();
790 return;
791 };
792
793 let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor;
794 let Some(ConfiguredModel { provider, .. }) =
795 LanguageModelRegistry::read_global(cx).inline_assistant_model()
796 else {
797 return;
798 };
799
800 let initial_prompt = action.prompt.clone();
801 if provider.is_authenticated(cx) {
802 self.inline_assist_delegate
803 .assist(prompt_editor, initial_prompt, window, cx);
804 } else {
805 for window in cx.windows() {
806 if let Some(workspace) = window.downcast::<Workspace>() {
807 let panel = workspace
808 .update(cx, |workspace, window, cx| {
809 window.activate_window();
810 self.inline_assist_delegate
811 .focus_assistant_panel(workspace, window, cx)
812 })
813 .ok();
814 if panel == Some(true) {
815 return;
816 }
817 }
818 }
819 }
820 }
821
822 fn move_down_from_title(
823 &mut self,
824 _: &editor::actions::MoveDown,
825 window: &mut Window,
826 cx: &mut Context<Self>,
827 ) {
828 if let Some(prompt_id) = self.active_prompt_id {
829 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
830 window.focus(&prompt_editor.body_editor.focus_handle(cx));
831 }
832 }
833 }
834
835 fn move_up_from_body(
836 &mut self,
837 _: &editor::actions::MoveUp,
838 window: &mut Window,
839 cx: &mut Context<Self>,
840 ) {
841 if let Some(prompt_id) = self.active_prompt_id {
842 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
843 window.focus(&prompt_editor.title_editor.focus_handle(cx));
844 }
845 }
846 }
847
848 fn handle_prompt_title_editor_event(
849 &mut self,
850 prompt_id: PromptId,
851 title_editor: &Entity<Editor>,
852 event: &EditorEvent,
853 window: &mut Window,
854 cx: &mut Context<Self>,
855 ) {
856 match event {
857 EditorEvent::BufferEdited => {
858 self.save_prompt(prompt_id, window, cx);
859 self.count_tokens(prompt_id, window, cx);
860 }
861 EditorEvent::Blurred => {
862 title_editor.update(cx, |title_editor, cx| {
863 title_editor.change_selections(None, window, cx, |selections| {
864 let cursor = selections.oldest_anchor().head();
865 selections.select_anchor_ranges([cursor..cursor]);
866 });
867 });
868 }
869 _ => {}
870 }
871 }
872
873 fn handle_prompt_body_editor_event(
874 &mut self,
875 prompt_id: PromptId,
876 body_editor: &Entity<Editor>,
877 event: &EditorEvent,
878 window: &mut Window,
879 cx: &mut Context<Self>,
880 ) {
881 match event {
882 EditorEvent::BufferEdited => {
883 self.save_prompt(prompt_id, window, cx);
884 self.count_tokens(prompt_id, window, cx);
885 }
886 EditorEvent::Blurred => {
887 body_editor.update(cx, |body_editor, cx| {
888 body_editor.change_selections(None, window, cx, |selections| {
889 let cursor = selections.oldest_anchor().head();
890 selections.select_anchor_ranges([cursor..cursor]);
891 });
892 });
893 }
894 _ => {}
895 }
896 }
897
898 fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
899 let Some(ConfiguredModel { model, .. }) =
900 LanguageModelRegistry::read_global(cx).default_model()
901 else {
902 return;
903 };
904 if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
905 let editor = &prompt.body_editor.read(cx);
906 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
907 let body = buffer.as_rope().clone();
908 prompt.pending_token_count = cx.spawn_in(window, async move |this, cx| {
909 async move {
910 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
911
912 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
913 let token_count = cx
914 .update(|_, cx| {
915 model.count_tokens(
916 LanguageModelRequest {
917 messages: vec![LanguageModelRequestMessage {
918 role: Role::System,
919 content: vec![body.to_string().into()],
920 cache: false,
921 }],
922 tools: Vec::new(),
923 stop: Vec::new(),
924 temperature: None,
925 },
926 cx,
927 )
928 })?
929 .await?;
930
931 this.update(cx, |this, cx| {
932 let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
933 prompt_editor.token_count = Some(token_count);
934 cx.notify();
935 })
936 }
937 .log_err()
938 .await
939 });
940 }
941 }
942
943 fn render_prompt_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
944 v_flex()
945 .id("prompt-list")
946 .capture_action(cx.listener(Self::focus_active_prompt))
947 .bg(cx.theme().colors().panel_background)
948 .h_full()
949 .px_1()
950 .w_1_3()
951 .overflow_x_hidden()
952 .child(
953 h_flex()
954 .p(DynamicSpacing::Base04.rems(cx))
955 .h_9()
956 .w_full()
957 .flex_none()
958 .justify_end()
959 .child(
960 IconButton::new("new-prompt", IconName::Plus)
961 .style(ButtonStyle::Transparent)
962 .shape(IconButtonShape::Square)
963 .tooltip(move |window, cx| {
964 Tooltip::for_action("New Prompt", &NewPrompt, window, cx)
965 })
966 .on_click(|_, window, cx| {
967 window.dispatch_action(Box::new(NewPrompt), cx);
968 }),
969 ),
970 )
971 .child(div().flex_grow().child(self.picker.clone()))
972 }
973
974 fn render_active_prompt(&mut self, cx: &mut Context<PromptLibrary>) -> gpui::Stateful<Div> {
975 div()
976 .w_2_3()
977 .h_full()
978 .id("prompt-editor")
979 .border_l_1()
980 .border_color(cx.theme().colors().border)
981 .bg(cx.theme().colors().editor_background)
982 .flex_none()
983 .min_w_64()
984 .children(self.active_prompt_id.and_then(|prompt_id| {
985 let prompt_metadata = self.store.read(cx).metadata(prompt_id)?;
986 let prompt_editor = &self.prompt_editors[&prompt_id];
987 let focus_handle = prompt_editor.body_editor.focus_handle(cx);
988 let model = LanguageModelRegistry::read_global(cx)
989 .default_model()
990 .map(|default| default.model);
991 let settings = ThemeSettings::get_global(cx);
992
993 Some(
994 v_flex()
995 .id("prompt-editor-inner")
996 .size_full()
997 .relative()
998 .overflow_hidden()
999 .pl(DynamicSpacing::Base16.rems(cx))
1000 .pt(DynamicSpacing::Base08.rems(cx))
1001 .on_click(cx.listener(move |_, _, window, _| {
1002 window.focus(&focus_handle);
1003 }))
1004 .child(
1005 h_flex()
1006 .group("active-editor-header")
1007 .pr(DynamicSpacing::Base16.rems(cx))
1008 .pt(DynamicSpacing::Base02.rems(cx))
1009 .pb(DynamicSpacing::Base08.rems(cx))
1010 .justify_between()
1011 .child(
1012 h_flex().gap_1().child(
1013 div()
1014 .max_w_80()
1015 .on_action(cx.listener(Self::move_down_from_title))
1016 .border_1()
1017 .border_color(transparent_black())
1018 .rounded_sm()
1019 .group_hover("active-editor-header", |this| {
1020 this.border_color(
1021 cx.theme().colors().border_variant,
1022 )
1023 })
1024 .child(EditorElement::new(
1025 &prompt_editor.title_editor,
1026 EditorStyle {
1027 background: cx.theme().system().transparent,
1028 local_player: cx.theme().players().local(),
1029 text: TextStyle {
1030 color: cx
1031 .theme()
1032 .colors()
1033 .editor_foreground,
1034 font_family: settings
1035 .ui_font
1036 .family
1037 .clone(),
1038 font_features: settings
1039 .ui_font
1040 .features
1041 .clone(),
1042 font_size: HeadlineSize::Large
1043 .rems()
1044 .into(),
1045 font_weight: settings.ui_font.weight,
1046 line_height: relative(
1047 settings.buffer_line_height.value(),
1048 ),
1049 ..Default::default()
1050 },
1051 scrollbar_width: Pixels::ZERO,
1052 syntax: cx.theme().syntax().clone(),
1053 status: cx.theme().status().clone(),
1054 inlay_hints_style:
1055 editor::make_inlay_hints_style(cx),
1056 inline_completion_styles:
1057 editor::make_suggestion_styles(cx),
1058 ..EditorStyle::default()
1059 },
1060 )),
1061 ),
1062 )
1063 .child(
1064 h_flex()
1065 .h_full()
1066 .child(
1067 h_flex()
1068 .h_full()
1069 .gap(DynamicSpacing::Base16.rems(cx))
1070 .child(div()),
1071 )
1072 .child(
1073 h_flex()
1074 .h_full()
1075 .gap(DynamicSpacing::Base16.rems(cx))
1076 .children(prompt_editor.token_count.map(
1077 |token_count| {
1078 let token_count: SharedString =
1079 token_count.to_string().into();
1080 let label_token_count: SharedString =
1081 token_count.to_string().into();
1082
1083 h_flex()
1084 .id("token_count")
1085 .tooltip(move |window, cx| {
1086 let token_count =
1087 token_count.clone();
1088
1089 Tooltip::with_meta(
1090 format!(
1091 "{} tokens",
1092 token_count.clone()
1093 ),
1094 None,
1095 format!(
1096 "Model: {}",
1097 model
1098 .as_ref()
1099 .map(|model| model
1100 .name()
1101 .0)
1102 .unwrap_or_default()
1103 ),
1104 window,
1105 cx,
1106 )
1107 })
1108 .child(
1109 Label::new(format!(
1110 "{} tokens",
1111 label_token_count.clone()
1112 ))
1113 .color(Color::Muted),
1114 )
1115 },
1116 ))
1117 .child(if prompt_id.is_built_in() {
1118 div()
1119 .id("built-in-prompt")
1120 .child(
1121 Icon::new(IconName::FileLock)
1122 .color(Color::Muted),
1123 )
1124 .tooltip(move |window, cx| {
1125 Tooltip::with_meta(
1126 "Built-in prompt",
1127 None,
1128 BUILT_IN_TOOLTIP_TEXT,
1129 window,
1130 cx,
1131 )
1132 })
1133 .into_any()
1134 } else {
1135 IconButton::new(
1136 "delete-prompt",
1137 IconName::Trash,
1138 )
1139 .size(ButtonSize::Large)
1140 .style(ButtonStyle::Transparent)
1141 .shape(IconButtonShape::Square)
1142 .size(ButtonSize::Large)
1143 .tooltip(move |window, cx| {
1144 Tooltip::for_action(
1145 "Delete Prompt",
1146 &DeletePrompt,
1147 window,
1148 cx,
1149 )
1150 })
1151 .on_click(|_, window, cx| {
1152 window.dispatch_action(
1153 Box::new(DeletePrompt),
1154 cx,
1155 );
1156 })
1157 .into_any_element()
1158 })
1159 .child(
1160 IconButton::new(
1161 "duplicate-prompt",
1162 IconName::BookCopy,
1163 )
1164 .size(ButtonSize::Large)
1165 .style(ButtonStyle::Transparent)
1166 .shape(IconButtonShape::Square)
1167 .size(ButtonSize::Large)
1168 .tooltip(move |window, cx| {
1169 Tooltip::for_action(
1170 "Duplicate Prompt",
1171 &DuplicatePrompt,
1172 window,
1173 cx,
1174 )
1175 })
1176 .on_click(|_, window, cx| {
1177 window.dispatch_action(
1178 Box::new(DuplicatePrompt),
1179 cx,
1180 );
1181 }),
1182 )
1183 .child(
1184 IconButton::new(
1185 "toggle-default-prompt",
1186 IconName::Sparkle,
1187 )
1188 .style(ButtonStyle::Transparent)
1189 .toggle_state(prompt_metadata.default)
1190 .selected_icon(IconName::SparkleFilled)
1191 .icon_color(if prompt_metadata.default {
1192 Color::Accent
1193 } else {
1194 Color::Muted
1195 })
1196 .shape(IconButtonShape::Square)
1197 .size(ButtonSize::Large)
1198 .tooltip(Tooltip::text(
1199 if prompt_metadata.default {
1200 "Remove from Default Prompt"
1201 } else {
1202 "Add to Default Prompt"
1203 },
1204 ))
1205 .on_click(|_, window, cx| {
1206 window.dispatch_action(
1207 Box::new(ToggleDefaultPrompt),
1208 cx,
1209 );
1210 }),
1211 ),
1212 ),
1213 ),
1214 )
1215 .child(
1216 div()
1217 .on_action(cx.listener(Self::focus_picker))
1218 .on_action(cx.listener(Self::inline_assist))
1219 .on_action(cx.listener(Self::move_up_from_body))
1220 .flex_grow()
1221 .h_full()
1222 .child(prompt_editor.body_editor.clone()),
1223 ),
1224 )
1225 }))
1226 }
1227}
1228
1229impl Render for PromptLibrary {
1230 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1231 let ui_font = theme::setup_ui_font(window, cx);
1232 let theme = cx.theme().clone();
1233
1234 h_flex()
1235 .id("prompt-manager")
1236 .key_context("PromptLibrary")
1237 .on_action(cx.listener(|this, &NewPrompt, window, cx| this.new_prompt(window, cx)))
1238 .on_action(
1239 cx.listener(|this, &DeletePrompt, window, cx| {
1240 this.delete_active_prompt(window, cx)
1241 }),
1242 )
1243 .on_action(cx.listener(|this, &DuplicatePrompt, window, cx| {
1244 this.duplicate_active_prompt(window, cx)
1245 }))
1246 .on_action(cx.listener(|this, &ToggleDefaultPrompt, window, cx| {
1247 this.toggle_default_for_active_prompt(window, cx)
1248 }))
1249 .size_full()
1250 .overflow_hidden()
1251 .font(ui_font)
1252 .text_color(theme.colors().text)
1253 .child(self.render_prompt_list(cx))
1254 .map(|el| {
1255 if self.store.read(cx).prompt_count() == 0 {
1256 el.child(
1257 v_flex()
1258 .w_2_3()
1259 .h_full()
1260 .items_center()
1261 .justify_center()
1262 .gap_4()
1263 .bg(cx.theme().colors().editor_background)
1264 .child(
1265 h_flex()
1266 .gap_2()
1267 .child(
1268 Icon::new(IconName::Book)
1269 .size(IconSize::Medium)
1270 .color(Color::Muted),
1271 )
1272 .child(
1273 Label::new("No prompts yet")
1274 .size(LabelSize::Large)
1275 .color(Color::Muted),
1276 ),
1277 )
1278 .child(
1279 h_flex()
1280 .child(h_flex())
1281 .child(
1282 v_flex()
1283 .gap_1()
1284 .child(Label::new("Create your first prompt:"))
1285 .child(
1286 Button::new("create-prompt", "New Prompt")
1287 .full_width()
1288 .key_binding(KeyBinding::for_action(
1289 &NewPrompt, window, cx,
1290 ))
1291 .on_click(|_, window, cx| {
1292 window.dispatch_action(
1293 NewPrompt.boxed_clone(),
1294 cx,
1295 )
1296 }),
1297 ),
1298 )
1299 .child(h_flex()),
1300 ),
1301 )
1302 } else {
1303 el.child(self.render_active_prompt(cx))
1304 }
1305 })
1306 }
1307}