From a13a92fbbf534703aaceda42096de9805f8e7a13 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 14 May 2024 13:48:36 +0200 Subject: [PATCH] Introduce recent files ambient context for assistant (#11791) image Release Notes: - Added a new ambient context feature that allows showing the model up to three buffers (along with their diagnostics) that the user interacted with recently. --------- Co-authored-by: Nathan Sobo --- assets/icons/countdown_timer.svg | 1 + crates/assistant/src/assistant.rs | 10 +- crates/assistant/src/assistant_panel.rs | 778 +++++++++++++++--------- crates/assistant/src/embedded_scope.rs | 91 --- crates/gpui/src/window.rs | 12 + crates/tab_switcher/src/tab_switcher.rs | 4 +- crates/ui/src/components/icon.rs | 2 + crates/workspace/src/pane.rs | 26 +- crates/workspace/src/workspace.rs | 9 +- 9 files changed, 522 insertions(+), 411 deletions(-) create mode 100644 assets/icons/countdown_timer.svg delete mode 100644 crates/assistant/src/embedded_scope.rs diff --git a/assets/icons/countdown_timer.svg b/assets/icons/countdown_timer.svg new file mode 100644 index 0000000000..b9b7479228 --- /dev/null +++ b/assets/icons/countdown_timer.svg @@ -0,0 +1 @@ + diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 46eeb4c095..ae436df59f 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -6,11 +6,8 @@ mod prompts; mod saved_conversation; mod streaming_diff; -mod embedded_scope; - pub use assistant_panel::AssistantPanel; use assistant_settings::{AssistantSettings, OpenAiModel, ZedDotDevModel}; -use chrono::{DateTime, Local}; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; pub(crate) use completion_provider::*; @@ -26,7 +23,6 @@ use std::{ actions!( assistant, [ - NewConversation, Assist, Split, CycleMessageRole, @@ -35,6 +31,7 @@ actions!( ResetKey, InlineAssist, ToggleIncludeConversation, + ToggleHistory, ] ); @@ -93,8 +90,8 @@ impl LanguageModel { pub fn display_name(&self) -> String { match self { - LanguageModel::OpenAi(model) => format!("openai/{}", model.display_name()), - LanguageModel::ZedDotDev(model) => format!("zed.dev/{}", model.display_name()), + LanguageModel::OpenAi(model) => model.display_name().into(), + LanguageModel::ZedDotDev(model) => model.display_name().into(), } } @@ -178,7 +175,6 @@ pub struct LanguageModelChoiceDelta { #[derive(Clone, Debug, Serialize, Deserialize)] struct MessageMetadata { role: Role, - sent_at: DateTime, status: MessageStatus, } diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 118b59bc04..d090c96357 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,15 +1,13 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel}, codegen::{self, Codegen, CodegenKind}, - embedded_scope::EmbeddedScope, prompts::generate_content_prompt, Assist, CompletionProvider, CycleMessageRole, InlineAssist, LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus, - NewConversation, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, - SavedMessage, Split, ToggleFocus, ToggleIncludeConversation, + QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage, + Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation, }; use anyhow::{anyhow, Result}; -use chrono::{DateTime, Local}; use collections::{hash_map, HashMap, HashSet, VecDeque}; use editor::{ actions::{MoveDown, MoveUp}, @@ -17,21 +15,24 @@ use editor::{ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, }, scroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer, MultiBufferSnapshot, - RowExt, ToOffset as _, ToPoint, + Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt, + ToOffset as _, ToPoint, }; use file_icons::FileIcons; use fs::Fs; use futures::StreamExt; use gpui::{ - canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext, - AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, EventEmitter, - FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, - IntoElement, Model, ModelContext, ParentElement, Pixels, Render, SharedString, - StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, UniformListScrollHandle, - View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WindowContext, + canvas, div, point, relative, rems, uniform_list, Action, AnyView, AppContext, AsyncAppContext, + AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model, + ModelContext, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled, + Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, + WeakModel, WeakView, WhiteSpace, WindowContext, +}; +use language::{ + language_settings::SoftWrap, Buffer, BufferSnapshot, DiagnosticEntry, LanguageRegistry, Point, + ToOffset as _, }; -use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, Point, ToOffset as _}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::Project; @@ -40,19 +41,17 @@ use settings::Settings; use std::{cmp, fmt::Write, iter, ops::Range, path::PathBuf, sync::Arc, time::Duration}; use telemetry_events::AssistantKind; use theme::ThemeSettings; -use ui::{ - prelude::*, - utils::{DateTimeType, FormatDistance}, - ButtonLike, Tab, TabBar, Tooltip, -}; +use ui::{popover_menu, prelude::*, ButtonLike, ContextMenu, Tab, TabBar, Tooltip}; use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt}; use uuid::Uuid; -use workspace::notifications::NotificationId; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, searchable::Direction, Event as WorkspaceEvent, Save, Toast, ToggleZoom, Toolbar, Workspace, }; +use workspace::{notifications::NotificationId, NewFile}; + +const MAX_RECENT_BUFFERS: usize = 3; pub fn init(cx: &mut AppContext) { cx.observe_new_views( @@ -676,7 +675,7 @@ impl AssistantPanel { messages.extend( conversation .messages(cx) - .map(|message| message.to_open_ai_message(buffer)), + .map(|message| message.to_request_message(buffer)), ); } let model = self.model.clone(); @@ -853,6 +852,18 @@ impl AssistantPanel { } } + fn toggle_history(&mut self, _: &ToggleHistory, cx: &mut ViewContext) { + self.show_saved_conversations = !self.show_saved_conversations; + cx.notify(); + } + + fn show_history(&mut self, cx: &mut ViewContext) { + if !self.show_saved_conversations { + self.show_saved_conversations = true; + cx.notify(); + } + } + fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext) { let mut propagate = true; if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { @@ -907,36 +918,74 @@ impl AssistantPanel { Some(&self.active_conversation_editor.as_ref()?.editor) } - fn render_hamburger_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("hamburger_button", IconName::Menu) - .on_click(cx.listener(|this, _event, cx| { - this.show_saved_conversations = !this.show_saved_conversations; - cx.notify(); - })) - .tooltip(|cx| Tooltip::text("Conversation History", cx)) + fn render_popover_button(&self, cx: &mut ViewContext) -> impl IntoElement { + let assistant = cx.view().clone(); + let zoomed = self.zoomed; + popover_menu("assistant-popover") + .trigger(IconButton::new("trigger", IconName::Menu)) + .menu(move |cx| { + let assistant = assistant.clone(); + ContextMenu::build(cx, |menu, _cx| { + menu.entry( + if zoomed { "Zoom Out" } else { "Zoom In" }, + Some(Box::new(ToggleZoom)), + { + let assistant = assistant.clone(); + move |cx| { + assistant.focus_handle(cx).dispatch_action(&ToggleZoom, cx); + } + }, + ) + .entry("New Context", Some(Box::new(NewFile)), { + let assistant = assistant.clone(); + move |cx| { + assistant.focus_handle(cx).dispatch_action(&NewFile, cx); + } + }) + .entry("History", Some(Box::new(ToggleHistory)), { + let assistant = assistant.clone(); + move |cx| assistant.update(cx, |assistant, cx| assistant.show_history(cx)) + }) + }) + .into() + }) } - fn render_editor_tools(&self, cx: &mut ViewContext) -> Vec { - if self.active_conversation_editor().is_some() { - vec![ - Self::render_split_button(cx).into_any_element(), - Self::render_quote_button(cx).into_any_element(), - Self::render_assist_button(cx).into_any_element(), - ] - } else { - Default::default() - } - } + fn render_inject_context_menu(&self, _cx: &mut ViewContext) -> impl Element { + let workspace = self.workspace.clone(); - fn render_split_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("split_button", IconName::Snip) - .on_click(cx.listener(|this, _event, cx| { - if let Some(active_editor) = this.active_conversation_editor() { - active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx)); - } + popover_menu("inject-context-menu") + .trigger(IconButton::new("trigger", IconName::Quote).tooltip(|cx| { + // Tooltip::with_meta("Insert Context", None, "Type # to insert via keyboard", cx) + Tooltip::text("Insert Context", cx) })) - .icon_size(IconSize::Small) - .tooltip(|cx| Tooltip::for_action("Split Message", &Split, cx)) + .menu(move |cx| { + ContextMenu::build(cx, |menu, _cx| { + // menu.entry("Insert Search", None, { + // let assistant = assistant.clone(); + // move |_cx| {} + // }) + // .entry("Insert Docs", None, { + // let assistant = assistant.clone(); + // move |cx| {} + // }) + menu.entry("Quote Selection", None, { + let workspace = workspace.clone(); + move |cx| { + workspace + .update(cx, |workspace, cx| { + ConversationEditor::quote_selection( + workspace, + &Default::default(), + cx, + ) + }) + .ok(); + } + }) + }) + .into() + }) } fn render_assist_button(cx: &mut ViewContext) -> impl IntoElement { @@ -950,44 +999,6 @@ impl AssistantPanel { .tooltip(|cx| Tooltip::for_action("Assist", &Assist, cx)) } - fn render_quote_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("quote_button", IconName::Quote) - .on_click(cx.listener(|this, _event, cx| { - if let Some(workspace) = this.workspace.upgrade() { - cx.window_context().defer(move |cx| { - workspace.update(cx, |workspace, cx| { - ConversationEditor::quote_selection(workspace, &Default::default(), cx) - }); - }); - } - })) - .icon_size(IconSize::Small) - .tooltip(|cx| Tooltip::for_action("Quote Selection", &QuoteSelection, cx)) - } - - fn render_plus_button(cx: &mut ViewContext) -> impl IntoElement { - IconButton::new("plus_button", IconName::Plus) - .on_click(cx.listener(|this, _event, cx| { - this.new_conversation(cx); - })) - .icon_size(IconSize::Small) - .tooltip(|cx| Tooltip::for_action("New Conversation", &NewConversation, cx)) - } - - fn render_zoom_button(&self, cx: &mut ViewContext) -> impl IntoElement { - let zoomed = self.zoomed; - IconButton::new("zoom_button", IconName::Maximize) - .on_click(cx.listener(|this, _event, cx| { - this.toggle_zoom(&ToggleZoom, cx); - })) - .selected(zoomed) - .selected_icon(IconName::Minimize) - .icon_size(IconSize::Small) - .tooltip(move |cx| { - Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, cx) - }) - } - fn render_saved_conversation( &mut self, index: usize, @@ -1058,9 +1069,7 @@ impl AssistantPanel { fn render_signed_in(&mut self, cx: &mut ViewContext) -> impl IntoElement { let header = TabBar::new("assistant_header") - .start_child( - h_flex().gap_1().child(Self::render_hamburger_button(cx)), // .children(title), - ) + .start_child(h_flex().gap_1().child(self.render_popover_button(cx))) .children(self.active_conversation_editor().map(|editor| { h_flex() .h(rems(Tab::CONTAINER_HEIGHT_IN_REMS)) @@ -1068,26 +1077,30 @@ impl AssistantPanel { .px_2() .child(Label::new(editor.read(cx).title(cx)).into_element()) })) - .when(self.focus_handle.contains_focused(cx), |this| { - this.end_child( - h_flex() - .gap_2() - .when(self.active_conversation_editor().is_some(), |this| { - this.child(h_flex().gap_1().children(self.render_editor_tools(cx))) - .child( - ui::Divider::vertical() - .inset() - .color(ui::DividerColor::Border), - ) - }) - .child( + .end_child( + h_flex() + .gap_2() + .when_some(self.active_conversation_editor(), |this, editor| { + let conversation = editor.read(cx).conversation.clone(); + this.child( h_flex() .gap_1() - .child(Self::render_plus_button(cx)) - .child(self.render_zoom_button(cx)), - ), - ) - }); + .child(self.render_model(&conversation, cx)) + .children(self.render_remaining_tokens(&conversation, cx)), + ) + .child( + ui::Divider::vertical() + .inset() + .color(ui::DividerColor::Border), + ) + }) + .child( + h_flex() + .gap_1() + .child(self.render_inject_context_menu(cx)) + .child(Self::render_assist_button(cx)), + ), + ); let contents = if self.active_conversation_editor().is_some() { let mut registrar = DivRegistrar::new( @@ -1099,6 +1112,7 @@ impl AssistantPanel { } else { div() }; + v_flex() .key_context("AssistantPanel") .size_full() @@ -1106,6 +1120,7 @@ impl AssistantPanel { this.new_conversation(cx); })) .on_action(cx.listener(AssistantPanel::toggle_zoom)) + .on_action(cx.listener(AssistantPanel::toggle_history)) .on_action(cx.listener(AssistantPanel::deploy)) .on_action(cx.listener(AssistantPanel::select_next_match)) .on_action(cx.listener(AssistantPanel::select_prev_match)) @@ -1150,20 +1165,7 @@ impl AssistantPanel { .into_any_element() } else if let Some(editor) = self.active_conversation_editor() { let editor = editor.clone(); - let conversation = editor.read(cx).conversation.clone(); - div() - .size_full() - .child(editor.clone()) - .child( - h_flex() - .absolute() - .gap_1() - .top_3() - .right_5() - .child(self.render_model(&conversation, cx)) - .children(self.render_remaining_tokens(&conversation, cx)), - ) - .into_any_element() + div().size_full().child(editor.clone()).into_any_element() } else { div().into_any_element() }, @@ -1192,9 +1194,13 @@ impl AssistantPanel { } else if remaining_tokens <= 500 { Color::Warning } else { - Color::Default + Color::Muted }; - Some(Label::new(remaining_tokens.to_string()).color(remaining_tokens_color)) + Some( + Label::new(remaining_tokens.to_string()) + .size(LabelSize::Small) + .color(remaining_tokens_color), + ) } } @@ -1319,7 +1325,7 @@ struct Summary { pub struct Conversation { id: Option, buffer: Model, - embedded_scope: EmbeddedScope, + ambient_context: AmbientContext, message_anchors: Vec, messages_metadata: HashMap, next_message_id: MessageId, @@ -1335,13 +1341,40 @@ pub struct Conversation { _subscriptions: Vec, } +#[derive(Default)] +struct AmbientContext { + recent_buffers: RecentBuffersContext, +} + +struct RecentBuffersContext { + enabled: bool, + buffers: Vec, + message: String, + pending_message: Option>, +} + +struct RecentBuffer { + buffer: WeakModel, + _subscription: Subscription, +} + +impl Default for RecentBuffersContext { + fn default() -> Self { + Self { + enabled: true, + buffers: Vec::new(), + message: String::new(), + pending_message: None, + } + } +} + impl EventEmitter for Conversation {} impl Conversation { fn new( model: LanguageModel, language_registry: Arc, - embedded_scope: EmbeddedScope, cx: &mut ModelContext, ) -> Self { let markdown = language_registry.language_for_name("Markdown"); @@ -1364,6 +1397,7 @@ impl Conversation { message_anchors: Default::default(), messages_metadata: Default::default(), next_message_id: Default::default(), + ambient_context: AmbientContext::default(), summary: None, pending_summary: Task::ready(None), completion_count: Default::default(), @@ -1375,7 +1409,6 @@ impl Conversation { pending_save: Task::ready(Ok(())), path: None, buffer, - embedded_scope, }; let message = MessageAnchor { @@ -1387,7 +1420,6 @@ impl Conversation { message.id, MessageMetadata { role: Role::User, - sent_at: Local::now(), status: MessageStatus::Done, }, ); @@ -1460,6 +1492,7 @@ impl Conversation { message_anchors, messages_metadata: saved_conversation.message_metadata, next_message_id, + ambient_context: AmbientContext::default(), summary: Some(Summary { text: saved_conversation.summary, done: true, @@ -1474,13 +1507,193 @@ impl Conversation { pending_save: Task::ready(Ok(())), path: Some(path), buffer, - embedded_scope: EmbeddedScope::new(), }; this.count_remaining_tokens(cx); this }) } + fn toggle_recent_buffers(&mut self, cx: &mut ModelContext) { + self.ambient_context.recent_buffers.enabled = !self.ambient_context.recent_buffers.enabled; + self.update_recent_buffers_context(cx); + } + + fn set_recent_buffers( + &mut self, + buffers: impl IntoIterator>, + cx: &mut ModelContext, + ) { + self.ambient_context.recent_buffers.buffers.clear(); + self.ambient_context + .recent_buffers + .buffers + .extend(buffers.into_iter().map(|buffer| RecentBuffer { + buffer: buffer.downgrade(), + _subscription: cx.observe(&buffer, |this, _, cx| { + this.update_recent_buffers_context(cx); + }), + })); + self.update_recent_buffers_context(cx); + } + + fn update_recent_buffers_context(&mut self, cx: &mut ModelContext) { + let buffers = self + .ambient_context + .recent_buffers + .buffers + .iter() + .filter_map(|recent| { + recent + .buffer + .read_with(cx, |buffer, cx| { + ( + buffer.file().map(|file| file.full_path(cx)), + buffer.snapshot(), + ) + }) + .ok() + }) + .collect::>(); + + if !self.ambient_context.recent_buffers.enabled || buffers.is_empty() { + self.ambient_context.recent_buffers.message.clear(); + self.ambient_context.recent_buffers.pending_message = None; + self.count_remaining_tokens(cx); + cx.notify(); + } else { + self.ambient_context.recent_buffers.pending_message = + Some(cx.spawn(|this, mut cx| async move { + const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); + cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; + + let message = cx + .background_executor() + .spawn(async move { Self::message_for_recent_buffers(&buffers) }) + .await; + this.update(&mut cx, |this, cx| { + this.ambient_context.recent_buffers.message = message; + this.count_remaining_tokens(cx); + cx.notify(); + }) + .ok(); + })); + } + } + + fn message_for_recent_buffers(buffers: &[(Option, BufferSnapshot)]) -> String { + let mut message = String::new(); + writeln!( + message, + "The following is a list of recent buffers that the user has opened." + ) + .unwrap(); + writeln!( + message, + "For every line in the buffer, I will include a row number that line corresponds to." + ) + .unwrap(); + writeln!( + message, + "Lines that don't have a number correspond to errors and warnings. For example:" + ) + .unwrap(); + writeln!(message, "path/to/file.md").unwrap(); + writeln!(message, "```markdown").unwrap(); + writeln!(message, "1 The quick brown fox").unwrap(); + writeln!(message, "2 jumps over one active").unwrap(); + writeln!(message, " --- error: should be 'the'").unwrap(); + writeln!(message, " ------ error: should be 'lazy'").unwrap(); + writeln!(message, "3 dog").unwrap(); + writeln!(message, "```").unwrap(); + + message.push('\n'); + writeln!(message, "Here's the actual recent buffer list:").unwrap(); + for (path, buffer) in buffers { + if let Some(path) = path { + writeln!(message, "{}", path.display()).unwrap(); + } else { + writeln!(message, "untitled").unwrap(); + } + + if let Some(language) = buffer.language() { + writeln!(message, "```{}", language.name().to_lowercase()).unwrap(); + } else { + writeln!(message, "```").unwrap(); + } + + let mut diagnostics = buffer + .diagnostics_in_range::<_, Point>( + language::Anchor::MIN..language::Anchor::MAX, + false, + ) + .peekable(); + + let mut active_diagnostics = Vec::>::new(); + const GUTTER_PADDING: usize = 4; + let gutter_width = + ((buffer.max_point().row + 1) as f32).log10() as usize + 1 + GUTTER_PADDING; + for buffer_row in 0..=buffer.max_point().row { + let display_row = buffer_row + 1; + active_diagnostics.retain(|diagnostic| { + (diagnostic.range.start.row..=diagnostic.range.end.row).contains(&buffer_row) + }); + while diagnostics.peek().map_or(false, |diagnostic| { + (diagnostic.range.start.row..=diagnostic.range.end.row).contains(&buffer_row) + }) { + active_diagnostics.push(diagnostics.next().unwrap()); + } + + let row_width = (display_row as f32).log10() as usize + 1; + write!(message, "{}", display_row).unwrap(); + if row_width < gutter_width { + message.extend(iter::repeat(' ').take(gutter_width - row_width)); + } + + for chunk in buffer.text_for_range( + Point::new(buffer_row, 0)..Point::new(buffer_row, buffer.line_len(buffer_row)), + ) { + message.push_str(chunk); + } + message.push('\n'); + + for diagnostic in &active_diagnostics { + message.extend(iter::repeat(' ').take(gutter_width)); + + let start_column = if diagnostic.range.start.row == buffer_row { + message + .extend(iter::repeat(' ').take(diagnostic.range.start.column as usize)); + diagnostic.range.start.column + } else { + 0 + }; + let end_column = if diagnostic.range.end.row == buffer_row { + diagnostic.range.end.column + } else { + buffer.line_len(buffer_row) + }; + + message.extend(iter::repeat('-').take((end_column - start_column) as usize)); + writeln!(message, " {}", diagnostic.diagnostic.message).unwrap(); + } + } + + message.push('\n'); + } + + writeln!( + message, + "When quoting the above code, mention which rows the code occurs at." + ) + .unwrap(); + writeln!( + message, + "Never include rows in the quoted code itself and only report lines that didn't start with a row number." + ) + .unwrap(); + + message + } + fn handle_buffer_event( &mut self, _: Model, @@ -1656,20 +1869,27 @@ impl Conversation { } fn to_completion_request(&self, cx: &mut ModelContext) -> LanguageModelRequest { - let mut request = LanguageModelRequest { + let messages = self + .ambient_context + .recent_buffers + .enabled + .then(|| LanguageModelRequestMessage { + role: Role::System, + content: self.ambient_context.recent_buffers.message.clone(), + }) + .into_iter() + .chain( + self.messages(cx) + .filter(|message| matches!(message.status, MessageStatus::Done)) + .map(|message| message.to_request_message(self.buffer.read(cx))), + ); + + LanguageModelRequest { model: self.model.clone(), - messages: self - .messages(cx) - .filter(|message| matches!(message.status, MessageStatus::Done)) - .map(|message| message.to_open_ai_message(self.buffer.read(cx))) - .collect(), + messages: messages.collect(), stop: vec![], temperature: 1.0, - }; - - let context_message = self.embedded_scope.message(cx); - request.messages.extend(context_message); - request + } } fn cancel_last_assist(&mut self) -> bool { @@ -1721,14 +1941,8 @@ impl Conversation { }; self.message_anchors .insert(next_message_ix, message.clone()); - self.messages_metadata.insert( - message.id, - MessageMetadata { - role, - sent_at: Local::now(), - status, - }, - ); + self.messages_metadata + .insert(message.id, MessageMetadata { role, status }); cx.emit(ConversationEvent::MessagesEdited); Some(message) } else { @@ -1785,7 +1999,6 @@ impl Conversation { suffix.id, MessageMetadata { role, - sent_at: Local::now(), status: MessageStatus::Done, }, ); @@ -1830,7 +2043,6 @@ impl Conversation { selection.id, MessageMetadata { role, - sent_at: Local::now(), status: MessageStatus::Done, }, ); @@ -1855,7 +2067,7 @@ impl Conversation { let messages = self .messages(cx) .take(2) - .map(|message| message.to_open_ai_message(self.buffer.read(cx))) + .map(|message| message.to_request_message(self.buffer.read(cx))) .chain(Some(LanguageModelRequestMessage { role: Role::User, content: "Summarize the conversation into a short title without punctuation" @@ -1962,7 +2174,6 @@ impl Conversation { id: message_anchor.id, anchor: message_anchor.start, role: metadata.role, - sent_at: metadata.sent_at, status: metadata.status.clone(), }); } @@ -2061,8 +2272,7 @@ impl ConversationEditor { workspace: View, cx: &mut ViewContext, ) -> Self { - let conversation = cx - .new_model(|cx| Conversation::new(model, language_registry, EmbeddedScope::new(), cx)); + let conversation = cx.new_model(|cx| Conversation::new(model, language_registry, cx)); Self::for_conversation(conversation, fs, workspace, cx) } @@ -2096,7 +2306,7 @@ impl ConversationEditor { workspace: workspace.downgrade(), _subscriptions, }; - cx.defer(|this, cx| this.update_active_buffer(workspace, cx)); + this.update_recent_editors(cx); this.update_message_headers(cx); this } @@ -2232,32 +2442,57 @@ impl ConversationEditor { fn handle_workspace_event( &mut self, - workspace: View, + _: View, event: &WorkspaceEvent, cx: &mut ViewContext, ) { - if let WorkspaceEvent::ActiveItemChanged = event { - self.update_active_buffer(workspace, cx); + match event { + WorkspaceEvent::ActiveItemChanged + | WorkspaceEvent::ItemAdded + | WorkspaceEvent::ItemRemoved + | WorkspaceEvent::PaneAdded(_) + | WorkspaceEvent::PaneRemoved => self.update_recent_editors(cx), + _ => {} } } - fn update_active_buffer( - &mut self, - workspace: View, - cx: &mut ViewContext<'_, ConversationEditor>, - ) { - let active_buffer = workspace - .read(cx) - .active_item(cx) - .and_then(|item| Some(item.act_as::(cx)?.read(cx).buffer().clone())); + fn update_recent_editors(&mut self, cx: &mut ViewContext) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let mut timestamps_by_entity_id = HashMap::default(); + for pane in workspace.read(cx).panes() { + let pane = pane.read(cx); + for entry in pane.activation_history() { + timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp); + } + } + + let mut timestamps_by_buffer = HashMap::default(); + for editor in workspace.read(cx).items_of_type::(cx) { + let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { + continue; + }; + + let new_timestamp = timestamps_by_entity_id + .get(&editor.entity_id()) + .copied() + .unwrap_or_default(); + let timestamp = timestamps_by_buffer.entry(buffer).or_insert(new_timestamp); + *timestamp = cmp::max(*timestamp, new_timestamp); + } + + let mut recent_buffers = timestamps_by_buffer.into_iter().collect::>(); + recent_buffers.sort_unstable_by_key(|(_, timestamp)| *timestamp); + if recent_buffers.len() > MAX_RECENT_BUFFERS { + let excess = recent_buffers.len() - MAX_RECENT_BUFFERS; + recent_buffers.drain(..excess); + } self.conversation.update(cx, |conversation, cx| { conversation - .embedded_scope - .set_active_buffer(active_buffer.clone(), cx); - - conversation.count_remaining_tokens(cx); - cx.notify(); + .set_recent_buffers(recent_buffers.into_iter().map(|(buffer, _)| buffer), cx); }); } @@ -2295,7 +2530,8 @@ impl ConversationEditor { .conversation .read(cx) .messages(cx) - .map(|message| BlockProperties { + .enumerate() + .map(|(ix, message)| BlockProperties { position: buffer .anchor_in_excerpt(excerpt_id, message.anchor) .unwrap(), @@ -2303,7 +2539,7 @@ impl ConversationEditor { style: BlockStyle::Sticky, render: Box::new({ let conversation = self.conversation.clone(); - move |_cx| { + move |cx| { let message_id = message.id; let sender = ButtonLike::new("role") .style(ButtonStyle::Filled) @@ -2335,22 +2571,10 @@ impl ConversationEditor { h_flex() .id(("message_header", message_id.0)) .h_11() + .w_full() .relative() .gap_1() .child(sender) - // TODO: Only show this if the message if the message has been sent - .child( - Label::new( - FormatDistance::from_now(DateTimeType::Local( - message.sent_at, - )) - .hide_prefix(true) - .add_suffix(true) - .to_string(), - ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) .children( if let MessageStatus::Error(error) = message.status.clone() { Some( @@ -2363,6 +2587,65 @@ impl ConversationEditor { None }, ) + .children((ix == 0).then(|| { + div() + .h_flex() + .flex_1() + .justify_end() + .pr_4() + .gap_1() + .child( + IconButton::new("include_file", IconName::File) + .icon_size(IconSize::Small) + .selected( + conversation + .read(cx) + .ambient_context + .recent_buffers + .enabled, + ) + .on_click({ + let conversation = conversation.downgrade(); + move |_, cx| { + conversation + .update(cx, |conversation, cx| { + conversation + .toggle_recent_buffers(cx); + }) + .ok(); + } + }) + .tooltip(|cx| { + Tooltip::text("Include Open Files", cx) + }), + ) + // .child( + // IconButton::new("include_terminal", IconName::Terminal) + // .icon_size(IconSize::Small) + // .tooltip(|cx| { + // Tooltip::text("Include Terminal", cx) + // }), + // ) + // .child( + // IconButton::new( + // "include_edit_history", + // IconName::FileGit, + // ) + // .icon_size(IconSize::Small) + // .tooltip( + // |cx| Tooltip::text("Include Edit History", cx), + // ), + // ) + // .child( + // IconButton::new( + // "include_file_trees", + // IconName::FileTree, + // ) + // .icon_size(IconSize::Small) + // .tooltip(|cx| Tooltip::text("Include File Trees", cx)), + // ) + .into_any() + })) .into_any_element() } }), @@ -2500,104 +2783,6 @@ impl ConversationEditor { .map(|summary| summary.text.clone()) .unwrap_or_else(|| "New Conversation".into()) } - - fn render_embedded_scope(&self, cx: &mut ViewContext) -> Option { - let active_buffer = self - .conversation - .read(cx) - .embedded_scope - .active_buffer()? - .clone(); - - Some( - div() - .p_4() - .v_flex() - .child( - div() - .h_flex() - .items_center() - .child(Icon::new(IconName::File)) - .child( - div() - .h_6() - .child(Label::new("File Contexts")) - .ml_1() - .font_weight(FontWeight::SEMIBOLD), - ), - ) - .child( - div() - .ml_4() - .child(self.render_active_buffer(active_buffer, cx)), - ), - ) - } - - fn render_active_buffer( - &self, - buffer: Model, - cx: &mut ViewContext, - ) -> impl Element { - let buffer = buffer.read(cx); - let icon_path; - let path; - if let Some(singleton) = buffer.as_singleton() { - let singleton = singleton.read(cx); - - path = singleton.file().map(|file| file.full_path(cx)); - - icon_path = path - .as_ref() - .and_then(|path| FileIcons::get_icon(path.as_path(), cx)) - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from("icons/file_icons/file.svg")); - } else { - icon_path = SharedString::from("icons/file_icons/file.svg"); - path = None; - } - - let file_name = path.map_or("Untitled".to_string(), |path| { - path.to_string_lossy().to_string() - }); - - let enabled = self - .conversation - .read(cx) - .embedded_scope - .active_buffer_enabled(); - - let file_name_text_color = if enabled { - Color::Default - } else { - Color::Disabled - }; - - div() - .id("active-buffer") - .h_flex() - .cursor_pointer() - .child(Icon::from_path(icon_path).color(file_name_text_color)) - .child( - div() - .h_6() - .child(Label::new(file_name).color(file_name_text_color)) - .ml_1(), - ) - .children(enabled.then(|| { - div() - .child(Icon::new(IconName::Check).color(file_name_text_color)) - .ml_1() - })) - .on_click(cx.listener(move |this, _, cx| { - this.conversation.update(cx, |conversation, cx| { - conversation - .embedded_scope - .set_active_buffer_enabled(!enabled); - cx.notify(); - }) - })) - } } impl EventEmitter for ConversationEditor {} @@ -2621,7 +2806,6 @@ impl Render for ConversationEditor { .bg(cx.theme().colors().editor_background) .child(self.editor.clone()), ) - .child(div().flex_shrink().children(self.render_embedded_scope(cx))) } } @@ -2644,12 +2828,11 @@ pub struct Message { id: MessageId, anchor: language::Anchor, role: Role, - sent_at: DateTime, status: MessageStatus, } impl Message { - fn to_open_ai_message(&self, buffer: &Buffer) -> LanguageModelRequestMessage { + fn to_request_message(&self, buffer: &Buffer) -> LanguageModelRequestMessage { let content = buffer .text_for_range(self.offset_range.clone()) .collect::(); @@ -2997,9 +3180,8 @@ mod tests { init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let conversation = cx.new_model(|cx| { - Conversation::new(LanguageModel::default(), registry, EmbeddedScope::new(), cx) - }); + let conversation = + cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, cx)); let buffer = conversation.read(cx).buffer.clone(); let message_1 = conversation.read(cx).message_anchors[0].clone(); @@ -3130,9 +3312,8 @@ mod tests { init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let conversation = cx.new_model(|cx| { - Conversation::new(LanguageModel::default(), registry, EmbeddedScope::new(), cx) - }); + let conversation = + cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, cx)); let buffer = conversation.read(cx).buffer.clone(); let message_1 = conversation.read(cx).message_anchors[0].clone(); @@ -3230,9 +3411,8 @@ mod tests { cx.set_global(settings_store); init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let conversation = cx.new_model(|cx| { - Conversation::new(LanguageModel::default(), registry, EmbeddedScope::new(), cx) - }); + let conversation = + cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, cx)); let buffer = conversation.read(cx).buffer.clone(); let message_1 = conversation.read(cx).message_anchors[0].clone(); @@ -3316,14 +3496,8 @@ mod tests { cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default())); cx.update(init); let registry = Arc::new(LanguageRegistry::test(cx.executor())); - let conversation = cx.new_model(|cx| { - Conversation::new( - LanguageModel::default(), - registry.clone(), - EmbeddedScope::new(), - cx, - ) - }); + let conversation = + cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry.clone(), cx)); let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone()); let message_0 = conversation.read_with(cx, |conversation, _| conversation.message_anchors[0].id); diff --git a/crates/assistant/src/embedded_scope.rs b/crates/assistant/src/embedded_scope.rs deleted file mode 100644 index 2bff3c3bfa..0000000000 --- a/crates/assistant/src/embedded_scope.rs +++ /dev/null @@ -1,91 +0,0 @@ -use editor::MultiBuffer; -use gpui::{AppContext, Model, ModelContext, Subscription}; - -use crate::{assistant_panel::Conversation, LanguageModelRequestMessage, Role}; - -#[derive(Default)] -pub struct EmbeddedScope { - active_buffer: Option>, - active_buffer_enabled: bool, - active_buffer_subscription: Option, -} - -impl EmbeddedScope { - pub fn new() -> Self { - Self { - active_buffer: None, - active_buffer_enabled: true, - active_buffer_subscription: None, - } - } - - pub fn set_active_buffer( - &mut self, - buffer: Option>, - cx: &mut ModelContext, - ) { - self.active_buffer_subscription.take(); - - if let Some(active_buffer) = buffer.clone() { - self.active_buffer_subscription = - Some(cx.subscribe(&active_buffer, |conversation, _, e, cx| { - if let multi_buffer::Event::Edited { .. } = e { - conversation.count_remaining_tokens(cx) - } - })); - } - - self.active_buffer = buffer; - } - - pub fn active_buffer(&self) -> Option<&Model> { - self.active_buffer.as_ref() - } - - pub fn active_buffer_enabled(&self) -> bool { - self.active_buffer_enabled - } - - pub fn set_active_buffer_enabled(&mut self, enabled: bool) { - self.active_buffer_enabled = enabled; - } - - /// Provide a message for the language model based on the active buffer. - pub fn message(&self, cx: &AppContext) -> Option { - if !self.active_buffer_enabled { - return None; - } - - let active_buffer = self.active_buffer.as_ref()?; - let buffer = active_buffer.read(cx); - - if let Some(singleton) = buffer.as_singleton() { - let singleton = singleton.read(cx); - - let filename = singleton - .file() - .map(|file| file.path().to_string_lossy()) - .unwrap_or("Untitled".into()); - - let text = singleton.text(); - - let language = singleton - .language() - .map(|l| { - let name = l.code_fence_block_name(); - name.to_string() - }) - .unwrap_or_default(); - - let markdown = - format!("User's active file `{filename}`:\n\n```{language}\n{text}```\n\n"); - - return Some(LanguageModelRequestMessage { - role: Role::System, - content: markdown, - }); - } - - None - } -} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 17236c07c6..dfa49da230 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -200,6 +200,18 @@ impl FocusHandle { pub fn contains(&self, other: &Self, cx: &WindowContext) -> bool { self.id.contains(other.id, cx) } + + /// Dispatch an action on the element that rendered this focus handle + pub fn dispatch_action(&self, action: &dyn Action, cx: &mut WindowContext) { + if let Some(node_id) = cx + .window + .rendered_frame + .dispatch_tree + .focusable_node_id(self.id) + { + cx.dispatch_action_on_node(node_id, action) + } + } } impl Clone for FocusHandle { diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 651d2ba284..232c0b37c9 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -189,8 +189,8 @@ impl TabSwitcherDelegate { let pane = pane.read(cx); let mut history_indices = HashMap::default(); pane.activation_history().iter().rev().enumerate().for_each( - |(history_index, entity_id)| { - history_indices.insert(entity_id, history_index); + |(history_index, history_entry)| { + history_indices.insert(history_entry.entity_id, history_index); }, ); diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 77710a2309..75eae2a4f0 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -107,6 +107,7 @@ pub enum IconName { CopilotError, CopilotInit, Copy, + CountdownTimer, Dash, Delete, Disconnected, @@ -221,6 +222,7 @@ impl IconName { IconName::CopilotError => "icons/copilot_error.svg", IconName::CopilotInit => "icons/copilot_init.svg", IconName::Copy => "icons/copy.svg", + IconName::CountdownTimer => "icons/countdown_timer.svg", IconName::Dash => "icons/dash.svg", IconName::Delete => "icons/delete.svg", IconName::Disconnected => "icons/disconnected.svg", diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index aae840c982..b875dfdacf 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -191,7 +191,8 @@ pub struct Pane { ), focus_handle: FocusHandle, items: Vec>, - activation_history: Vec, + activation_history: Vec, + next_activation_timestamp: Arc, zoomed: bool, was_focused: bool, active_item_index: usize, @@ -219,6 +220,11 @@ pub struct Pane { double_click_dispatch_action: Box, } +pub struct ActivationHistoryEntry { + pub entity_id: EntityId, + pub timestamp: usize, +} + pub struct ItemNavHistory { history: NavHistory, item: Arc, @@ -296,6 +302,7 @@ impl Pane { focus_handle, items: Vec::new(), activation_history: Vec::new(), + next_activation_timestamp: next_timestamp.clone(), was_focused: false, zoomed: false, active_item_index: 0, @@ -506,7 +513,7 @@ impl Pane { self.active_item_index } - pub fn activation_history(&self) -> &Vec { + pub fn activation_history(&self) -> &[ActivationHistoryEntry] { &self.activation_history } @@ -892,10 +899,13 @@ impl Pane { if let Some(newly_active_item) = self.items.get(index) { self.activation_history - .retain(|&previously_active_item_id| { - previously_active_item_id != newly_active_item.item_id() - }); - self.activation_history.push(newly_active_item.item_id()); + .retain(|entry| entry.entity_id != newly_active_item.item_id()); + self.activation_history.push(ActivationHistoryEntry { + entity_id: newly_active_item.item_id(), + timestamp: self + .next_activation_timestamp + .fetch_add(1, Ordering::SeqCst), + }); } self.update_toolbar(cx); @@ -1211,7 +1221,7 @@ impl Pane { cx: &mut ViewContext, ) { self.activation_history - .retain(|&history_entry| history_entry != self.items[item_index].item_id()); + .retain(|entry| entry.entity_id != self.items[item_index].item_id()); if item_index == self.active_item_index { let index_to_activate = self @@ -1219,7 +1229,7 @@ impl Pane { .pop() .and_then(|last_activated_item| { self.items.iter().enumerate().find_map(|(index, item)| { - (item.item_id() == last_activated_item).then_some(index) + (item.item_id() == last_activated_item.entity_id).then_some(index) }) }) // We didn't have a valid activation history entry, so fallback diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 430a63cde1..863ba42db8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -532,6 +532,9 @@ impl DelayedDebouncedEditAction { pub enum Event { PaneAdded(View), + PaneRemoved, + ItemAdded, + ItemRemoved, ActiveItemChanged, ContactRequestedJoin(u64), WorkspaceCreated(WeakView), @@ -2513,7 +2516,10 @@ impl Workspace { cx: &mut ViewContext, ) { match event { - pane::Event::AddItem { item } => item.added_to_pane(self, pane, cx), + pane::Event::AddItem { item } => { + item.added_to_pane(self, pane, cx); + cx.emit(Event::ItemAdded); + } pane::Event::Split(direction) => { self.split_and_clone(pane, *direction, cx); } @@ -2696,6 +2702,7 @@ impl Workspace { } else { self.active_item_path_changed(cx); } + cx.emit(Event::PaneRemoved); } pub fn panes(&self) -> &[View] {