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