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",
"anyhow",
"assistant_slash_command",
"async-watch",
"cargo_toml",
"chrono",
"client",
@ -824,15 +823,6 @@ dependencies = [
"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]]
name = "async_zip"
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
anthropic = { workspace = true, features = ["schemars"] }
assistant_slash_command.workspace = true
async-watch.workspace = true
cargo_toml.workspace = true
chrono.workspace = true
client.workspace = true

View File

@ -19,14 +19,13 @@ use command_palette_hooks::CommandPaletteFilter;
pub(crate) use completion_provider::*;
use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
pub(crate) use model_selector::*;
use prompt_library::PromptStore;
pub(crate) use saved_conversation::*;
use semantic_index::{CloudEmbeddingProvider, SemanticIndex};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
use slash_command::{
active_command, fetch_command, file_command, project_command, prompt_command, rustdoc_command,
search_command, tabs_command,
active_command, default_command, fetch_command, file_command, project_command, prompt_command,
rustdoc_command, search_command, tabs_command,
};
use std::{
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(project_command::ProjectSlashCommand, 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(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)]

View File

@ -1,11 +1,11 @@
use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings},
codegen::{self, Codegen, CodegenKind},
prompt_library::{open_prompt_library, PromptMetadata, PromptStore},
prompt_library::open_prompt_library,
prompts::generate_content_prompt,
search::*,
slash_command::{
prompt_command::PromptPlaceholder, SlashCommandCompletionProvider, SlashCommandLine,
default_command::DefaultSlashCommand, SlashCommandCompletionProvider, SlashCommandLine,
SlashCommandRegistry,
},
ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist,
@ -14,7 +14,7 @@ use crate::{
SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector,
};
use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection};
use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
use client::telemetry::Telemetry;
use collections::{hash_map, BTreeSet, HashMap, HashSet, VecDeque};
use editor::{actions::ShowCompletions, GutterDimensions};
@ -40,10 +40,9 @@ use gpui::{
Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext,
WeakModel, WeakView, WhiteSpace, WindowContext,
};
use language::LspAdapterDelegate;
use language::{
language_settings::SoftWrap, AnchorRangeExt, AutoindentMode, Buffer, LanguageRegistry,
OffsetRangeExt as _, Point, ToOffset as _,
LspAdapterDelegate, OffsetRangeExt as _, Point, ToOffset as _,
};
use multi_buffer::MultiBufferRow;
use parking_lot::Mutex;
@ -118,14 +117,6 @@ pub struct AssistantPanel {
_watch_saved_conversations: Task<Result<()>>,
authentication_prompt: Option<AnyView>,
model_menu_handle: PopoverMenuHandle<ContextMenu>,
default_prompt: DefaultPrompt,
_watch_prompt_store: Task<()>,
}
#[derive(Default)]
struct DefaultPrompt {
text: String,
sections: Vec<SlashCommandOutputSection<usize>>,
}
struct ActiveConversationEditor {
@ -146,8 +137,6 @@ impl AssistantPanel {
.await
.log_err()
.unwrap_or_default();
let prompt_store = cx.update(|cx| PromptStore::global(cx))?.await?;
let default_prompts = prompt_store.load_default().await?;
// TODO: deserialize state.
let workspace_handle = workspace.clone();
@ -173,22 +162,6 @@ impl AssistantPanel {
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 mut toolbar = Toolbar::new();
toolbar.set_can_navigate(false, cx);
@ -216,7 +189,7 @@ impl AssistantPanel {
})
.detach();
let mut this = Self {
Self {
workspace: workspace_handle,
active_conversation_editor: None,
show_saved_conversations: false,
@ -239,11 +212,7 @@ impl AssistantPanel {
_watch_saved_conversations,
authentication_prompt: None,
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();
}
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(
&mut self,
prev_settings_version: usize,
@ -862,7 +782,6 @@ impl AssistantPanel {
let editor = cx.new_view(|cx| {
ConversationEditor::new(
&self.default_prompt,
self.languages.clone(),
self.slash_commands.clone(),
self.fs.clone(),
@ -1460,7 +1379,9 @@ enum ConversationEvent {
updated: Vec<PendingSlashCommand>,
},
SlashCommandFinished {
output_range: Range<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),
));
let start_ix = match self
.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 old_range = self.pending_command_indices_for_range(start..end, cx);
let mut new_commands = Vec::new();
let mut lines = buffer.text_for_range(start..end).lines();
@ -1773,9 +1683,7 @@ impl Conversation {
offset = lines.offset();
}
let removed_commands = self
.pending_slash_commands
.splice(start_ix..end_ix, new_commands);
let removed_commands = self.pending_slash_commands.splice(old_range, new_commands);
removed.extend(removed_commands.map(|command| command.source_range));
}
@ -1849,25 +1757,60 @@ impl Conversation {
cx: &mut ModelContext<Self>,
) -> Option<&mut PendingSlashCommand> {
let buffer = self.buffer.read(cx);
let ix = self
match self
.pending_slash_commands
.binary_search_by(|probe| {
if probe.source_range.start.cmp(&position, buffer).is_gt() {
Ordering::Less
} else if probe.source_range.end.cmp(&position, buffer).is_lt() {
Ordering::Greater
.binary_search_by(|probe| probe.source_range.end.cmp(&position, buffer))
{
Ok(ix) => Some(&mut self.pending_slash_commands[ix]),
Err(ix) => {
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 {
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(
&mut self,
command_range: Range<language::Anchor>,
output: Task<Result<SlashCommandOutput>>,
insert_trailing_newline: bool,
cx: &mut ModelContext<Self>,
) {
self.reparse_slash_commands(cx);
@ -1878,13 +1821,14 @@ impl Conversation {
let output = output.await;
this.update(&mut cx, |this, cx| match output {
Ok(mut output) => {
if !output.text.ends_with('\n') {
if insert_trailing_newline {
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 old_end = command_range.end.to_offset(buffer);
let new_end = start + output.text.len();
buffer.edit([(start..old_end, output.text)], None, cx);
let mut sections = output
@ -1897,9 +1841,14 @@ impl Conversation {
})
.collect::<Vec<_>>();
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) => {
if let Some(pending_command) =
@ -2596,7 +2545,6 @@ pub struct ConversationEditor {
impl ConversationEditor {
fn new(
default_prompt: &DefaultPrompt,
language_registry: Arc<LanguageRegistry>,
slash_command_registry: Arc<SlashCommandRegistry>,
fs: Arc<dyn Fs>,
@ -2618,31 +2566,7 @@ impl ConversationEditor {
let mut this =
Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, 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.insert_default_prompt(cx);
this
}
@ -2695,6 +2619,32 @@ impl ConversationEditor {
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>) {
let cursors = self.cursors(cx);
@ -2817,6 +2767,7 @@ impl ConversationEditor {
command.source_range,
&command.name,
command.argument.as_deref(),
true,
workspace.clone(),
cx,
);
@ -2830,6 +2781,7 @@ impl ConversationEditor {
command_range: Range<language::Anchor>,
name: &str,
argument: Option<&str>,
insert_trailing_newline: bool,
workspace: WeakView<Workspace>,
cx: &mut ViewContext<Self>,
) {
@ -2838,7 +2790,12 @@ impl ConversationEditor {
let argument = argument.map(ToString::to_string);
let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, 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.name,
command.argument.as_deref(),
false,
workspace.clone(),
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);
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 assistant_slash_command::SlashCommandRegistry;
use chrono::{DateTime, Utc};
use collections::HashMap;
use editor::{Editor, EditorEvent};
use editor::{actions::Tab, Editor, EditorEvent};
use futures::{
future::{self, BoxFuture, Shared},
FutureExt,
};
use fuzzy::StringMatchCandidate;
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions, point, size, AppContext, BackgroundExecutor, Bounds, DevicePixels, Empty,
EventEmitter, Global, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions, View,
WindowBounds, WindowHandle, WindowOptions,
actions, point, size, AnyElement, AppContext, BackgroundExecutor, Bounds, DevicePixels,
EventEmitter, Global, Model, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions,
View, WindowBounds, WindowHandle, WindowOptions,
};
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 picker::{Picker, PickerDelegate};
use rope::Rope;
use serde::{Deserialize, Serialize};
use std::{
cmp::Reverse,
future::Future,
path::PathBuf,
sync::{atomic::AtomicBool, Arc},
time::Duration,
};
use ui::{
div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render,
SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext,
div, prelude::*, IconButtonShape, ListHeader, ListItem, ListItemSpacing, ListSubHeader,
ParentElement, Render, SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext,
};
use util::{paths::PROMPTS_DIR, ResultExt, TryFutureExt};
use uuid::Uuid;
@ -80,7 +87,7 @@ pub fn open_prompt_library(
cx.open_window(
WindowOptions {
titlebar: Some(TitlebarOptions {
title: None,
title: Some("Prompt Library".into()),
appears_transparent: true,
traffic_light_position: Some(point(px(9.0), px(9.0))),
}),
@ -106,6 +113,8 @@ pub struct PromptLibrary {
struct PromptEditor {
editor: View<Editor>,
token_count: Option<usize>,
pending_token_count: Task<Option<()>>,
next_body_to_save: Option<Rope>,
pending_save: Option<Task<Option<()>>>,
_subscription: Subscription,
@ -114,30 +123,54 @@ struct PromptEditor {
struct PromptPickerDelegate {
store: Arc<PromptStore>,
selected_index: usize,
matches: Vec<PromptMetadata>,
entries: Vec<PromptPickerEntry>,
}
enum PromptPickerEvent {
Selected { prompt_id: PromptId },
Confirmed { prompt_id: PromptId },
Deleted { 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 PickerDelegate for PromptPickerDelegate {
type ListItem = ListItem;
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.matches.len()
self.entries.len()
}
fn selected_index(&self) -> usize {
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;
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> {
@ -146,11 +179,49 @@ impl PickerDelegate for PromptPickerDelegate {
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
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 {
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.delegate.selected_index = 0;
this.delegate.matches = matches;
this.delegate.entries = entries;
this.delegate.set_selected_index(selected_index, cx);
cx.notify();
})
.ok();
@ -158,7 +229,7 @@ impl PickerDelegate for PromptPickerDelegate {
}
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 {
prompt_id: prompt.id,
});
@ -173,10 +244,31 @@ impl PickerDelegate for PromptPickerDelegate {
selected: bool,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
let prompt = self.matches.get(ix)?;
let prompt = self.entries.get(ix)?;
let element = match prompt {
PromptPickerEntry::DefaultPromptsHeader => ListHeader::new("Default Prompts")
.inset(true)
.start_slot(Icon::new(IconName::ZedAssistant))
.selected(selected)
.into_any_element(),
PromptPickerEntry::DefaultPromptsEmpty => {
ListSubHeader::new("Star a prompt to add it to the default context")
.inset(true)
.selected(selected)
.into_any_element()
}
PromptPickerEntry::AllPromptsHeader => ListHeader::new("All Prompts")
.inset(true)
.start_slot(Icon::new(IconName::Library))
.selected(selected)
.into_any_element(),
PromptPickerEntry::AllPromptsEmpty => ListSubHeader::new("No prompts")
.inset(true)
.selected(selected)
.into_any_element(),
PromptPickerEntry::Prompt(prompt) => {
let default = prompt.default;
let prompt_id = prompt.id;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
@ -184,13 +276,6 @@ impl PickerDelegate for PromptPickerDelegate {
.child(Label::new(
prompt.title.clone().unwrap_or("Untitled".into()),
))
.end_slot(if default {
IconButton::new("toggle-default-prompt", IconName::StarFilled)
.shape(IconButtonShape::Square)
.into_any_element()
} else {
Empty.into_any()
})
.end_hover_slot(
h_flex()
.gap_2()
@ -206,9 +291,9 @@ impl PickerDelegate for PromptPickerDelegate {
IconButton::new(
"toggle-default-prompt",
if default {
IconName::StarFilled
IconName::ZedAssistantFilled
} else {
IconName::Star
IconName::ZedAssistant
},
)
.shape(IconButtonShape::Square)
@ -222,12 +307,17 @@ impl PickerDelegate for PromptPickerDelegate {
cx,
)
})
.on_click(cx.listener(move |_, _, 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 {
store: store.clone(),
selected_index: 0,
matches: Vec::new(),
entries: Vec::new(),
};
let picker = cx.new_view(|cx| {
let picker = Picker::uniform_list(delegate, cx)
.modal(false)
.max_height(None);
let picker = Picker::list(delegate, cx).modal(false).max_height(None);
picker.focus(cx);
picker
});
let mut this = Self {
Self {
store: store.clone(),
language_registry,
prompt_editors: HashMap::default(),
@ -258,11 +346,7 @@ impl PromptLibrary {
pending_load: Task::ready(()),
_subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)],
picker,
};
if let Some(prompt_id) = store.most_recently_saved() {
this.load_prompt(prompt_id, false, cx);
}
this
}
fn handle_picker_event(
@ -272,6 +356,9 @@ impl PromptLibrary {
cx: &mut ViewContext<Self>,
) {
match event {
PromptPickerEvent::Selected { prompt_id } => {
self.load_prompt(*prompt_id, false, cx);
}
PromptPickerEvent::Confirmed { prompt_id } => {
self.load_prompt(*prompt_id, true, cx);
}
@ -285,6 +372,15 @@ impl PromptLibrary {
}
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 save = self.store.save(prompt_id, None, false, "".into());
self.picker.update(cx, |picker, cx| picker.refresh(cx));
@ -383,7 +479,7 @@ impl PromptLibrary {
.editor
.update(cx, |editor, cx| editor.focus(cx));
}
self.active_prompt_id = Some(prompt_id);
self.set_active_prompt(Some(prompt_id), cx);
} else {
let language_registry = self.language_registry.clone();
let prompt = self.store.load(prompt_id);
@ -404,6 +500,8 @@ impl PromptLibrary {
editor.set_show_gutter(false, cx);
editor.set_show_wrap_guides(false, cx);
editor.set_show_indent_guides(false, cx);
editor
.set_completion_provider(Box::new(SlashCommandCompletionProvider));
if focus {
editor.focus(cx);
}
@ -419,11 +517,13 @@ impl PromptLibrary {
editor,
next_body_to_save: None,
pending_save: None,
token_count: None,
pending_token_count: Task::ready(None),
_subscription,
},
);
this.active_prompt_id = Some(prompt_id);
cx.notify();
this.set_active_prompt(Some(prompt_id), cx);
this.count_tokens(prompt_id, cx);
}
Err(error) => {
// 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>) {
if let Some(metadata) = self.store.metadata(prompt_id) {
let confirmation = cx.prompt(
@ -451,7 +577,7 @@ impl PromptLibrary {
if confirmation.await.ok() == Some(0) {
this.update(&mut cx, |this, cx| {
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.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(
&mut self,
prompt_id: PromptId,
@ -502,12 +641,53 @@ impl PromptLibrary {
});
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 {
v_flex()
.id("prompt-list")
.capture_action(cx.listener(Self::focus_active_prompt))
.bg(cx.theme().colors().panel_background)
.h_full()
.w_1_3()
@ -545,27 +725,37 @@ impl PromptLibrary {
.min_w_64()
.children(self.active_prompt_id.and_then(|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(
v_flex()
h_flex()
.size_full()
.items_start()
.child(
h_flex()
.h(TitleBar::height(cx))
.px(Spacing::Large.rems(cx))
.justify_end()
div()
.on_action(cx.listener(Self::focus_picker))
.flex_grow()
.h_full()
.pt(Spacing::Large.rems(cx))
.pl(Spacing::Large.rems(cx))
.child(prompt_editor.editor.clone()),
)
.child(
h_flex()
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::StarFilled
IconName::ZedAssistantFilled
} else {
IconName::Star
IconName::ZedAssistant
},
)
.size(ButtonSize::Large)
.shape(IconButtonShape::Square)
.tooltip(move |cx| {
Tooltip::for_action(
@ -578,31 +768,26 @@ impl PromptLibrary {
cx,
)
})
.on_click(
|_, cx| {
cx.dispatch_action(Box::new(
ToggleDefaultPrompt,
));
},
),
.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,
)
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)),
.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>>,
metadata: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
metadata_cache: RwLock<MetadataCache>,
updates: (Arc<async_watch::Sender<()>>, async_watch::Receiver<()>),
}
#[derive(Default)]
@ -668,9 +852,7 @@ impl MetadataCache {
cache.metadata.push(metadata.clone());
cache.metadata_by_id.insert(prompt_id, metadata);
}
cache
.metadata
.sort_unstable_by_key(|metadata| Reverse(metadata.saved_at));
cache.sort();
Ok(cache)
}
@ -681,13 +863,21 @@ impl MetadataCache {
} else {
self.metadata.push(metadata);
}
self.metadata.sort_by_key(|m| Reverse(m.saved_at));
self.sort();
}
fn remove(&mut self, id: PromptId) {
self.metadata.retain(|metadata| metadata.id != 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 {
@ -715,23 +905,17 @@ impl PromptStore {
let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
txn.commit()?;
let (updates_tx, updates_rx) = async_watch::channel(());
Ok(PromptStore {
executor,
env: db_env,
bodies,
metadata,
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>> {
let env = self.env.clone();
let bodies = self.bodies;
@ -743,8 +927,8 @@ impl PromptStore {
})
}
pub fn load_default(&self) -> Task<Result<Vec<(PromptMetadata, String)>>> {
let default_metadatas = self
pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
return self
.metadata_cache
.read()
.metadata
@ -752,23 +936,6 @@ impl PromptStore {
.filter(|metadata| metadata.default)
.cloned()
.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<()>> {
@ -854,7 +1021,6 @@ impl PromptStore {
let db_connection = self.env.clone();
let bodies = self.bodies;
let metadata = self.metadata;
let updates = self.updates.0.clone();
self.executor.spawn(async move {
let mut txn = db_connection.write_txn()?;
@ -863,7 +1029,6 @@ impl PromptStore {
bodies.put(&mut txn, &id, &body.to_string())?;
txn.commit()?;
updates.send(()).ok();
Ok(())
})
@ -885,24 +1050,18 @@ impl PromptStore {
let db_connection = self.env.clone();
let metadata = self.metadata;
let updates = self.updates.0.clone();
self.executor.spawn(async move {
let mut txn = db_connection.write_txn()?;
metadata.put(&mut txn, &id, &prompt_metadata)?;
txn.commit()?;
updates.send(()).ok();
Ok(())
})
}
fn most_recently_saved(&self) -> Option<PromptId> {
self.metadata_cache
.read()
.metadata
.first()
.map(|metadata| metadata.id)
fn first(&self) -> Option<PromptMetadata> {
self.metadata_cache.read().metadata.first().cloned()
}
}
@ -933,3 +1092,123 @@ fn title_from_body(body: impl IntoIterator<Item = char>) -> Option<SharedString>
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;
pub mod active_command;
pub mod default_command;
pub mod fetch_command;
pub mod file_command;
pub mod project_command;
@ -117,6 +118,7 @@ impl SlashCommandCompletionProvider {
command_range.clone(),
&command_name,
None,
true,
workspace.clone(),
cx,
);
@ -178,6 +180,7 @@ impl SlashCommandCompletionProvider {
command_range.clone(),
&command_name,
Some(&arg),
true,
workspace.clone(),
cx,
);

View File

@ -96,6 +96,7 @@ impl SlashCommand for ActiveSlashCommand {
.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()
}),
}],
run_commands_in_text: false,
})
})
}

View File

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

View File

@ -148,6 +148,7 @@ impl SlashCommand for ProjectSlashCommand {
.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 workspace::Workspace;
pub(crate) struct PromptSlashCommand {
store: Arc<PromptStore>,
}
impl PromptSlashCommand {
pub fn new(store: Arc<PromptStore>) -> Self {
Self { store }
}
}
pub(crate) struct PromptSlashCommand;
impl SlashCommand for PromptSlashCommand {
fn name(&self) -> String {
@ -42,9 +34,9 @@ impl SlashCommand for PromptSlashCommand {
_workspace: WeakView<Workspace>,
cx: &mut AppContext,
) -> Task<Result<Vec<String>>> {
let store = self.store.clone();
let store = PromptStore::global(cx);
cx.background_executor().spawn(async move {
let prompts = store.search(query).await;
let prompts = store.await?.search(query).await;
Ok(prompts
.into_iter()
.filter_map(|prompt| Some(prompt.title?.to_string()))
@ -63,11 +55,12 @@ impl SlashCommand for PromptSlashCommand {
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 prompt = cx.background_executor().spawn({
let title = title.clone();
async move {
let store = store.await?;
let prompt_id = store
.id_for_title(&title)
.with_context(|| format!("no prompt found with title {:?}", title))?;
@ -91,6 +84,7 @@ impl SlashCommand for PromptSlashCommand {
.into_any_element()
}),
}],
run_commands_in_text: true,
})
})
}

View File

@ -192,6 +192,7 @@ impl SlashCommand for RustdocSlashCommand {
.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;

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)),
}

View File

@ -52,6 +52,7 @@ pub type RenderFoldPlaceholder = Arc<
pub struct SlashCommandOutput {
pub text: String,
pub sections: Vec<SlashCommandOutputSection<usize>>,
pub run_commands_in_text: bool,
}
#[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()
.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);

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 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();
self.delegate.set_selected_index(ix, cx);
let current_index = self.delegate.selected_index();

View File

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

View File

@ -6,6 +6,7 @@ pub struct ListSubHeader {
label: SharedString,
start_slot: Option<IconName>,
inset: bool,
selected: bool,
}
impl ListSubHeader {
@ -14,6 +15,7 @@ impl ListSubHeader {
label: label.into(),
start_slot: None,
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 {
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(
div()
.h_6()
.when(self.inset, |this| this.px_2())
.when(self.selected, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
.flex()
.flex_1()
.w_full()