Cargo.lock 🔗
@@ -375,7 +375,6 @@ dependencies = [
"assets",
"assistant_slash_command",
"async-watch",
- "breadcrumbs",
"cargo_toml",
"chrono",
"client",
Kirill Bulatov created
Both manual and LLM-through ways are supported:
https://github.com/user-attachments/assets/afb0d2b3-9a9b-4f78-a909-1e663e686323
Release Notes:
- Improved assistant panel summarization usability
Cargo.lock | 1
crates/assistant/Cargo.toml | 1
crates/assistant/src/assistant_panel.rs | 230 ++++++++++++++++++--------
crates/assistant/src/context.rs | 72 +++++--
4 files changed, 204 insertions(+), 100 deletions(-)
@@ -375,7 +375,6 @@ dependencies = [
"assets",
"assistant_slash_command",
"async-watch",
- "breadcrumbs",
"cargo_toml",
"chrono",
"client",
@@ -26,7 +26,6 @@ anyhow.workspace = true
assets.workspace = true
assistant_slash_command.workspace = true
async-watch.workspace = true
-breadcrumbs.workspace = true
cargo_toml.workspace = true
chrono.workspace = true
client.workspace = true
@@ -16,7 +16,6 @@ use crate::{
};
use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
-use breadcrumbs::Breadcrumbs;
use client::proto;
use collections::{BTreeSet, HashMap, HashSet};
use completion::CompletionProvider;
@@ -50,6 +49,7 @@ use project::{Project, ProjectLspAdapterDelegate};
use search::{buffer_search::DivRegistrar, BufferSearchBar};
use settings::Settings;
use std::{
+ borrow::Cow,
cmp::{self, Ordering},
fmt::Write,
ops::Range,
@@ -58,7 +58,6 @@ use std::{
time::Duration,
};
use terminal_view::{terminal_panel::TerminalPanel, TerminalView};
-use theme::ThemeSettings;
use ui::{
prelude::*,
utils::{format_distance_from_now, DateTimeType},
@@ -68,7 +67,7 @@ use ui::{
use util::ResultExt;
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
- item::{self, BreadcrumbText, FollowableItem, Item, ItemHandle},
+ item::{self, FollowableItem, Item, ItemHandle},
notifications::NotifyTaskExt,
pane::{self, SaveIntent},
searchable::{SearchEvent, SearchableItem},
@@ -113,6 +112,7 @@ pub struct AssistantPanel {
subscriptions: Vec<Subscription>,
authentication_prompt: Option<AnyView>,
model_selector_menu_handle: PopoverMenuHandle<ContextMenu>,
+ model_summary_editor: View<Editor>,
}
#[derive(Clone)]
@@ -300,6 +300,14 @@ impl AssistantPanel {
cx: &mut ViewContext<Self>,
) -> Self {
let model_selector_menu_handle = PopoverMenuHandle::default();
+ let model_summary_editor = cx.new_view(|cx| Editor::single_line(cx));
+ let context_editor_toolbar = cx.new_view(|_| {
+ ContextEditorToolbarItem::new(
+ workspace,
+ model_selector_menu_handle.clone(),
+ model_summary_editor.clone(),
+ )
+ });
let pane = cx.new_view(|cx| {
let mut pane = Pane::new(
workspace.weak_handle(),
@@ -345,13 +353,7 @@ impl AssistantPanel {
.into_any_element()
});
pane.toolbar().update(cx, |toolbar, cx| {
- toolbar.add_item(cx.new_view(|_| Breadcrumbs::new()), cx);
- toolbar.add_item(
- cx.new_view(|_| {
- ContextEditorToolbarItem::new(workspace, model_selector_menu_handle.clone())
- }),
- cx,
- );
+ toolbar.add_item(context_editor_toolbar.clone(), cx);
toolbar.add_item(cx.new_view(BufferSearchBar::new), cx)
});
pane
@@ -360,6 +362,8 @@ impl AssistantPanel {
let subscriptions = vec![
cx.observe(&pane, |_, _, cx| cx.notify()),
cx.subscribe(&pane, Self::handle_pane_event),
+ cx.subscribe(&context_editor_toolbar, Self::handle_toolbar_event),
+ cx.subscribe(&model_summary_editor, Self::handle_summary_editor_event),
cx.observe_global::<CompletionProvider>({
let mut prev_settings_version = CompletionProvider::global(cx).settings_version();
move |this, cx| {
@@ -381,6 +385,7 @@ impl AssistantPanel {
subscriptions,
authentication_prompt: None,
model_selector_menu_handle,
+ model_summary_editor,
}
}
@@ -390,10 +395,19 @@ impl AssistantPanel {
event: &pane::Event,
cx: &mut ViewContext<Self>,
) {
- match event {
- pane::Event::Remove => cx.emit(PanelEvent::Close),
- pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn),
- pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut),
+ let update_model_summary = match event {
+ pane::Event::Remove => {
+ cx.emit(PanelEvent::Close);
+ false
+ }
+ pane::Event::ZoomIn => {
+ cx.emit(PanelEvent::ZoomIn);
+ false
+ }
+ pane::Event::ZoomOut => {
+ cx.emit(PanelEvent::ZoomOut);
+ false
+ }
pane::Event::AddItem { item } => {
self.workspace
@@ -401,6 +415,7 @@ impl AssistantPanel {
item.added_to_pane(workspace, self.pane.clone(), cx)
})
.ok();
+ true
}
pane::Event::ActivateItem { local } => {
@@ -412,13 +427,59 @@ impl AssistantPanel {
.ok();
}
cx.emit(AssistantPanelEvent::ContextEdited);
+ true
}
pane::Event::RemoveItem { .. } => {
cx.emit(AssistantPanelEvent::ContextEdited);
+ true
}
- _ => {}
+ _ => false,
+ };
+
+ if update_model_summary {
+ if let Some(editor) = self.active_context_editor(cx) {
+ self.show_updated_summary(&editor, cx)
+ }
+ }
+ }
+
+ fn handle_summary_editor_event(
+ &mut self,
+ model_summary_editor: View<Editor>,
+ event: &EditorEvent,
+ cx: &mut ViewContext<Self>,
+ ) {
+ if matches!(event, EditorEvent::Edited { .. }) {
+ if let Some(context_editor) = self.active_context_editor(cx) {
+ let new_summary = model_summary_editor.read(cx).text(cx);
+ context_editor.update(cx, |context_editor, cx| {
+ context_editor.context.update(cx, |context, cx| {
+ if context.summary().is_none()
+ && (new_summary == DEFAULT_TAB_TITLE || new_summary.trim().is_empty())
+ {
+ return;
+ }
+ context.custom_summary(new_summary, cx)
+ });
+ });
+ }
+ }
+ }
+
+ fn handle_toolbar_event(
+ &mut self,
+ _: View<ContextEditorToolbarItem>,
+ _: &ContextEditorToolbarItemEvent,
+ cx: &mut ViewContext<Self>,
+ ) {
+ if let Some(context_editor) = self.active_context_editor(cx) {
+ context_editor.update(cx, |context_editor, cx| {
+ context_editor.context.update(cx, |context, cx| {
+ context.summarize(true, cx);
+ })
+ })
}
}
@@ -430,17 +491,19 @@ impl AssistantPanel {
if self.is_authenticated(cx) {
self.authentication_prompt = None;
- if let Some(editor) = self.active_context_editor(cx) {
- editor.update(cx, |active_context, cx| {
- active_context
- .context
- .update(cx, |context, cx| context.completion_provider_changed(cx))
- })
+ match self.active_context_editor(cx) {
+ Some(editor) => {
+ editor.update(cx, |active_context, cx| {
+ active_context
+ .context
+ .update(cx, |context, cx| context.completion_provider_changed(cx))
+ });
+ }
+ None => {
+ self.new_context(cx);
+ }
}
- if self.active_context_editor(cx).is_none() {
- self.new_context(cx);
- }
cx.notify();
} else if self.authentication_prompt.is_none()
|| prev_settings_version != CompletionProvider::global(cx).settings_version()
@@ -637,18 +700,43 @@ impl AssistantPanel {
.push(cx.subscribe(&context_editor, Self::handle_context_editor_event));
}
+ self.show_updated_summary(&context_editor, cx);
+
cx.emit(AssistantPanelEvent::ContextEdited);
cx.notify();
}
+ fn show_updated_summary(
+ &self,
+ context_editor: &View<ContextEditor>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ context_editor.update(cx, |context_editor, cx| {
+ let new_summary = context_editor
+ .context
+ .read(cx)
+ .summary()
+ .map(|s| s.text.clone())
+ .unwrap_or_else(|| context_editor.title(cx).to_string());
+ self.model_summary_editor.update(cx, |summary_editor, cx| {
+ if summary_editor.text(cx) != new_summary {
+ summary_editor.set_text(new_summary, cx);
+ }
+ });
+ });
+ }
+
fn handle_context_editor_event(
&mut self,
- _: View<ContextEditor>,
+ context_editor: View<ContextEditor>,
event: &EditorEvent,
cx: &mut ViewContext<Self>,
) {
match event {
- EditorEvent::TitleChanged { .. } => cx.notify(),
+ EditorEvent::TitleChanged => {
+ self.show_updated_summary(&context_editor, cx);
+ cx.notify()
+ }
EditorEvent::Edited { .. } => cx.emit(AssistantPanelEvent::ContextEdited),
_ => {}
}
@@ -1001,9 +1089,10 @@ pub struct ContextEditor {
assistant_panel: WeakView<AssistantPanel>,
}
-impl ContextEditor {
- const MAX_TAB_TITLE_LEN: usize = 16;
+const DEFAULT_TAB_TITLE: &str = "New Context";
+const MAX_TAB_TITLE_LEN: usize = 16;
+impl ContextEditor {
fn for_context(
context: Model<Context>,
fs: Arc<dyn Fs>,
@@ -1316,7 +1405,7 @@ impl ContextEditor {
ContextEvent::SummaryChanged => {
cx.emit(EditorEvent::TitleChanged);
self.context.update(cx, |context, cx| {
- context.save(None, self.fs.clone(), cx);
+ context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx);
});
}
ContextEvent::StreamedCompletion => {
@@ -2031,16 +2120,18 @@ impl ContextEditor {
}
fn save(&mut self, _: &Save, cx: &mut ViewContext<Self>) {
- self.context
- .update(cx, |context, cx| context.save(None, self.fs.clone(), cx));
+ self.context.update(cx, |context, cx| {
+ context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx)
+ });
}
- fn title(&self, cx: &AppContext) -> String {
+ fn title(&self, cx: &AppContext) -> Cow<str> {
self.context
.read(cx)
.summary()
.map(|summary| summary.text.clone())
- .unwrap_or_else(|| "New Context".into())
+ .map(Cow::Owned)
+ .unwrap_or_else(|| Cow::Borrowed(DEFAULT_TAB_TITLE))
}
fn render_send_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
@@ -2139,14 +2230,13 @@ impl Item for ContextEditor {
type Event = editor::EditorEvent;
fn tab_content_text(&self, cx: &WindowContext) -> Option<SharedString> {
- Some(util::truncate_and_trailoff(&self.title(cx), Self::MAX_TAB_TITLE_LEN).into())
+ Some(util::truncate_and_trailoff(&self.title(cx), MAX_TAB_TITLE_LEN).into())
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(item::ItemEvent)) {
match event {
EditorEvent::Edited { .. } => {
f(item::ItemEvent::Edit);
- f(item::ItemEvent::UpdateBreadcrumbs);
}
EditorEvent::TitleChanged => {
f(item::ItemEvent::UpdateTab);
@@ -2156,48 +2246,13 @@ impl Item for ContextEditor {
}
fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString> {
- Some(self.title(cx).into())
+ Some(self.title(cx).to_string().into())
}
fn as_searchable(&self, handle: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
- fn breadcrumbs(
- &self,
- theme: &theme::Theme,
- cx: &AppContext,
- ) -> Option<Vec<item::BreadcrumbText>> {
- let editor = self.editor.read(cx);
- let cursor = editor.selections.newest_anchor().head();
- let multibuffer = &editor.buffer().read(cx);
- let (_, symbols) = multibuffer.symbols_containing(cursor, Some(&theme.syntax()), cx)?;
-
- let settings = ThemeSettings::get_global(cx);
-
- let mut breadcrumbs = Vec::new();
-
- let title = self.title(cx);
- if title.chars().count() > Self::MAX_TAB_TITLE_LEN {
- breadcrumbs.push(BreadcrumbText {
- text: title,
- highlights: None,
- font: Some(settings.buffer_font.clone()),
- });
- }
-
- breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText {
- text: symbol.text,
- highlights: Some(symbol.highlight_ranges),
- font: Some(settings.buffer_font.clone()),
- }));
- Some(breadcrumbs)
- }
-
- fn breadcrumb_location(&self) -> ToolbarItemLocation {
- ToolbarItemLocation::PrimaryLeft
- }
-
fn set_nav_history(&mut self, nav_history: pane::ItemNavHistory, cx: &mut ViewContext<Self>) {
self.editor.update(cx, |editor, cx| {
Item::set_nav_history(editor, nav_history, cx)
@@ -2405,18 +2460,21 @@ pub struct ContextEditorToolbarItem {
workspace: WeakView<Workspace>,
active_context_editor: Option<WeakView<ContextEditor>>,
model_selector_menu_handle: PopoverMenuHandle<ContextMenu>,
+ model_summary_editor: View<Editor>,
}
impl ContextEditorToolbarItem {
pub fn new(
workspace: &Workspace,
model_selector_menu_handle: PopoverMenuHandle<ContextMenu>,
+ model_summary_editor: View<Editor>,
) -> Self {
Self {
fs: workspace.app_state().fs.clone(),
workspace: workspace.weak_handle(),
active_context_editor: None,
model_selector_menu_handle,
+ model_summary_editor,
}
}
@@ -2524,14 +2582,35 @@ impl ContextEditorToolbarItem {
impl Render for ContextEditorToolbarItem {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
- h_flex()
+ let left_side = h_flex()
+ .gap_2()
+ .flex_1()
+ .min_w(rems(DEFAULT_TAB_TITLE.len() as f32))
+ .when(self.active_context_editor.is_some(), |left_side| {
+ left_side
+ .child(
+ IconButton::new("regenerate-context", IconName::ArrowCircle)
+ .tooltip(|cx| Tooltip::text("Regenerate Summary", cx))
+ .on_click(cx.listener(move |_, _, cx| {
+ cx.emit(ContextEditorToolbarItemEvent::RegenerateSummary)
+ })),
+ )
+ .child(self.model_summary_editor.clone())
+ });
+ let right_side = h_flex()
.gap_2()
.child(ModelSelector::new(
self.model_selector_menu_handle.clone(),
self.fs.clone(),
))
.children(self.render_remaining_tokens(cx))
- .child(self.render_inject_context_menu(cx))
+ .child(self.render_inject_context_menu(cx));
+
+ h_flex()
+ .size_full()
+ .justify_between()
+ .child(left_side)
+ .child(right_side)
}
}
@@ -2559,6 +2638,11 @@ impl ToolbarItemView for ContextEditorToolbarItem {
impl EventEmitter<ToolbarItemEvent> for ContextEditorToolbarItem {}
+enum ContextEditorToolbarItemEvent {
+ RegenerateSummary,
+}
+impl EventEmitter<ContextEditorToolbarItemEvent> for ContextEditorToolbarItem {}
+
pub struct ContextHistory {
picker: View<Picker<SavedContextPickerDelegate>>,
_subscriptions: Vec<Subscription>,
@@ -9,7 +9,7 @@ use assistant_slash_command::{
use client::{self, proto, telemetry::Telemetry};
use clock::ReplicaId;
use collections::{HashMap, HashSet};
-use fs::Fs;
+use fs::{Fs, RemoveOptions};
use futures::{
future::{self, Shared},
FutureExt, StreamExt,
@@ -1675,7 +1675,7 @@ impl Context {
this.update(&mut cx, |this, cx| {
this.pending_completions
.retain(|completion| completion.id != this.completion_count);
- this.summarize(cx);
+ this.summarize(false, cx);
})?;
anyhow::Ok(())
@@ -1968,8 +1968,8 @@ impl Context {
self.message_anchors.insert(insertion_ix, new_anchor);
}
- fn summarize(&mut self, cx: &mut ModelContext<Self>) {
- if self.message_anchors.len() >= 2 && self.summary.is_none() {
+ pub(super) fn summarize(&mut self, replace_old: bool, cx: &mut ModelContext<Self>) {
+ if replace_old || (self.message_anchors.len() >= 2 && self.summary.is_none()) {
if !CompletionProvider::global(cx).is_authenticated() {
return;
}
@@ -1993,13 +1993,18 @@ impl Context {
async move {
let mut messages = stream.await?;
+ let mut replaced = !replace_old;
while let Some(message) = messages.next().await {
let text = message?;
let mut lines = text.lines();
this.update(&mut cx, |this, cx| {
let version = this.version.clone();
let timestamp = this.next_timestamp();
- let summary = this.summary.get_or_insert(Default::default());
+ let summary = this.summary.get_or_insert(ContextSummary::default());
+ if !replaced && replace_old {
+ summary.text.clear();
+ replaced = true;
+ }
summary.text.extend(lines.next());
summary.timestamp = timestamp;
let operation = ContextOperation::UpdateSummary {
@@ -2142,35 +2147,52 @@ impl Context {
if let Some(summary) = summary {
let context = this.read_with(&cx, |this, cx| this.serialize(cx))?;
- let path = if let Some(old_path) = old_path {
- old_path
- } else {
- let mut discriminant = 1;
- let mut new_path;
- loop {
- new_path = contexts_dir().join(&format!(
- "{} - {}.zed.json",
- summary.trim(),
- discriminant
- ));
- if fs.is_file(&new_path).await {
- discriminant += 1;
- } else {
- break;
- }
+ let mut discriminant = 1;
+ let mut new_path;
+ loop {
+ new_path = contexts_dir().join(&format!(
+ "{} - {}.zed.json",
+ summary.trim(),
+ discriminant
+ ));
+ if fs.is_file(&new_path).await {
+ discriminant += 1;
+ } else {
+ break;
}
- new_path
- };
+ }
fs.create_dir(contexts_dir().as_ref()).await?;
- fs.atomic_write(path.clone(), serde_json::to_string(&context).unwrap())
+ fs.atomic_write(new_path.clone(), serde_json::to_string(&context).unwrap())
.await?;
- this.update(&mut cx, |this, _| this.path = Some(path))?;
+ if let Some(old_path) = old_path {
+ if new_path != old_path {
+ fs.remove_file(
+ &old_path,
+ RemoveOptions {
+ recursive: false,
+ ignore_if_not_exists: true,
+ },
+ )
+ .await?;
+ }
+ }
+
+ this.update(&mut cx, |this, _| this.path = Some(new_path))?;
}
Ok(())
});
}
+
+ pub(crate) fn custom_summary(&mut self, custom_summary: String, cx: &mut ModelContext<Self>) {
+ let timestamp = self.next_timestamp();
+ let summary = self.summary.get_or_insert(ContextSummary::default());
+ summary.timestamp = timestamp;
+ summary.done = true;
+ summary.text = custom_summary;
+ cx.emit(ContextEvent::SummaryChanged);
+ }
}
#[derive(Debug, Default)]