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