Compare commits

..

12 Commits

Author SHA1 Message Date
Thomas Mickley-Doyle
c080d3d60d Add example for negative comment 2025-04-21 22:57:44 -05:00
Conrad Irwin
dfbd132d9f Update Split bindings in terminal (#29188)
Closes #29087

Release Notes:

- Changed default bindings for splitting terminals from `ctrl-k
{up,down,left,right}` to `ctrl-alt-{up,down,left,right}`. `ctrl-k` is
used by Readline to cut to the end of the line.
2025-04-21 19:48:18 -06:00
Michael Sloan
2e8ee9b64f agent: Make directory context display update on rename (#29189)
Release Notes:

- N/A
2025-04-22 01:44:31 +00:00
Gaku Kanematsu
c15382c4d8 vim: Add cursor shape settings for each vim mode (#28636)
Closes #4495

Release Notes:

- vim: add cursor shape settings for each vim mode

---

Add cursor shape settings for each vim mode to enable users to specify
them.

Example of `settings.json`:

```json
{
  "vim_mode": true,
  "vim": {
    "cursor_shape": {
      "normal": "hollow",
      "insert": "bar",
      "replace": "block",
      "visual": "underline"
    }
  }
}
```

After this change is applied,

- The cursor shape specified by the user for each mode is used.
- In insert mode, the `vim > cursor_shape > insert` setting takes
precedence over the primary `cursor_shape` setting.
- If `vim > cursor_shape > insert` is not set, the primary
`cursor_shape` will be used in insert mode.
- The cursor shape will remain unchanged before and after this update
when the user does not set the `vim > cursor_shape` setting.

Video:


[screen-record.webm](https://github.com/user-attachments/assets/b87461a1-6b3a-4a77-a607-a340f106def5)
2025-04-21 18:42:04 -06:00
Michael Sloan
70c51b513b agent eval: Default to also running typescript examples (#29185)
Release Notes:

- N/A
2025-04-21 23:59:35 +00:00
Mikayla Maki
38afae86a9 Use buffer size for markdown preview (#29172)
Note:

This is implemented in a very hacky and one-off manner. The primary
change is to pass a rem size through the markdown render tree, and scale
all sizing (rems & pixels) based on the passed in rem size manually.
This required copying in the `CheckBox` component from `ui::CheckBox` to
make it use the manual rem scaling without modifying the `CheckBox`
implementation directly as it is used elsewhere.

A better solution is required, likely involving `window.with_rem_size`
and/or _actual_ `em` units that allow text-size-relative scaling.

Release Notes:

- Made it so Markdown preview uses the _buffer_ font size instead of the
_ui_ font size.

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Nate Butler <nate@zed.dev>
2025-04-21 19:29:21 -04:00
Michael Sloan
9249919b7a Write {result_count}.diff and last.diff eval run outputs (#29181)
These are only written when the diff has changed. `patch.diff` has been
removed as its redundant with `last.diff`.

It can be convenient to open `last.diff` and use undo/redo to navigate
its history.

Release Notes:

- N/A
2025-04-21 23:19:07 +00:00
Michael Sloan
9fe4a14f73 Add a brief description of GPUI 2->GPUI 3 changes to .rules (#29180)
Release Notes:

- N/A
2025-04-21 22:41:15 +00:00
Finn Evers
7cc3c03b08 editor: Fix hang when scrolling over single line input fields (#28471)
Closes #21684
Closes #28463
Closes #28264 

This PR fixes Zed hanging when scrolling over single line input fields
with `scroll_beyond_last_line` set to `vertical_scroll_margin`. The
change here is to fix the calculations of available lines.

The issue only arises with the setting present because with all
overscroll settings and `max_row` being 1 for single-line editors, the
calculation would still return the correct value of available lines,
which is 1. However, with overscrolling set to `vertical_scroll_margin`
and that set to any value greater than 0, the calculation would return
that the single-line editor has more than one line, which caused the
issues described above (Actually, setting `vertical_scroll_margin` to 1
works for some reason, overscrolls "properly" and does not cause a
crash. But I really did not want to investigate this buggy behavior
further).

This PR fixes this by always reporting the number of available lines as
the line number value for single line editors, which will (mostly) be 1
(for more context see the discussion in this PR).

Release Notes:

- Fixed an issue where Zed would crash when scrolling over single line
input fields and `scroll_beyond_last_line` set to
`vertical_scroll_margin`.
2025-04-22 00:37:04 +02:00
Richard Feldman
4f2f9ff762 Streaming tool calls (#29179)
https://github.com/user-attachments/assets/7854a737-ef83-414c-b397-45122e4f32e8



Release Notes:

- Create file and edit file tools now stream their tool descriptions, so
you can see what they're doing sooner.

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-04-21 22:28:32 +00:00
Michael Sloan
7aa0fa1543 Add ability to attach rules as context (#29109)
Release Notes:

- agent: Added support for adding rules as context.
2025-04-21 20:16:51 +00:00
Michael Sloan
3b31860d52 Add to .rules: Avoid creating mod.rs paths (#29174)
Release Notes:

- N/A
2025-04-21 20:14:56 +00:00
47 changed files with 1464 additions and 247 deletions

11
.rules
View File

@@ -5,6 +5,7 @@
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
* Never create files with `mod.rs` paths - prefer `src/some_module.rs` instead of `src/some_module/mod.rs`.
# GPUI
@@ -108,3 +109,13 @@ When a view's state has changed in a way that may affect its rendering, it shoul
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmittor<EventType> for EntityType {}`.
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
## Recent API changes
GPUI has had some changes to its APIs. Always write code using the new APIs:
* `spawn` methods now take async closures (`AsyncFn`), and so should be called like `cx.spawn(async move |cx| ...)`.
* Use `Entity<T>`. This replaces `Model<T>` and `View<T>` which longer exists and should NEVER be used.
* Use `App` references. This replaces `AppContext` which no longer exists and should NEVER be used.
* Use `Context<T>` references. This replaces `ModelContext<T>` which no longer exists and should NEVER be used.
* `Window` is now passed around explicitly. The new interface adds a `Window` reference parameter to some methods, and adds some new "*_in" methods for plumbing `Window`. The old types `WindowContext` and `ViewContext<T>` should NEVER be used.

8
Cargo.lock generated
View File

@@ -4920,6 +4920,7 @@ dependencies = [
"serde",
"settings",
"shellexpand 2.1.2",
"smol",
"telemetry",
"toml 0.8.20",
"unindent",
@@ -7713,6 +7714,7 @@ dependencies = [
"mistral",
"ollama",
"open_ai",
"partial-json-fixer",
"project",
"proto",
"schemars",
@@ -9828,6 +9830,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "partial-json-fixer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35ffd90b3f3b6477db7478016b9efb1b7e9d38eafd095f0542fe0ec2ea884a13"
[[package]]
name = "password-hash"
version = "0.4.2"

View File

@@ -480,6 +480,7 @@ num-format = "0.4.4"
ordered-float = "2.1.1"
palette = { version = "0.7.5", default-features = false, features = ["std"] }
parking_lot = "0.12.1"
partial-json-fixer = "0.5.3"
pathdiff = "0.2"
pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }

View File

@@ -1028,10 +1028,10 @@
// Using `ctrl-shift-space` in Zed requires disabling the macOS global shortcut.
// System Preferences->Keyboard->Keyboard Shortcuts->Input Sources->Select the previous input source (uncheck)
"ctrl-shift-space": "terminal::ToggleViMode",
"ctrl-k up": "pane::SplitUp",
"ctrl-k down": "pane::SplitDown",
"ctrl-k left": "pane::SplitLeft",
"ctrl-k right": "pane::SplitRight"
"ctrl-alt-up": "pane::SplitUp",
"ctrl-alt-down": "pane::SplitDown",
"ctrl-alt-left": "pane::SplitLeft",
"ctrl-alt-right": "pane::SplitRight"
}
},
{

View File

@@ -73,9 +73,9 @@ There are project rules that apply to these root directories:
{{/each}}
{{/if}}
{{#if has_default_user_rules}}
{{#if has_user_rules}}
The user has specified the following rules that should be applied:
{{#each default_user_rules}}
{{#each user_rules}}
{{#if title}}
Rules title: {{title}}

View File

@@ -1489,7 +1489,12 @@
"use_multiline_find": false,
"use_smartcase_find": false,
"highlight_on_yank_duration": 200,
"custom_digraphs": {}
"custom_digraphs": {},
// Cursor shape for the each mode.
// Specify the mode as the key and the shape as the value.
// The mode can be one of the following: "normal", "replace", "insert", "visual".
// The shape can be one of the following: "block", "bar", "underline", "hollow".
"cursor_shape": {}
},
// The server to connect to. If the environment variable
// ZED_SERVER_URL is set, it will override this setting.

View File

@@ -1,4 +1,4 @@
use crate::context::{AssistantContext, ContextId, format_context_as_string};
use crate::context::{AssistantContext, ContextId, RULES_ICON, format_context_as_string};
use crate::context_picker::MentionLink;
use crate::thread::{
LastRestoreCheckpoint, MessageId, MessageSegment, Thread, ThreadError, ThreadEvent,
@@ -266,14 +266,6 @@ fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
}
}
fn render_tool_use_markdown(
text: SharedString,
language_registry: Arc<LanguageRegistry>,
cx: &mut App,
) -> Entity<Markdown> {
cx.new(|cx| Markdown::new(text, Some(language_registry), None, cx))
}
fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle {
let theme_settings = ThemeSettings::get_global(cx);
let colors = cx.theme().colors();
@@ -688,6 +680,12 @@ fn open_markdown_link(
}
}),
Some(MentionLink::Fetch(url)) => cx.open_url(&url),
Some(MentionLink::Rules(prompt_id)) => window.dispatch_action(
Box::new(OpenPromptLibrary {
prompt_to_select: Some(prompt_id.0),
}),
cx,
),
None => cx.open_url(&text),
}
}
@@ -861,21 +859,34 @@ impl ActiveThread {
tool_output: SharedString,
cx: &mut Context<Self>,
) {
let rendered = RenderedToolUse {
label: render_tool_use_markdown(tool_label.into(), self.language_registry.clone(), cx),
input: render_tool_use_markdown(
format!(
"```json\n{}\n```",
serde_json::to_string_pretty(tool_input).unwrap_or_default()
)
.into(),
self.language_registry.clone(),
cx,
),
output: render_tool_use_markdown(tool_output, self.language_registry.clone(), cx),
};
self.rendered_tool_uses
.insert(tool_use_id.clone(), rendered);
let rendered = self
.rendered_tool_uses
.entry(tool_use_id.clone())
.or_insert_with(|| RenderedToolUse {
label: cx.new(|cx| {
Markdown::new("".into(), Some(self.language_registry.clone()), None, cx)
}),
input: cx.new(|cx| {
Markdown::new("".into(), Some(self.language_registry.clone()), None, cx)
}),
output: cx.new(|cx| {
Markdown::new("".into(), Some(self.language_registry.clone()), None, cx)
}),
});
rendered.label.update(cx, |this, cx| {
this.replace(tool_label, cx);
});
rendered.input.update(cx, |this, cx| {
let input = format!(
"```json\n{}\n```",
serde_json::to_string_pretty(tool_input).unwrap_or_default()
);
this.replace(input, cx);
});
rendered.output.update(cx, |this, cx| {
this.replace(tool_output, cx);
});
}
fn handle_thread_event(
@@ -968,6 +979,19 @@ impl ActiveThread {
);
}
}
ThreadEvent::StreamedToolUse {
tool_use_id,
ui_text,
input,
} => {
self.render_tool_use_markdown(
tool_use_id.clone(),
ui_text.clone(),
input,
"".into(),
cx,
);
}
ThreadEvent::ToolFinished {
pending_tool_use, ..
} => {
@@ -2472,13 +2496,15 @@ impl ActiveThread {
let edit_tools = tool_use.needs_confirmation;
let status_icons = div().child(match &tool_use.status {
ToolUseStatus::Pending | ToolUseStatus::NeedsConfirmation => {
ToolUseStatus::NeedsConfirmation => {
let icon = Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small);
icon.into_any_element()
}
ToolUseStatus::Running => {
ToolUseStatus::Pending
| ToolUseStatus::InputStillStreaming
| ToolUseStatus::Running => {
let icon = Icon::new(IconName::ArrowCircle)
.color(Color::Accent)
.size(IconSize::Small);
@@ -2564,7 +2590,7 @@ impl ActiveThread {
}),
)),
),
ToolUseStatus::Running => container.child(
ToolUseStatus::InputStillStreaming | ToolUseStatus::Running => container.child(
results_content_container().child(
h_flex()
.gap_1()
@@ -2957,10 +2983,10 @@ impl ActiveThread {
return div().into_any();
};
let default_user_rules_text = if project_context.default_user_rules.is_empty() {
let user_rules_text = if project_context.user_rules.is_empty() {
None
} else if project_context.default_user_rules.len() == 1 {
let user_rules = &project_context.default_user_rules[0];
} else if project_context.user_rules.len() == 1 {
let user_rules = &project_context.user_rules[0];
match user_rules.title.as_ref() {
Some(title) => Some(format!("Using \"{title}\" user rule")),
@@ -2969,14 +2995,14 @@ impl ActiveThread {
} else {
Some(format!(
"Using {} user rules",
project_context.default_user_rules.len()
project_context.user_rules.len()
))
};
let first_default_user_rules_id = project_context
.default_user_rules
let first_user_rules_id = project_context
.user_rules
.first()
.map(|user_rules| user_rules.uuid);
.map(|user_rules| user_rules.uuid.0);
let rules_files = project_context
.worktrees
@@ -2993,7 +3019,7 @@ impl ActiveThread {
rules_files => Some(format!("Using {} project rules files", rules_files.len())),
};
if default_user_rules_text.is_none() && rules_file_text.is_none() {
if user_rules_text.is_none() && rules_file_text.is_none() {
return div().into_any();
}
@@ -3001,45 +3027,42 @@ impl ActiveThread {
.pt_2()
.px_2p5()
.gap_1()
.when_some(
default_user_rules_text,
|parent, default_user_rules_text| {
parent.child(
h_flex()
.w_full()
.child(
Icon::new(IconName::File)
.size(IconSize::XSmall)
.color(Color::Disabled),
)
.child(
Label::new(default_user_rules_text)
.size(LabelSize::XSmall)
.color(Color::Muted)
.truncate()
.buffer_font(cx)
.ml_1p5()
.mr_0p5(),
)
.child(
IconButton::new("open-prompt-library", IconName::ArrowUpRightAlt)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Ignored)
// TODO: Figure out a way to pass focus handle here so we can display the `OpenPromptLibrary` keybinding
.tooltip(Tooltip::text("View User Rules"))
.on_click(move |_event, window, cx| {
window.dispatch_action(
Box::new(OpenPromptLibrary {
prompt_to_focus: first_default_user_rules_id,
}),
cx,
)
}),
),
)
},
)
.when_some(user_rules_text, |parent, user_rules_text| {
parent.child(
h_flex()
.w_full()
.child(
Icon::new(RULES_ICON)
.size(IconSize::XSmall)
.color(Color::Disabled),
)
.child(
Label::new(user_rules_text)
.size(LabelSize::XSmall)
.color(Color::Muted)
.truncate()
.buffer_font(cx)
.ml_1p5()
.mr_0p5(),
)
.child(
IconButton::new("open-prompt-library", IconName::ArrowUpRightAlt)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.icon_color(Color::Ignored)
// TODO: Figure out a way to pass focus handle here so we can display the `OpenPromptLibrary` keybinding
.tooltip(Tooltip::text("View User Rules"))
.on_click(move |_event, window, cx| {
window.dispatch_action(
Box::new(OpenPromptLibrary {
prompt_to_select: first_user_rules_id,
}),
cx,
)
}),
),
)
})
.when_some(rules_file_text, |parent, rules_file_text| {
parent.child(
h_flex()
@@ -3259,12 +3282,10 @@ pub(crate) fn open_context(
}
}
AssistantContext::Directory(directory_context) => {
let project_path = directory_context.project_path(cx);
let entry_id = directory_context.entry_id;
workspace.update(cx, |workspace, cx| {
workspace.project().update(cx, |project, cx| {
if let Some(entry) = project.entry_for_path(&project_path, cx) {
cx.emit(project::Event::RevealInProjectPanel(entry.id));
}
workspace.project().update(cx, |_project, cx| {
cx.emit(project::Event::RevealInProjectPanel(entry_id));
})
})
}
@@ -3316,6 +3337,12 @@ pub(crate) fn open_context(
}
})
}
AssistantContext::Rules(rules_context) => window.dispatch_action(
Box::new(OpenPromptLibrary {
prompt_to_select: Some(rules_context.prompt_id.0),
}),
cx,
),
}
}

View File

@@ -25,7 +25,7 @@ use language_model::{LanguageModelProviderTosView, LanguageModelRegistry};
use language_model_selector::ToggleModelSelector;
use project::Project;
use prompt_library::{PromptLibrary, open_prompt_library};
use prompt_store::{PromptBuilder, PromptId};
use prompt_store::{PromptBuilder, PromptId, UserPromptId};
use proto::Plan;
use settings::{Settings, update_settings_file};
use time::UtcOffset;
@@ -79,11 +79,11 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx));
}
})
.register_action(|workspace, _: &OpenPromptLibrary, window, cx| {
.register_action(|workspace, action: &OpenPromptLibrary, window, cx| {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
workspace.focus_panel::<AssistantPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_prompt_library(&OpenPromptLibrary::default(), window, cx)
panel.deploy_prompt_library(action, window, cx)
});
}
})
@@ -502,7 +502,9 @@ impl AssistantPanel {
None,
))
}),
action.prompt_to_focus.map(|uuid| PromptId::User { uuid }),
action.prompt_to_select.map(|uuid| PromptId::User {
uuid: UserPromptId(uuid),
}),
cx,
)
.detach_and_log_err(cx);

View File

@@ -3,7 +3,8 @@ use std::{ops::Range, path::Path, sync::Arc};
use gpui::{App, Entity, SharedString};
use language::{Buffer, File};
use language_model::LanguageModelRequestMessage;
use project::{ProjectPath, Worktree};
use project::{ProjectEntryId, ProjectPath, Worktree};
use prompt_store::UserPromptId;
use rope::Point;
use serde::{Deserialize, Serialize};
use text::{Anchor, BufferId};
@@ -12,6 +13,8 @@ use util::post_inc;
use crate::thread::Thread;
pub const RULES_ICON: IconName = IconName::Context;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
pub struct ContextId(pub(crate) usize);
@@ -20,6 +23,7 @@ impl ContextId {
Self(post_inc(&mut self.0))
}
}
pub enum ContextKind {
File,
Directory,
@@ -27,6 +31,7 @@ pub enum ContextKind {
Excerpt,
FetchedUrl,
Thread,
Rules,
}
impl ContextKind {
@@ -38,6 +43,7 @@ impl ContextKind {
ContextKind::Excerpt => IconName::Code,
ContextKind::FetchedUrl => IconName::Globe,
ContextKind::Thread => IconName::MessageBubbles,
ContextKind::Rules => RULES_ICON,
}
}
}
@@ -50,6 +56,7 @@ pub enum AssistantContext {
FetchedUrl(FetchedUrlContext),
Thread(ThreadContext),
Excerpt(ExcerptContext),
Rules(RulesContext),
}
impl AssistantContext {
@@ -61,6 +68,7 @@ impl AssistantContext {
Self::FetchedUrl(url) => url.id,
Self::Thread(thread) => thread.id,
Self::Excerpt(excerpt) => excerpt.id,
Self::Rules(rules) => rules.id,
}
}
}
@@ -75,17 +83,25 @@ pub struct FileContext {
pub struct DirectoryContext {
pub id: ContextId,
pub worktree: Entity<Worktree>,
pub path: Arc<Path>,
pub entry_id: ProjectEntryId,
pub last_path: Arc<Path>,
/// Buffers of the files within the directory.
pub context_buffers: Vec<ContextBuffer>,
}
impl DirectoryContext {
pub fn project_path(&self, cx: &App) -> ProjectPath {
ProjectPath {
worktree_id: self.worktree.read(cx).id(),
path: self.path.clone(),
}
pub fn entry<'a>(&self, cx: &'a App) -> Option<&'a project::Entry> {
self.worktree.read(cx).entry_for_id(self.entry_id)
}
pub fn project_path(&self, cx: &App) -> Option<ProjectPath> {
let worktree = self.worktree.read(cx);
worktree
.entry_for_id(self.entry_id)
.map(|entry| ProjectPath {
worktree_id: worktree.id(),
path: entry.path.clone(),
})
}
}
@@ -123,7 +139,7 @@ impl ThreadContext {
#[derive(Clone)]
pub struct ContextBuffer {
pub id: BufferId,
// TODO: Entity<Buffer> holds onto the thread even if the thread is deleted. Should probably be
// TODO: Entity<Buffer> holds onto the buffer even if the buffer is deleted. Should probably be
// a WeakEntity and handle removal from the UI when it has dropped.
pub buffer: Entity<Buffer>,
pub file: Arc<dyn File>,
@@ -168,6 +184,14 @@ pub struct ExcerptContext {
pub context_buffer: ContextBuffer,
}
#[derive(Debug, Clone)]
pub struct RulesContext {
pub id: ContextId,
pub prompt_id: UserPromptId,
pub title: SharedString,
pub text: SharedString,
}
/// Formats a collection of contexts into a string representation
pub fn format_context_as_string<'a>(
contexts: impl Iterator<Item = &'a AssistantContext>,
@@ -179,6 +203,7 @@ pub fn format_context_as_string<'a>(
let mut excerpt_context = Vec::new();
let mut fetch_context = Vec::new();
let mut thread_context = Vec::new();
let mut rules_context = Vec::new();
for context in contexts {
match context {
@@ -188,6 +213,7 @@ pub fn format_context_as_string<'a>(
AssistantContext::Excerpt(context) => excerpt_context.push(context),
AssistantContext::FetchedUrl(context) => fetch_context.push(context),
AssistantContext::Thread(context) => thread_context.push(context),
AssistantContext::Rules(context) => rules_context.push(context),
}
}
@@ -197,6 +223,7 @@ pub fn format_context_as_string<'a>(
&& excerpt_context.is_empty()
&& fetch_context.is_empty()
&& thread_context.is_empty()
&& rules_context.is_empty()
{
return None;
}
@@ -263,6 +290,18 @@ pub fn format_context_as_string<'a>(
result.push_str("</conversation_threads>\n");
}
if !rules_context.is_empty() {
result.push_str(
"<user_rules>\n\
The user has specified the following rules that should be applied:\n\n",
);
for context in &rules_context {
result.push_str(&context.text);
result.push('\n');
}
result.push_str("</user_rules>\n");
}
result.push_str("</context>\n");
Some(result)
}

View File

@@ -1,6 +1,7 @@
mod completion_provider;
mod fetch_context_picker;
mod file_context_picker;
mod rules_context_picker;
mod symbol_context_picker;
mod thread_context_picker;
@@ -18,17 +19,22 @@ use gpui::{
};
use multi_buffer::MultiBufferRow;
use project::{Entry, ProjectPath};
use prompt_store::UserPromptId;
use rules_context_picker::RulesContextEntry;
use symbol_context_picker::SymbolContextPicker;
use thread_context_picker::{ThreadContextEntry, render_thread_context_entry};
use ui::{
ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*,
};
use uuid::Uuid;
use workspace::{Workspace, notifications::NotifyResultExt};
use crate::AssistantPanel;
use crate::context::RULES_ICON;
pub use crate::context_picker::completion_provider::ContextPickerCompletionProvider;
use crate::context_picker::fetch_context_picker::FetchContextPicker;
use crate::context_picker::file_context_picker::FileContextPicker;
use crate::context_picker::rules_context_picker::RulesContextPicker;
use crate::context_picker::thread_context_picker::ThreadContextPicker;
use crate::context_store::ContextStore;
use crate::thread::ThreadId;
@@ -40,6 +46,7 @@ enum ContextPickerMode {
Symbol,
Fetch,
Thread,
Rules,
}
impl TryFrom<&str> for ContextPickerMode {
@@ -51,6 +58,7 @@ impl TryFrom<&str> for ContextPickerMode {
"symbol" => Ok(Self::Symbol),
"fetch" => Ok(Self::Fetch),
"thread" => Ok(Self::Thread),
"rules" => Ok(Self::Rules),
_ => Err(format!("Invalid context picker mode: {}", value)),
}
}
@@ -63,6 +71,7 @@ impl ContextPickerMode {
Self::Symbol => "symbol",
Self::Fetch => "fetch",
Self::Thread => "thread",
Self::Rules => "rules",
}
}
@@ -72,6 +81,7 @@ impl ContextPickerMode {
Self::Symbol => "Symbols",
Self::Fetch => "Fetch",
Self::Thread => "Threads",
Self::Rules => "Rules",
}
}
@@ -81,6 +91,7 @@ impl ContextPickerMode {
Self::Symbol => IconName::Code,
Self::Fetch => IconName::Globe,
Self::Thread => IconName::MessageBubbles,
Self::Rules => RULES_ICON,
}
}
}
@@ -92,6 +103,7 @@ enum ContextPickerState {
Symbol(Entity<SymbolContextPicker>),
Fetch(Entity<FetchContextPicker>),
Thread(Entity<ThreadContextPicker>),
Rules(Entity<RulesContextPicker>),
}
pub(super) struct ContextPicker {
@@ -253,6 +265,19 @@ impl ContextPicker {
}));
}
}
ContextPickerMode::Rules => {
if let Some(thread_store) = self.thread_store.as_ref() {
self.mode = ContextPickerState::Rules(cx.new(|cx| {
RulesContextPicker::new(
thread_store.clone(),
context_picker.clone(),
self.context_store.clone(),
window,
cx,
)
}));
}
}
}
cx.notify();
@@ -381,6 +406,7 @@ impl ContextPicker {
ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()),
ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()),
ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()),
ContextPickerState::Rules(entity) => entity.update(cx, |_, cx| cx.notify()),
}
}
}
@@ -395,6 +421,7 @@ impl Focusable for ContextPicker {
ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx),
ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx),
ContextPickerState::Rules(user_rules_picker) => user_rules_picker.focus_handle(cx),
}
}
}
@@ -410,6 +437,9 @@ impl Render for ContextPicker {
ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()),
ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()),
ContextPickerState::Rules(user_rules_picker) => {
parent.child(user_rules_picker.clone())
}
})
}
}
@@ -431,6 +461,7 @@ fn supported_context_picker_modes(
];
if thread_store.is_some() {
modes.push(ContextPickerMode::Thread);
modes.push(ContextPickerMode::Rules);
}
modes
}
@@ -626,6 +657,7 @@ pub enum MentionLink {
Symbol(ProjectPath, String),
Fetch(String),
Thread(ThreadId),
Rules(UserPromptId),
}
impl MentionLink {
@@ -633,14 +665,16 @@ impl MentionLink {
const SYMBOL: &str = "@symbol";
const THREAD: &str = "@thread";
const FETCH: &str = "@fetch";
const RULES: &str = "@rules";
const SEPARATOR: &str = ":";
pub fn is_valid(url: &str) -> bool {
url.starts_with(Self::FILE)
|| url.starts_with(Self::SYMBOL)
|| url.starts_with(Self::FETCH)
|| url.starts_with(Self::THREAD)
|| url.starts_with(Self::FETCH)
|| url.starts_with(Self::RULES)
}
pub fn for_file(file_name: &str, full_path: &str) -> String {
@@ -657,12 +691,16 @@ impl MentionLink {
)
}
pub fn for_thread(thread: &ThreadContextEntry) -> String {
format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
}
pub fn for_fetch(url: &str) -> String {
format!("[@{}]({}:{})", url, Self::FETCH, url)
}
pub fn for_thread(thread: &ThreadContextEntry) -> String {
format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
pub fn for_rules(rules: &RulesContextEntry) -> String {
format!("[@{}]({}:{})", rules.title, Self::RULES, rules.prompt_id.0)
}
pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
@@ -706,6 +744,10 @@ impl MentionLink {
Some(MentionLink::Thread(thread_id))
}
Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
Self::RULES => {
let prompt_id = UserPromptId(Uuid::try_parse(argument).ok()?);
Some(MentionLink::Rules(prompt_id))
}
_ => None,
}
}

View File

@@ -14,11 +14,13 @@ use http_client::HttpClientWithUrl;
use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext;
use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId};
use prompt_store::PromptId;
use rope::Point;
use text::{Anchor, ToPoint};
use ui::prelude::*;
use workspace::Workspace;
use crate::context::RULES_ICON;
use crate::context_picker::file_context_picker::search_files;
use crate::context_picker::symbol_context_picker::search_symbols;
use crate::context_store::ContextStore;
@@ -26,6 +28,7 @@ use crate::thread_store::ThreadStore;
use super::fetch_context_picker::fetch_url_content;
use super::file_context_picker::FileMatch;
use super::rules_context_picker::{RulesContextEntry, search_rules};
use super::symbol_context_picker::SymbolMatch;
use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
use super::{
@@ -38,6 +41,7 @@ pub(crate) enum Match {
File(FileMatch),
Thread(ThreadMatch),
Fetch(SharedString),
Rules(RulesContextEntry),
Mode(ModeMatch),
}
@@ -54,6 +58,7 @@ impl Match {
Match::Thread(_) => 1.,
Match::Symbol(_) => 1.,
Match::Fetch(_) => 1.,
Match::Rules(_) => 1.,
}
}
}
@@ -112,6 +117,21 @@ fn search(
Task::ready(Vec::new())
}
}
Some(ContextPickerMode::Rules) => {
if let Some(thread_store) = thread_store.as_ref().and_then(|t| t.upgrade()) {
let search_rules_task =
search_rules(query.clone(), cancellation_flag.clone(), thread_store, cx);
cx.background_spawn(async move {
search_rules_task
.await
.into_iter()
.map(Match::Rules)
.collect::<Vec<_>>()
})
} else {
Task::ready(Vec::new())
}
}
None => {
if query.is_empty() {
let mut matches = recent_entries
@@ -287,6 +307,60 @@ impl ContextPickerCompletionProvider {
}
}
fn completion_for_rules(
rules: RulesContextEntry,
excerpt_id: ExcerptId,
source_range: Range<Anchor>,
editor: Entity<Editor>,
context_store: Entity<ContextStore>,
thread_store: Entity<ThreadStore>,
) -> Completion {
let new_text = MentionLink::for_rules(&rules);
let new_text_len = new_text.len();
Completion {
replace_range: source_range.clone(),
new_text,
label: CodeLabel::plain(rules.title.to_string(), None),
documentation: None,
insert_text_mode: None,
source: project::CompletionSource::Custom,
icon_path: Some(RULES_ICON.path().into()),
confirm: Some(confirm_completion_callback(
RULES_ICON.path().into(),
rules.title.clone(),
excerpt_id,
source_range.start,
new_text_len,
editor.clone(),
move |cx| {
let prompt_uuid = rules.prompt_id;
let prompt_id = PromptId::User { uuid: prompt_uuid };
let context_store = context_store.clone();
let Some(prompt_store) = thread_store.read(cx).prompt_store() else {
log::error!("Can't add user rules as prompt store is missing.");
return;
};
let prompt_store = prompt_store.read(cx);
let Some(metadata) = prompt_store.metadata(prompt_id) else {
return;
};
let Some(title) = metadata.title else {
return;
};
let text_task = prompt_store.load(prompt_id, cx);
cx.spawn(async move |cx| {
let text = text_task.await?;
context_store.update(cx, |context_store, cx| {
context_store.add_rules(prompt_uuid, title, text, false, cx)
})
})
.detach_and_log_err(cx);
},
)),
}
}
fn completion_for_fetch(
source_range: Range<Anchor>,
url_to_fetch: SharedString,
@@ -593,6 +667,17 @@ impl CompletionProvider for ContextPickerCompletionProvider {
thread_store,
))
}
Match::Rules(user_rules) => {
let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?;
Some(Self::completion_for_rules(
user_rules,
excerpt_id,
source_range.clone(),
editor.clone(),
context_store.clone(),
thread_store,
))
}
Match::Fetch(url) => Some(Self::completion_for_fetch(
source_range.clone(),
url,

View File

@@ -0,0 +1,248 @@
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use anyhow::anyhow;
use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity};
use picker::{Picker, PickerDelegate};
use prompt_store::{PromptId, UserPromptId};
use ui::{ListItem, prelude::*};
use crate::context::RULES_ICON;
use crate::context_picker::ContextPicker;
use crate::context_store::{self, ContextStore};
use crate::thread_store::ThreadStore;
pub struct RulesContextPicker {
picker: Entity<Picker<RulesContextPickerDelegate>>,
}
impl RulesContextPicker {
pub fn new(
thread_store: WeakEntity<ThreadStore>,
context_picker: WeakEntity<ContextPicker>,
context_store: WeakEntity<context_store::ContextStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let delegate = RulesContextPickerDelegate::new(thread_store, context_picker, context_store);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
RulesContextPicker { picker }
}
}
impl Focusable for RulesContextPicker {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for RulesContextPicker {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.picker.clone()
}
}
#[derive(Debug, Clone)]
pub struct RulesContextEntry {
pub prompt_id: UserPromptId,
pub title: SharedString,
}
pub struct RulesContextPickerDelegate {
thread_store: WeakEntity<ThreadStore>,
context_picker: WeakEntity<ContextPicker>,
context_store: WeakEntity<context_store::ContextStore>,
matches: Vec<RulesContextEntry>,
selected_index: usize,
}
impl RulesContextPickerDelegate {
pub fn new(
thread_store: WeakEntity<ThreadStore>,
context_picker: WeakEntity<ContextPicker>,
context_store: WeakEntity<context_store::ContextStore>,
) -> Self {
RulesContextPickerDelegate {
thread_store,
context_picker,
context_store,
matches: Vec::new(),
selected_index: 0,
}
}
}
impl PickerDelegate for RulesContextPickerDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search available rules…".into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let Some(thread_store) = self.thread_store.upgrade() else {
return Task::ready(());
};
let search_task = search_rules(query, Arc::new(AtomicBool::default()), thread_store, cx);
cx.spawn_in(window, async move |this, cx| {
let matches = search_task.await;
this.update(cx, |this, cx| {
this.delegate.matches = matches;
this.delegate.selected_index = 0;
cx.notify();
})
.ok();
})
}
fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry) = self.matches.get(self.selected_index) else {
return;
};
let Some(thread_store) = self.thread_store.upgrade() else {
return;
};
let prompt_id = entry.prompt_id;
let load_rules_task = thread_store.update(cx, |thread_store, cx| {
thread_store.load_rules(prompt_id, cx)
});
cx.spawn(async move |this, cx| {
let (metadata, text) = load_rules_task.await?;
let Some(title) = metadata.title else {
return Err(anyhow!("Encountered user rule with no title when attempting to add it to agent context."));
};
this.update(cx, |this, cx| {
this.delegate
.context_store
.update(cx, |context_store, cx| {
context_store.add_rules(prompt_id, title, text, true, cx)
})
.ok();
})
})
.detach_and_log_err(cx);
}
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
self.context_picker
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let thread = &self.matches[ix];
Some(ListItem::new(ix).inset(true).toggle_state(selected).child(
render_thread_context_entry(thread, self.context_store.clone(), cx),
))
}
}
pub fn render_thread_context_entry(
user_rules: &RulesContextEntry,
context_store: WeakEntity<ContextStore>,
cx: &mut App,
) -> Div {
let added = context_store.upgrade().map_or(false, |ctx_store| {
ctx_store
.read(cx)
.includes_user_rules(&user_rules.prompt_id)
.is_some()
});
h_flex()
.gap_1p5()
.w_full()
.justify_between()
.child(
h_flex()
.gap_1p5()
.max_w_72()
.child(
Icon::new(RULES_ICON)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(Label::new(user_rules.title.clone()).truncate()),
)
.when(added, |el| {
el.child(
h_flex()
.gap_1()
.child(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Success),
)
.child(Label::new("Added").size(LabelSize::Small)),
)
})
}
pub(crate) fn search_rules(
query: String,
cancellation_flag: Arc<AtomicBool>,
thread_store: Entity<ThreadStore>,
cx: &mut App,
) -> Task<Vec<RulesContextEntry>> {
let Some(prompt_store) = thread_store.read(cx).prompt_store() else {
return Task::ready(vec![]);
};
let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx);
cx.background_spawn(async move {
search_task
.await
.into_iter()
.flat_map(|metadata| {
// Default prompts are filtered out as they are automatically included.
if metadata.default {
None
} else {
match metadata.id {
PromptId::EditWorkflow => None,
PromptId::User { uuid } => Some(RulesContextEntry {
prompt_id: uuid,
title: metadata.title?,
}),
}
}
})
.collect::<Vec<_>>()
})
}

View File

@@ -103,11 +103,11 @@ impl PickerDelegate for ThreadContextPickerDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let Some(threads) = self.thread_store.upgrade() else {
let Some(thread_store) = self.thread_store.upgrade() else {
return Task::ready(());
};
let search_task = search_threads(query, Arc::new(AtomicBool::default()), threads, cx);
let search_task = search_threads(query, Arc::new(AtomicBool::default()), thread_store, cx);
cx.spawn_in(window, async move |this, cx| {
let matches = search_task.await;
this.update(cx, |this, cx| {
@@ -217,15 +217,15 @@ pub(crate) fn search_threads(
thread_store: Entity<ThreadStore>,
cx: &mut App,
) -> Task<Vec<ThreadMatch>> {
let threads = thread_store.update(cx, |this, _cx| {
this.threads()
.into_iter()
.map(|thread| ThreadContextEntry {
id: thread.id,
summary: thread.summary,
})
.collect::<Vec<_>>()
});
let threads = thread_store
.read(cx)
.threads()
.into_iter()
.map(|thread| ThreadContextEntry {
id: thread.id,
summary: thread.summary,
})
.collect::<Vec<_>>();
let executor = cx.background_executor().clone();
cx.background_spawn(async move {

View File

@@ -8,7 +8,8 @@ use futures::future::join_all;
use futures::{self, Future, FutureExt, future};
use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity};
use language::{Buffer, File};
use project::{Project, ProjectItem, ProjectPath, Worktree};
use project::{Project, ProjectEntryId, ProjectItem, ProjectPath, Worktree};
use prompt_store::UserPromptId;
use rope::{Point, Rope};
use text::{Anchor, BufferId, OffsetRangeExt};
use util::{ResultExt as _, maybe};
@@ -16,7 +17,7 @@ use util::{ResultExt as _, maybe};
use crate::ThreadStore;
use crate::context::{
AssistantContext, ContextBuffer, ContextId, ContextSymbol, ContextSymbolId, DirectoryContext,
ExcerptContext, FetchedUrlContext, FileContext, SymbolContext, ThreadContext,
ExcerptContext, FetchedUrlContext, FileContext, RulesContext, SymbolContext, ThreadContext,
};
use crate::context_strip::SuggestedContext;
use crate::thread::{Thread, ThreadId};
@@ -25,7 +26,6 @@ pub struct ContextStore {
project: WeakEntity<Project>,
context: Vec<AssistantContext>,
thread_store: Option<WeakEntity<ThreadStore>>,
// TODO: If an EntityId is used for all context types (like BufferId), can remove ContextId.
next_context_id: ContextId,
files: BTreeMap<BufferId, ContextId>,
directories: HashMap<ProjectPath, ContextId>,
@@ -35,6 +35,7 @@ pub struct ContextStore {
threads: HashMap<ThreadId, ContextId>,
thread_summary_tasks: Vec<Task<()>>,
fetched_urls: HashMap<String, ContextId>,
user_rules: HashMap<UserPromptId, ContextId>,
}
impl ContextStore {
@@ -55,6 +56,7 @@ impl ContextStore {
threads: HashMap::default(),
thread_summary_tasks: Vec::new(),
fetched_urls: HashMap::default(),
user_rules: HashMap::default(),
}
}
@@ -72,6 +74,7 @@ impl ContextStore {
self.directories.clear();
self.threads.clear();
self.fetched_urls.clear();
self.user_rules.clear();
}
pub fn add_file_from_path(
@@ -159,6 +162,14 @@ impl ContextStore {
return Task::ready(Err(anyhow!("failed to read project")));
};
let Some(entry_id) = project
.read(cx)
.entry_for_path(&project_path, cx)
.map(|entry| entry.id)
else {
return Task::ready(Err(anyhow!("no entry found for directory context")));
};
let already_included = match self.includes_directory(&project_path) {
Some(FileInclusion::Direct(context_id)) => {
if remove_if_exists {
@@ -228,7 +239,7 @@ impl ContextStore {
}
this.update(cx, |this, cx| {
this.insert_directory(worktree, project_path, context_buffers, cx);
this.insert_directory(worktree, entry_id, project_path, context_buffers, cx);
})?;
anyhow::Ok(())
@@ -238,19 +249,21 @@ impl ContextStore {
fn insert_directory(
&mut self,
worktree: Entity<Worktree>,
entry_id: ProjectEntryId,
project_path: ProjectPath,
context_buffers: Vec<ContextBuffer>,
cx: &mut Context<Self>,
) {
let id = self.next_context_id.post_inc();
let path = project_path.path.clone();
let last_path = project_path.path.clone();
self.directories.insert(project_path, id);
self.context
.push(AssistantContext::Directory(DirectoryContext {
id,
worktree,
path,
entry_id,
last_path,
context_buffers,
}));
cx.notify();
@@ -390,6 +403,42 @@ impl ContextStore {
cx.notify();
}
pub fn add_rules(
&mut self,
prompt_id: UserPromptId,
title: impl Into<SharedString>,
text: impl Into<SharedString>,
remove_if_exists: bool,
cx: &mut Context<ContextStore>,
) {
if let Some(context_id) = self.includes_user_rules(&prompt_id) {
if remove_if_exists {
self.remove_context(context_id, cx);
}
} else {
self.insert_user_rules(prompt_id, title, text, cx);
}
}
pub fn insert_user_rules(
&mut self,
prompt_id: UserPromptId,
title: impl Into<SharedString>,
text: impl Into<SharedString>,
cx: &mut Context<ContextStore>,
) {
let id = self.next_context_id.post_inc();
self.user_rules.insert(prompt_id, id);
self.context.push(AssistantContext::Rules(RulesContext {
id,
prompt_id,
title: title.into(),
text: text.into(),
}));
cx.notify();
}
pub fn add_fetched_url(
&mut self,
url: String,
@@ -518,6 +567,9 @@ impl ContextStore {
AssistantContext::Thread(_) => {
self.threads.retain(|_, context_id| *context_id != id);
}
AssistantContext::Rules(RulesContext { prompt_id, .. }) => {
self.user_rules.remove(&prompt_id);
}
}
cx.notify();
@@ -614,6 +666,10 @@ impl ContextStore {
self.threads.get(thread_id).copied()
}
pub fn includes_user_rules(&self, prompt_id: &UserPromptId) -> Option<ContextId> {
self.user_rules.get(prompt_id).copied()
}
pub fn includes_url(&self, url: &str) -> Option<ContextId> {
self.fetched_urls.get(url).copied()
}
@@ -641,7 +697,8 @@ impl ContextStore {
| AssistantContext::Symbol(_)
| AssistantContext::Excerpt(_)
| AssistantContext::FetchedUrl(_)
| AssistantContext::Thread(_) => None,
| AssistantContext::Thread(_)
| AssistantContext::Rules(_) => None,
})
.collect()
}
@@ -828,6 +885,7 @@ pub fn refresh_context_store_text(
let task = maybe!({
match context {
AssistantContext::File(file_context) => {
// TODO: Should refresh if the path has changed, as it's in the text.
if changed_buffers.is_empty()
|| changed_buffers.contains(&file_context.context_buffer.buffer)
{
@@ -836,8 +894,9 @@ pub fn refresh_context_store_text(
}
}
AssistantContext::Directory(directory_context) => {
let directory_path = directory_context.project_path(cx);
let should_refresh = changed_buffers.is_empty()
let directory_path = directory_context.project_path(cx)?;
let should_refresh = directory_path.path != directory_context.last_path
|| changed_buffers.is_empty()
|| changed_buffers.iter().any(|buffer| {
let Some(buffer_path) = buffer.read(cx).project_path(cx) else {
return false;
@@ -847,10 +906,16 @@ pub fn refresh_context_store_text(
if should_refresh {
let context_store = context_store.clone();
return refresh_directory_text(context_store, directory_context, cx);
return refresh_directory_text(
context_store,
directory_context,
directory_path,
cx,
);
}
}
AssistantContext::Symbol(symbol_context) => {
// TODO: Should refresh if the path has changed, as it's in the text.
if changed_buffers.is_empty()
|| changed_buffers.contains(&symbol_context.context_symbol.buffer)
{
@@ -859,6 +924,7 @@ pub fn refresh_context_store_text(
}
}
AssistantContext::Excerpt(excerpt_context) => {
// TODO: Should refresh if the path has changed, as it's in the text.
if changed_buffers.is_empty()
|| changed_buffers.contains(&excerpt_context.context_buffer.buffer)
{
@@ -876,6 +942,10 @@ pub fn refresh_context_store_text(
// and doing the caching properly could be tricky (unless it's already handled by
// the HttpClient?).
AssistantContext::FetchedUrl(_) => {}
AssistantContext::Rules(user_rules_context) => {
let context_store = context_store.clone();
return Some(refresh_user_rules(context_store, user_rules_context, cx));
}
}
None
@@ -914,6 +984,7 @@ fn refresh_file_text(
fn refresh_directory_text(
context_store: Entity<ContextStore>,
directory_context: &DirectoryContext,
directory_path: ProjectPath,
cx: &App,
) -> Option<Task<()>> {
let mut stale = false;
@@ -938,7 +1009,8 @@ fn refresh_directory_text(
let id = directory_context.id;
let worktree = directory_context.worktree.clone();
let path = directory_context.path.clone();
let entry_id = directory_context.entry_id;
let last_path = directory_path.path;
Some(cx.spawn(async move |cx| {
let context_buffers = context_buffers.await;
context_store
@@ -946,7 +1018,8 @@ fn refresh_directory_text(
let new_directory_context = DirectoryContext {
id,
worktree,
path,
entry_id,
last_path,
context_buffers,
};
context_store.replace_context(AssistantContext::Directory(new_directory_context));
@@ -1026,6 +1099,45 @@ fn refresh_thread_text(
})
}
fn refresh_user_rules(
context_store: Entity<ContextStore>,
user_rules_context: &RulesContext,
cx: &App,
) -> Task<()> {
let id = user_rules_context.id;
let prompt_id = user_rules_context.prompt_id;
let Some(thread_store) = context_store.read(cx).thread_store.as_ref() else {
return Task::ready(());
};
let Ok(load_task) = thread_store.read_with(cx, |thread_store, cx| {
thread_store.load_rules(prompt_id, cx)
}) else {
return Task::ready(());
};
cx.spawn(async move |cx| {
if let Ok((metadata, text)) = load_task.await {
if let Some(title) = metadata.title.clone() {
context_store
.update(cx, |context_store, _cx| {
context_store.replace_context(AssistantContext::Rules(RulesContext {
id,
prompt_id,
title,
text: text.into(),
}));
})
.ok();
return;
}
}
context_store
.update(cx, |context_store, cx| {
context_store.remove_context(id, cx);
})
.ok();
})
}
fn refresh_context_buffer(
context_buffer: &ContextBuffer,
cx: &App,

View File

@@ -774,7 +774,9 @@ impl Thread {
cx,
);
}
AssistantContext::FetchedUrl(_) | AssistantContext::Thread(_) => {}
AssistantContext::FetchedUrl(_)
| AssistantContext::Thread(_)
| AssistantContext::Rules(_) => {}
}
}
});
@@ -1291,12 +1293,27 @@ impl Thread {
thread.insert_message(Role::Assistant, vec![], cx)
});
thread.tool_use.request_tool_use(
let tool_use_id = tool_use.id.clone();
let streamed_input = if tool_use.is_input_complete {
None
} else {
Some((&tool_use.input).clone())
};
let ui_text = thread.tool_use.request_tool_use(
last_assistant_message_id,
tool_use,
tool_use_metadata.clone(),
cx,
);
if let Some(input) = streamed_input {
cx.emit(ThreadEvent::StreamedToolUse {
tool_use_id,
ui_text,
input,
});
}
}
}
@@ -2187,6 +2204,11 @@ pub enum ThreadEvent {
StreamedCompletion,
StreamedAssistantText(MessageId, String),
StreamedAssistantThinking(MessageId, String),
StreamedToolUse {
tool_use_id: LanguageModelToolUseId,
ui_text: Arc<str>,
input: serde_json::Value,
},
Stopped(Result<StopReason, Arc<anyhow::Error>>),
MessageAdded(MessageId),
MessageEdited(MessageId),

View File

@@ -24,8 +24,8 @@ use heed::types::SerdeBincode;
use language_model::{LanguageModelToolUseId, Role, TokenUsage};
use project::{Project, Worktree};
use prompt_store::{
DefaultUserRulesContext, ProjectContext, PromptBuilder, PromptId, PromptStore,
PromptsUpdatedEvent, RulesFileContext, WorktreeContext,
ProjectContext, PromptBuilder, PromptId, PromptMetadata, PromptStore, PromptsUpdatedEvent,
RulesFileContext, UserPromptId, UserRulesContext, WorktreeContext,
};
use serde::{Deserialize, Serialize};
use settings::{Settings as _, SettingsStore};
@@ -62,6 +62,7 @@ pub struct ThreadStore {
project: Entity<Project>,
tools: Entity<ToolWorkingSet>,
prompt_builder: Arc<PromptBuilder>,
prompt_store: Option<Entity<PromptStore>>,
context_server_manager: Entity<ContextServerManager>,
context_server_tool_ids: HashMap<Arc<str>, Vec<ToolId>>,
threads: Vec<SerializedThreadMetadata>,
@@ -135,6 +136,7 @@ impl ThreadStore {
let (ready_tx, ready_rx) = oneshot::channel();
let mut ready_tx = Some(ready_tx);
let reload_system_prompt_task = cx.spawn({
let prompt_store = prompt_store.clone();
async move |thread_store, cx| {
loop {
let Some(reload_task) = thread_store
@@ -158,6 +160,7 @@ impl ThreadStore {
project,
tools,
prompt_builder,
prompt_store,
context_server_manager,
context_server_tool_ids: HashMap::default(),
threads: Vec::new(),
@@ -245,7 +248,7 @@ impl ThreadStore {
let default_user_rules = default_user_rules
.into_iter()
.flat_map(|(contents, prompt_metadata)| match contents {
Ok(contents) => Some(DefaultUserRulesContext {
Ok(contents) => Some(UserRulesContext {
uuid: match prompt_metadata.id {
PromptId::User { uuid } => uuid,
PromptId::EditWorkflow => return None,
@@ -346,6 +349,27 @@ impl ThreadStore {
self.context_server_manager.clone()
}
pub fn prompt_store(&self) -> Option<Entity<PromptStore>> {
self.prompt_store.clone()
}
pub fn load_rules(
&self,
prompt_id: UserPromptId,
cx: &App,
) -> Task<Result<(PromptMetadata, String)>> {
let prompt_id = PromptId::User { uuid: prompt_id };
let Some(prompt_store) = self.prompt_store.as_ref() else {
return Task::ready(Err(anyhow!("Prompt store unexpectedly missing.")));
};
let prompt_store = prompt_store.read(cx);
let Some(metadata) = prompt_store.metadata(prompt_id) else {
return Task::ready(Err(anyhow!("User rules not found in library.")));
};
let text_task = prompt_store.load(prompt_id, cx);
cx.background_spawn(async move { Ok((metadata, text_task.await?)) })
}
pub fn tools(&self) -> Entity<ToolWorkingSet> {
self.tools.clone()
}

View File

@@ -75,6 +75,7 @@ impl ToolUseState {
id: tool_use.id.clone(),
name: tool_use.name.clone().into(),
input: tool_use.input.clone(),
is_input_complete: true,
})
.collect::<Vec<_>>();
@@ -176,6 +177,9 @@ impl ToolUseState {
PendingToolUseStatus::Error(ref err) => {
ToolUseStatus::Error(err.clone().into())
}
PendingToolUseStatus::InputStillStreaming => {
ToolUseStatus::InputStillStreaming
}
}
} else {
ToolUseStatus::Pending
@@ -192,7 +196,12 @@ impl ToolUseState {
tool_uses.push(ToolUse {
id: tool_use.id.clone(),
name: tool_use.name.clone().into(),
ui_text: self.tool_ui_label(&tool_use.name, &tool_use.input, cx),
ui_text: self.tool_ui_label(
&tool_use.name,
&tool_use.input,
tool_use.is_input_complete,
cx,
),
input: tool_use.input.clone(),
status,
icon,
@@ -207,10 +216,15 @@ impl ToolUseState {
&self,
tool_name: &str,
input: &serde_json::Value,
is_input_complete: bool,
cx: &App,
) -> SharedString {
if let Some(tool) = self.tools.read(cx).tool(tool_name, cx) {
tool.ui_text(input).into()
if is_input_complete {
tool.ui_text(input).into()
} else {
tool.still_streaming_ui_text(input).into()
}
} else {
format!("Unknown tool {tool_name:?}").into()
}
@@ -258,22 +272,50 @@ impl ToolUseState {
tool_use: LanguageModelToolUse,
metadata: ToolUseMetadata,
cx: &App,
) {
self.tool_uses_by_assistant_message
) -> Arc<str> {
let tool_uses = self
.tool_uses_by_assistant_message
.entry(assistant_message_id)
.or_default()
.push(tool_use.clone());
.or_default();
self.tool_use_metadata_by_id
.insert(tool_use.id.clone(), metadata);
let mut existing_tool_use_found = false;
// The tool use is being requested by the Assistant, so we want to
// attach the tool results to the next user message.
let next_user_message_id = MessageId(assistant_message_id.0 + 1);
self.tool_uses_by_user_message
.entry(next_user_message_id)
.or_default()
.push(tool_use.id.clone());
for existing_tool_use in tool_uses.iter_mut() {
if existing_tool_use.id == tool_use.id {
*existing_tool_use = tool_use.clone();
existing_tool_use_found = true;
}
}
if !existing_tool_use_found {
tool_uses.push(tool_use.clone());
}
let status = if tool_use.is_input_complete {
self.tool_use_metadata_by_id
.insert(tool_use.id.clone(), metadata);
// The tool use is being requested by the Assistant, so we want to
// attach the tool results to the next user message.
let next_user_message_id = MessageId(assistant_message_id.0 + 1);
self.tool_uses_by_user_message
.entry(next_user_message_id)
.or_default()
.push(tool_use.id.clone());
PendingToolUseStatus::Idle
} else {
PendingToolUseStatus::InputStillStreaming
};
let ui_text: Arc<str> = self
.tool_ui_label(
&tool_use.name,
&tool_use.input,
tool_use.is_input_complete,
cx,
)
.into();
self.pending_tool_uses_by_id.insert(
tool_use.id.clone(),
@@ -281,13 +323,13 @@ impl ToolUseState {
assistant_message_id,
id: tool_use.id,
name: tool_use.name.clone(),
ui_text: self
.tool_ui_label(&tool_use.name, &tool_use.input, cx)
.into(),
ui_text: ui_text.clone(),
input: tool_use.input,
status: PendingToolUseStatus::Idle,
status,
},
);
ui_text
}
pub fn run_pending_tool(
@@ -497,6 +539,7 @@ pub struct Confirmation {
#[derive(Debug, Clone)]
pub enum PendingToolUseStatus {
InputStillStreaming,
Idle,
NeedsConfirmation(Arc<Confirmation>),
Running { _task: Shared<Task<()>> },

View File

@@ -264,10 +264,14 @@ impl AddedContext {
}
AssistantContext::Directory(directory_context) => {
let full_path = directory_context
.worktree
.read(cx)
.full_path(&directory_context.path);
let worktree = directory_context.worktree.read(cx);
// If the directory no longer exists, use its last known path.
let full_path = worktree
.entry_for_id(directory_context.entry_id)
.map_or_else(
|| directory_context.last_path.clone(),
|entry| worktree.full_path(&entry.path).into(),
);
let full_path_string: SharedString =
full_path.to_string_lossy().into_owned().into();
let name = full_path
@@ -354,6 +358,16 @@ impl AddedContext {
.read(cx)
.is_generating_detailed_summary(),
},
AssistantContext::Rules(user_rules_context) => AddedContext {
id: user_rules_context.id,
kind: ContextKind::Rules,
name: user_rules_context.title.clone(),
parent: None,
tooltip: None,
icon_path: None,
summarizing: false,
},
}
}
}

View File

@@ -27,7 +27,7 @@ use language_model::{
};
use project::Project;
use prompt_library::{PromptLibrary, open_prompt_library};
use prompt_store::{PromptBuilder, PromptId};
use prompt_store::{PromptBuilder, PromptId, UserPromptId};
use search::{BufferSearchBar, buffer_search::DivRegistrar};
use settings::{Settings, update_settings_file};
@@ -58,11 +58,11 @@ pub fn init(cx: &mut App) {
.register_action(AssistantPanel::show_configuration)
.register_action(AssistantPanel::create_new_context)
.register_action(AssistantPanel::restart_context_servers)
.register_action(|workspace, _: &OpenPromptLibrary, window, cx| {
.register_action(|workspace, action: &OpenPromptLibrary, window, cx| {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
workspace.focus_panel::<AssistantPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_prompt_library(&OpenPromptLibrary::default(), window, cx)
panel.deploy_prompt_library(action, window, cx)
});
}
});
@@ -1060,7 +1060,9 @@ impl AssistantPanel {
None,
))
}),
action.prompt_to_focus.map(|uuid| PromptId::User { uuid }),
action.prompt_to_select.map(|uuid| PromptId::User {
uuid: UserPromptId(uuid),
}),
cx,
)
.detach_and_log_err(cx);

View File

@@ -44,9 +44,10 @@ impl SlashCommand for PromptSlashCommand {
let store = PromptStore::global(cx);
let query = arguments.to_owned().join(" ");
cx.spawn(async move |cx| {
let cancellation_flag = Arc::new(AtomicBool::default());
let prompts: Vec<PromptMetadata> = store
.await?
.read_with(cx, |store, cx| store.search(query, cx))?
.read_with(cx, |store, cx| store.search(query, cancellation_flag, cx))?
.await;
Ok(prompts
.into_iter()

View File

@@ -30,6 +30,7 @@ pub fn init(cx: &mut App) {
#[derive(Debug, Clone)]
pub enum ToolUseStatus {
InputStillStreaming,
NeedsConfirmation,
Pending,
Running,
@@ -41,6 +42,7 @@ impl ToolUseStatus {
pub fn text(&self) -> SharedString {
match self {
ToolUseStatus::NeedsConfirmation => "".into(),
ToolUseStatus::InputStillStreaming => "".into(),
ToolUseStatus::Pending => "".into(),
ToolUseStatus::Running => "".into(),
ToolUseStatus::Finished(out) => out.clone(),
@@ -148,6 +150,12 @@ pub trait Tool: 'static + Send + Sync {
/// Returns markdown to be displayed in the UI for this tool.
fn ui_text(&self, input: &serde_json::Value) -> String;
/// Returns markdown to be displayed in the UI for this tool, while the input JSON is still streaming
/// (so information may be missing).
fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
self.ui_text(input)
}
/// Runs the tool with the provided input.
fn run(
self: Arc<Self>,

View File

@@ -33,8 +33,18 @@ pub struct CreateFileToolInput {
pub contents: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct PartialInput {
#[serde(default)]
path: String,
#[serde(default)]
contents: String,
}
pub struct CreateFileTool;
const DEFAULT_UI_TEXT: &str = "Create file";
impl Tool for CreateFileTool {
fn name(&self) -> String {
"create_file".into()
@@ -62,7 +72,14 @@ impl Tool for CreateFileTool {
let path = MarkdownString::inline_code(&input.path);
format!("Create file {path}")
}
Err(_) => "Create file".to_string(),
Err(_) => DEFAULT_UI_TEXT.to_string(),
}
}
fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
match serde_json::from_value::<PartialInput>(input.clone()).ok() {
Some(input) if !input.path.is_empty() => input.path,
_ => DEFAULT_UI_TEXT.to_string(),
}
}
@@ -111,3 +128,60 @@ impl Tool for CreateFileTool {
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn still_streaming_ui_text_with_path() {
let tool = CreateFileTool;
let input = json!({
"path": "src/main.rs",
"contents": "fn main() {\n println!(\"Hello, world!\");\n}"
});
assert_eq!(tool.still_streaming_ui_text(&input), "src/main.rs");
}
#[test]
fn still_streaming_ui_text_without_path() {
let tool = CreateFileTool;
let input = json!({
"path": "",
"contents": "fn main() {\n println!(\"Hello, world!\");\n}"
});
assert_eq!(tool.still_streaming_ui_text(&input), DEFAULT_UI_TEXT);
}
#[test]
fn still_streaming_ui_text_with_null() {
let tool = CreateFileTool;
let input = serde_json::Value::Null;
assert_eq!(tool.still_streaming_ui_text(&input), DEFAULT_UI_TEXT);
}
#[test]
fn ui_text_with_valid_input() {
let tool = CreateFileTool;
let input = json!({
"path": "src/main.rs",
"contents": "fn main() {\n println!(\"Hello, world!\");\n}"
});
assert_eq!(tool.ui_text(&input), "Create file `src/main.rs`");
}
#[test]
fn ui_text_with_invalid_input() {
let tool = CreateFileTool;
let input = json!({
"invalid": "field"
});
assert_eq!(tool.ui_text(&input), DEFAULT_UI_TEXT);
}
}

View File

@@ -47,8 +47,22 @@ pub struct EditFileToolInput {
pub new_string: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct PartialInput {
#[serde(default)]
path: String,
#[serde(default)]
display_description: String,
#[serde(default)]
old_string: String,
#[serde(default)]
new_string: String,
}
pub struct EditFileTool;
const DEFAULT_UI_TEXT: &str = "Edit file";
impl Tool for EditFileTool {
fn name(&self) -> String {
"edit_file".into()
@@ -77,6 +91,22 @@ impl Tool for EditFileTool {
}
}
fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
let description = input.display_description.trim();
if !description.is_empty() {
return description.to_string();
}
let path = input.path.trim();
if !path.is_empty() {
return path.to_string();
}
}
DEFAULT_UI_TEXT.to_string()
}
fn run(
self: Arc<Self>,
input: serde_json::Value,
@@ -181,3 +211,69 @@ impl Tool for EditFileTool {
}).into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn still_streaming_ui_text_with_path() {
let tool = EditFileTool;
let input = json!({
"path": "src/main.rs",
"display_description": "",
"old_string": "old code",
"new_string": "new code"
});
assert_eq!(tool.still_streaming_ui_text(&input), "src/main.rs");
}
#[test]
fn still_streaming_ui_text_with_description() {
let tool = EditFileTool;
let input = json!({
"path": "",
"display_description": "Fix error handling",
"old_string": "old code",
"new_string": "new code"
});
assert_eq!(tool.still_streaming_ui_text(&input), "Fix error handling");
}
#[test]
fn still_streaming_ui_text_with_path_and_description() {
let tool = EditFileTool;
let input = json!({
"path": "src/main.rs",
"display_description": "Fix error handling",
"old_string": "old code",
"new_string": "new code"
});
assert_eq!(tool.still_streaming_ui_text(&input), "Fix error handling");
}
#[test]
fn still_streaming_ui_text_no_path_or_description() {
let tool = EditFileTool;
let input = json!({
"path": "",
"display_description": "",
"old_string": "old code",
"new_string": "new code"
});
assert_eq!(tool.still_streaming_ui_text(&input), DEFAULT_UI_TEXT);
}
#[test]
fn still_streaming_ui_text_with_null() {
let tool = EditFileTool;
let input = serde_json::Value::Null;
assert_eq!(tool.still_streaming_ui_text(&input), DEFAULT_UI_TEXT);
}
}

View File

@@ -6681,7 +6681,10 @@ impl Element for EditorElement {
let max_row = snapshot.max_point().row().as_f32();
// The max scroll position for the top of the window
let max_scroll_top = if matches!(snapshot.mode, EditorMode::AutoHeight { .. }) {
let max_scroll_top = if matches!(
snapshot.mode,
EditorMode::AutoHeight { .. } | EditorMode::SingleLine { .. }
) {
(max_row - height_in_lines + 1.).max(0.)
} else {
let settings = EditorSettings::get_global(cx);

View File

@@ -7,9 +7,9 @@ edition.workspace = true
[dependencies]
agent.workspace = true
anyhow.workspace = true
async-watch.workspace = true
assistant_tool.workspace = true
assistant_tools.workspace = true
async-watch.workspace = true
chrono.workspace = true
clap.workspace = true
client.workspace = true
@@ -37,6 +37,7 @@ reqwest_client.workspace = true
serde.workspace = true
settings.workspace = true
shellexpand.workspace = true
smol.workspace = true
telemetry.workspace = true
toml.workspace = true
unindent.workspace = true

View File

@@ -0,0 +1,4 @@
# Pull Request: https://github.com/zed-industries/zed/pull/27934
url = "https://github.com/zed-industries/zed.git"
revision = "889bc13b7dd61c67d894cee8a6cdd87f56c6c45b"
language_extension = "rs"

View File

@@ -0,0 +1,3 @@
1. The changes must add internal state to track whether the user is providing feedback comments and to store the comment editor instance. This includes adding new fields to the ActiveThread struct and initializing them appropriately when the thread is created.
2. When a user selects negative feedback, the application should show a UI for submitting additional comments. This includes rendering a short prompt, a multi-line text editor, and submit/cancel buttons. The editor should only be created when first needed, and the UI should be dismissed and cleaned up after submission or cancellation.
3. On submit, the system must report the negative feedback as before, and if the comment field is not empty, it must also log the comment as a separate telemetry event. Positive feedback handling should remain unchanged and bypass the comment UI entirely.

View File

@@ -0,0 +1 @@
Add support for optional user comments when the thumbs down is given on a thread. Comments should be submitted along with the reaction and logged if provided. Make sure the UI highlights the ui icon when the user clicks it.

View File

@@ -0,0 +1,3 @@
1. The first tool call should perform a regex search for terms related to "negative feedback." Since no specific file path or code snippet was provided, regex is necessary to locate relevant content. Once the matching files are found, their contents should be read.
2. Only the `zed/crates/agent/src/active_thread.rs` file needs to be edited. All logic related to reactions and negative comments should be contained within this file.
3. A comment box should appear only when the negative reaction is clicked. The positive reaction behavior should remain unchanged.

View File

@@ -40,8 +40,8 @@ struct Args {
/// Model to use (default: "claude-3-7-sonnet-latest")
#[arg(long, default_value = "claude-3-7-sonnet-latest")]
model: String,
#[arg(long, value_delimiter = ',')]
languages: Option<Vec<String>>,
#[arg(long, value_delimiter = ',', default_value = "rs,ts")]
languages: Vec<String>,
/// How many times to run each example. Note that this is currently not very efficient as N
/// worktrees will be created for the examples.
#[arg(long, default_value = "1")]
@@ -59,7 +59,6 @@ fn main() {
let args = Args::parse();
let all_available_examples = list_all_examples().unwrap();
let languages = args.languages.unwrap_or_else(|| vec!["rs".to_string()]);
let example_paths = all_available_examples
.iter()
@@ -151,7 +150,7 @@ fn main() {
.base
.language_extension
.as_ref()
.map_or(false, |lang| languages.contains(lang))
.map_or(false, |lang| args.languages.contains(lang))
{
skipped.push(example.name);
continue;

View File

@@ -323,6 +323,13 @@ impl Example {
return Err(anyhow!("Setup only mode"));
}
let example_output_dir = this.example_output_directory();
let last_diff_file_path = example_output_dir.join("last.diff");
// Write an empty "last.diff" so that it can be opened in Zed for convenient view of the
// history using undo/redo.
std::fs::write(&last_diff_file_path, "")?;
let thread_store = thread_store.await?;
let thread =
thread_store.update(cx, |thread_store, cx| thread_store.create_thread(cx))?;
@@ -330,24 +337,43 @@ impl Example {
thread.update(cx, |thread, _cx| {
let mut request_count = 0;
let example_dir_path = this.example_output_directory();
let last_request = Rc::clone(&last_request);
let previous_diff = Rc::new(RefCell::new("".to_string()));
let example_output_dir = example_output_dir.clone();
let last_diff_file_path = last_diff_file_path.clone();
let this = this.clone();
thread.set_request_callback(move |request, response_events| {
*last_request.borrow_mut() = Some(request.clone());
request_count += 1;
let messages_file_path = example_dir_path.join(format!("{request_count}.messages.md"));
let last_messages_file_path = example_dir_path.join("last.messages.md");
let messages_file_path = example_output_dir.join(format!("{request_count}.messages.md"));
let diff_file_path = example_output_dir.join(format!("{request_count}.diff"));
let last_messages_file_path = example_output_dir.join("last.messages.md");
let request_markdown = RequestMarkdown::new(request);
let response_events_markdown = response_events_to_markdown(response_events);
let messages = format!("{}\n\n{}", request_markdown.messages, response_events_markdown);
fs::write(messages_file_path, messages.clone()).expect("failed to write messages file");
fs::write(last_messages_file_path, messages).expect("failed to write last messages file");
fs::write(&messages_file_path, messages.clone()).expect("failed to write messages file");
fs::write(&last_messages_file_path, messages).expect("failed to write last messages file");
let diff_result = smol::block_on(this.repository_diff());
match diff_result {
Ok(diff) => {
if diff != previous_diff.borrow().clone() {
fs::write(&diff_file_path, &diff).expect("failed to write diff file");
fs::write(&last_diff_file_path, &diff).expect("failed to write last diff file");
*previous_diff.borrow_mut() = diff;
}
}
Err(err) => {
let error_message = format!("{err:?}");
fs::write(&diff_file_path, &error_message).expect("failed to write diff error to file");
fs::write(&last_diff_file_path, &error_message).expect("failed to write last diff file");
}
}
if request_count == 1 {
let tools_file_path = example_dir_path.join("tools.md");
let tools_file_path = example_output_dir.join("tools.md");
fs::write(tools_file_path, request_markdown.tools).expect("failed to write tools file");
}
});
@@ -426,6 +452,7 @@ impl Example {
ThreadEvent::ToolConfirmationNeeded => {
panic!("{}Bug: Tool confirmation should not be required in eval", log_prefix);
},
ThreadEvent::StreamedToolUse { .. } |
ThreadEvent::StreamedCompletion |
ThreadEvent::MessageAdded(_) |
ThreadEvent::MessageEdited(_) |
@@ -459,11 +486,7 @@ impl Example {
println!("{}Getting repository diff", this.log_prefix);
let repository_diff = this.repository_diff().await?;
let example_output_dir = this.example_output_directory();
let repository_diff_path = example_output_dir.join("patch.diff");
let mut repository_diff_output_file = File::create(&repository_diff_path)?;
writeln!(&mut repository_diff_output_file, "{}", &repository_diff).log_err();
std::fs::write(last_diff_file_path, &repository_diff)?;
println!("{}Getting diagnostics", this.log_prefix);
let diagnostics_after = cx

View File

@@ -187,6 +187,7 @@ pub struct LanguageModelToolUse {
pub id: LanguageModelToolUseId,
pub name: Arc<str>,
pub input: serde_json::Value,
pub is_input_complete: bool,
}
pub struct LanguageModelTextStream {

View File

@@ -38,6 +38,7 @@ menu.workspace = true
mistral = { workspace = true, features = ["schemars"] }
ollama = { workspace = true, features = ["schemars"] }
open_ai = { workspace = true, features = ["schemars"] }
partial-json-fixer.workspace = true
project.workspace = true
proto.workspace = true
schemars.workspace = true

View File

@@ -713,6 +713,35 @@ pub fn map_to_language_model_completion_events(
ContentDelta::InputJsonDelta { partial_json } => {
if let Some(tool_use) = state.tool_uses_by_index.get_mut(&index) {
tool_use.input_json.push_str(&partial_json);
return Some((
vec![maybe!({
Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: tool_use.id.clone().into(),
name: tool_use.name.clone().into(),
is_input_complete: false,
input: if tool_use.input_json.is_empty() {
serde_json::Value::Object(
serde_json::Map::default(),
)
} else {
serde_json::Value::from_str(
// Convert invalid (incomplete) JSON into
// JSON that serde will accept, e.g. by closing
// unclosed delimiters. This way, we can update
// the UI with whatever has been streamed back so far.
&partial_json_fixer::fix_json(
&tool_use.input_json,
),
)
.map_err(|err| anyhow!(err))?
},
},
))
})],
state,
));
}
}
},
@@ -724,6 +753,7 @@ pub fn map_to_language_model_completion_events(
LanguageModelToolUse {
id: tool_use.id.into(),
name: tool_use.name.into(),
is_input_complete: true,
input: if tool_use.input_json.is_empty() {
serde_json::Value::Object(
serde_json::Map::default(),

View File

@@ -893,6 +893,7 @@ pub fn map_to_language_model_completion_events(
let tool_use_event = LanguageModelToolUse {
id: tool_use.id.into(),
name: tool_use.name.into(),
is_input_complete: true,
input: if tool_use.input_json.is_empty() {
Value::Null
} else {

View File

@@ -367,6 +367,7 @@ pub fn map_to_language_model_completion_events(
LanguageModelToolUse {
id: tool_call.id.into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input: serde_json::Value::from_str(
&tool_call.arguments,
)?,

View File

@@ -529,6 +529,7 @@ pub fn map_to_language_model_completion_events(
LanguageModelToolUse {
id,
name,
is_input_complete: true,
input: function_call_part.function_call.args,
},
)));

View File

@@ -490,6 +490,7 @@ pub fn map_to_language_model_completion_events(
LanguageModelToolUse {
id: tool_call.id.into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input: serde_json::Value::from_str(
&tool_call.arguments,
)?,

View File

@@ -192,6 +192,11 @@ impl Markdown {
self.parse(cx);
}
pub fn replace(&mut self, source: impl Into<SharedString>, cx: &mut Context<Self>) {
self.source = source.into();
self.parse(cx);
}
pub fn reset(&mut self, source: SharedString, cx: &mut Context<Self>) {
if source == self.source() {
return;

View File

@@ -11,6 +11,8 @@ use gpui::{
list,
};
use language::LanguageRegistry;
use settings::Settings;
use theme::ThemeSettings;
use ui::prelude::*;
use workspace::item::{Item, ItemHandle};
use workspace::{Pane, Workspace};
@@ -185,6 +187,7 @@ impl MarkdownPreviewView {
})
}
});
let block = contents.children.get(ix).unwrap();
let rendered_block = render_markdown_block(block, &mut render_cx);
@@ -195,7 +198,9 @@ impl MarkdownPreviewView {
div()
.id(ix)
.when(should_apply_padding, |this| this.pb_3())
.when(should_apply_padding, |this| {
this.pb(render_cx.scaled_rems(0.75))
})
.group("markdown-block")
.on_click(cx.listener(
move |this, event: &ClickEvent, window, cx| {
@@ -234,7 +239,11 @@ impl MarkdownPreviewView {
container.child(
div()
.relative()
.child(div().pl_4().child(rendered_block))
.child(
div()
.pl(render_cx.scaled_rems(1.0))
.child(rendered_block),
)
.child(indicator.absolute().left_0().top_0()),
)
})
@@ -504,6 +513,8 @@ impl Item for MarkdownPreviewView {
impl Render for MarkdownPreviewView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx);
let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height;
v_flex()
.id("MarkdownPreview")
.key_context("MarkdownPreview")
@@ -511,6 +522,8 @@ impl Render for MarkdownPreviewView {
.size_full()
.bg(cx.theme().colors().editor_background)
.p_4()
.text_size(buffer_size)
.line_height(relative(buffer_line_height.value()))
.child(
div()
.flex_grow()

View File

@@ -8,7 +8,7 @@ use gpui::{
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
Keystroke, Length, Modifiers, ParentElement, Render, Resource, SharedString, Styled,
StyledText, TextStyle, WeakEntity, Window, div, img, px, rems,
StyledText, TextStyle, WeakEntity, Window, div, img, rems,
};
use settings::Settings;
use std::{
@@ -18,10 +18,10 @@ use std::{
};
use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
use ui::{
ButtonCommon, Checkbox, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, StatefulInteractiveElement,
StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover, h_flex, relative,
tooltip_container, v_flex,
ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize,
InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, Pixels, Rems,
StatefulInteractiveElement, StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover,
h_flex, relative, tooltip_container, v_flex,
};
use workspace::{OpenOptions, OpenVisible, Workspace};
@@ -36,6 +36,7 @@ pub struct RenderContext {
text_style: TextStyle,
border_color: Hsla,
text_color: Hsla,
window_rem_size: Pixels,
text_muted_color: Hsla,
code_block_background_color: Hsla,
code_span_background_color: Hsla,
@@ -56,6 +57,7 @@ impl RenderContext {
let buffer_font_family = settings.buffer_font.family.clone();
let mut buffer_text_style = window.text_style();
buffer_text_style.font_family = buffer_font_family.clone();
buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx));
RenderContext {
workspace,
@@ -67,6 +69,7 @@ impl RenderContext {
syntax_theme: theme.syntax().clone(),
border_color: theme.colors().border,
text_color: theme.colors().text,
window_rem_size: window.rem_size(),
text_muted_color: theme.colors().text_muted,
code_block_background_color: theme.colors().surface_background,
code_span_background_color: theme.colors().editor_document_highlight_read_background,
@@ -88,6 +91,17 @@ impl RenderContext {
ElementId::from(SharedString::from(id))
}
/// HACK: used to have rems relative to buffer font size, so that things scale appropriately as
/// buffer font size changes. The callees of this function should be reimplemented to use real
/// relative sizing once that is implemented in GPUI
pub fn scaled_rems(&self, rems: f32) -> Rems {
return self
.buffer_text_style
.font_size
.to_rems(self.window_rem_size)
.mul(rems);
}
/// This ensures that children inside of block quotes
/// have padding between them.
///
@@ -103,7 +117,7 @@ impl RenderContext {
/// and "And this is the next paragraph."
fn with_common_p(&self, element: Div) -> Div {
if self.indent > 0 {
element.pb_3()
element.pb(self.scaled_rems(0.75))
} else {
element
}
@@ -141,27 +155,38 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte
fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
let size = match parsed.level {
HeadingLevel::H1 => rems(2.),
HeadingLevel::H2 => rems(1.5),
HeadingLevel::H3 => rems(1.25),
HeadingLevel::H4 => rems(1.),
HeadingLevel::H5 => rems(0.875),
HeadingLevel::H6 => rems(0.85),
HeadingLevel::H1 => 2.,
HeadingLevel::H2 => 1.5,
HeadingLevel::H3 => 1.25,
HeadingLevel::H4 => 1.,
HeadingLevel::H5 => 0.875,
HeadingLevel::H6 => 0.85,
};
let text_size = cx.scaled_rems(size);
// was `DefiniteLength::from(text_size.mul(1.25))`
// let line_height = DefiniteLength::from(text_size.mul(1.25));
let line_height = text_size * 1.25;
// was `rems(0.15)`
// let padding_top = cx.scaled_rems(0.15);
let padding_top = rems(0.15);
// was `.pb_1()` = `rems(0.25)`
// let padding_bottom = cx.scaled_rems(0.25);
let padding_bottom = rems(0.25);
let color = match parsed.level {
HeadingLevel::H6 => cx.text_muted_color,
_ => cx.text_color,
};
let line_height = DefiniteLength::from(size.mul(1.25));
div()
.line_height(line_height)
.text_size(size)
.text_size(text_size)
.text_color(color)
.pt(rems(0.15))
.pb_1()
.pt(padding_top)
.pb(padding_bottom)
.children(render_markdown_text(&parsed.contents, cx))
.whitespace_normal()
.into_any()
@@ -173,22 +198,23 @@ fn render_markdown_list_item(
) -> AnyElement {
use ParsedMarkdownListItemType::*;
let padding = rems((parsed.depth - 1) as f32);
let padding = cx.scaled_rems((parsed.depth - 1) as f32);
let bullet = match &parsed.item_type {
Ordered(order) => format!("{}.", order).into_any_element(),
Unordered => "".into_any_element(),
Task(checked, range) => div()
.id(cx.next_id(range))
.mt(px(3.))
.mt(cx.scaled_rems(3.0 / 16.0))
.child(
Checkbox::new(
MarkdownCheckbox::new(
"checkbox",
if *checked {
ToggleState::Selected
} else {
ToggleState::Unselected
},
cx.clone(),
)
.when_some(
cx.checkbox_clicked_callback.clone(),
@@ -216,7 +242,7 @@ fn render_markdown_list_item(
})
.into_any_element(),
};
let bullet = div().mr_2().child(bullet);
let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet);
let contents: Vec<AnyElement> = parsed
.content
@@ -227,11 +253,175 @@ fn render_markdown_list_item(
let item = h_flex()
.pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
.items_start()
.children(vec![bullet, div().children(contents).pr_4().w_full()]);
.children(vec![
bullet,
div().children(contents).pr(cx.scaled_rems(1.0)).w_full(),
]);
cx.with_common_p(item).into_any()
}
/// # MarkdownCheckbox ///
/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview
/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the
/// app are not visually affected
#[derive(gpui::IntoElement)]
struct MarkdownCheckbox {
id: ElementId,
toggle_state: ToggleState,
disabled: bool,
placeholder: bool,
on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
filled: bool,
style: ui::ToggleStyle,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
label: Option<SharedString>,
render_cx: RenderContext,
}
impl MarkdownCheckbox {
/// Creates a new [`Checkbox`].
fn new(id: impl Into<ElementId>, checked: ToggleState, render_cx: RenderContext) -> Self {
Self {
id: id.into(),
toggle_state: checked,
disabled: false,
on_click: None,
filled: false,
style: ui::ToggleStyle::default(),
tooltip: None,
label: None,
placeholder: false,
render_cx,
}
}
/// Binds a handler to the [`Checkbox`] that will be called when clicked.
fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
fn bg_color(&self, cx: &App) -> Hsla {
let style = self.style.clone();
match (style, self.filled) {
(ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
(ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
(ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
(ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
(ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(),
(ui::ToggleStyle::Custom(color), true) => color.opacity(0.2),
}
}
fn border_color(&self, cx: &App) -> Hsla {
if self.disabled {
return cx.theme().colors().border_variant;
}
match self.style.clone() {
ui::ToggleStyle::Ghost => cx.theme().colors().border,
ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border,
ui::ToggleStyle::Custom(color) => color.opacity(0.3),
}
}
}
impl gpui::RenderOnce for MarkdownCheckbox {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let group_id = format!("checkbox_group_{:?}", self.id);
let color = if self.disabled {
Color::Disabled
} else {
Color::Selected
};
let icon_size_small = IconSize::Custom(self.render_cx.scaled_rems(14. / 16.)); // was IconSize::Small
let icon = match self.toggle_state {
ToggleState::Selected => {
if self.placeholder {
None
} else {
Some(
ui::Icon::new(IconName::Check)
.size(icon_size_small)
.color(color),
)
}
}
ToggleState::Indeterminate => Some(
ui::Icon::new(IconName::Dash)
.size(icon_size_small)
.color(color),
),
ToggleState::Unselected => None,
};
let bg_color = self.bg_color(cx);
let border_color = self.border_color(cx);
let hover_border_color = border_color.alpha(0.7);
let size = self.render_cx.scaled_rems(1.25); // was Self::container_size(); (20px)
let checkbox = h_flex()
.id(self.id.clone())
.justify_center()
.items_center()
.size(size)
.group(group_id.clone())
.child(
div()
.flex()
.flex_none()
.justify_center()
.items_center()
.m(self.render_cx.scaled_rems(0.25)) // was .m_1
.size(self.render_cx.scaled_rems(1.0)) // was .size_4
.rounded(self.render_cx.scaled_rems(0.125)) // was .rounded_xs
.border_1()
.bg(bg_color)
.border_color(border_color)
.when(self.disabled, |this| this.cursor_not_allowed())
.when(self.disabled, |this| {
this.bg(cx.theme().colors().element_disabled.opacity(0.6))
})
.when(!self.disabled, |this| {
this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color))
})
.when(self.placeholder, |this| {
this.child(
div()
.flex_none()
.rounded_full()
.bg(color.color(cx).alpha(0.5))
.size(self.render_cx.scaled_rems(0.25)), // was .size_1
)
})
.children(icon),
);
h_flex()
.id(self.id)
.gap(ui::DynamicSpacing::Base06.rems(cx))
.child(checkbox)
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| {
this.on_click(move |_, window, cx| {
on_click(&self.toggle_state.inverse(), window, cx)
})
},
)
// TODO: Allow label size to be different from default.
// TODO: Allow label color to be different from muted.
.when_some(self.label, |this, label| {
this.child(Label::new(label).color(Color::Muted))
})
.when_some(self.tooltip, |this, tooltip| {
this.tooltip(move |window, cx| tooltip(window, cx))
})
}
}
fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize {
paragraphs
.iter()
@@ -578,8 +768,8 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
}
fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
let rule = div().w_full().h(px(2.)).bg(cx.border_color);
div().pt_3().pb_3().child(rule).into_any()
let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color);
div().py(cx.scaled_rems(0.5)).child(rule).into_any()
}
struct InteractiveMarkdownElementTooltip {

View File

@@ -331,15 +331,19 @@ impl ProjectPanel {
cx.subscribe(&project, |this, project, event, cx| match event {
project::Event::ActiveEntryChanged(Some(entry_id)) => {
if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
this.reveal_entry(project.clone(), *entry_id, true, cx);
this.reveal_entry(project.clone(), *entry_id, true, cx).ok();
}
}
project::Event::ActiveEntryChanged(None) => {
this.marked_entries.clear();
}
project::Event::RevealInProjectPanel(entry_id) => {
this.reveal_entry(project.clone(), *entry_id, false, cx);
cx.emit(PanelEvent::Activate);
if let Some(()) = this
.reveal_entry(project.clone(), *entry_id, false, cx)
.log_err()
{
cx.emit(PanelEvent::Activate);
}
}
project::Event::ActivateProjectPanel => {
cx.emit(PanelEvent::Activate);
@@ -4422,7 +4426,7 @@ impl ProjectPanel {
entry_id: ProjectEntryId,
skip_ignored: bool,
cx: &mut Context<Self>,
) {
) -> Result<()> {
if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
let worktree = worktree.read(cx);
if skip_ignored
@@ -4430,7 +4434,9 @@ impl ProjectPanel {
.entry_for_id(entry_id)
.map_or(true, |entry| entry.is_ignored && !entry.is_always_included)
{
return;
return Err(anyhow!(
"can't reveal an ignored entry in the project panel"
));
}
let worktree_id = worktree.id();
@@ -4443,6 +4449,11 @@ impl ProjectPanel {
});
self.autoscroll(cx);
cx.notify();
Ok(())
} else {
Err(anyhow!(
"can't reveal a non-existent entry in the project panel"
))
}
}

View File

@@ -16,6 +16,7 @@ use release_channel::ReleaseChannel;
use rope::Rope;
use settings::Settings;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use theme::ThemeSettings;
use ui::{
@@ -75,7 +76,7 @@ pub fn open_prompt_library(
language_registry: Arc<LanguageRegistry>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
prompt_to_focus: Option<PromptId>,
prompt_to_select: Option<PromptId>,
cx: &mut App,
) -> Task<Result<WindowHandle<PromptLibrary>>> {
let store = PromptStore::global(cx);
@@ -90,8 +91,8 @@ pub fn open_prompt_library(
if let Some(existing_window) = existing_window {
existing_window
.update(cx, |prompt_library, window, cx| {
if let Some(prompt_to_focus) = prompt_to_focus {
prompt_library.load_prompt(prompt_to_focus, true, window, cx);
if let Some(prompt_to_select) = prompt_to_select {
prompt_library.load_prompt(prompt_to_select, true, window, cx);
}
window.activate_window()
})
@@ -126,18 +127,15 @@ pub fn open_prompt_library(
},
|window, cx| {
cx.new(|cx| {
let mut prompt_library = PromptLibrary::new(
PromptLibrary::new(
store,
language_registry,
inline_assist_delegate,
make_completion_provider,
prompt_to_select,
window,
cx,
);
if let Some(prompt_to_focus) = prompt_to_focus {
prompt_library.load_prompt(prompt_to_focus, true, window, cx);
}
prompt_library
)
})
},
)
@@ -221,7 +219,8 @@ impl PickerDelegate for PromptPickerDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let search = self.store.read(cx).search(query, cx);
let cancellation_flag = Arc::new(AtomicBool::default());
let search = self.store.read(cx).search(query, cancellation_flag, cx);
let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
cx.spawn_in(window, async move |this, cx| {
let (matches, selected_index) = cx
@@ -353,13 +352,26 @@ impl PromptLibrary {
language_registry: Arc<LanguageRegistry>,
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
prompt_to_select: Option<PromptId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (selected_index, matches) = if let Some(prompt_to_select) = prompt_to_select {
let matches = store.read(cx).all_prompt_metadata();
let selected_index = matches
.iter()
.enumerate()
.find(|(_, metadata)| metadata.id == prompt_to_select)
.map_or(0, |(ix, _)| ix);
(selected_index, matches)
} else {
(0, vec![])
};
let delegate = PromptPickerDelegate {
store: store.clone(),
selected_index: 0,
matches: Vec::new(),
selected_index,
matches,
};
let picker = cx.new(|cx| {

View File

@@ -54,14 +54,14 @@ pub struct PromptMetadata {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PromptId {
User { uuid: Uuid },
User { uuid: UserPromptId },
EditWorkflow,
}
impl PromptId {
pub fn new() -> PromptId {
PromptId::User {
uuid: Uuid::new_v4(),
uuid: UserPromptId::new(),
}
}
@@ -70,6 +70,22 @@ impl PromptId {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserPromptId(pub Uuid);
impl UserPromptId {
pub fn new() -> UserPromptId {
UserPromptId(Uuid::new_v4())
}
}
impl From<Uuid> for UserPromptId {
fn from(uuid: Uuid) -> Self {
UserPromptId(uuid)
}
}
pub struct PromptStore {
env: heed::Env,
metadata_cache: RwLock<MetadataCache>,
@@ -212,7 +228,7 @@ impl PromptStore {
for (prompt_id_v1, metadata_v1) in metadata_v1 {
let prompt_id_v2 = PromptId::User {
uuid: prompt_id_v1.0,
uuid: UserPromptId(prompt_id_v1.0),
};
let Some(body_v1) = bodies_v1.remove(&prompt_id_v1) else {
continue;
@@ -257,6 +273,10 @@ impl PromptStore {
})
}
pub fn all_prompt_metadata(&self) -> Vec<PromptMetadata> {
self.metadata_cache.read().metadata.clone()
}
pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
return self
.metadata_cache
@@ -314,7 +334,12 @@ impl PromptStore {
Some(metadata.id)
}
pub fn search(&self, query: String, cx: &App) -> Task<Vec<PromptMetadata>> {
pub fn search(
&self,
query: String,
cancellation_flag: Arc<AtomicBool>,
cx: &App,
) -> Task<Vec<PromptMetadata>> {
let cached_metadata = self.metadata_cache.read().metadata.clone();
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
@@ -333,7 +358,7 @@ impl PromptStore {
&query,
false,
100,
&AtomicBool::default(),
&cancellation_flag,
executor,
)
.await;

View File

@@ -15,34 +15,32 @@ use std::{
};
use text::LineEnding;
use util::{ResultExt, get_system_shell};
use uuid::Uuid;
use crate::UserPromptId;
#[derive(Debug, Clone, Serialize)]
pub struct ProjectContext {
pub worktrees: Vec<WorktreeContext>,
/// Whether any worktree has a rules_file. Provided as a field because handlebars can't do this.
pub has_rules: bool,
pub default_user_rules: Vec<DefaultUserRulesContext>,
/// `!default_user_rules.is_empty()` - provided as a field because handlebars can't do this.
pub has_default_user_rules: bool,
pub user_rules: Vec<UserRulesContext>,
/// `!user_rules.is_empty()` - provided as a field because handlebars can't do this.
pub has_user_rules: bool,
pub os: String,
pub arch: String,
pub shell: String,
}
impl ProjectContext {
pub fn new(
worktrees: Vec<WorktreeContext>,
default_user_rules: Vec<DefaultUserRulesContext>,
) -> Self {
pub fn new(worktrees: Vec<WorktreeContext>, default_user_rules: Vec<UserRulesContext>) -> Self {
let has_rules = worktrees
.iter()
.any(|worktree| worktree.rules_file.is_some());
Self {
worktrees,
has_rules,
has_default_user_rules: !default_user_rules.is_empty(),
default_user_rules,
has_user_rules: !default_user_rules.is_empty(),
user_rules: default_user_rules,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
shell: get_system_shell(),
@@ -51,8 +49,8 @@ impl ProjectContext {
}
#[derive(Debug, Clone, Serialize)]
pub struct DefaultUserRulesContext {
pub uuid: Uuid,
pub struct UserRulesContext {
pub uuid: UserPromptId,
pub title: Option<String>,
pub contents: String,
}
@@ -397,6 +395,7 @@ impl PromptBuilder {
#[cfg(test)]
mod test {
use super::*;
use uuid::Uuid;
#[test]
fn test_assistant_system_prompt_renders() {
@@ -408,8 +407,8 @@ mod test {
text: "".into(),
}),
}];
let default_user_rules = vec![DefaultUserRulesContext {
uuid: Uuid::nil(),
let default_user_rules = vec![UserRulesContext {
uuid: UserPromptId(Uuid::nil()),
title: Some("Rules title".into()),
contents: "Rules contents".into(),
}];

View File

@@ -1023,6 +1023,7 @@ impl Vim {
}
pub fn cursor_shape(&self, cx: &mut App) -> CursorShape {
let cursor_shape = VimSettings::get_global(cx).cursor_shape;
match self.mode {
Mode::Normal => {
if let Some(operator) = self.operator_stack.last() {
@@ -1040,18 +1041,18 @@ impl Vim {
_ => CursorShape::Underline,
}
} else {
// No operator active -> Block cursor
CursorShape::Block
cursor_shape.normal.unwrap_or(CursorShape::Block)
}
}
Mode::Replace => CursorShape::Underline,
Mode::HelixNormal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
CursorShape::Block
Mode::HelixNormal => cursor_shape.normal.unwrap_or(CursorShape::Block),
Mode::Replace => cursor_shape.replace.unwrap_or(CursorShape::Underline),
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
cursor_shape.visual.unwrap_or(CursorShape::Block)
}
Mode::Insert => {
Mode::Insert => cursor_shape.insert.unwrap_or({
let editor_settings = EditorSettings::get_global(cx);
editor_settings.cursor_shape.unwrap_or_default()
}
}),
}
}
@@ -1693,6 +1694,27 @@ pub enum UseSystemClipboard {
OnYank,
}
/// The settings for cursor shape.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
struct CursorShapeSettings {
/// Cursor shape for the normal mode.
///
/// Default: block
pub normal: Option<CursorShape>,
/// Cursor shape for the replace mode.
///
/// Default: underline
pub replace: Option<CursorShape>,
/// Cursor shape for the visual mode.
///
/// Default: block
pub visual: Option<CursorShape>,
/// Cursor shape for the insert mode.
///
/// The default value follows the primary cursor_shape.
pub insert: Option<CursorShape>,
}
#[derive(Deserialize)]
struct VimSettings {
pub default_mode: Mode,
@@ -1702,6 +1724,7 @@ struct VimSettings {
pub use_smartcase_find: bool,
pub custom_digraphs: HashMap<String, Arc<str>>,
pub highlight_on_yank_duration: u64,
pub cursor_shape: CursorShapeSettings,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -1713,6 +1736,7 @@ struct VimSettingsContent {
pub use_smartcase_find: Option<bool>,
pub custom_digraphs: Option<HashMap<String, Arc<str>>>,
pub highlight_on_yank_duration: Option<u64>,
pub cursor_shape: Option<CursorShapeSettings>,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -1771,6 +1795,7 @@ impl Settings for VimSettings {
highlight_on_yank_duration: settings
.highlight_on_yank_duration
.ok_or_else(Self::missing_default)?,
cursor_shape: settings.cursor_shape.ok_or_else(Self::missing_default)?,
})
}
}

View File

@@ -201,7 +201,7 @@ pub mod assistant {
#[serde(deny_unknown_fields)]
pub struct OpenPromptLibrary {
#[serde(skip)]
pub prompt_to_focus: Option<Uuid>,
pub prompt_to_select: Option<Uuid>,
}
impl_action_with_deprecated_aliases!(