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:
parent
1a8303b020
commit
8030c0025a
33 changed files with 452 additions and 347 deletions
52
crates/assistant_slash_commands/Cargo.toml
Normal file
52
crates/assistant_slash_commands/Cargo.toml
Normal 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
|
1
crates/assistant_slash_commands/LICENSE-GPL
Symbolic link
1
crates/assistant_slash_commands/LICENSE-GPL
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE-GPL
|
|
@ -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
|
||||
}
|
370
crates/assistant_slash_commands/src/auto_command.rs
Normal file
370
crates/assistant_slash_commands/src/auto_command.rs
Normal 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(¤t_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)
|
||||
}
|
156
crates/assistant_slash_commands/src/cargo_workspace_command.rs
Normal file
156
crates/assistant_slash_commands/src/cargo_workspace_command.rs
Normal 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)))
|
||||
}
|
||||
}
|
249
crates/assistant_slash_commands/src/context_server_command.rs
Normal file
249
crates/assistant_slash_commands/src/context_server_command.rs
Normal 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,
|
||||
}
|
||||
}
|
89
crates/assistant_slash_commands/src/default_command.rs
Normal file
89
crates/assistant_slash_commands/src/default_command.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
125
crates/assistant_slash_commands/src/delta_command.rs
Normal file
125
crates/assistant_slash_commands/src/delta_command.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
433
crates/assistant_slash_commands/src/diagnostics_command.rs
Normal file
433
crates/assistant_slash_commands/src/diagnostics_command.rs
Normal 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,
|
||||
});
|
||||
}
|
539
crates/assistant_slash_commands/src/docs_command.rs
Normal file
539
crates/assistant_slash_commands/src/docs_command.rs
Normal 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
178
crates/assistant_slash_commands/src/fetch_command.rs
Normal file
178
crates/assistant_slash_commands/src/fetch_command.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
758
crates/assistant_slash_commands/src/file_command.rs
Normal file
758
crates/assistant_slash_commands/src/file_command.rs
Normal 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);
|
||||
}
|
||||
}
|
69
crates/assistant_slash_commands/src/now_command.rs
Normal file
69
crates/assistant_slash_commands/src/now_command.rs
Normal 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()))
|
||||
}
|
||||
}
|
196
crates/assistant_slash_commands/src/project_command.rs
Normal file
196
crates/assistant_slash_commands/src/project_command.rs
Normal 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()
|
||||
}
|
||||
}
|
24
crates/assistant_slash_commands/src/prompt_after_summary.txt
Normal file
24
crates/assistant_slash_commands/src/prompt_after_summary.txt
Normal 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:
|
|
@ -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:
|
113
crates/assistant_slash_commands/src/prompt_command.rs
Normal file
113
crates/assistant_slash_commands/src/prompt_command.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
180
crates/assistant_slash_commands/src/search_command.rs
Normal file
180
crates/assistant_slash_commands/src/search_command.rs
Normal 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),
|
||||
));
|
||||
}
|
194
crates/assistant_slash_commands/src/selection_command.rs
Normal file
194
crates/assistant_slash_commands/src/selection_command.rs
Normal 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)
|
||||
}
|
117
crates/assistant_slash_commands/src/streaming_example_command.rs
Normal file
117
crates/assistant_slash_commands/src/streaming_example_command.rs
Normal 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()))
|
||||
}
|
||||
}
|
99
crates/assistant_slash_commands/src/symbols_command.rs
Normal file
99
crates/assistant_slash_commands/src/symbols_command.rs
Normal 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)))
|
||||
}
|
||||
}
|
320
crates/assistant_slash_commands/src/tab_command.rs
Normal file
320
crates/assistant_slash_commands/src/tab_command.rs
Normal 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
|
||||
}
|
127
crates/assistant_slash_commands/src/terminal_command.rs
Normal file
127
crates/assistant_slash_commands/src/terminal_command.rs
Normal 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>())
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue