Extract slash commands to their own crate (#23261)

This PR extracts the slash command definitions out of the `assistant`
crate and into their own `assistant_slash_commands` crate.

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers 2025-01-16 17:17:07 -05:00 committed by GitHub
parent 1a8303b020
commit 8030c0025a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 452 additions and 347 deletions

View file

@ -0,0 +1,52 @@
[package]
name = "assistant_slash_commands"
version = "0.1.0"
edition = "2021"
publish = false
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/assistant_slash_commands.rs"
[dependencies]
anyhow.workspace = true
assistant_slash_command.workspace = true
cargo_toml.workspace = true
chrono.workspace = true
collections.workspace = true
context_server.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
globset.workspace = true
gpui.workspace = true
html_to_markdown.workspace = true
http_client.workspace = true
indexed_docs.workspace = true
language.workspace = true
language_model.workspace = true
log.workspace = true
project.workspace = true
prompt_library.workspace = true
rope.workspace = true
schemars.workspace = true
semantic_index.workspace = true
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
terminal_view.workspace = true
text.workspace = true
toml.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
[dev-dependencies]
env_logger.workspace = true
pretty_assertions.workspace = true
settings.workspace = true

View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -0,0 +1,57 @@
mod auto_command;
mod cargo_workspace_command;
mod context_server_command;
mod default_command;
mod delta_command;
mod diagnostics_command;
mod docs_command;
mod fetch_command;
mod file_command;
mod now_command;
mod project_command;
mod prompt_command;
mod search_command;
mod selection_command;
mod streaming_example_command;
mod symbols_command;
mod tab_command;
mod terminal_command;
use gpui::AppContext;
use language::{CodeLabel, HighlightId};
use ui::ActiveTheme as _;
pub use crate::auto_command::*;
pub use crate::cargo_workspace_command::*;
pub use crate::context_server_command::*;
pub use crate::default_command::*;
pub use crate::delta_command::*;
pub use crate::diagnostics_command::*;
pub use crate::docs_command::*;
pub use crate::fetch_command::*;
pub use crate::file_command::*;
pub use crate::now_command::*;
pub use crate::project_command::*;
pub use crate::prompt_command::*;
pub use crate::search_command::*;
pub use crate::selection_command::*;
pub use crate::streaming_example_command::*;
pub use crate::symbols_command::*;
pub use crate::tab_command::*;
pub use crate::terminal_command::*;
pub fn create_label_for_command(
command_name: &str,
arguments: &[&str],
cx: &AppContext,
) -> CodeLabel {
let mut label = CodeLabel::default();
label.push_str(command_name, None);
label.push_str(" ", None);
label.push_str(
&arguments.join(" "),
cx.theme().syntax().highlight_id("comment").map(HighlightId),
);
label.filter_range = 0..command_name.len();
label
}

View file

@ -0,0 +1,370 @@
use anyhow::{anyhow, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use feature_flags::FeatureFlag;
use futures::StreamExt;
use gpui::{AppContext, AsyncAppContext, AsyncWindowContext, Task, WeakView, WindowContext};
use language::{CodeLabel, LspAdapterDelegate};
use language_model::{
LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, Role,
};
use semantic_index::{FileSummary, SemanticDb};
use smol::channel;
use std::sync::{atomic::AtomicBool, Arc};
use ui::{prelude::*, BorrowAppContext};
use util::ResultExt;
use workspace::Workspace;
use crate::create_label_for_command;
pub struct AutoSlashCommandFeatureFlag;
impl FeatureFlag for AutoSlashCommandFeatureFlag {
const NAME: &'static str = "auto-slash-command";
}
pub struct AutoCommand;
impl SlashCommand for AutoCommand {
fn name(&self) -> String {
"auto".into()
}
fn description(&self) -> String {
"Automatically infer what context to add".into()
}
fn icon(&self) -> IconName {
IconName::Wand
}
fn menu_text(&self) -> String {
self.description()
}
fn label(&self, cx: &AppContext) -> CodeLabel {
create_label_for_command("auto", &["--prompt"], cx)
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
workspace: Option<WeakView<Workspace>>,
cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
// There's no autocomplete for a prompt, since it's arbitrary text.
// However, we can use this opportunity to kick off a drain of the backlog.
// That way, it can hopefully be done resummarizing by the time we've actually
// typed out our prompt. This re-runs on every keystroke during autocomplete,
// but in the future, we could instead do it only once, when /auto is first entered.
let Some(workspace) = workspace.and_then(|ws| ws.upgrade()) else {
log::warn!("workspace was dropped or unavailable during /auto autocomplete");
return Task::ready(Ok(Vec::new()));
};
let project = workspace.read(cx).project().clone();
let Some(project_index) =
cx.update_global(|index: &mut SemanticDb, cx| index.project_index(project, cx))
else {
return Task::ready(Err(anyhow!("No project indexer, cannot use /auto")));
};
let cx: &mut AppContext = cx;
cx.spawn(|cx: gpui::AsyncAppContext| async move {
let task = project_index.read_with(&cx, |project_index, cx| {
project_index.flush_summary_backlogs(cx)
})?;
cx.background_executor().spawn(task).await;
anyhow::Ok(Vec::new())
})
}
fn requires_argument(&self) -> bool {
true
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: language::BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
};
if arguments.is_empty() {
return Task::ready(Err(anyhow!("missing prompt")));
};
let argument = arguments.join(" ");
let original_prompt = argument.to_string();
let project = workspace.read(cx).project().clone();
let Some(project_index) =
cx.update_global(|index: &mut SemanticDb, cx| index.project_index(project, cx))
else {
return Task::ready(Err(anyhow!("no project indexer")));
};
let task = cx.spawn(|cx: AsyncWindowContext| async move {
let summaries = project_index
.read_with(&cx, |project_index, cx| project_index.all_summaries(cx))?
.await?;
commands_for_summaries(&summaries, &original_prompt, &cx).await
});
// As a convenience, append /auto's argument to the end of the prompt
// so you don't have to write it again.
let original_prompt = argument.to_string();
cx.background_executor().spawn(async move {
let commands = task.await?;
let mut prompt = String::new();
log::info!(
"Translating this response into slash-commands: {:?}",
commands
);
for command in commands {
prompt.push('/');
prompt.push_str(&command.name);
prompt.push(' ');
prompt.push_str(&command.arg);
prompt.push('\n');
}
prompt.push('\n');
prompt.push_str(&original_prompt);
Ok(SlashCommandOutput {
text: prompt,
sections: Vec::new(),
run_commands_in_text: true,
}
.to_event_stream())
})
}
}
const PROMPT_INSTRUCTIONS_BEFORE_SUMMARY: &str = include_str!("prompt_before_summary.txt");
const PROMPT_INSTRUCTIONS_AFTER_SUMMARY: &str = include_str!("prompt_after_summary.txt");
fn summaries_prompt(summaries: &[FileSummary], original_prompt: &str) -> String {
let json_summaries = serde_json::to_string(summaries).unwrap();
format!("{PROMPT_INSTRUCTIONS_BEFORE_SUMMARY}\n{json_summaries}\n{PROMPT_INSTRUCTIONS_AFTER_SUMMARY}\n{original_prompt}")
}
/// The slash commands that the model is told about, and which we look for in the inference response.
const SUPPORTED_SLASH_COMMANDS: &[&str] = &["search", "file"];
#[derive(Debug, Clone)]
struct CommandToRun {
name: String,
arg: String,
}
/// Given the pre-indexed file summaries for this project, as well as the original prompt
/// string passed to `/auto`, get a list of slash commands to run, along with their arguments.
///
/// The prompt's output does not include the slashes (to reduce the chance that it makes a mistake),
/// so taking one of these returned Strings and turning it into a real slash-command-with-argument
/// involves prepending a slash to it.
///
/// This function will validate that each of the returned lines begins with one of SUPPORTED_SLASH_COMMANDS.
/// Any other lines it encounters will be discarded, with a warning logged.
async fn commands_for_summaries(
summaries: &[FileSummary],
original_prompt: &str,
cx: &AsyncAppContext,
) -> Result<Vec<CommandToRun>> {
if summaries.is_empty() {
log::warn!("Inferring no context because there were no summaries available.");
return Ok(Vec::new());
}
// Use the globally configured model to translate the summaries into slash-commands,
// because Qwen2-7B-Instruct has not done a good job at that task.
let Some(model) = cx.update(|cx| LanguageModelRegistry::read_global(cx).active_model())? else {
log::warn!("Can't infer context because there's no active model.");
return Ok(Vec::new());
};
// Only go up to 90% of the actual max token count, to reduce chances of
// exceeding the token count due to inaccuracies in the token counting heuristic.
let max_token_count = (model.max_token_count() * 9) / 10;
// Rather than recursing (which would require this async function use a pinned box),
// we use an explicit stack of arguments and answers for when we need to "recurse."
let mut stack = vec![summaries];
let mut final_response = Vec::new();
let mut prompts = Vec::new();
// TODO We only need to create multiple Requests because we currently
// don't have the ability to tell if a CompletionProvider::complete response
// was a "too many tokens in this request" error. If we had that, then
// we could try the request once, instead of having to make separate requests
// to check the token count and then afterwards to run the actual prompt.
let make_request = |prompt: String| LanguageModelRequest {
messages: vec![LanguageModelRequestMessage {
role: Role::User,
content: vec![prompt.into()],
// Nothing in here will benefit from caching
cache: false,
}],
tools: Vec::new(),
stop: Vec::new(),
temperature: None,
};
while let Some(current_summaries) = stack.pop() {
// The split can result in one slice being empty and the other having one element.
// Whenever that happens, skip the empty one.
if current_summaries.is_empty() {
continue;
}
log::info!(
"Inferring prompt context using {} file summaries",
current_summaries.len()
);
let prompt = summaries_prompt(&current_summaries, original_prompt);
let start = std::time::Instant::now();
// Per OpenAI, 1 token ~= 4 chars in English (we go with 4.5 to overestimate a bit, because failed API requests cost a lot of perf)
// Verifying this against an actual model.count_tokens() confirms that it's usually within ~5% of the correct answer, whereas
// getting the correct answer from tiktoken takes hundreds of milliseconds (compared to this arithmetic being ~free).
// source: https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
let token_estimate = prompt.len() * 2 / 9;
let duration = start.elapsed();
log::info!(
"Time taken to count tokens for prompt of length {:?}B: {:?}",
prompt.len(),
duration
);
if token_estimate < max_token_count {
prompts.push(prompt);
} else if current_summaries.len() == 1 {
log::warn!("Inferring context for a single file's summary failed because the prompt's token length exceeded the model's token limit.");
} else {
log::info!(
"Context inference using file summaries resulted in a prompt containing {token_estimate} tokens, which exceeded the model's max of {max_token_count}. Retrying as two separate prompts, each including half the number of summaries.",
);
let (left, right) = current_summaries.split_at(current_summaries.len() / 2);
stack.push(right);
stack.push(left);
}
}
let all_start = std::time::Instant::now();
let (tx, rx) = channel::bounded(1024);
let completion_streams = prompts
.into_iter()
.map(|prompt| {
let request = make_request(prompt.clone());
let model = model.clone();
let tx = tx.clone();
let stream = model.stream_completion(request, &cx);
(stream, tx)
})
.collect::<Vec<_>>();
cx.background_executor()
.spawn(async move {
let futures = completion_streams
.into_iter()
.enumerate()
.map(|(ix, (stream, tx))| async move {
let start = std::time::Instant::now();
let events = stream.await?;
log::info!("Time taken for awaiting /await chunk stream #{ix}: {:?}", start.elapsed());
let completion: String = events
.filter_map(|event| async {
if let Ok(LanguageModelCompletionEvent::Text(text)) = event {
Some(text)
} else {
None
}
})
.collect()
.await;
log::info!("Time taken for all /auto chunks to come back for #{ix}: {:?}", start.elapsed());
for line in completion.split('\n') {
if let Some(first_space) = line.find(' ') {
let command = &line[..first_space].trim();
let arg = &line[first_space..].trim();
tx.send(CommandToRun {
name: command.to_string(),
arg: arg.to_string(),
})
.await?;
} else if !line.trim().is_empty() {
// All slash-commands currently supported in context inference need a space for the argument.
log::warn!(
"Context inference returned a non-blank line that contained no spaces (meaning no argument for the slash command): {:?}",
line
);
}
}
anyhow::Ok(())
})
.collect::<Vec<_>>();
let _ = futures::future::try_join_all(futures).await.log_err();
let duration = all_start.elapsed();
eprintln!("All futures completed in {:?}", duration);
})
.await;
drop(tx); // Close the channel so that rx.collect() won't hang. This is safe because all futures have completed.
let results = rx.collect::<Vec<_>>().await;
eprintln!(
"Finished collecting from the channel with {} results",
results.len()
);
for command in results {
// Don't return empty or duplicate commands
if !command.name.is_empty()
&& !final_response
.iter()
.any(|cmd: &CommandToRun| cmd.name == command.name && cmd.arg == command.arg)
{
if SUPPORTED_SLASH_COMMANDS
.iter()
.any(|supported| &command.name == supported)
{
final_response.push(command);
} else {
log::warn!(
"Context inference returned an unrecognized slash command: {:?}",
command
);
}
}
}
// Sort the commands by name (reversed just so that /search appears before /file)
final_response.sort_by(|cmd1, cmd2| cmd1.name.cmp(&cmd2.name).reverse());
Ok(final_response)
}

View file

@ -0,0 +1,156 @@
use anyhow::{anyhow, Context, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use fs::Fs;
use gpui::{AppContext, Model, Task, WeakView};
use language::{BufferSnapshot, LspAdapterDelegate};
use project::{Project, ProjectPath};
use std::{
fmt::Write,
path::Path,
sync::{atomic::AtomicBool, Arc},
};
use ui::prelude::*;
use workspace::Workspace;
pub struct CargoWorkspaceSlashCommand;
impl CargoWorkspaceSlashCommand {
async fn build_message(fs: Arc<dyn Fs>, path_to_cargo_toml: &Path) -> Result<String> {
let buffer = fs.load(path_to_cargo_toml).await?;
let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?;
let mut message = String::new();
writeln!(message, "You are in a Rust project.")?;
if let Some(workspace) = cargo_toml.workspace {
writeln!(
message,
"The project is a Cargo workspace with the following members:"
)?;
for member in workspace.members {
writeln!(message, "- {member}")?;
}
if !workspace.default_members.is_empty() {
writeln!(message, "The default members are:")?;
for member in workspace.default_members {
writeln!(message, "- {member}")?;
}
}
if !workspace.dependencies.is_empty() {
writeln!(
message,
"The following workspace dependencies are installed:"
)?;
for dependency in workspace.dependencies.keys() {
writeln!(message, "- {dependency}")?;
}
}
} else if let Some(package) = cargo_toml.package {
writeln!(
message,
"The project name is \"{name}\".",
name = package.name
)?;
let description = package
.description
.as_ref()
.and_then(|description| description.get().ok().cloned());
if let Some(description) = description.as_ref() {
writeln!(message, "It describes itself as \"{description}\".")?;
}
if !cargo_toml.dependencies.is_empty() {
writeln!(message, "The following dependencies are installed:")?;
for dependency in cargo_toml.dependencies.keys() {
writeln!(message, "- {dependency}")?;
}
}
}
Ok(message)
}
fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
let worktree = project.read(cx).worktrees(cx).next()?;
let worktree = worktree.read(cx);
let entry = worktree.entry_for_path("Cargo.toml")?;
let path = ProjectPath {
worktree_id: worktree.id(),
path: entry.path.clone(),
};
Some(Arc::from(
project.read(cx).absolute_path(&path, cx)?.as_path(),
))
}
}
impl SlashCommand for CargoWorkspaceSlashCommand {
fn name(&self) -> String {
"cargo-workspace".into()
}
fn description(&self) -> String {
"insert project workspace metadata".into()
}
fn menu_text(&self) -> String {
"Insert Project Workspace Metadata".into()
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Err(anyhow!("this command does not require argument")))
}
fn requires_argument(&self) -> bool {
false
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let output = workspace.update(cx, |workspace, cx| {
let project = workspace.project().clone();
let fs = workspace.project().read(cx).fs().clone();
let path = Self::path_to_cargo_toml(project, cx);
let output = cx.background_executor().spawn(async move {
let path = path.with_context(|| "Cargo.toml not found")?;
Self::build_message(fs, &path).await
});
cx.foreground_executor().spawn(async move {
let text = output.await?;
let range = 0..text.len();
Ok(SlashCommandOutput {
text,
sections: vec![SlashCommandOutputSection {
range,
icon: IconName::FileTree,
label: "Project".into(),
metadata: None,
}],
run_commands_in_text: false,
}
.to_event_stream())
})
});
output.unwrap_or_else(|error| Task::ready(Err(error)))
}
}

View file

@ -0,0 +1,249 @@
use anyhow::{anyhow, Result};
use assistant_slash_command::{
AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandOutput,
SlashCommandOutputSection, SlashCommandResult,
};
use collections::HashMap;
use context_server::{
manager::{ContextServer, ContextServerManager},
types::Prompt,
};
use gpui::{AppContext, Model, Task, WeakView, WindowContext};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use text::LineEnding;
use ui::{IconName, SharedString};
use workspace::Workspace;
use crate::create_label_for_command;
pub struct ContextServerSlashCommand {
server_manager: Model<ContextServerManager>,
server_id: Arc<str>,
prompt: Prompt,
}
impl ContextServerSlashCommand {
pub fn new(
server_manager: Model<ContextServerManager>,
server: &Arc<ContextServer>,
prompt: Prompt,
) -> Self {
Self {
server_id: server.id(),
prompt,
server_manager,
}
}
}
impl SlashCommand for ContextServerSlashCommand {
fn name(&self) -> String {
self.prompt.name.clone()
}
fn label(&self, cx: &AppContext) -> language::CodeLabel {
let mut parts = vec![self.prompt.name.as_str()];
if let Some(args) = &self.prompt.arguments {
if let Some(arg) = args.first() {
parts.push(arg.name.as_str());
}
}
create_label_for_command(&parts[0], &parts[1..], cx)
}
fn description(&self) -> String {
match &self.prompt.description {
Some(desc) => desc.clone(),
None => format!("Run '{}' from {}", self.prompt.name, self.server_id),
}
}
fn menu_text(&self) -> String {
match &self.prompt.description {
Some(desc) => desc.clone(),
None => format!("Run '{}' from {}", self.prompt.name, self.server_id),
}
}
fn requires_argument(&self) -> bool {
self.prompt.arguments.as_ref().map_or(false, |args| {
args.iter().any(|arg| arg.required == Some(true))
})
}
fn complete_argument(
self: Arc<Self>,
arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
let Ok((arg_name, arg_value)) = completion_argument(&self.prompt, arguments) else {
return Task::ready(Err(anyhow!("Failed to complete argument")));
};
let server_id = self.server_id.clone();
let prompt_name = self.prompt.name.clone();
if let Some(server) = self.server_manager.read(cx).get_server(&server_id) {
cx.foreground_executor().spawn(async move {
let Some(protocol) = server.client() else {
return Err(anyhow!("Context server not initialized"));
};
let completion_result = protocol
.completion(
context_server::types::CompletionReference::Prompt(
context_server::types::PromptReference {
r#type: context_server::types::PromptReferenceType::Prompt,
name: prompt_name,
},
),
arg_name,
arg_value,
)
.await?;
let completions = completion_result
.values
.into_iter()
.map(|value| ArgumentCompletion {
label: CodeLabel::plain(value.clone(), None),
new_text: value,
after_completion: AfterCompletion::Continue,
replace_previous_arguments: false,
})
.collect();
Ok(completions)
})
} else {
Task::ready(Err(anyhow!("Context server not found")))
}
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let server_id = self.server_id.clone();
let prompt_name = self.prompt.name.clone();
let prompt_args = match prompt_arguments(&self.prompt, arguments) {
Ok(args) => args,
Err(e) => return Task::ready(Err(e)),
};
let manager = self.server_manager.read(cx);
if let Some(server) = manager.get_server(&server_id) {
cx.foreground_executor().spawn(async move {
let Some(protocol) = server.client() else {
return Err(anyhow!("Context server not initialized"));
};
let result = protocol.run_prompt(&prompt_name, prompt_args).await?;
// Check that there are only user roles
if result
.messages
.iter()
.any(|msg| !matches!(msg.role, context_server::types::Role::User))
{
return Err(anyhow!(
"Prompt contains non-user roles, which is not supported"
));
}
// Extract text from user messages into a single prompt string
let mut prompt = result
.messages
.into_iter()
.filter_map(|msg| match msg.content {
context_server::types::MessageContent::Text { text, .. } => Some(text),
_ => None,
})
.collect::<Vec<String>>()
.join("\n\n");
// We must normalize the line endings here, since servers might return CR characters.
LineEnding::normalize(&mut prompt);
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: 0..(prompt.len()),
icon: IconName::ZedAssistant,
label: SharedString::from(
result
.description
.unwrap_or(format!("Result from {}", prompt_name)),
),
metadata: None,
}],
text: prompt,
run_commands_in_text: false,
}
.to_event_stream())
})
} else {
Task::ready(Err(anyhow!("Context server not found")))
}
}
}
fn completion_argument(prompt: &Prompt, arguments: &[String]) -> Result<(String, String)> {
if arguments.is_empty() {
return Err(anyhow!("No arguments given"));
}
match &prompt.arguments {
Some(args) if args.len() == 1 => {
let arg_name = args[0].name.clone();
let arg_value = arguments.join(" ");
Ok((arg_name, arg_value))
}
Some(_) => Err(anyhow!("Prompt must have exactly one argument")),
None => Err(anyhow!("Prompt has no arguments")),
}
}
fn prompt_arguments(prompt: &Prompt, arguments: &[String]) -> Result<HashMap<String, String>> {
match &prompt.arguments {
Some(args) if args.len() > 1 => Err(anyhow!(
"Prompt has more than one argument, which is not supported"
)),
Some(args) if args.len() == 1 => {
if !arguments.is_empty() {
let mut map = HashMap::default();
map.insert(args[0].name.clone(), arguments.join(" "));
Ok(map)
} else if arguments.is_empty() && args[0].required == Some(false) {
Ok(HashMap::default())
} else {
Err(anyhow!("Prompt expects argument but none given"))
}
}
Some(_) | None => {
if arguments.is_empty() {
Ok(HashMap::default())
} else {
Err(anyhow!("Prompt expects no arguments but some were given"))
}
}
}
}
/// MCP servers can return prompts with multiple arguments. Since we only
/// support one argument, we ignore all others. This is the necessary predicate
/// for this.
pub fn acceptable_prompt(prompt: &Prompt) -> bool {
match &prompt.arguments {
None => true,
Some(args) if args.len() <= 1 => true,
_ => false,
}
}

View file

@ -0,0 +1,89 @@
use anyhow::{anyhow, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use gpui::{Task, WeakView};
use language::{BufferSnapshot, LspAdapterDelegate};
use prompt_library::PromptStore;
use std::{
fmt::Write,
sync::{atomic::AtomicBool, Arc},
};
use ui::prelude::*;
use workspace::Workspace;
pub 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: Arc<Self>,
_arguments: &[String],
_cancellation_flag: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Err(anyhow!("this command does not require argument")))
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
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();
text.push('\n');
for prompt in prompts {
if let Some(title) = prompt.title {
writeln!(text, "/prompt {}", title).unwrap();
}
}
text.pop();
if text.is_empty() {
text.push('\n');
}
if !text.ends_with('\n') {
text.push('\n');
}
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: 0..text.len(),
icon: IconName::Library,
label: "Default".into(),
metadata: None,
}],
text,
run_commands_in_text: true,
}
.to_event_stream())
})
}
}

View file

@ -0,0 +1,125 @@
use crate::file_command::{FileCommandMetadata, FileSlashCommand};
use anyhow::{anyhow, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use collections::HashSet;
use futures::future;
use gpui::{Task, WeakView, WindowContext};
use language::{BufferSnapshot, LspAdapterDelegate};
use std::sync::{atomic::AtomicBool, Arc};
use text::OffsetRangeExt;
use ui::prelude::*;
use workspace::Workspace;
pub struct DeltaSlashCommand;
impl SlashCommand for DeltaSlashCommand {
fn name(&self) -> String {
"delta".into()
}
fn description(&self) -> String {
"Re-insert changed files".into()
}
fn menu_text(&self) -> String {
self.description()
}
fn icon(&self) -> IconName {
IconName::Diff
}
fn requires_argument(&self) -> bool {
false
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancellation_flag: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Err(anyhow!("this command does not require argument")))
}
fn run(
self: Arc<Self>,
_arguments: &[String],
context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let mut paths = HashSet::default();
let mut file_command_old_outputs = Vec::new();
let mut file_command_new_outputs = Vec::new();
for section in context_slash_command_output_sections.iter().rev() {
if let Some(metadata) = section
.metadata
.as_ref()
.and_then(|value| serde_json::from_value::<FileCommandMetadata>(value.clone()).ok())
{
if paths.insert(metadata.path.clone()) {
file_command_old_outputs.push(
context_buffer
.as_rope()
.slice(section.range.to_offset(&context_buffer)),
);
file_command_new_outputs.push(Arc::new(FileSlashCommand).run(
&[metadata.path.clone()],
context_slash_command_output_sections,
context_buffer.clone(),
workspace.clone(),
delegate.clone(),
cx,
));
}
}
}
cx.background_executor().spawn(async move {
let mut output = SlashCommandOutput::default();
let mut changes_detected = false;
let file_command_new_outputs = future::join_all(file_command_new_outputs).await;
for (old_text, new_output) in file_command_old_outputs
.into_iter()
.zip(file_command_new_outputs)
{
if let Ok(new_output) = new_output {
if let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await
{
if let Some(file_command_range) = new_output.sections.first() {
let new_text = &new_output.text[file_command_range.range.clone()];
if old_text.chars().ne(new_text.chars()) {
changes_detected = true;
output.sections.extend(new_output.sections.into_iter().map(
|section| SlashCommandOutputSection {
range: output.text.len() + section.range.start
..output.text.len() + section.range.end,
icon: section.icon,
label: section.label,
metadata: section.metadata,
},
));
output.text.push_str(&new_output.text);
}
}
}
}
}
if !changes_detected {
return Err(anyhow!("no new changes detected"));
}
Ok(output.to_event_stream())
})
}
}

View file

@ -0,0 +1,433 @@
use anyhow::{anyhow, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use fuzzy::{PathMatch, StringMatchCandidate};
use gpui::{AppContext, Model, Task, View, WeakView};
use language::{
Anchor, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, LspAdapterDelegate,
OffsetRangeExt, ToOffset,
};
use project::{DiagnosticSummary, PathMatchCandidateSet, Project};
use rope::Point;
use std::{
fmt::Write,
path::{Path, PathBuf},
sync::{atomic::AtomicBool, Arc},
};
use ui::prelude::*;
use util::paths::PathMatcher;
use util::ResultExt;
use workspace::Workspace;
use crate::create_label_for_command;
pub struct DiagnosticsSlashCommand;
impl DiagnosticsSlashCommand {
fn search_paths(
&self,
query: String,
cancellation_flag: Arc<AtomicBool>,
workspace: &View<Workspace>,
cx: &mut AppContext,
) -> Task<Vec<PathMatch>> {
if query.is_empty() {
let workspace = workspace.read(cx);
let entries = workspace.recent_navigation_history(Some(10), cx);
let path_prefix: Arc<str> = Arc::default();
Task::ready(
entries
.into_iter()
.map(|(entry, _)| PathMatch {
score: 0.,
positions: Vec::new(),
worktree_id: entry.worktree_id.to_usize(),
path: entry.path.clone(),
path_prefix: path_prefix.clone(),
is_dir: false, // Diagnostics can't be produced for directories
distance_to_relative_ancestor: 0,
})
.collect(),
)
} else {
let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
let candidate_sets = worktrees
.into_iter()
.map(|worktree| {
let worktree = worktree.read(cx);
PathMatchCandidateSet {
snapshot: worktree.snapshot(),
include_ignored: worktree
.root_entry()
.map_or(false, |entry| entry.is_ignored),
include_root_name: true,
candidates: project::Candidates::Entries,
}
})
.collect::<Vec<_>>();
let executor = cx.background_executor().clone();
cx.foreground_executor().spawn(async move {
fuzzy::match_path_sets(
candidate_sets.as_slice(),
query.as_str(),
None,
false,
100,
&cancellation_flag,
executor,
)
.await
})
}
}
}
impl SlashCommand for DiagnosticsSlashCommand {
fn name(&self) -> String {
"diagnostics".into()
}
fn label(&self, cx: &AppContext) -> language::CodeLabel {
create_label_for_command("diagnostics", &[INCLUDE_WARNINGS_ARGUMENT], cx)
}
fn description(&self) -> String {
"Insert diagnostics".into()
}
fn icon(&self) -> IconName {
IconName::XCircle
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn accepts_arguments(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
arguments: &[String],
cancellation_flag: Arc<AtomicBool>,
workspace: Option<WeakView<Workspace>>,
cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
return Task::ready(Err(anyhow!("workspace was dropped")));
};
let query = arguments.last().cloned().unwrap_or_default();
let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx);
let executor = cx.background_executor().clone();
cx.background_executor().spawn(async move {
let mut matches: Vec<String> = paths
.await
.into_iter()
.map(|path_match| {
format!(
"{}{}",
path_match.path_prefix,
path_match.path.to_string_lossy()
)
})
.collect();
matches.extend(
fuzzy::match_strings(
&Options::match_candidates_for_args(),
&query,
false,
10,
&cancellation_flag,
executor,
)
.await
.into_iter()
.map(|candidate| candidate.string),
);
Ok(matches
.into_iter()
.map(|completion| ArgumentCompletion {
label: completion.clone().into(),
new_text: completion,
after_completion: assistant_slash_command::AfterCompletion::Run,
replace_previous_arguments: false,
})
.collect())
})
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow!("workspace was dropped")));
};
let options = Options::parse(arguments);
let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
cx.spawn(move |_| async move {
task.await?
.map(|output| output.to_event_stream())
.ok_or_else(|| anyhow!("No diagnostics found"))
})
}
}
#[derive(Default)]
struct Options {
include_warnings: bool,
path_matcher: Option<PathMatcher>,
}
const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings";
impl Options {
fn parse(arguments: &[String]) -> Self {
let mut include_warnings = false;
let mut path_matcher = None;
for arg in arguments {
if arg == INCLUDE_WARNINGS_ARGUMENT {
include_warnings = true;
} else {
path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err();
}
}
Self {
include_warnings,
path_matcher,
}
}
fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
[StringMatchCandidate::new(0, INCLUDE_WARNINGS_ARGUMENT)]
}
}
fn collect_diagnostics(
project: Model<Project>,
options: Options,
cx: &mut AppContext,
) -> Task<Result<Option<SlashCommandOutput>>> {
let error_source = if let Some(path_matcher) = &options.path_matcher {
debug_assert_eq!(path_matcher.sources().len(), 1);
Some(path_matcher.sources().first().cloned().unwrap_or_default())
} else {
None
};
let glob_is_exact_file_match = if let Some(path) = options
.path_matcher
.as_ref()
.and_then(|pm| pm.sources().first())
{
PathBuf::try_from(path)
.ok()
.and_then(|path| {
project.read(cx).worktrees(cx).find_map(|worktree| {
let worktree = worktree.read(cx);
let worktree_root_path = Path::new(worktree.root_name());
let relative_path = path.strip_prefix(worktree_root_path).ok()?;
worktree.absolutize(&relative_path).ok()
})
})
.is_some()
} else {
false
};
let project_handle = project.downgrade();
let diagnostic_summaries: Vec<_> = project
.read(cx)
.diagnostic_summaries(false, cx)
.flat_map(|(path, _, summary)| {
let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
let mut path_buf = PathBuf::from(worktree.read(cx).root_name());
path_buf.push(&path.path);
Some((path, path_buf, summary))
})
.collect();
cx.spawn(|mut cx| async move {
let mut output = SlashCommandOutput::default();
if let Some(error_source) = error_source.as_ref() {
writeln!(output.text, "diagnostics: {}", error_source).unwrap();
} else {
writeln!(output.text, "diagnostics").unwrap();
}
let mut project_summary = DiagnosticSummary::default();
for (project_path, path, summary) in diagnostic_summaries {
if let Some(path_matcher) = &options.path_matcher {
if !path_matcher.is_match(&path) {
continue;
}
}
project_summary.error_count += summary.error_count;
if options.include_warnings {
project_summary.warning_count += summary.warning_count;
} else if summary.error_count == 0 {
continue;
}
let last_end = output.text.len();
let file_path = path.to_string_lossy().to_string();
if !glob_is_exact_file_match {
writeln!(&mut output.text, "{file_path}").unwrap();
}
if let Some(buffer) = project_handle
.update(&mut cx, |project, cx| project.open_buffer(project_path, cx))?
.await
.log_err()
{
let snapshot = cx.read_model(&buffer, |buffer, _| buffer.snapshot())?;
collect_buffer_diagnostics(&mut output, &snapshot, options.include_warnings);
}
if !glob_is_exact_file_match {
output.sections.push(SlashCommandOutputSection {
range: last_end..output.text.len().saturating_sub(1),
icon: IconName::File,
label: file_path.into(),
metadata: None,
});
}
}
// No diagnostics found
if output.sections.is_empty() {
return Ok(None);
}
let mut label = String::new();
label.push_str("Diagnostics");
if let Some(source) = error_source {
write!(label, " ({})", source).unwrap();
}
if project_summary.error_count > 0 || project_summary.warning_count > 0 {
label.push(':');
if project_summary.error_count > 0 {
write!(label, " {} errors", project_summary.error_count).unwrap();
if project_summary.warning_count > 0 {
label.push_str(",");
}
}
if project_summary.warning_count > 0 {
write!(label, " {} warnings", project_summary.warning_count).unwrap();
}
}
output.sections.insert(
0,
SlashCommandOutputSection {
range: 0..output.text.len(),
icon: IconName::Warning,
label: label.into(),
metadata: None,
},
);
Ok(Some(output))
})
}
pub fn collect_buffer_diagnostics(
output: &mut SlashCommandOutput,
snapshot: &BufferSnapshot,
include_warnings: bool,
) {
for (_, group) in snapshot.diagnostic_groups(None) {
let entry = &group.entries[group.primary_ix];
collect_diagnostic(output, entry, &snapshot, include_warnings)
}
}
fn collect_diagnostic(
output: &mut SlashCommandOutput,
entry: &DiagnosticEntry<Anchor>,
snapshot: &BufferSnapshot,
include_warnings: bool,
) {
const EXCERPT_EXPANSION_SIZE: u32 = 2;
const MAX_MESSAGE_LENGTH: usize = 2000;
let (ty, icon) = match entry.diagnostic.severity {
DiagnosticSeverity::WARNING => {
if !include_warnings {
return;
}
("warning", IconName::Warning)
}
DiagnosticSeverity::ERROR => ("error", IconName::XCircle),
_ => return,
};
let prev_len = output.text.len();
let range = entry.range.to_point(snapshot);
let diagnostic_row_number = range.start.row + 1;
let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
let excerpt_range =
Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot);
output.text.push_str("```");
if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
output.text.push_str(&language_name);
}
output.text.push('\n');
let mut buffer_text = String::new();
for chunk in snapshot.text_for_range(excerpt_range) {
buffer_text.push_str(chunk);
}
for (i, line) in buffer_text.lines().enumerate() {
let line_number = start_row + i as u32 + 1;
writeln!(output.text, "{}", line).unwrap();
if line_number == diagnostic_row_number {
output.text.push_str("//");
let prev_len = output.text.len();
write!(output.text, " {}: ", ty).unwrap();
let padding = output.text.len() - prev_len;
let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
.replace('\n', format!("\n//{:padding$}", "").as_str());
writeln!(output.text, "{message}").unwrap();
}
}
writeln!(output.text, "```").unwrap();
output.sections.push(SlashCommandOutputSection {
range: prev_len..output.text.len().saturating_sub(1),
icon,
label: entry.diagnostic.message.clone().into(),
metadata: None,
});
}

View file

@ -0,0 +1,539 @@
use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use gpui::{AppContext, BackgroundExecutor, Model, Task, WeakView};
use indexed_docs::{
DocsDotRsProvider, IndexedDocsRegistry, IndexedDocsStore, LocalRustdocProvider, PackageName,
ProviderId,
};
use language::{BufferSnapshot, LspAdapterDelegate};
use project::{Project, ProjectPath};
use ui::prelude::*;
use util::{maybe, ResultExt};
use workspace::Workspace;
pub struct DocsSlashCommand;
impl DocsSlashCommand {
pub const NAME: &'static str = "docs";
fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
let worktree = project.read(cx).worktrees(cx).next()?;
let worktree = worktree.read(cx);
let entry = worktree.entry_for_path("Cargo.toml")?;
let path = ProjectPath {
worktree_id: worktree.id(),
path: entry.path.clone(),
};
Some(Arc::from(
project.read(cx).absolute_path(&path, cx)?.as_path(),
))
}
/// Ensures that the indexed doc providers for Rust are registered.
///
/// Ideally we would do this sooner, but we need to wait until we're able to
/// access the workspace so we can read the project.
fn ensure_rust_doc_providers_are_registered(
&self,
workspace: Option<WeakView<Workspace>>,
cx: &mut AppContext,
) {
let indexed_docs_registry = IndexedDocsRegistry::global(cx);
if indexed_docs_registry
.get_provider_store(LocalRustdocProvider::id())
.is_none()
{
let index_provider_deps = maybe!({
let workspace = workspace.clone().ok_or_else(|| anyhow!("no workspace"))?;
let workspace = workspace
.upgrade()
.ok_or_else(|| anyhow!("workspace was dropped"))?;
let project = workspace.read(cx).project().clone();
let fs = project.read(cx).fs().clone();
let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
.and_then(|path| path.parent().map(|path| path.to_path_buf()))
.ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
anyhow::Ok((fs, cargo_workspace_root))
});
if let Some((fs, cargo_workspace_root)) = index_provider_deps.log_err() {
indexed_docs_registry.register_provider(Box::new(LocalRustdocProvider::new(
fs,
cargo_workspace_root,
)));
}
}
if indexed_docs_registry
.get_provider_store(DocsDotRsProvider::id())
.is_none()
{
let http_client = maybe!({
let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
let workspace = workspace
.upgrade()
.ok_or_else(|| anyhow!("workspace was dropped"))?;
let project = workspace.read(cx).project().clone();
anyhow::Ok(project.read(cx).client().http_client().clone())
});
if let Some(http_client) = http_client.log_err() {
indexed_docs_registry
.register_provider(Box::new(DocsDotRsProvider::new(http_client)));
}
}
}
/// Runs just-in-time indexing for a given package, in case the slash command
/// is run without any entries existing in the index.
fn run_just_in_time_indexing(
store: Arc<IndexedDocsStore>,
key: String,
package: PackageName,
executor: BackgroundExecutor,
) -> Task<()> {
executor.clone().spawn(async move {
let (prefix, needs_full_index) = if let Some((prefix, _)) = key.split_once('*') {
// If we have a wildcard in the search, we want to wait until
// we've completely finished indexing so we get a full set of
// results for the wildcard.
(prefix.to_string(), true)
} else {
(key, false)
};
// If we already have some entries, we assume that we've indexed the package before
// and don't need to do it again.
let has_any_entries = store
.any_with_prefix(prefix.clone())
.await
.unwrap_or_default();
if has_any_entries {
return ();
};
let index_task = store.clone().index(package.clone());
if needs_full_index {
_ = index_task.await;
} else {
loop {
executor.timer(Duration::from_millis(200)).await;
if store
.any_with_prefix(prefix.clone())
.await
.unwrap_or_default()
|| !store.is_indexing(&package)
{
break;
}
}
}
})
}
}
impl SlashCommand for DocsSlashCommand {
fn name(&self) -> String {
Self::NAME.into()
}
fn description(&self) -> String {
"insert docs".into()
}
fn menu_text(&self) -> String {
"Insert Documentation".into()
}
fn requires_argument(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
arguments: &[String],
_cancel: Arc<AtomicBool>,
workspace: Option<WeakView<Workspace>>,
cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
self.ensure_rust_doc_providers_are_registered(workspace, cx);
let indexed_docs_registry = IndexedDocsRegistry::global(cx);
let args = DocsSlashCommandArgs::parse(arguments);
let store = args
.provider()
.ok_or_else(|| anyhow!("no docs provider specified"))
.and_then(|provider| IndexedDocsStore::try_global(provider, cx));
cx.background_executor().spawn(async move {
fn build_completions(items: Vec<String>) -> Vec<ArgumentCompletion> {
items
.into_iter()
.map(|item| ArgumentCompletion {
label: item.clone().into(),
new_text: item.to_string(),
after_completion: assistant_slash_command::AfterCompletion::Run,
replace_previous_arguments: false,
})
.collect()
}
match args {
DocsSlashCommandArgs::NoProvider => {
let providers = indexed_docs_registry.list_providers();
if providers.is_empty() {
return Ok(vec![ArgumentCompletion {
label: "No available docs providers.".into(),
new_text: String::new(),
after_completion: false.into(),
replace_previous_arguments: false,
}]);
}
Ok(providers
.into_iter()
.map(|provider| ArgumentCompletion {
label: provider.to_string().into(),
new_text: provider.to_string(),
after_completion: false.into(),
replace_previous_arguments: false,
})
.collect())
}
DocsSlashCommandArgs::SearchPackageDocs {
provider,
package,
index,
} => {
let store = store?;
if index {
// We don't need to hold onto this task, as the `IndexedDocsStore` will hold it
// until it completes.
drop(store.clone().index(package.as_str().into()));
}
let suggested_packages = store.clone().suggest_packages().await?;
let search_results = store.search(package).await;
let mut items = build_completions(search_results);
let workspace_crate_completions = suggested_packages
.into_iter()
.filter(|package_name| {
!items
.iter()
.any(|item| item.label.text() == package_name.as_ref())
})
.map(|package_name| ArgumentCompletion {
label: format!("{package_name} (unindexed)").into(),
new_text: format!("{package_name}"),
after_completion: true.into(),
replace_previous_arguments: false,
})
.collect::<Vec<_>>();
items.extend(workspace_crate_completions);
if items.is_empty() {
return Ok(vec![ArgumentCompletion {
label: format!(
"Enter a {package_term} name.",
package_term = package_term(&provider)
)
.into(),
new_text: provider.to_string(),
after_completion: false.into(),
replace_previous_arguments: false,
}]);
}
Ok(items)
}
DocsSlashCommandArgs::SearchItemDocs { item_path, .. } => {
let store = store?;
let items = store.search(item_path).await;
Ok(build_completions(items))
}
}
})
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
if arguments.is_empty() {
return Task::ready(Err(anyhow!("missing an argument")));
};
let args = DocsSlashCommandArgs::parse(arguments);
let executor = cx.background_executor().clone();
let task = cx.background_executor().spawn({
let store = args
.provider()
.ok_or_else(|| anyhow!("no docs provider specified"))
.and_then(|provider| IndexedDocsStore::try_global(provider, cx));
async move {
let (provider, key) = match args.clone() {
DocsSlashCommandArgs::NoProvider => bail!("no docs provider specified"),
DocsSlashCommandArgs::SearchPackageDocs {
provider, package, ..
} => (provider, package),
DocsSlashCommandArgs::SearchItemDocs {
provider,
item_path,
..
} => (provider, item_path),
};
if key.trim().is_empty() {
bail!(
"no {package_term} name provided",
package_term = package_term(&provider)
);
}
let store = store?;
if let Some(package) = args.package() {
Self::run_just_in_time_indexing(store.clone(), key.clone(), package, executor)
.await;
}
let (text, ranges) = if let Some((prefix, _)) = key.split_once('*') {
let docs = store.load_many_by_prefix(prefix.to_string()).await?;
let mut text = String::new();
let mut ranges = Vec::new();
for (key, docs) in docs {
let prev_len = text.len();
text.push_str(&docs.0);
text.push_str("\n");
ranges.push((key, prev_len..text.len()));
text.push_str("\n");
}
(text, ranges)
} else {
let item_docs = store.load(key.clone()).await?;
let text = item_docs.to_string();
let range = 0..text.len();
(text, vec![(key, range)])
};
anyhow::Ok((provider, text, ranges))
}
});
cx.foreground_executor().spawn(async move {
let (provider, text, ranges) = task.await?;
Ok(SlashCommandOutput {
text,
sections: ranges
.into_iter()
.map(|(key, range)| SlashCommandOutputSection {
range,
icon: IconName::FileDoc,
label: format!("docs ({provider}): {key}",).into(),
metadata: None,
})
.collect(),
run_commands_in_text: false,
}
.to_event_stream())
})
}
}
fn is_item_path_delimiter(char: char) -> bool {
!char.is_alphanumeric() && char != '-' && char != '_'
}
#[derive(Debug, PartialEq, Clone)]
pub enum DocsSlashCommandArgs {
NoProvider,
SearchPackageDocs {
provider: ProviderId,
package: String,
index: bool,
},
SearchItemDocs {
provider: ProviderId,
package: String,
item_path: String,
},
}
impl DocsSlashCommandArgs {
pub fn parse(arguments: &[String]) -> Self {
let Some(provider) = arguments
.get(0)
.cloned()
.filter(|arg| !arg.trim().is_empty())
else {
return Self::NoProvider;
};
let provider = ProviderId(provider.into());
let Some(argument) = arguments.get(1) else {
return Self::NoProvider;
};
if let Some((package, rest)) = argument.split_once(is_item_path_delimiter) {
if rest.trim().is_empty() {
Self::SearchPackageDocs {
provider,
package: package.to_owned(),
index: true,
}
} else {
Self::SearchItemDocs {
provider,
package: package.to_owned(),
item_path: argument.to_owned(),
}
}
} else {
Self::SearchPackageDocs {
provider,
package: argument.to_owned(),
index: false,
}
}
}
pub fn provider(&self) -> Option<ProviderId> {
match self {
Self::NoProvider => None,
Self::SearchPackageDocs { provider, .. } | Self::SearchItemDocs { provider, .. } => {
Some(provider.clone())
}
}
}
pub fn package(&self) -> Option<PackageName> {
match self {
Self::NoProvider => None,
Self::SearchPackageDocs { package, .. } | Self::SearchItemDocs { package, .. } => {
Some(package.as_str().into())
}
}
}
}
/// Returns the term used to refer to a package.
fn package_term(provider: &ProviderId) -> &'static str {
if provider == &DocsDotRsProvider::id() || provider == &LocalRustdocProvider::id() {
return "crate";
}
"package"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_docs_slash_command_args() {
assert_eq!(
DocsSlashCommandArgs::parse(&["".to_string()]),
DocsSlashCommandArgs::NoProvider
);
assert_eq!(
DocsSlashCommandArgs::parse(&["rustdoc".to_string()]),
DocsSlashCommandArgs::NoProvider
);
assert_eq!(
DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "".to_string()]),
DocsSlashCommandArgs::SearchPackageDocs {
provider: ProviderId("rustdoc".into()),
package: "".into(),
index: false
}
);
assert_eq!(
DocsSlashCommandArgs::parse(&["gleam".to_string(), "".to_string()]),
DocsSlashCommandArgs::SearchPackageDocs {
provider: ProviderId("gleam".into()),
package: "".into(),
index: false
}
);
assert_eq!(
DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui".to_string()]),
DocsSlashCommandArgs::SearchPackageDocs {
provider: ProviderId("rustdoc".into()),
package: "gpui".into(),
index: false,
}
);
assert_eq!(
DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib".to_string()]),
DocsSlashCommandArgs::SearchPackageDocs {
provider: ProviderId("gleam".into()),
package: "gleam_stdlib".into(),
index: false
}
);
// Adding an item path delimiter indicates we can start indexing.
assert_eq!(
DocsSlashCommandArgs::parse(&["rustdoc".to_string(), "gpui:".to_string()]),
DocsSlashCommandArgs::SearchPackageDocs {
provider: ProviderId("rustdoc".into()),
package: "gpui".into(),
index: true,
}
);
assert_eq!(
DocsSlashCommandArgs::parse(&["gleam".to_string(), "gleam_stdlib/".to_string()]),
DocsSlashCommandArgs::SearchPackageDocs {
provider: ProviderId("gleam".into()),
package: "gleam_stdlib".into(),
index: true
}
);
assert_eq!(
DocsSlashCommandArgs::parse(&[
"rustdoc".to_string(),
"gpui::foo::bar::Baz".to_string()
]),
DocsSlashCommandArgs::SearchItemDocs {
provider: ProviderId("rustdoc".into()),
package: "gpui".into(),
item_path: "gpui::foo::bar::Baz".into()
}
);
assert_eq!(
DocsSlashCommandArgs::parse(&[
"gleam".to_string(),
"gleam_stdlib/gleam/int".to_string()
]),
DocsSlashCommandArgs::SearchItemDocs {
provider: ProviderId("gleam".into()),
package: "gleam_stdlib".into(),
item_path: "gleam_stdlib/gleam/int".into()
}
);
}
}

View file

@ -0,0 +1,178 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use futures::AsyncReadExt;
use gpui::{Task, WeakView};
use html_to_markdown::{convert_html_to_markdown, markdown, TagHandler};
use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
use language::{BufferSnapshot, LspAdapterDelegate};
use ui::prelude::*;
use workspace::Workspace;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
enum ContentType {
Html,
Plaintext,
Json,
}
pub struct FetchSlashCommand;
impl FetchSlashCommand {
async fn build_message(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<String> {
let mut url = url.to_owned();
if !url.starts_with("https://") && !url.starts_with("http://") {
url = format!("https://{url}");
}
let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
let mut body = Vec::new();
response
.body_mut()
.read_to_end(&mut body)
.await
.context("error reading response body")?;
if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
}
let Some(content_type) = response.headers().get("content-type") else {
bail!("missing Content-Type header");
};
let content_type = content_type
.to_str()
.context("invalid Content-Type header")?;
let content_type = match content_type {
"text/html" => ContentType::Html,
"text/plain" => ContentType::Plaintext,
"application/json" => ContentType::Json,
_ => ContentType::Html,
};
match content_type {
ContentType::Html => {
let mut handlers: Vec<TagHandler> = vec![
Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
Rc::new(RefCell::new(markdown::ParagraphHandler)),
Rc::new(RefCell::new(markdown::HeadingHandler)),
Rc::new(RefCell::new(markdown::ListHandler)),
Rc::new(RefCell::new(markdown::TableHandler::new())),
Rc::new(RefCell::new(markdown::StyledTextHandler)),
];
if url.contains("wikipedia.org") {
use html_to_markdown::structure::wikipedia;
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
handlers.push(Rc::new(
RefCell::new(wikipedia::WikipediaCodeHandler::new()),
));
} else {
handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
}
convert_html_to_markdown(&body[..], &mut handlers)
}
ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
ContentType::Json => {
let json: serde_json::Value = serde_json::from_slice(&body)?;
Ok(format!(
"```json\n{}\n```",
serde_json::to_string_pretty(&json)?
))
}
}
}
}
impl SlashCommand for FetchSlashCommand {
fn name(&self) -> String {
"fetch".into()
}
fn description(&self) -> String {
"Insert fetched URL contents".into()
}
fn icon(&self) -> IconName {
IconName::Globe
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(Vec::new()))
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let Some(argument) = arguments.first() else {
return Task::ready(Err(anyhow!("missing URL")));
};
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow!("workspace was dropped")));
};
let http_client = workspace.read(cx).client().http_client();
let url = argument.to_string();
let text = cx.background_executor().spawn({
let url = url.clone();
async move { Self::build_message(http_client, &url).await }
});
let url = SharedString::from(url);
cx.foreground_executor().spawn(async move {
let text = text.await?;
if text.trim().is_empty() {
bail!("no textual content found");
}
let range = 0..text.len();
Ok(SlashCommandOutput {
text,
sections: vec![SlashCommandOutputSection {
range,
icon: IconName::Globe,
label: format!("fetch {}", url).into(),
metadata: None,
}],
run_commands_in_text: false,
}
.to_event_stream())
})
}
}

View file

@ -0,0 +1,758 @@
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{
AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult,
};
use futures::channel::mpsc;
use futures::Stream;
use fuzzy::PathMatch;
use gpui::{AppContext, Model, Task, View, WeakView};
use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
use project::{PathMatchCandidateSet, Project};
use serde::{Deserialize, Serialize};
use smol::stream::StreamExt;
use std::{
fmt::Write,
ops::{Range, RangeInclusive},
path::{Path, PathBuf},
sync::{atomic::AtomicBool, Arc},
};
use ui::prelude::*;
use util::ResultExt;
use workspace::Workspace;
pub struct FileSlashCommand;
impl FileSlashCommand {
fn search_paths(
&self,
query: String,
cancellation_flag: Arc<AtomicBool>,
workspace: &View<Workspace>,
cx: &mut AppContext,
) -> Task<Vec<PathMatch>> {
if query.is_empty() {
let workspace = workspace.read(cx);
let project = workspace.project().read(cx);
let entries = workspace.recent_navigation_history(Some(10), cx);
let entries = entries
.into_iter()
.map(|entries| (entries.0, false))
.chain(project.worktrees(cx).flat_map(|worktree| {
let worktree = worktree.read(cx);
let id = worktree.id();
worktree.child_entries(Path::new("")).map(move |entry| {
(
project::ProjectPath {
worktree_id: id,
path: entry.path.clone(),
},
entry.kind.is_dir(),
)
})
}))
.collect::<Vec<_>>();
let path_prefix: Arc<str> = Arc::default();
Task::ready(
entries
.into_iter()
.filter_map(|(entry, is_dir)| {
let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
let mut full_path = PathBuf::from(worktree.read(cx).root_name());
full_path.push(&entry.path);
Some(PathMatch {
score: 0.,
positions: Vec::new(),
worktree_id: entry.worktree_id.to_usize(),
path: full_path.into(),
path_prefix: path_prefix.clone(),
distance_to_relative_ancestor: 0,
is_dir,
})
})
.collect(),
)
} else {
let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
let candidate_sets = worktrees
.into_iter()
.map(|worktree| {
let worktree = worktree.read(cx);
PathMatchCandidateSet {
snapshot: worktree.snapshot(),
include_ignored: worktree
.root_entry()
.map_or(false, |entry| entry.is_ignored),
include_root_name: true,
candidates: project::Candidates::Entries,
}
})
.collect::<Vec<_>>();
let executor = cx.background_executor().clone();
cx.foreground_executor().spawn(async move {
fuzzy::match_path_sets(
candidate_sets.as_slice(),
query.as_str(),
None,
false,
100,
&cancellation_flag,
executor,
)
.await
})
}
}
}
impl SlashCommand for FileSlashCommand {
fn name(&self) -> String {
"file".into()
}
fn description(&self) -> String {
"Insert file and/or directory".into()
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
true
}
fn icon(&self) -> IconName {
IconName::File
}
fn complete_argument(
self: Arc<Self>,
arguments: &[String],
cancellation_flag: Arc<AtomicBool>,
workspace: Option<WeakView<Workspace>>,
cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
return Task::ready(Err(anyhow!("workspace was dropped")));
};
let paths = self.search_paths(
arguments.last().cloned().unwrap_or_default(),
cancellation_flag,
&workspace,
cx,
);
let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
cx.background_executor().spawn(async move {
Ok(paths
.await
.into_iter()
.filter_map(|path_match| {
let text = format!(
"{}{}",
path_match.path_prefix,
path_match.path.to_string_lossy()
);
let mut label = CodeLabel::default();
let file_name = path_match.path.file_name()?.to_string_lossy();
let label_text = if path_match.is_dir {
format!("{}/ ", file_name)
} else {
format!("{} ", file_name)
};
label.push_str(label_text.as_str(), None);
label.push_str(&text, comment_id);
label.filter_range = 0..file_name.len();
Some(ArgumentCompletion {
label,
new_text: text,
after_completion: AfterCompletion::Compose,
replace_previous_arguments: false,
})
})
.collect())
})
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow!("workspace was dropped")));
};
if arguments.is_empty() {
return Task::ready(Err(anyhow!("missing path")));
};
Task::ready(Ok(collect_files(
workspace.read(cx).project().clone(),
arguments,
cx,
)
.boxed()))
}
}
fn collect_files(
project: Model<Project>,
glob_inputs: &[String],
cx: &mut AppContext,
) -> impl Stream<Item = Result<SlashCommandEvent>> {
let Ok(matchers) = glob_inputs
.into_iter()
.map(|glob_input| {
custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()])
.with_context(|| format!("invalid path {glob_input}"))
})
.collect::<anyhow::Result<Vec<custom_path_matcher::PathMatcher>>>()
else {
return futures::stream::once(async { Err(anyhow!("invalid path")) }).boxed();
};
let project_handle = project.downgrade();
let snapshots = project
.read(cx)
.worktrees(cx)
.map(|worktree| worktree.read(cx).snapshot())
.collect::<Vec<_>>();
let (events_tx, events_rx) = mpsc::unbounded();
cx.spawn(|mut cx| async move {
for snapshot in snapshots {
let worktree_id = snapshot.id();
let mut directory_stack: Vec<Arc<Path>> = Vec::new();
let mut folded_directory_names_stack = Vec::new();
let mut is_top_level_directory = true;
for entry in snapshot.entries(false, 0) {
let mut path_including_worktree_name = PathBuf::new();
path_including_worktree_name.push(snapshot.root_name());
path_including_worktree_name.push(&entry.path);
if !matchers
.iter()
.any(|matcher| matcher.is_match(&path_including_worktree_name))
{
continue;
}
while let Some(dir) = directory_stack.last() {
if entry.path.starts_with(dir) {
break;
}
directory_stack.pop().unwrap();
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "\n".into(),
run_commands_in_text: false,
},
)))?;
}
let filename = entry
.path
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string();
if entry.is_dir() {
// Auto-fold directories that contain no files
let mut child_entries = snapshot.child_entries(&entry.path);
if let Some(child) = child_entries.next() {
if child_entries.next().is_none() && child.kind.is_dir() {
if is_top_level_directory {
is_top_level_directory = false;
folded_directory_names_stack.push(
path_including_worktree_name.to_string_lossy().to_string(),
);
} else {
folded_directory_names_stack.push(filename.to_string());
}
continue;
}
} else {
// Skip empty directories
folded_directory_names_stack.clear();
continue;
}
let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
if prefix_paths.is_empty() {
let label = if is_top_level_directory {
is_top_level_directory = false;
path_including_worktree_name.to_string_lossy().to_string()
} else {
filename
};
events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
icon: IconName::Folder,
label: label.clone().into(),
metadata: None,
}))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: label,
run_commands_in_text: false,
},
)))?;
directory_stack.push(entry.path.clone());
} else {
let entry_name = format!("{}/{}", prefix_paths, &filename);
events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
icon: IconName::Folder,
label: entry_name.clone().into(),
metadata: None,
}))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: entry_name,
run_commands_in_text: false,
},
)))?;
directory_stack.push(entry.path.clone());
}
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "\n".into(),
run_commands_in_text: false,
},
)))?;
} else if entry.is_file() {
let Some(open_buffer_task) = project_handle
.update(&mut cx, |project, cx| {
project.open_buffer((worktree_id, &entry.path), cx)
})
.ok()
else {
continue;
};
if let Some(buffer) = open_buffer_task.await.log_err() {
let mut output = SlashCommandOutput::default();
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
append_buffer_to_output(
&snapshot,
Some(&path_including_worktree_name),
&mut output,
)
.log_err();
let mut buffer_events = output.to_event_stream();
while let Some(event) = buffer_events.next().await {
events_tx.unbounded_send(event)?;
}
}
}
}
while let Some(_) = directory_stack.pop() {
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
}
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
events_rx.boxed()
}
pub fn codeblock_fence_for_path(
path: Option<&Path>,
row_range: Option<RangeInclusive<u32>>,
) -> String {
let mut text = String::new();
write!(text, "```").unwrap();
if let Some(path) = path {
if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
write!(text, "{} ", extension).unwrap();
}
write!(text, "{}", path.display()).unwrap();
} else {
write!(text, "untitled").unwrap();
}
if let Some(row_range) = row_range {
write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
}
text.push('\n');
text
}
#[derive(Serialize, Deserialize)]
pub struct FileCommandMetadata {
pub path: String,
}
pub fn build_entry_output_section(
range: Range<usize>,
path: Option<&Path>,
is_directory: bool,
line_range: Option<Range<u32>>,
) -> SlashCommandOutputSection<usize> {
let mut label = if let Some(path) = path {
path.to_string_lossy().to_string()
} else {
"untitled".to_string()
};
if let Some(line_range) = line_range {
write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
}
let icon = if is_directory {
IconName::Folder
} else {
IconName::File
};
SlashCommandOutputSection {
range,
icon,
label: label.into(),
metadata: if is_directory {
None
} else {
path.and_then(|path| {
serde_json::to_value(FileCommandMetadata {
path: path.to_string_lossy().to_string(),
})
.ok()
})
},
}
}
/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix
/// check. Only subpaths pass the prefix check, rather than any prefix.
mod custom_path_matcher {
use std::{fmt::Debug as _, path::Path};
use globset::{Glob, GlobSet, GlobSetBuilder};
#[derive(Clone, Debug, Default)]
pub struct PathMatcher {
sources: Vec<String>,
sources_with_trailing_slash: Vec<String>,
glob: GlobSet,
}
impl std::fmt::Display for PathMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.sources.fmt(f)
}
}
impl PartialEq for PathMatcher {
fn eq(&self, other: &Self) -> bool {
self.sources.eq(&other.sources)
}
}
impl Eq for PathMatcher {}
impl PathMatcher {
pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
let globs = globs
.into_iter()
.map(|glob| Glob::new(&glob))
.collect::<Result<Vec<_>, _>>()?;
let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
let sources_with_trailing_slash = globs
.iter()
.map(|glob| glob.glob().to_string() + std::path::MAIN_SEPARATOR_STR)
.collect();
let mut glob_builder = GlobSetBuilder::new();
for single_glob in globs {
glob_builder.add(single_glob);
}
let glob = glob_builder.build()?;
Ok(PathMatcher {
glob,
sources,
sources_with_trailing_slash,
})
}
pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
let other_path = other.as_ref();
self.sources
.iter()
.zip(self.sources_with_trailing_slash.iter())
.any(|(source, with_slash)| {
let as_bytes = other_path.as_os_str().as_encoded_bytes();
let with_slash = if source.ends_with("/") {
source.as_bytes()
} else {
with_slash.as_bytes()
};
as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes())
})
|| self.glob.is_match(other_path)
|| self.check_with_end_separator(other_path)
}
fn check_with_end_separator(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
let separator = std::path::MAIN_SEPARATOR_STR;
if path_str.ends_with(separator) {
return false;
} else {
self.glob.is_match(path_str.to_string() + separator)
}
}
}
}
pub fn append_buffer_to_output(
buffer: &BufferSnapshot,
path: Option<&Path>,
output: &mut SlashCommandOutput,
) -> Result<()> {
let prev_len = output.text.len();
let mut content = buffer.text();
LineEnding::normalize(&mut content);
output.text.push_str(&codeblock_fence_for_path(path, None));
output.text.push_str(&content);
if !output.text.ends_with('\n') {
output.text.push('\n');
}
output.text.push_str("```");
output.text.push('\n');
let section_ix = output.sections.len();
output.sections.insert(
section_ix,
build_entry_output_section(prev_len..output.text.len(), path, false, None),
);
output.text.push('\n');
Ok(())
}
#[cfg(test)]
mod test {
use assistant_slash_command::SlashCommandOutput;
use fs::FakeFs;
use gpui::TestAppContext;
use pretty_assertions::assert_eq;
use project::Project;
use serde_json::json;
use settings::SettingsStore;
use smol::stream::StreamExt;
use super::collect_files;
pub fn init_test(cx: &mut gpui::TestAppContext) {
if std::env::var("RUST_LOG").is_ok() {
env_logger::try_init().ok();
}
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
// release_channel::init(SemanticVersion::default(), cx);
language::init(cx);
Project::init_settings(cx);
});
}
#[gpui::test]
async fn test_file_exact_matching(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dir": {
"subdir": {
"file_0": "0"
},
"file_1": "1",
"file_2": "2",
"file_3": "3",
},
"dir.rs": "4"
}),
)
.await;
let project = Project::test(fs, ["/root".as_ref()], cx).await;
let result_1 =
cx.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx));
let result_1 = SlashCommandOutput::from_event_stream(result_1.boxed())
.await
.unwrap();
assert!(result_1.text.starts_with("root/dir"));
// 4 files + 2 directories
assert_eq!(result_1.sections.len(), 6);
let result_2 =
cx.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx));
let result_2 = SlashCommandOutput::from_event_stream(result_2.boxed())
.await
.unwrap();
assert_eq!(result_1, result_2);
let result =
cx.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx).boxed());
let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
assert!(result.text.starts_with("root/dir"));
// 5 files + 2 directories
assert_eq!(result.sections.len(), 7);
// Ensure that the project lasts until after the last await
drop(project);
}
#[gpui::test]
async fn test_file_sub_directory_rendering(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/zed",
json!({
"assets": {
"dir1": {
".gitkeep": ""
},
"dir2": {
".gitkeep": ""
},
"themes": {
"ayu": {
"LICENSE": "1",
},
"andromeda": {
"LICENSE": "2",
},
"summercamp": {
"LICENSE": "3",
},
},
},
}),
)
.await;
let project = Project::test(fs, ["/zed".as_ref()], cx).await;
let result =
cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
let result = SlashCommandOutput::from_event_stream(result.boxed())
.await
.unwrap();
// Sanity check
assert!(result.text.starts_with("zed/assets/themes\n"));
assert_eq!(result.sections.len(), 7);
// Ensure that full file paths are included in the real output
assert!(result.text.contains("zed/assets/themes/andromeda/LICENSE"));
assert!(result.text.contains("zed/assets/themes/ayu/LICENSE"));
assert!(result.text.contains("zed/assets/themes/summercamp/LICENSE"));
assert_eq!(result.sections[5].label, "summercamp");
// Ensure that things are in descending order, with properly relativized paths
assert_eq!(
result.sections[0].label,
"zed/assets/themes/andromeda/LICENSE"
);
assert_eq!(result.sections[1].label, "andromeda");
assert_eq!(result.sections[2].label, "zed/assets/themes/ayu/LICENSE");
assert_eq!(result.sections[3].label, "ayu");
assert_eq!(
result.sections[4].label,
"zed/assets/themes/summercamp/LICENSE"
);
// Ensure that the project lasts until after the last await
drop(project);
}
#[gpui::test]
async fn test_file_deep_sub_directory_rendering(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/zed",
json!({
"assets": {
"themes": {
"LICENSE": "1",
"summercamp": {
"LICENSE": "1",
"subdir": {
"LICENSE": "1",
"subsubdir": {
"LICENSE": "3",
}
}
},
},
},
}),
)
.await;
let project = Project::test(fs, ["/zed".as_ref()], cx).await;
let result =
cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
let result = SlashCommandOutput::from_event_stream(result.boxed())
.await
.unwrap();
assert!(result.text.starts_with("zed/assets/themes\n"));
assert_eq!(result.sections[0].label, "zed/assets/themes/LICENSE");
assert_eq!(
result.sections[1].label,
"zed/assets/themes/summercamp/LICENSE"
);
assert_eq!(
result.sections[2].label,
"zed/assets/themes/summercamp/subdir/LICENSE"
);
assert_eq!(
result.sections[3].label,
"zed/assets/themes/summercamp/subdir/subsubdir/LICENSE"
);
assert_eq!(result.sections[4].label, "subsubdir");
assert_eq!(result.sections[5].label, "subdir");
assert_eq!(result.sections[6].label, "summercamp");
assert_eq!(result.sections[7].label, "zed/assets/themes");
assert_eq!(result.text, "zed/assets/themes\n```zed/assets/themes/LICENSE\n1\n```\n\nsummercamp\n```zed/assets/themes/summercamp/LICENSE\n1\n```\n\nsubdir\n```zed/assets/themes/summercamp/subdir/LICENSE\n1\n```\n\nsubsubdir\n```zed/assets/themes/summercamp/subdir/subsubdir/LICENSE\n3\n```\n\n");
// Ensure that the project lasts until after the last await
drop(project);
}
}

View file

@ -0,0 +1,69 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::Result;
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use chrono::Local;
use gpui::{Task, WeakView};
use language::{BufferSnapshot, LspAdapterDelegate};
use ui::prelude::*;
use workspace::Workspace;
pub struct NowSlashCommand;
impl SlashCommand for NowSlashCommand {
fn name(&self) -> String {
"now".into()
}
fn description(&self) -> String {
"Insert current date and time".into()
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(Vec::new()))
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
_cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let now = Local::now();
let text = format!("Today is {now}.", now = now.to_rfc2822());
let range = 0..text.len();
Task::ready(Ok(SlashCommandOutput {
text,
sections: vec![SlashCommandOutputSection {
range,
icon: IconName::CountdownTimer,
label: now.to_rfc2822().into(),
metadata: None,
}],
run_commands_in_text: false,
}
.to_event_stream()))
}
}

View file

@ -0,0 +1,196 @@
use std::{
fmt::Write as _,
ops::DerefMut,
sync::{atomic::AtomicBool, Arc},
};
use anyhow::{anyhow, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use feature_flags::FeatureFlag;
use gpui::{AppContext, Task, WeakView, WindowContext};
use language::{Anchor, CodeLabel, LspAdapterDelegate};
use language_model::{LanguageModelRegistry, LanguageModelTool};
use prompt_library::PromptBuilder;
use schemars::JsonSchema;
use semantic_index::SemanticDb;
use serde::Deserialize;
use ui::prelude::*;
use workspace::Workspace;
use super::{create_label_for_command, search_command::add_search_result_section};
pub struct ProjectSlashCommandFeatureFlag;
impl FeatureFlag for ProjectSlashCommandFeatureFlag {
const NAME: &'static str = "project-slash-command";
}
pub struct ProjectSlashCommand {
prompt_builder: Arc<PromptBuilder>,
}
impl ProjectSlashCommand {
pub fn new(prompt_builder: Arc<PromptBuilder>) -> Self {
Self { prompt_builder }
}
}
impl SlashCommand for ProjectSlashCommand {
fn name(&self) -> String {
"project".into()
}
fn label(&self, cx: &AppContext) -> CodeLabel {
create_label_for_command("project", &[], cx)
}
fn description(&self) -> String {
"Generate a semantic search based on context".into()
}
fn icon(&self) -> IconName {
IconName::Folder
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(Vec::new()))
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<Anchor>],
context_buffer: language::BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let model_registry = LanguageModelRegistry::read_global(cx);
let current_model = model_registry.active_model();
let prompt_builder = self.prompt_builder.clone();
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
};
let project = workspace.read(cx).project().clone();
let fs = project.read(cx).fs().clone();
let Some(project_index) =
cx.update_global(|index: &mut SemanticDb, cx| index.project_index(project, cx))
else {
return Task::ready(Err(anyhow::anyhow!("no project indexer")));
};
cx.spawn(|mut cx| async move {
let current_model = current_model.ok_or_else(|| anyhow!("no model selected"))?;
let prompt =
prompt_builder.generate_project_slash_command_prompt(context_buffer.text())?;
let search_queries = current_model
.use_tool::<SearchQueries>(
language_model::LanguageModelRequest {
messages: vec![language_model::LanguageModelRequestMessage {
role: language_model::Role::User,
content: vec![language_model::MessageContent::Text(prompt)],
cache: false,
}],
tools: vec![],
stop: vec![],
temperature: None,
},
cx.deref_mut(),
)
.await?
.search_queries;
let results = project_index
.read_with(&cx, |project_index, cx| {
project_index.search(search_queries.clone(), 25, cx)
})?
.await?;
let results = SemanticDb::load_results(results, &fs, &cx).await?;
cx.background_executor()
.spawn(async move {
let mut output = "Project context:\n".to_string();
let mut sections = Vec::new();
for (ix, query) in search_queries.into_iter().enumerate() {
let start_ix = output.len();
writeln!(&mut output, "Results for {query}:").unwrap();
let mut has_results = false;
for result in &results {
if result.query_index == ix {
add_search_result_section(result, &mut output, &mut sections);
has_results = true;
}
}
if has_results {
sections.push(SlashCommandOutputSection {
range: start_ix..output.len(),
icon: IconName::MagnifyingGlass,
label: query.into(),
metadata: None,
});
output.push('\n');
} else {
output.truncate(start_ix);
}
}
sections.push(SlashCommandOutputSection {
range: 0..output.len(),
icon: IconName::Book,
label: "Project context".into(),
metadata: None,
});
Ok(SlashCommandOutput {
text: output,
sections,
run_commands_in_text: true,
}
.to_event_stream())
})
.await
})
}
}
#[derive(JsonSchema, Deserialize)]
struct SearchQueries {
/// An array of semantic search queries.
///
/// These queries will be used to search the user's codebase.
/// The function can only accept 4 queries, otherwise it will error.
/// As such, it's important that you limit the length of the search_queries array to 5 queries or less.
search_queries: Vec<String>,
}
impl LanguageModelTool for SearchQueries {
fn name() -> String {
"search_queries".to_string()
}
fn description() -> String {
"Generate semantic search queries based on context".to_string()
}
}

View file

@ -0,0 +1,24 @@
Actions have a cost, so only include actions that you think
will be helpful to you in doing a great job answering the
prompt in the future.
You must respond ONLY with a list of actions you would like to
perform. Each action should be on its own line, and followed by a space and then its parameter.
Actions can be performed more than once with different parameters.
Here is an example valid response:
```
file path/to/my/file.txt
file path/to/another/file.txt
search something to search for
search something else to search for
```
Once again, do not forget: you must respond ONLY in the format of
one action per line, and the action name should be followed by
its parameter. Your response must not include anything other
than a list of actions, with one action per line, in this format.
It is extremely important that you do not deviate from this format even slightly!
This is the end of my instructions for how to respond. The rest is the prompt:

View file

@ -0,0 +1,31 @@
I'm going to give you a prompt. I don't want you to respond
to the prompt itself. I want you to figure out which of the following
actions on my project, if any, would help you answer the prompt.
Here are the actions:
## file
This action's parameter is a file path to one of the files
in the project. If you ask for this action, I will tell you
the full contents of the file, so you can learn all the
details of the file.
## search
This action's parameter is a string to do a semantic search for
across the files in the project. (You will have a JSON summary
of all the files in the project.) It will tell you which files this string
(or similar strings; it is a semantic search) appear in,
as well as some context of the lines surrounding each result.
It's very important that you only use this action when you think
that searching across the specific files in this project for the query
in question will be useful. For example, don't use this command to search
for queries you might put into a general Web search engine, because those
will be too general to give useful results in this project-specific search.
---
That was the end of the list of actions.
Here is a JSON summary of each of the files in my project:

View file

@ -0,0 +1,113 @@
use anyhow::{anyhow, Context, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use gpui::{Task, WeakView};
use language::{BufferSnapshot, LspAdapterDelegate};
use prompt_library::PromptStore;
use std::sync::{atomic::AtomicBool, Arc};
use ui::prelude::*;
use workspace::Workspace;
pub struct PromptSlashCommand;
impl SlashCommand for PromptSlashCommand {
fn name(&self) -> String {
"prompt".into()
}
fn description(&self) -> String {
"Insert prompt from library".into()
}
fn icon(&self) -> IconName {
IconName::Library
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
arguments: &[String],
_cancellation_flag: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
let store = PromptStore::global(cx);
let query = arguments.to_owned().join(" ");
cx.background_executor().spawn(async move {
let prompts = store.await?.search(query).await;
Ok(prompts
.into_iter()
.filter_map(|prompt| {
let prompt_title = prompt.title?.to_string();
Some(ArgumentCompletion {
label: prompt_title.clone().into(),
new_text: prompt_title,
after_completion: true.into(),
replace_previous_arguments: true,
})
})
.collect())
})
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let title = arguments.to_owned().join(" ");
if title.trim().is_empty() {
return Task::ready(Err(anyhow!("missing prompt name")));
};
let store = PromptStore::global(cx);
let title = SharedString::from(title.clone());
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))?;
let body = store.load(prompt_id).await?;
anyhow::Ok(body)
}
});
cx.foreground_executor().spawn(async move {
let mut prompt = prompt.await?;
if prompt.starts_with('/') {
// Prevent an edge case where the inserted prompt starts with a slash command (that leads to funky rendering).
prompt.insert(0, '\n');
}
if prompt.is_empty() {
prompt.push('\n');
}
let range = 0..prompt.len();
Ok(SlashCommandOutput {
text: prompt,
sections: vec![SlashCommandOutputSection {
range,
icon: IconName::Library,
label: title,
metadata: None,
}],
run_commands_in_text: true,
}
.to_event_stream())
})
}
}

View file

@ -0,0 +1,180 @@
use anyhow::Result;
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use feature_flags::FeatureFlag;
use gpui::{AppContext, Task, WeakView};
use language::{CodeLabel, LspAdapterDelegate};
use semantic_index::{LoadedSearchResult, SemanticDb};
use std::{
fmt::Write,
sync::{atomic::AtomicBool, Arc},
};
use ui::{prelude::*, IconName};
use workspace::Workspace;
use crate::create_label_for_command;
use crate::file_command::{build_entry_output_section, codeblock_fence_for_path};
pub struct SearchSlashCommandFeatureFlag;
impl FeatureFlag for SearchSlashCommandFeatureFlag {
const NAME: &'static str = "search-slash-command";
fn enabled_for_staff() -> bool {
false
}
}
pub struct SearchSlashCommand;
impl SlashCommand for SearchSlashCommand {
fn name(&self) -> String {
"search".into()
}
fn label(&self, cx: &AppContext) -> CodeLabel {
create_label_for_command("search", &["--n"], cx)
}
fn description(&self) -> String {
"Search your project semantically".into()
}
fn icon(&self) -> IconName {
IconName::SearchCode
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(Vec::new()))
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: language::BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
};
if arguments.is_empty() {
return Task::ready(Err(anyhow::anyhow!("missing search query")));
};
let mut limit = None;
let mut query = String::new();
for part in arguments {
if let Some(parameter) = part.strip_prefix("--") {
if let Ok(count) = parameter.parse::<usize>() {
limit = Some(count);
continue;
}
}
query.push_str(part);
query.push(' ');
}
query.pop();
if query.is_empty() {
return Task::ready(Err(anyhow::anyhow!("missing search query")));
}
let project = workspace.read(cx).project().clone();
let fs = project.read(cx).fs().clone();
let Some(project_index) =
cx.update_global(|index: &mut SemanticDb, cx| index.project_index(project, cx))
else {
return Task::ready(Err(anyhow::anyhow!("no project indexer")));
};
cx.spawn(|cx| async move {
let results = project_index
.read_with(&cx, |project_index, cx| {
project_index.search(vec![query.clone()], limit.unwrap_or(5), cx)
})?
.await?;
let loaded_results = SemanticDb::load_results(results, &fs, &cx).await?;
let output = cx
.background_executor()
.spawn(async move {
let mut text = format!("Search results for {query}:\n");
let mut sections = Vec::new();
for loaded_result in &loaded_results {
add_search_result_section(loaded_result, &mut text, &mut sections);
}
let query = SharedString::from(query);
sections.push(SlashCommandOutputSection {
range: 0..text.len(),
icon: IconName::MagnifyingGlass,
label: query,
metadata: None,
});
SlashCommandOutput {
text,
sections,
run_commands_in_text: false,
}
.to_event_stream()
})
.await;
Ok(output)
})
}
}
pub fn add_search_result_section(
loaded_result: &LoadedSearchResult,
text: &mut String,
sections: &mut Vec<SlashCommandOutputSection<usize>>,
) {
let LoadedSearchResult {
path,
full_path,
excerpt_content,
row_range,
..
} = loaded_result;
let section_start_ix = text.len();
text.push_str(&codeblock_fence_for_path(
Some(&path),
Some(row_range.clone()),
));
text.push_str(&excerpt_content);
if !text.ends_with('\n') {
text.push('\n');
}
writeln!(text, "```\n").unwrap();
let section_end_ix = text.len() - 1;
sections.push(build_entry_output_section(
section_start_ix..section_end_ix,
Some(&full_path),
false,
Some(row_range.start() + 1..row_range.end() + 1),
));
}

View file

@ -0,0 +1,194 @@
use anyhow::{anyhow, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
SlashCommandOutputSection, SlashCommandResult,
};
use editor::Editor;
use futures::StreamExt;
use gpui::{AppContext, Task, WeakView};
use gpui::{SharedString, ViewContext, WindowContext};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use ui::IconName;
use workspace::Workspace;
use crate::file_command::codeblock_fence_for_path;
pub struct SelectionCommand;
impl SlashCommand for SelectionCommand {
fn name(&self) -> String {
"selection".into()
}
fn label(&self, _cx: &AppContext) -> CodeLabel {
CodeLabel::plain(self.name(), None)
}
fn description(&self) -> String {
"Insert editor selection".into()
}
fn icon(&self) -> IconName {
IconName::Quote
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn accepts_arguments(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Err(anyhow!("this command does not require argument")))
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let mut events = vec![];
let Some(creases) = workspace
.update(cx, selections_creases)
.unwrap_or_else(|e| {
events.push(Err(e));
None
})
else {
return Task::ready(Err(anyhow!("no active selection")));
};
for (text, title) in creases {
events.push(Ok(SlashCommandEvent::StartSection {
icon: IconName::TextSnippet,
label: SharedString::from(title),
metadata: None,
}));
events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
text,
run_commands_in_text: false,
})));
events.push(Ok(SlashCommandEvent::EndSection));
events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
text: "\n".to_string(),
run_commands_in_text: false,
})));
}
let result = futures::stream::iter(events).boxed();
Task::ready(Ok(result))
}
}
pub fn selections_creases(
workspace: &mut workspace::Workspace,
cx: &mut ViewContext<Workspace>,
) -> Option<Vec<(String, String)>> {
let editor = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))?;
let mut creases = vec![];
editor.update(cx, |editor, cx| {
let selections = editor.selections.all_adjusted(cx);
let buffer = editor.buffer().read(cx).snapshot(cx);
for selection in selections {
let range = editor::ToOffset::to_offset(&selection.start, &buffer)
..editor::ToOffset::to_offset(&selection.end, &buffer);
let selected_text = buffer.text_for_range(range.clone()).collect::<String>();
if selected_text.is_empty() {
continue;
}
let start_language = buffer.language_at(range.start);
let end_language = buffer.language_at(range.end);
let language_name = if start_language == end_language {
start_language.map(|language| language.code_fence_block_name())
} else {
None
};
let language_name = language_name.as_deref().unwrap_or("");
let filename = buffer
.file_at(selection.start)
.map(|file| file.full_path(cx));
let text = if language_name == "markdown" {
selected_text
.lines()
.map(|line| format!("> {}", line))
.collect::<Vec<_>>()
.join("\n")
} else {
let start_symbols = buffer
.symbols_containing(selection.start, None)
.map(|(_, symbols)| symbols);
let end_symbols = buffer
.symbols_containing(selection.end, None)
.map(|(_, symbols)| symbols);
let outline_text =
if let Some((start_symbols, end_symbols)) = start_symbols.zip(end_symbols) {
Some(
start_symbols
.into_iter()
.zip(end_symbols)
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.text)
.collect::<Vec<_>>()
.join(" > "),
)
} else {
None
};
let line_comment_prefix = start_language
.and_then(|l| l.default_scope().line_comment_prefixes().first().cloned());
let fence = codeblock_fence_for_path(
filename.as_deref(),
Some(selection.start.row..=selection.end.row),
);
if let Some((line_comment_prefix, outline_text)) =
line_comment_prefix.zip(outline_text)
{
let breadcrumb = format!("{line_comment_prefix}Excerpt from: {outline_text}\n");
format!("{fence}{breadcrumb}{selected_text}\n```")
} else {
format!("{fence}{selected_text}\n```")
}
};
let crease_title = if let Some(path) = filename {
let start_line = selection.start.row + 1;
let end_line = selection.end.row + 1;
if start_line == end_line {
format!("{}, Line {}", path.display(), start_line)
} else {
format!("{}, Lines {} to {}", path.display(), start_line, end_line)
}
} else {
"Quoted selection".to_string()
};
creases.push((text, crease_title));
}
});
Some(creases)
}

View file

@ -0,0 +1,117 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
SlashCommandOutputSection, SlashCommandResult,
};
use feature_flags::FeatureFlag;
use futures::channel::mpsc;
use gpui::{Task, WeakView};
use language::{BufferSnapshot, LspAdapterDelegate};
use smol::stream::StreamExt;
use smol::Timer;
use ui::prelude::*;
use workspace::Workspace;
pub struct StreamingExampleSlashCommandFeatureFlag;
impl FeatureFlag for StreamingExampleSlashCommandFeatureFlag {
const NAME: &'static str = "streaming-example-slash-command";
}
pub struct StreamingExampleSlashCommand;
impl SlashCommand for StreamingExampleSlashCommand {
fn name(&self) -> String {
"streaming-example".into()
}
fn description(&self) -> String {
"An example slash command that showcases streaming.".into()
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(Vec::new()))
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
_workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let (events_tx, events_rx) = mpsc::unbounded();
cx.background_executor()
.spawn(async move {
events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
icon: IconName::FileRust,
label: "Section 1".into(),
metadata: None,
}))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "Hello".into(),
run_commands_in_text: false,
},
)))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
Timer::after(Duration::from_secs(1)).await;
events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
icon: IconName::FileRust,
label: "Section 2".into(),
metadata: None,
}))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "World".into(),
run_commands_in_text: false,
},
)))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
for n in 1..=10 {
Timer::after(Duration::from_secs(1)).await;
events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
icon: IconName::StarFilled,
label: format!("Section {n}").into(),
metadata: None,
}))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "lorem ipsum ".repeat(n).trim().into(),
run_commands_in_text: false,
},
)))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
Task::ready(Ok(events_rx.boxed()))
}
}

View file

@ -0,0 +1,99 @@
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use editor::Editor;
use gpui::{Task, WeakView};
use language::{BufferSnapshot, LspAdapterDelegate};
use std::sync::Arc;
use std::{path::Path, sync::atomic::AtomicBool};
use ui::{IconName, WindowContext};
use workspace::Workspace;
pub struct OutlineSlashCommand;
impl SlashCommand for OutlineSlashCommand {
fn name(&self) -> String {
"symbols".into()
}
fn description(&self) -> String {
"Insert symbols for active tab".into()
}
fn icon(&self) -> IconName {
IconName::ListTree
}
fn menu_text(&self) -> String {
self.description()
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Err(anyhow!("this command does not require argument")))
}
fn requires_argument(&self) -> bool {
false
}
fn run(
self: Arc<Self>,
_arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let output = workspace.update(cx, |workspace, cx| {
let Some(active_item) = workspace.active_item(cx) else {
return Task::ready(Err(anyhow!("no active tab")));
};
let Some(buffer) = active_item
.downcast::<Editor>()
.and_then(|editor| editor.read(cx).buffer().read(cx).as_singleton())
else {
return Task::ready(Err(anyhow!("active tab is not an editor")));
};
let snapshot = buffer.read(cx).snapshot();
let path = snapshot.resolve_file_path(cx, true);
cx.background_executor().spawn(async move {
let outline = snapshot
.outline(None)
.context("no symbols for active tab")?;
let path = path.as_deref().unwrap_or(Path::new("untitled"));
let mut outline_text = format!("Symbols for {}:\n", path.display());
for item in &outline.path_candidates {
outline_text.push_str("- ");
outline_text.push_str(&item.string);
outline_text.push('\n');
}
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: 0..outline_text.len(),
icon: IconName::ListTree,
label: path.to_string_lossy().to_string().into(),
metadata: None,
}],
text: outline_text,
run_commands_in_text: false,
}
.to_event_stream())
})
});
output.unwrap_or_else(|error| Task::ready(Err(error)))
}
}

View file

@ -0,0 +1,320 @@
use anyhow::{Context, Result};
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use collections::{HashMap, HashSet};
use editor::Editor;
use futures::future::join_all;
use gpui::{Entity, Task, WeakView};
use language::{BufferSnapshot, CodeLabel, HighlightId, LspAdapterDelegate};
use std::{
path::PathBuf,
sync::{atomic::AtomicBool, Arc},
};
use ui::{prelude::*, ActiveTheme, WindowContext};
use util::ResultExt;
use workspace::Workspace;
use crate::file_command::append_buffer_to_output;
pub struct TabSlashCommand;
const ALL_TABS_COMPLETION_ITEM: &str = "all";
impl SlashCommand for TabSlashCommand {
fn name(&self) -> String {
"tab".into()
}
fn description(&self) -> String {
"Insert open tabs (active tab by default)".to_owned()
}
fn icon(&self) -> IconName {
IconName::FileTree
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn accepts_arguments(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
arguments: &[String],
cancel: Arc<AtomicBool>,
workspace: Option<WeakView<Workspace>>,
cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
let mut has_all_tabs_completion_item = false;
let argument_set = arguments
.iter()
.filter(|argument| {
if has_all_tabs_completion_item || ALL_TABS_COMPLETION_ITEM == argument.as_str() {
has_all_tabs_completion_item = true;
false
} else {
true
}
})
.cloned()
.collect::<HashSet<_>>();
if has_all_tabs_completion_item {
return Task::ready(Ok(Vec::new()));
}
let active_item_path = workspace.as_ref().and_then(|workspace| {
workspace
.update(cx, |workspace, cx| {
let snapshot = active_item_buffer(workspace, cx).ok()?;
snapshot.resolve_file_path(cx, true)
})
.ok()
.flatten()
});
let current_query = arguments.last().cloned().unwrap_or_default();
let tab_items_search =
tab_items_for_queries(workspace, &[current_query], cancel, false, cx);
let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
cx.spawn(|_| async move {
let tab_items = tab_items_search.await?;
let run_command = tab_items.len() == 1;
let tab_completion_items = tab_items.into_iter().filter_map(|(path, ..)| {
let path_string = path.as_deref()?.to_string_lossy().to_string();
if argument_set.contains(&path_string) {
return None;
}
if active_item_path.is_some() && active_item_path == path {
return None;
}
let label = create_tab_completion_label(path.as_ref()?, comment_id);
Some(ArgumentCompletion {
label,
new_text: path_string,
replace_previous_arguments: false,
after_completion: run_command.into(),
})
});
let active_item_completion = active_item_path
.as_deref()
.map(|active_item_path| {
let path_string = active_item_path.to_string_lossy().to_string();
let label = create_tab_completion_label(active_item_path, comment_id);
ArgumentCompletion {
label,
new_text: path_string,
replace_previous_arguments: false,
after_completion: run_command.into(),
}
})
.filter(|completion| !argument_set.contains(&completion.new_text));
Ok(active_item_completion
.into_iter()
.chain(Some(ArgumentCompletion {
label: ALL_TABS_COMPLETION_ITEM.into(),
new_text: ALL_TABS_COMPLETION_ITEM.to_owned(),
replace_previous_arguments: false,
after_completion: true.into(),
}))
.chain(tab_completion_items)
.collect())
})
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let tab_items_search = tab_items_for_queries(
Some(workspace),
arguments,
Arc::new(AtomicBool::new(false)),
true,
cx,
);
cx.background_executor().spawn(async move {
let mut output = SlashCommandOutput::default();
for (full_path, buffer, _) in tab_items_search.await? {
append_buffer_to_output(&buffer, full_path.as_deref(), &mut output).log_err();
}
Ok(output.to_event_stream())
})
}
}
fn tab_items_for_queries(
workspace: Option<WeakView<Workspace>>,
queries: &[String],
cancel: Arc<AtomicBool>,
strict_match: bool,
cx: &mut WindowContext,
) -> Task<anyhow::Result<Vec<(Option<PathBuf>, BufferSnapshot, usize)>>> {
let empty_query = queries.is_empty() || queries.iter().all(|query| query.trim().is_empty());
let queries = queries.to_owned();
cx.spawn(|mut cx| async move {
let mut open_buffers =
workspace
.context("no workspace")?
.update(&mut cx, |workspace, cx| {
if strict_match && empty_query {
let snapshot = active_item_buffer(workspace, cx)?;
let full_path = snapshot.resolve_file_path(cx, true);
return anyhow::Ok(vec![(full_path, snapshot, 0)]);
}
let mut timestamps_by_entity_id = HashMap::default();
let mut visited_buffers = HashSet::default();
let mut open_buffers = Vec::new();
for pane in workspace.panes() {
let pane = pane.read(cx);
for entry in pane.activation_history() {
timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp);
}
}
for editor in workspace.items_of_type::<Editor>(cx) {
if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
if let Some(timestamp) =
timestamps_by_entity_id.get(&editor.entity_id())
{
if visited_buffers.insert(buffer.read(cx).remote_id()) {
let snapshot = buffer.read(cx).snapshot();
let full_path = snapshot.resolve_file_path(cx, true);
open_buffers.push((full_path, snapshot, *timestamp));
}
}
}
}
Ok(open_buffers)
})??;
let background_executor = cx.background_executor().clone();
cx.background_executor()
.spawn(async move {
open_buffers.sort_by_key(|(_, _, timestamp)| *timestamp);
if empty_query
|| queries
.iter()
.any(|query| query == ALL_TABS_COMPLETION_ITEM)
{
return Ok(open_buffers);
}
let matched_items = if strict_match {
let match_candidates = open_buffers
.iter()
.enumerate()
.filter_map(|(id, (full_path, ..))| {
let path_string = full_path.as_deref()?.to_string_lossy().to_string();
Some((id, path_string))
})
.fold(HashMap::default(), |mut candidates, (id, path_string)| {
candidates
.entry(path_string)
.or_insert_with(Vec::new)
.push(id);
candidates
});
queries
.iter()
.filter_map(|query| match_candidates.get(query))
.flatten()
.copied()
.filter_map(|id| open_buffers.get(id))
.cloned()
.collect()
} else {
let match_candidates = open_buffers
.iter()
.enumerate()
.filter_map(|(id, (full_path, ..))| {
let path_string = full_path.as_deref()?.to_string_lossy().to_string();
Some(fuzzy::StringMatchCandidate::new(id, &path_string))
})
.collect::<Vec<_>>();
let mut processed_matches = HashSet::default();
let file_queries = queries.iter().map(|query| {
fuzzy::match_strings(
&match_candidates,
query,
true,
usize::MAX,
&cancel,
background_executor.clone(),
)
});
join_all(file_queries)
.await
.into_iter()
.flatten()
.filter(|string_match| processed_matches.insert(string_match.candidate_id))
.filter_map(|string_match| open_buffers.get(string_match.candidate_id))
.cloned()
.collect()
};
Ok(matched_items)
})
.await
})
}
fn active_item_buffer(
workspace: &mut Workspace,
cx: &mut ViewContext<Workspace>,
) -> anyhow::Result<BufferSnapshot> {
let active_editor = workspace
.active_item(cx)
.context("no active item")?
.downcast::<Editor>()
.context("active item is not an editor")?;
let snapshot = active_editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.context("active editor is not a singleton buffer")?
.read(cx)
.snapshot();
Ok(snapshot)
}
fn create_tab_completion_label(
path: &std::path::Path,
comment_id: Option<HighlightId>,
) -> CodeLabel {
let file_name = path
.file_name()
.map(|f| f.to_string_lossy())
.unwrap_or_default();
let parent_path = path
.parent()
.map(|p| p.to_string_lossy())
.unwrap_or_default();
let mut label = CodeLabel::default();
label.push_str(&file_name, None);
label.push_str(" ", None);
label.push_str(&parent_path, comment_id);
label.filter_range = 0..file_name.len();
label
}

View file

@ -0,0 +1,127 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::Result;
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandResult,
};
use gpui::{AppContext, Task, View, WeakView};
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
use terminal_view::{terminal_panel::TerminalPanel, TerminalView};
use ui::prelude::*;
use workspace::{dock::Panel, Workspace};
use super::create_label_for_command;
pub struct TerminalSlashCommand;
const LINE_COUNT_ARG: &str = "--line-count";
const DEFAULT_CONTEXT_LINES: usize = 50;
impl SlashCommand for TerminalSlashCommand {
fn name(&self) -> String {
"terminal".into()
}
fn label(&self, cx: &AppContext) -> CodeLabel {
create_label_for_command("terminal", &[LINE_COUNT_ARG], cx)
}
fn description(&self) -> String {
"Insert terminal output".into()
}
fn icon(&self) -> IconName {
IconName::Terminal
}
fn menu_text(&self) -> String {
self.description()
}
fn requires_argument(&self) -> bool {
false
}
fn accepts_arguments(&self) -> bool {
true
}
fn complete_argument(
self: Arc<Self>,
_arguments: &[String],
_cancel: Arc<AtomicBool>,
_workspace: Option<WeakView<Workspace>>,
_cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(Vec::new()))
}
fn run(
self: Arc<Self>,
arguments: &[String],
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
_context_buffer: BufferSnapshot,
workspace: WeakView<Workspace>,
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
cx: &mut WindowContext,
) -> Task<SlashCommandResult> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
};
let Some(active_terminal) = resolve_active_terminal(&workspace, cx) else {
return Task::ready(Err(anyhow::anyhow!("no active terminal")));
};
let line_count = arguments
.get(0)
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(DEFAULT_CONTEXT_LINES);
let lines = active_terminal
.read(cx)
.model()
.read(cx)
.last_n_non_empty_lines(line_count);
let mut text = String::new();
text.push_str("Terminal output:\n");
text.push_str(&lines.join("\n"));
let range = 0..text.len();
Task::ready(Ok(SlashCommandOutput {
text,
sections: vec![SlashCommandOutputSection {
range,
icon: IconName::Terminal,
label: "Terminal".into(),
metadata: None,
}],
run_commands_in_text: false,
}
.to_event_stream()))
}
}
fn resolve_active_terminal(
workspace: &View<Workspace>,
cx: &WindowContext,
) -> Option<View<TerminalView>> {
if let Some(terminal_view) = workspace
.read(cx)
.active_item(cx)
.and_then(|item| item.act_as::<TerminalView>(cx))
{
return Some(terminal_view);
}
let terminal_panel = workspace.read(cx).panel::<TerminalPanel>(cx)?;
terminal_panel.read(cx).pane().and_then(|pane| {
pane.read(cx)
.active_item()
.and_then(|t| t.downcast::<TerminalView>())
})
}