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