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