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 intent: None,
926 mode: None,
927 messages: vec![LanguageModelRequestMessage {
928 role: Role::System,
929 content: vec![body.to_string().into()],
930 cache: false,
931 }],
932 tools: Vec::new(),
933 tool_choice: None,
934 stop: Vec::new(),
935 temperature: None,
936 },
937 cx,
938 )
939 })?
940 .await?;
941
942 this.update(cx, |this, cx| {
943 let rule_editor = this.rule_editors.get_mut(&prompt_id).unwrap();
944 rule_editor.token_count = Some(token_count);
945 cx.notify();
946 })
947 }
948 .log_err()
949 .await
950 });
951 }
952 }
953
954 fn render_rule_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
955 v_flex()
956 .id("rule-list")
957 .capture_action(cx.listener(Self::focus_active_rule))
958 .bg(cx.theme().colors().panel_background)
959 .h_full()
960 .px_1()
961 .w_1_3()
962 .overflow_x_hidden()
963 .child(
964 h_flex()
965 .p(DynamicSpacing::Base04.rems(cx))
966 .h_9()
967 .w_full()
968 .flex_none()
969 .justify_end()
970 .child(
971 IconButton::new("new-rule", IconName::Plus)
972 .style(ButtonStyle::Transparent)
973 .shape(IconButtonShape::Square)
974 .tooltip(move |window, cx| {
975 Tooltip::for_action("New Rule", &NewRule, window, cx)
976 })
977 .on_click(|_, window, cx| {
978 window.dispatch_action(Box::new(NewRule), cx);
979 }),
980 ),
981 )
982 .child(div().flex_grow().child(self.picker.clone()))
983 }
984
985 fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
986 div()
987 .w_2_3()
988 .h_full()
989 .id("rule-editor")
990 .border_l_1()
991 .border_color(cx.theme().colors().border)
992 .bg(cx.theme().colors().editor_background)
993 .flex_none()
994 .min_w_64()
995 .children(self.active_rule_id.and_then(|prompt_id| {
996 let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
997 let rule_editor = &self.rule_editors[&prompt_id];
998 let focus_handle = rule_editor.body_editor.focus_handle(cx);
999 let model = LanguageModelRegistry::read_global(cx)
1000 .default_model()
1001 .map(|default| default.model);
1002 let settings = ThemeSettings::get_global(cx);
1003
1004 Some(
1005 v_flex()
1006 .id("rule-editor-inner")
1007 .size_full()
1008 .relative()
1009 .overflow_hidden()
1010 .pl(DynamicSpacing::Base16.rems(cx))
1011 .pt(DynamicSpacing::Base08.rems(cx))
1012 .on_click(cx.listener(move |_, _, window, _| {
1013 window.focus(&focus_handle);
1014 }))
1015 .child(
1016 h_flex()
1017 .group("active-editor-header")
1018 .pr(DynamicSpacing::Base16.rems(cx))
1019 .pt(DynamicSpacing::Base02.rems(cx))
1020 .pb(DynamicSpacing::Base08.rems(cx))
1021 .justify_between()
1022 .child(
1023 h_flex().gap_1().child(
1024 div()
1025 .max_w_80()
1026 .on_action(cx.listener(Self::move_down_from_title))
1027 .border_1()
1028 .border_color(transparent_black())
1029 .rounded_sm()
1030 .group_hover("active-editor-header", |this| {
1031 this.border_color(
1032 cx.theme().colors().border_variant,
1033 )
1034 })
1035 .child(EditorElement::new(
1036 &rule_editor.title_editor,
1037 EditorStyle {
1038 background: cx.theme().system().transparent,
1039 local_player: cx.theme().players().local(),
1040 text: TextStyle {
1041 color: cx
1042 .theme()
1043 .colors()
1044 .editor_foreground,
1045 font_family: settings
1046 .ui_font
1047 .family
1048 .clone(),
1049 font_features: settings
1050 .ui_font
1051 .features
1052 .clone(),
1053 font_size: HeadlineSize::Large
1054 .rems()
1055 .into(),
1056 font_weight: settings.ui_font.weight,
1057 line_height: relative(
1058 settings.buffer_line_height.value(),
1059 ),
1060 ..Default::default()
1061 },
1062 scrollbar_width: Pixels::ZERO,
1063 syntax: cx.theme().syntax().clone(),
1064 status: cx.theme().status().clone(),
1065 inlay_hints_style:
1066 editor::make_inlay_hints_style(cx),
1067 inline_completion_styles:
1068 editor::make_suggestion_styles(cx),
1069 ..EditorStyle::default()
1070 },
1071 )),
1072 ),
1073 )
1074 .child(
1075 h_flex()
1076 .h_full()
1077 .child(
1078 h_flex()
1079 .h_full()
1080 .gap(DynamicSpacing::Base16.rems(cx))
1081 .child(div()),
1082 )
1083 .child(
1084 h_flex()
1085 .h_full()
1086 .gap(DynamicSpacing::Base16.rems(cx))
1087 .children(rule_editor.token_count.map(
1088 |token_count| {
1089 let token_count: SharedString =
1090 token_count.to_string().into();
1091 let label_token_count: SharedString =
1092 token_count.to_string().into();
1093
1094 h_flex()
1095 .id("token_count")
1096 .tooltip(move |window, cx| {
1097 let token_count =
1098 token_count.clone();
1099
1100 Tooltip::with_meta(
1101 format!(
1102 "{} tokens",
1103 token_count.clone()
1104 ),
1105 None,
1106 format!(
1107 "Model: {}",
1108 model
1109 .as_ref()
1110 .map(|model| model
1111 .name()
1112 .0)
1113 .unwrap_or_default()
1114 ),
1115 window,
1116 cx,
1117 )
1118 })
1119 .child(
1120 Label::new(format!(
1121 "{} tokens",
1122 label_token_count.clone()
1123 ))
1124 .color(Color::Muted),
1125 )
1126 },
1127 ))
1128 .child(if prompt_id.is_built_in() {
1129 div()
1130 .id("built-in-rule")
1131 .child(
1132 Icon::new(IconName::FileLock)
1133 .color(Color::Muted),
1134 )
1135 .tooltip(move |window, cx| {
1136 Tooltip::with_meta(
1137 "Built-in rule",
1138 None,
1139 BUILT_IN_TOOLTIP_TEXT,
1140 window,
1141 cx,
1142 )
1143 })
1144 .into_any()
1145 } else {
1146 IconButton::new("delete-rule", IconName::Trash)
1147 .size(ButtonSize::Large)
1148 .style(ButtonStyle::Transparent)
1149 .shape(IconButtonShape::Square)
1150 .size(ButtonSize::Large)
1151 .tooltip(move |window, cx| {
1152 Tooltip::for_action(
1153 "Delete Rule",
1154 &DeleteRule,
1155 window,
1156 cx,
1157 )
1158 })
1159 .on_click(|_, window, cx| {
1160 window.dispatch_action(
1161 Box::new(DeleteRule),
1162 cx,
1163 );
1164 })
1165 .into_any_element()
1166 })
1167 .child(
1168 IconButton::new(
1169 "duplicate-rule",
1170 IconName::BookCopy,
1171 )
1172 .size(ButtonSize::Large)
1173 .style(ButtonStyle::Transparent)
1174 .shape(IconButtonShape::Square)
1175 .size(ButtonSize::Large)
1176 .tooltip(move |window, cx| {
1177 Tooltip::for_action(
1178 "Duplicate Rule",
1179 &DuplicateRule,
1180 window,
1181 cx,
1182 )
1183 })
1184 .on_click(|_, window, cx| {
1185 window.dispatch_action(
1186 Box::new(DuplicateRule),
1187 cx,
1188 );
1189 }),
1190 )
1191 .child(
1192 IconButton::new(
1193 "toggle-default-rule",
1194 IconName::Sparkle,
1195 )
1196 .style(ButtonStyle::Transparent)
1197 .toggle_state(rule_metadata.default)
1198 .selected_icon(IconName::SparkleFilled)
1199 .icon_color(if rule_metadata.default {
1200 Color::Accent
1201 } else {
1202 Color::Muted
1203 })
1204 .shape(IconButtonShape::Square)
1205 .size(ButtonSize::Large)
1206 .tooltip(Tooltip::text(
1207 if rule_metadata.default {
1208 "Remove from Default Rules"
1209 } else {
1210 "Add to Default Rules"
1211 },
1212 ))
1213 .on_click(|_, window, cx| {
1214 window.dispatch_action(
1215 Box::new(ToggleDefaultRule),
1216 cx,
1217 );
1218 }),
1219 ),
1220 ),
1221 ),
1222 )
1223 .child(
1224 div()
1225 .on_action(cx.listener(Self::focus_picker))
1226 .on_action(cx.listener(Self::inline_assist))
1227 .on_action(cx.listener(Self::move_up_from_body))
1228 .flex_grow()
1229 .h_full()
1230 .child(rule_editor.body_editor.clone()),
1231 ),
1232 )
1233 }))
1234 }
1235}
1236
1237impl Render for RulesLibrary {
1238 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1239 let ui_font = theme::setup_ui_font(window, cx);
1240 let theme = cx.theme().clone();
1241
1242 h_flex()
1243 .id("rules-library")
1244 .key_context("PromptLibrary")
1245 .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
1246 .on_action(
1247 cx.listener(|this, &DeleteRule, window, cx| this.delete_active_rule(window, cx)),
1248 )
1249 .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
1250 this.duplicate_active_rule(window, cx)
1251 }))
1252 .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
1253 this.toggle_default_for_active_rule(window, cx)
1254 }))
1255 .size_full()
1256 .overflow_hidden()
1257 .font(ui_font)
1258 .text_color(theme.colors().text)
1259 .child(self.render_rule_list(cx))
1260 .map(|el| {
1261 if self.store.read(cx).prompt_count() == 0 {
1262 el.child(
1263 v_flex()
1264 .w_2_3()
1265 .h_full()
1266 .items_center()
1267 .justify_center()
1268 .gap_4()
1269 .bg(cx.theme().colors().editor_background)
1270 .child(
1271 h_flex()
1272 .gap_2()
1273 .child(
1274 Icon::new(IconName::Book)
1275 .size(IconSize::Medium)
1276 .color(Color::Muted),
1277 )
1278 .child(
1279 Label::new("No rules yet")
1280 .size(LabelSize::Large)
1281 .color(Color::Muted),
1282 ),
1283 )
1284 .child(
1285 h_flex()
1286 .child(h_flex())
1287 .child(
1288 v_flex()
1289 .gap_1()
1290 .child(Label::new("Create your first rule:"))
1291 .child(
1292 Button::new("create-rule", "New Rule")
1293 .full_width()
1294 .key_binding(KeyBinding::for_action(
1295 &NewRule, window, cx,
1296 ))
1297 .on_click(|_, window, cx| {
1298 window.dispatch_action(
1299 NewRule.boxed_clone(),
1300 cx,
1301 )
1302 }),
1303 ),
1304 )
1305 .child(h_flex()),
1306 ),
1307 )
1308 } else {
1309 el.child(self.render_active_rule(cx))
1310 }
1311 })
1312 }
1313}