From 1c09b69384e3d4dde4d681fa8bd607d849980c8a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 29 Apr 2024 14:26:19 -0700 Subject: [PATCH] Pin message composer to the bottom of the new assistant panel (#11186) Release Notes: - N/A --------- Co-authored-by: Marshall Co-authored-by: Nate Co-authored-by: Kyle Co-authored-by: Marshall Bowers --- crates/assistant2/Cargo.toml | 5 - .../assistant2/examples/assistant_example.rs | 127 ---------- .../examples/chat_with_functions.rs | 141 ++++++++++- .../assistant2/examples/file_interactions.rs | 221 ----------------- crates/assistant2/src/assistant2.rs | 188 ++++++--------- crates/assistant2/src/ui.rs | 3 + crates/assistant2/src/ui/composer.rs | 222 ++++++++++++++++++ 7 files changed, 430 insertions(+), 477 deletions(-) delete mode 100644 crates/assistant2/examples/assistant_example.rs delete mode 100644 crates/assistant2/examples/file_interactions.rs create mode 100644 crates/assistant2/src/ui.rs create mode 100644 crates/assistant2/src/ui/composer.rs diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 82b43dbaa484308e5be1825d856a3175fc62e458..f79ff8d97aa6d7737a2cd67e1e1df87fb71edd3b 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -8,11 +8,6 @@ license = "GPL-3.0-or-later" [lib] path = "src/assistant2.rs" -[[example]] -name = "assistant_example" -path = "examples/assistant_example.rs" -crate-type = ["bin"] - [dependencies] anyhow.workspace = true assistant_tooling.workspace = true diff --git a/crates/assistant2/examples/assistant_example.rs b/crates/assistant2/examples/assistant_example.rs deleted file mode 100644 index 6dbbb27148f5afa1d74102f0352bbf9cd5e2369e..0000000000000000000000000000000000000000 --- a/crates/assistant2/examples/assistant_example.rs +++ /dev/null @@ -1,127 +0,0 @@ -use anyhow::Context as _; -use assets::Assets; -use assistant2::{tools::ProjectIndexTool, AssistantPanel}; -use assistant_tooling::ToolRegistry; -use client::Client; -use gpui::{actions, App, AppContext, KeyBinding, Task, View, WindowOptions}; -use language::LanguageRegistry; -use project::Project; -use semantic_index::{OpenAiEmbeddingModel, OpenAiEmbeddingProvider, SemanticIndex}; -use settings::{KeymapFile, DEFAULT_KEYMAP_PATH}; -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; -use theme::LoadThemes; -use ui::{div, prelude::*, Render}; -use util::{http::HttpClientWithUrl, ResultExt as _}; - -actions!(example, [Quit]); - -fn main() { - let args: Vec = std::env::args().collect(); - - env_logger::init(); - App::new().with_assets(Assets).run(|cx| { - cx.bind_keys(Some(KeyBinding::new("cmd-q", Quit, None))); - cx.on_action(|_: &Quit, cx: &mut AppContext| { - cx.quit(); - }); - - if args.len() < 2 { - eprintln!( - "Usage: cargo run --example assistant_example -p assistant2 -- " - ); - cx.quit(); - return; - } - - settings::init(cx); - language::init(cx); - Project::init_settings(cx); - editor::init(cx); - theme::init(LoadThemes::JustBase, cx); - Assets.load_fonts(cx).unwrap(); - KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, cx).unwrap(); - client::init_settings(cx); - release_channel::init("0.130.0", cx); - - let client = Client::production(cx); - { - let client = client.clone(); - cx.spawn(|cx| async move { client.authenticate_and_connect(false, &cx).await }) - .detach_and_log_err(cx); - } - assistant2::init(client.clone(), cx); - - let language_registry = Arc::new(LanguageRegistry::new( - Task::ready(()), - cx.background_executor().clone(), - )); - let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client()); - languages::init(language_registry.clone(), node_runtime, cx); - - let http = Arc::new(HttpClientWithUrl::new("http://localhost:11434")); - - let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY not set"); - let embedding_provider = OpenAiEmbeddingProvider::new( - http.clone(), - OpenAiEmbeddingModel::TextEmbedding3Small, - open_ai::OPEN_AI_API_URL.to_string(), - api_key, - ); - - cx.spawn(|mut cx| async move { - let mut semantic_index = SemanticIndex::new( - PathBuf::from("/tmp/semantic-index-db.mdb"), - Arc::new(embedding_provider), - &mut cx, - ) - .await?; - - let project_path = Path::new(&args[1]); - let project = Project::example([project_path], &mut cx).await; - - cx.update(|cx| { - let fs = project.read(cx).fs().clone(); - - let project_index = semantic_index.project_index(project.clone(), cx); - - cx.open_window(WindowOptions::default(), |cx| { - let mut tool_registry = ToolRegistry::new(); - tool_registry - .register(ProjectIndexTool::new(project_index.clone(), fs.clone()), cx) - .context("failed to register ProjectIndexTool") - .log_err(); - - cx.new_view(|cx| Example::new(language_registry, Arc::new(tool_registry), cx)) - }); - cx.activate(true); - }) - }) - .detach_and_log_err(cx); - }) -} - -struct Example { - assistant_panel: View, -} - -impl Example { - fn new( - language_registry: Arc, - tool_registry: Arc, - cx: &mut ViewContext, - ) -> Self { - Self { - assistant_panel: cx - .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)), - } - } -} - -impl Render for Example { - fn render(&mut self, _cx: &mut ViewContext) -> impl ui::prelude::IntoElement { - div().size_full().child(self.assistant_panel.clone()) - } -} diff --git a/crates/assistant2/examples/chat_with_functions.rs b/crates/assistant2/examples/chat_with_functions.rs index 35dacacb78bbe7ae89188e290fc1b9152b2aabed..58207741bf218ac5e650a70f61ec86ee8f0ee253 100644 --- a/crates/assistant2/examples/chat_with_functions.rs +++ b/crates/assistant2/examples/chat_with_functions.rs @@ -4,15 +4,17 @@ use anyhow::{Context as _, Result}; use assets::Assets; use assistant2::AssistantPanel; use assistant_tooling::{LanguageModelTool, ToolRegistry}; -use client::Client; -use gpui::{actions, AnyElement, App, AppContext, KeyBinding, Task, View, WindowOptions}; +use client::{Client, UserStore}; +use fs::Fs; +use futures::StreamExt as _; +use gpui::{actions, AnyElement, App, AppContext, KeyBinding, Model, Task, View, WindowOptions}; use language::LanguageRegistry; use project::Project; use rand::Rng; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{KeymapFile, DEFAULT_KEYMAP_PATH}; -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use theme::LoadThemes; use ui::{div, prelude::*, Render}; use util::ResultExt as _; @@ -159,6 +161,121 @@ impl LanguageModelTool for RollDiceTool { } } +struct FileBrowserTool { + fs: Arc, + root_dir: PathBuf, +} + +impl FileBrowserTool { + fn new(fs: Arc, root_dir: PathBuf) -> Self { + Self { fs, root_dir } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct FileBrowserParams { + command: FileBrowserCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +enum FileBrowserCommand { + Ls { path: PathBuf }, + Cat { path: PathBuf }, +} + +#[derive(Serialize, Deserialize)] +enum FileBrowserOutput { + Ls { entries: Vec }, + Cat { content: String }, +} + +pub struct FileBrowserView { + result: Result, +} + +impl Render for FileBrowserView { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let Ok(output) = self.result.as_ref() else { + return h_flex().child("Failed to perform operation"); + }; + + match output { + FileBrowserOutput::Ls { entries } => v_flex().children( + entries + .into_iter() + .map(|entry| h_flex().text_ui(cx).child(entry.clone())), + ), + FileBrowserOutput::Cat { content } => h_flex().child(content.clone()), + } + } +} + +impl LanguageModelTool for FileBrowserTool { + type Input = FileBrowserParams; + type Output = FileBrowserOutput; + type View = FileBrowserView; + + fn name(&self) -> String { + "file_browser".to_string() + } + + fn description(&self) -> String { + "A tool for browsing the filesystem.".to_string() + } + + fn execute(&self, input: &Self::Input, cx: &AppContext) -> Task> { + cx.spawn({ + let fs = self.fs.clone(); + let root_dir = self.root_dir.clone(); + let input = input.clone(); + |_cx| async move { + match input.command { + FileBrowserCommand::Ls { path } => { + let path = root_dir.join(path); + + let mut output = fs.read_dir(&path).await?; + + let mut entries = Vec::new(); + while let Some(entry) = output.next().await { + let entry = entry?; + entries.push(entry.display().to_string()); + } + + Ok(FileBrowserOutput::Ls { entries }) + } + FileBrowserCommand::Cat { path } => { + let path = root_dir.join(path); + + let output = fs.load(&path).await?; + + Ok(FileBrowserOutput::Cat { content: output }) + } + } + } + }) + } + + fn output_view( + _tool_call_id: String, + _input: Self::Input, + result: Result, + cx: &mut WindowContext, + ) -> gpui::View { + cx.new_view(|_cx| FileBrowserView { result }) + } + + fn format(_input: &Self::Input, output: &Result) -> String { + let Ok(output) = output else { + return "Failed to perform command: {input:?}".to_string(); + }; + + match output { + FileBrowserOutput::Ls { entries } => entries.join("\n"), + FileBrowserOutput::Cat { content } => content.to_owned(), + } + } +} + fn main() { env_logger::init(); App::new().with_assets(Assets).run(|cx| { @@ -189,11 +306,16 @@ fn main() { Task::ready(()), cx.background_executor().clone(), )); + + let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx)); let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client()); languages::init(language_registry.clone(), node_runtime, cx); cx.spawn(|cx| async move { cx.update(|cx| { + let fs = Arc::new(fs::RealFs::new(None)); + let cwd = std::env::current_dir().expect("Failed to get current working directory"); + cx.open_window(WindowOptions::default(), |cx| { let mut tool_registry = ToolRegistry::new(); tool_registry @@ -201,6 +323,11 @@ fn main() { .context("failed to register DummyTool") .log_err(); + tool_registry + .register(FileBrowserTool::new(fs, cwd), cx) + .context("failed to register FileBrowserTool") + .log_err(); + let tool_registry = Arc::new(tool_registry); println!("Tools registered"); @@ -208,7 +335,7 @@ fn main() { println!("{}", definition); } - cx.new_view(|cx| Example::new(language_registry, tool_registry, cx)) + cx.new_view(|cx| Example::new(language_registry, tool_registry, user_store, cx)) }); cx.activate(true); }) @@ -225,11 +352,13 @@ impl Example { fn new( language_registry: Arc, tool_registry: Arc, + user_store: Model, cx: &mut ViewContext, ) -> Self { Self { - assistant_panel: cx - .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)), + assistant_panel: cx.new_view(|cx| { + AssistantPanel::new(language_registry, tool_registry, user_store, cx) + }), } } } diff --git a/crates/assistant2/examples/file_interactions.rs b/crates/assistant2/examples/file_interactions.rs deleted file mode 100644 index 3d397aac7e6222631b80def0fa3e68ec9049c4de..0000000000000000000000000000000000000000 --- a/crates/assistant2/examples/file_interactions.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! This example creates a basic Chat UI for interacting with the filesystem. - -use anyhow::{Context as _, Result}; -use assets::Assets; -use assistant2::AssistantPanel; -use assistant_tooling::{LanguageModelTool, ToolRegistry}; -use client::Client; -use fs::Fs; -use futures::StreamExt; -use gpui::{actions, App, AppContext, KeyBinding, Task, View, WindowOptions}; -use language::LanguageRegistry; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{KeymapFile, DEFAULT_KEYMAP_PATH}; -use std::path::PathBuf; -use std::sync::Arc; -use theme::LoadThemes; -use ui::{div, prelude::*, Render}; -use util::ResultExt as _; - -actions!(example, [Quit]); - -struct FileBrowserTool { - fs: Arc, - root_dir: PathBuf, -} - -impl FileBrowserTool { - fn new(fs: Arc, root_dir: PathBuf) -> Self { - Self { fs, root_dir } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -struct FileBrowserParams { - command: FileBrowserCommand, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -enum FileBrowserCommand { - Ls { path: PathBuf }, - Cat { path: PathBuf }, -} - -#[derive(Serialize, Deserialize)] -enum FileBrowserOutput { - Ls { entries: Vec }, - Cat { content: String }, -} - -pub struct FileBrowserView { - result: Result, -} - -impl Render for FileBrowserView { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let Ok(output) = self.result.as_ref() else { - return h_flex().child("Failed to perform operation"); - }; - - match output { - FileBrowserOutput::Ls { entries } => v_flex().children( - entries - .into_iter() - .map(|entry| h_flex().text_ui(cx).child(entry.clone())), - ), - FileBrowserOutput::Cat { content } => h_flex().child(content.clone()), - } - } -} - -impl LanguageModelTool for FileBrowserTool { - type Input = FileBrowserParams; - type Output = FileBrowserOutput; - type View = FileBrowserView; - - fn name(&self) -> String { - "file_browser".to_string() - } - - fn description(&self) -> String { - "A tool for browsing the filesystem.".to_string() - } - - fn execute(&self, input: &Self::Input, cx: &AppContext) -> Task> { - cx.spawn({ - let fs = self.fs.clone(); - let root_dir = self.root_dir.clone(); - let input = input.clone(); - |_cx| async move { - match input.command { - FileBrowserCommand::Ls { path } => { - let path = root_dir.join(path); - - let mut output = fs.read_dir(&path).await?; - - let mut entries = Vec::new(); - while let Some(entry) = output.next().await { - let entry = entry?; - entries.push(entry.display().to_string()); - } - - Ok(FileBrowserOutput::Ls { entries }) - } - FileBrowserCommand::Cat { path } => { - let path = root_dir.join(path); - - let output = fs.load(&path).await?; - - Ok(FileBrowserOutput::Cat { content: output }) - } - } - } - }) - } - - fn output_view( - _tool_call_id: String, - _input: Self::Input, - result: Result, - cx: &mut WindowContext, - ) -> gpui::View { - cx.new_view(|_cx| FileBrowserView { result }) - } - - fn format(_input: &Self::Input, output: &Result) -> String { - let Ok(output) = output else { - return "Failed to perform command: {input:?}".to_string(); - }; - - match output { - FileBrowserOutput::Ls { entries } => entries.join("\n"), - FileBrowserOutput::Cat { content } => content.to_owned(), - } - } -} - -fn main() { - env_logger::init(); - App::new().with_assets(Assets).run(|cx| { - cx.bind_keys(Some(KeyBinding::new("cmd-q", Quit, None))); - cx.on_action(|_: &Quit, cx: &mut AppContext| { - cx.quit(); - }); - - settings::init(cx); - language::init(cx); - Project::init_settings(cx); - editor::init(cx); - theme::init(LoadThemes::JustBase, cx); - Assets.load_fonts(cx).unwrap(); - KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, cx).unwrap(); - client::init_settings(cx); - release_channel::init("0.130.0", cx); - - let client = Client::production(cx); - { - let client = client.clone(); - cx.spawn(|cx| async move { client.authenticate_and_connect(false, &cx).await }) - .detach_and_log_err(cx); - } - assistant2::init(client.clone(), cx); - - let language_registry = Arc::new(LanguageRegistry::new( - Task::ready(()), - cx.background_executor().clone(), - )); - let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client()); - languages::init(language_registry.clone(), node_runtime, cx); - - cx.spawn(|cx| async move { - cx.update(|cx| { - let fs = Arc::new(fs::RealFs::new(None)); - let cwd = std::env::current_dir().expect("Failed to get current working directory"); - - cx.open_window(WindowOptions::default(), |cx| { - let mut tool_registry = ToolRegistry::new(); - tool_registry - .register(FileBrowserTool::new(fs, cwd), cx) - .context("failed to register FileBrowserTool") - .log_err(); - - let tool_registry = Arc::new(tool_registry); - - println!("Tools registered"); - for definition in tool_registry.definitions() { - println!("{}", definition); - } - - cx.new_view(|cx| Example::new(language_registry, tool_registry, cx)) - }); - cx.activate(true); - }) - }) - .detach_and_log_err(cx); - }) -} - -struct Example { - assistant_panel: View, -} - -impl Example { - fn new( - language_registry: Arc, - tool_registry: Arc, - cx: &mut ViewContext, - ) -> Self { - Self { - assistant_panel: cx - .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)), - } - } -} - -impl Render for Example { - fn render(&mut self, _cx: &mut ViewContext) -> impl ui::prelude::IntoElement { - div().size_full().child(self.assistant_panel.clone()) - } -} diff --git a/crates/assistant2/src/assistant2.rs b/crates/assistant2/src/assistant2.rs index aa0450ac2f4bb06b51a670923d086ba65693e2f7..5349a7ee863cde3421c83f931fdf1e4cf5294171 100644 --- a/crates/assistant2/src/assistant2.rs +++ b/crates/assistant2/src/assistant2.rs @@ -1,17 +1,19 @@ mod assistant_settings; mod completion_provider; pub mod tools; +mod ui; +use ::ui::{div, prelude::*, Color, ViewContext}; use anyhow::{Context, Result}; use assistant_tooling::{ToolFunctionCall, ToolRegistry}; -use client::{proto, Client}; +use client::{proto, Client, UserStore}; use completion_provider::*; use editor::Editor; use feature_flags::FeatureFlagAppExt as _; use futures::{future::join_all, StreamExt}; use gpui::{ - list, prelude::*, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, - FocusableView, ListAlignment, ListState, Render, Task, View, WeakView, + list, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, FocusableView, + ListAlignment, ListState, Model, Render, Task, View, WeakView, }; use language::{language_settings::SoftWrap, LanguageRegistry}; use open_ai::{FunctionContent, ToolCall, ToolCallContent}; @@ -22,7 +24,7 @@ use settings::Settings; use std::sync::Arc; use theme::ThemeSettings; use tools::ProjectIndexTool; -use ui::{popover_menu, prelude::*, ButtonLike, Color, ContextMenu, Tooltip}; +use ui::Composer; use util::{paths::EMBEDDINGS_DIR, ResultExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -101,6 +103,8 @@ impl AssistantPanel { (workspace.app_state().clone(), workspace.project().clone()) })?; + let user_store = app_state.user_store.clone(); + cx.new_view(|cx| { // todo!("this will panic if the semantic index failed to load or has not loaded yet") let project_index = cx.update_global(|semantic_index: &mut SemanticIndex, cx| { @@ -118,7 +122,7 @@ impl AssistantPanel { let tool_registry = Arc::new(tool_registry); - Self::new(app_state.languages.clone(), tool_registry, cx) + Self::new(app_state.languages.clone(), tool_registry, user_store, cx) }) }) } @@ -126,10 +130,16 @@ impl AssistantPanel { pub fn new( language_registry: Arc, tool_registry: Arc, + user_store: Model, cx: &mut ViewContext, ) -> Self { let chat = cx.new_view(|cx| { - AssistantChat::new(language_registry.clone(), tool_registry.clone(), cx) + AssistantChat::new( + language_registry.clone(), + tool_registry.clone(), + user_store, + cx, + ) }); Self { width: None, chat } @@ -174,7 +184,7 @@ impl Panel for AssistantPanel { cx.notify(); } - fn icon(&self, _cx: &WindowContext) -> Option { + fn icon(&self, _cx: &WindowContext) -> Option<::ui::IconName> { Some(IconName::Ai) } @@ -191,13 +201,7 @@ impl EventEmitter for AssistantPanel {} impl FocusableView for AssistantPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.chat - .read(cx) - .messages - .iter() - .rev() - .find_map(|msg| msg.focus_handle(cx)) - .expect("no user message in chat") + self.chat.read(cx).composer_editor.read(cx).focus_handle(cx) } } @@ -206,6 +210,8 @@ struct AssistantChat { messages: Vec, list_state: ListState, language_registry: Arc, + composer_editor: View, + user_store: Model, next_message_id: MessageId, pending_completion: Option>, tool_registry: Arc, @@ -215,6 +221,7 @@ impl AssistantChat { fn new( language_registry: Arc, tool_registry: Arc, + user_store: Model, cx: &mut ViewContext, ) -> Self { let model = CompletionProvider::get(cx).default_model(); @@ -229,17 +236,22 @@ impl AssistantChat { }, ); - let mut this = Self { + Self { model, messages: Vec::new(), + composer_editor: cx.new_view(|cx| { + let mut editor = Editor::auto_height(80, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_placeholder_text("Type a message to the assistant", cx); + editor + }), list_state, + user_store, language_registry, next_message_id: MessageId(0), pending_completion: None, tool_registry, - }; - this.push_new_user_message(true, cx); - this + } } fn focused_message_id(&self, cx: &WindowContext) -> Option { @@ -262,19 +274,37 @@ impl AssistantChat { if let Some(ChatMessage::Assistant(message)) = self.messages.last() { if message.body.text.is_empty() { self.pop_message(cx); - } else { - self.push_new_user_message(false, cx); } } } fn submit(&mut self, Submit(mode): &Submit, cx: &mut ViewContext) { - let Some(focused_message_id) = self.focused_message_id(cx) else { - log::error!("unexpected state: no user message editor is focused."); + // Don't allow multiple concurrent completions. + if self.pending_completion.is_some() { + cx.propagate(); return; - }; + } - self.truncate_messages(focused_message_id, cx); + if let Some(focused_message_id) = self.focused_message_id(cx) { + self.truncate_messages(focused_message_id, cx); + } else if self.composer_editor.focus_handle(cx).is_focused(cx) { + let message = self.composer_editor.update(cx, |composer_editor, cx| { + let text = composer_editor.text(cx); + let id = self.next_message_id.post_inc(); + let body = cx.new_view(|cx| { + let mut editor = Editor::auto_height(80, cx); + editor.set_text(text, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor + }); + composer_editor.clear(cx); + ChatMessage::User(UserMessage { id, body }) + }); + self.push_message(message, cx); + } else { + log::error!("unexpected state: no user message editor is focused."); + return; + } let mode = *mode; self.pending_completion = Some(cx.spawn(move |this, mut cx| async move { @@ -288,12 +318,8 @@ impl AssistantChat { .log_err(); this.update(&mut cx, |this, cx| { - let focus = this - .user_message(focused_message_id) - .body - .focus_handle(cx) - .contains_focused(cx); - this.push_new_user_message(focus, cx); + let composer_focus_handle = this.composer_editor.focus_handle(cx); + cx.focus(&composer_focus_handle); this.pending_completion = None; }) .context("Failed to push new user message") @@ -301,6 +327,10 @@ impl AssistantChat { })); } + fn can_submit(&self) -> bool { + self.pending_completion.is_none() + } + async fn request_completion( this: WeakView, mode: SubmitMode, @@ -424,32 +454,6 @@ impl AssistantChat { } } - fn user_message(&mut self, message_id: MessageId) -> &mut UserMessage { - self.messages - .iter_mut() - .find_map(|message| match message { - ChatMessage::User(user_message) if user_message.id == message_id => { - Some(user_message) - } - _ => None, - }) - .expect("User message not found") - } - - fn push_new_user_message(&mut self, focus: bool, cx: &mut ViewContext) { - let id = self.next_message_id.post_inc(); - let body = cx.new_view(|cx| { - let mut editor = Editor::auto_height(80, cx); - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - if focus { - cx.focus_self(); - } - editor - }); - let message = ChatMessage::User(UserMessage { id, body }); - self.push_message(message, cx); - } - fn push_new_assistant_message(&mut self, cx: &mut ViewContext) { let message = ChatMessage::Assistant(AssistantMessage { id: self.next_message_id.post_inc(), @@ -525,7 +529,6 @@ impl AssistantChat { .child(div().p_2().child(Label::new("You").color(Color::Default))) .child( div() - .on_action(cx.listener(Self::submit)) .p_2() .text_color(cx.theme().colors().editor_foreground) .font(ThemeSettings::get_global(cx).buffer_font.clone()) @@ -637,59 +640,6 @@ impl AssistantChat { completion_messages } - - fn render_model_dropdown(&self, cx: &mut ViewContext) -> impl IntoElement { - let this = cx.view().downgrade(); - div().h_flex().justify_end().child( - div().w_32().child( - popover_menu("user-menu") - .menu(move |cx| { - ContextMenu::build(cx, |mut menu, cx| { - for model in CompletionProvider::get(cx).available_models() { - menu = menu.custom_entry( - { - let model = model.clone(); - move |_| Label::new(model.clone()).into_any_element() - }, - { - let this = this.clone(); - move |cx| { - _ = this.update(cx, |this, cx| { - this.model = model.clone(); - cx.notify(); - }); - } - }, - ); - } - menu - }) - .into() - }) - .trigger( - ButtonLike::new("active-model") - .child( - h_flex() - .w_full() - .gap_0p5() - .child( - div() - .overflow_x_hidden() - .flex_grow() - .whitespace_nowrap() - .child(Label::new(self.model.clone())), - ) - .child(div().child( - Icon::new(IconName::ChevronDown).color(Color::Muted), - )), - ) - .style(ButtonStyle::Subtle) - .tooltip(move |cx| Tooltip::text("Change Model", cx)), - ) - .anchor(gpui::AnchorCorner::TopRight), - ), - ) - } } impl Render for AssistantChat { @@ -699,20 +649,22 @@ impl Render for AssistantChat { .flex_1() .v_flex() .key_context("AssistantChat") + .on_action(cx.listener(Self::submit)) .on_action(cx.listener(Self::cancel)) .text_color(Color::Default.color(cx)) - .child(self.render_model_dropdown(cx)) .child(list(self.list_state.clone()).flex_1()) - .child( - h_flex() - .mt_2() - .gap_2() - .children(self.tool_registry.status_views().iter().cloned()), - ) + .child(Composer::new( + cx.view().downgrade(), + self.model.clone(), + self.composer_editor.clone(), + self.user_store.read(cx).current_user(), + self.can_submit(), + self.tool_registry.clone(), + )) } } -#[derive(Copy, Clone, Eq, PartialEq)] +#[derive(Debug, Copy, Clone, Eq, PartialEq)] struct MessageId(usize); impl MessageId { diff --git a/crates/assistant2/src/ui.rs b/crates/assistant2/src/ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..5520a289cb8bdde4038e751da573b5cd877ecce1 --- /dev/null +++ b/crates/assistant2/src/ui.rs @@ -0,0 +1,3 @@ +mod composer; + +pub use composer::*; diff --git a/crates/assistant2/src/ui/composer.rs b/crates/assistant2/src/ui/composer.rs new file mode 100644 index 0000000000000000000000000000000000000000..756f87a4a7948a165be8445423f25ee411076ee7 --- /dev/null +++ b/crates/assistant2/src/ui/composer.rs @@ -0,0 +1,222 @@ +use assistant_tooling::ToolRegistry; +use client::User; +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{FontStyle, FontWeight, TextStyle, View, WeakView, WhiteSpace}; +use settings::Settings; +use std::sync::Arc; +use theme::ThemeSettings; +use ui::{popover_menu, prelude::*, Avatar, ButtonLike, ContextMenu, Tooltip}; + +use crate::{AssistantChat, CompletionProvider, Submit, SubmitMode}; + +#[derive(IntoElement)] +pub struct Composer { + assistant_chat: WeakView, + model: String, + editor: View, + player: Option>, + can_submit: bool, + tool_registry: Arc, +} + +impl Composer { + pub fn new( + assistant_chat: WeakView, + model: String, + editor: View, + player: Option>, + can_submit: bool, + tool_registry: Arc, + ) -> Self { + Self { + assistant_chat, + model, + editor, + player, + can_submit, + tool_registry, + } + } +} + +impl RenderOnce for Composer { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let mut player_avatar = div().size(rems(20.0 / 16.0)).into_any_element(); + if let Some(player) = self.player.clone() { + player_avatar = Avatar::new(player.avatar_uri.clone()) + .size(rems(20.0 / 16.0)) + .into_any_element(); + } + + let font_size = rems(0.875); + let line_height = font_size.to_pixels(cx.rem_size()) * 1.3; + + h_flex() + .w_full() + .items_start() + .mt_4() + .gap_3() + .child(player_avatar) + .child( + v_flex() + .size_full() + .gap_1() + .child( + v_flex() + .w_full() + .p_4() + .bg(cx.theme().colors().editor_background) + .rounded_lg() + .child( + v_flex() + .justify_between() + .w_full() + .gap_1() + .min_h(line_height * 4 + px(74.0)) + .child({ + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: font_size.into(), + font_weight: FontWeight::NORMAL, + font_style: FontStyle::Normal, + line_height: line_height.into(), + background_color: None, + underline: None, + strikethrough: None, + white_space: WhiteSpace::Normal, + }; + + EditorElement::new( + &self.editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + }) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_between() + .w_full() + .child( + h_flex().gap_1().child( + // IconButton/button + // Toggle - if enabled, .selected(true).selected_style(IconButtonStyle::Filled) + // + // match status + // Tooltip::with_meta("some label explaining project index + status", "click to enable") + IconButton::new( + "add-context", + IconName::FileDoc, + ) + .icon_color(Color::Muted), + ), // .child( + // IconButton::new( + // "add-context", + // IconName::Plus, + // ) + // .icon_color(Color::Muted), + // ), + ) + .child( + Button::new("send-button", "Send") + .style(ButtonStyle::Filled) + .disabled(!self.can_submit) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(Submit( + SubmitMode::Codebase, + ))) + }) + .tooltip(|cx| { + Tooltip::for_action( + "Submit message", + &Submit(SubmitMode::Codebase), + cx, + ) + }), + ), + ), + ), + ) + .child( + h_flex() + .w_full() + .justify_between() + .child(ModelSelector::new(self.assistant_chat, self.model)) + .children(self.tool_registry.status_views().iter().cloned()), + ), + ) + } +} + +#[derive(IntoElement)] +struct ModelSelector { + assistant_chat: WeakView, + model: String, +} + +impl ModelSelector { + pub fn new(assistant_chat: WeakView, model: String) -> Self { + Self { + assistant_chat, + model, + } + } +} + +impl RenderOnce for ModelSelector { + fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + popover_menu("model-switcher") + .menu(move |cx| { + ContextMenu::build(cx, |mut menu, cx| { + for model in CompletionProvider::get(cx).available_models() { + menu = menu.custom_entry( + { + let model = model.clone(); + move |_| Label::new(model.clone()).into_any_element() + }, + { + let assistant_chat = self.assistant_chat.clone(); + move |cx| { + _ = assistant_chat.update(cx, |assistant_chat, cx| { + assistant_chat.model = model.clone(); + cx.notify(); + }); + } + }, + ); + } + menu + }) + .into() + }) + .trigger( + ButtonLike::new("active-model") + .child( + h_flex() + .w_full() + .gap_0p5() + .child( + div() + .overflow_x_hidden() + .flex_grow() + .whitespace_nowrap() + .child(Label::new(self.model)), + ) + .child( + div().child(Icon::new(IconName::ChevronDown).color(Color::Muted)), + ), + ) + .style(ButtonStyle::Subtle) + .tooltip(move |cx| Tooltip::text("Change Model", cx)), + ) + .anchor(gpui::AnchorCorner::BottomRight) + } +}