1use anyhow::Result;
2use collections::{HashMap, HashSet};
3use editor::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, Tiling, TitlebarOptions, WindowBounds, WindowHandle,
8 WindowOptions, 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::{ActionSequence, Settings};
19use std::sync::Arc;
20use std::sync::atomic::AtomicBool;
21use std::time::Duration;
22use theme_settings::ThemeSettings;
23use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
24use ui_input::ErasedEditor;
25use util::{ResultExt, TryFutureExt};
26use workspace::{MultiWorkspace, 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 prompt_to_select: Option<PromptId>,
79 cx: &mut App,
80) -> Task<Result<WindowHandle<RulesLibrary>>> {
81 let store = PromptStore::global(cx);
82 cx.spawn(async move |cx| {
83 // We query windows in spawn so that all windows have been returned to GPUI
84 let existing_window = cx.update(|cx| {
85 let existing_window = cx
86 .windows()
87 .into_iter()
88 .find_map(|window| window.downcast::<RulesLibrary>());
89 if let Some(existing_window) = existing_window {
90 existing_window
91 .update(cx, |rules_library, window, cx| {
92 if let Some(prompt_to_select) = prompt_to_select {
93 rules_library.load_rule(prompt_to_select, true, window, cx);
94 }
95 window.activate_window()
96 })
97 .ok();
98
99 Some(existing_window)
100 } else {
101 None
102 }
103 });
104
105 if let Some(existing_window) = existing_window {
106 return Ok(existing_window);
107 }
108
109 let store = store.await?;
110 cx.update(|cx| {
111 let app_id = ReleaseChannel::global(cx).app_id();
112 let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
113 let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
114 Ok(val) if val == "server" => gpui::WindowDecorations::Server,
115 Ok(val) if val == "client" => gpui::WindowDecorations::Client,
116 _ => match WorkspaceSettings::get_global(cx).window_decorations {
117 settings::WindowDecorations::Server => gpui::WindowDecorations::Server,
118 settings::WindowDecorations::Client => gpui::WindowDecorations::Client,
119 },
120 };
121 cx.open_window(
122 WindowOptions {
123 titlebar: Some(TitlebarOptions {
124 title: Some("Rules Library".into()),
125 appears_transparent: true,
126 traffic_light_position: Some(point(px(12.0), px(12.0))),
127 }),
128 app_id: Some(app_id.to_owned()),
129 window_bounds: Some(WindowBounds::Windowed(bounds)),
130 window_background: cx.theme().window_background_appearance(),
131 window_decorations: Some(window_decorations),
132 window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE),
133 kind: gpui::WindowKind::Floating,
134 ..Default::default()
135 },
136 |window, cx| {
137 cx.new(|cx| {
138 RulesLibrary::new(
139 store,
140 language_registry,
141 inline_assist_delegate,
142 prompt_to_select,
143 window,
144 cx,
145 )
146 })
147 },
148 )
149 })
150 })
151}
152
153pub struct RulesLibrary {
154 title_bar: Option<Entity<PlatformTitleBar>>,
155 store: Entity<PromptStore>,
156 language_registry: Arc<LanguageRegistry>,
157 rule_editors: HashMap<PromptId, RuleEditor>,
158 active_rule_id: Option<PromptId>,
159 picker: Entity<Picker<RulePickerDelegate>>,
160 pending_load: Task<()>,
161 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
162 _subscriptions: Vec<Subscription>,
163}
164
165struct RuleEditor {
166 title_editor: Entity<Editor>,
167 body_editor: Entity<Editor>,
168 token_count: Option<u64>,
169 pending_token_count: Task<Option<()>>,
170 next_title_and_body_to_save: Option<(String, Rope)>,
171 pending_save: Option<Task<Option<()>>>,
172 _subscriptions: Vec<Subscription>,
173}
174
175enum RulePickerEntry {
176 Header(SharedString),
177 Rule(PromptMetadata),
178 Separator,
179}
180
181struct RulePickerDelegate {
182 store: Entity<PromptStore>,
183 selected_index: usize,
184 filtered_entries: Vec<RulePickerEntry>,
185}
186
187enum RulePickerEvent {
188 Selected { prompt_id: PromptId },
189 Confirmed { prompt_id: PromptId },
190 Deleted { prompt_id: PromptId },
191 ToggledDefault { prompt_id: PromptId },
192}
193
194impl EventEmitter<RulePickerEvent> for Picker<RulePickerDelegate> {}
195
196impl PickerDelegate for RulePickerDelegate {
197 type ListItem = AnyElement;
198
199 fn match_count(&self) -> usize {
200 self.filtered_entries.len()
201 }
202
203 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
204 Some("No rules found matching your search.".into())
205 }
206
207 fn selected_index(&self) -> usize {
208 self.selected_index
209 }
210
211 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
212 self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
213
214 if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
215 cx.emit(RulePickerEvent::Selected { prompt_id: rule.id });
216 }
217
218 cx.notify();
219 }
220
221 fn can_select(&self, ix: usize, _: &mut Window, _: &mut Context<Picker<Self>>) -> bool {
222 match self.filtered_entries.get(ix) {
223 Some(RulePickerEntry::Rule(_)) => true,
224 Some(RulePickerEntry::Header(_)) | Some(RulePickerEntry::Separator) | None => false,
225 }
226 }
227
228 fn select_on_hover(&self) -> bool {
229 false
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_slot_on_hover(
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 rule_to_select: Option<PromptId>,
475 window: &mut Window,
476 cx: &mut Context<Self>,
477 ) -> Self {
478 let (_selected_index, _matches) = if let Some(rule_to_select) = rule_to_select {
479 let matches = store.read(cx).all_prompt_metadata();
480 let selected_index = matches
481 .iter()
482 .enumerate()
483 .find(|(_, metadata)| metadata.id == rule_to_select)
484 .map_or(0, |(ix, _)| ix);
485 (selected_index, matches)
486 } else {
487 (0, vec![])
488 };
489
490 let picker_delegate = RulePickerDelegate {
491 store: store.clone(),
492 selected_index: 0,
493 filtered_entries: Vec::new(),
494 };
495
496 let picker = cx.new(|cx| {
497 let picker = Picker::list(picker_delegate, window, cx)
498 .modal(false)
499 .max_height(None);
500 picker.focus(window, cx);
501 picker
502 });
503
504 Self {
505 title_bar: if !cfg!(target_os = "macos") {
506 Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx)))
507 } else {
508 None
509 },
510 store,
511 language_registry,
512 rule_editors: HashMap::default(),
513 active_rule_id: None,
514 pending_load: Task::ready(()),
515 inline_assist_delegate,
516 _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)],
517 picker,
518 }
519 }
520
521 fn handle_picker_event(
522 &mut self,
523 _: &Entity<Picker<RulePickerDelegate>>,
524 event: &RulePickerEvent,
525 window: &mut Window,
526 cx: &mut Context<Self>,
527 ) {
528 match event {
529 RulePickerEvent::Selected { prompt_id } => {
530 self.load_rule(*prompt_id, false, window, cx);
531 }
532 RulePickerEvent::Confirmed { prompt_id } => {
533 self.load_rule(*prompt_id, true, window, cx);
534 }
535 RulePickerEvent::ToggledDefault { prompt_id } => {
536 self.toggle_default_for_rule(*prompt_id, window, cx);
537 }
538 RulePickerEvent::Deleted { prompt_id } => {
539 self.delete_rule(*prompt_id, window, cx);
540 }
541 }
542 }
543
544 pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
545 // If we already have an untitled rule, use that instead
546 // of creating a new one.
547 if let Some(metadata) = self.store.read(cx).first()
548 && metadata.title.is_none()
549 {
550 self.load_rule(metadata.id, true, window, cx);
551 return;
552 }
553
554 let prompt_id = PromptId::new();
555 let save = self.store.update(cx, |store, cx| {
556 store.save(prompt_id, None, false, "".into(), cx)
557 });
558 self.picker
559 .update(cx, |picker, cx| picker.refresh(window, cx));
560 cx.spawn_in(window, async move |this, cx| {
561 save.await?;
562 this.update_in(cx, |this, window, cx| {
563 this.load_rule(prompt_id, true, window, cx)
564 })
565 })
566 .detach_and_log_err(cx);
567 }
568
569 pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
570 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
571
572 if !prompt_id.can_edit() {
573 return;
574 }
575
576 let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap();
577 let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap();
578 let title = rule_editor.title_editor.read(cx).text(cx);
579 let body = rule_editor.body_editor.update(cx, |editor, cx| {
580 editor
581 .buffer()
582 .read(cx)
583 .as_singleton()
584 .unwrap()
585 .read(cx)
586 .as_rope()
587 .clone()
588 });
589
590 let store = self.store.clone();
591 let executor = cx.background_executor().clone();
592
593 rule_editor.next_title_and_body_to_save = Some((title, body));
594 if rule_editor.pending_save.is_none() {
595 rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
596 async move {
597 loop {
598 let title_and_body = this.update(cx, |this, _| {
599 this.rule_editors
600 .get_mut(&prompt_id)?
601 .next_title_and_body_to_save
602 .take()
603 })?;
604
605 if let Some((title, body)) = title_and_body {
606 let title = if title.trim().is_empty() {
607 None
608 } else {
609 Some(SharedString::from(title))
610 };
611 cx.update(|_window, cx| {
612 store.update(cx, |store, cx| {
613 store.save(prompt_id, title, rule_metadata.default, body, cx)
614 })
615 })?
616 .await
617 .log_err();
618 this.update_in(cx, |this, window, cx| {
619 this.picker
620 .update(cx, |picker, cx| picker.refresh(window, cx));
621 cx.notify();
622 })?;
623
624 executor.timer(SAVE_THROTTLE).await;
625 } else {
626 break;
627 }
628 }
629
630 this.update(cx, |this, _cx| {
631 if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) {
632 rule_editor.pending_save = None;
633 }
634 })
635 }
636 .log_err()
637 .await
638 }));
639 }
640 }
641
642 pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
643 if let Some(active_rule_id) = self.active_rule_id {
644 self.delete_rule(active_rule_id, window, cx);
645 }
646 }
647
648 pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
649 if let Some(active_rule_id) = self.active_rule_id {
650 self.duplicate_rule(active_rule_id, window, cx);
651 }
652 }
653
654 pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
655 if let Some(active_rule_id) = self.active_rule_id {
656 self.toggle_default_for_rule(active_rule_id, window, cx);
657 }
658 }
659
660 pub fn restore_default_content_for_active_rule(
661 &mut self,
662 window: &mut Window,
663 cx: &mut Context<Self>,
664 ) {
665 if let Some(active_rule_id) = self.active_rule_id {
666 self.restore_default_content(active_rule_id, window, cx);
667 }
668 }
669
670 pub fn restore_default_content(
671 &mut self,
672 prompt_id: PromptId,
673 window: &mut Window,
674 cx: &mut Context<Self>,
675 ) {
676 let Some(built_in) = prompt_id.as_built_in() else {
677 return;
678 };
679
680 if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
681 rule_editor.body_editor.update(cx, |editor, cx| {
682 editor.set_text(built_in.default_content(), window, cx);
683 });
684 }
685 }
686
687 pub fn toggle_default_for_rule(
688 &mut self,
689 prompt_id: PromptId,
690 window: &mut Window,
691 cx: &mut Context<Self>,
692 ) {
693 self.store.update(cx, move |store, cx| {
694 if let Some(rule_metadata) = store.metadata(prompt_id) {
695 store
696 .save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx)
697 .detach_and_log_err(cx);
698 }
699 });
700 self.picker
701 .update(cx, |picker, cx| picker.refresh(window, cx));
702 cx.notify();
703 }
704
705 pub fn load_rule(
706 &mut self,
707 prompt_id: PromptId,
708 focus: bool,
709 window: &mut Window,
710 cx: &mut Context<Self>,
711 ) {
712 if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
713 if focus {
714 rule_editor
715 .body_editor
716 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
717 }
718 self.set_active_rule(Some(prompt_id), window, cx);
719 } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) {
720 let language_registry = self.language_registry.clone();
721 let rule = self.store.read(cx).load(prompt_id, cx);
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 if focus {
757 window.focus(&editor.focus_handle(cx), cx);
758 }
759 editor
760 });
761 let _subscriptions = vec![
762 cx.subscribe_in(
763 &title_editor,
764 window,
765 move |this, editor, event, window, cx| {
766 this.handle_rule_title_editor_event(
767 prompt_id, editor, event, window, cx,
768 )
769 },
770 ),
771 cx.subscribe_in(
772 &body_editor,
773 window,
774 move |this, editor, event, window, cx| {
775 this.handle_rule_body_editor_event(
776 prompt_id, editor, event, window, cx,
777 )
778 },
779 ),
780 ];
781 this.rule_editors.insert(
782 prompt_id,
783 RuleEditor {
784 title_editor,
785 body_editor,
786 next_title_and_body_to_save: None,
787 pending_save: None,
788 token_count: None,
789 pending_token_count: Task::ready(None),
790 _subscriptions,
791 },
792 );
793 this.set_active_rule(Some(prompt_id), window, cx);
794 this.count_tokens(prompt_id, window, cx);
795 }
796 Err(error) => {
797 // TODO: we should show the error in the UI.
798 log::error!("error while loading rule: {:?}", error);
799 }
800 })
801 .ok();
802 });
803 }
804 }
805
806 fn set_active_rule(
807 &mut self,
808 prompt_id: Option<PromptId>,
809 window: &mut Window,
810 cx: &mut Context<Self>,
811 ) {
812 self.active_rule_id = prompt_id;
813 self.picker.update(cx, |picker, cx| {
814 if let Some(prompt_id) = prompt_id {
815 if picker
816 .delegate
817 .filtered_entries
818 .get(picker.delegate.selected_index())
819 .is_none_or(|old_selected_prompt| {
820 if let RulePickerEntry::Rule(rule) = old_selected_prompt {
821 rule.id != prompt_id
822 } else {
823 true
824 }
825 })
826 && let Some(ix) = picker.delegate.filtered_entries.iter().position(|mat| {
827 if let RulePickerEntry::Rule(rule) = mat {
828 rule.id == prompt_id
829 } else {
830 false
831 }
832 })
833 {
834 picker.set_selected_index(ix, None, true, window, cx);
835 }
836 } else {
837 picker.focus(window, cx);
838 }
839 });
840 cx.notify();
841 }
842
843 pub fn delete_rule(
844 &mut self,
845 prompt_id: PromptId,
846 window: &mut Window,
847 cx: &mut Context<Self>,
848 ) {
849 if let Some(metadata) = self.store.read(cx).metadata(prompt_id) {
850 let confirmation = window.prompt(
851 PromptLevel::Warning,
852 &format!(
853 "Are you sure you want to delete {}",
854 metadata.title.unwrap_or("Untitled".into())
855 ),
856 None,
857 &["Delete", "Cancel"],
858 cx,
859 );
860
861 cx.spawn_in(window, async move |this, cx| {
862 if confirmation.await.ok() == Some(0) {
863 this.update_in(cx, |this, window, cx| {
864 if this.active_rule_id == Some(prompt_id) {
865 this.set_active_rule(None, window, cx);
866 }
867 this.rule_editors.remove(&prompt_id);
868 this.store
869 .update(cx, |store, cx| store.delete(prompt_id, cx))
870 .detach_and_log_err(cx);
871 this.picker
872 .update(cx, |picker, cx| picker.refresh(window, cx));
873 cx.notify();
874 })?;
875 }
876 anyhow::Ok(())
877 })
878 .detach_and_log_err(cx);
879 }
880 }
881
882 pub fn duplicate_rule(
883 &mut self,
884 prompt_id: PromptId,
885 window: &mut Window,
886 cx: &mut Context<Self>,
887 ) {
888 if let Some(rule) = self.rule_editors.get(&prompt_id) {
889 const DUPLICATE_SUFFIX: &str = " copy";
890 let title_to_duplicate = rule.title_editor.read(cx).text(cx);
891 let existing_titles = self
892 .rule_editors
893 .iter()
894 .filter(|&(&id, _)| id != prompt_id)
895 .map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx))
896 .filter(|title| title.starts_with(&title_to_duplicate))
897 .collect::<HashSet<_>>();
898
899 let title = if existing_titles.is_empty() {
900 title_to_duplicate + DUPLICATE_SUFFIX
901 } else {
902 let mut i = 1;
903 loop {
904 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
905 if !existing_titles.contains(&new_title) {
906 break new_title;
907 }
908 i += 1;
909 }
910 };
911
912 let new_id = PromptId::new();
913 let body = rule.body_editor.read(cx).text(cx);
914 let save = self.store.update(cx, |store, cx| {
915 store.save(new_id, Some(title.into()), false, body.into(), cx)
916 });
917 self.picker
918 .update(cx, |picker, cx| picker.refresh(window, cx));
919 cx.spawn_in(window, async move |this, cx| {
920 save.await?;
921 this.update_in(cx, |rules_library, window, cx| {
922 rules_library.load_rule(new_id, true, window, cx)
923 })
924 })
925 .detach_and_log_err(cx);
926 }
927 }
928
929 fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
930 if let Some(active_rule) = self.active_rule_id {
931 self.rule_editors[&active_rule]
932 .body_editor
933 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
934 cx.stop_propagation();
935 }
936 }
937
938 fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
939 self.picker
940 .update(cx, |picker, cx| picker.focus(window, cx));
941 }
942
943 pub fn inline_assist(
944 &mut self,
945 action: &InlineAssist,
946 window: &mut Window,
947 cx: &mut Context<Self>,
948 ) {
949 let Some(active_rule_id) = self.active_rule_id else {
950 cx.propagate();
951 return;
952 };
953
954 let rule_editor = &self.rule_editors[&active_rule_id].body_editor;
955 let Some(ConfiguredModel { provider, .. }) =
956 LanguageModelRegistry::read_global(cx).inline_assistant_model()
957 else {
958 return;
959 };
960
961 let initial_prompt = action.prompt.clone();
962 if provider.is_authenticated(cx) {
963 self.inline_assist_delegate
964 .assist(rule_editor, initial_prompt, window, cx);
965 } else {
966 for window in cx.windows() {
967 if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
968 let panel = multi_workspace
969 .update(cx, |multi_workspace, window, cx| {
970 window.activate_window();
971 multi_workspace.workspace().update(cx, |workspace, cx| {
972 self.inline_assist_delegate
973 .focus_agent_panel(workspace, window, cx)
974 })
975 })
976 .ok();
977 if panel == Some(true) {
978 return;
979 }
980 }
981 }
982 }
983 }
984
985 fn move_down_from_title(
986 &mut self,
987 _: &zed_actions::editor::MoveDown,
988 window: &mut Window,
989 cx: &mut Context<Self>,
990 ) {
991 if let Some(rule_id) = self.active_rule_id
992 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
993 {
994 window.focus(&rule_editor.body_editor.focus_handle(cx), cx);
995 }
996 }
997
998 fn move_up_from_body(
999 &mut self,
1000 _: &zed_actions::editor::MoveUp,
1001 window: &mut Window,
1002 cx: &mut Context<Self>,
1003 ) {
1004 if let Some(rule_id) = self.active_rule_id
1005 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
1006 {
1007 window.focus(&rule_editor.title_editor.focus_handle(cx), cx);
1008 }
1009 }
1010
1011 fn handle_rule_title_editor_event(
1012 &mut self,
1013 prompt_id: PromptId,
1014 title_editor: &Entity<Editor>,
1015 event: &EditorEvent,
1016 window: &mut Window,
1017 cx: &mut Context<Self>,
1018 ) {
1019 match event {
1020 EditorEvent::BufferEdited => {
1021 self.save_rule(prompt_id, window, cx);
1022 self.count_tokens(prompt_id, window, cx);
1023 }
1024 EditorEvent::Blurred => {
1025 title_editor.update(cx, |title_editor, cx| {
1026 title_editor.change_selections(
1027 SelectionEffects::no_scroll(),
1028 window,
1029 cx,
1030 |selections| {
1031 let cursor = selections.oldest_anchor().head();
1032 selections.select_anchor_ranges([cursor..cursor]);
1033 },
1034 );
1035 });
1036 }
1037 _ => {}
1038 }
1039 }
1040
1041 fn handle_rule_body_editor_event(
1042 &mut self,
1043 prompt_id: PromptId,
1044 body_editor: &Entity<Editor>,
1045 event: &EditorEvent,
1046 window: &mut Window,
1047 cx: &mut Context<Self>,
1048 ) {
1049 match event {
1050 EditorEvent::BufferEdited => {
1051 self.save_rule(prompt_id, window, cx);
1052 self.count_tokens(prompt_id, window, cx);
1053 }
1054 EditorEvent::Blurred => {
1055 body_editor.update(cx, |body_editor, cx| {
1056 body_editor.change_selections(
1057 SelectionEffects::no_scroll(),
1058 window,
1059 cx,
1060 |selections| {
1061 let cursor = selections.oldest_anchor().head();
1062 selections.select_anchor_ranges([cursor..cursor]);
1063 },
1064 );
1065 });
1066 }
1067 _ => {}
1068 }
1069 }
1070
1071 fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
1072 let Some(ConfiguredModel { model, .. }) =
1073 LanguageModelRegistry::read_global(cx).default_model()
1074 else {
1075 return;
1076 };
1077 if let Some(rule) = self.rule_editors.get_mut(&prompt_id) {
1078 let editor = &rule.body_editor.read(cx);
1079 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
1080 let body = buffer.as_rope().clone();
1081 rule.pending_token_count = cx.spawn_in(window, async move |this, cx| {
1082 async move {
1083 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
1084
1085 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
1086 let token_count = cx
1087 .update(|_, cx| {
1088 model.count_tokens(
1089 LanguageModelRequest {
1090 thread_id: None,
1091 prompt_id: None,
1092 intent: 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 thinking_effort: None,
1105 speed: None,
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 .start_icon(
1159 Icon::new(IconName::Plus)
1160 .size(IconSize::Small)
1161 .color(Color::Muted),
1162 )
1163 .on_click(|_, window, cx| {
1164 window.dispatch_action(Box::new(NewRule), cx);
1165 }),
1166 ),
1167 )
1168 }
1169 })
1170 .child(div().flex_grow().child(self.picker.clone()))
1171 }
1172
1173 fn render_active_rule_editor(
1174 &self,
1175 editor: &Entity<Editor>,
1176 read_only: bool,
1177 cx: &mut Context<Self>,
1178 ) -> impl IntoElement {
1179 let settings = ThemeSettings::get_global(cx);
1180 let text_color = if read_only {
1181 cx.theme().colors().text_muted
1182 } else {
1183 cx.theme().colors().text
1184 };
1185
1186 div()
1187 .w_full()
1188 .pl_1()
1189 .border_1()
1190 .border_color(transparent_black())
1191 .rounded_sm()
1192 .when(!read_only, |this| {
1193 this.group_hover("active-editor-header", |this| {
1194 this.border_color(cx.theme().colors().border_variant)
1195 })
1196 })
1197 .on_action(cx.listener(Self::move_down_from_title))
1198 .child(EditorElement::new(
1199 &editor,
1200 EditorStyle {
1201 background: cx.theme().system().transparent,
1202 local_player: cx.theme().players().local(),
1203 text: TextStyle {
1204 color: text_color,
1205 font_family: settings.ui_font.family.clone(),
1206 font_features: settings.ui_font.features.clone(),
1207 font_size: HeadlineSize::Medium.rems().into(),
1208 font_weight: settings.ui_font.weight,
1209 line_height: relative(settings.buffer_line_height.value()),
1210 ..Default::default()
1211 },
1212 scrollbar_width: Pixels::ZERO,
1213 syntax: cx.theme().syntax().clone(),
1214 status: cx.theme().status().clone(),
1215 inlay_hints_style: editor::make_inlay_hints_style(cx),
1216 edit_prediction_styles: editor::make_suggestion_styles(cx),
1217 ..EditorStyle::default()
1218 },
1219 ))
1220 }
1221
1222 fn render_duplicate_rule_button(&self) -> impl IntoElement {
1223 IconButton::new("duplicate-rule", IconName::BookCopy)
1224 .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
1225 .on_click(|_, window, cx| {
1226 window.dispatch_action(Box::new(DuplicateRule), cx);
1227 })
1228 }
1229
1230 fn render_built_in_rule_controls(&self) -> impl IntoElement {
1231 h_flex()
1232 .gap_1()
1233 .child(self.render_duplicate_rule_button())
1234 .child(
1235 IconButton::new("restore-default", IconName::RotateCcw)
1236 .tooltip(move |_window, cx| {
1237 Tooltip::for_action(
1238 "Restore to Default Content",
1239 &RestoreDefaultContent,
1240 cx,
1241 )
1242 })
1243 .on_click(|_, window, cx| {
1244 window.dispatch_action(Box::new(RestoreDefaultContent), cx);
1245 }),
1246 )
1247 }
1248
1249 fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
1250 h_flex()
1251 .gap_1()
1252 .child(
1253 IconButton::new("toggle-default-rule", IconName::Paperclip)
1254 .toggle_state(default)
1255 .when(default, |this| this.icon_color(Color::Accent))
1256 .map(|this| {
1257 if default {
1258 this.tooltip(Tooltip::text("Remove from Default Rules"))
1259 } else {
1260 this.tooltip(move |_window, cx| {
1261 Tooltip::with_meta(
1262 "Add to Default Rules",
1263 None,
1264 "Always included in every thread.",
1265 cx,
1266 )
1267 })
1268 }
1269 })
1270 .on_click(|_, window, cx| {
1271 window.dispatch_action(Box::new(ToggleDefaultRule), cx);
1272 }),
1273 )
1274 .child(self.render_duplicate_rule_button())
1275 .child(
1276 IconButton::new("delete-rule", IconName::Trash)
1277 .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
1278 .on_click(|_, window, cx| {
1279 window.dispatch_action(Box::new(DeleteRule), cx);
1280 }),
1281 )
1282 }
1283
1284 fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
1285 div()
1286 .id("rule-editor")
1287 .h_full()
1288 .flex_grow()
1289 .border_l_1()
1290 .border_color(cx.theme().colors().border)
1291 .bg(cx.theme().colors().editor_background)
1292 .children(self.active_rule_id.and_then(|prompt_id| {
1293 let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
1294 let rule_editor = &self.rule_editors[&prompt_id];
1295 let focus_handle = rule_editor.body_editor.focus_handle(cx);
1296 let registry = LanguageModelRegistry::read_global(cx);
1297 let model = registry.default_model().map(|default| default.model);
1298 let built_in = prompt_id.is_built_in();
1299
1300 Some(
1301 v_flex()
1302 .id("rule-editor-inner")
1303 .size_full()
1304 .relative()
1305 .overflow_hidden()
1306 .on_click(cx.listener(move |_, _, window, cx| {
1307 window.focus(&focus_handle, cx);
1308 }))
1309 .child(
1310 h_flex()
1311 .group("active-editor-header")
1312 .h_12()
1313 .px_2()
1314 .gap_2()
1315 .justify_between()
1316 .child(self.render_active_rule_editor(
1317 &rule_editor.title_editor,
1318 built_in,
1319 cx,
1320 ))
1321 .child(
1322 h_flex()
1323 .h_full()
1324 .flex_shrink_0()
1325 .children(rule_editor.token_count.map(|token_count| {
1326 let token_count: SharedString =
1327 token_count.to_string().into();
1328 let label_token_count: SharedString =
1329 token_count.to_string().into();
1330
1331 div()
1332 .id("token_count")
1333 .mr_1()
1334 .flex_shrink_0()
1335 .tooltip(move |_window, cx| {
1336 Tooltip::with_meta(
1337 "Token Estimation",
1338 None,
1339 format!(
1340 "Model: {}",
1341 model
1342 .as_ref()
1343 .map(|model| model.name().0)
1344 .unwrap_or_default()
1345 ),
1346 cx,
1347 )
1348 })
1349 .child(
1350 Label::new(format!(
1351 "{} tokens",
1352 label_token_count
1353 ))
1354 .color(Color::Muted),
1355 )
1356 }))
1357 .map(|this| {
1358 if built_in {
1359 this.child(self.render_built_in_rule_controls())
1360 } else {
1361 this.child(self.render_regular_rule_controls(
1362 rule_metadata.default,
1363 ))
1364 }
1365 }),
1366 ),
1367 )
1368 .child(
1369 div()
1370 .on_action(cx.listener(Self::focus_picker))
1371 .on_action(cx.listener(Self::inline_assist))
1372 .on_action(cx.listener(Self::move_up_from_body))
1373 .h_full()
1374 .flex_grow()
1375 .child(
1376 h_flex()
1377 .py_2()
1378 .pl_2p5()
1379 .h_full()
1380 .flex_1()
1381 .child(rule_editor.body_editor.clone()),
1382 ),
1383 ),
1384 )
1385 }))
1386 }
1387}
1388
1389impl Render for RulesLibrary {
1390 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1391 let ui_font = theme_settings::setup_ui_font(window, cx);
1392 let theme = cx.theme().clone();
1393
1394 client_side_decorations(
1395 v_flex()
1396 .id("rules-library")
1397 .key_context("RulesLibrary")
1398 .on_action(
1399 |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| {
1400 for action in &action_sequence.0 {
1401 window.dispatch_action(action.boxed_clone(), cx);
1402 }
1403 },
1404 )
1405 .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
1406 .on_action(
1407 cx.listener(|this, &DeleteRule, window, cx| {
1408 this.delete_active_rule(window, cx)
1409 }),
1410 )
1411 .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
1412 this.duplicate_active_rule(window, cx)
1413 }))
1414 .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
1415 this.toggle_default_for_active_rule(window, cx)
1416 }))
1417 .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
1418 this.restore_default_content_for_active_rule(window, cx)
1419 }))
1420 .size_full()
1421 .overflow_hidden()
1422 .font(ui_font)
1423 .text_color(theme.colors().text)
1424 .children(self.title_bar.clone())
1425 .bg(theme.colors().background)
1426 .child(
1427 h_flex()
1428 .flex_1()
1429 .when(!cfg!(target_os = "macos"), |this| {
1430 this.border_t_1().border_color(cx.theme().colors().border)
1431 })
1432 .child(self.render_rule_list(cx))
1433 .child(self.render_active_rule(cx)),
1434 ),
1435 window,
1436 cx,
1437 Tiling::default(),
1438 )
1439 }
1440}