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