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