Polish prompt library UX (#12647)

This could still use some improvement UI-wise but the user experience
should be a lot better.

- [x] Show in "Window" application menu
- [x] Load prompt as it's selected in the picker
- [x] Refocus picker on `esc`
- [x] When creating a new prompt, if a new prompt already exists and is
unedited, activate it instead
- [x] Add `/default` command
- [x] Evaluate /commands on prompt insertion
- [x] Autocomplete /commands (but don't evaluate) during prompt editing
- [x] Show token count using the settings model, right-aligned in the
editor
- [x] Picker 
- [x] Sorted alpha
- [x] 2 sublists
    - Default
        - Empty state: Star a prompt to add it to your default prompt
        - Otherwise show prompts with star on hover
    - All
        - Move prompts with star on hover

Release Notes:

- N/A
This commit is contained in:
Antonio Scandurra 2024-06-04 18:36:54 +02:00 committed by GitHub
parent e4bb666eab
commit c5b22eee2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 716 additions and 358 deletions

10
Cargo.lock generated
View File

@ -339,7 +339,6 @@ dependencies = [
"anthropic", "anthropic",
"anyhow", "anyhow",
"assistant_slash_command", "assistant_slash_command",
"async-watch",
"cargo_toml", "cargo_toml",
"chrono", "chrono",
"client", "client",
@ -824,15 +823,6 @@ dependencies = [
"tungstenite 0.16.0", "tungstenite 0.16.0",
] ]
[[package]]
name = "async-watch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a078faf4e27c0c6cc0efb20e5da59dcccc04968ebf2801d8e0b2195124cdcdb2"
dependencies = [
"event-listener 2.5.3",
]
[[package]] [[package]]
name = "async_zip" name = "async_zip"
version = "0.0.17" version = "0.0.17"

View File

@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 1.75L5.88467 5.14092C5.82759 5.31446 5.73055 5.47218 5.60136 5.60136C5.47218 5.73055 5.31446 5.82759 5.14092 5.88467L1.75 7L5.14092 8.11533C5.31446 8.17241 5.47218 8.26945 5.60136 8.39864C5.73055 8.52782 5.82759 8.68554 5.88467 8.85908L7 12.25L8.11533 8.85908C8.17241 8.68554 8.26945 8.52782 8.39864 8.39864C8.52782 8.26945 8.68554 8.17241 8.85908 8.11533L12.25 7L8.85908 5.88467C8.68554 5.82759 8.52782 5.73055 8.39864 5.60136C8.26945 5.47218 8.17241 5.31446 8.11533 5.14092L7 1.75Z" fill="black" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.91667 1.75V4.08333M1.75 2.91667H4.08333" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.0833 9.91667V12.25M9.91667 11.0833H12.25" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1017 B

View File

@ -16,7 +16,6 @@ doctest = false
anyhow.workspace = true anyhow.workspace = true
anthropic = { workspace = true, features = ["schemars"] } anthropic = { workspace = true, features = ["schemars"] }
assistant_slash_command.workspace = true assistant_slash_command.workspace = true
async-watch.workspace = true
cargo_toml.workspace = true cargo_toml.workspace = true
chrono.workspace = true chrono.workspace = true
client.workspace = true client.workspace = true

View File

@ -19,14 +19,13 @@ use command_palette_hooks::CommandPaletteFilter;
pub(crate) use completion_provider::*; pub(crate) use completion_provider::*;
use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
pub(crate) use model_selector::*; pub(crate) use model_selector::*;
use prompt_library::PromptStore;
pub(crate) use saved_conversation::*; pub(crate) use saved_conversation::*;
use semantic_index::{CloudEmbeddingProvider, SemanticIndex}; use semantic_index::{CloudEmbeddingProvider, SemanticIndex};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
use slash_command::{ use slash_command::{
active_command, fetch_command, file_command, project_command, prompt_command, rustdoc_command, active_command, default_command, fetch_command, file_command, project_command, prompt_command,
search_command, tabs_command, rustdoc_command, search_command, tabs_command,
}; };
use std::{ use std::{
fmt::{self, Display}, fmt::{self, Display},
@ -303,18 +302,10 @@ fn register_slash_commands(cx: &mut AppContext) {
slash_command_registry.register_command(tabs_command::TabsSlashCommand, true); slash_command_registry.register_command(tabs_command::TabsSlashCommand, true);
slash_command_registry.register_command(project_command::ProjectSlashCommand, true); slash_command_registry.register_command(project_command::ProjectSlashCommand, true);
slash_command_registry.register_command(search_command::SearchSlashCommand, true); slash_command_registry.register_command(search_command::SearchSlashCommand, true);
slash_command_registry.register_command(prompt_command::PromptSlashCommand, true);
slash_command_registry.register_command(default_command::DefaultSlashCommand, true);
slash_command_registry.register_command(rustdoc_command::RustdocSlashCommand, false); slash_command_registry.register_command(rustdoc_command::RustdocSlashCommand, false);
slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
let store = PromptStore::global(cx);
cx.background_executor()
.spawn(async move {
let store = store.await?;
slash_command_registry
.register_command(prompt_command::PromptSlashCommand::new(store), true);
anyhow::Ok(())
})
.detach_and_log_err(cx);
} }
#[cfg(test)] #[cfg(test)]

View File

@ -1,11 +1,11 @@
use crate::{ use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings}, assistant_settings::{AssistantDockPosition, AssistantSettings},
codegen::{self, Codegen, CodegenKind}, codegen::{self, Codegen, CodegenKind},
prompt_library::{open_prompt_library, PromptMetadata, PromptStore}, prompt_library::open_prompt_library,
prompts::generate_content_prompt, prompts::generate_content_prompt,
search::*, search::*,
slash_command::{ slash_command::{
prompt_command::PromptPlaceholder, SlashCommandCompletionProvider, SlashCommandLine, default_command::DefaultSlashCommand, SlashCommandCompletionProvider, SlashCommandLine,
SlashCommandRegistry, SlashCommandRegistry,
}, },
ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist, ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist,
@ -14,7 +14,7 @@ use crate::{
SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector, SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector,
}; };
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection}; use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
use client::telemetry::Telemetry; use client::telemetry::Telemetry;
use collections::{hash_map, BTreeSet, HashMap, HashSet, VecDeque}; use collections::{hash_map, BTreeSet, HashMap, HashSet, VecDeque};
use editor::{actions::ShowCompletions, GutterDimensions}; use editor::{actions::ShowCompletions, GutterDimensions};
@ -40,10 +40,9 @@ use gpui::{
Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext,
WeakModel, WeakView, WhiteSpace, WindowContext, WeakModel, WeakView, WhiteSpace, WindowContext,
}; };
use language::LspAdapterDelegate;
use language::{ use language::{
language_settings::SoftWrap, AnchorRangeExt, AutoindentMode, Buffer, LanguageRegistry, language_settings::SoftWrap, AnchorRangeExt, AutoindentMode, Buffer, LanguageRegistry,
OffsetRangeExt as _, Point, ToOffset as _, LspAdapterDelegate, OffsetRangeExt as _, Point, ToOffset as _,
}; };
use multi_buffer::MultiBufferRow; use multi_buffer::MultiBufferRow;
use parking_lot::Mutex; use parking_lot::Mutex;
@ -118,14 +117,6 @@ pub struct AssistantPanel {
_watch_saved_conversations: Task<Result<()>>, _watch_saved_conversations: Task<Result<()>>,
authentication_prompt: Option<AnyView>, authentication_prompt: Option<AnyView>,
model_menu_handle: PopoverMenuHandle<ContextMenu>, model_menu_handle: PopoverMenuHandle<ContextMenu>,
default_prompt: DefaultPrompt,
_watch_prompt_store: Task<()>,
}
#[derive(Default)]
struct DefaultPrompt {
text: String,
sections: Vec<SlashCommandOutputSection<usize>>,
} }
struct ActiveConversationEditor { struct ActiveConversationEditor {
@ -146,8 +137,6 @@ impl AssistantPanel {
.await .await
.log_err() .log_err()
.unwrap_or_default(); .unwrap_or_default();
let prompt_store = cx.update(|cx| PromptStore::global(cx))?.await?;
let default_prompts = prompt_store.load_default().await?;
// TODO: deserialize state. // TODO: deserialize state.
let workspace_handle = workspace.clone(); let workspace_handle = workspace.clone();
@ -173,22 +162,6 @@ impl AssistantPanel {
anyhow::Ok(()) anyhow::Ok(())
}); });
let _watch_prompt_store = cx.spawn(|this, mut cx| async move {
let mut updates = prompt_store.updates();
while updates.changed().await.is_ok() {
let Some(prompts) = prompt_store.load_default().await.log_err() else {
continue;
};
if this
.update(&mut cx, |this, _cx| this.update_default_prompt(prompts))
.is_err()
{
break;
}
}
});
let toolbar = cx.new_view(|cx| { let toolbar = cx.new_view(|cx| {
let mut toolbar = Toolbar::new(); let mut toolbar = Toolbar::new();
toolbar.set_can_navigate(false, cx); toolbar.set_can_navigate(false, cx);
@ -216,7 +189,7 @@ impl AssistantPanel {
}) })
.detach(); .detach();
let mut this = Self { Self {
workspace: workspace_handle, workspace: workspace_handle,
active_conversation_editor: None, active_conversation_editor: None,
show_saved_conversations: false, show_saved_conversations: false,
@ -239,11 +212,7 @@ impl AssistantPanel {
_watch_saved_conversations, _watch_saved_conversations,
authentication_prompt: None, authentication_prompt: None,
model_menu_handle: PopoverMenuHandle::default(), model_menu_handle: PopoverMenuHandle::default(),
default_prompt: DefaultPrompt::default(), }
_watch_prompt_store,
};
this.update_default_prompt(default_prompts);
this
}) })
}) })
}) })
@ -266,55 +235,6 @@ impl AssistantPanel {
cx.notify(); cx.notify();
} }
fn update_default_prompt(&mut self, prompts: Vec<(PromptMetadata, String)>) {
self.default_prompt.text.clear();
self.default_prompt.sections.clear();
if !prompts.is_empty() {
self.default_prompt.text.push_str("Default Prompt:\n");
}
for (metadata, body) in prompts {
let section_start = self.default_prompt.text.len();
self.default_prompt.text.push_str(&body);
let section_end = self.default_prompt.text.len();
self.default_prompt
.sections
.push(SlashCommandOutputSection {
range: section_start..section_end,
render_placeholder: Arc::new(move |id, unfold, _cx| {
PromptPlaceholder {
title: metadata
.title
.clone()
.unwrap_or_else(|| SharedString::from("Untitled")),
id,
unfold,
}
.into_any_element()
}),
});
self.default_prompt.text.push('\n');
}
self.default_prompt.text.pop();
if !self.default_prompt.text.is_empty() {
self.default_prompt.sections.insert(
0,
SlashCommandOutputSection {
range: 0..self.default_prompt.text.len(),
render_placeholder: Arc::new(move |id, unfold, _cx| {
PromptPlaceholder {
title: "Default".into(),
id,
unfold,
}
.into_any_element()
}),
},
)
}
}
fn completion_provider_changed( fn completion_provider_changed(
&mut self, &mut self,
prev_settings_version: usize, prev_settings_version: usize,
@ -862,7 +782,6 @@ impl AssistantPanel {
let editor = cx.new_view(|cx| { let editor = cx.new_view(|cx| {
ConversationEditor::new( ConversationEditor::new(
&self.default_prompt,
self.languages.clone(), self.languages.clone(),
self.slash_commands.clone(), self.slash_commands.clone(),
self.fs.clone(), self.fs.clone(),
@ -1460,7 +1379,9 @@ enum ConversationEvent {
updated: Vec<PendingSlashCommand>, updated: Vec<PendingSlashCommand>,
}, },
SlashCommandFinished { SlashCommandFinished {
output_range: Range<language::Anchor>,
sections: Vec<SlashCommandOutputSection<language::Anchor>>, sections: Vec<SlashCommandOutputSection<language::Anchor>>,
run_commands_in_output: bool,
}, },
} }
@ -1727,18 +1648,7 @@ impl Conversation {
buffer.line_len(row_range.end - 1), buffer.line_len(row_range.end - 1),
)); ));
let start_ix = match self let old_range = self.pending_command_indices_for_range(start..end, cx);
.pending_slash_commands
.binary_search_by(|probe| probe.source_range.start.cmp(&start, buffer))
{
Ok(ix) | Err(ix) => ix,
};
let end_ix = match self.pending_slash_commands[start_ix..]
.binary_search_by(|probe| probe.source_range.end.cmp(&end, buffer))
{
Ok(ix) => start_ix + ix + 1,
Err(ix) => start_ix + ix,
};
let mut new_commands = Vec::new(); let mut new_commands = Vec::new();
let mut lines = buffer.text_for_range(start..end).lines(); let mut lines = buffer.text_for_range(start..end).lines();
@ -1773,9 +1683,7 @@ impl Conversation {
offset = lines.offset(); offset = lines.offset();
} }
let removed_commands = self let removed_commands = self.pending_slash_commands.splice(old_range, new_commands);
.pending_slash_commands
.splice(start_ix..end_ix, new_commands);
removed.extend(removed_commands.map(|command| command.source_range)); removed.extend(removed_commands.map(|command| command.source_range));
} }
@ -1849,25 +1757,60 @@ impl Conversation {
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Option<&mut PendingSlashCommand> { ) -> Option<&mut PendingSlashCommand> {
let buffer = self.buffer.read(cx); let buffer = self.buffer.read(cx);
let ix = self match self
.pending_slash_commands .pending_slash_commands
.binary_search_by(|probe| { .binary_search_by(|probe| probe.source_range.end.cmp(&position, buffer))
if probe.source_range.start.cmp(&position, buffer).is_gt() { {
Ordering::Less Ok(ix) => Some(&mut self.pending_slash_commands[ix]),
} else if probe.source_range.end.cmp(&position, buffer).is_lt() { Err(ix) => {
Ordering::Greater let cmd = self.pending_slash_commands.get_mut(ix)?;
if position.cmp(&cmd.source_range.start, buffer).is_ge()
&& position.cmp(&cmd.source_range.end, buffer).is_le()
{
Some(cmd)
} else { } else {
Ordering::Equal None
} }
}) }
.ok()?; }
self.pending_slash_commands.get_mut(ix) }
fn pending_commands_for_range(
&self,
range: Range<language::Anchor>,
cx: &AppContext,
) -> &[PendingSlashCommand] {
let range = self.pending_command_indices_for_range(range, cx);
&self.pending_slash_commands[range]
}
fn pending_command_indices_for_range(
&self,
range: Range<language::Anchor>,
cx: &AppContext,
) -> Range<usize> {
let buffer = self.buffer.read(cx);
let start_ix = match self
.pending_slash_commands
.binary_search_by(|probe| probe.source_range.end.cmp(&range.start, &buffer))
{
Ok(ix) | Err(ix) => ix,
};
let end_ix = match self
.pending_slash_commands
.binary_search_by(|probe| probe.source_range.start.cmp(&range.end, &buffer))
{
Ok(ix) => ix + 1,
Err(ix) => ix,
};
start_ix..end_ix
} }
fn insert_command_output( fn insert_command_output(
&mut self, &mut self,
command_range: Range<language::Anchor>, command_range: Range<language::Anchor>,
output: Task<Result<SlashCommandOutput>>, output: Task<Result<SlashCommandOutput>>,
insert_trailing_newline: bool,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) { ) {
self.reparse_slash_commands(cx); self.reparse_slash_commands(cx);
@ -1878,13 +1821,14 @@ impl Conversation {
let output = output.await; let output = output.await;
this.update(&mut cx, |this, cx| match output { this.update(&mut cx, |this, cx| match output {
Ok(mut output) => { Ok(mut output) => {
if !output.text.ends_with('\n') { if insert_trailing_newline {
output.text.push('\n'); output.text.push('\n');
} }
let sections = this.buffer.update(cx, |buffer, cx| { let event = this.buffer.update(cx, |buffer, cx| {
let start = command_range.start.to_offset(buffer); let start = command_range.start.to_offset(buffer);
let old_end = command_range.end.to_offset(buffer); let old_end = command_range.end.to_offset(buffer);
let new_end = start + output.text.len();
buffer.edit([(start..old_end, output.text)], None, cx); buffer.edit([(start..old_end, output.text)], None, cx);
let mut sections = output let mut sections = output
@ -1897,9 +1841,14 @@ impl Conversation {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
sections.sort_by(|a, b| a.range.cmp(&b.range, buffer)); sections.sort_by(|a, b| a.range.cmp(&b.range, buffer));
sections ConversationEvent::SlashCommandFinished {
output_range: buffer.anchor_after(start)
..buffer.anchor_before(new_end),
sections,
run_commands_in_output: output.run_commands_in_text,
}
}); });
cx.emit(ConversationEvent::SlashCommandFinished { sections }); cx.emit(event);
} }
Err(error) => { Err(error) => {
if let Some(pending_command) = if let Some(pending_command) =
@ -2596,7 +2545,6 @@ pub struct ConversationEditor {
impl ConversationEditor { impl ConversationEditor {
fn new( fn new(
default_prompt: &DefaultPrompt,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
slash_command_registry: Arc<SlashCommandRegistry>, slash_command_registry: Arc<SlashCommandRegistry>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
@ -2618,31 +2566,7 @@ impl ConversationEditor {
let mut this = let mut this =
Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx); Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx);
this.insert_default_prompt(cx);
if !default_prompt.text.is_empty() {
this.editor
.update(cx, |editor, cx| editor.insert(&default_prompt.text, cx));
let snapshot = this.conversation.read(cx).buffer.read(cx).text_snapshot();
this.insert_slash_command_output_sections(
default_prompt
.sections
.iter()
.map(|section| SlashCommandOutputSection {
range: snapshot.anchor_after(section.range.start)
..snapshot.anchor_before(section.range.end),
render_placeholder: section.render_placeholder.clone(),
}),
cx,
);
this.split(&Split, cx);
this.conversation.update(cx, |this, _cx| {
this.messages_metadata
.get_mut(&MessageId::default())
.unwrap()
.role = Role::System;
});
}
this this
} }
@ -2695,6 +2619,32 @@ impl ConversationEditor {
this this
} }
fn insert_default_prompt(&mut self, cx: &mut ViewContext<Self>) {
let command_name = DefaultSlashCommand.name();
self.editor.update(cx, |editor, cx| {
editor.insert(&format!("/{command_name}"), cx)
});
self.split(&Split, cx);
let command = self.conversation.update(cx, |conversation, cx| {
conversation
.messages_metadata
.get_mut(&MessageId::default())
.unwrap()
.role = Role::System;
conversation.reparse_slash_commands(cx);
conversation.pending_slash_commands[0].clone()
});
self.run_command(
command.source_range,
&command.name,
command.argument.as_deref(),
false,
self.workspace.clone(),
cx,
);
}
fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) { fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
let cursors = self.cursors(cx); let cursors = self.cursors(cx);
@ -2817,6 +2767,7 @@ impl ConversationEditor {
command.source_range, command.source_range,
&command.name, &command.name,
command.argument.as_deref(), command.argument.as_deref(),
true,
workspace.clone(), workspace.clone(),
cx, cx,
); );
@ -2830,6 +2781,7 @@ impl ConversationEditor {
command_range: Range<language::Anchor>, command_range: Range<language::Anchor>,
name: &str, name: &str,
argument: Option<&str>, argument: Option<&str>,
insert_trailing_newline: bool,
workspace: WeakView<Workspace>, workspace: WeakView<Workspace>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
@ -2838,7 +2790,12 @@ impl ConversationEditor {
let argument = argument.map(ToString::to_string); let argument = argument.map(ToString::to_string);
let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, cx); let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, cx);
self.conversation.update(cx, |conversation, cx| { self.conversation.update(cx, |conversation, cx| {
conversation.insert_command_output(command_range, output, cx) conversation.insert_command_output(
command_range,
output,
insert_trailing_newline,
cx,
)
}); });
} }
} }
@ -2938,6 +2895,7 @@ impl ConversationEditor {
command.source_range.clone(), command.source_range.clone(),
&command.name, &command.name,
command.argument.as_deref(), command.argument.as_deref(),
false,
workspace.clone(), workspace.clone(),
cx, cx,
); );
@ -2991,8 +2949,32 @@ impl ConversationEditor {
); );
}) })
} }
ConversationEvent::SlashCommandFinished { sections } => { ConversationEvent::SlashCommandFinished {
output_range,
sections,
run_commands_in_output,
} => {
self.insert_slash_command_output_sections(sections.iter().cloned(), cx); self.insert_slash_command_output_sections(sections.iter().cloned(), cx);
if *run_commands_in_output {
let commands = self.conversation.update(cx, |conversation, cx| {
conversation.reparse_slash_commands(cx);
conversation
.pending_commands_for_range(output_range.clone(), cx)
.to_vec()
});
for command in commands {
self.run_command(
command.source_range,
&command.name,
command.argument.as_deref(),
false,
self.workspace.clone(),
cx,
);
}
}
} }
} }
} }

View File

@ -1,33 +1,40 @@
use crate::{
slash_command::SlashCommandLine, CompletionProvider, LanguageModelRequest,
LanguageModelRequestMessage, Role,
};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use assistant_slash_command::SlashCommandRegistry;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use collections::HashMap; use collections::HashMap;
use editor::{Editor, EditorEvent}; use editor::{actions::Tab, Editor, EditorEvent};
use futures::{ use futures::{
future::{self, BoxFuture, Shared}, future::{self, BoxFuture, Shared},
FutureExt, FutureExt,
}; };
use fuzzy::StringMatchCandidate; use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{ use gpui::{
actions, point, size, AppContext, BackgroundExecutor, Bounds, DevicePixels, Empty, actions, point, size, AnyElement, AppContext, BackgroundExecutor, Bounds, DevicePixels,
EventEmitter, Global, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions, View, EventEmitter, Global, Model, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions,
WindowBounds, WindowHandle, WindowOptions, View, WindowBounds, WindowHandle, WindowOptions,
}; };
use heed::{types::SerdeBincode, Database, RoTxn}; use heed::{types::SerdeBincode, Database, RoTxn};
use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; use language::{
language_settings::SoftWrap, Buffer, Documentation, LanguageRegistry, LanguageServerId, Point,
ToPoint as _,
};
use parking_lot::RwLock; use parking_lot::RwLock;
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use rope::Rope; use rope::Rope;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
cmp::Reverse,
future::Future, future::Future,
path::PathBuf, path::PathBuf,
sync::{atomic::AtomicBool, Arc}, sync::{atomic::AtomicBool, Arc},
time::Duration, time::Duration,
}; };
use ui::{ use ui::{
div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render, div, prelude::*, IconButtonShape, ListHeader, ListItem, ListItemSpacing, ListSubHeader,
SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext, ParentElement, Render, SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext,
}; };
use util::{paths::PROMPTS_DIR, ResultExt, TryFutureExt}; use util::{paths::PROMPTS_DIR, ResultExt, TryFutureExt};
use uuid::Uuid; use uuid::Uuid;
@ -80,7 +87,7 @@ pub fn open_prompt_library(
cx.open_window( cx.open_window(
WindowOptions { WindowOptions {
titlebar: Some(TitlebarOptions { titlebar: Some(TitlebarOptions {
title: None, title: Some("Prompt Library".into()),
appears_transparent: true, appears_transparent: true,
traffic_light_position: Some(point(px(9.0), px(9.0))), traffic_light_position: Some(point(px(9.0), px(9.0))),
}), }),
@ -106,6 +113,8 @@ pub struct PromptLibrary {
struct PromptEditor { struct PromptEditor {
editor: View<Editor>, editor: View<Editor>,
token_count: Option<usize>,
pending_token_count: Task<Option<()>>,
next_body_to_save: Option<Rope>, next_body_to_save: Option<Rope>,
pending_save: Option<Task<Option<()>>>, pending_save: Option<Task<Option<()>>>,
_subscription: Subscription, _subscription: Subscription,
@ -114,30 +123,54 @@ struct PromptEditor {
struct PromptPickerDelegate { struct PromptPickerDelegate {
store: Arc<PromptStore>, store: Arc<PromptStore>,
selected_index: usize, selected_index: usize,
matches: Vec<PromptMetadata>, entries: Vec<PromptPickerEntry>,
} }
enum PromptPickerEvent { enum PromptPickerEvent {
Selected { prompt_id: PromptId },
Confirmed { prompt_id: PromptId }, Confirmed { prompt_id: PromptId },
Deleted { prompt_id: PromptId }, Deleted { prompt_id: PromptId },
ToggledDefault { prompt_id: PromptId }, ToggledDefault { prompt_id: PromptId },
} }
#[derive(Debug)]
enum PromptPickerEntry {
DefaultPromptsHeader,
DefaultPromptsEmpty,
AllPromptsHeader,
AllPromptsEmpty,
Prompt(PromptMetadata),
}
impl PromptPickerEntry {
fn prompt_id(&self) -> Option<PromptId> {
match self {
PromptPickerEntry::Prompt(metadata) => Some(metadata.id),
_ => None,
}
}
}
impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {} impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
impl PickerDelegate for PromptPickerDelegate { impl PickerDelegate for PromptPickerDelegate {
type ListItem = ListItem; type ListItem = AnyElement;
fn match_count(&self) -> usize { fn match_count(&self) -> usize {
self.matches.len() self.entries.len()
} }
fn selected_index(&self) -> usize { fn selected_index(&self) -> usize {
self.selected_index self.selected_index
} }
fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) { fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
self.selected_index = ix; self.selected_index = ix;
if let Some(PromptPickerEntry::Prompt(prompt)) = self.entries.get(self.selected_index) {
cx.emit(PromptPickerEvent::Selected {
prompt_id: prompt.id,
});
}
} }
fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> { fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
@ -146,11 +179,49 @@ impl PickerDelegate for PromptPickerDelegate {
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> { fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
let search = self.store.search(query); let search = self.store.search(query);
let prev_prompt_id = self
.entries
.get(self.selected_index)
.and_then(|mat| mat.prompt_id());
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
let matches = search.await; let (entries, selected_index) = cx
.background_executor()
.spawn(async move {
let prompts = search.await;
let (default_prompts, prompts) = prompts
.into_iter()
.partition::<Vec<_>, _>(|prompt| prompt.default);
let mut entries = Vec::new();
entries.push(PromptPickerEntry::DefaultPromptsHeader);
if default_prompts.is_empty() {
entries.push(PromptPickerEntry::DefaultPromptsEmpty);
} else {
entries.extend(default_prompts.into_iter().map(PromptPickerEntry::Prompt));
}
entries.push(PromptPickerEntry::AllPromptsHeader);
if prompts.is_empty() {
entries.push(PromptPickerEntry::AllPromptsEmpty);
} else {
entries.extend(prompts.into_iter().map(PromptPickerEntry::Prompt));
}
let selected_index = prev_prompt_id
.and_then(|prev_prompt_id| {
entries
.iter()
.position(|entry| entry.prompt_id() == Some(prev_prompt_id))
})
.or_else(|| entries.iter().position(|entry| entry.prompt_id().is_some()))
.unwrap_or(0);
(entries, selected_index)
})
.await;
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.delegate.selected_index = 0; this.delegate.entries = entries;
this.delegate.matches = matches; this.delegate.set_selected_index(selected_index, cx);
cx.notify(); cx.notify();
}) })
.ok(); .ok();
@ -158,7 +229,7 @@ impl PickerDelegate for PromptPickerDelegate {
} }
fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) { fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
if let Some(prompt) = self.matches.get(self.selected_index) { if let Some(PromptPickerEntry::Prompt(prompt)) = self.entries.get(self.selected_index) {
cx.emit(PromptPickerEvent::Confirmed { cx.emit(PromptPickerEvent::Confirmed {
prompt_id: prompt.id, prompt_id: prompt.id,
}); });
@ -173,61 +244,80 @@ impl PickerDelegate for PromptPickerDelegate {
selected: bool, selected: bool,
cx: &mut ViewContext<Picker<Self>>, cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> { ) -> Option<Self::ListItem> {
let prompt = self.matches.get(ix)?; let prompt = self.entries.get(ix)?;
let default = prompt.default; let element = match prompt {
let prompt_id = prompt.id; PromptPickerEntry::DefaultPromptsHeader => ListHeader::new("Default Prompts")
Some(
ListItem::new(ix)
.inset(true) .inset(true)
.spacing(ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::ZedAssistant))
.selected(selected) .selected(selected)
.child(Label::new( .into_any_element(),
prompt.title.clone().unwrap_or("Untitled".into()), PromptPickerEntry::DefaultPromptsEmpty => {
)) ListSubHeader::new("Star a prompt to add it to the default context")
.end_slot(if default { .inset(true)
IconButton::new("toggle-default-prompt", IconName::StarFilled) .selected(selected)
.shape(IconButtonShape::Square) .into_any_element()
.into_any_element() }
} else { PromptPickerEntry::AllPromptsHeader => ListHeader::new("All Prompts")
Empty.into_any() .inset(true)
}) .start_slot(Icon::new(IconName::Library))
.end_hover_slot( .selected(selected)
h_flex() .into_any_element(),
.gap_2() PromptPickerEntry::AllPromptsEmpty => ListSubHeader::new("No prompts")
.child( .inset(true)
IconButton::new("delete-prompt", IconName::Trash) .selected(selected)
.shape(IconButtonShape::Square) .into_any_element(),
.tooltip(move |cx| Tooltip::text("Delete Prompt", cx)) PromptPickerEntry::Prompt(prompt) => {
.on_click(cx.listener(move |_, _, cx| { let default = prompt.default;
cx.emit(PromptPickerEvent::Deleted { prompt_id }) let prompt_id = prompt.id;
})), ListItem::new(ix)
) .inset(true)
.child( .spacing(ListItemSpacing::Sparse)
IconButton::new( .selected(selected)
"toggle-default-prompt", .child(Label::new(
if default { prompt.title.clone().unwrap_or("Untitled".into()),
IconName::StarFilled ))
} else { .end_hover_slot(
IconName::Star h_flex()
}, .gap_2()
.child(
IconButton::new("delete-prompt", IconName::Trash)
.shape(IconButtonShape::Square)
.tooltip(move |cx| Tooltip::text("Delete Prompt", cx))
.on_click(cx.listener(move |_, _, cx| {
cx.emit(PromptPickerEvent::Deleted { prompt_id })
})),
) )
.shape(IconButtonShape::Square) .child(
.tooltip(move |cx| { IconButton::new(
Tooltip::text( "toggle-default-prompt",
if default { if default {
"Remove from Default Prompt" IconName::ZedAssistantFilled
} else { } else {
"Add to Default Prompt" IconName::ZedAssistant
}, },
cx,
) )
}) .shape(IconButtonShape::Square)
.on_click(cx.listener(move |_, _, cx| { .tooltip(move |cx| {
cx.emit(PromptPickerEvent::ToggledDefault { prompt_id }) Tooltip::text(
})), if default {
), "Remove from Default Prompt"
), } else {
) "Add to Default Prompt"
},
cx,
)
})
.on_click(cx.listener(
move |_, _, cx| {
cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
},
)),
),
)
.into_any_element()
}
};
Some(element)
} }
} }
@ -240,17 +330,15 @@ impl PromptLibrary {
let delegate = PromptPickerDelegate { let delegate = PromptPickerDelegate {
store: store.clone(), store: store.clone(),
selected_index: 0, selected_index: 0,
matches: Vec::new(), entries: Vec::new(),
}; };
let picker = cx.new_view(|cx| { let picker = cx.new_view(|cx| {
let picker = Picker::uniform_list(delegate, cx) let picker = Picker::list(delegate, cx).modal(false).max_height(None);
.modal(false)
.max_height(None);
picker.focus(cx); picker.focus(cx);
picker picker
}); });
let mut this = Self { Self {
store: store.clone(), store: store.clone(),
language_registry, language_registry,
prompt_editors: HashMap::default(), prompt_editors: HashMap::default(),
@ -258,11 +346,7 @@ impl PromptLibrary {
pending_load: Task::ready(()), pending_load: Task::ready(()),
_subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)], _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)],
picker, picker,
};
if let Some(prompt_id) = store.most_recently_saved() {
this.load_prompt(prompt_id, false, cx);
} }
this
} }
fn handle_picker_event( fn handle_picker_event(
@ -272,6 +356,9 @@ impl PromptLibrary {
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
match event { match event {
PromptPickerEvent::Selected { prompt_id } => {
self.load_prompt(*prompt_id, false, cx);
}
PromptPickerEvent::Confirmed { prompt_id } => { PromptPickerEvent::Confirmed { prompt_id } => {
self.load_prompt(*prompt_id, true, cx); self.load_prompt(*prompt_id, true, cx);
} }
@ -285,6 +372,15 @@ impl PromptLibrary {
} }
pub fn new_prompt(&mut self, cx: &mut ViewContext<Self>) { pub fn new_prompt(&mut self, cx: &mut ViewContext<Self>) {
// If we already have an untitled prompt, use that instead
// of creating a new one.
if let Some(metadata) = self.store.first() {
if metadata.title.is_none() {
self.load_prompt(metadata.id, true, cx);
return;
}
}
let prompt_id = PromptId::new(); let prompt_id = PromptId::new();
let save = self.store.save(prompt_id, None, false, "".into()); let save = self.store.save(prompt_id, None, false, "".into());
self.picker.update(cx, |picker, cx| picker.refresh(cx)); self.picker.update(cx, |picker, cx| picker.refresh(cx));
@ -383,7 +479,7 @@ impl PromptLibrary {
.editor .editor
.update(cx, |editor, cx| editor.focus(cx)); .update(cx, |editor, cx| editor.focus(cx));
} }
self.active_prompt_id = Some(prompt_id); self.set_active_prompt(Some(prompt_id), cx);
} else { } else {
let language_registry = self.language_registry.clone(); let language_registry = self.language_registry.clone();
let prompt = self.store.load(prompt_id); let prompt = self.store.load(prompt_id);
@ -404,6 +500,8 @@ impl PromptLibrary {
editor.set_show_gutter(false, cx); editor.set_show_gutter(false, cx);
editor.set_show_wrap_guides(false, cx); editor.set_show_wrap_guides(false, cx);
editor.set_show_indent_guides(false, cx); editor.set_show_indent_guides(false, cx);
editor
.set_completion_provider(Box::new(SlashCommandCompletionProvider));
if focus { if focus {
editor.focus(cx); editor.focus(cx);
} }
@ -419,11 +517,13 @@ impl PromptLibrary {
editor, editor,
next_body_to_save: None, next_body_to_save: None,
pending_save: None, pending_save: None,
token_count: None,
pending_token_count: Task::ready(None),
_subscription, _subscription,
}, },
); );
this.active_prompt_id = Some(prompt_id); this.set_active_prompt(Some(prompt_id), cx);
cx.notify(); this.count_tokens(prompt_id, cx);
} }
Err(error) => { Err(error) => {
// TODO: we should show the error in the UI. // TODO: we should show the error in the UI.
@ -435,6 +535,32 @@ impl PromptLibrary {
} }
} }
fn set_active_prompt(&mut self, prompt_id: Option<PromptId>, cx: &mut ViewContext<Self>) {
self.active_prompt_id = prompt_id;
self.picker.update(cx, |picker, cx| {
if let Some(prompt_id) = prompt_id {
if picker
.delegate
.entries
.get(picker.delegate.selected_index())
.map_or(true, |old_selected_prompt| {
old_selected_prompt.prompt_id() != Some(prompt_id)
})
{
if let Some(ix) = picker
.delegate
.entries
.iter()
.position(|mat| mat.prompt_id() == Some(prompt_id))
{
picker.set_selected_index(ix, true, cx);
}
}
}
});
cx.notify();
}
pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) { pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
if let Some(metadata) = self.store.metadata(prompt_id) { if let Some(metadata) = self.store.metadata(prompt_id) {
let confirmation = cx.prompt( let confirmation = cx.prompt(
@ -451,7 +577,7 @@ impl PromptLibrary {
if confirmation.await.ok() == Some(0) { if confirmation.await.ok() == Some(0) {
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
if this.active_prompt_id == Some(prompt_id) { if this.active_prompt_id == Some(prompt_id) {
this.active_prompt_id = None; this.set_active_prompt(None, cx);
} }
this.prompt_editors.remove(&prompt_id); this.prompt_editors.remove(&prompt_id);
this.store.delete(prompt_id).detach_and_log_err(cx); this.store.delete(prompt_id).detach_and_log_err(cx);
@ -465,6 +591,19 @@ impl PromptLibrary {
} }
} }
fn focus_active_prompt(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
if let Some(active_prompt) = self.active_prompt_id {
self.prompt_editors[&active_prompt]
.editor
.update(cx, |editor, cx| editor.focus(cx));
cx.stop_propagation();
}
}
fn focus_picker(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
self.picker.update(cx, |picker, cx| picker.focus(cx));
}
fn handle_prompt_editor_event( fn handle_prompt_editor_event(
&mut self, &mut self,
prompt_id: PromptId, prompt_id: PromptId,
@ -502,12 +641,53 @@ impl PromptLibrary {
}); });
self.save_prompt(prompt_id, cx); self.save_prompt(prompt_id, cx);
self.count_tokens(prompt_id, cx);
}
}
fn count_tokens(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
let editor = &prompt.editor.read(cx);
let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
let body = buffer.as_rope().clone();
prompt.pending_token_count = cx.spawn(|this, mut cx| {
async move {
const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
let token_count = cx
.update(|cx| {
let provider = CompletionProvider::global(cx);
let model = provider.model();
provider.count_tokens(
LanguageModelRequest {
model,
messages: vec![LanguageModelRequestMessage {
role: Role::System,
content: body.to_string(),
}],
stop: Vec::new(),
temperature: 1.,
},
cx,
)
})?
.await?;
this.update(&mut cx, |this, cx| {
let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
prompt_editor.token_count = Some(token_count);
cx.notify();
})
}
.log_err()
});
} }
} }
fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_flex() v_flex()
.id("prompt-list") .id("prompt-list")
.capture_action(cx.listener(Self::focus_active_prompt))
.bg(cx.theme().colors().panel_background) .bg(cx.theme().colors().panel_background)
.h_full() .h_full()
.w_1_3() .w_1_3()
@ -545,64 +725,69 @@ impl PromptLibrary {
.min_w_64() .min_w_64()
.children(self.active_prompt_id.and_then(|prompt_id| { .children(self.active_prompt_id.and_then(|prompt_id| {
let prompt_metadata = self.store.metadata(prompt_id)?; let prompt_metadata = self.store.metadata(prompt_id)?;
let editor = self.prompt_editors[&prompt_id].editor.clone(); let prompt_editor = &self.prompt_editors[&prompt_id];
Some( Some(
v_flex() h_flex()
.size_full() .size_full()
.items_start()
.child( .child(
h_flex() div()
.h(TitleBar::height(cx)) .on_action(cx.listener(Self::focus_picker))
.px(Spacing::Large.rems(cx)) .flex_grow()
.justify_end() .h_full()
.child( .pt(Spacing::Large.rems(cx))
h_flex() .pl(Spacing::Large.rems(cx))
.gap_4() .child(prompt_editor.editor.clone()),
.child(
IconButton::new(
"toggle-default-prompt",
if prompt_metadata.default {
IconName::StarFilled
} else {
IconName::Star
},
)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::for_action(
if prompt_metadata.default {
"Remove from Default Prompt"
} else {
"Add to Default Prompt"
},
&ToggleDefaultPrompt,
cx,
)
})
.on_click(
|_, cx| {
cx.dispatch_action(Box::new(
ToggleDefaultPrompt,
));
},
),
)
.child(
IconButton::new("delete-prompt", IconName::Trash)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::for_action(
"Delete Prompt",
&DeletePrompt,
cx,
)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(DeletePrompt));
}),
),
),
) )
.child(div().flex_grow().p(Spacing::Large.rems(cx)).child(editor)), .child(
v_flex()
.w_12()
.py(Spacing::Large.rems(cx))
.justify_start()
.items_center()
.gap_4()
.child(
IconButton::new(
"toggle-default-prompt",
if prompt_metadata.default {
IconName::ZedAssistantFilled
} else {
IconName::ZedAssistant
},
)
.size(ButtonSize::Large)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::for_action(
if prompt_metadata.default {
"Remove from Default Prompt"
} else {
"Add to Default Prompt"
},
&ToggleDefaultPrompt,
cx,
)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(ToggleDefaultPrompt));
}),
)
.child(
IconButton::new("delete-prompt", IconName::Trash)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::for_action("Delete Prompt", &DeletePrompt, cx)
})
.on_click(|_, cx| {
cx.dispatch_action(Box::new(DeletePrompt));
}),
)
.children(prompt_editor.token_count.map(|token_count| {
h_flex()
.justify_center()
.child(Label::new(token_count.to_string()))
})),
),
) )
})) }))
} }
@ -648,7 +833,6 @@ pub struct PromptStore {
bodies: Database<SerdeBincode<PromptId>, SerdeBincode<String>>, bodies: Database<SerdeBincode<PromptId>, SerdeBincode<String>>,
metadata: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>, metadata: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
metadata_cache: RwLock<MetadataCache>, metadata_cache: RwLock<MetadataCache>,
updates: (Arc<async_watch::Sender<()>>, async_watch::Receiver<()>),
} }
#[derive(Default)] #[derive(Default)]
@ -668,9 +852,7 @@ impl MetadataCache {
cache.metadata.push(metadata.clone()); cache.metadata.push(metadata.clone());
cache.metadata_by_id.insert(prompt_id, metadata); cache.metadata_by_id.insert(prompt_id, metadata);
} }
cache cache.sort();
.metadata
.sort_unstable_by_key(|metadata| Reverse(metadata.saved_at));
Ok(cache) Ok(cache)
} }
@ -681,13 +863,21 @@ impl MetadataCache {
} else { } else {
self.metadata.push(metadata); self.metadata.push(metadata);
} }
self.metadata.sort_by_key(|m| Reverse(m.saved_at)); self.sort();
} }
fn remove(&mut self, id: PromptId) { fn remove(&mut self, id: PromptId) {
self.metadata.retain(|metadata| metadata.id != id); self.metadata.retain(|metadata| metadata.id != id);
self.metadata_by_id.remove(&id); self.metadata_by_id.remove(&id);
} }
fn sort(&mut self) {
self.metadata.sort_unstable_by(|a, b| {
a.title
.cmp(&b.title)
.then_with(|| b.saved_at.cmp(&a.saved_at))
});
}
} }
impl PromptStore { impl PromptStore {
@ -715,23 +905,17 @@ impl PromptStore {
let metadata_cache = MetadataCache::from_db(metadata, &txn)?; let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
txn.commit()?; txn.commit()?;
let (updates_tx, updates_rx) = async_watch::channel(());
Ok(PromptStore { Ok(PromptStore {
executor, executor,
env: db_env, env: db_env,
bodies, bodies,
metadata, metadata,
metadata_cache: RwLock::new(metadata_cache), metadata_cache: RwLock::new(metadata_cache),
updates: (Arc::new(updates_tx), updates_rx),
}) })
} }
}) })
} }
pub fn updates(&self) -> async_watch::Receiver<()> {
self.updates.1.clone()
}
pub fn load(&self, id: PromptId) -> Task<Result<String>> { pub fn load(&self, id: PromptId) -> Task<Result<String>> {
let env = self.env.clone(); let env = self.env.clone();
let bodies = self.bodies; let bodies = self.bodies;
@ -743,8 +927,8 @@ impl PromptStore {
}) })
} }
pub fn load_default(&self) -> Task<Result<Vec<(PromptMetadata, String)>>> { pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
let default_metadatas = self return self
.metadata_cache .metadata_cache
.read() .read()
.metadata .metadata
@ -752,23 +936,6 @@ impl PromptStore {
.filter(|metadata| metadata.default) .filter(|metadata| metadata.default)
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let env = self.env.clone();
let bodies = self.bodies;
self.executor.spawn(async move {
let txn = env.read_txn()?;
let mut default_prompts = Vec::new();
for metadata in default_metadatas {
if let Some(body) = bodies.get(&txn, &metadata.id)? {
if !body.is_empty() {
default_prompts.push((metadata, body));
}
}
}
default_prompts.sort_unstable_by_key(|(metadata, _)| metadata.saved_at);
Ok(default_prompts)
})
} }
pub fn delete(&self, id: PromptId) -> Task<Result<()>> { pub fn delete(&self, id: PromptId) -> Task<Result<()>> {
@ -854,7 +1021,6 @@ impl PromptStore {
let db_connection = self.env.clone(); let db_connection = self.env.clone();
let bodies = self.bodies; let bodies = self.bodies;
let metadata = self.metadata; let metadata = self.metadata;
let updates = self.updates.0.clone();
self.executor.spawn(async move { self.executor.spawn(async move {
let mut txn = db_connection.write_txn()?; let mut txn = db_connection.write_txn()?;
@ -863,7 +1029,6 @@ impl PromptStore {
bodies.put(&mut txn, &id, &body.to_string())?; bodies.put(&mut txn, &id, &body.to_string())?;
txn.commit()?; txn.commit()?;
updates.send(()).ok();
Ok(()) Ok(())
}) })
@ -885,24 +1050,18 @@ impl PromptStore {
let db_connection = self.env.clone(); let db_connection = self.env.clone();
let metadata = self.metadata; let metadata = self.metadata;
let updates = self.updates.0.clone();
self.executor.spawn(async move { self.executor.spawn(async move {
let mut txn = db_connection.write_txn()?; let mut txn = db_connection.write_txn()?;
metadata.put(&mut txn, &id, &prompt_metadata)?; metadata.put(&mut txn, &id, &prompt_metadata)?;
txn.commit()?; txn.commit()?;
updates.send(()).ok();
Ok(()) Ok(())
}) })
} }
fn most_recently_saved(&self) -> Option<PromptId> { fn first(&self) -> Option<PromptMetadata> {
self.metadata_cache self.metadata_cache.read().metadata.first().cloned()
.read()
.metadata
.first()
.map(|metadata| metadata.id)
} }
} }
@ -933,3 +1092,123 @@ fn title_from_body(body: impl IntoIterator<Item = char>) -> Option<SharedString>
None None
} }
} }
struct SlashCommandCompletionProvider;
impl editor::CompletionProvider for SlashCommandCompletionProvider {
fn completions(
&self,
buffer: &Model<Buffer>,
buffer_position: language::Anchor,
cx: &mut ViewContext<Editor>,
) -> Task<Result<Vec<project::Completion>>> {
let Some((command_name, name_range)) = buffer.update(cx, |buffer, _cx| {
let position = buffer_position.to_point(buffer);
let line_start = Point::new(position.row, 0);
let mut lines = buffer.text_for_range(line_start..position).lines();
let line = lines.next()?;
let call = SlashCommandLine::parse(line)?;
if call.argument.is_some() {
// Don't autocomplete arguments.
None
} else {
let name = line[call.name.clone()].to_string();
let name_range_start = Point::new(position.row, call.name.start as u32);
let name_range_end = Point::new(position.row, call.name.end as u32);
let name_range =
buffer.anchor_after(name_range_start)..buffer.anchor_after(name_range_end);
Some((name, name_range))
}
}) else {
return Task::ready(Ok(Vec::new()));
};
let commands = SlashCommandRegistry::global(cx);
let candidates = commands
.command_names()
.into_iter()
.enumerate()
.map(|(ix, def)| StringMatchCandidate {
id: ix,
string: def.to_string(),
char_bag: def.as_ref().into(),
})
.collect::<Vec<_>>();
let command_name = command_name.to_string();
cx.spawn(|_, mut cx| async move {
let matches = match_strings(
&candidates,
&command_name,
true,
usize::MAX,
&Default::default(),
cx.background_executor().clone(),
)
.await;
cx.update(|cx| {
matches
.into_iter()
.filter_map(|mat| {
let command = commands.command(&mat.string)?;
let mut new_text = mat.string.clone();
let requires_argument = command.requires_argument();
if requires_argument {
new_text.push(' ');
}
Some(project::Completion {
old_range: name_range.clone(),
documentation: Some(Documentation::SingleLine(command.description())),
new_text,
label: command.label(cx),
server_id: LanguageServerId(0),
lsp_completion: Default::default(),
show_new_completions_on_confirm: false,
confirm: None,
})
})
.collect()
})
})
}
fn resolve_completions(
&self,
_: Model<Buffer>,
_: Vec<usize>,
_: Arc<RwLock<Box<[project::Completion]>>>,
_: &mut ViewContext<Editor>,
) -> Task<Result<bool>> {
Task::ready(Ok(true))
}
fn apply_additional_edits_for_completion(
&self,
_: Model<Buffer>,
_: project::Completion,
_: bool,
_: &mut ViewContext<Editor>,
) -> Task<Result<Option<language::Transaction>>> {
Task::ready(Ok(None))
}
fn is_completion_trigger(
&self,
buffer: &Model<Buffer>,
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
cx: &mut ViewContext<Editor>,
) -> bool {
let buffer = buffer.read(cx);
let position = position.to_point(buffer);
let line_start = Point::new(position.row, 0);
let mut lines = buffer.text_for_range(line_start..position).lines();
if let Some(line) = lines.next() {
SlashCommandLine::parse(line).is_some()
} else {
false
}
}
}

View File

@ -17,6 +17,7 @@ use std::{
use workspace::Workspace; use workspace::Workspace;
pub mod active_command; pub mod active_command;
pub mod default_command;
pub mod fetch_command; pub mod fetch_command;
pub mod file_command; pub mod file_command;
pub mod project_command; pub mod project_command;
@ -117,6 +118,7 @@ impl SlashCommandCompletionProvider {
command_range.clone(), command_range.clone(),
&command_name, &command_name,
None, None,
true,
workspace.clone(), workspace.clone(),
cx, cx,
); );
@ -178,6 +180,7 @@ impl SlashCommandCompletionProvider {
command_range.clone(), command_range.clone(),
&command_name, &command_name,
Some(&arg), Some(&arg),
true,
workspace.clone(), workspace.clone(),
cx, cx,
); );

View File

@ -96,6 +96,7 @@ impl SlashCommand for ActiveSlashCommand {
.into_any_element() .into_any_element()
}), }),
}], }],
run_commands_in_text: false,
}) })
}) })
}); });

View File

@ -0,0 +1,81 @@
use super::{prompt_command::PromptPlaceholder, SlashCommand, SlashCommandOutput};
use crate::prompt_library::PromptStore;
use anyhow::{anyhow, Result};
use assistant_slash_command::SlashCommandOutputSection;
use gpui::{AppContext, Task, WeakView};
use language::LspAdapterDelegate;
use std::{
fmt::Write,
sync::{atomic::AtomicBool, Arc},
};
use ui::prelude::*;
use workspace::Workspace;
pub(crate) struct DefaultSlashCommand;
impl SlashCommand for DefaultSlashCommand {
fn name(&self) -> String {
"default".into()
}
fn description(&self) -> String {
"insert default prompt".into()
}
fn menu_text(&self) -> String {
"Insert Default Prompt".into()
}
fn requires_argument(&self) -> bool {
false
}
fn complete_argument(
&self,
_query: String,
_cancellation_flag: Arc<AtomicBool>,
_workspace: WeakView<Workspace>,
_cx: &mut AppContext,
) -> Task<Result<Vec<String>>> {
Task::ready(Err(anyhow!("this command does not require argument")))
}
fn run(
self: Arc<Self>,
_argument: Option<&str>,
_workspace: WeakView<Workspace>,
_delegate: Arc<dyn LspAdapterDelegate>,
cx: &mut WindowContext,
) -> Task<Result<SlashCommandOutput>> {
let store = PromptStore::global(cx);
cx.background_executor().spawn(async move {
let store = store.await?;
let prompts = store.default_prompt_metadata();
let mut text = String::new();
writeln!(text, "Default Prompt:").unwrap();
for prompt in prompts {
if let Some(title) = prompt.title {
writeln!(text, "/prompt {}", title).unwrap();
}
}
text.pop();
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: 0..text.len(),
render_placeholder: Arc::new(move |id, unfold, _cx| {
PromptPlaceholder {
title: "Default".into(),
id,
unfold,
}
.into_any_element()
}),
}],
text,
run_commands_in_text: true,
})
})
}
}

View File

@ -107,6 +107,7 @@ impl SlashCommand for FetchSlashCommand {
.into_any_element() .into_any_element()
}), }),
}], }],
run_commands_in_text: false,
}) })
}) })
} }

View File

@ -187,6 +187,7 @@ impl SlashCommand for FileSlashCommand {
.into_any_element() .into_any_element()
}), }),
}], }],
run_commands_in_text: false,
}) })
}) })
} }

View File

@ -148,6 +148,7 @@ impl SlashCommand for ProjectSlashCommand {
.into_any_element() .into_any_element()
}), }),
}], }],
run_commands_in_text: false,
}) })
}) })
}); });

View File

@ -8,15 +8,7 @@ use std::sync::{atomic::AtomicBool, Arc};
use ui::{prelude::*, ButtonLike, ElevationIndex}; use ui::{prelude::*, ButtonLike, ElevationIndex};
use workspace::Workspace; use workspace::Workspace;
pub(crate) struct PromptSlashCommand { pub(crate) struct PromptSlashCommand;
store: Arc<PromptStore>,
}
impl PromptSlashCommand {
pub fn new(store: Arc<PromptStore>) -> Self {
Self { store }
}
}
impl SlashCommand for PromptSlashCommand { impl SlashCommand for PromptSlashCommand {
fn name(&self) -> String { fn name(&self) -> String {
@ -42,9 +34,9 @@ impl SlashCommand for PromptSlashCommand {
_workspace: WeakView<Workspace>, _workspace: WeakView<Workspace>,
cx: &mut AppContext, cx: &mut AppContext,
) -> Task<Result<Vec<String>>> { ) -> Task<Result<Vec<String>>> {
let store = self.store.clone(); let store = PromptStore::global(cx);
cx.background_executor().spawn(async move { cx.background_executor().spawn(async move {
let prompts = store.search(query).await; let prompts = store.await?.search(query).await;
Ok(prompts Ok(prompts
.into_iter() .into_iter()
.filter_map(|prompt| Some(prompt.title?.to_string())) .filter_map(|prompt| Some(prompt.title?.to_string()))
@ -63,11 +55,12 @@ impl SlashCommand for PromptSlashCommand {
return Task::ready(Err(anyhow!("missing prompt name"))); return Task::ready(Err(anyhow!("missing prompt name")));
}; };
let store = self.store.clone(); let store = PromptStore::global(cx);
let title = SharedString::from(title.to_string()); let title = SharedString::from(title.to_string());
let prompt = cx.background_executor().spawn({ let prompt = cx.background_executor().spawn({
let title = title.clone(); let title = title.clone();
async move { async move {
let store = store.await?;
let prompt_id = store let prompt_id = store
.id_for_title(&title) .id_for_title(&title)
.with_context(|| format!("no prompt found with title {:?}", title))?; .with_context(|| format!("no prompt found with title {:?}", title))?;
@ -91,6 +84,7 @@ impl SlashCommand for PromptSlashCommand {
.into_any_element() .into_any_element()
}), }),
}], }],
run_commands_in_text: true,
}) })
}) })
} }

View File

@ -192,6 +192,7 @@ impl SlashCommand for RustdocSlashCommand {
.into_any_element() .into_any_element()
}), }),
}], }],
run_commands_in_text: false,
}) })
}) })
} }

View File

@ -181,7 +181,11 @@ impl SlashCommand for SearchSlashCommand {
}), }),
}); });
SlashCommandOutput { text, sections } SlashCommandOutput {
text,
sections,
run_commands_in_text: false,
}
}) })
.await; .await;

View File

@ -109,7 +109,11 @@ impl SlashCommand for TabsSlashCommand {
}); });
} }
Ok(SlashCommandOutput { text, sections }) Ok(SlashCommandOutput {
text,
sections,
run_commands_in_text: false,
})
}), }),
Err(error) => Task::ready(Err(error)), Err(error) => Task::ready(Err(error)),
} }

View File

@ -52,6 +52,7 @@ pub type RenderFoldPlaceholder = Arc<
pub struct SlashCommandOutput { pub struct SlashCommandOutput {
pub text: String, pub text: String,
pub sections: Vec<SlashCommandOutputSection<usize>>, pub sections: Vec<SlashCommandOutputSection<usize>>,
pub run_commands_in_text: bool,
} }
#[derive(Clone)] #[derive(Clone)]

View File

@ -100,6 +100,7 @@ impl SlashCommand for ExtensionSlashCommand {
} }
}), }),
}], }],
run_commands_in_text: false,
}) })
}) })
} }

View File

@ -656,7 +656,7 @@ impl MacWindow {
.as_ref() .as_ref()
.and_then(|t| t.title.as_ref().map(AsRef::as_ref)) .and_then(|t| t.title.as_ref().map(AsRef::as_ref))
{ {
native_window.setTitle_(NSString::alloc(nil).init_str(title)); window.set_title(title);
} }
native_window.setMovable_(is_movable as BOOL); native_window.setMovable_(is_movable as BOOL);

View File

@ -236,7 +236,12 @@ impl<D: PickerDelegate> Picker<D> {
/// If `scroll_to_index` is true, the new selected index will be scrolled into view. /// If `scroll_to_index` is true, the new selected index will be scrolled into view.
/// ///
/// If some effect is bound to `selected_index_changed`, it will be executed. /// If some effect is bound to `selected_index_changed`, it will be executed.
fn set_selected_index(&mut self, ix: usize, scroll_to_index: bool, cx: &mut ViewContext<Self>) { pub fn set_selected_index(
&mut self,
ix: usize,
scroll_to_index: bool,
cx: &mut ViewContext<Self>,
) {
let previous_index = self.delegate.selected_index(); let previous_index = self.delegate.selected_index();
self.delegate.set_selected_index(ix, cx); self.delegate.set_selected_index(ix, cx);
let current_index = self.delegate.selected_index(); let current_index = self.delegate.selected_index();

View File

@ -192,6 +192,7 @@ pub enum IconName {
WholeWord, WholeWord,
XCircle, XCircle,
ZedAssistant, ZedAssistant,
ZedAssistantFilled,
ZedXCopilot, ZedXCopilot,
} }
@ -315,6 +316,7 @@ impl IconName {
IconName::WholeWord => "icons/word_search.svg", IconName::WholeWord => "icons/word_search.svg",
IconName::XCircle => "icons/error.svg", IconName::XCircle => "icons/error.svg",
IconName::ZedAssistant => "icons/zed_assistant.svg", IconName::ZedAssistant => "icons/zed_assistant.svg",
IconName::ZedAssistantFilled => "icons/zed_assistant_filled.svg",
IconName::ZedXCopilot => "icons/zed_x_copilot.svg", IconName::ZedXCopilot => "icons/zed_x_copilot.svg",
IconName::ArrowUpFromLine => "icons/arrow_up_from_line.svg", IconName::ArrowUpFromLine => "icons/arrow_up_from_line.svg",
} }

View File

@ -6,6 +6,7 @@ pub struct ListSubHeader {
label: SharedString, label: SharedString,
start_slot: Option<IconName>, start_slot: Option<IconName>,
inset: bool, inset: bool,
selected: bool,
} }
impl ListSubHeader { impl ListSubHeader {
@ -14,6 +15,7 @@ impl ListSubHeader {
label: label.into(), label: label.into(),
start_slot: None, start_slot: None,
inset: false, inset: false,
selected: false,
} }
} }
@ -28,12 +30,22 @@ impl ListSubHeader {
} }
} }
impl Selectable for ListSubHeader {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl RenderOnce for ListSubHeader { impl RenderOnce for ListSubHeader {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement { fn render(self, cx: &mut WindowContext) -> impl IntoElement {
h_flex().flex_1().w_full().relative().py_1().child( h_flex().flex_1().w_full().relative().py_1().child(
div() div()
.h_6() .h_6()
.when(self.inset, |this| this.px_2()) .when(self.inset, |this| this.px_2())
.when(self.selected, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
.flex() .flex()
.flex_1() .flex_1()
.w_full() .w_full()