prompt_library.rs

   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 {}