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