Cargo.lock 🔗
@@ -109,6 +109,8 @@ dependencies = [
"isahc",
"language",
"menu",
+ "project",
+ "regex",
"schemars",
"search",
"serde",
Antonio Scandurra created
Closes
https://linear.app/zed-industries/issue/Z-1890/save-assistant-conversations-to-the-filesystem
Closes
https://linear.app/zed-industries/issue/Z-2459/cycling-message-roles-on-the-last-empty-message-alters-the-message
Closes
https://linear.app/zed-industries/issue/Z-2460/cycling-role-in-an-empty-message-cycles-wrong-messages-role
Closes https://linear.app/zed-industries/issue/Z-2365/assistant-toolbar
Closes
https://linear.app/zed-industries/issue/Z-2461/always-insert-an-empty-message-at-the-end-of-the-conversation
Release Notes:
- You can now save conversations with the assistant to
`~/.config/zed/conversations` with `cmd-s`. Conversations are also
automatically saved as they are edited.
Cargo.lock | 2
assets/icons/assist_15.svg | 0
assets/icons/hamburger_15.svg | 3
assets/icons/quote_15.svg | 0
assets/icons/split_message_15.svg | 0
assets/keymaps/default.json | 16
assets/settings/default.json | 54
crates/ai/Cargo.toml | 4
crates/ai/src/ai.rs | 92 ++
crates/ai/src/assistant.rs | 738 +++++++++++++++++++----
crates/gpui/src/elements/label.rs | 1
crates/gpui/src/elements/svg.rs | 37 +
crates/project_panel/src/project_panel.rs | 1
crates/search/src/buffer_search.rs | 10
crates/terminal_view/src/terminal_panel.rs | 1
crates/theme/src/theme.rs | 23
crates/theme/src/ui.rs | 30
crates/util/src/paths.rs | 1
crates/workspace/src/dock.rs | 3
crates/workspace/src/pane.rs | 13
crates/workspace/src/toolbar.rs | 74 +-
crates/workspace/src/workspace.rs | 17
styles/src/styleTree/assistant.ts | 193 +++++
23 files changed, 1,050 insertions(+), 263 deletions(-)
@@ -109,6 +109,8 @@ dependencies = [
"isahc",
"language",
"menu",
+ "project",
+ "regex",
"schemars",
"search",
"serde",
@@ -0,0 +1 @@
@@ -0,0 +1,3 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 3C1.22386 3 1 3.22386 1 3.5C1 3.77614 1.22386 4 1.5 4H13.5C13.7761 4 14 3.77614 14 3.5C14 3.22386 13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5 8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761 13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z" fill="#CCCAC2"/>
+</svg>
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -40,7 +40,8 @@
"cmd-o": "workspace::Open",
"alt-cmd-o": "projects::OpenRecent",
"ctrl-~": "workspace::NewTerminal",
- "ctrl-`": "terminal_panel::ToggleFocus"
+ "ctrl-`": "terminal_panel::ToggleFocus",
+ "shift-escape": "workspace::ToggleZoom"
}
},
{
@@ -197,9 +198,17 @@
}
},
{
- "context": "AssistantEditor > Editor",
+ "context": "AssistantPanel",
+ "bindings": {
+ "cmd-g": "search::SelectNextMatch",
+ "cmd-shift-g": "search::SelectPrevMatch"
+ }
+ },
+ {
+ "context": "ConversationEditor > Editor",
"bindings": {
"cmd-enter": "assistant::Assist",
+ "cmd-s": "workspace::Save",
"cmd->": "assistant::QuoteSelection",
"shift-enter": "assistant::Split",
"ctrl-r": "assistant::CycleMessageRole"
@@ -234,8 +243,7 @@
"cmd-shift-g": "search::SelectPrevMatch",
"alt-cmd-c": "search::ToggleCaseSensitive",
"alt-cmd-w": "search::ToggleWholeWord",
- "alt-cmd-r": "search::ToggleRegex",
- "shift-escape": "workspace::ToggleZoom"
+ "alt-cmd-r": "search::ToggleRegex"
}
},
// Bindings from VS Code
@@ -57,37 +57,37 @@
"show_whitespaces": "selection",
// Scrollbar related settings
"scrollbar": {
- // When to show the scrollbar in the editor.
- // This setting can take four values:
- //
- // 1. Show the scrollbar if there's important information or
- // follow the system's configured behavior (default):
- // "auto"
- // 2. Match the system's configured behavior:
- // "system"
- // 3. Always show the scrollbar:
- // "always"
- // 4. Never show the scrollbar:
- // "never"
- "show": "auto",
- // Whether to show git diff indicators in the scrollbar.
- "git_diff": true
+ // When to show the scrollbar in the editor.
+ // This setting can take four values:
+ //
+ // 1. Show the scrollbar if there's important information or
+ // follow the system's configured behavior (default):
+ // "auto"
+ // 2. Match the system's configured behavior:
+ // "system"
+ // 3. Always show the scrollbar:
+ // "always"
+ // 4. Never show the scrollbar:
+ // "never"
+ "show": "auto",
+ // Whether to show git diff indicators in the scrollbar.
+ "git_diff": true
},
"project_panel": {
- // Whether to show the git status in the project panel.
- "git_status": true,
- // Where to dock project panel. Can be 'left' or 'right'.
- "dock": "left",
- // Default width of the project panel.
- "default_width": 240
+ // Whether to show the git status in the project panel.
+ "git_status": true,
+ // Where to dock project panel. Can be 'left' or 'right'.
+ "dock": "left",
+ // Default width of the project panel.
+ "default_width": 240
},
"assistant": {
- // Where to dock the assistant. Can be 'left', 'right' or 'bottom'.
- "dock": "right",
- // Default width when the assistant is docked to the left or right.
- "default_width": 450,
- // Default height when the assistant is docked to the bottom.
- "default_height": 320
+ // Where to dock the assistant. Can be 'left', 'right' or 'bottom'.
+ "dock": "right",
+ // Default width when the assistant is docked to the left or right.
+ "default_width": 640,
+ // Default height when the assistant is docked to the bottom.
+ "default_height": 320
},
// Whether the screen sharing icon is shown in the os status bar.
"show_call_status_icon": true,
@@ -22,9 +22,10 @@ util = { path = "../util" }
workspace = { path = "../workspace" }
anyhow.workspace = true
-chrono = "0.4"
+chrono = { version = "0.4", features = ["serde"] }
futures.workspace = true
isahc.workspace = true
+regex.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -33,3 +34,4 @@ tiktoken-rs = "0.4"
[dev-dependencies]
editor = { path = "../editor", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
@@ -1,10 +1,22 @@
pub mod assistant;
mod assistant_settings;
+use anyhow::Result;
pub use assistant::AssistantPanel;
+use chrono::{DateTime, Local};
+use collections::HashMap;
+use fs::Fs;
+use futures::StreamExt;
use gpui::AppContext;
+use regex::Regex;
use serde::{Deserialize, Serialize};
-use std::fmt::{self, Display};
+use std::{
+ cmp::Reverse,
+ fmt::{self, Display},
+ path::PathBuf,
+ sync::Arc,
+};
+use util::paths::CONVERSATIONS_DIR;
// Data types for chat completion requests
#[derive(Debug, Serialize)]
@@ -14,6 +26,84 @@ struct OpenAIRequest {
stream: bool,
}
+#[derive(
+ Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize,
+)]
+struct MessageId(usize);
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+struct MessageMetadata {
+ role: Role,
+ sent_at: DateTime<Local>,
+ status: MessageStatus,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+enum MessageStatus {
+ Pending,
+ Done,
+ Error(Arc<str>),
+}
+
+#[derive(Serialize, Deserialize)]
+struct SavedMessage {
+ id: MessageId,
+ start: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SavedConversation {
+ zed: String,
+ version: String,
+ text: String,
+ messages: Vec<SavedMessage>,
+ message_metadata: HashMap<MessageId, MessageMetadata>,
+ summary: String,
+ model: String,
+}
+
+impl SavedConversation {
+ const VERSION: &'static str = "0.1.0";
+}
+
+struct SavedConversationMetadata {
+ title: String,
+ path: PathBuf,
+ mtime: chrono::DateTime<chrono::Local>,
+}
+
+impl SavedConversationMetadata {
+ pub async fn list(fs: Arc<dyn Fs>) -> Result<Vec<Self>> {
+ fs.create_dir(&CONVERSATIONS_DIR).await?;
+
+ let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?;
+ let mut conversations = Vec::<SavedConversationMetadata>::new();
+ while let Some(path) = paths.next().await {
+ let path = path?;
+
+ let pattern = r" - \d+.zed.json$";
+ let re = Regex::new(pattern).unwrap();
+
+ let metadata = fs.metadata(&path).await?;
+ if let Some((file_name, metadata)) = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .zip(metadata)
+ {
+ let title = re.replace(file_name, "");
+ conversations.push(Self {
+ title: title.into_owned(),
+ path,
+ mtime: metadata.mtime.into(),
+ });
+ }
+ }
+ conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime));
+
+ Ok(conversations)
+ }
+}
+
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
struct RequestMessage {
role: Role,
@@ -1,6 +1,7 @@
use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings},
- OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role,
+ MessageId, MessageMetadata, MessageStatus, OpenAIRequest, OpenAIResponseStreamEvent,
+ RequestMessage, Role, SavedConversation, SavedConversationMetadata, SavedMessage,
};
use anyhow::{anyhow, Result};
use chrono::{DateTime, Local};
@@ -23,17 +24,26 @@ use gpui::{
};
use isahc::{http::StatusCode, Request, RequestExt};
use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _};
+use search::BufferSearchBar;
use serde::Deserialize;
use settings::SettingsStore;
use std::{
- borrow::Cow, cell::RefCell, cmp, fmt::Write, io, iter, ops::Range, rc::Rc, sync::Arc,
+ cell::RefCell,
+ cmp, env,
+ fmt::Write,
+ io, iter,
+ ops::Range,
+ path::{Path, PathBuf},
+ rc::Rc,
+ sync::Arc,
time::Duration,
};
-use util::{channel::ReleaseChannel, post_inc, truncate_and_trailoff, ResultExt, TryFutureExt};
+use theme::AssistantStyle;
+use util::{channel::ReleaseChannel, paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel},
- item::Item,
- pane, Pane, Workspace,
+ searchable::Direction,
+ Save, ToggleZoom, Toolbar, Workspace,
};
const OPENAI_API_URL: &'static str = "https://api.openai.com/v1";
@@ -47,7 +57,7 @@ actions!(
CycleMessageRole,
QuoteSelection,
ToggleFocus,
- ResetKey
+ ResetKey,
]
);
@@ -62,20 +72,28 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(
|workspace: &mut Workspace, _: &NewContext, cx: &mut ViewContext<Workspace>| {
if let Some(this) = workspace.panel::<AssistantPanel>(cx) {
- this.update(cx, |this, cx| this.add_context(cx))
+ this.update(cx, |this, cx| {
+ this.new_conversation(cx);
+ })
}
workspace.focus_panel::<AssistantPanel>(cx);
},
);
- cx.add_action(AssistantEditor::assist);
- cx.capture_action(AssistantEditor::cancel_last_assist);
- cx.add_action(AssistantEditor::quote_selection);
- cx.capture_action(AssistantEditor::copy);
- cx.capture_action(AssistantEditor::split);
- cx.capture_action(AssistantEditor::cycle_message_role);
+ cx.add_action(ConversationEditor::assist);
+ cx.capture_action(ConversationEditor::cancel_last_assist);
+ cx.capture_action(ConversationEditor::save);
+ cx.add_action(ConversationEditor::quote_selection);
+ cx.capture_action(ConversationEditor::copy);
+ cx.add_action(ConversationEditor::split);
+ cx.capture_action(ConversationEditor::cycle_message_role);
cx.add_action(AssistantPanel::save_api_key);
cx.add_action(AssistantPanel::reset_api_key);
+ cx.add_action(AssistantPanel::toggle_zoom);
+ cx.add_action(AssistantPanel::deploy);
+ cx.add_action(AssistantPanel::select_next_match);
+ cx.add_action(AssistantPanel::select_prev_match);
+ cx.add_action(AssistantPanel::handle_editor_cancel);
cx.add_action(
|workspace: &mut Workspace, _: &ToggleFocus, cx: &mut ViewContext<Workspace>| {
workspace.toggle_panel_focus::<AssistantPanel>(cx);
@@ -83,6 +101,7 @@ pub fn init(cx: &mut AppContext) {
);
}
+#[derive(Debug)]
pub enum AssistantPanelEvent {
ZoomIn,
ZoomOut,
@@ -92,15 +111,24 @@ pub enum AssistantPanelEvent {
}
pub struct AssistantPanel {
+ workspace: WeakViewHandle<Workspace>,
width: Option<f32>,
height: Option<f32>,
- pane: ViewHandle<Pane>,
+ active_editor_index: Option<usize>,
+ prev_active_editor_index: Option<usize>,
+ editors: Vec<ViewHandle<ConversationEditor>>,
+ saved_conversations: Vec<SavedConversationMetadata>,
+ saved_conversations_list_state: UniformListState,
+ zoomed: bool,
+ has_focus: bool,
+ toolbar: ViewHandle<Toolbar>,
api_key: Rc<RefCell<Option<String>>>,
api_key_editor: Option<ViewHandle<Editor>>,
has_read_credentials: bool,
languages: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
subscriptions: Vec<Subscription>,
+ _watch_saved_conversations: Task<Result<()>>,
}
impl AssistantPanel {
@@ -109,66 +137,51 @@ impl AssistantPanel {
cx: AsyncAppContext,
) -> Task<Result<ViewHandle<Self>>> {
cx.spawn(|mut cx| async move {
+ let fs = workspace.read_with(&cx, |workspace, _| workspace.app_state().fs.clone())?;
+ let saved_conversations = SavedConversationMetadata::list(fs.clone())
+ .await
+ .log_err()
+ .unwrap_or_default();
+
// TODO: deserialize state.
+ let workspace_handle = workspace.clone();
workspace.update(&mut cx, |workspace, cx| {
cx.add_view::<Self, _>(|cx| {
- let weak_self = cx.weak_handle();
- let pane = cx.add_view(|cx| {
- let mut pane = Pane::new(
- workspace.weak_handle(),
- workspace.project().clone(),
- workspace.app_state().background_actions,
- Default::default(),
- cx,
- );
- pane.set_can_split(false, cx);
- pane.set_can_navigate(false, cx);
- pane.on_can_drop(move |_, _| false);
- pane.set_render_tab_bar_buttons(cx, move |pane, cx| {
- let weak_self = weak_self.clone();
- Flex::row()
- .with_child(Pane::render_tab_bar_button(
- 0,
- "icons/plus_12.svg",
- false,
- Some(("New Context".into(), Some(Box::new(NewContext)))),
- cx,
- move |_, cx| {
- let weak_self = weak_self.clone();
- cx.window_context().defer(move |cx| {
- if let Some(this) = weak_self.upgrade(cx) {
- this.update(cx, |this, cx| this.add_context(cx));
- }
- })
- },
- None,
- ))
- .with_child(Pane::render_tab_bar_button(
- 1,
- if pane.is_zoomed() {
- "icons/minimize_8.svg"
- } else {
- "icons/maximize_8.svg"
- },
- pane.is_zoomed(),
- Some((
- "Toggle Zoom".into(),
- Some(Box::new(workspace::ToggleZoom)),
- )),
- cx,
- move |pane, cx| pane.toggle_zoom(&Default::default(), cx),
- None,
- ))
- .into_any()
- });
- let buffer_search_bar = cx.add_view(search::BufferSearchBar::new);
- pane.toolbar()
- .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx));
- pane
+ const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100);
+ let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move {
+ let mut events = fs
+ .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION)
+ .await;
+ while events.next().await.is_some() {
+ let saved_conversations = SavedConversationMetadata::list(fs.clone())
+ .await
+ .log_err()
+ .unwrap_or_default();
+ this.update(&mut cx, |this, _| {
+ this.saved_conversations = saved_conversations
+ })
+ .ok();
+ }
+
+ anyhow::Ok(())
});
+ let toolbar = cx.add_view(|cx| {
+ let mut toolbar = Toolbar::new(None);
+ toolbar.set_can_navigate(false, cx);
+ toolbar.add_item(cx.add_view(|cx| BufferSearchBar::new(cx)), cx);
+ toolbar
+ });
let mut this = Self {
- pane,
+ workspace: workspace_handle,
+ active_editor_index: Default::default(),
+ prev_active_editor_index: Default::default(),
+ editors: Default::default(),
+ saved_conversations,
+ saved_conversations_list_state: Default::default(),
+ zoomed: false,
+ has_focus: false,
+ toolbar,
api_key: Rc::new(RefCell::new(None)),
api_key_editor: None,
has_read_credentials: false,
@@ -177,20 +190,18 @@ impl AssistantPanel {
width: None,
height: None,
subscriptions: Default::default(),
+ _watch_saved_conversations,
};
let mut old_dock_position = this.position(cx);
- this.subscriptions = vec![
- cx.observe(&this.pane, |_, _, cx| cx.notify()),
- cx.subscribe(&this.pane, Self::handle_pane_event),
- cx.observe_global::<SettingsStore, _>(move |this, cx| {
+ this.subscriptions =
+ vec![cx.observe_global::<SettingsStore, _>(move |this, cx| {
let new_dock_position = this.position(cx);
if new_dock_position != old_dock_position {
old_dock_position = new_dock_position;
cx.emit(AssistantPanelEvent::DockPositionChanged);
}
- }),
- ];
+ })];
this
})
@@ -198,40 +209,64 @@ impl AssistantPanel {
})
}
- fn handle_pane_event(
+ fn new_conversation(&mut self, cx: &mut ViewContext<Self>) -> ViewHandle<ConversationEditor> {
+ let editor = cx.add_view(|cx| {
+ ConversationEditor::new(
+ self.api_key.clone(),
+ self.languages.clone(),
+ self.fs.clone(),
+ cx,
+ )
+ });
+ self.add_conversation(editor.clone(), cx);
+ editor
+ }
+
+ fn add_conversation(
&mut self,
- _pane: ViewHandle<Pane>,
- event: &pane::Event,
+ editor: ViewHandle<ConversationEditor>,
cx: &mut ViewContext<Self>,
) {
- match event {
- pane::Event::ZoomIn => cx.emit(AssistantPanelEvent::ZoomIn),
- pane::Event::ZoomOut => cx.emit(AssistantPanelEvent::ZoomOut),
- pane::Event::Focus => cx.emit(AssistantPanelEvent::Focus),
- pane::Event::Remove => cx.emit(AssistantPanelEvent::Close),
- _ => {}
- }
- }
+ self.subscriptions
+ .push(cx.subscribe(&editor, Self::handle_conversation_editor_event));
- fn add_context(&mut self, cx: &mut ViewContext<Self>) {
- let focus = self.has_focus(cx);
- let editor = cx
- .add_view(|cx| AssistantEditor::new(self.api_key.clone(), self.languages.clone(), cx));
+ let conversation = editor.read(cx).conversation.clone();
self.subscriptions
- .push(cx.subscribe(&editor, Self::handle_assistant_editor_event));
- self.pane.update(cx, |pane, cx| {
- pane.add_item(Box::new(editor), true, focus, None, cx)
- });
+ .push(cx.observe(&conversation, |_, _, cx| cx.notify()));
+
+ let index = self.editors.len();
+ self.editors.push(editor);
+ self.set_active_editor_index(Some(index), cx);
}
- fn handle_assistant_editor_event(
+ fn set_active_editor_index(&mut self, index: Option<usize>, cx: &mut ViewContext<Self>) {
+ self.prev_active_editor_index = self.active_editor_index;
+ self.active_editor_index = index;
+ if let Some(editor) = self.active_editor() {
+ let editor = editor.read(cx).editor.clone();
+ self.toolbar.update(cx, |toolbar, cx| {
+ toolbar.set_active_item(Some(&editor), cx);
+ });
+ if self.has_focus(cx) {
+ cx.focus(&editor);
+ }
+ } else {
+ self.toolbar.update(cx, |toolbar, cx| {
+ toolbar.set_active_item(None, cx);
+ });
+ }
+
+ cx.notify();
+ }
+
+ fn handle_conversation_editor_event(
&mut self,
- _: ViewHandle<AssistantEditor>,
- event: &AssistantEditorEvent,
+ _: ViewHandle<ConversationEditor>,
+ event: &ConversationEditorEvent,
cx: &mut ViewContext<Self>,
) {
match event {
- AssistantEditorEvent::TabContentChanged => self.pane.update(cx, |_, cx| cx.notify()),
+ ConversationEditorEvent::TabContentChanged => cx.notify(),
}
}
@@ -262,6 +297,266 @@ impl AssistantPanel {
cx.focus_self();
cx.notify();
}
+
+ fn toggle_zoom(&mut self, _: &workspace::ToggleZoom, cx: &mut ViewContext<Self>) {
+ if self.zoomed {
+ cx.emit(AssistantPanelEvent::ZoomOut)
+ } else {
+ cx.emit(AssistantPanelEvent::ZoomIn)
+ }
+ }
+
+ fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext<Self>) {
+ if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+ if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) {
+ return;
+ }
+ }
+ cx.propagate_action();
+ }
+
+ fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext<Self>) {
+ if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+ if !search_bar.read(cx).is_dismissed() {
+ search_bar.update(cx, |search_bar, cx| {
+ search_bar.dismiss(&Default::default(), cx)
+ });
+ return;
+ }
+ }
+ cx.propagate_action();
+ }
+
+ fn select_next_match(&mut self, _: &search::SelectNextMatch, cx: &mut ViewContext<Self>) {
+ if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+ search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, cx));
+ }
+ }
+
+ fn select_prev_match(&mut self, _: &search::SelectPrevMatch, cx: &mut ViewContext<Self>) {
+ if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+ search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, cx));
+ }
+ }
+
+ fn active_editor(&self) -> Option<&ViewHandle<ConversationEditor>> {
+ self.editors.get(self.active_editor_index?)
+ }
+
+ fn render_hamburger_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+ enum ListConversations {}
+ let theme = theme::current(cx);
+ MouseEventHandler::<ListConversations, _>::new(0, cx, |state, _| {
+ let style = theme.assistant.hamburger_button.style_for(state);
+ Svg::for_style(style.icon.clone())
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+ if this.active_editor().is_some() {
+ this.set_active_editor_index(None, cx);
+ } else {
+ this.set_active_editor_index(this.prev_active_editor_index, cx);
+ }
+ })
+ }
+
+ fn render_editor_tools(&self, cx: &mut ViewContext<Self>) -> Vec<AnyElement<Self>> {
+ if self.active_editor().is_some() {
+ vec![
+ Self::render_split_button(cx).into_any(),
+ Self::render_quote_button(cx).into_any(),
+ Self::render_assist_button(cx).into_any(),
+ ]
+ } else {
+ Default::default()
+ }
+ }
+
+ fn render_split_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+ let theme = theme::current(cx);
+ let tooltip_style = theme::current(cx).tooltip.clone();
+ MouseEventHandler::<Split, _>::new(0, cx, |state, _| {
+ let style = theme.assistant.split_button.style_for(state);
+ Svg::for_style(style.icon.clone())
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+ if let Some(active_editor) = this.active_editor() {
+ active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx));
+ }
+ })
+ .with_tooltip::<Split>(
+ 1,
+ "Split Message".into(),
+ Some(Box::new(Split)),
+ tooltip_style,
+ cx,
+ )
+ }
+
+ fn render_assist_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+ let theme = theme::current(cx);
+ let tooltip_style = theme::current(cx).tooltip.clone();
+ MouseEventHandler::<Assist, _>::new(0, cx, |state, _| {
+ let style = theme.assistant.assist_button.style_for(state);
+ Svg::for_style(style.icon.clone())
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+ if let Some(active_editor) = this.active_editor() {
+ active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx));
+ }
+ })
+ .with_tooltip::<Assist>(
+ 1,
+ "Assist".into(),
+ Some(Box::new(Assist)),
+ tooltip_style,
+ cx,
+ )
+ }
+
+ fn render_quote_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+ let theme = theme::current(cx);
+ let tooltip_style = theme::current(cx).tooltip.clone();
+ MouseEventHandler::<QuoteSelection, _>::new(0, cx, |state, _| {
+ let style = theme.assistant.quote_button.style_for(state);
+ Svg::for_style(style.icon.clone())
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+ if let Some(workspace) = this.workspace.upgrade(cx) {
+ cx.window_context().defer(move |cx| {
+ workspace.update(cx, |workspace, cx| {
+ ConversationEditor::quote_selection(workspace, &Default::default(), cx)
+ });
+ });
+ }
+ })
+ .with_tooltip::<QuoteSelection>(
+ 1,
+ "Assist".into(),
+ Some(Box::new(QuoteSelection)),
+ tooltip_style,
+ cx,
+ )
+ }
+
+ fn render_plus_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+ enum AddConversation {}
+ let theme = theme::current(cx);
+ MouseEventHandler::<AddConversation, _>::new(0, cx, |state, _| {
+ let style = theme.assistant.plus_button.style_for(state);
+ Svg::for_style(style.icon.clone())
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+ this.new_conversation(cx);
+ })
+ }
+
+ fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl Element<Self> {
+ enum ToggleZoomButton {}
+
+ let theme = theme::current(cx);
+ let style = if self.zoomed {
+ &theme.assistant.zoom_out_button
+ } else {
+ &theme.assistant.zoom_in_button
+ };
+
+ MouseEventHandler::<ToggleZoomButton, _>::new(0, cx, |state, _| {
+ let style = style.style_for(state);
+ Svg::for_style(style.icon.clone())
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this, cx| {
+ this.toggle_zoom(&ToggleZoom, cx);
+ })
+ }
+
+ fn render_saved_conversation(
+ &mut self,
+ index: usize,
+ cx: &mut ViewContext<Self>,
+ ) -> impl Element<Self> {
+ let conversation = &self.saved_conversations[index];
+ let path = conversation.path.clone();
+ MouseEventHandler::<SavedConversationMetadata, _>::new(index, cx, move |state, cx| {
+ let style = &theme::current(cx).assistant.saved_conversation;
+ Flex::row()
+ .with_child(
+ Label::new(
+ conversation.mtime.format("%F %I:%M%p").to_string(),
+ style.saved_at.text.clone(),
+ )
+ .aligned()
+ .contained()
+ .with_style(style.saved_at.container),
+ )
+ .with_child(
+ Label::new(conversation.title.clone(), style.title.text.clone())
+ .aligned()
+ .contained()
+ .with_style(style.title.container),
+ )
+ .contained()
+ .with_style(*style.container.style_for(state))
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.open_conversation(path.clone(), cx)
+ .detach_and_log_err(cx)
+ })
+ }
+
+ fn open_conversation(&mut self, path: PathBuf, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
+ if let Some(ix) = self.editor_index_for_path(&path, cx) {
+ self.set_active_editor_index(Some(ix), cx);
+ return Task::ready(Ok(()));
+ }
+
+ let fs = self.fs.clone();
+ let api_key = self.api_key.clone();
+ let languages = self.languages.clone();
+ cx.spawn(|this, mut cx| async move {
+ let saved_conversation = fs.load(&path).await?;
+ let saved_conversation = serde_json::from_str(&saved_conversation)?;
+ let conversation = cx.add_model(|cx| {
+ Conversation::deserialize(saved_conversation, path.clone(), api_key, languages, cx)
+ });
+ this.update(&mut cx, |this, cx| {
+ // If, by the time we've loaded the conversation, the user has already opened
+ // the same conversation, we don't want to open it again.
+ if let Some(ix) = this.editor_index_for_path(&path, cx) {
+ this.set_active_editor_index(Some(ix), cx);
+ } else {
+ let editor = cx
+ .add_view(|cx| ConversationEditor::for_conversation(conversation, fs, cx));
+ this.add_conversation(editor, cx);
+ }
+ })?;
+ Ok(())
+ })
+ }
+
+ fn editor_index_for_path(&self, path: &Path, cx: &AppContext) -> Option<usize> {
+ self.editors
+ .iter()
+ .position(|editor| editor.read(cx).conversation.read(cx).path.as_deref() == Some(path))
+ }
}
fn build_api_key_editor(cx: &mut ViewContext<AssistantPanel>) -> ViewHandle<Editor> {
@@ -285,7 +580,8 @@ impl View for AssistantPanel {
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- let style = &theme::current(cx).assistant;
+ let theme = &theme::current(cx);
+ let style = &theme.assistant;
if let Some(api_key_editor) = self.api_key_editor.as_ref() {
Flex::column()
.with_child(
@@ -306,19 +602,76 @@ impl View for AssistantPanel {
.aligned()
.into_any()
} else {
- ChildView::new(&self.pane, cx).into_any()
+ let title = self.active_editor().map(|editor| {
+ Label::new(editor.read(cx).title(cx), style.title.text.clone())
+ .contained()
+ .with_style(style.title.container)
+ .aligned()
+ .left()
+ .flex(1., false)
+ });
+
+ Flex::column()
+ .with_child(
+ Flex::row()
+ .with_child(Self::render_hamburger_button(cx).aligned())
+ .with_children(title)
+ .with_children(
+ self.render_editor_tools(cx)
+ .into_iter()
+ .map(|tool| tool.aligned().flex_float()),
+ )
+ .with_child(Self::render_plus_button(cx).aligned().flex_float())
+ .with_child(self.render_zoom_button(cx).aligned())
+ .contained()
+ .with_style(theme.workspace.tab_bar.container)
+ .expanded()
+ .constrained()
+ .with_height(theme.workspace.tab_bar.height),
+ )
+ .with_children(if self.toolbar.read(cx).hidden() {
+ None
+ } else {
+ Some(ChildView::new(&self.toolbar, cx).expanded())
+ })
+ .with_child(if let Some(editor) = self.active_editor() {
+ ChildView::new(editor, cx).flex(1., true).into_any()
+ } else {
+ UniformList::new(
+ self.saved_conversations_list_state.clone(),
+ self.saved_conversations.len(),
+ cx,
+ |this, range, items, cx| {
+ for ix in range {
+ items.push(this.render_saved_conversation(ix, cx).into_any());
+ }
+ },
+ )
+ .flex(1., true)
+ .into_any()
+ })
+ .into_any()
}
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ self.has_focus = true;
+ self.toolbar
+ .update(cx, |toolbar, cx| toolbar.focus_changed(true, cx));
if cx.is_self_focused() {
- if let Some(api_key_editor) = self.api_key_editor.as_ref() {
+ if let Some(editor) = self.active_editor() {
+ cx.focus(editor);
+ } else if let Some(api_key_editor) = self.api_key_editor.as_ref() {
cx.focus(api_key_editor);
- } else {
- cx.focus(&self.pane);
}
}
}
+
+ fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ self.has_focus = false;
+ self.toolbar
+ .update(cx, |toolbar, cx| toolbar.focus_changed(false, cx));
+ }
}
impl Panel for AssistantPanel {
@@ -371,19 +724,22 @@ impl Panel for AssistantPanel {
matches!(event, AssistantPanelEvent::ZoomOut)
}
- fn is_zoomed(&self, cx: &WindowContext) -> bool {
- self.pane.read(cx).is_zoomed()
+ fn is_zoomed(&self, _: &WindowContext) -> bool {
+ self.zoomed
}
fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>) {
- self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx));
+ self.zoomed = zoomed;
+ cx.notify();
}
fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
if active {
if self.api_key.borrow().is_none() && !self.has_read_credentials {
self.has_read_credentials = true;
- let api_key = if let Some((_, api_key)) = cx
+ let api_key = if let Ok(api_key) = env::var("OPENAI_API_KEY") {
+ Some(api_key)
+ } else if let Some((_, api_key)) = cx
.platform()
.read_credentials(OPENAI_API_URL)
.log_err()
@@ -401,8 +757,8 @@ impl Panel for AssistantPanel {
}
}
- if self.pane.read(cx).items_len() == 0 {
- self.add_context(cx);
+ if self.editors.is_empty() {
+ self.new_conversation(cx);
}
}
}
@@ -427,12 +783,8 @@ impl Panel for AssistantPanel {
matches!(event, AssistantPanelEvent::Close)
}
- fn has_focus(&self, cx: &WindowContext) -> bool {
- self.pane.read(cx).has_focus()
- || self
- .api_key_editor
- .as_ref()
- .map_or(false, |editor| editor.is_focused(cx))
+ fn has_focus(&self, _: &WindowContext) -> bool {
+ self.has_focus
}
fn is_focus_event(event: &Self::Event) -> bool {
@@ -440,18 +792,24 @@ impl Panel for AssistantPanel {
}
}
-enum AssistantEvent {
+enum ConversationEvent {
MessagesEdited,
SummaryChanged,
StreamedCompletion,
}
-struct Assistant {
+#[derive(Default)]
+struct Summary {
+ text: String,
+ done: bool,
+}
+
+struct Conversation {
buffer: ModelHandle<Buffer>,
message_anchors: Vec<MessageAnchor>,
messages_metadata: HashMap<MessageId, MessageMetadata>,
next_message_id: MessageId,
- summary: Option<String>,
+ summary: Option<Summary>,
pending_summary: Task<Option<()>>,
completion_count: usize,
pending_completions: Vec<PendingCompletion>,
@@ -460,14 +818,16 @@ struct Assistant {
max_token_count: usize,
pending_token_count: Task<Option<()>>,
api_key: Rc<RefCell<Option<String>>>,
+ pending_save: Task<Result<()>>,
+ path: Option<PathBuf>,
_subscriptions: Vec<Subscription>,
}
-impl Entity for Assistant {
- type Event = AssistantEvent;
+impl Entity for Conversation {
+ type Event = ConversationEvent;
}
-impl Assistant {
+impl Conversation {
fn new(
api_key: Rc<RefCell<Option<String>>>,
language_registry: Arc<LanguageRegistry>,
@@ -505,6 +865,8 @@ impl Assistant {
pending_token_count: Task::ready(None),
model: model.into(),
_subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
+ pending_save: Task::ready(Ok(())),
+ path: None,
api_key,
buffer,
};
@@ -526,6 +888,88 @@ impl Assistant {
this
}
+ fn serialize(&self, cx: &AppContext) -> SavedConversation {
+ SavedConversation {
+ zed: "conversation".into(),
+ version: SavedConversation::VERSION.into(),
+ text: self.buffer.read(cx).text(),
+ message_metadata: self.messages_metadata.clone(),
+ messages: self
+ .messages(cx)
+ .map(|message| SavedMessage {
+ id: message.id,
+ start: message.offset_range.start,
+ })
+ .collect(),
+ summary: self
+ .summary
+ .as_ref()
+ .map(|summary| summary.text.clone())
+ .unwrap_or_default(),
+ model: self.model.clone(),
+ }
+ }
+
+ fn deserialize(
+ saved_conversation: SavedConversation,
+ path: PathBuf,
+ api_key: Rc<RefCell<Option<String>>>,
+ language_registry: Arc<LanguageRegistry>,
+ cx: &mut ModelContext<Self>,
+ ) -> Self {
+ let model = saved_conversation.model;
+ let markdown = language_registry.language_for_name("Markdown");
+ let mut message_anchors = Vec::new();
+ let mut next_message_id = MessageId(0);
+ let buffer = cx.add_model(|cx| {
+ let mut buffer = Buffer::new(0, saved_conversation.text, cx);
+ for message in saved_conversation.messages {
+ message_anchors.push(MessageAnchor {
+ id: message.id,
+ start: buffer.anchor_before(message.start),
+ });
+ next_message_id = cmp::max(next_message_id, MessageId(message.id.0 + 1));
+ }
+ buffer.set_language_registry(language_registry);
+ cx.spawn_weak(|buffer, mut cx| async move {
+ let markdown = markdown.await?;
+ let buffer = buffer
+ .upgrade(&cx)
+ .ok_or_else(|| anyhow!("buffer was dropped"))?;
+ buffer.update(&mut cx, |buffer, cx| {
+ buffer.set_language(Some(markdown), cx)
+ });
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ buffer
+ });
+
+ let mut this = Self {
+ message_anchors,
+ messages_metadata: saved_conversation.message_metadata,
+ next_message_id,
+ summary: Some(Summary {
+ text: saved_conversation.summary,
+ done: true,
+ }),
+ pending_summary: Task::ready(None),
+ completion_count: Default::default(),
+ pending_completions: Default::default(),
+ token_count: None,
+ max_token_count: tiktoken_rs::model::get_context_size(&model),
+ pending_token_count: Task::ready(None),
+ model,
+ _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
+ pending_save: Task::ready(Ok(())),
+ path: Some(path),
+ api_key,
+ buffer,
+ };
+ this.count_remaining_tokens(cx);
+ this
+ }
+
fn handle_buffer_event(
&mut self,
_: ModelHandle<Buffer>,
@@ -535,7 +979,7 @@ impl Assistant {
match event {
language::Event::Edited => {
self.count_remaining_tokens(cx);
- cx.emit(AssistantEvent::MessagesEdited);
+ cx.emit(ConversationEvent::MessagesEdited);
}
_ => {}
}
@@ -552,7 +996,11 @@ impl Assistant {
Role::Assistant => "assistant".into(),
Role::System => "system".into(),
},
- content: self.buffer.read(cx).text_for_range(message.range).collect(),
+ content: self
+ .buffer
+ .read(cx)
+ .text_for_range(message.offset_range)
+ .collect(),
name: None,
})
})
@@ -567,7 +1015,7 @@ impl Assistant {
.await?;
this.upgrade(&cx)
- .ok_or_else(|| anyhow!("assistant was dropped"))?
+ .ok_or_else(|| anyhow!("conversation was dropped"))?
.update(&mut cx, |this, cx| {
this.max_token_count = tiktoken_rs::model::get_context_size(&this.model);
this.token_count = Some(token_count);
@@ -596,6 +1044,14 @@ impl Assistant {
) -> Vec<MessageAnchor> {
let mut user_messages = Vec::new();
let mut tasks = Vec::new();
+
+ let last_message_id = self.message_anchors.iter().rev().find_map(|message| {
+ message
+ .start
+ .is_valid(self.buffer.read(cx))
+ .then_some(message.id)
+ });
+
for selected_message_id in selected_messages {
let selected_message_role =
if let Some(metadata) = self.messages_metadata.get(&selected_message_id) {
@@ -658,6 +1114,19 @@ impl Assistant {
)
.unwrap();
+ // Queue up the user's next reply
+ if Some(selected_message_id) == last_message_id {
+ let user_message = self
+ .insert_message_after(
+ assistant_message.id,
+ Role::User,
+ MessageStatus::Done,
+ cx,
+ )
+ .unwrap();
+ user_messages.push(user_message);
+ }
+
tasks.push(cx.spawn_weak({
|this, mut cx| async move {
let assistant_message_id = assistant_message.id;
@@ -668,7 +1137,7 @@ impl Assistant {
let mut message = message?;
if let Some(choice) = message.choices.pop() {
this.upgrade(&cx)
- .ok_or_else(|| anyhow!("assistant was dropped"))?
+ .ok_or_else(|| anyhow!("conversation was dropped"))?
.update(&mut cx, |this, cx| {
let text: Arc<str> = choice.delta.content?.into();
let message_ix = this.message_anchors.iter().position(
@@ -686,7 +1155,7 @@ impl Assistant {
});
buffer.edit([(offset..offset, text)], None, cx);
});
- cx.emit(AssistantEvent::StreamedCompletion);
+ cx.emit(ConversationEvent::StreamedCompletion);
Some(())
});
@@ -695,7 +1164,7 @@ impl Assistant {
}
this.upgrade(&cx)
- .ok_or_else(|| anyhow!("assistant was dropped"))?
+ .ok_or_else(|| anyhow!("conversation was dropped"))?
.update(&mut cx, |this, cx| {
this.pending_completions.retain(|completion| {
completion.id != this.completion_count
@@ -749,7 +1218,7 @@ impl Assistant {
for id in ids {
if let Some(metadata) = self.messages_metadata.get_mut(&id) {
metadata.role.cycle();
- cx.emit(AssistantEvent::MessagesEdited);
+ cx.emit(ConversationEvent::MessagesEdited);
cx.notify();
}
}
@@ -767,10 +1236,19 @@ impl Assistant {
.iter()
.position(|message| message.id == message_id)
{
+ // Find the next valid message after the one we were given.
+ let mut next_message_ix = prev_message_ix + 1;
+ while let Some(next_message) = self.message_anchors.get(next_message_ix) {
+ if next_message.start.is_valid(self.buffer.read(cx)) {
+ break;
+ }
+ next_message_ix += 1;
+ }
+
let start = self.buffer.update(cx, |buffer, cx| {
- let offset = self.message_anchors[prev_message_ix + 1..]
- .iter()
- .find(|message| message.start.is_valid(buffer))
+ let offset = self
+ .message_anchors
+ .get(next_message_ix)
.map_or(buffer.len(), |message| message.start.to_offset(buffer) - 1);
buffer.edit([(offset..offset, "\n")], None, cx);
buffer.anchor_before(offset + 1)
@@ -165,6 +165,7 @@ impl<V: View> Element<V> for Label {
_: &mut V,
cx: &mut ViewContext<V>,
) -> Self::PaintState {
+ let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
line.paint(
scene,
bounds.origin(),
@@ -1,7 +1,5 @@
-use std::{borrow::Cow, ops::Range};
-
-use serde_json::json;
-
+use super::constrain_size_preserving_aspect_ratio;
+use crate::json::ToJson;
use crate::{
color::Color,
geometry::{
@@ -10,6 +8,10 @@ use crate::{
},
scene, Element, LayoutContext, SceneBuilder, SizeConstraint, View, ViewContext,
};
+use schemars::JsonSchema;
+use serde_derive::Deserialize;
+use serde_json::json;
+use std::{borrow::Cow, ops::Range};
pub struct Svg {
path: Cow<'static, str>,
@@ -24,6 +26,14 @@ impl Svg {
}
}
+ pub fn for_style<V: View>(style: SvgStyle) -> impl Element<V> {
+ Self::new(style.asset)
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.dimensions.width)
+ .with_height(style.dimensions.height)
+ }
+
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
@@ -105,9 +115,24 @@ impl<V: View> Element<V> for Svg {
}
}
-use crate::json::ToJson;
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct SvgStyle {
+ pub color: Color,
+ pub asset: String,
+ pub dimensions: Dimensions,
+}
-use super::constrain_size_preserving_aspect_ratio;
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct Dimensions {
+ pub width: f32,
+ pub height: f32,
+}
+
+impl Dimensions {
+ pub fn to_vec(&self) -> Vector2F {
+ vec2f(self.width, self.height)
+ }
+}
fn from_usvg_rect(rect: usvg::Rect) -> RectF {
RectF::new(
@@ -153,6 +153,7 @@ pub fn init(cx: &mut AppContext) {
);
}
+#[derive(Debug)]
pub enum Event {
OpenedEntry {
entry_id: ProjectEntryId,
@@ -259,7 +259,11 @@ impl BufferSearchBar {
}
}
- fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
+ pub fn is_dismissed(&self) -> bool {
+ self.dismissed
+ }
+
+ pub fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
self.dismissed = true;
for searchable_item in self.seachable_items_with_matches.keys() {
if let Some(searchable_item) =
@@ -275,7 +279,7 @@ impl BufferSearchBar {
cx.notify();
}
- fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext<Self>) -> bool {
+ pub fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext<Self>) -> bool {
let searchable_item = if let Some(searchable_item) = &self.active_searchable_item {
SearchableItemHandle::boxed_clone(searchable_item.as_ref())
} else {
@@ -484,7 +488,7 @@ impl BufferSearchBar {
self.select_match(Direction::Prev, cx);
}
- fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
+ pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
if let Some(index) = self.active_match_index {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(matches) = self
@@ -25,6 +25,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(TerminalPanel::new_terminal);
}
+#[derive(Debug)]
pub enum Event {
Close,
DockPositionChanged,
@@ -4,7 +4,7 @@ pub mod ui;
use gpui::{
color::Color,
- elements::{ContainerStyle, ImageStyle, LabelStyle, Shadow, TooltipStyle},
+ elements::{ContainerStyle, ImageStyle, LabelStyle, Shadow, SvgStyle, TooltipStyle},
fonts::{HighlightStyle, TextStyle},
platform, AppContext, AssetSource, Border, MouseState,
};
@@ -13,7 +13,7 @@ use serde::{de::DeserializeOwned, Deserialize};
use serde_json::Value;
use settings::SettingsStore;
use std::{collections::HashMap, sync::Arc};
-use ui::{ButtonStyle, CheckboxStyle, IconStyle, ModalStyle, SvgStyle};
+use ui::{ButtonStyle, CheckboxStyle, IconStyle, ModalStyle};
pub use theme_registry::*;
pub use theme_settings::*;
@@ -993,18 +993,33 @@ pub struct TerminalStyle {
#[derive(Clone, Deserialize, Default, JsonSchema)]
pub struct AssistantStyle {
pub container: ContainerStyle,
- pub header: ContainerStyle,
+ pub hamburger_button: Interactive<IconStyle>,
+ pub split_button: Interactive<IconStyle>,
+ pub assist_button: Interactive<IconStyle>,
+ pub quote_button: Interactive<IconStyle>,
+ pub zoom_in_button: Interactive<IconStyle>,
+ pub zoom_out_button: Interactive<IconStyle>,
+ pub plus_button: Interactive<IconStyle>,
+ pub title: ContainedText,
+ pub message_header: ContainerStyle,
pub sent_at: ContainedText,
pub user_sender: Interactive<ContainedText>,
pub assistant_sender: Interactive<ContainedText>,
pub system_sender: Interactive<ContainedText>,
- pub model_info_container: ContainerStyle,
pub model: Interactive<ContainedText>,
pub remaining_tokens: ContainedText,
pub no_remaining_tokens: ContainedText,
pub error_icon: Icon,
pub api_key_editor: FieldEditor,
pub api_key_prompt: ContainedText,
+ pub saved_conversation: SavedConversation,
+}
+
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct SavedConversation {
+ pub container: Interactive<ContainerStyle>,
+ pub saved_at: ContainedText,
+ pub title: ContainedText,
}
#[derive(Clone, Deserialize, Default, JsonSchema)]
@@ -1,13 +1,12 @@
use std::borrow::Cow;
use gpui::{
- color::Color,
elements::{
- ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label,
- MouseEventHandler, ParentElement, Stack, Svg,
+ ConstrainedBox, Container, ContainerStyle, Dimensions, Empty, Flex, KeystrokeLabel, Label,
+ MouseEventHandler, ParentElement, Stack, Svg, SvgStyle,
},
fonts::TextStyle,
- geometry::vector::{vec2f, Vector2F},
+ geometry::vector::Vector2F,
platform,
platform::MouseButton,
scene::MouseClick,
@@ -94,25 +93,6 @@ where
.with_cursor_style(platform::CursorStyle::PointingHand)
}
-#[derive(Clone, Deserialize, Default, JsonSchema)]
-pub struct SvgStyle {
- pub color: Color,
- pub asset: String,
- pub dimensions: Dimensions,
-}
-
-#[derive(Clone, Deserialize, Default, JsonSchema)]
-pub struct Dimensions {
- pub width: f32,
- pub height: f32,
-}
-
-impl Dimensions {
- pub fn to_vec(&self) -> Vector2F {
- vec2f(self.width, self.height)
- }
-}
-
pub fn svg<V: View>(style: &SvgStyle) -> ConstrainedBox<V> {
Svg::new(style.asset.clone())
.with_color(style.color)
@@ -123,8 +103,8 @@ pub fn svg<V: View>(style: &SvgStyle) -> ConstrainedBox<V> {
#[derive(Clone, Deserialize, Default, JsonSchema)]
pub struct IconStyle {
- icon: SvgStyle,
- container: ContainerStyle,
+ pub icon: SvgStyle,
+ pub container: ContainerStyle,
}
pub fn icon<V: View>(style: &IconStyle) -> Container<V> {
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
lazy_static::lazy_static! {
pub static ref HOME: PathBuf = dirs::home_dir().expect("failed to determine home directory");
pub static ref CONFIG_DIR: PathBuf = HOME.join(".config").join("zed");
+ pub static ref CONVERSATIONS_DIR: PathBuf = HOME.join(".config/zed/conversations");
pub static ref LOGS_DIR: PathBuf = HOME.join("Library/Logs/Zed");
pub static ref SUPPORT_DIR: PathBuf = HOME.join("Library/Application Support/Zed");
pub static ref LANGUAGES_DIR: PathBuf = HOME.join("Library/Application Support/Zed/languages");
@@ -249,7 +249,7 @@ impl Dock {
}
}
- pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
+ pub(crate) fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
let subscriptions = [
cx.observe(&panel, |_, _, cx| cx.notify()),
cx.subscribe(&panel, |this, panel, event, cx| {
@@ -605,6 +605,7 @@ pub mod test {
use super::*;
use gpui::{ViewContext, WindowContext};
+ #[derive(Debug)]
pub enum TestPanelEvent {
PositionChanged,
Activated,
@@ -1,9 +1,10 @@
mod dragged_item_receiver;
use super::{ItemHandle, SplitDirection};
+pub use crate::toolbar::Toolbar;
use crate::{
- item::WeakItemHandle, notify_of_new_dock, toolbar::Toolbar, AutosaveSetting, Item,
- NewCenterTerminal, NewFile, NewSearch, ToggleZoom, Workspace, WorkspaceSettings,
+ item::WeakItemHandle, notify_of_new_dock, AutosaveSetting, Item, NewCenterTerminal, NewFile,
+ NewSearch, ToggleZoom, Workspace, WorkspaceSettings,
};
use anyhow::Result;
use collections::{HashMap, HashSet, VecDeque};
@@ -250,7 +251,7 @@ impl Pane {
pane: handle.clone(),
next_timestamp,
}))),
- toolbar: cx.add_view(|_| Toolbar::new(handle)),
+ toolbar: cx.add_view(|_| Toolbar::new(Some(handle))),
tab_bar_context_menu: TabBarContextMenu {
kind: TabBarContextMenuKind::New,
handle: context_menu,
@@ -1112,7 +1113,7 @@ impl Pane {
.get(self.active_item_index)
.map(|item| item.as_ref());
self.toolbar.update(cx, |toolbar, cx| {
- toolbar.set_active_pane_item(active_item, cx);
+ toolbar.set_active_item(active_item, cx);
});
}
@@ -1602,7 +1603,7 @@ impl View for Pane {
}
self.toolbar.update(cx, |toolbar, cx| {
- toolbar.pane_focus_update(true, cx);
+ toolbar.focus_changed(true, cx);
});
if let Some(active_item) = self.active_item() {
@@ -1631,7 +1632,7 @@ impl View for Pane {
fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
self.has_focus = false;
self.toolbar.update(cx, |toolbar, cx| {
- toolbar.pane_focus_update(false, cx);
+ toolbar.focus_changed(false, cx);
});
cx.notify();
}
@@ -38,7 +38,7 @@ trait ToolbarItemViewHandle {
active_pane_item: Option<&dyn ItemHandle>,
cx: &mut WindowContext,
) -> ToolbarItemLocation;
- fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext);
+ fn focus_changed(&mut self, pane_focused: bool, cx: &mut WindowContext);
fn row_count(&self, cx: &WindowContext) -> usize;
}
@@ -51,10 +51,10 @@ pub enum ToolbarItemLocation {
}
pub struct Toolbar {
- active_pane_item: Option<Box<dyn ItemHandle>>,
+ active_item: Option<Box<dyn ItemHandle>>,
hidden: bool,
can_navigate: bool,
- pane: WeakViewHandle<Pane>,
+ pane: Option<WeakViewHandle<Pane>>,
items: Vec<(Box<dyn ToolbarItemViewHandle>, ToolbarItemLocation)>,
}
@@ -121,7 +121,7 @@ impl View for Toolbar {
let pane = self.pane.clone();
let mut enable_go_backward = false;
let mut enable_go_forward = false;
- if let Some(pane) = pane.upgrade(cx) {
+ if let Some(pane) = pane.and_then(|pane| pane.upgrade(cx)) {
let pane = pane.read(cx);
enable_go_backward = pane.can_navigate_backward();
enable_go_forward = pane.can_navigate_forward();
@@ -143,19 +143,17 @@ impl View for Toolbar {
enable_go_backward,
spacing,
{
- let pane = pane.clone();
move |toolbar, cx| {
- if let Some(workspace) = toolbar
- .pane
- .upgrade(cx)
- .and_then(|pane| pane.read(cx).workspace().upgrade(cx))
+ if let Some(pane) = toolbar.pane.as_ref().and_then(|pane| pane.upgrade(cx))
{
- let pane = pane.clone();
- cx.window_context().defer(move |cx| {
- workspace.update(cx, |workspace, cx| {
- workspace.go_back(pane.clone(), cx).detach_and_log_err(cx);
- });
- })
+ if let Some(workspace) = pane.read(cx).workspace().upgrade(cx) {
+ let pane = pane.downgrade();
+ cx.window_context().defer(move |cx| {
+ workspace.update(cx, |workspace, cx| {
+ workspace.go_back(pane, cx).detach_and_log_err(cx);
+ });
+ })
+ }
}
}
},
@@ -171,21 +169,17 @@ impl View for Toolbar {
enable_go_forward,
spacing,
{
- let pane = pane.clone();
move |toolbar, cx| {
- if let Some(workspace) = toolbar
- .pane
- .upgrade(cx)
- .and_then(|pane| pane.read(cx).workspace().upgrade(cx))
+ if let Some(pane) = toolbar.pane.as_ref().and_then(|pane| pane.upgrade(cx))
{
- let pane = pane.clone();
- cx.window_context().defer(move |cx| {
- workspace.update(cx, |workspace, cx| {
- workspace
- .go_forward(pane.clone(), cx)
- .detach_and_log_err(cx);
- });
- });
+ if let Some(workspace) = pane.read(cx).workspace().upgrade(cx) {
+ let pane = pane.downgrade();
+ cx.window_context().defer(move |cx| {
+ workspace.update(cx, |workspace, cx| {
+ workspace.go_forward(pane, cx).detach_and_log_err(cx);
+ });
+ })
+ }
}
}
},
@@ -269,9 +263,9 @@ fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>
}
impl Toolbar {
- pub fn new(pane: WeakViewHandle<Pane>) -> Self {
+ pub fn new(pane: Option<WeakViewHandle<Pane>>) -> Self {
Self {
- active_pane_item: None,
+ active_item: None,
pane,
items: Default::default(),
hidden: false,
@@ -288,7 +282,7 @@ impl Toolbar {
where
T: 'static + ToolbarItemView,
{
- let location = item.set_active_pane_item(self.active_pane_item.as_deref(), cx);
+ let location = item.set_active_pane_item(self.active_item.as_deref(), cx);
cx.subscribe(&item, |this, item, event, cx| {
if let Some((_, current_location)) =
this.items.iter_mut().find(|(i, _)| i.id() == item.id())
@@ -307,20 +301,16 @@ impl Toolbar {
cx.notify();
}
- pub fn set_active_pane_item(
- &mut self,
- pane_item: Option<&dyn ItemHandle>,
- cx: &mut ViewContext<Self>,
- ) {
- self.active_pane_item = pane_item.map(|item| item.boxed_clone());
+ pub fn set_active_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
+ self.active_item = item.map(|item| item.boxed_clone());
self.hidden = self
- .active_pane_item
+ .active_item
.as_ref()
.map(|item| !item.show_toolbar(cx))
.unwrap_or(false);
for (toolbar_item, current_location) in self.items.iter_mut() {
- let new_location = toolbar_item.set_active_pane_item(pane_item, cx);
+ let new_location = toolbar_item.set_active_pane_item(item, cx);
if new_location != *current_location {
*current_location = new_location;
cx.notify();
@@ -328,9 +318,9 @@ impl Toolbar {
}
}
- pub fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut ViewContext<Self>) {
+ pub fn focus_changed(&mut self, focused: bool, cx: &mut ViewContext<Self>) {
for (toolbar_item, _) in self.items.iter_mut() {
- toolbar_item.pane_focus_update(pane_focused, cx);
+ toolbar_item.focus_changed(focused, cx);
}
}
@@ -364,7 +354,7 @@ impl<T: ToolbarItemView> ToolbarItemViewHandle for ViewHandle<T> {
})
}
- fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext) {
+ fn focus_changed(&mut self, pane_focused: bool, cx: &mut WindowContext) {
self.update(cx, |this, cx| {
this.pane_focus_update(pane_focused, cx);
cx.notify();
@@ -861,7 +861,10 @@ impl Workspace {
&self.right_dock
}
- pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
+ pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>)
+ where
+ T::Event: std::fmt::Debug,
+ {
let dock = match panel.position(cx) {
DockPosition::Left => &self.left_dock,
DockPosition::Bottom => &self.bottom_dock,
@@ -904,10 +907,11 @@ impl Workspace {
});
} else if T::should_zoom_in_on_event(event) {
dock.update(cx, |dock, cx| dock.set_panel_zoomed(&panel, true, cx));
- if panel.has_focus(cx) {
- this.zoomed = Some(panel.downgrade().into_any());
- this.zoomed_position = Some(panel.read(cx).position(cx));
+ if !panel.has_focus(cx) {
+ cx.focus(&panel);
}
+ this.zoomed = Some(panel.downgrade().into_any());
+ this.zoomed_position = Some(panel.read(cx).position(cx));
} else if T::should_zoom_out_on_event(event) {
dock.update(cx, |dock, cx| dock.set_panel_zoomed(&panel, false, cx));
if this.zoomed_position == Some(prev_position) {
@@ -1702,6 +1706,11 @@ impl Workspace {
cx.notify();
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn zoomed_view(&self, cx: &AppContext) -> Option<AnyViewHandle> {
+ self.zoomed.and_then(|view| view.upgrade(cx))
+ }
+
fn dismiss_zoomed_items_to_reveal(
&mut self,
dock_to_reveal: Option<DockPosition>,
@@ -10,11 +10,190 @@ export default function assistant(colorScheme: ColorScheme) {
background: editor(colorScheme).background,
padding: { left: 12 },
},
- header: {
+ messageHeader: {
border: border(layer, "default", { bottom: true, top: true }),
margin: { bottom: 6, top: 6 },
background: editor(colorScheme).background,
},
+ hamburgerButton: interactive({
+ base: {
+ icon: {
+ color: foreground(layer, "variant"),
+ asset: "icons/hamburger_15.svg",
+ dimensions: {
+ width: 15,
+ height: 15,
+ },
+ },
+ container: {
+ margin: { left: 12 },
+ }
+ },
+ state: {
+ hovered: {
+ icon: {
+ color: foreground(layer, "hovered")
+ }
+ }
+ }
+ }),
+ splitButton: interactive({
+ base: {
+ icon: {
+ color: foreground(layer, "variant"),
+ asset: "icons/split_message_15.svg",
+ dimensions: {
+ width: 15,
+ height: 15,
+ },
+ },
+ container: {
+ margin: { left: 12 },
+ }
+ },
+ state: {
+ hovered: {
+ icon: {
+ color: foreground(layer, "hovered")
+ }
+ }
+ }
+ }),
+ quoteButton: interactive({
+ base: {
+ icon: {
+ color: foreground(layer, "variant"),
+ asset: "icons/quote_15.svg",
+ dimensions: {
+ width: 15,
+ height: 15,
+ },
+ },
+ container: {
+ margin: { left: 12 },
+ }
+ },
+ state: {
+ hovered: {
+ icon: {
+ color: foreground(layer, "hovered")
+ }
+ }
+ }
+ }),
+ assistButton: interactive({
+ base: {
+ icon: {
+ color: foreground(layer, "variant"),
+ asset: "icons/assist_15.svg",
+ dimensions: {
+ width: 15,
+ height: 15,
+ },
+ },
+ container: {
+ margin: { left: 12, right: 24 },
+ }
+ },
+ state: {
+ hovered: {
+ icon: {
+ color: foreground(layer, "hovered")
+ }
+ }
+ }
+ }),
+ zoomInButton: interactive({
+ base: {
+ icon: {
+ color: foreground(layer, "variant"),
+ asset: "icons/maximize_8.svg",
+ dimensions: {
+ width: 12,
+ height: 12,
+ },
+ },
+ container: {
+ margin: { right: 12 },
+ }
+ },
+ state: {
+ hovered: {
+ icon: {
+ color: foreground(layer, "hovered")
+ }
+ }
+ }
+ }),
+ zoomOutButton: interactive({
+ base: {
+ icon: {
+ color: foreground(layer, "variant"),
+ asset: "icons/minimize_8.svg",
+ dimensions: {
+ width: 12,
+ height: 12,
+ },
+ },
+ container: {
+ margin: { right: 12 },
+ }
+ },
+ state: {
+ hovered: {
+ icon: {
+ color: foreground(layer, "hovered")
+ }
+ }
+ }
+ }),
+ plusButton: interactive({
+ base: {
+ icon: {
+ color: foreground(layer, "variant"),
+ asset: "icons/plus_12.svg",
+ dimensions: {
+ width: 12,
+ height: 12,
+ },
+ },
+ container: {
+ margin: { right: 12 },
+ }
+ },
+ state: {
+ hovered: {
+ icon: {
+ color: foreground(layer, "hovered")
+ }
+ }
+ }
+ }),
+ title: {
+ margin: { left: 12 },
+ ...text(layer, "sans", "default", { size: "sm" })
+ },
+ savedConversation: {
+ container: interactive({
+ base: {
+ background: background(layer, "on"),
+ padding: { top: 4, bottom: 4 }
+ },
+ state: {
+ hovered: {
+ background: background(layer, "on", "hovered"),
+ }
+ },
+ }),
+ savedAt: {
+ margin: { left: 8 },
+ ...text(layer, "sans", "default", { size: "xs" }),
+ },
+ title: {
+ margin: { left: 16 },
+ ...text(layer, "sans", "default", { size: "sm", weight: "bold" }),
+ }
+ },
userSender: {
default: {
...text(layer, "sans", "default", {
@@ -43,13 +222,10 @@ export default function assistant(colorScheme: ColorScheme) {
margin: { top: 2, left: 8 },
...text(layer, "sans", "default", { size: "2xs" }),
},
- modelInfoContainer: {
- margin: { right: 16, top: 4 },
- },
model: interactive({
base: {
background: background(layer, "on"),
- border: border(layer, "on", { overlay: true }),
+ margin: { left: 12, right: 12, top: 12 },
padding: 4,
cornerRadius: 4,
...text(layer, "sans", "default", { size: "xs" }),
@@ -57,22 +233,21 @@ export default function assistant(colorScheme: ColorScheme) {
state: {
hovered: {
background: background(layer, "on", "hovered"),
+ border: border(layer, "on", { overlay: true }),
},
},
}),
remainingTokens: {
background: background(layer, "on"),
- border: border(layer, "on", { overlay: true }),
+ margin: { top: 12, right: 12 },
padding: 4,
- margin: { left: 4 },
cornerRadius: 4,
...text(layer, "sans", "positive", { size: "xs" }),
},
noRemainingTokens: {
background: background(layer, "on"),
- border: border(layer, "on", { overlay: true }),
+ margin: { top: 12, right: 12 },
padding: 4,
- margin: { left: 4 },
cornerRadius: 4,
...text(layer, "sans", "negative", { size: "xs" }),
},