Make slash command output streamable (#19632)

This PR adds support for streaming output from slash commands

In this PR we are focused primarily on the interface of the
`SlashCommand` trait to support streaming the output. We will follow up
later with support for extensions and context servers to take advantage
of the streaming nature.

Release Notes:

- N/A

---------

Co-authored-by: David Soria Parra <davidsp@anthropic.com>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: David <david@anthropic.com>
Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: Max <max@zed.dev>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Will <will@zed.dev>
This commit is contained in:
Marshall Bowers 2024-11-06 19:24:43 -05:00 committed by GitHub
parent f6fbf662b4
commit b129e18396
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1130 additions and 501 deletions

View file

@ -1,3 +1,4 @@
use crate::slash_command::file_command::codeblock_fence_for_path;
use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings},
humanize_token_count,
@ -6,24 +7,23 @@ use crate::{
slash_command::{
default_command::DefaultSlashCommand,
docs_command::{DocsSlashCommand, DocsSlashCommandArgs},
file_command::{self, codeblock_fence_for_path},
SlashCommandCompletionProvider, SlashCommandRegistry,
file_command, SlashCommandCompletionProvider, SlashCommandRegistry,
},
slash_command_picker,
terminal_inline_assistant::TerminalInlineAssistant,
Assist, AssistantPatch, AssistantPatchStatus, CacheStatus, ConfirmCommand, Content, Context,
ContextEvent, ContextId, ContextStore, ContextStoreEvent, CopyCode, CycleMessageRole,
DeployHistory, DeployPromptLibrary, Edit, InlineAssistant, InsertDraggedFiles,
InsertIntoEditor, Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate,
ModelSelector, NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection,
RemoteContextMetadata, RequestType, SavedContextMetadata, Split, ToggleFocus,
ToggleModelSelector,
InsertIntoEditor, InvokedSlashCommandStatus, Message, MessageId, MessageMetadata,
MessageStatus, ModelPickerDelegate, ModelSelector, NewContext, ParsedSlashCommand,
PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, RequestType,
SavedContextMetadata, SlashCommandId, Split, ToggleFocus, ToggleModelSelector,
};
use anyhow::Result;
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
use assistant_tool::ToolRegistry;
use client::{proto, zed_urls, Client, Status};
use collections::{BTreeSet, HashMap, HashSet};
use collections::{hash_map, BTreeSet, HashMap, HashSet};
use editor::{
actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt},
display_map::{
@ -38,12 +38,12 @@ use editor::{display_map::CreaseId, FoldPlaceholder};
use fs::Fs;
use futures::FutureExt;
use gpui::{
canvas, div, img, percentage, point, pulsating_between, size, Action, Animation, AnimationExt,
AnyElement, AnyView, AppContext, AsyncWindowContext, ClipboardEntry, ClipboardItem,
CursorStyle, Empty, Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView,
FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels, Render, RenderImage,
SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Task, Transformation,
UpdateGlobal, View, VisualContext, WeakView, WindowContext,
canvas, div, img, percentage, point, prelude::*, pulsating_between, size, Action, Animation,
AnimationExt, AnyElement, AnyView, AppContext, AsyncWindowContext, ClipboardEntry,
ClipboardItem, CursorStyle, Empty, Entity, EventEmitter, ExternalPaths, FocusHandle,
FocusableView, FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels,
Render, RenderImage, SharedString, Size, StatefulInteractiveElement, Styled, Subscription,
Task, Transformation, UpdateGlobal, View, WeakModel, WeakView,
};
use indexed_docs::IndexedDocsStore;
use language::{
@ -77,8 +77,8 @@ use text::SelectionGoal;
use ui::{
prelude::*,
utils::{format_distance_from_now, DateTimeType},
Avatar, ButtonLike, ContextMenu, Disclosure, ElevationIndex, KeyBinding, ListItem,
ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip,
Avatar, ButtonLike, ContextMenu, Disclosure, ElevationIndex, IconButtonShape, KeyBinding,
ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip,
};
use util::{maybe, ResultExt};
use workspace::{
@ -1477,7 +1477,7 @@ pub struct ContextEditor {
scroll_position: Option<ScrollPosition>,
remote_id: Option<workspace::ViewId>,
pending_slash_command_creases: HashMap<Range<language::Anchor>, CreaseId>,
pending_slash_command_blocks: HashMap<Range<language::Anchor>, CustomBlockId>,
invoked_slash_command_creases: HashMap<SlashCommandId, CreaseId>,
pending_tool_use_creases: HashMap<Range<language::Anchor>, CreaseId>,
_subscriptions: Vec<Subscription>,
patches: HashMap<Range<language::Anchor>, PatchViewState>,
@ -1548,7 +1548,7 @@ impl ContextEditor {
workspace,
project,
pending_slash_command_creases: HashMap::default(),
pending_slash_command_blocks: HashMap::default(),
invoked_slash_command_creases: HashMap::default(),
pending_tool_use_creases: HashMap::default(),
_subscriptions,
patches: HashMap::default(),
@ -1573,14 +1573,13 @@ impl ContextEditor {
});
let command = self.context.update(cx, |context, cx| {
context.reparse(cx);
context.pending_slash_commands()[0].clone()
context.parsed_slash_commands()[0].clone()
});
self.run_command(
command.source_range,
&command.name,
&command.arguments,
false,
false,
self.workspace.clone(),
cx,
);
@ -1753,7 +1752,6 @@ impl ContextEditor {
&command.name,
&command.arguments,
true,
false,
workspace.clone(),
cx,
);
@ -1769,7 +1767,6 @@ impl ContextEditor {
name: &str,
arguments: &[String],
ensure_trailing_newline: bool,
expand_result: bool,
workspace: WeakView<Workspace>,
cx: &mut ViewContext<Self>,
) {
@ -1793,9 +1790,9 @@ impl ContextEditor {
self.context.update(cx, |context, cx| {
context.insert_command_output(
command_range,
name,
output,
ensure_trailing_newline,
expand_result,
cx,
)
});
@ -1865,8 +1862,7 @@ impl ContextEditor {
IconName::PocketKnife,
tool_use.name.clone().into(),
),
constrain_width: false,
merge_adjacent: false,
..Default::default()
};
let render_trailer =
move |_row, _unfold, _cx: &mut WindowContext| Empty.into_any();
@ -1921,11 +1917,10 @@ impl ContextEditor {
ContextEvent::PatchesUpdated { removed, updated } => {
self.patches_updated(removed, updated, cx);
}
ContextEvent::PendingSlashCommandsUpdated { removed, updated } => {
ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => {
self.editor.update(cx, |editor, cx| {
let buffer = editor.buffer().read(cx).snapshot(cx);
let (excerpt_id, buffer_id, _) = buffer.as_singleton().unwrap();
let excerpt_id = *excerpt_id;
let (&excerpt_id, _, _) = buffer.as_singleton().unwrap();
editor.remove_creases(
removed
@ -1934,16 +1929,6 @@ impl ContextEditor {
cx,
);
editor.remove_blocks(
HashSet::from_iter(
removed.iter().filter_map(|range| {
self.pending_slash_command_blocks.remove(range)
}),
),
None,
cx,
);
let crease_ids = editor.insert_creases(
updated.iter().map(|command| {
let workspace = self.workspace.clone();
@ -1958,7 +1943,6 @@ impl ContextEditor {
&command.name,
&command.arguments,
false,
false,
workspace.clone(),
cx,
);
@ -1968,8 +1952,7 @@ impl ContextEditor {
});
let placeholder = FoldPlaceholder {
render: Arc::new(move |_, _, _| Empty.into_any()),
constrain_width: false,
merge_adjacent: false,
..Default::default()
};
let render_toggle = {
let confirm_command = confirm_command.clone();
@ -2011,62 +1994,29 @@ impl ContextEditor {
cx,
);
let block_ids = editor.insert_blocks(
updated
.iter()
.filter_map(|command| match &command.status {
PendingSlashCommandStatus::Error(error) => {
Some((command, error.clone()))
}
_ => None,
})
.map(|(command, error_message)| BlockProperties {
style: BlockStyle::Fixed,
height: 1,
placement: BlockPlacement::Below(Anchor {
buffer_id: Some(buffer_id),
excerpt_id,
text_anchor: command.source_range.start,
}),
render: slash_command_error_block_renderer(error_message),
priority: 0,
}),
None,
cx,
);
self.pending_slash_command_creases.extend(
updated
.iter()
.map(|command| command.source_range.clone())
.zip(crease_ids),
);
self.pending_slash_command_blocks.extend(
updated
.iter()
.map(|command| command.source_range.clone())
.zip(block_ids),
);
})
}
ContextEvent::InvokedSlashCommandChanged { command_id } => {
self.update_invoked_slash_command(*command_id, cx);
}
ContextEvent::SlashCommandOutputSectionAdded { section } => {
self.insert_slash_command_output_sections([section.clone()], false, cx);
}
ContextEvent::SlashCommandFinished {
output_range,
sections,
run_commands_in_output,
expand_result,
output_range: _output_range,
run_commands_in_ranges,
} => {
self.insert_slash_command_output_sections(
sections.iter().cloned(),
*expand_result,
cx,
);
if *run_commands_in_output {
for range in run_commands_in_ranges {
let commands = self.context.update(cx, |context, cx| {
context.reparse(cx);
context
.pending_commands_for_range(output_range.clone(), cx)
.pending_commands_for_range(range.clone(), cx)
.to_vec()
});
@ -2076,7 +2026,6 @@ impl ContextEditor {
&command.name,
&command.arguments,
false,
false,
self.workspace.clone(),
cx,
);
@ -2119,8 +2068,7 @@ impl ContextEditor {
IconName::PocketKnife,
format!("Tool Result: {tool_use_id}").into(),
),
constrain_width: false,
merge_adjacent: false,
..Default::default()
};
let render_trailer =
move |_row, _unfold, _cx: &mut WindowContext| Empty.into_any();
@ -2158,6 +2106,77 @@ impl ContextEditor {
}
}
fn update_invoked_slash_command(
&mut self,
command_id: SlashCommandId,
cx: &mut ViewContext<Self>,
) {
let context_editor = cx.view().downgrade();
self.editor.update(cx, |editor, cx| {
if let Some(invoked_slash_command) =
self.context.read(cx).invoked_slash_command(&command_id)
{
if let InvokedSlashCommandStatus::Finished = invoked_slash_command.status {
let buffer = editor.buffer().read(cx).snapshot(cx);
let (&excerpt_id, _buffer_id, _buffer_snapshot) =
buffer.as_singleton().unwrap();
let start = buffer
.anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start)
.unwrap();
let end = buffer
.anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end)
.unwrap();
editor.remove_folds_with_type(
&[start..end],
TypeId::of::<PendingSlashCommand>(),
false,
cx,
);
editor.remove_creases(
HashSet::from_iter(self.invoked_slash_command_creases.remove(&command_id)),
cx,
);
} else if let hash_map::Entry::Vacant(entry) =
self.invoked_slash_command_creases.entry(command_id)
{
let buffer = editor.buffer().read(cx).snapshot(cx);
let (&excerpt_id, _buffer_id, _buffer_snapshot) =
buffer.as_singleton().unwrap();
let context = self.context.downgrade();
let crease_start = buffer
.anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start)
.unwrap();
let crease_end = buffer
.anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end)
.unwrap();
let fold_placeholder =
invoked_slash_command_fold_placeholder(command_id, context, context_editor);
let crease_ids = editor.insert_creases(
[Crease::new(
crease_start..crease_end,
fold_placeholder.clone(),
fold_toggle("invoked-slash-command"),
|_row, _folded, _cx| Empty.into_any(),
)],
cx,
);
editor.fold_ranges([(crease_start..crease_end, fold_placeholder)], false, cx);
entry.insert(crease_ids[0]);
} else {
cx.notify()
}
} else {
editor.remove_creases(
HashSet::from_iter(self.invoked_slash_command_creases.remove(&command_id)),
cx,
);
cx.notify();
};
});
}
fn patches_updated(
&mut self,
removed: &Vec<Range<text::Anchor>>,
@ -2229,8 +2248,7 @@ impl ContextEditor {
.unwrap_or_else(|| Empty.into_any())
})
},
constrain_width: false,
merge_adjacent: false,
..Default::default()
};
let should_refold;
@ -2288,7 +2306,7 @@ impl ContextEditor {
}
if should_refold {
editor.unfold_ranges([patch_start..patch_end], true, false, cx);
editor.unfold_ranges(&[patch_start..patch_end], true, false, cx);
editor.fold_ranges([(patch_start..patch_end, header_placeholder)], false, cx);
}
}
@ -2334,8 +2352,7 @@ impl ContextEditor {
section.icon,
section.label.clone(),
),
constrain_width: false,
merge_adjacent: false,
..Default::default()
},
render_slash_command_output_toggle,
|_, _, _| Empty.into_any_element(),
@ -3275,13 +3292,12 @@ impl ContextEditor {
Crease::new(
start..end,
FoldPlaceholder {
constrain_width: false,
render: render_fold_icon_button(
weak_editor.clone(),
metadata.crease.icon,
metadata.crease.label.clone(),
),
merge_adjacent: false,
..Default::default()
},
render_slash_command_output_toggle,
|_, _, _| Empty.into_any(),
@ -4947,8 +4963,7 @@ fn quote_selection_fold_placeholder(title: String, editor: WeakView<Editor>) ->
.into_any_element()
}
}),
constrain_width: false,
merge_adjacent: false,
..Default::default()
}
}
@ -4992,7 +5007,7 @@ fn render_pending_slash_command_gutter_decoration(
fn render_docs_slash_command_trailer(
row: MultiBufferRow,
command: PendingSlashCommand,
command: ParsedSlashCommand,
cx: &mut WindowContext,
) -> AnyElement {
if command.arguments.is_empty() {
@ -5076,17 +5091,78 @@ fn make_lsp_adapter_delegate(
})
}
fn slash_command_error_block_renderer(message: String) -> RenderBlock {
Box::new(move |_| {
div()
.pl_6()
.child(
Label::new(format!("error: {}", message))
.single_line()
.color(Color::Error),
)
.into_any()
})
enum PendingSlashCommand {}
fn invoked_slash_command_fold_placeholder(
command_id: SlashCommandId,
context: WeakModel<Context>,
context_editor: WeakView<ContextEditor>,
) -> FoldPlaceholder {
FoldPlaceholder {
constrain_width: false,
merge_adjacent: false,
render: Arc::new(move |fold_id, _, cx| {
let Some(context) = context.upgrade() else {
return Empty.into_any();
};
let Some(command) = context.read(cx).invoked_slash_command(&command_id) else {
return Empty.into_any();
};
h_flex()
.id(fold_id)
.px_1()
.ml_6()
.gap_2()
.bg(cx.theme().colors().surface_background)
.rounded_md()
.child(Label::new(format!("/{}", command.name.clone())))
.map(|parent| match &command.status {
InvokedSlashCommandStatus::Running(_) => {
parent.child(Icon::new(IconName::ArrowCircle).with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(4)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
))
}
InvokedSlashCommandStatus::Error(message) => parent
.child(
Label::new(format!("error: {message}"))
.single_line()
.color(Color::Error),
)
.child(
IconButton::new("dismiss-error", IconName::Close)
.shape(IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click({
let context_editor = context_editor.clone();
move |_event, cx| {
context_editor
.update(cx, |context_editor, cx| {
context_editor.editor.update(cx, |editor, cx| {
editor.remove_creases(
HashSet::from_iter(
context_editor
.invoked_slash_command_creases
.remove(&command_id),
),
cx,
);
})
})
.log_err();
}
}),
),
InvokedSlashCommandStatus::Finished => parent,
})
.into_any_element()
}),
type_tag: Some(TypeId::of::<PendingSlashCommand>()),
}
}
enum TokenState {

View file

@ -8,7 +8,8 @@ use crate::{
};
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{
SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
SlashCommandContent, SlashCommandEvent, SlashCommandOutputSection, SlashCommandRegistry,
SlashCommandResult,
};
use assistant_tool::ToolRegistry;
use client::{self, proto, telemetry::Telemetry};
@ -47,9 +48,10 @@ use std::{
time::{Duration, Instant},
};
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use text::BufferSnapshot;
use text::{BufferSnapshot, ToPoint};
use util::{post_inc, ResultExt, TryFutureExt};
use uuid::Uuid;
use workspace::ui::IconName;
#[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ContextId(String);
@ -92,10 +94,21 @@ pub enum ContextOperation {
summary: ContextSummary,
version: clock::Global,
},
SlashCommandFinished {
SlashCommandStarted {
id: SlashCommandId,
output_range: Range<language::Anchor>,
sections: Vec<SlashCommandOutputSection<language::Anchor>>,
name: String,
version: clock::Global,
},
SlashCommandFinished {
id: SlashCommandId,
timestamp: clock::Lamport,
error_message: Option<String>,
version: clock::Global,
},
SlashCommandOutputSectionAdded {
timestamp: clock::Lamport,
section: SlashCommandOutputSection<language::Anchor>,
version: clock::Global,
},
BufferOperation(language::Operation),
@ -152,31 +165,47 @@ impl ContextOperation {
},
version: language::proto::deserialize_version(&update.version),
}),
proto::context_operation::Variant::SlashCommandFinished(finished) => {
Ok(Self::SlashCommandFinished {
proto::context_operation::Variant::SlashCommandStarted(message) => {
Ok(Self::SlashCommandStarted {
id: SlashCommandId(language::proto::deserialize_timestamp(
finished.id.context("invalid id")?,
message.id.context("invalid id")?,
)),
output_range: language::proto::deserialize_anchor_range(
finished.output_range.context("invalid range")?,
message.output_range.context("invalid range")?,
)?,
sections: finished
.sections
.into_iter()
.map(|section| {
Ok(SlashCommandOutputSection {
range: language::proto::deserialize_anchor_range(
section.range.context("invalid range")?,
)?,
icon: section.icon_name.parse()?,
label: section.label.into(),
metadata: section
.metadata
.and_then(|metadata| serde_json::from_str(&metadata).log_err()),
})
})
.collect::<Result<Vec<_>>>()?,
version: language::proto::deserialize_version(&finished.version),
name: message.name,
version: language::proto::deserialize_version(&message.version),
})
}
proto::context_operation::Variant::SlashCommandOutputSectionAdded(message) => {
let section = message.section.context("missing section")?;
Ok(Self::SlashCommandOutputSectionAdded {
timestamp: language::proto::deserialize_timestamp(
message.timestamp.context("missing timestamp")?,
),
section: SlashCommandOutputSection {
range: language::proto::deserialize_anchor_range(
section.range.context("invalid range")?,
)?,
icon: section.icon_name.parse()?,
label: section.label.into(),
metadata: section
.metadata
.and_then(|metadata| serde_json::from_str(&metadata).log_err()),
},
version: language::proto::deserialize_version(&message.version),
})
}
proto::context_operation::Variant::SlashCommandCompleted(message) => {
Ok(Self::SlashCommandFinished {
id: SlashCommandId(language::proto::deserialize_timestamp(
message.id.context("invalid id")?,
)),
timestamp: language::proto::deserialize_timestamp(
message.timestamp.context("missing timestamp")?,
),
error_message: message.error_message,
version: language::proto::deserialize_version(&message.version),
})
}
proto::context_operation::Variant::BufferOperation(op) => Ok(Self::BufferOperation(
@ -231,21 +260,33 @@ impl ContextOperation {
},
)),
},
Self::SlashCommandFinished {
Self::SlashCommandStarted {
id,
output_range,
sections,
name,
version,
} => proto::ContextOperation {
variant: Some(proto::context_operation::Variant::SlashCommandFinished(
proto::context_operation::SlashCommandFinished {
variant: Some(proto::context_operation::Variant::SlashCommandStarted(
proto::context_operation::SlashCommandStarted {
id: Some(language::proto::serialize_timestamp(id.0)),
output_range: Some(language::proto::serialize_anchor_range(
output_range.clone(),
)),
sections: sections
.iter()
.map(|section| {
name: name.clone(),
version: language::proto::serialize_version(version),
},
)),
},
Self::SlashCommandOutputSectionAdded {
timestamp,
section,
version,
} => proto::ContextOperation {
variant: Some(
proto::context_operation::Variant::SlashCommandOutputSectionAdded(
proto::context_operation::SlashCommandOutputSectionAdded {
timestamp: Some(language::proto::serialize_timestamp(*timestamp)),
section: Some({
let icon_name: &'static str = section.icon.into();
proto::SlashCommandOutputSection {
range: Some(language::proto::serialize_anchor_range(
@ -257,8 +298,23 @@ impl ContextOperation {
serde_json::to_string(metadata).log_err()
}),
}
})
.collect(),
}),
version: language::proto::serialize_version(version),
},
),
),
},
Self::SlashCommandFinished {
id,
timestamp,
error_message,
version,
} => proto::ContextOperation {
variant: Some(proto::context_operation::Variant::SlashCommandCompleted(
proto::context_operation::SlashCommandCompleted {
id: Some(language::proto::serialize_timestamp(id.0)),
timestamp: Some(language::proto::serialize_timestamp(*timestamp)),
error_message: error_message.clone(),
version: language::proto::serialize_version(version),
},
)),
@ -278,7 +334,9 @@ impl ContextOperation {
Self::InsertMessage { anchor, .. } => anchor.id.0,
Self::UpdateMessage { metadata, .. } => metadata.timestamp,
Self::UpdateSummary { summary, .. } => summary.timestamp,
Self::SlashCommandFinished { id, .. } => id.0,
Self::SlashCommandStarted { id, .. } => id.0,
Self::SlashCommandOutputSectionAdded { timestamp, .. }
| Self::SlashCommandFinished { timestamp, .. } => *timestamp,
Self::BufferOperation(_) => {
panic!("reading the timestamp of a buffer operation is not supported")
}
@ -291,6 +349,8 @@ impl ContextOperation {
Self::InsertMessage { version, .. }
| Self::UpdateMessage { version, .. }
| Self::UpdateSummary { version, .. }
| Self::SlashCommandStarted { version, .. }
| Self::SlashCommandOutputSectionAdded { version, .. }
| Self::SlashCommandFinished { version, .. } => version,
Self::BufferOperation(_) => {
panic!("reading the version of a buffer operation is not supported")
@ -311,15 +371,19 @@ pub enum ContextEvent {
removed: Vec<Range<language::Anchor>>,
updated: Vec<Range<language::Anchor>>,
},
PendingSlashCommandsUpdated {
InvokedSlashCommandChanged {
command_id: SlashCommandId,
},
ParsedSlashCommandsUpdated {
removed: Vec<Range<language::Anchor>>,
updated: Vec<PendingSlashCommand>,
updated: Vec<ParsedSlashCommand>,
},
SlashCommandOutputSectionAdded {
section: SlashCommandOutputSection<language::Anchor>,
},
SlashCommandFinished {
output_range: Range<language::Anchor>,
sections: Vec<SlashCommandOutputSection<language::Anchor>>,
run_commands_in_output: bool,
expand_result: bool,
run_commands_in_ranges: Vec<Range<language::Anchor>>,
},
UsePendingTools,
ToolFinished {
@ -478,7 +542,8 @@ pub struct Context {
pending_ops: Vec<ContextOperation>,
operations: Vec<ContextOperation>,
buffer: Model<Buffer>,
pending_slash_commands: Vec<PendingSlashCommand>,
parsed_slash_commands: Vec<ParsedSlashCommand>,
invoked_slash_commands: HashMap<SlashCommandId, InvokedSlashCommand>,
edits_since_last_parse: language::Subscription,
finished_slash_commands: HashSet<SlashCommandId>,
slash_command_output_sections: Vec<SlashCommandOutputSection<language::Anchor>>,
@ -508,7 +573,7 @@ trait ContextAnnotation {
fn range(&self) -> &Range<language::Anchor>;
}
impl ContextAnnotation for PendingSlashCommand {
impl ContextAnnotation for ParsedSlashCommand {
fn range(&self) -> &Range<language::Anchor> {
&self.source_range
}
@ -580,7 +645,8 @@ impl Context {
message_anchors: Default::default(),
contents: Default::default(),
messages_metadata: Default::default(),
pending_slash_commands: Vec::new(),
parsed_slash_commands: Vec::new(),
invoked_slash_commands: HashMap::default(),
finished_slash_commands: HashSet::default(),
pending_tool_uses_by_id: HashMap::default(),
slash_command_output_sections: Vec::new(),
@ -827,24 +893,50 @@ impl Context {
summary_changed = true;
}
}
ContextOperation::SlashCommandFinished {
ContextOperation::SlashCommandStarted {
id,
output_range,
sections,
name,
..
} => {
self.invoked_slash_commands.insert(
id,
InvokedSlashCommand {
name: name.into(),
range: output_range,
status: InvokedSlashCommandStatus::Running(Task::ready(())),
},
);
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
}
ContextOperation::SlashCommandOutputSectionAdded { section, .. } => {
let buffer = self.buffer.read(cx);
if let Err(ix) = self
.slash_command_output_sections
.binary_search_by(|probe| probe.range.cmp(&section.range, buffer))
{
self.slash_command_output_sections
.insert(ix, section.clone());
cx.emit(ContextEvent::SlashCommandOutputSectionAdded { section });
}
}
ContextOperation::SlashCommandFinished {
id, error_message, ..
} => {
if self.finished_slash_commands.insert(id) {
let buffer = self.buffer.read(cx);
self.slash_command_output_sections
.extend(sections.iter().cloned());
self.slash_command_output_sections
.sort_by(|a, b| a.range.cmp(&b.range, buffer));
cx.emit(ContextEvent::SlashCommandFinished {
output_range,
sections,
expand_result: false,
run_commands_in_output: false,
});
if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id) {
match error_message {
Some(message) => {
slash_command.status =
InvokedSlashCommandStatus::Error(message.into());
}
None => {
slash_command.status = InvokedSlashCommandStatus::Finished;
}
}
}
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id });
}
}
ContextOperation::BufferOperation(_) => unreachable!(),
@ -882,32 +974,34 @@ impl Context {
self.messages_metadata.contains_key(message_id)
}
ContextOperation::UpdateSummary { .. } => true,
ContextOperation::SlashCommandFinished {
output_range,
sections,
..
} => {
let version = &self.buffer.read(cx).version;
sections
.iter()
.map(|section| &section.range)
.chain([output_range])
.all(|range| {
let observed_start = range.start == language::Anchor::MIN
|| range.start == language::Anchor::MAX
|| version.observed(range.start.timestamp);
let observed_end = range.end == language::Anchor::MIN
|| range.end == language::Anchor::MAX
|| version.observed(range.end.timestamp);
observed_start && observed_end
})
ContextOperation::SlashCommandStarted { output_range, .. } => {
self.has_received_operations_for_anchor_range(output_range.clone(), cx)
}
ContextOperation::SlashCommandOutputSectionAdded { section, .. } => {
self.has_received_operations_for_anchor_range(section.range.clone(), cx)
}
ContextOperation::SlashCommandFinished { .. } => true,
ContextOperation::BufferOperation(_) => {
panic!("buffer operations should always be applied")
}
}
}
fn has_received_operations_for_anchor_range(
&self,
range: Range<text::Anchor>,
cx: &AppContext,
) -> bool {
let version = &self.buffer.read(cx).version;
let observed_start = range.start == language::Anchor::MIN
|| range.start == language::Anchor::MAX
|| version.observed(range.start.timestamp);
let observed_end = range.end == language::Anchor::MIN
|| range.end == language::Anchor::MAX
|| version.observed(range.end.timestamp);
observed_start && observed_end
}
fn push_op(&mut self, op: ContextOperation, cx: &mut ModelContext<Self>) {
self.operations.push(op.clone());
cx.emit(ContextEvent::Operation(op));
@ -983,8 +1077,15 @@ impl Context {
.binary_search_by(|probe| probe.range.cmp(&tagged_range, buffer))
}
pub fn pending_slash_commands(&self) -> &[PendingSlashCommand] {
&self.pending_slash_commands
pub fn parsed_slash_commands(&self) -> &[ParsedSlashCommand] {
&self.parsed_slash_commands
}
pub fn invoked_slash_command(
&self,
command_id: &SlashCommandId,
) -> Option<&InvokedSlashCommand> {
self.invoked_slash_commands.get(command_id)
}
pub fn slash_command_output_sections(&self) -> &[SlashCommandOutputSection<language::Anchor>] {
@ -1306,7 +1407,7 @@ impl Context {
}
if !updated_slash_commands.is_empty() || !removed_slash_command_ranges.is_empty() {
cx.emit(ContextEvent::PendingSlashCommandsUpdated {
cx.emit(ContextEvent::ParsedSlashCommandsUpdated {
removed: removed_slash_command_ranges,
updated: updated_slash_commands,
});
@ -1324,7 +1425,7 @@ impl Context {
&mut self,
range: Range<text::Anchor>,
buffer: &BufferSnapshot,
updated: &mut Vec<PendingSlashCommand>,
updated: &mut Vec<ParsedSlashCommand>,
removed: &mut Vec<Range<text::Anchor>>,
cx: &AppContext,
) {
@ -1358,7 +1459,7 @@ impl Context {
.map_or(command_line.name.end, |argument| argument.end);
let source_range =
buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix);
let pending_command = PendingSlashCommand {
let pending_command = ParsedSlashCommand {
name: name.to_string(),
arguments,
source_range,
@ -1373,7 +1474,7 @@ impl Context {
offset = lines.offset();
}
let removed_commands = self.pending_slash_commands.splice(old_range, new_commands);
let removed_commands = self.parsed_slash_commands.splice(old_range, new_commands);
removed.extend(removed_commands.map(|command| command.source_range));
}
@ -1642,15 +1743,15 @@ impl Context {
&mut self,
position: language::Anchor,
cx: &mut ModelContext<Self>,
) -> Option<&mut PendingSlashCommand> {
) -> Option<&mut ParsedSlashCommand> {
let buffer = self.buffer.read(cx);
match self
.pending_slash_commands
.parsed_slash_commands
.binary_search_by(|probe| probe.source_range.end.cmp(&position, buffer))
{
Ok(ix) => Some(&mut self.pending_slash_commands[ix]),
Ok(ix) => Some(&mut self.parsed_slash_commands[ix]),
Err(ix) => {
let cmd = self.pending_slash_commands.get_mut(ix)?;
let cmd = self.parsed_slash_commands.get_mut(ix)?;
if position.cmp(&cmd.source_range.start, buffer).is_ge()
&& position.cmp(&cmd.source_range.end, buffer).is_le()
{
@ -1666,9 +1767,9 @@ impl Context {
&self,
range: Range<language::Anchor>,
cx: &AppContext,
) -> &[PendingSlashCommand] {
) -> &[ParsedSlashCommand] {
let range = self.pending_command_indices_for_range(range, cx);
&self.pending_slash_commands[range]
&self.parsed_slash_commands[range]
}
fn pending_command_indices_for_range(
@ -1676,7 +1777,7 @@ impl Context {
range: Range<language::Anchor>,
cx: &AppContext,
) -> Range<usize> {
self.indices_intersecting_buffer_range(&self.pending_slash_commands, range, cx)
self.indices_intersecting_buffer_range(&self.parsed_slash_commands, range, cx)
}
fn indices_intersecting_buffer_range<T: ContextAnnotation>(
@ -1702,112 +1803,275 @@ impl Context {
pub fn insert_command_output(
&mut self,
command_range: Range<language::Anchor>,
command_source_range: Range<language::Anchor>,
name: &str,
output: Task<SlashCommandResult>,
ensure_trailing_newline: bool,
expand_result: bool,
cx: &mut ModelContext<Self>,
) {
let version = self.version.clone();
let command_id = SlashCommandId(self.next_timestamp());
const PENDING_OUTPUT_END_MARKER: &str = "";
let (command_range, command_source_range, insert_position) =
self.buffer.update(cx, |buffer, cx| {
let command_source_range = command_source_range.to_offset(buffer);
let mut insertion = format!("\n{PENDING_OUTPUT_END_MARKER}");
if ensure_trailing_newline {
insertion.push('\n');
}
buffer.edit(
[(
command_source_range.end..command_source_range.end,
insertion,
)],
None,
cx,
);
let insert_position = buffer.anchor_after(command_source_range.end + 1);
let command_range = buffer.anchor_before(command_source_range.start)
..buffer.anchor_after(
command_source_range.end + 1 + PENDING_OUTPUT_END_MARKER.len(),
);
let command_source_range = buffer.anchor_before(command_source_range.start)
..buffer.anchor_before(command_source_range.end + 1);
(command_range, command_source_range, insert_position)
});
self.reparse(cx);
let insert_output_task = cx.spawn(|this, mut cx| {
let command_range = command_range.clone();
async move {
let output = output.await;
let output = match output {
Ok(output) => SlashCommandOutput::from_event_stream(output).await,
Err(err) => Err(err),
};
this.update(&mut cx, |this, cx| match output {
Ok(mut output) => {
output.ensure_valid_section_ranges();
let insert_output_task = cx.spawn(|this, mut cx| async move {
let run_command = async {
let mut stream = output.await?;
// Ensure there is a newline after the last section.
if ensure_trailing_newline {
let has_newline_after_last_section =
output.sections.last().map_or(false, |last_section| {
output.text[last_section.range.end..].ends_with('\n')
struct PendingSection {
start: language::Anchor,
icon: IconName,
label: SharedString,
metadata: Option<serde_json::Value>,
}
let mut pending_section_stack: Vec<PendingSection> = Vec::new();
let mut run_commands_in_ranges: Vec<Range<language::Anchor>> = Vec::new();
let mut last_role: Option<Role> = None;
let mut last_section_range = None;
while let Some(event) = stream.next().await {
let event = event?;
match event {
SlashCommandEvent::StartMessage {
role,
merge_same_roles,
} => {
if !merge_same_roles && Some(role) != last_role {
this.update(&mut cx, |this, cx| {
let offset = this.buffer.read_with(cx, |buffer, _cx| {
insert_position.to_offset(buffer)
});
this.insert_message_at_offset(
offset,
role,
MessageStatus::Pending,
cx,
);
})?;
}
last_role = Some(role);
}
SlashCommandEvent::StartSection {
icon,
label,
metadata,
} => {
this.update(&mut cx, |this, cx| {
this.buffer.update(cx, |buffer, cx| {
let insert_point = insert_position.to_point(buffer);
if insert_point.column > 0 {
buffer.edit([(insert_point..insert_point, "\n")], None, cx);
}
pending_section_stack.push(PendingSection {
start: buffer.anchor_before(insert_position),
icon,
label,
metadata,
});
});
if !has_newline_after_last_section {
output.text.push('\n');
})?;
}
SlashCommandEvent::Content(SlashCommandContent::Text {
text,
run_commands_in_text,
}) => {
this.update(&mut cx, |this, cx| {
let start = this.buffer.read(cx).anchor_before(insert_position);
let result = this.buffer.update(cx, |buffer, cx| {
buffer.edit(
[(insert_position..insert_position, text)],
None,
cx,
)
});
let end = this.buffer.read(cx).anchor_before(insert_position);
if run_commands_in_text {
run_commands_in_ranges.push(start..end);
}
result
})?;
}
SlashCommandEvent::EndSection { metadata } => {
if let Some(pending_section) = pending_section_stack.pop() {
this.update(&mut cx, |this, cx| {
let offset_range = (pending_section.start..insert_position)
.to_offset(this.buffer.read(cx));
if offset_range.is_empty() {
return;
}
let range = this.buffer.update(cx, |buffer, _cx| {
buffer.anchor_after(offset_range.start)
..buffer.anchor_before(offset_range.end)
});
this.insert_slash_command_output_section(
SlashCommandOutputSection {
range: range.clone(),
icon: pending_section.icon,
label: pending_section.label,
metadata: metadata.or(pending_section.metadata),
},
cx,
);
last_section_range = Some(range);
})?;
}
}
}
}
this.update(&mut cx, |this, cx| {
this.buffer.update(cx, |buffer, cx| {
let mut deletions = vec![(command_source_range.to_offset(buffer), "")];
let insert_position = insert_position.to_offset(buffer);
let command_range_end = command_range.end.to_offset(buffer);
if buffer.contains_str_at(insert_position, PENDING_OUTPUT_END_MARKER) {
deletions.push((
insert_position..insert_position + PENDING_OUTPUT_END_MARKER.len(),
"",
));
}
if ensure_trailing_newline
&& buffer.contains_str_at(command_range_end, "\n")
{
let newline_offset = insert_position.saturating_sub(1);
if buffer.contains_str_at(newline_offset, "\n")
&& last_section_range.map_or(true, |last_section_range| {
!last_section_range
.to_offset(buffer)
.contains(&newline_offset)
})
{
deletions.push((command_range_end..command_range_end + 1, ""));
}
}
let version = this.version.clone();
let command_id = SlashCommandId(this.next_timestamp());
let (operation, event) = this.buffer.update(cx, |buffer, cx| {
let start = command_range.start.to_offset(buffer);
let old_end = command_range.end.to_offset(buffer);
let new_end = start + output.text.len();
buffer.edit([(start..old_end, output.text)], None, cx);
buffer.edit(deletions, None, cx);
});
})?;
let mut sections = output
.sections
.into_iter()
.map(|section| SlashCommandOutputSection {
range: buffer.anchor_after(start + section.range.start)
..buffer.anchor_before(start + section.range.end),
icon: section.icon,
label: section.label,
metadata: section.metadata,
})
.collect::<Vec<_>>();
sections.sort_by(|a, b| a.range.cmp(&b.range, buffer));
debug_assert!(pending_section_stack.is_empty());
this.slash_command_output_sections
.extend(sections.iter().cloned());
this.slash_command_output_sections
.sort_by(|a, b| a.range.cmp(&b.range, buffer));
anyhow::Ok(())
};
let output_range =
buffer.anchor_after(start)..buffer.anchor_before(new_end);
this.finished_slash_commands.insert(command_id);
let command_result = run_command.await;
(
ContextOperation::SlashCommandFinished {
id: command_id,
output_range: output_range.clone(),
sections: sections.clone(),
version,
},
ContextEvent::SlashCommandFinished {
output_range,
sections,
run_commands_in_output: output.run_commands_in_text,
expand_result,
},
)
});
this.push_op(operation, cx);
cx.emit(event);
this.update(&mut cx, |this, cx| {
let version = this.version.clone();
let timestamp = this.next_timestamp();
let Some(invoked_slash_command) = this.invoked_slash_commands.get_mut(&command_id)
else {
return;
};
let mut error_message = None;
match command_result {
Ok(()) => {
invoked_slash_command.status = InvokedSlashCommandStatus::Finished;
}
Err(error) => {
if let Some(pending_command) =
this.pending_command_for_position(command_range.start, cx)
{
pending_command.status =
PendingSlashCommandStatus::Error(error.to_string());
cx.emit(ContextEvent::PendingSlashCommandsUpdated {
removed: vec![pending_command.source_range.clone()],
updated: vec![pending_command.clone()],
});
}
let message = error.to_string();
invoked_slash_command.status =
InvokedSlashCommandStatus::Error(message.clone().into());
error_message = Some(message);
}
})
.ok();
}
}
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id });
this.push_op(
ContextOperation::SlashCommandFinished {
id: command_id,
timestamp,
error_message,
version,
},
cx,
);
})
.ok();
});
if let Some(pending_command) = self.pending_command_for_position(command_range.start, cx) {
pending_command.status = PendingSlashCommandStatus::Running {
_task: insert_output_task.shared(),
};
cx.emit(ContextEvent::PendingSlashCommandsUpdated {
removed: vec![pending_command.source_range.clone()],
updated: vec![pending_command.clone()],
});
}
self.invoked_slash_commands.insert(
command_id,
InvokedSlashCommand {
name: name.to_string().into(),
range: command_range.clone(),
status: InvokedSlashCommandStatus::Running(insert_output_task),
},
);
cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id });
self.push_op(
ContextOperation::SlashCommandStarted {
id: command_id,
output_range: command_range,
name: name.to_string(),
version,
},
cx,
);
}
fn insert_slash_command_output_section(
&mut self,
section: SlashCommandOutputSection<language::Anchor>,
cx: &mut ModelContext<Self>,
) {
let buffer = self.buffer.read(cx);
let insertion_ix = match self
.slash_command_output_sections
.binary_search_by(|probe| probe.range.cmp(&section.range, buffer))
{
Ok(ix) | Err(ix) => ix,
};
self.slash_command_output_sections
.insert(insertion_ix, section.clone());
cx.emit(ContextEvent::SlashCommandOutputSectionAdded {
section: section.clone(),
});
let version = self.version.clone();
let timestamp = self.next_timestamp();
self.push_op(
ContextOperation::SlashCommandOutputSectionAdded {
timestamp,
section,
version,
},
cx,
);
}
pub fn insert_tool_output(
@ -2312,43 +2576,54 @@ impl Context {
next_message_ix += 1;
}
let start = self.buffer.update(cx, |buffer, cx| {
let offset = self
.message_anchors
.get(next_message_ix)
.map_or(buffer.len(), |message| {
buffer.clip_offset(message.start.to_offset(buffer) - 1, Bias::Left)
});
buffer.edit([(offset..offset, "\n")], None, cx);
buffer.anchor_before(offset + 1)
});
let version = self.version.clone();
let anchor = MessageAnchor {
id: MessageId(self.next_timestamp()),
start,
};
let metadata = MessageMetadata {
role,
status,
timestamp: anchor.id.0,
cache: None,
};
self.insert_message(anchor.clone(), metadata.clone(), cx);
self.push_op(
ContextOperation::InsertMessage {
anchor: anchor.clone(),
metadata,
version,
},
cx,
);
Some(anchor)
let buffer = self.buffer.read(cx);
let offset = self
.message_anchors
.get(next_message_ix)
.map_or(buffer.len(), |message| {
buffer.clip_offset(message.start.to_offset(buffer) - 1, Bias::Left)
});
Some(self.insert_message_at_offset(offset, role, status, cx))
} else {
None
}
}
fn insert_message_at_offset(
&mut self,
offset: usize,
role: Role,
status: MessageStatus,
cx: &mut ModelContext<Self>,
) -> MessageAnchor {
let start = self.buffer.update(cx, |buffer, cx| {
buffer.edit([(offset..offset, "\n")], None, cx);
buffer.anchor_before(offset + 1)
});
let version = self.version.clone();
let anchor = MessageAnchor {
id: MessageId(self.next_timestamp()),
start,
};
let metadata = MessageMetadata {
role,
status,
timestamp: anchor.id.0,
cache: None,
};
self.insert_message(anchor.clone(), metadata.clone(), cx);
self.push_op(
ContextOperation::InsertMessage {
anchor: anchor.clone(),
metadata,
version,
},
cx,
);
anchor
}
pub fn insert_content(&mut self, content: Content, cx: &mut ModelContext<Self>) {
let buffer = self.buffer.read(cx);
let insertion_ix = match self
@ -2814,13 +3089,27 @@ impl ContextVersion {
}
#[derive(Debug, Clone)]
pub struct PendingSlashCommand {
pub struct ParsedSlashCommand {
pub name: String,
pub arguments: SmallVec<[String; 3]>,
pub status: PendingSlashCommandStatus,
pub source_range: Range<language::Anchor>,
}
#[derive(Debug)]
pub struct InvokedSlashCommand {
pub name: SharedString,
pub range: Range<language::Anchor>,
pub status: InvokedSlashCommandStatus,
}
#[derive(Debug)]
pub enum InvokedSlashCommandStatus {
Running(Task<()>),
Error(SharedString),
Finished,
}
#[derive(Debug, Clone)]
pub enum PendingSlashCommandStatus {
Idle,
@ -2960,27 +3249,23 @@ impl SavedContext {
version.observe(timestamp);
}
let timestamp = next_timestamp.tick();
operations.push(ContextOperation::SlashCommandFinished {
id: SlashCommandId(timestamp),
output_range: language::Anchor::MIN..language::Anchor::MAX,
sections: self
.slash_command_output_sections
.into_iter()
.map(|section| {
let buffer = buffer.read(cx);
SlashCommandOutputSection {
range: buffer.anchor_after(section.range.start)
..buffer.anchor_before(section.range.end),
icon: section.icon,
label: section.label,
metadata: section.metadata,
}
})
.collect(),
version: version.clone(),
});
version.observe(timestamp);
let buffer = buffer.read(cx);
for section in self.slash_command_output_sections {
let timestamp = next_timestamp.tick();
operations.push(ContextOperation::SlashCommandOutputSectionAdded {
timestamp,
section: SlashCommandOutputSection {
range: buffer.anchor_after(section.range.start)
..buffer.anchor_before(section.range.end),
icon: section.icon,
label: section.label,
metadata: section.metadata,
},
version: version.clone(),
});
version.observe(timestamp);
}
let timestamp = next_timestamp.tick();
operations.push(ContextOperation::UpdateSummary {

View file

@ -2,14 +2,19 @@ use super::{AssistantEdit, MessageCacheMetadata};
use crate::{
assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus,
Context, ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
SlashCommandId,
};
use anyhow::Result;
use assistant_slash_command::{
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
SlashCommandRegistry, SlashCommandResult,
ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent, SlashCommandOutput,
SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult,
};
use collections::HashSet;
use collections::{HashMap, HashSet};
use fs::FakeFs;
use futures::{
channel::mpsc,
stream::{self, StreamExt},
};
use gpui::{AppContext, Model, SharedString, Task, TestAppContext, WeakView};
use language::{Buffer, BufferSnapshot, LanguageRegistry, LspAdapterDelegate};
use language_model::{LanguageModelCacheConfiguration, LanguageModelRegistry, Role};
@ -27,8 +32,8 @@ use std::{
rc::Rc,
sync::{atomic::AtomicBool, Arc},
};
use text::{network::Network, OffsetRangeExt as _, ReplicaId};
use ui::{Context as _, WindowContext};
use text::{network::Network, OffsetRangeExt as _, ReplicaId, ToOffset};
use ui::{Context as _, IconName, WindowContext};
use unindent::Unindent;
use util::{
test::{generate_marked_text, marked_text_ranges},
@ -381,20 +386,41 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
let context =
cx.new_model(|cx| Context::local(registry.clone(), None, None, prompt_builder.clone(), cx));
let output_ranges = Rc::new(RefCell::new(HashSet::default()));
#[derive(Default)]
struct ContextRanges {
parsed_commands: HashSet<Range<language::Anchor>>,
command_outputs: HashMap<SlashCommandId, Range<language::Anchor>>,
output_sections: HashSet<Range<language::Anchor>>,
}
let context_ranges = Rc::new(RefCell::new(ContextRanges::default()));
context.update(cx, |_, cx| {
cx.subscribe(&context, {
let ranges = output_ranges.clone();
move |_, _, event, _| match event {
ContextEvent::PendingSlashCommandsUpdated { removed, updated } => {
for range in removed {
ranges.borrow_mut().remove(range);
let context_ranges = context_ranges.clone();
move |context, _, event, _| {
let mut context_ranges = context_ranges.borrow_mut();
match event {
ContextEvent::InvokedSlashCommandChanged { command_id } => {
let command = context.invoked_slash_command(command_id).unwrap();
context_ranges
.command_outputs
.insert(*command_id, command.range.clone());
}
for command in updated {
ranges.borrow_mut().insert(command.source_range.clone());
ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => {
for range in removed {
context_ranges.parsed_commands.remove(range);
}
for command in updated {
context_ranges
.parsed_commands
.insert(command.source_range.clone());
}
}
ContextEvent::SlashCommandOutputSectionAdded { section } => {
context_ranges.output_sections.insert(section.range.clone());
}
_ => {}
}
_ => {}
}
})
.detach();
@ -406,14 +432,12 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
buffer.update(cx, |buffer, cx| {
buffer.edit([(0..0, "/file src/lib.rs")], None, cx);
});
assert_text_and_output_ranges(
assert_text_and_context_ranges(
&buffer,
&output_ranges.borrow(),
"
«/file src/lib.rs»
"
.unindent()
.trim_end(),
&context_ranges,
&"
«/file src/lib.rs»"
.unindent(),
cx,
);
@ -422,14 +446,12 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
let edit_offset = buffer.text().find("lib.rs").unwrap();
buffer.edit([(edit_offset..edit_offset + "lib".len(), "main")], None, cx);
});
assert_text_and_output_ranges(
assert_text_and_context_ranges(
&buffer,
&output_ranges.borrow(),
"
«/file src/main.rs»
"
.unindent()
.trim_end(),
&context_ranges,
&"
«/file src/main.rs»"
.unindent(),
cx,
);
@ -442,36 +464,180 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
cx,
);
});
assert_text_and_output_ranges(
assert_text_and_context_ranges(
&buffer,
&output_ranges.borrow(),
&context_ranges,
&"
/unknown src/main.rs"
.unindent(),
cx,
);
// Undoing the insertion of an non-existent slash command resorts the previous one.
buffer.update(cx, |buffer, cx| buffer.undo(cx));
assert_text_and_context_ranges(
&buffer,
&context_ranges,
&"
«/file src/main.rs»"
.unindent(),
cx,
);
let (command_output_tx, command_output_rx) = mpsc::unbounded();
context.update(cx, |context, cx| {
let command_source_range = context.parsed_slash_commands[0].source_range.clone();
context.insert_command_output(
command_source_range,
"file",
Task::ready(Ok(command_output_rx.boxed())),
true,
cx,
);
});
assert_text_and_context_ranges(
&buffer,
&context_ranges,
&"
«/file src/main.rs»
"
/unknown src/main.rs
.unindent(),
cx,
);
command_output_tx
.unbounded_send(Ok(SlashCommandEvent::StartSection {
icon: IconName::Ai,
label: "src/main.rs".into(),
metadata: None,
}))
.unwrap();
command_output_tx
.unbounded_send(Ok(SlashCommandEvent::Content("src/main.rs".into())))
.unwrap();
cx.run_until_parked();
assert_text_and_context_ranges(
&buffer,
&context_ranges,
&"
«/file src/main.rs»
src/main.rs
"
.unindent()
.trim_end(),
.unindent(),
cx,
);
command_output_tx
.unbounded_send(Ok(SlashCommandEvent::Content("\nfn main() {}".into())))
.unwrap();
cx.run_until_parked();
assert_text_and_context_ranges(
&buffer,
&context_ranges,
&"
«/file src/main.rs»
src/main.rs
fn main() {}
"
.unindent(),
cx,
);
command_output_tx
.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))
.unwrap();
cx.run_until_parked();
assert_text_and_context_ranges(
&buffer,
&context_ranges,
&"
«/file src/main.rs»
src/main.rs
fn main() {}
"
.unindent(),
cx,
);
drop(command_output_tx);
cx.run_until_parked();
assert_text_and_context_ranges(
&buffer,
&context_ranges,
&"
src/main.rs
fn main() {}
"
.unindent(),
cx,
);
#[track_caller]
fn assert_text_and_output_ranges(
fn assert_text_and_context_ranges(
buffer: &Model<Buffer>,
ranges: &HashSet<Range<language::Anchor>>,
ranges: &RefCell<ContextRanges>,
expected_marked_text: &str,
cx: &mut TestAppContext,
) {
let (expected_text, expected_ranges) = marked_text_ranges(expected_marked_text, false);
let (actual_text, actual_ranges) = buffer.update(cx, |buffer, _| {
let mut ranges = ranges
.iter()
.map(|range| range.to_offset(buffer))
.collect::<Vec<_>>();
ranges.sort_by_key(|a| a.start);
(buffer.text(), ranges)
let mut actual_marked_text = String::new();
buffer.update(cx, |buffer, _| {
struct Endpoint {
offset: usize,
marker: char,
}
let ranges = ranges.borrow();
let mut endpoints = Vec::new();
for range in ranges.command_outputs.values() {
endpoints.push(Endpoint {
offset: range.start.to_offset(buffer),
marker: '',
});
}
for range in ranges.parsed_commands.iter() {
endpoints.push(Endpoint {
offset: range.start.to_offset(buffer),
marker: '«',
});
}
for range in ranges.output_sections.iter() {
endpoints.push(Endpoint {
offset: range.start.to_offset(buffer),
marker: '',
});
}
for range in ranges.output_sections.iter() {
endpoints.push(Endpoint {
offset: range.end.to_offset(buffer),
marker: '',
});
}
for range in ranges.parsed_commands.iter() {
endpoints.push(Endpoint {
offset: range.end.to_offset(buffer),
marker: '»',
});
}
for range in ranges.command_outputs.values() {
endpoints.push(Endpoint {
offset: range.end.to_offset(buffer),
marker: '',
});
}
endpoints.sort_by_key(|endpoint| endpoint.offset);
let mut offset = 0;
for endpoint in endpoints {
actual_marked_text.extend(buffer.text_for_range(offset..endpoint.offset));
actual_marked_text.push(endpoint.marker);
offset = endpoint.offset;
}
actual_marked_text.extend(buffer.text_for_range(offset..buffer.len()));
});
assert_eq!(actual_text, expected_text);
assert_eq!(actual_ranges, expected_ranges);
assert_eq!(actual_marked_text, expected_marked_text);
}
}
@ -1063,44 +1229,57 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std
offset + 1..offset + 1 + command_text.len()
});
let output_len = rng.gen_range(1..=10);
let output_text = RandomCharIter::new(&mut rng)
.filter(|c| *c != '\r')
.take(output_len)
.take(10)
.collect::<String>();
let mut events = vec![Ok(SlashCommandEvent::StartMessage {
role: Role::User,
merge_same_roles: true,
})];
let num_sections = rng.gen_range(0..=3);
let mut sections = Vec::with_capacity(num_sections);
let mut section_start = 0;
for _ in 0..num_sections {
let section_start = rng.gen_range(0..output_len);
let section_end = rng.gen_range(section_start..=output_len);
sections.push(SlashCommandOutputSection {
range: section_start..section_end,
icon: ui::IconName::Ai,
let mut section_end = rng.gen_range(section_start..=output_text.len());
while !output_text.is_char_boundary(section_end) {
section_end += 1;
}
events.push(Ok(SlashCommandEvent::StartSection {
icon: IconName::Ai,
label: "section".into(),
metadata: None,
});
}));
events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
text: output_text[section_start..section_end].to_string(),
run_commands_in_text: false,
})));
events.push(Ok(SlashCommandEvent::EndSection { metadata: None }));
section_start = section_end;
}
if section_start < output_text.len() {
events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
text: output_text[section_start..].to_string(),
run_commands_in_text: false,
})));
}
log::info!(
"Context {}: insert slash command output at {:?} with {:?}",
"Context {}: insert slash command output at {:?} with {:?} events",
context_index,
command_range,
sections
events.len()
);
let command_range = context.buffer.read(cx).anchor_after(command_range.start)
..context.buffer.read(cx).anchor_after(command_range.end);
context.insert_command_output(
command_range,
Task::ready(Ok(SlashCommandOutput {
text: output_text,
sections,
run_commands_in_text: false,
}
.to_event_stream())),
"/command",
Task::ready(Ok(stream::iter(events).boxed())),
true,
false,
cx,
);
});

View file

@ -127,7 +127,6 @@ impl SlashCommandCompletionProvider {
&command_name,
&[],
true,
false,
workspace.clone(),
cx,
);
@ -212,7 +211,6 @@ impl SlashCommandCompletionProvider {
&command_name,
&completed_arguments,
true,
false,
workspace.clone(),
cx,
);

View file

@ -75,12 +75,6 @@ impl SlashCommand for StreamingExampleSlashCommand {
},
)))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "\n".into(),
run_commands_in_text: false,
},
)))?;
Timer::after(Duration::from_secs(1)).await;
@ -96,12 +90,6 @@ impl SlashCommand for StreamingExampleSlashCommand {
},
)))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "\n".into(),
run_commands_in_text: false,
},
)))?;
for n in 1..=10 {
Timer::after(Duration::from_secs(1)).await;
@ -119,12 +107,6 @@ impl SlashCommand for StreamingExampleSlashCommand {
)))?;
events_tx
.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
SlashCommandContent::Text {
text: "\n".into(),
run_commands_in_text: false,
},
)))?;
}
anyhow::Ok(())