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