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