diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 1fa9c3cc4ffadfc2b7437a562ba0e175db1e6f78..14ac7411146c3256283307f2245517ca9dc6d21a 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,13 +1,15 @@ use crate::{OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role}; use anyhow::{anyhow, Result}; -use editor::{Editor, MultiBuffer}; +use collections::HashMap; +use editor::{Editor, ExcerptId, ExcerptRange, MultiBuffer}; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ actions, elements::*, executor::Background, Action, AppContext, AsyncAppContext, Entity, - ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + WindowContext, }; use isahc::{http::StatusCode, Request, RequestExt}; -use language::{language_settings::SoftWrap, Anchor, Buffer, Language, LanguageRegistry}; +use language::{language_settings::SoftWrap, Buffer, Language, LanguageRegistry}; use std::{io, sync::Arc}; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ @@ -19,8 +21,8 @@ use workspace::{ actions!(assistant, [NewContext, Assist, CancelLastAssist]); pub fn init(cx: &mut AppContext) { - cx.add_action(Assistant::assist); - cx.capture_action(Assistant::cancel_last_assist); + cx.add_action(AssistantEditor::assist); + cx.capture_action(AssistantEditor::cancel_last_assist); } pub enum AssistantPanelEvent { @@ -188,7 +190,7 @@ impl Panel for AssistantPanel { .await?; workspace.update(&mut cx, |workspace, cx| { let editor = Box::new(cx.add_view(|cx| { - Assistant::new(markdown, workspace.app_state().languages.clone(), cx) + AssistantEditor::new(markdown, workspace.app_state().languages.clone(), cx) })); Pane::add_item(workspace, &pane, editor, true, focus, None, cx); })?; @@ -230,38 +232,31 @@ impl Panel for AssistantPanel { } struct Assistant { + buffer: ModelHandle, messages: Vec, - editor: ViewHandle, + messages_by_id: HashMap, completion_count: usize, pending_completions: Vec, markdown: Arc, language_registry: Arc, } -struct PendingCompletion { - id: usize, - _task: Task>, +impl Entity for Assistant { + type Event = (); } impl Assistant { fn new( markdown: Arc, language_registry: Arc, - cx: &mut ViewContext, + cx: &mut ModelContext, ) -> Self { - let editor = cx.add_view(|cx| { - let multibuffer = cx.add_model(|_| MultiBuffer::new(0)); - let mut editor = Editor::for_multibuffer(multibuffer, None, cx); - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); - editor - }); - let mut this = Self { + buffer: cx.add_model(|_| MultiBuffer::new(0)), messages: Default::default(), - editor, - completion_count: 0, - pending_completions: Vec::new(), + messages_by_id: Default::default(), + completion_count: Default::default(), + pending_completions: Default::default(), markdown, language_registry, }; @@ -269,7 +264,7 @@ impl Assistant { this } - fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { + fn assist(&mut self, cx: &mut ModelContext) { let messages = self .messages .iter() @@ -285,8 +280,8 @@ impl Assistant { }; if let Some(api_key) = std::env::var("OPENAI_API_KEY").log_err() { - let stream = stream_completion(api_key, cx.background_executor().clone(), request); - let response_buffer = self.push_message(Role::Assistant, cx); + let stream = stream_completion(api_key, cx.background().clone(), request); + let response = self.push_message(Role::Assistant, cx); self.push_message(Role::User, cx); let task = cx.spawn(|this, mut cx| { async move { @@ -295,7 +290,7 @@ impl Assistant { while let Some(message) = messages.next().await { let mut message = message?; if let Some(choice) = message.choices.pop() { - response_buffer.update(&mut cx, |content, cx| { + response.content.update(&mut cx, |content, cx| { let text: Arc = choice.delta.content?.into(); content.edit([(content.len()..content.len(), text)], None, cx); Some(()) @@ -306,8 +301,7 @@ impl Assistant { this.update(&mut cx, |this, _| { this.pending_completions .retain(|completion| completion.id != this.completion_count); - }) - .ok(); + }); anyhow::Ok(()) } @@ -321,45 +315,123 @@ impl Assistant { } } - fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { - if self.pending_completions.pop().is_none() { - cx.propagate_action(); - } + fn cancel_last_assist(&mut self) -> bool { + self.pending_completions.pop().is_some() } - fn push_message(&mut self, role: Role, cx: &mut ViewContext) -> ModelHandle { + fn push_message(&mut self, role: Role, cx: &mut ModelContext) -> Message { let content = cx.add_model(|cx| { let mut buffer = Buffer::new(0, "", cx); buffer.set_language(Some(self.markdown.clone()), cx); buffer.set_language_registry(self.language_registry.clone()); buffer }); + let excerpt_id = self.buffer.update(cx, |buffer, cx| { + buffer + .push_excerpts( + content.clone(), + vec![ExcerptRange { + context: 0..0, + primary: None, + }], + cx, + ) + .pop() + .unwrap() + }); + let message = Message { role, content: content.clone(), }; - self.messages.push(message); + self.messages.push(message.clone()); + self.messages_by_id.insert(excerpt_id, message.clone()); + message + } +} - self.editor.update(cx, |editor, cx| { - editor.buffer().update(cx, |buffer, cx| { - buffer.push_excerpts_with_context_lines( - content.clone(), - vec![Anchor::MIN..Anchor::MAX], - 0, - cx, - ) - }); +struct PendingCompletion { + id: usize, + _task: Task>, +} + +struct AssistantEditor { + assistant: ModelHandle, + editor: ViewHandle, +} + +impl AssistantEditor { + fn new( + markdown: Arc, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let assistant = cx.add_model(|cx| Assistant::new(markdown, language_registry, cx)); + let editor = cx.add_view(|cx| { + let mut editor = Editor::for_multibuffer(assistant.read(cx).buffer.clone(), None, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); + editor.set_render_excerpt_header( + { + let assistant = assistant.clone(); + move |editor, params: editor::RenderExcerptHeaderParams, cx| { + let style = &theme::current(cx).assistant; + if let Some(message) = assistant.read(cx).messages_by_id.get(¶ms.id) { + let sender = match message.role { + Role::User => Label::new("You", style.user_sender.text.clone()) + .contained() + .with_style(style.user_sender.container), + Role::Assistant => { + Label::new("Assistant", style.assistant_sender.text.clone()) + .contained() + .with_style(style.assistant_sender.container) + } + Role::System => { + Label::new("System", style.assistant_sender.text.clone()) + .contained() + .with_style(style.assistant_sender.container) + } + }; + + Flex::row() + .with_child(sender) + .aligned() + .left() + .contained() + .with_style(style.header) + .into_any() + } else { + Empty::new().into_any() + } + } + }, + cx, + ); + editor }); + Self { assistant, editor } + } - content + fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { + self.assistant + .update(cx, |assistant, cx| assistant.assist(cx)); + } + + fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + if !self + .assistant + .update(cx, |assistant, _| assistant.cancel_last_assist()) + { + cx.propagate_action(); + } } } -impl Entity for Assistant { +impl Entity for AssistantEditor { type Event = (); } -impl View for Assistant { +impl View for AssistantEditor { fn ui_name() -> &'static str { "ContextEditor" } @@ -374,7 +446,7 @@ impl View for Assistant { } } -impl Item for Assistant { +impl Item for AssistantEditor { fn tab_content( &self, _: Option, @@ -385,6 +457,7 @@ impl Item for Assistant { } } +#[derive(Clone)] struct Message { role: Role, content: ModelHandle, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 31df5069db39f2c769f7d96b88958b4d9f606c2d..a1e55dc03630d3d4136bc1ded66e3fe10ef5c0e9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -46,7 +46,8 @@ use gpui::{ platform::{CursorStyle, MouseButton}, serde_json::{self, json}, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, - ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + LayoutContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -498,6 +499,7 @@ pub struct Editor { mode: EditorMode, show_gutter: bool, placeholder_text: Option>, + render_excerpt_header: Option, highlighted_rows: Option>, #[allow(clippy::type_complexity)] background_highlights: BTreeMap Color, Vec>)>, @@ -1301,6 +1303,7 @@ impl Editor { mode, show_gutter: mode == EditorMode::Full, placeholder_text: None, + render_excerpt_header: None, highlighted_rows: None, background_highlights: Default::default(), nav_history: None, @@ -6663,6 +6666,20 @@ impl Editor { cx.notify(); } + pub fn set_render_excerpt_header( + &mut self, + render_excerpt_header: impl 'static + + Fn( + &mut Editor, + RenderExcerptHeaderParams, + &mut LayoutContext, + ) -> AnyElement, + cx: &mut ViewContext, + ) { + self.render_excerpt_header = Some(Arc::new(render_excerpt_header)); + cx.notify(); + } + pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext) { if let Some(buffer) = self.buffer().read(cx).as_singleton() { if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) { @@ -7308,8 +7325,12 @@ impl View for Editor { }); } + let mut editor = EditorElement::new(style.clone()); + if let Some(render_excerpt_header) = self.render_excerpt_header.clone() { + editor = editor.with_render_excerpt_header(render_excerpt_header); + } Stack::new() - .with_child(EditorElement::new(style.clone())) + .with_child(editor) .with_child(ChildView::new(&self.mouse_context_menu, cx)) .into_any() } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5b4da4407360b1a2af410b411bc910385307401e..a028c7ca1c985aa6ecc1ff4e24430e23a8ffa3b7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -91,18 +91,41 @@ impl SelectionLayout { } } -#[derive(Clone)] +pub struct RenderExcerptHeaderParams<'a> { + pub id: crate::ExcerptId, + pub buffer: &'a language::BufferSnapshot, + pub range: &'a crate::ExcerptRange, + pub starts_new_buffer: bool, + pub gutter_padding: f32, + pub editor_style: &'a EditorStyle, +} + +pub type RenderExcerptHeader = Arc< + dyn Fn( + &mut Editor, + RenderExcerptHeaderParams, + &mut LayoutContext, + ) -> AnyElement, +>; + pub struct EditorElement { style: Arc, + render_excerpt_header: RenderExcerptHeader, } impl EditorElement { pub fn new(style: EditorStyle) -> Self { Self { style: Arc::new(style), + render_excerpt_header: Arc::new(render_excerpt_header), } } + pub fn with_render_excerpt_header(mut self, render: RenderExcerptHeader) -> Self { + self.render_excerpt_header = render; + self + } + fn attach_mouse_handlers( scene: &mut SceneBuilder, position_map: &Arc, @@ -1465,11 +1488,9 @@ impl EditorElement { line_height: f32, style: &EditorStyle, line_layouts: &[LineWithInvisibles], - include_root: bool, editor: &mut Editor, cx: &mut LayoutContext, ) -> (f32, Vec) { - let tooltip_style = theme::current(cx).tooltip.clone(); let scroll_x = snapshot.scroll_anchor.offset.x(); let (fixed_blocks, non_fixed_blocks) = snapshot .blocks_in_range(rows.clone()) @@ -1510,112 +1531,18 @@ impl EditorElement { range, starts_new_buffer, .. - } => { - let id = *id; - let jump_icon = project::File::from_dyn(buffer.file()).map(|file| { - let jump_path = ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }; - let jump_anchor = range - .primary - .as_ref() - .map_or(range.context.start, |primary| primary.start); - let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); - - enum JumpIcon {} - MouseEventHandler::::new(id.into(), cx, |state, _| { - let style = style.jump_icon.style_for(state, false); - Svg::new("icons/arrow_up_right_8.svg") - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .contained() - .with_style(style.container) - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, editor, cx| { - if let Some(workspace) = editor - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade(cx)) - { - workspace.update(cx, |workspace, cx| { - Editor::jump( - workspace, - jump_path.clone(), - jump_position, - jump_anchor, - cx, - ); - }); - } - }) - .with_tooltip::( - id.into(), - "Jump to Buffer".to_string(), - Some(Box::new(crate::OpenExcerpts)), - tooltip_style.clone(), - cx, - ) - .aligned() - .flex_float() - }); - - if *starts_new_buffer { - let style = &self.style.diagnostic_path_header; - let font_size = - (style.text_scale_factor * self.style.text.font_size).round(); - - let path = buffer.resolve_file_path(cx, include_root); - let mut filename = None; - let mut parent_path = None; - // Can't use .and_then() because `.file_name()` and `.parent()` return references :( - if let Some(path) = path { - filename = path.file_name().map(|f| f.to_string_lossy().to_string()); - parent_path = - path.parent().map(|p| p.to_string_lossy().to_string() + "/"); - } - - Flex::row() - .with_child( - Label::new( - filename.unwrap_or_else(|| "untitled".to_string()), - style.filename.text.clone().with_font_size(font_size), - ) - .contained() - .with_style(style.filename.container) - .aligned(), - ) - .with_children(parent_path.map(|path| { - Label::new(path, style.path.text.clone().with_font_size(font_size)) - .contained() - .with_style(style.path.container) - .aligned() - })) - .with_children(jump_icon) - .contained() - .with_style(style.container) - .with_padding_left(gutter_padding) - .with_padding_right(gutter_padding) - .expanded() - .into_any_named("path header block") - } else { - let text_style = self.style.text.clone(); - Flex::row() - .with_child(Label::new("⋯", text_style)) - .with_children(jump_icon) - .contained() - .with_padding_left(gutter_padding) - .with_padding_right(gutter_padding) - .expanded() - .into_any_named("collapsed context") - } - } + } => (self.render_excerpt_header)( + editor, + RenderExcerptHeaderParams { + id: *id, + buffer, + range, + starts_new_buffer: *starts_new_buffer, + gutter_padding, + editor_style: style, + }, + cx, + ), }; element.layout( @@ -2080,12 +2007,6 @@ impl Element for EditorElement { ShowScrollbar::Never => false, }; - let include_root = editor - .project - .as_ref() - .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) - .unwrap_or_default(); - let fold_ranges: Vec<(BufferRow, Range, Color)> = fold_ranges .into_iter() .map(|(id, fold)| { @@ -2144,7 +2065,6 @@ impl Element for EditorElement { line_height, &style, &line_layouts, - include_root, editor, cx, ); @@ -2759,6 +2679,121 @@ impl HighlightedRange { } } +fn render_excerpt_header( + editor: &mut Editor, + RenderExcerptHeaderParams { + id, + buffer, + range, + starts_new_buffer, + gutter_padding, + editor_style, + }: RenderExcerptHeaderParams, + cx: &mut LayoutContext, +) -> AnyElement { + let tooltip_style = theme::current(cx).tooltip.clone(); + let include_root = editor + .project + .as_ref() + .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) + .unwrap_or_default(); + let jump_icon = project::File::from_dyn(buffer.file()).map(|file| { + let jump_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }; + let jump_anchor = range + .primary + .as_ref() + .map_or(range.context.start, |primary| primary.start); + let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); + + enum JumpIcon {} + MouseEventHandler::::new(id.into(), cx, |state, _| { + let style = editor_style.jump_icon.style_for(state, false); + Svg::new("icons/arrow_up_right_8.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, editor, cx| { + if let Some(workspace) = editor + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade(cx)) + { + workspace.update(cx, |workspace, cx| { + Editor::jump(workspace, jump_path.clone(), jump_position, jump_anchor, cx); + }); + } + }) + .with_tooltip::( + id.into(), + "Jump to Buffer".to_string(), + Some(Box::new(crate::OpenExcerpts)), + tooltip_style.clone(), + cx, + ) + .aligned() + .flex_float() + }); + + if starts_new_buffer { + let style = &editor_style.diagnostic_path_header; + let font_size = (style.text_scale_factor * editor_style.text.font_size).round(); + + let path = buffer.resolve_file_path(cx, include_root); + let mut filename = None; + let mut parent_path = None; + // Can't use .and_then() because `.file_name()` and `.parent()` return references :( + if let Some(path) = path { + filename = path.file_name().map(|f| f.to_string_lossy().to_string()); + parent_path = path.parent().map(|p| p.to_string_lossy().to_string() + "/"); + } + + Flex::row() + .with_child( + Label::new( + filename.unwrap_or_else(|| "untitled".to_string()), + style.filename.text.clone().with_font_size(font_size), + ) + .contained() + .with_style(style.filename.container) + .aligned(), + ) + .with_children(parent_path.map(|path| { + Label::new(path, style.path.text.clone().with_font_size(font_size)) + .contained() + .with_style(style.path.container) + .aligned() + })) + .with_children(jump_icon) + .contained() + .with_style(style.container) + .with_padding_left(gutter_padding) + .with_padding_right(gutter_padding) + .expanded() + .into_any_named("path header block") + } else { + let text_style = editor_style.text.clone(); + Flex::row() + .with_child(Label::new("⋯", text_style)) + .with_children(jump_icon) + .contained() + .with_padding_left(gutter_padding) + .with_padding_right(gutter_padding) + .expanded() + .into_any_named("collapsed context") + } +} + fn position_to_display_point( position: Vector2F, text_bounds: RectF, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 010d6956eb47ac57b8a9d08b876715f86a1d61b8..0056230766800aa216cd164461471a42bf4772a7 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -971,6 +971,9 @@ pub struct TerminalStyle { #[derive(Clone, Deserialize, Default)] pub struct AssistantStyle { pub container: ContainerStyle, + pub header: ContainerStyle, + pub user_sender: ContainedText, + pub assistant_sender: ContainedText, } #[derive(Clone, Deserialize, Default)] diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index 810831285e0ec38f248ae01b3b00ba10c4ca669c..ea02c3b383976747010e2a31b32a3ba43833111d 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -1,13 +1,23 @@ import { ColorScheme } from "../themes/common/colorScheme" +import { text, border } from "./components" import editor from "./editor" export default function assistant(colorScheme: ColorScheme) { + const layer = colorScheme.highest; return { container: { background: editor(colorScheme).background, - padding: { - left: 10, - } + padding: { left: 12 } + }, + header: { + border: border(layer, "default", { bottom: true, top: true }), + margin: { bottom: 6, top: 6 } + }, + user_sender: { + ...text(layer, "sans", "default", { size: "sm", weight: "bold" }), + }, + assistant_sender: { + ...text(layer, "sans", "accent", { size: "sm", weight: "bold" }), } } }