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, MouseButton,
7 PromptLevel, Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds,
8 WindowHandle, 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::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::{MultiWorkspace, 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 is_movable: !cfg!(target_os = "macos"),
137 ..Default::default()
138 },
139 |window, cx| {
140 cx.new(|cx| {
141 RulesLibrary::new(
142 store,
143 language_registry,
144 inline_assist_delegate,
145 make_completion_provider,
146 prompt_to_select,
147 window,
148 cx,
149 )
150 })
151 },
152 )
153 })
154 })
155}
156
157pub struct RulesLibrary {
158 title_bar: Option<Entity<PlatformTitleBar>>,
159 store: Entity<PromptStore>,
160 language_registry: Arc<LanguageRegistry>,
161 rule_editors: HashMap<PromptId, RuleEditor>,
162 active_rule_id: Option<PromptId>,
163 picker: Entity<Picker<RulePickerDelegate>>,
164 pending_load: Task<()>,
165 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
166 make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
167 _subscriptions: Vec<Subscription>,
168}
169
170struct RuleEditor {
171 title_editor: Entity<Editor>,
172 body_editor: Entity<Editor>,
173 token_count: Option<u64>,
174 pending_token_count: Task<Option<()>>,
175 next_title_and_body_to_save: Option<(String, Rope)>,
176 pending_save: Option<Task<Option<()>>>,
177 _subscriptions: Vec<Subscription>,
178}
179
180enum RulePickerEntry {
181 Header(SharedString),
182 Rule(PromptMetadata),
183 Separator,
184}
185
186struct RulePickerDelegate {
187 store: Entity<PromptStore>,
188 selected_index: usize,
189 filtered_entries: Vec<RulePickerEntry>,
190}
191
192enum RulePickerEvent {
193 Selected { prompt_id: PromptId },
194 Confirmed { prompt_id: PromptId },
195 Deleted { prompt_id: PromptId },
196 ToggledDefault { prompt_id: PromptId },
197}
198
199impl EventEmitter<RulePickerEvent> for Picker<RulePickerDelegate> {}
200
201impl PickerDelegate for RulePickerDelegate {
202 type ListItem = AnyElement;
203
204 fn match_count(&self) -> usize {
205 self.filtered_entries.len()
206 }
207
208 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
209 Some("No rules found matching your search.".into())
210 }
211
212 fn selected_index(&self) -> usize {
213 self.selected_index
214 }
215
216 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
217 self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
218
219 if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
220 cx.emit(RulePickerEvent::Selected { prompt_id: rule.id });
221 }
222
223 cx.notify();
224 }
225
226 fn can_select(&self, ix: usize, _: &mut Window, _: &mut Context<Picker<Self>>) -> bool {
227 match self.filtered_entries.get(ix) {
228 Some(RulePickerEntry::Rule(_)) => true,
229 Some(RulePickerEntry::Header(_)) | Some(RulePickerEntry::Separator) | None => false,
230 }
231 }
232
233 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
234 "Search…".into()
235 }
236
237 fn update_matches(
238 &mut self,
239 query: String,
240 window: &mut Window,
241 cx: &mut Context<Picker<Self>>,
242 ) -> Task<()> {
243 let cancellation_flag = Arc::new(AtomicBool::default());
244 let search = self.store.read(cx).search(query, cancellation_flag, cx);
245
246 let prev_prompt_id = self
247 .filtered_entries
248 .get(self.selected_index)
249 .and_then(|entry| {
250 if let RulePickerEntry::Rule(rule) = entry {
251 Some(rule.id)
252 } else {
253 None
254 }
255 });
256
257 cx.spawn_in(window, async move |this, cx| {
258 let (filtered_entries, selected_index) = cx
259 .background_spawn(async move {
260 let matches = search.await;
261
262 let (built_in_rules, user_rules): (Vec<_>, Vec<_>) =
263 matches.into_iter().partition(|rule| rule.id.is_built_in());
264 let (default_rules, other_rules): (Vec<_>, Vec<_>) =
265 user_rules.into_iter().partition(|rule| rule.default);
266
267 let mut filtered_entries = Vec::new();
268
269 if !built_in_rules.is_empty() {
270 filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into()));
271
272 for rule in built_in_rules {
273 filtered_entries.push(RulePickerEntry::Rule(rule));
274 }
275
276 filtered_entries.push(RulePickerEntry::Separator);
277 }
278
279 if !default_rules.is_empty() {
280 filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
281
282 for rule in default_rules {
283 filtered_entries.push(RulePickerEntry::Rule(rule));
284 }
285
286 filtered_entries.push(RulePickerEntry::Separator);
287 }
288
289 for rule in other_rules {
290 filtered_entries.push(RulePickerEntry::Rule(rule));
291 }
292
293 let selected_index = prev_prompt_id
294 .and_then(|prev_prompt_id| {
295 filtered_entries.iter().position(|entry| {
296 if let RulePickerEntry::Rule(rule) = entry {
297 rule.id == prev_prompt_id
298 } else {
299 false
300 }
301 })
302 })
303 .unwrap_or_else(|| {
304 filtered_entries
305 .iter()
306 .position(|entry| matches!(entry, RulePickerEntry::Rule(_)))
307 .unwrap_or(0)
308 });
309
310 (filtered_entries, selected_index)
311 })
312 .await;
313
314 this.update_in(cx, |this, window, cx| {
315 this.delegate.filtered_entries = filtered_entries;
316 this.set_selected_index(
317 selected_index,
318 Some(picker::Direction::Down),
319 true,
320 window,
321 cx,
322 );
323 cx.notify();
324 })
325 .ok();
326 })
327 }
328
329 fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
330 if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
331 cx.emit(RulePickerEvent::Confirmed { prompt_id: rule.id });
332 }
333 }
334
335 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
336
337 fn render_match(
338 &self,
339 ix: usize,
340 selected: bool,
341 _: &mut Window,
342 cx: &mut Context<Picker<Self>>,
343 ) -> Option<Self::ListItem> {
344 match self.filtered_entries.get(ix)? {
345 RulePickerEntry::Header(title) => {
346 let tooltip_text = if title.as_ref() == "Built-in Rules" {
347 "Built-in rules are those included out of the box with Zed."
348 } else {
349 "Default Rules are attached by default with every new thread."
350 };
351
352 Some(
353 ListSubHeader::new(title.clone())
354 .end_slot(
355 IconButton::new("info", IconName::Info)
356 .style(ButtonStyle::Transparent)
357 .icon_size(IconSize::Small)
358 .icon_color(Color::Muted)
359 .tooltip(Tooltip::text(tooltip_text))
360 .into_any_element(),
361 )
362 .inset(true)
363 .into_any_element(),
364 )
365 }
366 RulePickerEntry::Separator => Some(
367 h_flex()
368 .py_1()
369 .child(Divider::horizontal())
370 .into_any_element(),
371 ),
372 RulePickerEntry::Rule(rule) => {
373 let default = rule.default;
374 let prompt_id = rule.id;
375
376 Some(
377 ListItem::new(ix)
378 .inset(true)
379 .spacing(ListItemSpacing::Sparse)
380 .toggle_state(selected)
381 .child(
382 Label::new(rule.title.clone().unwrap_or("Untitled".into()))
383 .truncate()
384 .mr_10(),
385 )
386 .end_slot::<IconButton>((default && !prompt_id.is_built_in()).then(|| {
387 IconButton::new("toggle-default-rule", IconName::Paperclip)
388 .toggle_state(true)
389 .icon_color(Color::Accent)
390 .icon_size(IconSize::Small)
391 .tooltip(Tooltip::text("Remove from Default Rules"))
392 .on_click(cx.listener(move |_, _, _, cx| {
393 cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
394 }))
395 }))
396 .when(!prompt_id.is_built_in(), |this| {
397 this.end_hover_slot(
398 h_flex()
399 .child(
400 IconButton::new("delete-rule", IconName::Trash)
401 .icon_color(Color::Muted)
402 .icon_size(IconSize::Small)
403 .tooltip(Tooltip::text("Delete Rule"))
404 .on_click(cx.listener(move |_, _, _, cx| {
405 cx.emit(RulePickerEvent::Deleted { prompt_id })
406 })),
407 )
408 .child(
409 IconButton::new("toggle-default-rule", IconName::Plus)
410 .selected_icon(IconName::Dash)
411 .toggle_state(default)
412 .icon_size(IconSize::Small)
413 .icon_color(if default {
414 Color::Accent
415 } else {
416 Color::Muted
417 })
418 .map(|this| {
419 if default {
420 this.tooltip(Tooltip::text(
421 "Remove from Default Rules",
422 ))
423 } else {
424 this.tooltip(move |_window, cx| {
425 Tooltip::with_meta(
426 "Add to Default Rules",
427 None,
428 "Always included in every thread.",
429 cx,
430 )
431 })
432 }
433 })
434 .on_click(cx.listener(move |_, _, _, cx| {
435 cx.emit(RulePickerEvent::ToggledDefault {
436 prompt_id,
437 })
438 })),
439 ),
440 )
441 })
442 .into_any_element(),
443 )
444 }
445 }
446 }
447
448 fn render_editor(
449 &self,
450 editor: &Arc<dyn ErasedEditor>,
451 _: &mut Window,
452 cx: &mut Context<Picker<Self>>,
453 ) -> Div {
454 let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
455
456 h_flex()
457 .py_1()
458 .px_1p5()
459 .mx_1()
460 .gap_1p5()
461 .rounded_sm()
462 .bg(cx.theme().colors().editor_background)
463 .border_1()
464 .border_color(cx.theme().colors().border)
465 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
466 .child(editor.clone())
467 }
468}
469
470impl RulesLibrary {
471 fn new(
472 store: Entity<PromptStore>,
473 language_registry: Arc<LanguageRegistry>,
474 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
475 make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
476 rule_to_select: Option<PromptId>,
477 window: &mut Window,
478 cx: &mut Context<Self>,
479 ) -> Self {
480 let (_selected_index, _matches) = if let Some(rule_to_select) = rule_to_select {
481 let matches = store.read(cx).all_prompt_metadata();
482 let selected_index = matches
483 .iter()
484 .enumerate()
485 .find(|(_, metadata)| metadata.id == rule_to_select)
486 .map_or(0, |(ix, _)| ix);
487 (selected_index, matches)
488 } else {
489 (0, vec![])
490 };
491
492 let picker_delegate = RulePickerDelegate {
493 store: store.clone(),
494 selected_index: 0,
495 filtered_entries: Vec::new(),
496 };
497
498 let picker = cx.new(|cx| {
499 let picker = Picker::list(picker_delegate, window, cx)
500 .modal(false)
501 .max_height(None);
502 picker.focus(window, cx);
503 picker
504 });
505
506 Self {
507 title_bar: Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))),
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(multi_workspace) = window.downcast::<MultiWorkspace>() {
969 let panel = multi_workspace
970 .update(cx, |multi_workspace, window, cx| {
971 window.activate_window();
972 multi_workspace.workspace().update(cx, |workspace, cx| {
973 self.inline_assist_delegate
974 .focus_agent_panel(workspace, window, cx)
975 })
976 })
977 .ok();
978 if panel == Some(true) {
979 return;
980 }
981 }
982 }
983 }
984 }
985
986 fn move_down_from_title(
987 &mut self,
988 _: &zed_actions::editor::MoveDown,
989 window: &mut Window,
990 cx: &mut Context<Self>,
991 ) {
992 if let Some(rule_id) = self.active_rule_id
993 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
994 {
995 window.focus(&rule_editor.body_editor.focus_handle(cx), cx);
996 }
997 }
998
999 fn move_up_from_body(
1000 &mut self,
1001 _: &zed_actions::editor::MoveUp,
1002 window: &mut Window,
1003 cx: &mut Context<Self>,
1004 ) {
1005 if let Some(rule_id) = self.active_rule_id
1006 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
1007 {
1008 window.focus(&rule_editor.title_editor.focus_handle(cx), cx);
1009 }
1010 }
1011
1012 fn handle_rule_title_editor_event(
1013 &mut self,
1014 prompt_id: PromptId,
1015 title_editor: &Entity<Editor>,
1016 event: &EditorEvent,
1017 window: &mut Window,
1018 cx: &mut Context<Self>,
1019 ) {
1020 match event {
1021 EditorEvent::BufferEdited => {
1022 self.save_rule(prompt_id, window, cx);
1023 self.count_tokens(prompt_id, window, cx);
1024 }
1025 EditorEvent::Blurred => {
1026 title_editor.update(cx, |title_editor, cx| {
1027 title_editor.change_selections(
1028 SelectionEffects::no_scroll(),
1029 window,
1030 cx,
1031 |selections| {
1032 let cursor = selections.oldest_anchor().head();
1033 selections.select_anchor_ranges([cursor..cursor]);
1034 },
1035 );
1036 });
1037 }
1038 _ => {}
1039 }
1040 }
1041
1042 fn handle_rule_body_editor_event(
1043 &mut self,
1044 prompt_id: PromptId,
1045 body_editor: &Entity<Editor>,
1046 event: &EditorEvent,
1047 window: &mut Window,
1048 cx: &mut Context<Self>,
1049 ) {
1050 match event {
1051 EditorEvent::BufferEdited => {
1052 self.save_rule(prompt_id, window, cx);
1053 self.count_tokens(prompt_id, window, cx);
1054 }
1055 EditorEvent::Blurred => {
1056 body_editor.update(cx, |body_editor, cx| {
1057 body_editor.change_selections(
1058 SelectionEffects::no_scroll(),
1059 window,
1060 cx,
1061 |selections| {
1062 let cursor = selections.oldest_anchor().head();
1063 selections.select_anchor_ranges([cursor..cursor]);
1064 },
1065 );
1066 });
1067 }
1068 _ => {}
1069 }
1070 }
1071
1072 fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
1073 let Some(ConfiguredModel { model, .. }) =
1074 LanguageModelRegistry::read_global(cx).default_model()
1075 else {
1076 return;
1077 };
1078 if let Some(rule) = self.rule_editors.get_mut(&prompt_id) {
1079 let editor = &rule.body_editor.read(cx);
1080 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
1081 let body = buffer.as_rope().clone();
1082 rule.pending_token_count = cx.spawn_in(window, async move |this, cx| {
1083 async move {
1084 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
1085
1086 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
1087 let token_count = cx
1088 .update(|_, cx| {
1089 model.count_tokens(
1090 LanguageModelRequest {
1091 thread_id: None,
1092 prompt_id: None,
1093 intent: None,
1094 messages: vec![LanguageModelRequestMessage {
1095 role: Role::System,
1096 content: vec![body.to_string().into()],
1097 cache: false,
1098 reasoning_details: None,
1099 }],
1100 tools: Vec::new(),
1101 tool_choice: None,
1102 stop: Vec::new(),
1103 temperature: None,
1104 thinking_allowed: true,
1105 thinking_effort: None,
1106 speed: None,
1107 },
1108 cx,
1109 )
1110 })?
1111 .await?;
1112
1113 this.update(cx, |this, cx| {
1114 let rule_editor = this.rule_editors.get_mut(&prompt_id).unwrap();
1115 rule_editor.token_count = Some(token_count);
1116 cx.notify();
1117 })
1118 }
1119 .log_err()
1120 .await
1121 });
1122 }
1123 }
1124
1125 fn render_rule_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
1126 v_flex()
1127 .id("rule-list")
1128 .capture_action(cx.listener(Self::focus_active_rule))
1129 .h_full()
1130 .w_64()
1131 .overflow_x_hidden()
1132 .bg(cx.theme().colors().panel_background)
1133 .when(!cfg!(target_os = "macos"), |this| this.px_1p5())
1134 .map(|this| {
1135 if cfg!(target_os = "macos") {
1136 let Some(title_bar) = self.title_bar.as_ref() else {
1137 return this;
1138 };
1139 let button_padding = DynamicSpacing::Base08.rems(cx);
1140 let panel_background = cx.theme().colors().panel_background;
1141 title_bar.update(cx, |title_bar, _cx| {
1142 title_bar.set_background_color(Some(panel_background));
1143 title_bar.set_children(Some(
1144 h_flex()
1145 .w_full()
1146 .pr(button_padding)
1147 .justify_end()
1148 .child(
1149 div()
1150 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1151 cx.stop_propagation();
1152 })
1153 .child(
1154 IconButton::new("new-rule", IconName::Plus)
1155 .tooltip(move |_window, cx| {
1156 Tooltip::for_action("New Rule", &NewRule, cx)
1157 })
1158 .on_click(|_, window, cx| {
1159 window.dispatch_action(Box::new(NewRule), cx);
1160 }),
1161 ),
1162 )
1163 .into_any_element(),
1164 ));
1165 });
1166 this.child(title_bar.clone())
1167 } else {
1168 this.child(
1169 h_flex().p_1().w_full().child(
1170 Button::new("new-rule", "New Rule")
1171 .full_width()
1172 .style(ButtonStyle::Outlined)
1173 .start_icon(
1174 Icon::new(IconName::Plus)
1175 .size(IconSize::Small)
1176 .color(Color::Muted),
1177 )
1178 .on_click(|_, window, cx| {
1179 window.dispatch_action(Box::new(NewRule), cx);
1180 }),
1181 ),
1182 )
1183 }
1184 })
1185 .child(
1186 div()
1187 .flex_grow()
1188 .when(cfg!(target_os = "macos"), |this| this.px_1p5())
1189 .child(self.picker.clone()),
1190 )
1191 }
1192
1193 fn render_active_rule_editor(
1194 &self,
1195 editor: &Entity<Editor>,
1196 read_only: bool,
1197 cx: &mut Context<Self>,
1198 ) -> impl IntoElement {
1199 let settings = ThemeSettings::get_global(cx);
1200 let text_color = if read_only {
1201 cx.theme().colors().text_muted
1202 } else {
1203 cx.theme().colors().text
1204 };
1205
1206 div()
1207 .w_full()
1208 .pl_1()
1209 .border_1()
1210 .border_color(transparent_black())
1211 .rounded_sm()
1212 .when(!read_only, |this| {
1213 this.group_hover("active-editor-header", |this| {
1214 this.border_color(cx.theme().colors().border_variant)
1215 })
1216 })
1217 .on_action(cx.listener(Self::move_down_from_title))
1218 .child(EditorElement::new(
1219 &editor,
1220 EditorStyle {
1221 background: cx.theme().system().transparent,
1222 local_player: cx.theme().players().local(),
1223 text: TextStyle {
1224 color: text_color,
1225 font_family: settings.ui_font.family.clone(),
1226 font_features: settings.ui_font.features.clone(),
1227 font_size: HeadlineSize::Medium.rems().into(),
1228 font_weight: settings.ui_font.weight,
1229 line_height: relative(settings.buffer_line_height.value()),
1230 ..Default::default()
1231 },
1232 scrollbar_width: Pixels::ZERO,
1233 syntax: cx.theme().syntax().clone(),
1234 status: cx.theme().status().clone(),
1235 inlay_hints_style: editor::make_inlay_hints_style(cx),
1236 edit_prediction_styles: editor::make_suggestion_styles(cx),
1237 ..EditorStyle::default()
1238 },
1239 ))
1240 }
1241
1242 fn render_duplicate_rule_button(&self) -> impl IntoElement {
1243 IconButton::new("duplicate-rule", IconName::BookCopy)
1244 .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
1245 .on_click(|_, window, cx| {
1246 window.dispatch_action(Box::new(DuplicateRule), cx);
1247 })
1248 }
1249
1250 fn render_built_in_rule_controls(&self) -> impl IntoElement {
1251 h_flex()
1252 .gap_1()
1253 .child(self.render_duplicate_rule_button())
1254 .child(
1255 IconButton::new("restore-default", IconName::RotateCcw)
1256 .tooltip(move |_window, cx| {
1257 Tooltip::for_action(
1258 "Restore to Default Content",
1259 &RestoreDefaultContent,
1260 cx,
1261 )
1262 })
1263 .on_click(|_, window, cx| {
1264 window.dispatch_action(Box::new(RestoreDefaultContent), cx);
1265 }),
1266 )
1267 }
1268
1269 fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
1270 h_flex()
1271 .gap_1()
1272 .child(
1273 IconButton::new("toggle-default-rule", IconName::Paperclip)
1274 .toggle_state(default)
1275 .when(default, |this| this.icon_color(Color::Accent))
1276 .map(|this| {
1277 if default {
1278 this.tooltip(Tooltip::text("Remove from Default Rules"))
1279 } else {
1280 this.tooltip(move |_window, cx| {
1281 Tooltip::with_meta(
1282 "Add to Default Rules",
1283 None,
1284 "Always included in every thread.",
1285 cx,
1286 )
1287 })
1288 }
1289 })
1290 .on_click(|_, window, cx| {
1291 window.dispatch_action(Box::new(ToggleDefaultRule), cx);
1292 }),
1293 )
1294 .child(self.render_duplicate_rule_button())
1295 .child(
1296 IconButton::new("delete-rule", IconName::Trash)
1297 .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
1298 .on_click(|_, window, cx| {
1299 window.dispatch_action(Box::new(DeleteRule), cx);
1300 }),
1301 )
1302 }
1303
1304 fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
1305 div()
1306 .id("rule-editor")
1307 .h_full()
1308 .flex_grow()
1309 .border_l_1()
1310 .border_color(cx.theme().colors().border)
1311 .bg(cx.theme().colors().editor_background)
1312 .children(self.active_rule_id.and_then(|prompt_id| {
1313 let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
1314 let rule_editor = &self.rule_editors[&prompt_id];
1315 let focus_handle = rule_editor.body_editor.focus_handle(cx);
1316 let registry = LanguageModelRegistry::read_global(cx);
1317 let model = registry.default_model().map(|default| default.model);
1318 let built_in = prompt_id.is_built_in();
1319
1320 Some(
1321 v_flex()
1322 .id("rule-editor-inner")
1323 .size_full()
1324 .relative()
1325 .overflow_hidden()
1326 .on_click(cx.listener(move |_, _, window, cx| {
1327 window.focus(&focus_handle, cx);
1328 }))
1329 .child(
1330 h_flex()
1331 .group("active-editor-header")
1332 .h_12()
1333 .px_2()
1334 .gap_2()
1335 .justify_between()
1336 .child(self.render_active_rule_editor(
1337 &rule_editor.title_editor,
1338 built_in,
1339 cx,
1340 ))
1341 .child(
1342 h_flex()
1343 .h_full()
1344 .flex_shrink_0()
1345 .children(rule_editor.token_count.map(|token_count| {
1346 let token_count: SharedString =
1347 token_count.to_string().into();
1348 let label_token_count: SharedString =
1349 token_count.to_string().into();
1350
1351 div()
1352 .id("token_count")
1353 .mr_1()
1354 .flex_shrink_0()
1355 .tooltip(move |_window, cx| {
1356 Tooltip::with_meta(
1357 "Token Estimation",
1358 None,
1359 format!(
1360 "Model: {}",
1361 model
1362 .as_ref()
1363 .map(|model| model.name().0)
1364 .unwrap_or_default()
1365 ),
1366 cx,
1367 )
1368 })
1369 .child(
1370 Label::new(format!(
1371 "{} tokens",
1372 label_token_count
1373 ))
1374 .color(Color::Muted),
1375 )
1376 }))
1377 .map(|this| {
1378 if built_in {
1379 this.child(self.render_built_in_rule_controls())
1380 } else {
1381 this.child(self.render_regular_rule_controls(
1382 rule_metadata.default,
1383 ))
1384 }
1385 }),
1386 ),
1387 )
1388 .child(
1389 div()
1390 .on_action(cx.listener(Self::focus_picker))
1391 .on_action(cx.listener(Self::inline_assist))
1392 .on_action(cx.listener(Self::move_up_from_body))
1393 .h_full()
1394 .flex_grow()
1395 .child(
1396 h_flex()
1397 .py_2()
1398 .pl_2p5()
1399 .h_full()
1400 .flex_1()
1401 .child(rule_editor.body_editor.clone()),
1402 ),
1403 ),
1404 )
1405 }))
1406 }
1407}
1408
1409impl Render for RulesLibrary {
1410 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1411 let ui_font = theme::setup_ui_font(window, cx);
1412 let theme = cx.theme().clone();
1413
1414 client_side_decorations(
1415 v_flex()
1416 .id("rules-library")
1417 .key_context("RulesLibrary")
1418 .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
1419 .on_action(
1420 cx.listener(|this, &DeleteRule, window, cx| {
1421 this.delete_active_rule(window, cx)
1422 }),
1423 )
1424 .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
1425 this.duplicate_active_rule(window, cx)
1426 }))
1427 .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
1428 this.toggle_default_for_active_rule(window, cx)
1429 }))
1430 .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
1431 this.restore_default_content_for_active_rule(window, cx)
1432 }))
1433 .size_full()
1434 .overflow_hidden()
1435 .font(ui_font)
1436 .text_color(theme.colors().text)
1437 .when(!cfg!(target_os = "macos"), |this| {
1438 this.children(self.title_bar.clone())
1439 })
1440 .bg(theme.colors().background)
1441 .child(
1442 h_flex()
1443 .flex_1()
1444 .when(!cfg!(target_os = "macos"), |this| {
1445 this.border_t_1().border_color(cx.theme().colors().border)
1446 })
1447 .child(self.render_rule_list(cx))
1448 .child(self.render_active_rule(cx)),
1449 ),
1450 window,
1451 cx,
1452 Tiling::default(),
1453 )
1454 }
1455}