logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
[package]
|
||||
name = "agent_ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/agent_ui.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = [
|
||||
"acp_thread/test-support",
|
||||
"eval_utils",
|
||||
"gpui/test-support",
|
||||
"language/test-support",
|
||||
"reqwest_client",
|
||||
"workspace/test-support",
|
||||
"agent/test-support",
|
||||
]
|
||||
audio = ["dep:audio"]
|
||||
unit-eval = []
|
||||
|
||||
[dependencies]
|
||||
acp_thread.workspace = true
|
||||
action_log.workspace = true
|
||||
agent-client-protocol.workspace = true
|
||||
agent.workspace = true
|
||||
async-channel.workspace = true
|
||||
agent_servers.workspace = true
|
||||
agent_settings.workspace = true
|
||||
ai_onboarding.workspace = true
|
||||
anyhow.workspace = true
|
||||
heapless.workspace = true
|
||||
audio = { workspace = true, optional = true }
|
||||
base64.workspace = true
|
||||
buffer_diff.workspace = true
|
||||
chrono.workspace = true
|
||||
client.workspace = true
|
||||
cloud_api_types.workspace = true
|
||||
collections.workspace = true
|
||||
command_palette_hooks.workspace = true
|
||||
component.workspace = true
|
||||
context_server.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
eval_utils = { workspace = true, optional = true }
|
||||
extension.workspace = true
|
||||
extension_host.workspace = true
|
||||
feature_flags.workspace = true
|
||||
file_icons.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
git.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
html_to_markdown.workspace = true
|
||||
http_client.workspace = true
|
||||
indoc.workspace = true
|
||||
itertools.workspace = true
|
||||
jsonschema.workspace = true
|
||||
language.workspace = true
|
||||
language_model.workspace = true
|
||||
language_models.workspace = true
|
||||
log.workspace = true
|
||||
lsp.workspace = true
|
||||
markdown.workspace = true
|
||||
menu.workspace = true
|
||||
multi_buffer.workspace = true
|
||||
notifications.workspace = true
|
||||
ordered-float.workspace = true
|
||||
parking_lot.workspace = true
|
||||
paths.workspace = true
|
||||
picker.workspace = true
|
||||
platform_title_bar.workspace = true
|
||||
postage.workspace = true
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
proto.workspace = true
|
||||
rand.workspace = true
|
||||
release_channel.workspace = true
|
||||
remote.workspace = true
|
||||
remote_connection.workspace = true
|
||||
rope.workspace = true
|
||||
rules_library.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json_lenient.workspace = true
|
||||
settings.workspace = true
|
||||
streaming_diff.workspace = true
|
||||
task.workspace = true
|
||||
telemetry.workspace = true
|
||||
terminal.workspace = true
|
||||
terminal_view.workspace = true
|
||||
text.workspace = true
|
||||
theme.workspace = true
|
||||
theme_settings.workspace = true
|
||||
time.workspace = true
|
||||
ui.workspace = true
|
||||
ui_input.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
watch.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
image.workspace = true
|
||||
reqwest_client = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
acp_thread = { workspace = true, features = ["test-support"] }
|
||||
agent = { workspace = true, features = ["test-support"] }
|
||||
agent_servers = { workspace = true, features = ["test-support"] }
|
||||
buffer_diff = { workspace = true, features = ["test-support"] }
|
||||
client = { workspace = true, features = ["test-support"] }
|
||||
clock = { workspace = true, features = ["test-support"] }
|
||||
db = { workspace = true, features = ["test-support"] }
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
eval_utils.workspace = true
|
||||
git_ui.workspace = true
|
||||
gpui = { workspace = true, "features" = ["test-support"] }
|
||||
http_client = { workspace = true, features = ["test-support"] }
|
||||
indoc.workspace = true
|
||||
language = { workspace = true, "features" = ["test-support"] }
|
||||
languages = { workspace = true, features = ["test-support"] }
|
||||
language_model = { workspace = true, "features" = ["test-support"] }
|
||||
node_runtime = { workspace = true, features = ["test-support"] }
|
||||
pretty_assertions.workspace = true
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
remote = { workspace = true, features = ["test-support"] }
|
||||
remote_connection = { workspace = true, features = ["test-support"] }
|
||||
remote_server = { workspace = true, features = ["test-support"] }
|
||||
|
||||
search = { workspace = true, features = ["test-support"] }
|
||||
semver.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
tempfile.workspace = true
|
||||
vim.workspace = true
|
||||
tree-sitter-md.workspace = true
|
||||
unindent.workspace = true
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,876 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use collections::HashSet;
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt,
|
||||
};
|
||||
use language_model::LanguageModelRegistry;
|
||||
use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities};
|
||||
use settings::{OpenAiCompatibleSettingsContent, update_settings_file};
|
||||
use ui::{
|
||||
Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState,
|
||||
WithScrollbar, prelude::*,
|
||||
};
|
||||
use ui_input::InputField;
|
||||
use workspace::{ModalView, Workspace};
|
||||
|
||||
fn single_line_input(
|
||||
label: impl Into<SharedString>,
|
||||
placeholder: &str,
|
||||
text: Option<&str>,
|
||||
tab_index: isize,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<InputField> {
|
||||
cx.new(|cx| {
|
||||
let input = InputField::new(window, cx, placeholder)
|
||||
.label(label)
|
||||
.tab_index(tab_index)
|
||||
.tab_stop(true);
|
||||
|
||||
if let Some(text) = text {
|
||||
input.set_text(text, window, cx);
|
||||
}
|
||||
input
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum LlmCompatibleProvider {
|
||||
OpenAi,
|
||||
}
|
||||
|
||||
impl LlmCompatibleProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
LlmCompatibleProvider::OpenAi => "OpenAI",
|
||||
}
|
||||
}
|
||||
|
||||
fn api_url(&self) -> &'static str {
|
||||
match self {
|
||||
LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddLlmProviderInput {
|
||||
provider_name: Entity<InputField>,
|
||||
api_url: Entity<InputField>,
|
||||
api_key: Entity<InputField>,
|
||||
models: Vec<ModelInput>,
|
||||
}
|
||||
|
||||
impl AddLlmProviderInput {
|
||||
fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self {
|
||||
let provider_name =
|
||||
single_line_input("Provider Name", provider.name(), None, 1, window, cx);
|
||||
let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx);
|
||||
let api_key = cx.new(|cx| {
|
||||
InputField::new(
|
||||
window,
|
||||
cx,
|
||||
"000000000000000000000000000000000000000000000000",
|
||||
)
|
||||
.label("API Key")
|
||||
.tab_index(3)
|
||||
.tab_stop(true)
|
||||
.masked(true)
|
||||
});
|
||||
|
||||
Self {
|
||||
provider_name,
|
||||
api_url,
|
||||
api_key,
|
||||
models: vec![ModelInput::new(0, window, cx)],
|
||||
}
|
||||
}
|
||||
|
||||
fn add_model(&mut self, window: &mut Window, cx: &mut App) {
|
||||
let model_index = self.models.len();
|
||||
self.models.push(ModelInput::new(model_index, window, cx));
|
||||
}
|
||||
|
||||
fn remove_model(&mut self, index: usize) {
|
||||
self.models.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelCapabilityToggles {
|
||||
pub supports_tools: ToggleState,
|
||||
pub supports_images: ToggleState,
|
||||
pub supports_parallel_tool_calls: ToggleState,
|
||||
pub supports_prompt_cache_key: ToggleState,
|
||||
pub supports_chat_completions: ToggleState,
|
||||
}
|
||||
|
||||
struct ModelInput {
|
||||
name: Entity<InputField>,
|
||||
max_completion_tokens: Entity<InputField>,
|
||||
max_output_tokens: Entity<InputField>,
|
||||
max_tokens: Entity<InputField>,
|
||||
capabilities: ModelCapabilityToggles,
|
||||
}
|
||||
|
||||
impl ModelInput {
|
||||
fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self {
|
||||
let base_tab_index = (3 + (model_index * 4)) as isize;
|
||||
|
||||
let model_name = single_line_input(
|
||||
"Model Name",
|
||||
"e.g. gpt-5, claude-opus-4, gemini-2.5-pro",
|
||||
None,
|
||||
base_tab_index + 1,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let max_completion_tokens = single_line_input(
|
||||
"Max Completion Tokens",
|
||||
"200000",
|
||||
Some("200000"),
|
||||
base_tab_index + 2,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let max_output_tokens = single_line_input(
|
||||
"Max Output Tokens",
|
||||
"Max Output Tokens",
|
||||
Some("32000"),
|
||||
base_tab_index + 3,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let max_tokens = single_line_input(
|
||||
"Max Tokens",
|
||||
"Max Tokens",
|
||||
Some("200000"),
|
||||
base_tab_index + 4,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
let ModelCapabilities {
|
||||
tools,
|
||||
images,
|
||||
parallel_tool_calls,
|
||||
prompt_cache_key,
|
||||
chat_completions,
|
||||
..
|
||||
} = ModelCapabilities::default();
|
||||
|
||||
Self {
|
||||
name: model_name,
|
||||
max_completion_tokens,
|
||||
max_output_tokens,
|
||||
max_tokens,
|
||||
capabilities: ModelCapabilityToggles {
|
||||
supports_tools: tools.into(),
|
||||
supports_images: images.into(),
|
||||
supports_parallel_tool_calls: parallel_tool_calls.into(),
|
||||
supports_prompt_cache_key: prompt_cache_key.into(),
|
||||
supports_chat_completions: chat_completions.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(&self, cx: &App) -> Result<AvailableModel, SharedString> {
|
||||
let name = self.name.read(cx).text(cx);
|
||||
if name.is_empty() {
|
||||
return Err(SharedString::from("Model Name cannot be empty"));
|
||||
}
|
||||
Ok(AvailableModel {
|
||||
name,
|
||||
display_name: None,
|
||||
max_completion_tokens: Some(
|
||||
self.max_completion_tokens
|
||||
.read(cx)
|
||||
.text(cx)
|
||||
.parse::<u64>()
|
||||
.map_err(|_| SharedString::from("Max Completion Tokens must be a number"))?,
|
||||
),
|
||||
max_output_tokens: Some(
|
||||
self.max_output_tokens
|
||||
.read(cx)
|
||||
.text(cx)
|
||||
.parse::<u64>()
|
||||
.map_err(|_| SharedString::from("Max Output Tokens must be a number"))?,
|
||||
),
|
||||
max_tokens: self
|
||||
.max_tokens
|
||||
.read(cx)
|
||||
.text(cx)
|
||||
.parse::<u64>()
|
||||
.map_err(|_| SharedString::from("Max Tokens must be a number"))?,
|
||||
reasoning_effort: None,
|
||||
capabilities: ModelCapabilities {
|
||||
tools: self.capabilities.supports_tools.selected(),
|
||||
images: self.capabilities.supports_images.selected(),
|
||||
parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(),
|
||||
prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(),
|
||||
chat_completions: self.capabilities.supports_chat_completions.selected(),
|
||||
interleaved_reasoning: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn save_provider_to_settings(
|
||||
input: &AddLlmProviderInput,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<(), SharedString>> {
|
||||
let provider_name: Arc<str> = input.provider_name.read(cx).text(cx).into();
|
||||
if provider_name.is_empty() {
|
||||
return Task::ready(Err("Provider Name cannot be empty".into()));
|
||||
}
|
||||
|
||||
if LanguageModelRegistry::read_global(cx)
|
||||
.providers()
|
||||
.iter()
|
||||
.any(|provider| {
|
||||
provider.id().0.as_ref() == provider_name.as_ref()
|
||||
|| provider.name().0.as_ref() == provider_name.as_ref()
|
||||
})
|
||||
{
|
||||
return Task::ready(Err(
|
||||
"Provider Name is already taken by another provider".into()
|
||||
));
|
||||
}
|
||||
|
||||
let api_url = input.api_url.read(cx).text(cx);
|
||||
if api_url.is_empty() {
|
||||
return Task::ready(Err("API URL cannot be empty".into()));
|
||||
}
|
||||
|
||||
let api_key = input.api_key.read(cx).text(cx);
|
||||
if api_key.is_empty() {
|
||||
return Task::ready(Err("API Key cannot be empty".into()));
|
||||
}
|
||||
|
||||
let mut models = Vec::new();
|
||||
let mut model_names: HashSet<String> = HashSet::default();
|
||||
for model in &input.models {
|
||||
match model.parse(cx) {
|
||||
Ok(model) => {
|
||||
if !model_names.insert(model.name.clone()) {
|
||||
return Task::ready(Err("Model Names must be unique".into()));
|
||||
}
|
||||
models.push(model)
|
||||
}
|
||||
Err(err) => return Task::ready(Err(err)),
|
||||
}
|
||||
}
|
||||
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes());
|
||||
cx.spawn(async move |cx| {
|
||||
task.await
|
||||
.map_err(|_| SharedString::from("Failed to write API key to keychain"))?;
|
||||
cx.update(|cx| {
|
||||
update_settings_file(fs, cx, |settings, _cx| {
|
||||
settings
|
||||
.language_models
|
||||
.get_or_insert_default()
|
||||
.openai_compatible
|
||||
.get_or_insert_default()
|
||||
.insert(
|
||||
provider_name,
|
||||
OpenAiCompatibleSettingsContent {
|
||||
api_url,
|
||||
available_models: models,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub struct AddLlmProviderModal {
|
||||
provider: LlmCompatibleProvider,
|
||||
input: AddLlmProviderInput,
|
||||
scroll_handle: ScrollHandle,
|
||||
focus_handle: FocusHandle,
|
||||
last_error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl AddLlmProviderModal {
|
||||
pub fn toggle(
|
||||
provider: LlmCompatibleProvider,
|
||||
workspace: &mut Workspace,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
workspace.toggle_modal(window, cx, |window, cx| Self::new(provider, window, cx));
|
||||
}
|
||||
|
||||
fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
input: AddLlmProviderInput::new(provider, window, cx),
|
||||
provider,
|
||||
last_error: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let task = save_provider_to_settings(&self.input, cx);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
this.update(cx, |this, cx| match result {
|
||||
Ok(_) => {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
Err(error) => {
|
||||
this.last_error = Some(error);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn render_model_section(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.mt_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(Label::new("Models").size(LabelSize::Small))
|
||||
.child(
|
||||
Button::new("add-model", "Add Model")
|
||||
.start_icon(
|
||||
Icon::new(IconName::Plus)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.input.add_model(window, cx);
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.children(
|
||||
self.input
|
||||
.models
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, _)| self.render_model(ix, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_model(&self, ix: usize, cx: &mut Context<Self>) -> impl IntoElement + use<> {
|
||||
let has_more_than_one_model = self.input.models.len() > 1;
|
||||
let model = &self.input.models[ix];
|
||||
|
||||
v_flex()
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().colors().border.opacity(0.6))
|
||||
.bg(cx.theme().colors().element_active.opacity(0.15))
|
||||
.child(model.name.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(model.max_completion_tokens.clone())
|
||||
.child(model.max_output_tokens.clone()),
|
||||
)
|
||||
.child(model.max_tokens.clone())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools)
|
||||
.label("Supports tools")
|
||||
.on_click(cx.listener(move |this, checked, _window, cx| {
|
||||
this.input.models[ix].capabilities.supports_tools = *checked;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new(("supports-images", ix), model.capabilities.supports_images)
|
||||
.label("Supports images")
|
||||
.on_click(cx.listener(move |this, checked, _window, cx| {
|
||||
this.input.models[ix].capabilities.supports_images = *checked;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new(
|
||||
("supports-parallel-tool-calls", ix),
|
||||
model.capabilities.supports_parallel_tool_calls,
|
||||
)
|
||||
.label("Supports parallel_tool_calls")
|
||||
.on_click(cx.listener(
|
||||
move |this, checked, _window, cx| {
|
||||
this.input.models[ix]
|
||||
.capabilities
|
||||
.supports_parallel_tool_calls = *checked;
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new(
|
||||
("supports-prompt-cache-key", ix),
|
||||
model.capabilities.supports_prompt_cache_key,
|
||||
)
|
||||
.label("Supports prompt_cache_key")
|
||||
.on_click(cx.listener(
|
||||
move |this, checked, _window, cx| {
|
||||
this.input.models[ix].capabilities.supports_prompt_cache_key =
|
||||
*checked;
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new(
|
||||
("supports-chat-completions", ix),
|
||||
model.capabilities.supports_chat_completions,
|
||||
)
|
||||
.label("Supports /chat/completions")
|
||||
.on_click(cx.listener(
|
||||
move |this, checked, _window, cx| {
|
||||
this.input.models[ix].capabilities.supports_chat_completions =
|
||||
*checked;
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.when(has_more_than_one_model, |this| {
|
||||
this.child(
|
||||
Button::new(("remove-model", ix), "Remove Model")
|
||||
.start_icon(
|
||||
Icon::new(IconName::Trash)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.label_size(LabelSize::Small)
|
||||
.style(ButtonStyle::Outlined)
|
||||
.full_width()
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.input.remove_model(ix);
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.focus_next(cx);
|
||||
}
|
||||
|
||||
fn on_tab_prev(
|
||||
&mut self,
|
||||
_: &menu::SelectPrevious,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus_prev(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for AddLlmProviderModal {}
|
||||
|
||||
impl Focusable for AddLlmProviderModal {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for AddLlmProviderModal {}
|
||||
|
||||
impl Render for AddLlmProviderModal {
|
||||
fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
let window_size = window.viewport_size();
|
||||
let rem_size = window.rem_size();
|
||||
let is_large_window = window_size.height / rem_size > rems_from_px(600.).0;
|
||||
|
||||
let modal_max_height = if is_large_window {
|
||||
rems_from_px(450.)
|
||||
} else {
|
||||
rems_from_px(200.)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id("add-llm-provider-modal")
|
||||
.key_context("AddLlmProviderModal")
|
||||
.w(rems(34.))
|
||||
.elevation_3(cx)
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.on_action(cx.listener(Self::on_tab))
|
||||
.on_action(cx.listener(Self::on_tab_prev))
|
||||
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
|
||||
this.focus_handle(cx).focus(window, cx);
|
||||
}))
|
||||
.child(
|
||||
Modal::new("configure-context-server", None)
|
||||
.header(ModalHeader::new().headline("Add LLM Provider").description(
|
||||
match self.provider {
|
||||
LlmCompatibleProvider::OpenAi => {
|
||||
"This provider will use an OpenAI compatible API."
|
||||
}
|
||||
},
|
||||
))
|
||||
.when_some(self.last_error.clone(), |this, error| {
|
||||
this.section(
|
||||
Section::new().child(
|
||||
Banner::new()
|
||||
.severity(Severity::Warning)
|
||||
.child(div().text_xs().child(error)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.vertical_scrollbar_for(&self.scroll_handle, window, cx)
|
||||
.child(
|
||||
v_flex()
|
||||
.id("modal_content")
|
||||
.size_full()
|
||||
.tab_group()
|
||||
.max_h(modal_max_height)
|
||||
.pl_3()
|
||||
.pr_4()
|
||||
.pb_2()
|
||||
.gap_2()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.child(self.input.provider_name.clone())
|
||||
.child(self.input.api_url.clone())
|
||||
.child(self.input.api_key.clone())
|
||||
.child(self.render_model_section(cx)),
|
||||
),
|
||||
)
|
||||
.footer(
|
||||
ModalFooter::new().end_slot(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("cancel", "Cancel")
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.cancel(&menu::Cancel, window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("save-server", "Save Provider")
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Confirm,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.confirm(&menu::Confirm, window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fs::FakeFs;
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
use language_model::{
|
||||
LanguageModelProviderId, LanguageModelProviderName,
|
||||
fake_provider::FakeLanguageModelProvider,
|
||||
};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use util::path;
|
||||
use workspace::MultiWorkspace;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) {
|
||||
let cx = setup_test(cx).await;
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors("", "someurl", "somekey", vec![], cx,).await,
|
||||
Some("Provider Name cannot be empty".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors("someprovider", "", "somekey", vec![], cx,).await,
|
||||
Some("API URL cannot be empty".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors("someprovider", "someurl", "", vec![], cx,).await,
|
||||
Some("API Key cannot be empty".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors(
|
||||
"someprovider",
|
||||
"someurl",
|
||||
"somekey",
|
||||
vec![("", "200000", "200000", "32000")],
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
Some("Model Name cannot be empty".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors(
|
||||
"someprovider",
|
||||
"someurl",
|
||||
"somekey",
|
||||
vec![("somemodel", "abc", "200000", "32000")],
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
Some("Max Tokens must be a number".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors(
|
||||
"someprovider",
|
||||
"someurl",
|
||||
"somekey",
|
||||
vec![("somemodel", "200000", "abc", "32000")],
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
Some("Max Completion Tokens must be a number".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors(
|
||||
"someprovider",
|
||||
"someurl",
|
||||
"somekey",
|
||||
vec![("somemodel", "200000", "200000", "abc")],
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
Some("Max Output Tokens must be a number".into())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors(
|
||||
"someprovider",
|
||||
"someurl",
|
||||
"somekey",
|
||||
vec![
|
||||
("somemodel", "200000", "200000", "32000"),
|
||||
("somemodel", "200000", "200000", "32000"),
|
||||
],
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
Some("Model Names must be unique".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_provider_name_conflict(cx: &mut TestAppContext) {
|
||||
let cx = setup_test(cx).await;
|
||||
|
||||
cx.update(|_window, cx| {
|
||||
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.register_provider(
|
||||
Arc::new(FakeLanguageModelProvider::new(
|
||||
LanguageModelProviderId::new("someprovider"),
|
||||
LanguageModelProviderName::new("Some Provider"),
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
save_provider_validation_errors(
|
||||
"someprovider",
|
||||
"someurl",
|
||||
"someapikey",
|
||||
vec![("somemodel", "200000", "200000", "32000")],
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
Some("Provider Name is already taken by another provider".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_model_input_default_capabilities(cx: &mut TestAppContext) {
|
||||
let cx = setup_test(cx).await;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
let model_input = ModelInput::new(0, window, cx);
|
||||
model_input.name.update(cx, |input, cx| {
|
||||
input.set_text("somemodel", window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
model_input.capabilities.supports_tools,
|
||||
ToggleState::Selected
|
||||
);
|
||||
assert_eq!(
|
||||
model_input.capabilities.supports_images,
|
||||
ToggleState::Unselected
|
||||
);
|
||||
assert_eq!(
|
||||
model_input.capabilities.supports_parallel_tool_calls,
|
||||
ToggleState::Unselected
|
||||
);
|
||||
assert_eq!(
|
||||
model_input.capabilities.supports_prompt_cache_key,
|
||||
ToggleState::Unselected
|
||||
);
|
||||
assert_eq!(
|
||||
model_input.capabilities.supports_chat_completions,
|
||||
ToggleState::Selected
|
||||
);
|
||||
|
||||
let parsed_model = model_input.parse(cx).unwrap();
|
||||
assert!(parsed_model.capabilities.tools);
|
||||
assert!(!parsed_model.capabilities.images);
|
||||
assert!(!parsed_model.capabilities.parallel_tool_calls);
|
||||
assert!(!parsed_model.capabilities.prompt_cache_key);
|
||||
assert!(parsed_model.capabilities.chat_completions);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) {
|
||||
let cx = setup_test(cx).await;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
let mut model_input = ModelInput::new(0, window, cx);
|
||||
model_input.name.update(cx, |input, cx| {
|
||||
input.set_text("somemodel", window, cx);
|
||||
});
|
||||
|
||||
model_input.capabilities.supports_tools = ToggleState::Unselected;
|
||||
model_input.capabilities.supports_images = ToggleState::Unselected;
|
||||
model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected;
|
||||
model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected;
|
||||
model_input.capabilities.supports_chat_completions = ToggleState::Unselected;
|
||||
|
||||
let parsed_model = model_input.parse(cx).unwrap();
|
||||
assert!(!parsed_model.capabilities.tools);
|
||||
assert!(!parsed_model.capabilities.images);
|
||||
assert!(!parsed_model.capabilities.parallel_tool_calls);
|
||||
assert!(!parsed_model.capabilities.prompt_cache_key);
|
||||
assert!(!parsed_model.capabilities.chat_completions);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) {
|
||||
let cx = setup_test(cx).await;
|
||||
|
||||
cx.update(|window, cx| {
|
||||
let mut model_input = ModelInput::new(0, window, cx);
|
||||
model_input.name.update(cx, |input, cx| {
|
||||
input.set_text("somemodel", window, cx);
|
||||
});
|
||||
|
||||
model_input.capabilities.supports_tools = ToggleState::Selected;
|
||||
model_input.capabilities.supports_images = ToggleState::Unselected;
|
||||
model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected;
|
||||
model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected;
|
||||
model_input.capabilities.supports_chat_completions = ToggleState::Selected;
|
||||
|
||||
let parsed_model = model_input.parse(cx).unwrap();
|
||||
assert_eq!(parsed_model.name, "somemodel");
|
||||
assert!(parsed_model.capabilities.tools);
|
||||
assert!(!parsed_model.capabilities.images);
|
||||
assert!(parsed_model.capabilities.parallel_tool_calls);
|
||||
assert!(!parsed_model.capabilities.prompt_cache_key);
|
||||
assert!(parsed_model.capabilities.chat_completions);
|
||||
});
|
||||
}
|
||||
|
||||
async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
|
||||
cx.update(|cx| {
|
||||
let store = SettingsStore::test(cx);
|
||||
cx.set_global(store);
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
|
||||
language_model::init(cx);
|
||||
editor::init(cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
|
||||
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
|
||||
cx
|
||||
}
|
||||
|
||||
async fn save_provider_validation_errors(
|
||||
provider_name: &str,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
models: Vec<(&str, &str, &str, &str)>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Option<SharedString> {
|
||||
fn set_text(input: &Entity<InputField>, text: &str, window: &mut Window, cx: &mut App) {
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(text, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let task = cx.update(|window, cx| {
|
||||
let mut input = AddLlmProviderInput::new(LlmCompatibleProvider::OpenAi, window, cx);
|
||||
set_text(&input.provider_name, provider_name, window, cx);
|
||||
set_text(&input.api_url, api_url, window, cx);
|
||||
set_text(&input.api_key, api_key, window, cx);
|
||||
|
||||
for (i, (name, max_tokens, max_completion_tokens, max_output_tokens)) in
|
||||
models.iter().enumerate()
|
||||
{
|
||||
if i >= input.models.len() {
|
||||
input.models.push(ModelInput::new(i, window, cx));
|
||||
}
|
||||
let model = &mut input.models[i];
|
||||
set_text(&model.name, name, window, cx);
|
||||
set_text(&model.max_tokens, max_tokens, window, cx);
|
||||
set_text(
|
||||
&model.max_completion_tokens,
|
||||
max_completion_tokens,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
set_text(&model.max_output_tokens, max_output_tokens, window, cx);
|
||||
}
|
||||
save_provider_to_settings(&input, cx)
|
||||
});
|
||||
|
||||
task.await.err()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
use agent::ContextServerRegistry;
|
||||
use collections::HashMap;
|
||||
use context_server::ContextServerId;
|
||||
use gpui::{
|
||||
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Window, prelude::*,
|
||||
};
|
||||
use ui::{Divider, DividerColor, Modal, ModalHeader, WithScrollbar, prelude::*};
|
||||
use workspace::{ModalView, Workspace};
|
||||
|
||||
pub struct ConfigureContextServerToolsModal {
|
||||
context_server_id: ContextServerId,
|
||||
context_server_registry: Entity<ContextServerRegistry>,
|
||||
focus_handle: FocusHandle,
|
||||
expanded_tools: HashMap<SharedString, bool>,
|
||||
scroll_handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl ConfigureContextServerToolsModal {
|
||||
fn new(
|
||||
context_server_id: ContextServerId,
|
||||
context_server_registry: Entity<ContextServerRegistry>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
context_server_id,
|
||||
context_server_registry,
|
||||
focus_handle: cx.focus_handle(),
|
||||
expanded_tools: HashMap::default(),
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(
|
||||
context_server_id: ContextServerId,
|
||||
context_server_registry: Entity<ContextServerRegistry>,
|
||||
workspace: &mut Workspace,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
Self::new(context_server_id, context_server_registry, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent)
|
||||
}
|
||||
|
||||
fn render_modal_content(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let tools = self
|
||||
.context_server_registry
|
||||
.read(cx)
|
||||
.tools_for_server(&self.context_server_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.pb_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("modal_content")
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.max_h_128()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.children(tools.iter().enumerate().flat_map(|(index, tool)| {
|
||||
let tool_name = tool.name();
|
||||
let is_expanded = self
|
||||
.expanded_tools
|
||||
.get(tool_name.as_ref())
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
|
||||
let icon = if is_expanded {
|
||||
IconName::ChevronUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
};
|
||||
|
||||
let mut items = vec![
|
||||
v_flex()
|
||||
.child(
|
||||
h_flex()
|
||||
.id(format!("tool-header-{}", index))
|
||||
.py_1()
|
||||
.pl_1()
|
||||
.pr_2()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded_sm()
|
||||
.hover(|s| s.bg(cx.theme().colors().element_hover))
|
||||
.child(
|
||||
Label::new(tool_name.clone())
|
||||
.buffer_font(cx)
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.child(
|
||||
Icon::new(icon)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.on_click(cx.listener({
|
||||
move |this, _event, _window, _cx| {
|
||||
let current = this
|
||||
.expanded_tools
|
||||
.get(tool_name.as_ref())
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
this.expanded_tools
|
||||
.insert(tool_name.clone(), !current);
|
||||
_cx.notify();
|
||||
}
|
||||
})),
|
||||
)
|
||||
.when(is_expanded, |this| {
|
||||
this.child(
|
||||
Label::new(tool.description()).color(Color::Muted).mx_1(),
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
];
|
||||
|
||||
if index < tools.len() - 1 {
|
||||
items.push(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.child(Divider::horizontal().color(DividerColor::BorderVariant))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})),
|
||||
)
|
||||
.vertical_scrollbar_for(&self.scroll_handle, window, cx)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for ConfigureContextServerToolsModal {}
|
||||
|
||||
impl Focusable for ConfigureContextServerToolsModal {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for ConfigureContextServerToolsModal {}
|
||||
|
||||
impl Render for ConfigureContextServerToolsModal {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.key_context("ContextServerToolsModal")
|
||||
.occlude()
|
||||
.elevation_3(cx)
|
||||
.w(rems(34.))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.track_focus(&self.focus_handle)
|
||||
.child(
|
||||
Modal::new("configure-context-server-tools", None::<ScrollHandle>)
|
||||
.header(
|
||||
ModalHeader::new()
|
||||
.headline(format!("Tools from {}", self.context_server_id.0))
|
||||
.show_dismiss_button(true),
|
||||
)
|
||||
.child(self.render_modal_content(window, cx)),
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
use ui::prelude::*;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProfileModalHeader {
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
}
|
||||
|
||||
impl ProfileModalHeader {
|
||||
pub fn new(label: impl Into<SharedString>, icon: Option<IconName>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ProfileModalHeader {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let mut container = h_flex()
|
||||
.w_full()
|
||||
.px(DynamicSpacing::Base12.rems(cx))
|
||||
.pt(DynamicSpacing::Base08.rems(cx))
|
||||
.pb(DynamicSpacing::Base04.rems(cx))
|
||||
.rounded_t_sm()
|
||||
.gap_1p5();
|
||||
|
||||
if let Some(icon) = self.icon {
|
||||
container = container.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
|
||||
}
|
||||
|
||||
container.child(
|
||||
h_flex().gap_1().overflow_x_hidden().child(
|
||||
div()
|
||||
.max_w_96()
|
||||
.overflow_x_hidden()
|
||||
.text_ellipsis()
|
||||
.child(Headline::new(self.label).size(HeadlineSize::XSmall)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use agent::ContextServerRegistry;
|
||||
use agent_settings::{AgentProfileId, AgentProfileSettings};
|
||||
use fs::Fs;
|
||||
use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::{AgentProfileContent, ContextServerPresetContent, update_settings_file};
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
use util::ResultExt as _;
|
||||
|
||||
pub struct ToolPicker {
|
||||
picker: Entity<Picker<ToolPickerDelegate>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ToolPickerMode {
|
||||
BuiltinTools,
|
||||
McpTools,
|
||||
}
|
||||
|
||||
impl ToolPicker {
|
||||
pub fn builtin_tools(
|
||||
delegate: ToolPickerDelegate,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
|
||||
Self { picker }
|
||||
}
|
||||
|
||||
pub fn mcp_tools(
|
||||
delegate: ToolPickerDelegate,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let picker = cx.new(|cx| Picker::list(delegate, window, cx).modal(false));
|
||||
Self { picker }
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for ToolPicker {}
|
||||
|
||||
impl Focusable for ToolPicker {
|
||||
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
||||
self.picker.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ToolPicker {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex().w(rems(34.)).child(self.picker.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PickerItem {
|
||||
Tool {
|
||||
server_id: Option<Arc<str>>,
|
||||
name: Arc<str>,
|
||||
},
|
||||
ContextServer {
|
||||
server_id: Arc<str>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ToolPickerDelegate {
|
||||
tool_picker: WeakEntity<ToolPicker>,
|
||||
fs: Arc<dyn Fs>,
|
||||
items: Arc<Vec<PickerItem>>,
|
||||
profile_id: AgentProfileId,
|
||||
profile_settings: AgentProfileSettings,
|
||||
filtered_items: Vec<PickerItem>,
|
||||
selected_index: usize,
|
||||
mode: ToolPickerMode,
|
||||
}
|
||||
|
||||
impl ToolPickerDelegate {
|
||||
pub fn builtin_tools(
|
||||
tool_names: Vec<Arc<str>>,
|
||||
fs: Arc<dyn Fs>,
|
||||
profile_id: AgentProfileId,
|
||||
profile_settings: AgentProfileSettings,
|
||||
cx: &mut Context<ToolPicker>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
Arc::new(
|
||||
tool_names
|
||||
.into_iter()
|
||||
.map(|name| PickerItem::Tool {
|
||||
name,
|
||||
server_id: None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
ToolPickerMode::BuiltinTools,
|
||||
fs,
|
||||
profile_id,
|
||||
profile_settings,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn mcp_tools(
|
||||
registry: &Entity<ContextServerRegistry>,
|
||||
fs: Arc<dyn Fs>,
|
||||
profile_id: AgentProfileId,
|
||||
profile_settings: AgentProfileSettings,
|
||||
cx: &mut Context<ToolPicker>,
|
||||
) -> Self {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for (id, tools) in registry.read(cx).servers() {
|
||||
let server_id = id.clone().0;
|
||||
items.push(PickerItem::ContextServer {
|
||||
server_id: server_id.clone(),
|
||||
});
|
||||
items.extend(tools.keys().map(|tool_name| PickerItem::Tool {
|
||||
name: tool_name.clone().into(),
|
||||
server_id: Some(server_id.clone()),
|
||||
}));
|
||||
}
|
||||
|
||||
Self::new(
|
||||
Arc::new(items),
|
||||
ToolPickerMode::McpTools,
|
||||
fs,
|
||||
profile_id,
|
||||
profile_settings,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn new(
|
||||
items: Arc<Vec<PickerItem>>,
|
||||
mode: ToolPickerMode,
|
||||
fs: Arc<dyn Fs>,
|
||||
profile_id: AgentProfileId,
|
||||
profile_settings: AgentProfileSettings,
|
||||
cx: &mut Context<ToolPicker>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_picker: cx.entity().downgrade(),
|
||||
mode,
|
||||
fs,
|
||||
items,
|
||||
profile_id,
|
||||
profile_settings,
|
||||
filtered_items: Vec::new(),
|
||||
selected_index: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for ToolPickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_items.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 can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
|
||||
let item = &self.filtered_items[ix];
|
||||
match item {
|
||||
PickerItem::Tool { .. } => true,
|
||||
PickerItem::ContextServer { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
match self.mode {
|
||||
ToolPickerMode::BuiltinTools => "Search built-in tools…",
|
||||
ToolPickerMode::McpTools => "Search MCP tools…",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let all_items = self.items.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let filtered_items = cx
|
||||
.background_spawn(async move {
|
||||
let mut tools_by_provider: BTreeMap<Option<Arc<str>>, Vec<Arc<str>>> =
|
||||
BTreeMap::default();
|
||||
|
||||
for item in all_items.iter() {
|
||||
if let PickerItem::Tool { server_id, name } = item.clone()
|
||||
&& name.contains(&query)
|
||||
{
|
||||
tools_by_provider.entry(server_id).or_default().push(name);
|
||||
}
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
for (server_id, names) in tools_by_provider {
|
||||
if let Some(server_id) = server_id.clone() {
|
||||
items.push(PickerItem::ContextServer { server_id });
|
||||
}
|
||||
for name in names {
|
||||
items.push(PickerItem::Tool {
|
||||
server_id: server_id.clone(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, _cx| {
|
||||
this.delegate.filtered_items = filtered_items;
|
||||
this.delegate.selected_index = this
|
||||
.delegate
|
||||
.selected_index
|
||||
.min(this.delegate.filtered_items.len().saturating_sub(1));
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if self.filtered_items.is_empty() {
|
||||
self.dismissed(window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let item = &self.filtered_items[self.selected_index];
|
||||
|
||||
let PickerItem::Tool {
|
||||
name: tool_name,
|
||||
server_id,
|
||||
} = item
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let is_currently_enabled = if let Some(server_id) = server_id.clone() {
|
||||
let preset = self
|
||||
.profile_settings
|
||||
.context_servers
|
||||
.entry(server_id)
|
||||
.or_default();
|
||||
let is_enabled = *preset.tools.entry(tool_name.clone()).or_default();
|
||||
*preset.tools.entry(tool_name.clone()).or_default() = !is_enabled;
|
||||
is_enabled
|
||||
} else {
|
||||
let is_enabled = *self
|
||||
.profile_settings
|
||||
.tools
|
||||
.entry(tool_name.clone())
|
||||
.or_default();
|
||||
*self
|
||||
.profile_settings
|
||||
.tools
|
||||
.entry(tool_name.clone())
|
||||
.or_default() = !is_enabled;
|
||||
is_enabled
|
||||
};
|
||||
|
||||
update_settings_file(self.fs.clone(), cx, {
|
||||
let profile_id = self.profile_id.clone();
|
||||
let default_profile = self.profile_settings.clone();
|
||||
let server_id = server_id.clone();
|
||||
let tool_name = tool_name.clone();
|
||||
move |settings, _cx| {
|
||||
let profiles = settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.profiles
|
||||
.get_or_insert_default();
|
||||
let profile = profiles
|
||||
.entry(profile_id.0)
|
||||
.or_insert_with(|| AgentProfileContent {
|
||||
name: default_profile.name.into(),
|
||||
tools: default_profile.tools,
|
||||
enable_all_context_servers: Some(
|
||||
default_profile.enable_all_context_servers,
|
||||
),
|
||||
context_servers: default_profile
|
||||
.context_servers
|
||||
.into_iter()
|
||||
.map(|(server_id, preset)| {
|
||||
(
|
||||
server_id,
|
||||
ContextServerPresetContent {
|
||||
tools: preset.tools,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
default_model: default_profile.default_model.clone(),
|
||||
});
|
||||
|
||||
if let Some(server_id) = server_id {
|
||||
let preset = profile.context_servers.entry(server_id).or_default();
|
||||
*preset.tools.entry(tool_name).or_default() = !is_currently_enabled;
|
||||
} else {
|
||||
*profile.tools.entry(tool_name).or_default() = !is_currently_enabled;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
self.tool_picker
|
||||
.update(cx, |_this, cx| cx.emit(DismissEvent))
|
||||
.log_err();
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let item = &self.filtered_items.get(ix)?;
|
||||
match item {
|
||||
PickerItem::ContextServer { server_id, .. } => Some(
|
||||
div()
|
||||
.px_2()
|
||||
.pb_1()
|
||||
.when(ix > 1, |this| {
|
||||
this.mt_1()
|
||||
.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.child(
|
||||
Label::new(server_id)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
PickerItem::Tool { name, server_id } => {
|
||||
let is_enabled = if let Some(server_id) = server_id {
|
||||
self.profile_settings
|
||||
.context_servers
|
||||
.get(server_id.as_ref())
|
||||
.and_then(|preset| preset.tools.get(name))
|
||||
.copied()
|
||||
.unwrap_or(self.profile_settings.enable_all_context_servers)
|
||||
} else {
|
||||
self.profile_settings
|
||||
.tools
|
||||
.get(name)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(Label::new(name.clone()))
|
||||
.end_slot::<Icon>(is_enabled.then(|| {
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Success)
|
||||
}))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use acp_thread::{AgentConnection, LoadError};
|
||||
use agent_servers::AcpConnection;
|
||||
use agent_servers::{AgentServer, AgentServerDelegate};
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use futures::{FutureExt, future::Shared};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
|
||||
|
||||
use project::{AgentServerStore, AgentServersUpdated, Project};
|
||||
use watch::Receiver;
|
||||
|
||||
use crate::Agent;
|
||||
|
||||
pub enum AgentConnectionEntry {
|
||||
Connecting {
|
||||
connect_task: Shared<Task<Result<AgentConnectedState, LoadError>>>,
|
||||
},
|
||||
Connected(AgentConnectedState),
|
||||
Error {
|
||||
error: LoadError,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AgentConnectedState {
|
||||
pub connection: Rc<dyn AgentConnection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AgentConnectionStatus {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
}
|
||||
|
||||
impl AgentConnectionEntry {
|
||||
pub fn wait_for_connection(&self) -> Shared<Task<Result<AgentConnectedState, LoadError>>> {
|
||||
match self {
|
||||
AgentConnectionEntry::Connecting { connect_task } => connect_task.clone(),
|
||||
AgentConnectionEntry::Connected(state) => Task::ready(Ok(state.clone())).shared(),
|
||||
AgentConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> AgentConnectionStatus {
|
||||
match self {
|
||||
AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting,
|
||||
AgentConnectionEntry::Connected(_) => AgentConnectionStatus::Connected,
|
||||
AgentConnectionEntry::Error { .. } => AgentConnectionStatus::Disconnected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AgentConnectionEntryEvent {
|
||||
NewVersionAvailable(SharedString),
|
||||
}
|
||||
|
||||
impl EventEmitter<AgentConnectionEntryEvent> for AgentConnectionEntry {}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ActiveAcpConnection {
|
||||
pub agent_id: project::AgentId,
|
||||
pub connection: Rc<AcpConnection>,
|
||||
}
|
||||
|
||||
pub struct AgentConnectionStore {
|
||||
project: Entity<Project>,
|
||||
entries: HashMap<Agent, Entity<AgentConnectionEntry>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl AgentConnectionStore {
|
||||
pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
|
||||
let agent_server_store = project.read(cx).agent_server_store().clone();
|
||||
let subscription = cx.subscribe(&agent_server_store, Self::handle_agent_servers_updated);
|
||||
Self {
|
||||
project,
|
||||
entries: HashMap::default(),
|
||||
_subscriptions: vec![subscription],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project(&self) -> &Entity<Project> {
|
||||
&self.project
|
||||
}
|
||||
|
||||
pub fn entry(&self, key: &Agent) -> Option<&Entity<AgentConnectionEntry>> {
|
||||
self.entries.get(key)
|
||||
}
|
||||
|
||||
pub fn connection_status(&self, key: &Agent, cx: &App) -> AgentConnectionStatus {
|
||||
self.entries
|
||||
.get(key)
|
||||
.map(|entry| entry.read(cx).status())
|
||||
.unwrap_or(AgentConnectionStatus::Disconnected)
|
||||
}
|
||||
|
||||
pub fn agent_version(&self, key: &Agent, cx: &App) -> Option<SharedString> {
|
||||
match self.entries.get(key)?.read(cx) {
|
||||
AgentConnectionEntry::Connected(state) => state.connection.agent_version(),
|
||||
AgentConnectionEntry::Connecting { .. } | AgentConnectionEntry::Error { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_acp_connections(&self, cx: &App) -> Vec<ActiveAcpConnection> {
|
||||
self.entries
|
||||
.values()
|
||||
.filter_map(|entry| match entry.read(cx) {
|
||||
AgentConnectionEntry::Connected(state) => state
|
||||
.connection
|
||||
.clone()
|
||||
.downcast::<AcpConnection>()
|
||||
.map(|connection| ActiveAcpConnection {
|
||||
agent_id: state.connection.agent_id(),
|
||||
connection,
|
||||
}),
|
||||
AgentConnectionEntry::Connecting { .. } | AgentConnectionEntry::Error { .. } => {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn restart_connection(
|
||||
&mut self,
|
||||
key: Agent,
|
||||
server: Rc<dyn AgentServer>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<AgentConnectionEntry> {
|
||||
if let Some(entry) = self.entries.get(&key) {
|
||||
if matches!(entry.read(cx), AgentConnectionEntry::Connecting { .. }) {
|
||||
return entry.clone();
|
||||
}
|
||||
}
|
||||
|
||||
self.entries.remove(&key);
|
||||
self.request_connection(key, server, cx)
|
||||
}
|
||||
|
||||
pub fn request_connection(
|
||||
&mut self,
|
||||
key: Agent,
|
||||
server: Rc<dyn AgentServer>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<AgentConnectionEntry> {
|
||||
if let Some(entry) = self.entries.get(&key) {
|
||||
return entry.clone();
|
||||
}
|
||||
|
||||
let (mut new_version_rx, connect_task) = self.start_connection(server, cx);
|
||||
let connect_task = connect_task.shared();
|
||||
|
||||
let entry = cx.new(|_cx| AgentConnectionEntry::Connecting {
|
||||
connect_task: connect_task.clone(),
|
||||
});
|
||||
|
||||
self.entries.insert(key.clone(), entry.clone());
|
||||
cx.notify();
|
||||
|
||||
cx.spawn({
|
||||
let key = key.clone();
|
||||
let entry = entry.downgrade();
|
||||
async move |this, cx| match connect_task.await {
|
||||
Ok(connected_state) => {
|
||||
this.update(cx, move |this, cx| {
|
||||
if this.entries.get(&key) != entry.upgrade().as_ref() {
|
||||
return;
|
||||
}
|
||||
|
||||
entry
|
||||
.update(cx, move |entry, cx| {
|
||||
if let AgentConnectionEntry::Connecting { .. } = entry {
|
||||
*entry = AgentConnectionEntry::Connected(connected_state);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
this.update(cx, move |this, cx| {
|
||||
if this.entries.get(&key) != entry.upgrade().as_ref() {
|
||||
return;
|
||||
}
|
||||
|
||||
entry
|
||||
.update(cx, move |entry, cx| {
|
||||
if let AgentConnectionEntry::Connecting { .. } = entry {
|
||||
*entry = AgentConnectionEntry::Error { error };
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
this.entries.remove(&key);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn({
|
||||
let entry = entry.downgrade();
|
||||
async move |this, cx| {
|
||||
while let Ok(version) = new_version_rx.recv().await {
|
||||
let Some(version) = version else {
|
||||
continue;
|
||||
};
|
||||
|
||||
this.update(cx, move |this, cx| {
|
||||
if this.entries.get(&key) != entry.upgrade().as_ref() {
|
||||
return;
|
||||
}
|
||||
|
||||
entry
|
||||
.update(cx, move |_entry, cx| {
|
||||
cx.emit(AgentConnectionEntryEvent::NewVersionAvailable(
|
||||
version.into(),
|
||||
));
|
||||
})
|
||||
.ok();
|
||||
this.entries.remove(&key);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
fn handle_agent_servers_updated(
|
||||
&mut self,
|
||||
store: Entity<AgentServerStore>,
|
||||
_: &AgentServersUpdated,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let store = store.read(cx);
|
||||
self.entries.retain(|key, _| match key {
|
||||
Agent::NativeAgent => true,
|
||||
Agent::Custom { id } => store.external_agents.contains_key(id),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Agent::Stub => true,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_connection(
|
||||
&self,
|
||||
server: Rc<dyn AgentServer>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> (
|
||||
Receiver<Option<String>>,
|
||||
Task<Result<AgentConnectedState, LoadError>>,
|
||||
) {
|
||||
let (new_version_tx, new_version_rx) = watch::channel::<Option<String>>(None);
|
||||
|
||||
let agent_server_store = self.project.read(cx).agent_server_store().clone();
|
||||
let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx));
|
||||
|
||||
let connect_task = server.connect(delegate, self.project.clone(), cx);
|
||||
let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await {
|
||||
Ok(connection) => Ok(AgentConnectedState { connection }),
|
||||
Err(err) => match err.downcast::<LoadError>() {
|
||||
Ok(load_error) => Err(load_error),
|
||||
Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))),
|
||||
},
|
||||
});
|
||||
(new_version_rx, connect_task)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
use crate::{
|
||||
ModelUsageContext,
|
||||
language_model_selector::{LanguageModelSelector, language_model_selector},
|
||||
ui::ModelSelectorTooltip,
|
||||
};
|
||||
use fs::Fs;
|
||||
use gpui::{Entity, FocusHandle, SharedString};
|
||||
use language_model::IconOrSvg;
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use settings::update_settings_file;
|
||||
use std::sync::Arc;
|
||||
use ui::{PopoverMenuHandle, Tooltip, prelude::*};
|
||||
|
||||
pub struct AgentModelSelector {
|
||||
selector: Entity<LanguageModelSelector>,
|
||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
}
|
||||
|
||||
impl AgentModelSelector {
|
||||
pub(crate) fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
focus_handle: FocusHandle,
|
||||
model_usage_context: ModelUsageContext,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
selector: cx.new(move |cx| {
|
||||
language_model_selector(
|
||||
{
|
||||
let model_context = model_usage_context.clone();
|
||||
move |cx| model_context.configured_model(cx)
|
||||
},
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, cx| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model_id = model.id().0.to_string();
|
||||
match &model_usage_context {
|
||||
ModelUsageContext::InlineAssistant => {
|
||||
update_settings_file(fs.clone(), cx, move |settings, _cx| {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_inline_assistant_model(provider.clone(), model_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, should_be_favorite, cx| {
|
||||
crate::favorite_models::toggle_in_settings(
|
||||
model,
|
||||
should_be_favorite,
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
true, // Use popover styles for picker
|
||||
focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
menu_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.menu_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
pub fn active_model(&self, cx: &App) -> Option<language_model::ConfiguredModel> {
|
||||
self.selector.read(cx).delegate.active_model(cx)
|
||||
}
|
||||
|
||||
pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selector.update(cx, |selector, cx| {
|
||||
selector.delegate.cycle_favorite_models(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AgentModelSelector {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let model = self.selector.read(cx).delegate.active_model(cx);
|
||||
let model_name = model
|
||||
.as_ref()
|
||||
.map(|model| model.model.name().0)
|
||||
.unwrap_or_else(|| SharedString::from("Select a Model"));
|
||||
|
||||
let provider_icon = model.as_ref().map(|model| model.provider.icon());
|
||||
let color = if self.menu_handle.is_deployed() {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
let show_cycle_row = self.selector.read(cx).delegate.favorites_count() > 1;
|
||||
|
||||
let tooltip = Tooltip::element({
|
||||
move |_, _cx| {
|
||||
ModelSelectorTooltip::new()
|
||||
.show_cycle_row(show_cycle_row)
|
||||
.into_any_element()
|
||||
}
|
||||
});
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.selector.clone(),
|
||||
Button::new("active-model", model_name)
|
||||
.label_size(LabelSize::Small)
|
||||
.color(color)
|
||||
.when_some(provider_icon, |this, icon| {
|
||||
this.start_icon(
|
||||
match icon {
|
||||
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
|
||||
IconOrSvg::Icon(name) => Icon::new(name),
|
||||
}
|
||||
.color(color)
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
})
|
||||
.end_icon(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.color(color)
|
||||
.size(IconSize::XSmall),
|
||||
),
|
||||
tooltip,
|
||||
gpui::Anchor::TopRight,
|
||||
cx,
|
||||
)
|
||||
.with_handle(self.menu_handle.clone())
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(2.0),
|
||||
})
|
||||
.render(window, cx)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,697 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use client::zed_urls;
|
||||
use collections::HashMap;
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, Focusable, KeyContext, ParentElement, Render,
|
||||
RenderOnce, SharedString, Styled, TextStyle, UniformListScrollHandle, Window, point,
|
||||
uniform_list,
|
||||
};
|
||||
use project::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings};
|
||||
use project::{AgentRegistryStore, RegistryAgent};
|
||||
use settings::{Settings, SettingsStore, update_settings_file};
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::{
|
||||
ButtonStyle, ScrollableHandle, ToggleButtonGroup, ToggleButtonGroupSize,
|
||||
ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*,
|
||||
};
|
||||
use workspace::{
|
||||
Workspace,
|
||||
item::{Item, ItemEvent},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RegistryFilter {
|
||||
All,
|
||||
Installed,
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RegistryInstallStatus {
|
||||
NotInstalled,
|
||||
InstalledRegistry,
|
||||
InstalledCustom,
|
||||
InstalledExtension,
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct AgentRegistryCard {
|
||||
children: Vec<AnyElement>,
|
||||
}
|
||||
|
||||
impl AgentRegistryCard {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for AgentRegistryCard {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AgentRegistryCard {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div().w_full().child(
|
||||
v_flex()
|
||||
.p_3()
|
||||
.mt_4()
|
||||
.w_full()
|
||||
.min_h(rems_from_px(86.))
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors().elevated_surface_background.opacity(0.5))
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.rounded_md()
|
||||
.children(self.children),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AgentRegistryPage {
|
||||
registry_store: Entity<AgentRegistryStore>,
|
||||
list: UniformListScrollHandle,
|
||||
registry_agents: Vec<RegistryAgent>,
|
||||
filtered_registry_indices: Vec<usize>,
|
||||
installed_statuses: HashMap<String, RegistryInstallStatus>,
|
||||
query_editor: Entity<Editor>,
|
||||
filter: RegistryFilter,
|
||||
_subscriptions: Vec<gpui::Subscription>,
|
||||
}
|
||||
|
||||
impl AgentRegistryPage {
|
||||
pub fn new(
|
||||
_workspace: &Workspace,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
let registry_store = AgentRegistryStore::global(cx);
|
||||
let query_editor = cx.new(|cx| {
|
||||
let mut input = Editor::single_line(window, cx);
|
||||
input.set_placeholder_text("Search agents...", window, cx);
|
||||
input
|
||||
});
|
||||
cx.subscribe(&query_editor, Self::on_query_change).detach();
|
||||
|
||||
let mut subscriptions = Vec::new();
|
||||
subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
|
||||
this.reload_registry_agents(cx);
|
||||
}));
|
||||
subscriptions.push(cx.observe_global::<SettingsStore>(|this, cx| {
|
||||
this.filter_registry_agents(cx);
|
||||
}));
|
||||
|
||||
let mut this = Self {
|
||||
registry_store,
|
||||
list: UniformListScrollHandle::new(),
|
||||
registry_agents: Vec::new(),
|
||||
filtered_registry_indices: Vec::new(),
|
||||
installed_statuses: HashMap::default(),
|
||||
query_editor,
|
||||
filter: RegistryFilter::All,
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
this.reload_registry_agents(cx);
|
||||
this.registry_store
|
||||
.update(cx, |store, cx| store.refresh(cx));
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn reload_registry_agents(&mut self, cx: &mut Context<Self>) {
|
||||
self.registry_agents = self.registry_store.read(cx).agents().to_vec();
|
||||
self.registry_agents.sort_by(|left, right| {
|
||||
left.name()
|
||||
.as_ref()
|
||||
.to_lowercase()
|
||||
.cmp(&right.name().as_ref().to_lowercase())
|
||||
.then_with(|| {
|
||||
left.id()
|
||||
.as_ref()
|
||||
.to_lowercase()
|
||||
.cmp(&right.id().as_ref().to_lowercase())
|
||||
})
|
||||
});
|
||||
self.filter_registry_agents(cx);
|
||||
}
|
||||
|
||||
fn refresh_installed_statuses(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = cx
|
||||
.global::<SettingsStore>()
|
||||
.get::<AllAgentServersSettings>(None);
|
||||
self.installed_statuses.clear();
|
||||
for (id, settings) in settings.iter() {
|
||||
let status = match settings {
|
||||
CustomAgentServerSettings::Registry { .. } => {
|
||||
RegistryInstallStatus::InstalledRegistry
|
||||
}
|
||||
CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom,
|
||||
CustomAgentServerSettings::Extension { .. } => {
|
||||
RegistryInstallStatus::InstalledExtension
|
||||
}
|
||||
};
|
||||
self.installed_statuses.insert(id.clone(), status);
|
||||
}
|
||||
}
|
||||
|
||||
fn install_status(&self, id: &str) -> RegistryInstallStatus {
|
||||
self.installed_statuses
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(RegistryInstallStatus::NotInstalled)
|
||||
}
|
||||
|
||||
fn search_query(&self, cx: &mut App) -> Option<String> {
|
||||
let search = self.query_editor.read(cx).text(cx);
|
||||
if search.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(search)
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_registry_agents(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh_installed_statuses(cx);
|
||||
let search = self.search_query(cx).map(|search| search.to_lowercase());
|
||||
let filter = self.filter;
|
||||
let installed_statuses = self.installed_statuses.clone();
|
||||
|
||||
let filtered_indices = self
|
||||
.registry_agents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, agent)| {
|
||||
let matches_search = search.as_ref().is_none_or(|query| {
|
||||
let query = query.as_str();
|
||||
agent.id().as_ref().to_lowercase().contains(query)
|
||||
|| agent.name().as_ref().to_lowercase().contains(query)
|
||||
|| agent.description().as_ref().to_lowercase().contains(query)
|
||||
});
|
||||
|
||||
let install_status = installed_statuses
|
||||
.get(agent.id().as_ref())
|
||||
.copied()
|
||||
.unwrap_or(RegistryInstallStatus::NotInstalled);
|
||||
let matches_filter = match filter {
|
||||
RegistryFilter::All => true,
|
||||
RegistryFilter::Installed => {
|
||||
install_status != RegistryInstallStatus::NotInstalled
|
||||
}
|
||||
RegistryFilter::NotInstalled => {
|
||||
install_status == RegistryInstallStatus::NotInstalled
|
||||
}
|
||||
};
|
||||
|
||||
matches_search && matches_filter
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
|
||||
self.filtered_registry_indices = filtered_indices;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
|
||||
self.list.set_offset(point(px(0.), px(0.)));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_query_change(
|
||||
&mut self,
|
||||
_: Entity<Editor>,
|
||||
event: &editor::EditorEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let editor::EditorEvent::Edited { .. } = event {
|
||||
self.filter_registry_agents(cx);
|
||||
self.scroll_to_top(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_search(&self, cx: &mut Context<Self>) -> Div {
|
||||
let mut key_context = KeyContext::new_with_defaults();
|
||||
key_context.add("BufferSearchBar");
|
||||
|
||||
h_flex()
|
||||
.key_context(key_context)
|
||||
.h_8()
|
||||
.min_w(rems_from_px(384.))
|
||||
.flex_1()
|
||||
.pl_1p5()
|
||||
.pr_2()
|
||||
.gap_2()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
|
||||
.child(self.render_text_input(&self.query_editor, cx))
|
||||
}
|
||||
|
||||
fn render_text_input(
|
||||
&self,
|
||||
editor: &Entity<Editor>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let text_style = TextStyle {
|
||||
color: if editor.read(cx).read_only(cx) {
|
||||
cx.theme().colors().text_disabled
|
||||
} else {
|
||||
cx.theme().colors().text
|
||||
},
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings.ui_font.features.clone(),
|
||||
font_fallbacks: settings.ui_font.fallbacks.clone(),
|
||||
font_size: rems(0.875).into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
line_height: relative(1.3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
EditorElement::new(
|
||||
editor,
|
||||
EditorStyle {
|
||||
background: cx.theme().colors().editor_background,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let has_search = self.search_query(cx).is_some();
|
||||
let registry_store = self.registry_store.read(cx);
|
||||
let is_fetching = registry_store.is_fetching();
|
||||
let fetch_error = registry_store.fetch_error();
|
||||
|
||||
let message = if is_fetching {
|
||||
"Loading registry..."
|
||||
} else if fetch_error.is_some() {
|
||||
"Failed to load the agent registry. Please check your connection and try again."
|
||||
} else {
|
||||
match self.filter {
|
||||
RegistryFilter::All => {
|
||||
if has_search {
|
||||
"No agents match your search."
|
||||
} else {
|
||||
"No agents available."
|
||||
}
|
||||
}
|
||||
RegistryFilter::Installed => {
|
||||
if has_search {
|
||||
"No installed agents match your search."
|
||||
} else {
|
||||
"No installed agents."
|
||||
}
|
||||
}
|
||||
RegistryFilter::NotInstalled => {
|
||||
if has_search {
|
||||
"No uninstalled agents match your search."
|
||||
} else {
|
||||
"No uninstalled agents."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.py_4()
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.items_start()
|
||||
.when(fetch_error.is_some(), |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Warning),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(Label::new(message))
|
||||
.when_some(fetch_error.clone(), |this, fetch_error| {
|
||||
this.child(
|
||||
Label::new(fetch_error)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(fetch_error, |this, _| {
|
||||
let registry_store = self.registry_store.clone();
|
||||
this.child(
|
||||
Button::new("retry-agent-registry", "Retry")
|
||||
.style(ButtonStyle::Outlined)
|
||||
.size(ButtonSize::Compact)
|
||||
.on_click(move |_, _, cx| {
|
||||
registry_store.update(cx, |store, cx| store.refresh(cx));
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_agents(
|
||||
&mut self,
|
||||
range: Range<usize>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AgentRegistryCard> {
|
||||
range
|
||||
.map(|index| {
|
||||
let Some(agent_index) = self.filtered_registry_indices.get(index).copied() else {
|
||||
return self.render_missing_agent();
|
||||
};
|
||||
let Some(agent) = self.registry_agents.get(agent_index) else {
|
||||
return self.render_missing_agent();
|
||||
};
|
||||
self.render_registry_agent(agent, cx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_missing_agent(&self) -> AgentRegistryCard {
|
||||
AgentRegistryCard::new().child(
|
||||
Label::new("Missing registry entry.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_registry_agent(
|
||||
&self,
|
||||
agent: &RegistryAgent,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AgentRegistryCard {
|
||||
let install_status = self.install_status(agent.id().as_ref());
|
||||
let supports_current_platform = agent.supports_current_platform();
|
||||
|
||||
let icon = match agent.icon_path() {
|
||||
Some(icon_path) => Icon::from_external_svg(icon_path.clone()),
|
||||
None => Icon::new(IconName::Sparkle),
|
||||
}
|
||||
.size(IconSize::Medium)
|
||||
.color(Color::Muted);
|
||||
|
||||
let install_button =
|
||||
self.install_button(agent, install_status, supports_current_platform, cx);
|
||||
|
||||
let repository_button = agent.repository().map(|repository| {
|
||||
let repository_for_tooltip = repository.clone();
|
||||
let repository_for_click = repository.to_string();
|
||||
|
||||
IconButton::new(
|
||||
SharedString::from(format!("agent-repo-{}", agent.id())),
|
||||
IconName::Github,
|
||||
)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Visit Agent Repository",
|
||||
None,
|
||||
repository_for_tooltip.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.open_url(&repository_for_click);
|
||||
})
|
||||
});
|
||||
|
||||
let website_button = agent.website().map(|website| {
|
||||
let website = website.clone();
|
||||
let website_for_click = website.clone();
|
||||
IconButton::new(
|
||||
SharedString::from(format!("agent-website-{}", agent.id())),
|
||||
IconName::Link,
|
||||
)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::with_meta("Visit Agent Website", None, website.clone(), cx)
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.open_url(&website_for_click);
|
||||
})
|
||||
});
|
||||
|
||||
AgentRegistryCard::new()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(icon)
|
||||
.child(Headline::new(agent.name().clone()).size(HeadlineSize::Small))
|
||||
.child(Label::new(format!("v{}", agent.version())).color(Color::Muted))
|
||||
.when(!supports_current_platform, |this| {
|
||||
this.child(
|
||||
Label::new("Not supported on this platform")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Warning),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(install_button),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new(agent.description().clone())
|
||||
.size(LabelSize::Small)
|
||||
.truncate(),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new(format!("ID: {}", agent.id()))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.truncate(),
|
||||
)
|
||||
.when_some(repository_button, |this, button| this.child(button))
|
||||
.when_some(website_button, |this, button| this.child(button)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn install_button(
|
||||
&self,
|
||||
agent: &RegistryAgent,
|
||||
install_status: RegistryInstallStatus,
|
||||
supports_current_platform: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Button {
|
||||
let button_id = SharedString::from(format!("install-agent-{}", agent.id()));
|
||||
|
||||
if !supports_current_platform {
|
||||
return Button::new(button_id, "Unavailable")
|
||||
.style(ButtonStyle::OutlinedGhost)
|
||||
.disabled(true);
|
||||
}
|
||||
|
||||
match install_status {
|
||||
RegistryInstallStatus::NotInstalled => {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
let agent_id = agent.id().to_string();
|
||||
Button::new(button_id, "Install")
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.start_icon(
|
||||
Icon::new(IconName::Download)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.on_click(move |_, _, cx| {
|
||||
let agent_id = agent_id.clone();
|
||||
update_settings_file(fs.clone(), cx, move |settings, _| {
|
||||
let agent_servers = settings.agent_servers.get_or_insert_default();
|
||||
agent_servers.entry(agent_id).or_insert_with(|| {
|
||||
settings::CustomAgentServerSettings::Registry {
|
||||
default_mode: None,
|
||||
default_model: None,
|
||||
env: Default::default(),
|
||||
favorite_models: Vec::new(),
|
||||
default_config_options: HashMap::default(),
|
||||
favorite_config_option_values: HashMap::default(),
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
RegistryInstallStatus::InstalledRegistry => {
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
let agent_id = agent.id().to_string();
|
||||
Button::new(button_id, "Remove")
|
||||
.style(ButtonStyle::OutlinedGhost)
|
||||
.on_click(move |_, _, cx| {
|
||||
let agent_id = agent_id.clone();
|
||||
update_settings_file(fs.clone(), cx, move |settings, _| {
|
||||
let Some(agent_servers) = settings.agent_servers.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(entry) = agent_servers.get(agent_id.as_str())
|
||||
&& matches!(
|
||||
entry,
|
||||
settings::CustomAgentServerSettings::Registry { .. }
|
||||
)
|
||||
{
|
||||
agent_servers.remove(agent_id.as_str());
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed")
|
||||
.style(ButtonStyle::OutlinedGhost)
|
||||
.disabled(true),
|
||||
RegistryInstallStatus::InstalledExtension => Button::new(button_id, "Installed")
|
||||
.style(ButtonStyle::OutlinedGhost)
|
||||
.disabled(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AgentRegistryPage {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_4()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.justify_between()
|
||||
.child(Headline::new("ACP Registry").size(HeadlineSize::Large))
|
||||
.child(
|
||||
Button::new("learn-more", "Learn More")
|
||||
.style(ButtonStyle::Outlined)
|
||||
.size(ButtonSize::Medium)
|
||||
.end_icon(
|
||||
Icon::new(IconName::ArrowUpRight)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.open_url(&zed_urls::acp_registry_blog(cx))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.child(self.render_search(cx))
|
||||
.child(
|
||||
div().child(
|
||||
ToggleButtonGroup::single_row(
|
||||
"registry-filter-buttons",
|
||||
[
|
||||
ToggleButtonSimple::new(
|
||||
"All",
|
||||
cx.listener(|this, _event, _, cx| {
|
||||
this.filter = RegistryFilter::All;
|
||||
this.filter_registry_agents(cx);
|
||||
this.scroll_to_top(cx);
|
||||
}),
|
||||
),
|
||||
ToggleButtonSimple::new(
|
||||
"Installed",
|
||||
cx.listener(|this, _event, _, cx| {
|
||||
this.filter = RegistryFilter::Installed;
|
||||
this.filter_registry_agents(cx);
|
||||
this.scroll_to_top(cx);
|
||||
}),
|
||||
),
|
||||
ToggleButtonSimple::new(
|
||||
"Not Installed",
|
||||
cx.listener(|this, _event, _, cx| {
|
||||
this.filter = RegistryFilter::NotInstalled;
|
||||
this.filter_registry_agents(cx);
|
||||
this.scroll_to_top(cx);
|
||||
}),
|
||||
),
|
||||
],
|
||||
)
|
||||
.style(ToggleButtonGroupStyle::Outlined)
|
||||
.size(ToggleButtonGroupSize::Custom(rems_from_px(30.)))
|
||||
.label_size(LabelSize::Default)
|
||||
.auto_width()
|
||||
.selected_index(match self.filter {
|
||||
RegistryFilter::All => 0,
|
||||
RegistryFilter::Installed => 1,
|
||||
RegistryFilter::NotInstalled => 2,
|
||||
})
|
||||
.into_any_element(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
|
||||
let count = self.filtered_registry_indices.len();
|
||||
if count == 0 {
|
||||
this.child(self.render_empty_state(cx)).into_any_element()
|
||||
} else {
|
||||
let scroll_handle = &self.list;
|
||||
this.child(
|
||||
uniform_list("registry-entries", count, cx.processor(Self::render_agents))
|
||||
.flex_grow()
|
||||
.pb_4()
|
||||
.track_scroll(scroll_handle),
|
||||
)
|
||||
.vertical_scrollbar_for(scroll_handle, window, cx)
|
||||
.into_any_element()
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ItemEvent> for AgentRegistryPage {}
|
||||
|
||||
impl Focusable for AgentRegistryPage {
|
||||
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
||||
self.query_editor.read(cx).focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Item for AgentRegistryPage {
|
||||
type Event = ItemEvent;
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
|
||||
"ACP Registry".into()
|
||||
}
|
||||
|
||||
fn telemetry_event_text(&self) -> Option<&'static str> {
|
||||
Some("ACP Registry Page Opened")
|
||||
}
|
||||
|
||||
fn show_toolbar(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
|
||||
f(*event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,985 @@
|
||||
mod agent_configuration;
|
||||
pub mod agent_connection_store;
|
||||
mod agent_diff;
|
||||
mod agent_model_selector;
|
||||
mod agent_panel;
|
||||
mod agent_registry_ui;
|
||||
mod buffer_codegen;
|
||||
mod completion_provider;
|
||||
mod config_options;
|
||||
mod context;
|
||||
mod context_server_configuration;
|
||||
pub(crate) mod conversation_view;
|
||||
mod diagnostics;
|
||||
pub mod draft_prompt_store;
|
||||
mod entry_view_state;
|
||||
mod external_source_prompt;
|
||||
mod favorite_models;
|
||||
mod inline_assistant;
|
||||
mod inline_prompt_editor;
|
||||
mod language_model_selector;
|
||||
mod mention_set;
|
||||
mod message_editor;
|
||||
mod mode_selector;
|
||||
mod model_selector;
|
||||
mod model_selector_popover;
|
||||
mod profile_selector;
|
||||
mod terminal_codegen;
|
||||
mod terminal_inline_assistant;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
mod thread_import;
|
||||
pub mod thread_metadata_store;
|
||||
pub mod thread_worktree_archive;
|
||||
|
||||
pub mod threads_archive_view;
|
||||
mod ui;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::ui::IconName;
|
||||
use agent_client_protocol::schema as acp;
|
||||
use agent_settings::{AgentProfileId, AgentSettings};
|
||||
use command_palette_hooks::CommandPaletteFilter;
|
||||
use feature_flags::FeatureFlagAppExt as _;
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, Window, actions,
|
||||
};
|
||||
use language::{
|
||||
LanguageRegistry,
|
||||
language_settings::{AllLanguageSettings, EditPredictionProvider},
|
||||
};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
|
||||
};
|
||||
use project::{AgentId, DisableAiSettings};
|
||||
use prompt_store::PromptBuilder;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{LanguageModelSelection, Settings as _, SettingsStore};
|
||||
use std::any::TypeId;
|
||||
use std::path::{Path, PathBuf};
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
|
||||
pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore};
|
||||
pub use crate::agent_panel::{
|
||||
AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId,
|
||||
};
|
||||
use crate::agent_registry_ui::AgentRegistryPage;
|
||||
pub use crate::inline_assistant::InlineAssistant;
|
||||
pub use crate::thread_metadata_store::ThreadId;
|
||||
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
|
||||
pub use conversation_view::ConversationView;
|
||||
pub use external_source_prompt::ExternalSourcePrompt;
|
||||
pub(crate) use mode_selector::ModeSelector;
|
||||
pub(crate) use model_selector::ModelSelector;
|
||||
pub(crate) use model_selector_popover::ModelSelectorPopover;
|
||||
pub use thread_import::{
|
||||
AcpThreadImportOnboarding, CrossChannelImportOnboarding, ThreadImportModal,
|
||||
channels_with_threads, import_threads_from_other_channels,
|
||||
};
|
||||
use zed_actions;
|
||||
pub use zed_actions::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree};
|
||||
|
||||
pub(crate) fn resolve_agent_image(
|
||||
dest_url: &str,
|
||||
worktree_roots: &[PathBuf],
|
||||
) -> Option<ImageSource> {
|
||||
if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
|
||||
return Some(ImageSource::Resource(Resource::Uri(SharedUri::from(
|
||||
dest_url.to_string(),
|
||||
))));
|
||||
}
|
||||
|
||||
let path = Path::new(dest_url);
|
||||
if path.is_absolute() && path.exists() {
|
||||
return Some(ImageSource::Resource(Resource::Path(Arc::from(path))));
|
||||
}
|
||||
|
||||
for root in worktree_roots {
|
||||
let absolute_path = root.join(dest_url);
|
||||
if absolute_path.exists() {
|
||||
return Some(ImageSource::Resource(Resource::Path(Arc::from(
|
||||
absolute_path.as_path(),
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
|
||||
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
|
||||
actions!(
|
||||
agent,
|
||||
[
|
||||
/// Toggles the menu to create new agent threads.
|
||||
ToggleNewThreadMenu,
|
||||
/// Toggles the options menu for agent settings and preferences.
|
||||
ToggleOptionsMenu,
|
||||
/// Toggles the profile or mode selector for switching between agent profiles.
|
||||
ToggleProfileSelector,
|
||||
/// Cycles through available session modes.
|
||||
CycleModeSelector,
|
||||
/// Cycles through favorited models in the ACP model selector.
|
||||
CycleFavoriteModels,
|
||||
/// Expands the message editor to full size.
|
||||
ExpandMessageEditor,
|
||||
/// Adds a context server to the configuration.
|
||||
AddContextServer,
|
||||
/// Archives the currently selected thread.
|
||||
ArchiveSelectedThread,
|
||||
/// Removes the currently selected thread.
|
||||
RemoveSelectedThread,
|
||||
/// Starts a chat conversation with follow-up enabled.
|
||||
ChatWithFollow,
|
||||
/// Cycles to the next inline assist suggestion.
|
||||
CycleNextInlineAssist,
|
||||
/// Cycles to the previous inline assist suggestion.
|
||||
CyclePreviousInlineAssist,
|
||||
/// Moves focus up in the interface.
|
||||
FocusUp,
|
||||
/// Moves focus down in the interface.
|
||||
FocusDown,
|
||||
/// Moves focus left in the interface.
|
||||
FocusLeft,
|
||||
/// Moves focus right in the interface.
|
||||
FocusRight,
|
||||
/// Opens the active thread as a markdown file.
|
||||
OpenActiveThreadAsMarkdown,
|
||||
/// Opens the agent diff view to review changes.
|
||||
OpenAgentDiff,
|
||||
/// Copies the current thread to the clipboard as JSON for debugging.
|
||||
CopyThreadToClipboard,
|
||||
/// Loads a thread from the clipboard JSON for debugging.
|
||||
LoadThreadFromClipboard,
|
||||
/// Keeps the current suggestion or change.
|
||||
Keep,
|
||||
/// Rejects the current suggestion or change.
|
||||
Reject,
|
||||
/// Rejects all suggestions or changes.
|
||||
RejectAll,
|
||||
/// Undoes the most recent reject operation, restoring the rejected changes.
|
||||
UndoLastReject,
|
||||
/// Keeps all suggestions or changes.
|
||||
KeepAll,
|
||||
/// Allow this operation only this time.
|
||||
AllowOnce,
|
||||
/// Allow this operation and remember the choice.
|
||||
AllowAlways,
|
||||
/// Reject this operation only this time.
|
||||
RejectOnce,
|
||||
/// Follows the agent's suggestions.
|
||||
Follow,
|
||||
/// Resets the trial upsell notification.
|
||||
ResetTrialUpsell,
|
||||
/// Resets the trial end upsell notification.
|
||||
ResetTrialEndUpsell,
|
||||
/// Opens the "Add Context" menu in the message editor.
|
||||
OpenAddContextMenu,
|
||||
/// Interrupts the current generation and sends the message immediately.
|
||||
SendImmediately,
|
||||
/// Sends the next queued message immediately.
|
||||
SendNextQueuedMessage,
|
||||
/// Removes the first message from the queue (the next one to be sent).
|
||||
RemoveFirstQueuedMessage,
|
||||
/// Edits the first message in the queue (the next one to be sent).
|
||||
EditFirstQueuedMessage,
|
||||
/// Clears all messages from the queue.
|
||||
ClearMessageQueue,
|
||||
/// Opens the permission granularity dropdown for the current tool call.
|
||||
OpenPermissionDropdown,
|
||||
/// Toggles thinking mode for models that support extended thinking.
|
||||
ToggleThinkingMode,
|
||||
/// Cycles through available thinking effort levels for the current model.
|
||||
CycleThinkingEffort,
|
||||
/// Toggles the thinking effort selector menu open or closed.
|
||||
ToggleThinkingEffortMenu,
|
||||
/// Toggles fast mode for models that support it.
|
||||
ToggleFastMode,
|
||||
/// Scroll the output by one page up.
|
||||
ScrollOutputPageUp,
|
||||
/// Scroll the output by one page down.
|
||||
ScrollOutputPageDown,
|
||||
/// Scroll the output up by three lines.
|
||||
ScrollOutputLineUp,
|
||||
/// Scroll the output down by three lines.
|
||||
ScrollOutputLineDown,
|
||||
/// Scroll the output to the top.
|
||||
ScrollOutputToTop,
|
||||
/// Scroll the output to the bottom.
|
||||
ScrollOutputToBottom,
|
||||
/// Scroll the output to the previous user message.
|
||||
ScrollOutputToPreviousMessage,
|
||||
/// Scroll the output to the next user message.
|
||||
ScrollOutputToNextMessage,
|
||||
/// Import agent threads from other Zed release channels (e.g. Preview, Nightly).
|
||||
ImportThreadsFromOtherChannels,
|
||||
]
|
||||
);
|
||||
|
||||
actions!(
|
||||
dev,
|
||||
[
|
||||
/// Shows metadata for the currently active thread.
|
||||
ShowThreadMetadata,
|
||||
/// Shows metadata for all threads in the sidebar.
|
||||
ShowAllSidebarThreadMetadata,
|
||||
]
|
||||
);
|
||||
|
||||
/// Action to authorize a tool call with a specific permission option.
|
||||
/// This is used by the permission granularity dropdown to authorize tool calls.
|
||||
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuthorizeToolCall {
|
||||
/// The tool call ID to authorize.
|
||||
pub tool_call_id: String,
|
||||
/// The permission option ID to use.
|
||||
pub option_id: String,
|
||||
/// The kind of permission option (serialized as string).
|
||||
pub option_kind: String,
|
||||
}
|
||||
|
||||
/// Action to select a permission granularity option from the dropdown.
|
||||
/// This updates the selected granularity without triggering authorization.
|
||||
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SelectPermissionGranularity {
|
||||
/// The tool call ID for which to select the granularity.
|
||||
pub tool_call_id: String,
|
||||
/// The index of the selected granularity option.
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// Action to toggle a command pattern checkbox in the permission dropdown.
|
||||
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ToggleCommandPattern {
|
||||
/// The tool call ID for which to toggle the pattern.
|
||||
pub tool_call_id: String,
|
||||
/// The index of the command pattern to toggle.
|
||||
pub pattern_index: usize,
|
||||
}
|
||||
|
||||
/// Creates a new conversation thread, optionally based on an existing thread.
|
||||
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NewThread;
|
||||
|
||||
/// Creates a new external agent conversation thread.
|
||||
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NewExternalAgentThread {
|
||||
/// The agent id to use for the conversation.
|
||||
#[serde(deserialize_with = "deserialize_external_agent_id")]
|
||||
agent: AgentId,
|
||||
}
|
||||
|
||||
fn deserialize_external_agent_id<'de, D>(deserializer: D) -> Result<AgentId, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum AgentIdOrLegacyAgent {
|
||||
LegacyAgent(Agent),
|
||||
AgentId(AgentId),
|
||||
}
|
||||
|
||||
match AgentIdOrLegacyAgent::deserialize(deserializer)? {
|
||||
AgentIdOrLegacyAgent::AgentId(agent_id) => Ok(agent_id),
|
||||
AgentIdOrLegacyAgent::LegacyAgent(Agent::Custom { id }) => Ok(id),
|
||||
AgentIdOrLegacyAgent::LegacyAgent(Agent::NativeAgent) => Ok(Agent::NativeAgent.id()),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
AgentIdOrLegacyAgent::LegacyAgent(Agent::Stub) => Ok(Agent::Stub.id()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NewNativeAgentThreadFromSummary {
|
||||
from_session_id: acp::SessionId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum Agent {
|
||||
#[default]
|
||||
#[serde(alias = "NativeAgent", alias = "TextThread")]
|
||||
NativeAgent,
|
||||
#[serde(alias = "Custom")]
|
||||
Custom {
|
||||
#[serde(rename = "name")]
|
||||
id: AgentId,
|
||||
},
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Stub,
|
||||
}
|
||||
|
||||
impl From<AgentId> for Agent {
|
||||
fn from(id: AgentId) -> Self {
|
||||
if id.as_ref() == agent::ZED_AGENT_ID.as_ref() {
|
||||
return Self::NativeAgent;
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if id.as_ref() == "stub" {
|
||||
return Self::Stub;
|
||||
}
|
||||
Self::Custom { id }
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub fn id(&self) -> AgentId {
|
||||
match self {
|
||||
Self::NativeAgent => agent::ZED_AGENT_ID.clone(),
|
||||
Self::Custom { id } => id.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Self::Stub => "stub".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_native(&self) -> bool {
|
||||
matches!(self, Self::NativeAgent)
|
||||
}
|
||||
|
||||
pub fn label(&self) -> SharedString {
|
||||
match self {
|
||||
Self::NativeAgent => "Zed Agent".into(),
|
||||
Self::Custom { id, .. } => id.0.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Self::Stub => "Stub Agent".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> Option<IconName> {
|
||||
match self {
|
||||
Self::NativeAgent => None,
|
||||
Self::Custom { .. } => Some(IconName::Sparkle),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Self::Stub => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server(
|
||||
&self,
|
||||
fs: Arc<dyn fs::Fs>,
|
||||
thread_store: Entity<agent::ThreadStore>,
|
||||
) -> Rc<dyn agent_servers::AgentServer> {
|
||||
match self {
|
||||
Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, thread_store)),
|
||||
Self::Custom { id: name } => {
|
||||
Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Self::Stub => Rc::new(crate::test_support::StubAgentServer::default_response()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content to initialize new external agent with.
|
||||
pub enum AgentInitialContent {
|
||||
ThreadSummary {
|
||||
session_id: acp::SessionId,
|
||||
title: Option<SharedString>,
|
||||
},
|
||||
ContentBlock {
|
||||
blocks: Vec<acp::ContentBlock>,
|
||||
auto_submit: bool,
|
||||
},
|
||||
FromExternalSource(ExternalSourcePrompt),
|
||||
}
|
||||
|
||||
impl From<ExternalSourcePrompt> for AgentInitialContent {
|
||||
fn from(prompt: ExternalSourcePrompt) -> Self {
|
||||
Self::FromExternalSource(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the profile management interface for configuring agent tools and settings.
|
||||
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManageProfiles {
|
||||
#[serde(default)]
|
||||
pub customize_tools: Option<AgentProfileId>,
|
||||
}
|
||||
|
||||
impl ManageProfiles {
|
||||
pub fn customize_tools(profile_id: AgentProfileId) -> Self {
|
||||
Self {
|
||||
customize_tools: Some(profile_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum ModelUsageContext {
|
||||
InlineAssistant,
|
||||
}
|
||||
|
||||
impl ModelUsageContext {
|
||||
pub fn configured_model(&self, cx: &App) -> Option<ConfiguredModel> {
|
||||
match self {
|
||||
Self::InlineAssistant => {
|
||||
LanguageModelRegistry::read_global(cx).inline_assistant_model()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn humanize_token_count(count: u64) -> String {
|
||||
match count {
|
||||
0..=999 => count.to_string(),
|
||||
1000..=9999 => {
|
||||
let thousands = count / 1000;
|
||||
let hundreds = (count % 1000 + 50) / 100;
|
||||
if hundreds == 0 {
|
||||
format!("{}k", thousands)
|
||||
} else if hundreds == 10 {
|
||||
format!("{}k", thousands + 1)
|
||||
} else {
|
||||
format!("{}.{}k", thousands, hundreds)
|
||||
}
|
||||
}
|
||||
10_000..=999_999 => format!("{}k", (count + 500) / 1000),
|
||||
1_000_000..=9_999_999 => {
|
||||
let millions = count / 1_000_000;
|
||||
let hundred_thousands = (count % 1_000_000 + 50_000) / 100_000;
|
||||
if hundred_thousands == 0 {
|
||||
format!("{}M", millions)
|
||||
} else if hundred_thousands == 10 {
|
||||
format!("{}M", millions + 1)
|
||||
} else {
|
||||
format!("{}.{}M", millions, hundred_thousands)
|
||||
}
|
||||
}
|
||||
10_000_000.. => format!("{}M", (count + 500_000) / 1_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the `agent` crate.
|
||||
pub fn init(
|
||||
fs: Arc<dyn Fs>,
|
||||
prompt_builder: Arc<PromptBuilder>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
is_new_install: bool,
|
||||
is_eval: bool,
|
||||
cx: &mut App,
|
||||
) {
|
||||
agent::ThreadStore::init_global(cx);
|
||||
rules_library::init(cx);
|
||||
if !is_eval {
|
||||
// Initializing the language model from the user settings messes with the eval, so we only initialize them when
|
||||
// we're not running inside of the eval.
|
||||
init_language_model_settings(cx);
|
||||
}
|
||||
agent_panel::init(cx);
|
||||
context_server_configuration::init(language_registry.clone(), fs.clone(), cx);
|
||||
thread_metadata_store::init(cx);
|
||||
|
||||
inline_assistant::init(fs.clone(), prompt_builder.clone(), cx);
|
||||
terminal_inline_assistant::init(fs.clone(), prompt_builder, cx);
|
||||
cx.observe_new(move |workspace, window, cx| {
|
||||
ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx)
|
||||
})
|
||||
.detach();
|
||||
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
|
||||
workspace.register_action(
|
||||
move |workspace: &mut Workspace,
|
||||
_: &zed_actions::AcpRegistry,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>| {
|
||||
let existing = workspace
|
||||
.active_pane()
|
||||
.read(cx)
|
||||
.items()
|
||||
.find_map(|item| item.downcast::<AgentRegistryPage>());
|
||||
|
||||
if let Some(existing) = existing {
|
||||
existing.update(cx, |_, cx| {
|
||||
project::AgentRegistryStore::global(cx)
|
||||
.update(cx, |store, cx| store.refresh(cx));
|
||||
});
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
} else {
|
||||
let registry_page = AgentRegistryPage::new(workspace, window, cx);
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(registry_page),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
})
|
||||
.detach();
|
||||
cx.observe_new(ManageProfilesModal::register).detach();
|
||||
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
|
||||
workspace.register_action(
|
||||
|workspace: &mut Workspace,
|
||||
_: &ImportThreadsFromOtherChannels,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Workspace>| {
|
||||
import_threads_from_other_channels(workspace, cx);
|
||||
},
|
||||
);
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Update command palette filter based on AI settings
|
||||
update_command_palette_filter(cx);
|
||||
|
||||
// Watch for settings changes
|
||||
cx.observe_global::<SettingsStore>(|app_cx| {
|
||||
// When settings change, update the command palette filter
|
||||
update_command_palette_filter(app_cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.on_flags_ready(|_, cx| {
|
||||
update_command_palette_filter(cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
maybe_backfill_editor_layout(fs, is_new_install, cx);
|
||||
}
|
||||
|
||||
fn maybe_backfill_editor_layout(fs: Arc<dyn Fs>, is_new_install: bool, cx: &mut App) {
|
||||
let kvp = db::kvp::KeyValueStore::global(cx);
|
||||
let already_backfilled =
|
||||
util::ResultExt::log_err(kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY))
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if !already_backfilled {
|
||||
if !is_new_install {
|
||||
AgentSettings::backfill_editor_layout(fs, cx);
|
||||
}
|
||||
|
||||
db::write_and_log(cx, move || async move {
|
||||
kvp.write_kvp(
|
||||
PARALLEL_AGENT_LAYOUT_BACKFILL_KEY.to_string(),
|
||||
"1".to_string(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn update_command_palette_filter(cx: &mut App) {
|
||||
let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
|
||||
let agent_enabled = AgentSettings::get_global(cx).enabled;
|
||||
|
||||
let edit_prediction_provider = AllLanguageSettings::get_global(cx)
|
||||
.edit_predictions
|
||||
.provider;
|
||||
|
||||
CommandPaletteFilter::update_global(cx, |filter, _| {
|
||||
use editor::actions::{
|
||||
AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction,
|
||||
NextEditPrediction, PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction,
|
||||
};
|
||||
let edit_prediction_actions = [
|
||||
TypeId::of::<AcceptEditPrediction>(),
|
||||
TypeId::of::<AcceptNextWordEditPrediction>(),
|
||||
TypeId::of::<AcceptNextLineEditPrediction>(),
|
||||
TypeId::of::<AcceptEditPrediction>(),
|
||||
TypeId::of::<ShowEditPrediction>(),
|
||||
TypeId::of::<NextEditPrediction>(),
|
||||
TypeId::of::<PreviousEditPrediction>(),
|
||||
TypeId::of::<ToggleEditPrediction>(),
|
||||
];
|
||||
|
||||
if disable_ai {
|
||||
filter.hide_namespace("agent");
|
||||
filter.hide_namespace("agents");
|
||||
filter.hide_namespace("assistant");
|
||||
filter.hide_namespace("copilot");
|
||||
filter.hide_namespace("zed_predict_onboarding");
|
||||
filter.hide_namespace("edit_prediction");
|
||||
|
||||
filter.hide_action_types(&edit_prediction_actions);
|
||||
filter.hide_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
|
||||
} else {
|
||||
if agent_enabled {
|
||||
filter.show_namespace("agent");
|
||||
filter.show_namespace("agents");
|
||||
filter.show_namespace("assistant");
|
||||
} else {
|
||||
filter.hide_namespace("agent");
|
||||
filter.hide_namespace("agents");
|
||||
filter.hide_namespace("assistant");
|
||||
}
|
||||
|
||||
match edit_prediction_provider {
|
||||
EditPredictionProvider::None => {
|
||||
filter.hide_namespace("edit_prediction");
|
||||
filter.hide_namespace("copilot");
|
||||
filter.hide_action_types(&edit_prediction_actions);
|
||||
}
|
||||
EditPredictionProvider::Copilot => {
|
||||
filter.show_namespace("edit_prediction");
|
||||
filter.show_namespace("copilot");
|
||||
filter.show_action_types(edit_prediction_actions.iter());
|
||||
}
|
||||
EditPredictionProvider::Zed
|
||||
| EditPredictionProvider::Codestral
|
||||
| EditPredictionProvider::Ollama
|
||||
| EditPredictionProvider::OpenAiCompatibleApi
|
||||
| EditPredictionProvider::Mercury => {
|
||||
filter.show_namespace("edit_prediction");
|
||||
filter.hide_namespace("copilot");
|
||||
filter.show_action_types(edit_prediction_actions.iter());
|
||||
}
|
||||
}
|
||||
|
||||
filter.show_namespace("zed_predict_onboarding");
|
||||
filter.show_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
|
||||
|
||||
filter.show_namespace("multi_workspace");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn init_language_model_settings(cx: &mut App) {
|
||||
update_active_language_model_from_settings(cx);
|
||||
|
||||
cx.observe_global::<SettingsStore>(update_active_language_model_from_settings)
|
||||
.detach();
|
||||
cx.subscribe(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
|_, event: &language_model::Event, cx| match event {
|
||||
language_model::Event::ProviderStateChanged(_)
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_)
|
||||
| language_model::Event::ProvidersChanged => {
|
||||
update_active_language_model_from_settings(cx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn update_active_language_model_from_settings(cx: &mut App) {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
|
||||
fn to_selected_model(selection: &LanguageModelSelection) -> language_model::SelectedModel {
|
||||
language_model::SelectedModel {
|
||||
provider: LanguageModelProviderId::from(selection.provider.0.clone()),
|
||||
model: LanguageModelId::from(selection.model.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
let default = settings.default_model.as_ref().map(to_selected_model);
|
||||
let inline_assistant = settings
|
||||
.inline_assistant_model
|
||||
.as_ref()
|
||||
.map(to_selected_model);
|
||||
let commit_message = settings
|
||||
.commit_message_model
|
||||
.as_ref()
|
||||
.map(to_selected_model);
|
||||
let thread_summary = settings
|
||||
.thread_summary_model
|
||||
.as_ref()
|
||||
.map(to_selected_model);
|
||||
let inline_alternatives = settings
|
||||
.inline_alternatives
|
||||
.iter()
|
||||
.map(to_selected_model)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.select_default_model(default.as_ref(), cx);
|
||||
registry.select_inline_assistant_model(inline_assistant.as_ref(), cx);
|
||||
registry.select_commit_message_model(commit_message.as_ref(), cx);
|
||||
registry.select_thread_summary_model(thread_summary.as_ref(), cx);
|
||||
registry.select_inline_alternative_models(inline_alternatives, cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agent_settings::{AgentProfileId, AgentSettings};
|
||||
use command_palette_hooks::CommandPaletteFilter;
|
||||
use db::kvp::KeyValueStore;
|
||||
use editor::actions::AcceptEditPrediction;
|
||||
use gpui::{BorrowAppContext, TestAppContext, px};
|
||||
use project::DisableAiSettings;
|
||||
use settings::{
|
||||
DockPosition, NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, Settings, SettingsStore,
|
||||
};
|
||||
|
||||
#[gpui::test]
|
||||
fn test_agent_command_palette_visibility(cx: &mut TestAppContext) {
|
||||
// Init settings
|
||||
cx.update(|cx| {
|
||||
let store = SettingsStore::test(cx);
|
||||
cx.set_global(store);
|
||||
command_palette_hooks::init(cx);
|
||||
AgentSettings::register(cx);
|
||||
DisableAiSettings::register(cx);
|
||||
AllLanguageSettings::register(cx);
|
||||
});
|
||||
|
||||
let agent_settings = AgentSettings {
|
||||
enabled: true,
|
||||
button: true,
|
||||
dock: DockPosition::Right,
|
||||
flexible: true,
|
||||
default_width: px(300.),
|
||||
default_height: px(600.),
|
||||
max_content_width: Some(px(850.)),
|
||||
default_model: None,
|
||||
subagent_model: None,
|
||||
inline_assistant_model: None,
|
||||
inline_assistant_use_streaming_tools: false,
|
||||
commit_message_model: None,
|
||||
thread_summary_model: None,
|
||||
inline_alternatives: vec![],
|
||||
favorite_models: vec![],
|
||||
default_profile: AgentProfileId::default(),
|
||||
profiles: Default::default(),
|
||||
notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
|
||||
play_sound_when_agent_done: PlaySoundWhenAgentDone::Never,
|
||||
single_file_review: false,
|
||||
model_parameters: vec![],
|
||||
enable_feedback: false,
|
||||
expand_edit_card: true,
|
||||
expand_terminal_card: true,
|
||||
cancel_generation_on_terminal_stop: true,
|
||||
use_modifier_to_send: true,
|
||||
message_editor_min_lines: 1,
|
||||
tool_permissions: Default::default(),
|
||||
show_turn_stats: false,
|
||||
show_merge_conflict_indicator: true,
|
||||
sidebar_side: Default::default(),
|
||||
thinking_display: Default::default(),
|
||||
};
|
||||
|
||||
cx.update(|cx| {
|
||||
AgentSettings::override_global(agent_settings.clone(), cx);
|
||||
DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
|
||||
|
||||
// Initial update
|
||||
update_command_palette_filter(cx);
|
||||
});
|
||||
|
||||
// Assert visible
|
||||
cx.update(|cx| {
|
||||
let filter = CommandPaletteFilter::try_global(cx).unwrap();
|
||||
assert!(
|
||||
!filter.is_hidden(&NewThread),
|
||||
"NewThread should be visible by default"
|
||||
);
|
||||
});
|
||||
|
||||
// Disable agent
|
||||
cx.update(|cx| {
|
||||
let mut new_settings = agent_settings.clone();
|
||||
new_settings.enabled = false;
|
||||
AgentSettings::override_global(new_settings, cx);
|
||||
|
||||
// Trigger update
|
||||
update_command_palette_filter(cx);
|
||||
});
|
||||
|
||||
// Assert hidden
|
||||
cx.update(|cx| {
|
||||
let filter = CommandPaletteFilter::try_global(cx).unwrap();
|
||||
assert!(
|
||||
filter.is_hidden(&NewThread),
|
||||
"NewThread should be hidden when agent is disabled"
|
||||
);
|
||||
});
|
||||
|
||||
// Test EditPredictionProvider
|
||||
// Enable EditPredictionProvider::Copilot
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store.update_user_settings(cx, |s| {
|
||||
s.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert(Default::default())
|
||||
.provider = Some(EditPredictionProvider::Copilot);
|
||||
});
|
||||
});
|
||||
update_command_palette_filter(cx);
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
let filter = CommandPaletteFilter::try_global(cx).unwrap();
|
||||
assert!(
|
||||
!filter.is_hidden(&AcceptEditPrediction),
|
||||
"EditPrediction should be visible when provider is Copilot"
|
||||
);
|
||||
});
|
||||
|
||||
// Disable EditPredictionProvider (None)
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store.update_user_settings(cx, |s| {
|
||||
s.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert(Default::default())
|
||||
.provider = Some(EditPredictionProvider::None);
|
||||
});
|
||||
});
|
||||
update_command_palette_filter(cx);
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
let filter = CommandPaletteFilter::try_global(cx).unwrap();
|
||||
assert!(
|
||||
filter.is_hidden(&AcceptEditPrediction),
|
||||
"EditPrediction should be hidden when provider is None"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async fn setup_backfill_test(cx: &mut TestAppContext) -> Arc<dyn Fs> {
|
||||
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
||||
fs.save(
|
||||
paths::settings_file().as_path(),
|
||||
&"{}".into(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.update(|cx| {
|
||||
cx.set_global(db::AppDatabase::test_new());
|
||||
let store = SettingsStore::test(cx);
|
||||
cx.set_global(store);
|
||||
AgentSettings::register(cx);
|
||||
DisableAiSettings::register(cx);
|
||||
cx.set_staff(true);
|
||||
});
|
||||
|
||||
fs
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_backfill_sets_kvp_flag(cx: &mut TestAppContext) {
|
||||
let fs = setup_backfill_test(cx).await;
|
||||
|
||||
cx.update(|cx| {
|
||||
let kvp = KeyValueStore::global(cx);
|
||||
assert!(
|
||||
kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
maybe_backfill_editor_layout(fs.clone(), false, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let kvp = cx.update(|cx| KeyValueStore::global(cx));
|
||||
assert!(
|
||||
kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY)
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"flag should be set after backfill"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_backfill_new_install_sets_flag_without_writing_settings(cx: &mut TestAppContext) {
|
||||
let fs = setup_backfill_test(cx).await;
|
||||
|
||||
cx.update(|cx| {
|
||||
maybe_backfill_editor_layout(fs.clone(), true, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let kvp = cx.update(|cx| KeyValueStore::global(cx));
|
||||
assert!(
|
||||
kvp.read_kvp(PARALLEL_AGENT_LAYOUT_BACKFILL_KEY)
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"flag should be set even for new installs"
|
||||
);
|
||||
|
||||
let written = fs.load(paths::settings_file().as_path()).await.unwrap();
|
||||
assert_eq!(written.trim(), "{}", "settings file should be unchanged");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_backfill_is_idempotent(cx: &mut TestAppContext) {
|
||||
let fs = setup_backfill_test(cx).await;
|
||||
|
||||
cx.update(|cx| {
|
||||
maybe_backfill_editor_layout(fs.clone(), false, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let after_first = fs.load(paths::settings_file().as_path()).await.unwrap();
|
||||
|
||||
cx.update(|cx| {
|
||||
maybe_backfill_editor_layout(fs.clone(), false, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let after_second = fs.load(paths::settings_file().as_path()).await.unwrap();
|
||||
assert_eq!(
|
||||
after_first, after_second,
|
||||
"second call should not change settings"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_external_agent_variants() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Agent>(r#""NativeAgent""#).unwrap(),
|
||||
Agent::NativeAgent,
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Agent>(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(),
|
||||
Agent::Custom {
|
||||
id: "my-agent".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_new_external_agent_thread() {
|
||||
let action = serde_json::from_str::<NewExternalAgentThread>(r#"{"agent":"gemini"}"#)
|
||||
.expect("should deserialize agent id");
|
||||
assert_eq!(action.agent, AgentId::from("gemini"));
|
||||
|
||||
let action = serde_json::from_str::<NewExternalAgentThread>(
|
||||
r#"{"agent":{"custom":{"name":"gemini"}}}"#,
|
||||
)
|
||||
.expect("should deserialize legacy custom agent payload");
|
||||
assert_eq!(action.agent, AgentId::from("gemini"));
|
||||
|
||||
let action = serde_json::from_str::<NewExternalAgentThread>(r#"{"agent":"NativeAgent"}"#)
|
||||
.expect("should deserialize legacy native agent payload");
|
||||
assert_eq!(action.agent, Agent::NativeAgent.id());
|
||||
|
||||
assert!(serde_json::from_str::<NewExternalAgentThread>(r#"{}"#).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,880 @@
|
||||
use std::{cmp::Reverse, rc::Rc, sync::Arc};
|
||||
|
||||
use acp_thread::AgentSessionConfigOptions;
|
||||
use agent_client_protocol::schema as acp;
|
||||
use agent_servers::AgentServer;
|
||||
|
||||
use collections::HashSet;
|
||||
use fs::Fs;
|
||||
use fuzzy::StringMatchCandidate;
|
||||
use gpui::{
|
||||
App, BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*,
|
||||
};
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::SettingsStore;
|
||||
use ui::{
|
||||
ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
|
||||
use crate::ui::{HoldForDefault, documentation_aside_side};
|
||||
|
||||
const PICKER_THRESHOLD: usize = 5;
|
||||
|
||||
pub struct ConfigOptionsView {
|
||||
config_options: Rc<dyn AgentSessionConfigOptions>,
|
||||
selectors: Vec<Entity<ConfigOptionSelector>>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
config_option_ids: Vec<acp::SessionConfigId>,
|
||||
_refresh_task: Task<()>,
|
||||
}
|
||||
|
||||
impl ConfigOptionsView {
|
||||
pub fn new(
|
||||
config_options: Rc<dyn AgentSessionConfigOptions>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let selectors = Self::build_selectors(&config_options, &agent_server, &fs, window, cx);
|
||||
let config_option_ids = Self::config_option_ids(&config_options);
|
||||
|
||||
let rx = config_options.watch(cx);
|
||||
let refresh_task = cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some(mut rx) = rx {
|
||||
while let Ok(()) = rx.recv().await {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.rebuild_selectors(window, cx);
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
config_options,
|
||||
selectors,
|
||||
agent_server,
|
||||
fs,
|
||||
config_option_ids,
|
||||
_refresh_task: refresh_task,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_category_picker(
|
||||
&mut self,
|
||||
category: acp::SessionConfigOptionCategory,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some(config_id) = self.first_config_option_id(category) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(selector) = self.selector_for_config_id(&config_id, cx) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
selector.update(cx, |selector, cx| {
|
||||
selector.toggle_picker(window, cx);
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn cycle_category_option(
|
||||
&mut self,
|
||||
category: acp::SessionConfigOptionCategory,
|
||||
favorites_only: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some(config_id) = self.first_config_option_id(category) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(next_value) = self.next_value_for_config(&config_id, favorites_only, cx) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let task = self
|
||||
.config_options
|
||||
.set_config_option(config_id, next_value, cx);
|
||||
|
||||
cx.spawn(async move |_, _| {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to set config option: {:?}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn first_config_option_id(
|
||||
&self,
|
||||
category: acp::SessionConfigOptionCategory,
|
||||
) -> Option<acp::SessionConfigId> {
|
||||
self.config_options
|
||||
.config_options()
|
||||
.into_iter()
|
||||
.find(|option| option.category.as_ref() == Some(&category))
|
||||
.map(|option| option.id)
|
||||
}
|
||||
|
||||
fn selector_for_config_id(
|
||||
&self,
|
||||
config_id: &acp::SessionConfigId,
|
||||
cx: &App,
|
||||
) -> Option<Entity<ConfigOptionSelector>> {
|
||||
self.selectors
|
||||
.iter()
|
||||
.find(|selector| selector.read(cx).config_id() == config_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn next_value_for_config(
|
||||
&self,
|
||||
config_id: &acp::SessionConfigId,
|
||||
favorites_only: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<acp::SessionConfigValueId> {
|
||||
let mut options = extract_options(&self.config_options, config_id);
|
||||
if options.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if favorites_only {
|
||||
let favorites = self
|
||||
.agent_server
|
||||
.favorite_config_option_value_ids(config_id, cx);
|
||||
options.retain(|option| favorites.contains(&option.value));
|
||||
if options.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let current_value = get_current_value(&self.config_options, config_id);
|
||||
let current_index = current_value
|
||||
.as_ref()
|
||||
.and_then(|current| options.iter().position(|option| &option.value == current))
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
let next_index = if current_index == usize::MAX {
|
||||
0
|
||||
} else {
|
||||
(current_index + 1) % options.len()
|
||||
};
|
||||
|
||||
Some(options[next_index].value.clone())
|
||||
}
|
||||
|
||||
fn config_option_ids(
|
||||
config_options: &Rc<dyn AgentSessionConfigOptions>,
|
||||
) -> Vec<acp::SessionConfigId> {
|
||||
config_options
|
||||
.config_options()
|
||||
.into_iter()
|
||||
.map(|option| option.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rebuild_selectors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Config option updates can mutate option values for existing IDs (for example,
|
||||
// reasoning levels after a model switch). Rebuild to refresh cached picker entries.
|
||||
self.config_option_ids = Self::config_option_ids(&self.config_options);
|
||||
self.selectors = Self::build_selectors(
|
||||
&self.config_options,
|
||||
&self.agent_server,
|
||||
&self.fs,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn build_selectors(
|
||||
config_options: &Rc<dyn AgentSessionConfigOptions>,
|
||||
agent_server: &Rc<dyn AgentServer>,
|
||||
fs: &Arc<dyn Fs>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<Entity<ConfigOptionSelector>> {
|
||||
config_options
|
||||
.config_options()
|
||||
.into_iter()
|
||||
.map(|option| {
|
||||
let config_options = config_options.clone();
|
||||
let agent_server = agent_server.clone();
|
||||
let fs = fs.clone();
|
||||
cx.new(|cx| {
|
||||
ConfigOptionSelector::new(
|
||||
config_options,
|
||||
option.id.clone(),
|
||||
agent_server,
|
||||
fs,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConfigOptionsView {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.selectors.is_empty() {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.children(self.selectors.iter().cloned())
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigOptionSelector {
|
||||
config_options: Rc<dyn AgentSessionConfigOptions>,
|
||||
config_id: acp::SessionConfigId,
|
||||
picker_handle: PopoverMenuHandle<Picker<ConfigOptionPickerDelegate>>,
|
||||
picker: Entity<Picker<ConfigOptionPickerDelegate>>,
|
||||
setting_value: bool,
|
||||
}
|
||||
|
||||
impl ConfigOptionSelector {
|
||||
pub fn new(
|
||||
config_options: Rc<dyn AgentSessionConfigOptions>,
|
||||
config_id: acp::SessionConfigId,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let option_count = config_options
|
||||
.config_options()
|
||||
.iter()
|
||||
.find(|opt| opt.id == config_id)
|
||||
.map(count_config_options)
|
||||
.unwrap_or(0);
|
||||
|
||||
let is_searchable = option_count >= PICKER_THRESHOLD;
|
||||
|
||||
let picker = {
|
||||
let config_options = config_options.clone();
|
||||
let config_id = config_id.clone();
|
||||
let agent_server = agent_server.clone();
|
||||
let fs = fs.clone();
|
||||
cx.new(move |picker_cx| {
|
||||
let delegate = ConfigOptionPickerDelegate::new(
|
||||
config_options,
|
||||
config_id,
|
||||
agent_server,
|
||||
fs,
|
||||
window,
|
||||
picker_cx,
|
||||
);
|
||||
|
||||
if is_searchable {
|
||||
Picker::list(delegate, window, picker_cx)
|
||||
} else {
|
||||
Picker::nonsearchable_list(delegate, window, picker_cx)
|
||||
}
|
||||
.show_scrollbar(true)
|
||||
.width(rems(20.))
|
||||
.max_height(Some(rems(20.).into()))
|
||||
})
|
||||
};
|
||||
|
||||
Self {
|
||||
config_options,
|
||||
config_id,
|
||||
picker_handle: PopoverMenuHandle::default(),
|
||||
picker,
|
||||
setting_value: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_option(&self) -> Option<acp::SessionConfigOption> {
|
||||
self.config_options
|
||||
.config_options()
|
||||
.into_iter()
|
||||
.find(|opt| opt.id == self.config_id)
|
||||
}
|
||||
|
||||
fn config_id(&self) -> &acp::SessionConfigId {
|
||||
&self.config_id
|
||||
}
|
||||
|
||||
fn toggle_picker(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.picker_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
fn current_value_name(&self) -> String {
|
||||
let Some(option) = self.current_option() else {
|
||||
return "Unknown".to_string();
|
||||
};
|
||||
|
||||
match &option.kind {
|
||||
acp::SessionConfigKind::Select(select) => {
|
||||
find_option_name(&select.options, &select.current_value)
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
}
|
||||
_ => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_trigger_button(&self, _window: &mut Window, _cx: &mut Context<Self>) -> Button {
|
||||
let Some(option) = self.current_option() else {
|
||||
return Button::new("config-option-trigger", "Unknown")
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.disabled(true);
|
||||
};
|
||||
|
||||
let icon = if self.picker_handle.is_deployed() {
|
||||
IconName::ChevronUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
};
|
||||
|
||||
Button::new(
|
||||
ElementId::Name(format!("config-option-{}", option.id.0).into()),
|
||||
self.current_value_name(),
|
||||
)
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
|
||||
.disabled(self.setting_value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConfigOptionSelector {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(option) = self.current_option() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let trigger_button = self.render_trigger_button(window, cx);
|
||||
|
||||
let option_name = option.name.clone();
|
||||
let option_description: Option<SharedString> = option.description.map(Into::into);
|
||||
|
||||
let tooltip = Tooltip::element(move |_window, _cx| {
|
||||
let mut content = v_flex().gap_1().child(Label::new(option_name.clone()));
|
||||
if let Some(desc) = option_description.as_ref() {
|
||||
content = content.child(
|
||||
Label::new(desc.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
);
|
||||
}
|
||||
content.into_any()
|
||||
});
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.picker.clone(),
|
||||
trigger_button,
|
||||
tooltip,
|
||||
gpui::Anchor::BottomRight,
|
||||
cx,
|
||||
)
|
||||
.with_handle(self.picker_handle.clone())
|
||||
.render(window, cx)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ConfigOptionPickerEntry {
|
||||
Separator(SharedString),
|
||||
Option(ConfigOptionValue),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ConfigOptionValue {
|
||||
value: acp::SessionConfigValueId,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
group: Option<String>,
|
||||
}
|
||||
|
||||
struct ConfigOptionPickerDelegate {
|
||||
config_options: Rc<dyn AgentSessionConfigOptions>,
|
||||
config_id: acp::SessionConfigId,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
filtered_entries: Vec<ConfigOptionPickerEntry>,
|
||||
all_options: Vec<ConfigOptionValue>,
|
||||
selected_index: usize,
|
||||
selected_description: Option<(usize, SharedString, bool)>,
|
||||
favorites: HashSet<acp::SessionConfigValueId>,
|
||||
_settings_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl ConfigOptionPickerDelegate {
|
||||
fn new(
|
||||
config_options: Rc<dyn AgentSessionConfigOptions>,
|
||||
config_id: acp::SessionConfigId,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Self {
|
||||
let favorites = agent_server.favorite_config_option_value_ids(&config_id, cx);
|
||||
|
||||
let all_options = extract_options(&config_options, &config_id);
|
||||
let filtered_entries = options_to_picker_entries(&all_options, &favorites);
|
||||
|
||||
let current_value = get_current_value(&config_options, &config_id);
|
||||
let selected_index = current_value
|
||||
.and_then(|current| {
|
||||
filtered_entries.iter().position(|entry| {
|
||||
matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let agent_server_for_subscription = agent_server.clone();
|
||||
let config_id_for_subscription = config_id.clone();
|
||||
let settings_subscription =
|
||||
cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
|
||||
let new_favorites = agent_server_for_subscription
|
||||
.favorite_config_option_value_ids(&config_id_for_subscription, cx);
|
||||
if new_favorites != picker.delegate.favorites {
|
||||
picker.delegate.favorites = new_favorites;
|
||||
picker.refresh(window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
|
||||
Self {
|
||||
config_options,
|
||||
config_id,
|
||||
agent_server,
|
||||
fs,
|
||||
filtered_entries,
|
||||
all_options,
|
||||
selected_index,
|
||||
selected_description: None,
|
||||
favorites,
|
||||
_settings_subscription: settings_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_value(&self) -> Option<acp::SessionConfigValueId> {
|
||||
get_current_value(&self.config_options, &self.config_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for ConfigOptionPickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
|
||||
match self.filtered_entries.get(ix) {
|
||||
Some(ConfigOptionPickerEntry::Option(_)) => true,
|
||||
Some(ConfigOptionPickerEntry::Separator(_)) | None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Select an option…".into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let all_options = self.all_options.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let filtered_options = match this
|
||||
.read_with(cx, |_, cx| {
|
||||
if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((all_options.clone(), query.clone(), cx.background_executor().clone()))
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
Some((options, q, executor)) => fuzzy_search_options(options, &q, executor).await,
|
||||
None => all_options,
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.delegate.filtered_entries =
|
||||
options_to_picker_entries(&filtered_options, &this.delegate.favorites);
|
||||
|
||||
let current_value = this.delegate.current_value();
|
||||
let new_index = current_value
|
||||
.and_then(|current| {
|
||||
this.delegate.filtered_entries.iter().position(|entry| {
|
||||
matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if let Some(ConfigOptionPickerEntry::Option(option)) =
|
||||
self.filtered_entries.get(self.selected_index)
|
||||
{
|
||||
if window.modifiers().secondary() {
|
||||
let default_value = self
|
||||
.agent_server
|
||||
.default_config_option(self.config_id.0.as_ref(), cx);
|
||||
let is_default = default_value.as_deref() == Some(&*option.value.0);
|
||||
|
||||
self.agent_server.set_default_config_option(
|
||||
self.config_id.0.as_ref(),
|
||||
if is_default {
|
||||
None
|
||||
} else {
|
||||
Some(option.value.0.as_ref())
|
||||
},
|
||||
self.fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
let task = self.config_options.set_config_option(
|
||||
self.config_id.clone(),
|
||||
option.value.clone(),
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.spawn(async move |_, _| {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to set config option: {:?}", err);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
cx.defer_in(window, |picker, window, cx| {
|
||||
picker.set_query("", window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered_entries.get(ix)? {
|
||||
ConfigOptionPickerEntry::Separator(title) => Some(
|
||||
div()
|
||||
.when(ix > 0, |this| this.mt_1())
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child(title.clone()),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
ConfigOptionPickerEntry::Option(option) => {
|
||||
let current_value = self.current_value();
|
||||
let is_selected = current_value.as_ref() == Some(&option.value);
|
||||
|
||||
let default_value = self
|
||||
.agent_server
|
||||
.default_config_option(self.config_id.0.as_ref(), cx);
|
||||
let is_default = default_value.as_deref() == Some(&*option.value.0);
|
||||
|
||||
let is_favorite = self.favorites.contains(&option.value);
|
||||
|
||||
let option_name = option.name.clone();
|
||||
let description = option.description.clone();
|
||||
|
||||
Some(
|
||||
div()
|
||||
.id(("config-option-picker-item", ix))
|
||||
.when_some(description, |this, desc| {
|
||||
let desc: SharedString = desc.into();
|
||||
this.on_hover(cx.listener(move |menu, hovered, _, cx| {
|
||||
if *hovered {
|
||||
menu.delegate.selected_description =
|
||||
Some((ix, desc.clone(), is_default));
|
||||
} else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix)
|
||||
{
|
||||
menu.delegate.selected_description = None;
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(h_flex().w_full().child(Label::new(option_name).truncate()))
|
||||
.end_slot(div().pr_2().when(is_selected, |this| {
|
||||
this.child(Icon::new(IconName::Check).color(Color::Accent))
|
||||
}))
|
||||
.end_slot_on_hover(div().pr_1p5().child({
|
||||
let (icon, color, tooltip) = if is_favorite {
|
||||
(IconName::StarFilled, Color::Accent, "Unfavorite")
|
||||
} else {
|
||||
(IconName::Star, Color::Default, "Favorite")
|
||||
};
|
||||
|
||||
let config_id = self.config_id.clone();
|
||||
let value_id = option.value.clone();
|
||||
let agent_server = self.agent_server.clone();
|
||||
let fs = self.fs.clone();
|
||||
|
||||
IconButton::new(("toggle-favorite-config-option", ix), icon)
|
||||
.layer(ElevationIndex::ElevatedSurface)
|
||||
.icon_color(color)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text(tooltip))
|
||||
.on_click(move |_, _, cx| {
|
||||
agent_server.toggle_favorite_config_option_value(
|
||||
config_id.clone(),
|
||||
value_id.clone(),
|
||||
!is_favorite,
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
})),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn documentation_aside(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<ui::DocumentationAside> {
|
||||
self.selected_description
|
||||
.as_ref()
|
||||
.map(|(_, description, is_default)| {
|
||||
let description = description.clone();
|
||||
let is_default = *is_default;
|
||||
|
||||
let side = documentation_aside_side(cx);
|
||||
|
||||
ui::DocumentationAside::new(
|
||||
side,
|
||||
Rc::new(move |_| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new(description.clone()))
|
||||
.child(HoldForDefault::new(is_default))
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn documentation_aside_index(&self) -> Option<usize> {
|
||||
self.selected_description.as_ref().map(|(ix, _, _)| *ix)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_options(
|
||||
config_options: &Rc<dyn AgentSessionConfigOptions>,
|
||||
config_id: &acp::SessionConfigId,
|
||||
) -> Vec<ConfigOptionValue> {
|
||||
let Some(option) = config_options
|
||||
.config_options()
|
||||
.into_iter()
|
||||
.find(|opt| &opt.id == config_id)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match &option.kind {
|
||||
acp::SessionConfigKind::Select(select) => match &select.options {
|
||||
acp::SessionConfigSelectOptions::Ungrouped(options) => options
|
||||
.iter()
|
||||
.map(|opt| ConfigOptionValue {
|
||||
value: opt.value.clone(),
|
||||
name: opt.name.clone(),
|
||||
description: opt.description.clone(),
|
||||
group: None,
|
||||
})
|
||||
.collect(),
|
||||
acp::SessionConfigSelectOptions::Grouped(groups) => groups
|
||||
.iter()
|
||||
.flat_map(|group| {
|
||||
group.options.iter().map(|opt| ConfigOptionValue {
|
||||
value: opt.value.clone(),
|
||||
name: opt.name.clone(),
|
||||
description: opt.description.clone(),
|
||||
group: Some(group.name.clone()),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_value(
|
||||
config_options: &Rc<dyn AgentSessionConfigOptions>,
|
||||
config_id: &acp::SessionConfigId,
|
||||
) -> Option<acp::SessionConfigValueId> {
|
||||
config_options
|
||||
.config_options()
|
||||
.into_iter()
|
||||
.find(|opt| &opt.id == config_id)
|
||||
.and_then(|opt| match &opt.kind {
|
||||
acp::SessionConfigKind::Select(select) => Some(select.current_value.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn options_to_picker_entries(
|
||||
options: &[ConfigOptionValue],
|
||||
favorites: &HashSet<acp::SessionConfigValueId>,
|
||||
) -> Vec<ConfigOptionPickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
let mut favorite_options = Vec::new();
|
||||
|
||||
for option in options {
|
||||
if favorites.contains(&option.value) {
|
||||
favorite_options.push(option.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !favorite_options.is_empty() {
|
||||
entries.push(ConfigOptionPickerEntry::Separator("Favorites".into()));
|
||||
for option in favorite_options {
|
||||
entries.push(ConfigOptionPickerEntry::Option(option));
|
||||
}
|
||||
|
||||
// If the remaining list would start ungrouped (group == None), insert a separator so
|
||||
// Favorites doesn't visually run into the main list.
|
||||
if let Some(option) = options.first()
|
||||
&& option.group.is_none()
|
||||
{
|
||||
entries.push(ConfigOptionPickerEntry::Separator("All Options".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut current_group: Option<String> = None;
|
||||
for option in options {
|
||||
if option.group != current_group {
|
||||
if let Some(group_name) = &option.group {
|
||||
entries.push(ConfigOptionPickerEntry::Separator(
|
||||
group_name.clone().into(),
|
||||
));
|
||||
}
|
||||
current_group = option.group.clone();
|
||||
}
|
||||
entries.push(ConfigOptionPickerEntry::Option(option.clone()));
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
async fn fuzzy_search_options(
|
||||
options: Vec<ConfigOptionValue>,
|
||||
query: &str,
|
||||
executor: BackgroundExecutor,
|
||||
) -> Vec<ConfigOptionValue> {
|
||||
let candidates = options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, opt)| StringMatchCandidate::new(ix, &opt.name))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut matches = fuzzy::match_strings(
|
||||
&candidates,
|
||||
query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
&Default::default(),
|
||||
executor,
|
||||
)
|
||||
.await;
|
||||
|
||||
matches.sort_unstable_by_key(|mat| {
|
||||
let candidate = &candidates[mat.candidate_id];
|
||||
(Reverse(OrderedFloat(mat.score)), candidate.id)
|
||||
});
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|mat| options[mat.candidate_id].clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_option_name(
|
||||
options: &acp::SessionConfigSelectOptions,
|
||||
value_id: &acp::SessionConfigValueId,
|
||||
) -> Option<String> {
|
||||
match options {
|
||||
acp::SessionConfigSelectOptions::Ungrouped(opts) => opts
|
||||
.iter()
|
||||
.find(|o| &o.value == value_id)
|
||||
.map(|o| o.name.clone()),
|
||||
acp::SessionConfigSelectOptions::Grouped(groups) => groups.iter().find_map(|group| {
|
||||
group
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| &o.value == value_id)
|
||||
.map(|o| o.name.clone())
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn count_config_options(option: &acp::SessionConfigOption) -> usize {
|
||||
match &option.kind {
|
||||
acp::SessionConfigKind::Select(select) => match &select.options {
|
||||
acp::SessionConfigSelectOptions::Ungrouped(options) => options.len(),
|
||||
acp::SessionConfigSelectOptions::Grouped(groups) => {
|
||||
groups.iter().map(|g| g.options.len()).sum()
|
||||
}
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::mention_set::Mention;
|
||||
use gpui::{AppContext as _, Entity, Task};
|
||||
use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent};
|
||||
use ui::App;
|
||||
use util::ResultExt as _;
|
||||
|
||||
use crate::mention_set::MentionSet;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LoadedContext {
|
||||
pub text: String,
|
||||
pub images: Vec<LanguageModelImage>,
|
||||
}
|
||||
|
||||
impl LoadedContext {
|
||||
pub fn add_to_request_message(&self, request_message: &mut LanguageModelRequestMessage) {
|
||||
if !self.text.is_empty() {
|
||||
request_message
|
||||
.content
|
||||
.push(MessageContent::Text(self.text.to_string()));
|
||||
}
|
||||
|
||||
if !self.images.is_empty() {
|
||||
// Some providers only support image parts after an initial text part
|
||||
if request_message.content.is_empty() {
|
||||
request_message
|
||||
.content
|
||||
.push(MessageContent::Text("Images attached by user:".to_string()));
|
||||
}
|
||||
|
||||
for image in &self.images {
|
||||
request_message
|
||||
.content
|
||||
.push(MessageContent::Image(image.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads and formats a collection of contexts.
|
||||
pub fn load_context(mention_set: &Entity<MentionSet>, cx: &mut App) -> Task<Option<LoadedContext>> {
|
||||
let task = mention_set.update(cx, |mention_set, cx| mention_set.contents(true, cx));
|
||||
cx.background_spawn(async move {
|
||||
let mentions = task.await.log_err()?;
|
||||
let mut loaded_context = LoadedContext::default();
|
||||
loaded_context
|
||||
.text
|
||||
.push_str("The following items were attached by the user.\n");
|
||||
for (_, (_, mention)) in mentions {
|
||||
match mention {
|
||||
Mention::Text { content, .. } => {
|
||||
loaded_context.text.push_str(&content);
|
||||
}
|
||||
Mention::Image(mention_image) => loaded_context.images.push(LanguageModelImage {
|
||||
source: mention_image.data,
|
||||
..LanguageModelImage::empty()
|
||||
}),
|
||||
Mention::Link => {}
|
||||
}
|
||||
}
|
||||
Some(loaded_context)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use context_server::ContextServerId;
|
||||
use extension::ExtensionManifest;
|
||||
use fs::Fs;
|
||||
use gpui::WeakEntity;
|
||||
use language::LanguageRegistry;
|
||||
use settings::update_settings_file;
|
||||
use ui::prelude::*;
|
||||
use util::ResultExt;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::agent_configuration::ConfigureContextServerModal;
|
||||
|
||||
pub(crate) fn init(language_registry: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, cx: &mut App) {
|
||||
cx.observe_new(move |_: &mut Workspace, window, cx| {
|
||||
let Some(window) = window else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(extension_events) = extension::ExtensionEvents::try_global(cx).as_ref() {
|
||||
cx.subscribe_in(extension_events, window, {
|
||||
let language_registry = language_registry.clone();
|
||||
let fs = fs.clone();
|
||||
move |_, _, event, window, cx| match event {
|
||||
extension::Event::ExtensionInstalled(manifest) => {
|
||||
show_configure_mcp_modal(
|
||||
language_registry.clone(),
|
||||
manifest,
|
||||
cx.weak_entity(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
extension::Event::ExtensionUninstalled(manifest) => {
|
||||
remove_context_server_settings(
|
||||
manifest.context_servers.keys().cloned().collect(),
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
extension::Event::ConfigureExtensionRequested(manifest) => {
|
||||
if !manifest.context_servers.is_empty() {
|
||||
show_configure_mcp_modal(
|
||||
language_registry.clone(),
|
||||
manifest,
|
||||
cx.weak_entity(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
log::info!(
|
||||
"No extension events global found. Skipping context server configuration wizard"
|
||||
);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn remove_context_server_settings(
|
||||
context_server_ids: Vec<Arc<str>>,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
update_settings_file(fs, cx, move |settings, _| {
|
||||
settings
|
||||
.project
|
||||
.context_servers
|
||||
.retain(|server_id, _| !context_server_ids.contains(server_id));
|
||||
});
|
||||
}
|
||||
|
||||
fn show_configure_mcp_modal(
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
manifest: &Arc<ExtensionManifest>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Workspace>,
|
||||
) {
|
||||
if !window.is_window_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
let ids = manifest.context_servers.keys().cloned().collect::<Vec<_>>();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
for id in ids {
|
||||
let Some(task) = cx
|
||||
.update(|window, cx| {
|
||||
ConfigureContextServerModal::show_modal_for_existing_server(
|
||||
ContextServerId(id.clone()),
|
||||
language_registry.clone(),
|
||||
workspace.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
task.await.log_err();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
use anyhow::Result;
|
||||
use gpui::{App, AppContext as _, Entity, Task};
|
||||
use language::{Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, ToOffset};
|
||||
use project::{DiagnosticSummary, Project};
|
||||
use rope::Point;
|
||||
use std::{fmt::Write, ops::RangeInclusive, path::Path};
|
||||
use text::OffsetRangeExt;
|
||||
use util::ResultExt;
|
||||
use util::paths::PathMatcher;
|
||||
|
||||
pub fn codeblock_fence_for_path(
|
||||
path: Option<&str>,
|
||||
row_range: Option<RangeInclusive<u32>>,
|
||||
) -> String {
|
||||
let mut text = String::new();
|
||||
write!(text, "```").unwrap();
|
||||
|
||||
if let Some(path) = path {
|
||||
if let Some(extension) = Path::new(path).extension().and_then(|ext| ext.to_str()) {
|
||||
write!(text, "{} ", extension).unwrap();
|
||||
}
|
||||
|
||||
write!(text, "{path}").unwrap();
|
||||
} else {
|
||||
write!(text, "untitled").unwrap();
|
||||
}
|
||||
|
||||
if let Some(row_range) = row_range {
|
||||
write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
|
||||
}
|
||||
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
pub struct DiagnosticsOptions {
|
||||
pub include_errors: bool,
|
||||
pub include_warnings: bool,
|
||||
pub path_matcher: Option<PathMatcher>,
|
||||
}
|
||||
|
||||
/// Collects project diagnostics into a formatted string.
|
||||
///
|
||||
/// Returns `None` if no matching diagnostics were found.
|
||||
pub fn collect_diagnostics(
|
||||
project: Entity<Project>,
|
||||
options: DiagnosticsOptions,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Option<String>>> {
|
||||
let path_style = project.read(cx).path_style(cx);
|
||||
let glob_is_exact_file_match = if let Some(path) = options
|
||||
.path_matcher
|
||||
.as_ref()
|
||||
.and_then(|pm| pm.sources().next())
|
||||
{
|
||||
project
|
||||
.read(cx)
|
||||
.find_project_path(Path::new(path), cx)
|
||||
.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let project_handle = project.downgrade();
|
||||
let diagnostic_summaries: Vec<_> = project
|
||||
.read(cx)
|
||||
.diagnostic_summaries(false, cx)
|
||||
.flat_map(|(path, _, summary)| {
|
||||
let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
|
||||
let full_path = worktree.read(cx).root_name().join(&path.path);
|
||||
Some((path, full_path, summary))
|
||||
})
|
||||
.collect();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let error_source = if let Some(path_matcher) = &options.path_matcher {
|
||||
debug_assert_eq!(path_matcher.sources().count(), 1);
|
||||
Some(path_matcher.sources().next().unwrap_or_default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut text = String::new();
|
||||
if let Some(error_source) = error_source.as_ref() {
|
||||
writeln!(text, "diagnostics: {}", error_source).unwrap();
|
||||
} else {
|
||||
writeln!(text, "diagnostics").unwrap();
|
||||
}
|
||||
|
||||
let mut found_any_diagnostics = false;
|
||||
let mut project_summary = DiagnosticSummary::default();
|
||||
for (project_path, path, summary) in diagnostic_summaries {
|
||||
if let Some(path_matcher) = &options.path_matcher
|
||||
&& !path_matcher.is_match(&path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_errors = options.include_errors && summary.error_count > 0;
|
||||
let has_warnings = options.include_warnings && summary.warning_count > 0;
|
||||
if !has_errors && !has_warnings {
|
||||
continue;
|
||||
}
|
||||
|
||||
if options.include_errors {
|
||||
project_summary.error_count += summary.error_count;
|
||||
}
|
||||
if options.include_warnings {
|
||||
project_summary.warning_count += summary.warning_count;
|
||||
}
|
||||
|
||||
let file_path = path.display(path_style).to_string();
|
||||
if !glob_is_exact_file_match {
|
||||
writeln!(&mut text, "{file_path}").unwrap();
|
||||
}
|
||||
|
||||
if let Some(buffer) = project_handle
|
||||
.update(cx, |project, cx| project.open_buffer(project_path, cx))?
|
||||
.await
|
||||
.log_err()
|
||||
{
|
||||
let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot());
|
||||
if collect_buffer_diagnostics(
|
||||
&mut text,
|
||||
&snapshot,
|
||||
options.include_warnings,
|
||||
options.include_errors,
|
||||
) {
|
||||
found_any_diagnostics = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_any_diagnostics {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut label = String::new();
|
||||
label.push_str("Diagnostics");
|
||||
if let Some(source) = error_source {
|
||||
write!(label, " ({})", source).unwrap();
|
||||
}
|
||||
|
||||
if project_summary.error_count > 0 || project_summary.warning_count > 0 {
|
||||
label.push(':');
|
||||
|
||||
if project_summary.error_count > 0 {
|
||||
write!(label, " {} errors", project_summary.error_count).unwrap();
|
||||
if project_summary.warning_count > 0 {
|
||||
label.push(',');
|
||||
}
|
||||
}
|
||||
|
||||
if project_summary.warning_count > 0 {
|
||||
write!(label, " {} warnings", project_summary.warning_count).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend the summary label to the output.
|
||||
text.insert_str(0, &format!("{label}\n"));
|
||||
|
||||
Ok(Some(text))
|
||||
})
|
||||
}
|
||||
|
||||
/// Collects diagnostics from a buffer snapshot into the text output.
|
||||
///
|
||||
/// Returns `true` if any diagnostics were written.
|
||||
fn collect_buffer_diagnostics(
|
||||
text: &mut String,
|
||||
snapshot: &BufferSnapshot,
|
||||
include_warnings: bool,
|
||||
include_errors: bool,
|
||||
) -> bool {
|
||||
let mut found_any = false;
|
||||
for (_, group) in snapshot.diagnostic_groups(None) {
|
||||
let entry = &group.entries[group.primary_ix];
|
||||
if collect_diagnostic(text, entry, snapshot, include_warnings, include_errors) {
|
||||
found_any = true;
|
||||
}
|
||||
}
|
||||
found_any
|
||||
}
|
||||
|
||||
/// Formats a single diagnostic entry as a code excerpt with the diagnostic message.
|
||||
///
|
||||
/// Returns `true` if the diagnostic was written (i.e. it matched severity filters).
|
||||
fn collect_diagnostic(
|
||||
text: &mut String,
|
||||
entry: &DiagnosticEntryRef<'_, Anchor>,
|
||||
snapshot: &BufferSnapshot,
|
||||
include_warnings: bool,
|
||||
include_errors: bool,
|
||||
) -> bool {
|
||||
const EXCERPT_EXPANSION_SIZE: u32 = 2;
|
||||
const MAX_MESSAGE_LENGTH: usize = 2000;
|
||||
|
||||
let ty = match entry.diagnostic.severity {
|
||||
DiagnosticSeverity::WARNING => {
|
||||
if !include_warnings {
|
||||
return false;
|
||||
}
|
||||
"warning"
|
||||
}
|
||||
DiagnosticSeverity::ERROR => {
|
||||
if !include_errors {
|
||||
return false;
|
||||
}
|
||||
"error"
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let range = entry.range.to_point(snapshot);
|
||||
let diagnostic_row_number = range.start.row + 1;
|
||||
|
||||
let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
|
||||
let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
|
||||
let excerpt_range =
|
||||
Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot);
|
||||
|
||||
text.push_str("```");
|
||||
if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
|
||||
text.push_str(&language_name);
|
||||
}
|
||||
text.push('\n');
|
||||
|
||||
let mut buffer_text = String::new();
|
||||
for chunk in snapshot.text_for_range(excerpt_range) {
|
||||
buffer_text.push_str(chunk);
|
||||
}
|
||||
|
||||
for (i, line) in buffer_text.lines().enumerate() {
|
||||
let line_number = start_row + i as u32 + 1;
|
||||
writeln!(text, "{}", line).unwrap();
|
||||
|
||||
if line_number == diagnostic_row_number {
|
||||
text.push_str("//");
|
||||
let marker_start = text.len();
|
||||
write!(text, " {}: ", ty).unwrap();
|
||||
let padding = text.len() - marker_start;
|
||||
|
||||
let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
|
||||
.replace('\n', format!("\n//{:padding$}", "").as_str());
|
||||
|
||||
writeln!(text, "{message}").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(text, "```").unwrap();
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Per-thread draft prompt persistence and display label rendering.
|
||||
//!
|
||||
//! Drafts are persisted in the thread metadata store with `session_id: None`,
|
||||
//! but their unsent prompt text is kept separately here so we don't have to
|
||||
//! plumb draft-prompt storage through the native agent's thread database.
|
||||
//!
|
||||
//! The display-label helpers ([`display_label_for_draft`] and friends) live
|
||||
//! alongside the storage so the sidebar's preview rendering can't drift from
|
||||
//! the format we persist.
|
||||
|
||||
use agent_client_protocol::schema as acp;
|
||||
use anyhow::Context as _;
|
||||
use db::kvp::KeyValueStore;
|
||||
use gpui::{App, AppContext as _, Entity, Task};
|
||||
use ui::SharedString;
|
||||
use util::ResultExt as _;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::AgentPanel;
|
||||
use crate::thread_metadata_store::ThreadId;
|
||||
|
||||
const NAMESPACE: &str = "agent_draft_prompts";
|
||||
|
||||
/// Maximum length (in characters) of a draft label rendered in the sidebar.
|
||||
const MAX_LABEL_CHARS: usize = 250;
|
||||
|
||||
pub fn read(thread_id: ThreadId, cx: &App) -> Option<Vec<acp::ContentBlock>> {
|
||||
let kvp = KeyValueStore::global(cx);
|
||||
let raw = kvp
|
||||
.scoped(NAMESPACE)
|
||||
.read(&thread_id_key(thread_id))
|
||||
.log_err()
|
||||
.flatten()?;
|
||||
serde_json::from_str(&raw).log_err()
|
||||
}
|
||||
|
||||
pub fn write(
|
||||
thread_id: ThreadId,
|
||||
prompt: &[acp::ContentBlock],
|
||||
cx: &App,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
let kvp = KeyValueStore::global(cx);
|
||||
let key = thread_id_key(thread_id);
|
||||
let payload = match serde_json::to_string(prompt).context("serializing draft prompt") {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => return Task::ready(Err(err)),
|
||||
};
|
||||
cx.background_spawn(async move { kvp.scoped(NAMESPACE).write(key, payload).await })
|
||||
}
|
||||
|
||||
pub fn delete(thread_id: ThreadId, cx: &App) -> Task<anyhow::Result<()>> {
|
||||
let kvp = KeyValueStore::global(cx);
|
||||
let key = thread_id_key(thread_id);
|
||||
cx.background_spawn(async move { kvp.scoped(NAMESPACE).delete(key).await })
|
||||
}
|
||||
|
||||
fn thread_id_key(thread_id: ThreadId) -> String {
|
||||
thread_id.to_key_string()
|
||||
}
|
||||
|
||||
/// Rewrites `[@Something](scheme://...)` mention links as `@Something` so the
|
||||
/// sidebar's draft-title preview doesn't show raw markdown link syntax.
|
||||
pub fn clean_mention_links(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut remaining = input;
|
||||
|
||||
while let Some(start) = remaining.find("[@") {
|
||||
result.push_str(&remaining[..start]);
|
||||
let after_bracket = &remaining[start + 1..];
|
||||
if let Some(close_bracket) = after_bracket.find("](") {
|
||||
let mention = &after_bracket[..close_bracket];
|
||||
let after_link_start = &after_bracket[close_bracket + 2..];
|
||||
if let Some(close_paren) = after_link_start.find(')') {
|
||||
result.push_str(mention);
|
||||
remaining = &after_link_start[close_paren + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push_str("[@");
|
||||
remaining = &remaining[start + 2..];
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
/// Collapses whitespace and truncates raw editor text for display as a draft
|
||||
/// label in the sidebar.
|
||||
pub fn truncate_draft_label(raw: &str) -> Option<SharedString> {
|
||||
let first_line = raw.lines().next().unwrap_or("");
|
||||
let cleaned = clean_mention_links(first_line);
|
||||
let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some((truncate_at, _)) = text.char_indices().nth(MAX_LABEL_CHARS) {
|
||||
text.truncate(truncate_at);
|
||||
}
|
||||
Some(text.into())
|
||||
}
|
||||
|
||||
/// Renders a draft thread's display label for sidebar rows and similar
|
||||
/// preview UI.
|
||||
///
|
||||
/// Prefers the live message editor's text (when the thread's
|
||||
/// `ConversationView` is loaded in the workspace's `AgentPanel`), and
|
||||
/// otherwise falls back to the persisted draft prompt in the kvp store so
|
||||
/// drafts restored from disk — but not yet opened — still show a meaningful
|
||||
/// title instead of the generic default.
|
||||
pub fn display_label_for_draft(
|
||||
workspace: Option<&Entity<Workspace>>,
|
||||
thread_id: ThreadId,
|
||||
cx: &App,
|
||||
) -> Option<SharedString> {
|
||||
let in_memory = workspace
|
||||
.and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
|
||||
.and_then(|panel| panel.read(cx).editor_text_if_in_memory(thread_id, cx));
|
||||
match in_memory {
|
||||
Some(Some(raw)) => return truncate_draft_label(&raw),
|
||||
Some(None) => return None,
|
||||
None => {}
|
||||
}
|
||||
|
||||
let blocks = read(thread_id, cx)?;
|
||||
let raw = blocks
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
acp::ContentBlock::Text(text) => Some(text.text.as_str()),
|
||||
acp::ContentBlock::ResourceLink(link) => Some(link.uri.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
truncate_draft_label(&raw)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_mention_links() {
|
||||
// Simple mention link.
|
||||
assert_eq!(
|
||||
clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
|
||||
"check @Button.tsx"
|
||||
);
|
||||
|
||||
// Multiple mention links on one line.
|
||||
assert_eq!(
|
||||
clean_mention_links("look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"),
|
||||
"look at @foo.rs and @bar.rs"
|
||||
);
|
||||
|
||||
// Plain text without mentions is preserved.
|
||||
assert_eq!(
|
||||
clean_mention_links("plain text with no mentions"),
|
||||
"plain text with no mentions"
|
||||
);
|
||||
|
||||
// Broken syntax (no closing bracket) is left alone.
|
||||
assert_eq!(
|
||||
clean_mention_links("broken [@mention without closing"),
|
||||
"broken [@mention without closing"
|
||||
);
|
||||
|
||||
// Non-`@` markdown links are not touched.
|
||||
assert_eq!(
|
||||
clean_mention_links("see [docs](https://example.com)"),
|
||||
"see [docs](https://example.com)"
|
||||
);
|
||||
|
||||
// Empty input.
|
||||
assert_eq!(clean_mention_links(""), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use acp_thread::{AcpThread, AgentThreadEntry};
|
||||
use agent::ThreadStore;
|
||||
use agent_client_protocol::schema as acp;
|
||||
use collections::HashMap;
|
||||
use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior};
|
||||
use gpui::{
|
||||
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
|
||||
ScrollHandle, TextStyleRefinement, WeakEntity, Window,
|
||||
};
|
||||
use language::language_settings::SoftWrap;
|
||||
use project::{AgentId, Project};
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
use settings::Settings as _;
|
||||
use terminal_view::TerminalView;
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::{Context, TextSize};
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::message_editor::{MessageEditor, MessageEditorEvent, SharedSessionCapabilities};
|
||||
|
||||
pub struct EntryViewState {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
entries: Vec<Entry>,
|
||||
session_capabilities: SharedSessionCapabilities,
|
||||
agent_id: AgentId,
|
||||
}
|
||||
|
||||
impl EntryViewState {
|
||||
pub fn new(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
session_capabilities: SharedSessionCapabilities,
|
||||
agent_id: AgentId,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace,
|
||||
project,
|
||||
thread_store,
|
||||
prompt_store,
|
||||
entries: Vec::new(),
|
||||
session_capabilities,
|
||||
agent_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entry(&self, index: usize) -> Option<&Entry> {
|
||||
self.entries.get(index)
|
||||
}
|
||||
|
||||
pub fn sync_entry(
|
||||
&mut self,
|
||||
index: usize,
|
||||
thread: &Entity<AcpThread>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(thread_entry) = thread.read(cx).entries().get(index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match thread_entry {
|
||||
AgentThreadEntry::UserMessage(message) => {
|
||||
let can_rewind = thread.read(cx).supports_truncate(cx);
|
||||
let has_id = message.id.is_some();
|
||||
let is_subagent = thread.read(cx).parent_session_id().is_some();
|
||||
let chunks = message.chunks.clone();
|
||||
if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) {
|
||||
if !editor.focus_handle(cx).is_focused(window) {
|
||||
// Only update if we are not editing.
|
||||
// If we are, cancelling the edit will set the message to the newest content.
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_message(chunks, window, cx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let message_editor = cx.new(|cx| {
|
||||
let mut editor = MessageEditor::new(
|
||||
self.workspace.clone(),
|
||||
self.project.clone(),
|
||||
self.thread_store.clone(),
|
||||
self.prompt_store.clone(),
|
||||
self.session_capabilities.clone(),
|
||||
self.agent_id.clone(),
|
||||
"Edit message - @ to include context",
|
||||
editor::EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
max_lines: None,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
if !can_rewind || !has_id || is_subagent {
|
||||
editor.set_read_only(true, cx);
|
||||
}
|
||||
editor.set_message(chunks, window, cx);
|
||||
editor
|
||||
});
|
||||
cx.subscribe(&message_editor, move |_, editor, event, cx| {
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index: index,
|
||||
view_event: ViewEvent::MessageEditorEvent(editor, event.clone()),
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
self.set_entry(index, Entry::UserMessage(message_editor));
|
||||
}
|
||||
}
|
||||
AgentThreadEntry::ToolCall(tool_call) => {
|
||||
let id = tool_call.id.clone();
|
||||
let terminals = tool_call.terminals().cloned().collect::<Vec<_>>();
|
||||
let diffs = tool_call.diffs().cloned().collect::<Vec<_>>();
|
||||
|
||||
let views = if let Some(Entry::ToolCall(tool_call)) = self.entries.get_mut(index) {
|
||||
&mut tool_call.content
|
||||
} else {
|
||||
self.set_entry(
|
||||
index,
|
||||
Entry::ToolCall(ToolCallEntry {
|
||||
content: HashMap::default(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}),
|
||||
);
|
||||
let Some(Entry::ToolCall(tool_call)) = self.entries.get_mut(index) else {
|
||||
unreachable!()
|
||||
};
|
||||
&mut tool_call.content
|
||||
};
|
||||
|
||||
let is_tool_call_completed =
|
||||
matches!(tool_call.status, acp_thread::ToolCallStatus::Completed);
|
||||
|
||||
for terminal in terminals {
|
||||
match views.entry(terminal.entity_id()) {
|
||||
collections::hash_map::Entry::Vacant(entry) => {
|
||||
let element = create_terminal(
|
||||
self.workspace.clone(),
|
||||
self.project.clone(),
|
||||
terminal.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any();
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index: index,
|
||||
view_event: ViewEvent::NewTerminal(id.clone()),
|
||||
});
|
||||
entry.insert(element);
|
||||
}
|
||||
collections::hash_map::Entry::Occupied(_entry) => {
|
||||
if is_tool_call_completed && terminal.read(cx).output().is_none() {
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index: index,
|
||||
view_event: ViewEvent::TerminalMovedToBackground(id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for diff in diffs {
|
||||
views.entry(diff.entity_id()).or_insert_with(|| {
|
||||
let editor = create_editor_diff(diff.clone(), window, cx);
|
||||
cx.subscribe(&editor, {
|
||||
let diff = diff.clone();
|
||||
let entry_index = index;
|
||||
move |_this, _editor, event: &EditorEvent, cx| {
|
||||
if let EditorEvent::OpenExcerptsRequested {
|
||||
selections_by_buffer,
|
||||
split,
|
||||
} = event
|
||||
{
|
||||
let multibuffer = diff.read(cx).multibuffer();
|
||||
if let Some((buffer_id, (ranges, _))) =
|
||||
selections_by_buffer.iter().next()
|
||||
{
|
||||
if let Some(buffer) =
|
||||
multibuffer.read(cx).buffer(*buffer_id)
|
||||
{
|
||||
if let Some(range) = ranges.first() {
|
||||
let point =
|
||||
buffer.read(cx).offset_to_point(range.start.0);
|
||||
if let Some(path) = diff.read(cx).file_path(cx) {
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index,
|
||||
view_event: ViewEvent::OpenDiffLocation {
|
||||
path,
|
||||
position: point,
|
||||
split: *split,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
cx.emit(EntryViewEvent {
|
||||
entry_index: index,
|
||||
view_event: ViewEvent::NewDiff(id.clone()),
|
||||
});
|
||||
editor.into_any()
|
||||
});
|
||||
}
|
||||
}
|
||||
AgentThreadEntry::AssistantMessage(message) => {
|
||||
let entry = if let Some(Entry::AssistantMessage(entry)) =
|
||||
self.entries.get_mut(index)
|
||||
{
|
||||
entry
|
||||
} else {
|
||||
self.set_entry(
|
||||
index,
|
||||
Entry::AssistantMessage(AssistantMessageEntry {
|
||||
scroll_handles_by_chunk_index: HashMap::default(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}),
|
||||
);
|
||||
let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else {
|
||||
unreachable!()
|
||||
};
|
||||
entry
|
||||
};
|
||||
entry.sync(message);
|
||||
}
|
||||
AgentThreadEntry::CompletedPlan(_) => {
|
||||
if !matches!(self.entries.get(index), Some(Entry::CompletedPlan)) {
|
||||
self.set_entry(index, Entry::CompletedPlan);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn set_entry(&mut self, index: usize, entry: Entry) {
|
||||
if index == self.entries.len() {
|
||||
self.entries.push(entry);
|
||||
} else {
|
||||
self.entries[index] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, range: Range<usize>) {
|
||||
self.entries.drain(range);
|
||||
}
|
||||
|
||||
pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) {
|
||||
for entry in self.entries.iter() {
|
||||
match entry {
|
||||
Entry::UserMessage { .. }
|
||||
| Entry::AssistantMessage { .. }
|
||||
| Entry::CompletedPlan => {}
|
||||
Entry::ToolCall(ToolCallEntry { content, .. }) => {
|
||||
for view in content.values() {
|
||||
if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
|
||||
diff_editor.update(cx, |diff_editor, cx| {
|
||||
diff_editor.set_text_style_refinement(
|
||||
diff_editor_text_style_refinement(cx),
|
||||
);
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<EntryViewEvent> for EntryViewState {}
|
||||
|
||||
pub struct EntryViewEvent {
|
||||
pub entry_index: usize,
|
||||
pub view_event: ViewEvent,
|
||||
}
|
||||
|
||||
pub enum ViewEvent {
|
||||
NewDiff(acp::ToolCallId),
|
||||
NewTerminal(acp::ToolCallId),
|
||||
TerminalMovedToBackground(acp::ToolCallId),
|
||||
MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
|
||||
OpenDiffLocation {
|
||||
path: String,
|
||||
position: Point,
|
||||
split: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AssistantMessageEntry {
|
||||
scroll_handles_by_chunk_index: HashMap<usize, ScrollHandle>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl AssistantMessageEntry {
|
||||
pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option<ScrollHandle> {
|
||||
self.scroll_handles_by_chunk_index.get(&ix).cloned()
|
||||
}
|
||||
|
||||
pub fn sync(&mut self, message: &acp_thread::AssistantMessage) {
|
||||
if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() {
|
||||
let ix = message.chunks.len() - 1;
|
||||
let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default();
|
||||
handle.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolCallEntry {
|
||||
content: HashMap<EntityId, AnyEntity>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Entry {
|
||||
UserMessage(Entity<MessageEditor>),
|
||||
AssistantMessage(AssistantMessageEntry),
|
||||
ToolCall(ToolCallEntry),
|
||||
CompletedPlan,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn focus_handle(&self, cx: &App) -> Option<FocusHandle> {
|
||||
match self {
|
||||
Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)),
|
||||
Self::AssistantMessage(message) => Some(message.focus_handle.clone()),
|
||||
Self::ToolCall(tool_call) => Some(tool_call.focus_handle.clone()),
|
||||
Self::CompletedPlan => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_editor(&self) -> Option<&Entity<MessageEditor>> {
|
||||
match self {
|
||||
Self::UserMessage(editor) => Some(editor),
|
||||
Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor_for_diff(&self, diff: &Entity<acp_thread::Diff>) -> Option<Entity<Editor>> {
|
||||
self.content_map()?
|
||||
.get(&diff.entity_id())
|
||||
.cloned()
|
||||
.map(|entity| entity.downcast::<Editor>().unwrap())
|
||||
}
|
||||
|
||||
pub fn terminal(
|
||||
&self,
|
||||
terminal: &Entity<acp_thread::Terminal>,
|
||||
) -> Option<Entity<TerminalView>> {
|
||||
self.content_map()?
|
||||
.get(&terminal.entity_id())
|
||||
.cloned()
|
||||
.map(|entity| entity.downcast::<TerminalView>().unwrap())
|
||||
}
|
||||
|
||||
pub fn scroll_handle_for_assistant_message_chunk(
|
||||
&self,
|
||||
chunk_ix: usize,
|
||||
) -> Option<ScrollHandle> {
|
||||
match self {
|
||||
Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix),
|
||||
Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn content_map(&self) -> Option<&HashMap<EntityId, AnyEntity>> {
|
||||
match self {
|
||||
Self::ToolCall(ToolCallEntry { content, .. }) => Some(content),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn has_content(&self) -> bool {
|
||||
match self {
|
||||
Self::ToolCall(ToolCallEntry { content, .. }) => !content.is_empty(),
|
||||
Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for ToolCallEntry {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for Entry {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
match self {
|
||||
Self::UserMessage(editor) => editor.read(cx).focus_handle(cx),
|
||||
Self::AssistantMessage(message) => message.focus_handle.clone(),
|
||||
Self::ToolCall(tool_call) => tool_call.focus_handle.clone(),
|
||||
Self::CompletedPlan => cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_terminal(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
terminal: Entity<acp_thread::Terminal>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<TerminalView> {
|
||||
cx.new(|cx| {
|
||||
let mut view = TerminalView::new(
|
||||
terminal.read(cx).inner().clone(),
|
||||
workspace,
|
||||
None,
|
||||
project,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
view.set_embedded_mode(Some(1000), cx);
|
||||
view
|
||||
})
|
||||
}
|
||||
|
||||
fn create_editor_diff(
|
||||
diff: Entity<acp_thread::Diff>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Editor> {
|
||||
cx.new(|cx| {
|
||||
let mut editor = Editor::new(
|
||||
EditorMode::Full {
|
||||
scale_ui_elements_with_buffer_font_size: false,
|
||||
show_active_line_background: false,
|
||||
sizing_behavior: SizingBehavior::SizeByContent,
|
||||
},
|
||||
diff.read(cx).multibuffer().clone(),
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.set_show_gutter(false, cx);
|
||||
editor.disable_inline_diagnostics();
|
||||
editor.disable_expand_excerpt_buttons(cx);
|
||||
editor.set_show_vertical_scrollbar(false, cx);
|
||||
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
|
||||
editor.set_soft_wrap_mode(SoftWrap::None, cx);
|
||||
editor.set_forbid_vertical_scroll(true);
|
||||
editor.set_show_indent_guides(false, cx);
|
||||
editor.set_read_only(true);
|
||||
editor.set_delegate_open_excerpts(true);
|
||||
editor.set_show_bookmarks(false, cx);
|
||||
editor.set_show_breakpoints(false, cx);
|
||||
editor.set_show_code_actions(false, cx);
|
||||
editor.set_show_git_diff_gutter(false, cx);
|
||||
editor.set_expand_all_diff_hunks(cx);
|
||||
editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
|
||||
editor
|
||||
})
|
||||
}
|
||||
|
||||
fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
|
||||
TextStyleRefinement {
|
||||
font_size: Some(
|
||||
TextSize::Small
|
||||
.rems(cx)
|
||||
.to_pixels(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use acp_thread::{AgentConnection, StubAgentConnection};
|
||||
use agent_client_protocol::schema as acp;
|
||||
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
|
||||
use editor::RowInfo;
|
||||
use fs::FakeFs;
|
||||
use gpui::{AppContext as _, TestAppContext};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::entry_view_state::EntryViewState;
|
||||
use crate::message_editor::SessionCapabilities;
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use pretty_assertions::assert_matches;
|
||||
use project::Project;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use util::path;
|
||||
use workspace::{MultiWorkspace, PathList};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diff_sync(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/project",
|
||||
json!({
|
||||
"hello.txt": "hi world"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
|
||||
let tool_call = acp::ToolCall::new("tool", "Tool call")
|
||||
.status(acp::ToolCallStatus::InProgress)
|
||||
.content(vec![acp::ToolCallContent::Diff(
|
||||
acp::Diff::new("/project/hello.txt", "hello world").old_text("hi world"),
|
||||
)]);
|
||||
let connection = Rc::new(StubAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|_, cx| {
|
||||
connection.clone().new_session(
|
||||
project.clone(),
|
||||
PathList::new(&[Path::new(path!("/project"))]),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let session_id = thread.update(cx, |thread, _| thread.session_id().clone());
|
||||
|
||||
cx.update(|_, cx| {
|
||||
connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx)
|
||||
});
|
||||
|
||||
let thread_store = None;
|
||||
|
||||
let view_state = cx.new(|_cx| {
|
||||
EntryViewState::new(
|
||||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store,
|
||||
None,
|
||||
Arc::new(RwLock::new(SessionCapabilities::default())),
|
||||
"Test Agent".into(),
|
||||
)
|
||||
});
|
||||
|
||||
view_state.update_in(cx, |view_state, window, cx| {
|
||||
view_state.sync_entry(0, &thread, window, cx)
|
||||
});
|
||||
|
||||
let diff = thread.read_with(cx, |thread, _| {
|
||||
thread
|
||||
.entries()
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.diffs()
|
||||
.next()
|
||||
.unwrap()
|
||||
.clone()
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let diff_editor = view_state.read_with(cx, |view_state, _cx| {
|
||||
view_state.entry(0).unwrap().editor_for_diff(&diff).unwrap()
|
||||
});
|
||||
assert_eq!(
|
||||
diff_editor.read_with(cx, |editor, cx| editor.text(cx)),
|
||||
"hi world\nhello world"
|
||||
);
|
||||
let row_infos = diff_editor.read_with(cx, |editor, cx| {
|
||||
let multibuffer = editor.buffer().read(cx);
|
||||
multibuffer
|
||||
.snapshot(cx)
|
||||
.row_infos(MultiBufferRow(0))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_matches!(
|
||||
row_infos.as_slice(),
|
||||
[
|
||||
RowInfo {
|
||||
multibuffer_row: Some(MultiBufferRow(0)),
|
||||
diff_status: Some(DiffHunkStatus {
|
||||
kind: DiffHunkStatusKind::Deleted,
|
||||
..
|
||||
}),
|
||||
..
|
||||
},
|
||||
RowInfo {
|
||||
multibuffer_row: Some(MultiBufferRow(1)),
|
||||
diff_status: Some(DiffHunkStatus {
|
||||
kind: DiffHunkStatusKind::Added,
|
||||
..
|
||||
}),
|
||||
..
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalSourcePrompt(String);
|
||||
|
||||
impl ExternalSourcePrompt {
|
||||
pub fn new(prompt: &str) -> Option<Self> {
|
||||
sanitize(prompt).map(Self)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(prompt: &str) -> Option<String> {
|
||||
let mut sanitized_prompt = String::with_capacity(prompt.len());
|
||||
let mut consecutive_newline_count = 0;
|
||||
let mut characters = prompt.chars().peekable();
|
||||
|
||||
while let Some(character) = characters.next() {
|
||||
let character = if character == '\r' {
|
||||
if characters.peek() == Some(&'\n') {
|
||||
characters.next();
|
||||
}
|
||||
'\n'
|
||||
} else {
|
||||
character
|
||||
};
|
||||
|
||||
if is_bidi_control_character(character) || is_disallowed_control_character(character) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if character == '\n' {
|
||||
consecutive_newline_count += 1;
|
||||
if consecutive_newline_count > 2 {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
consecutive_newline_count = 0;
|
||||
}
|
||||
|
||||
sanitized_prompt.push(character);
|
||||
}
|
||||
|
||||
if sanitized_prompt.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sanitized_prompt)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_disallowed_control_character(character: char) -> bool {
|
||||
character.is_control() && !matches!(character, '\n' | '\t')
|
||||
}
|
||||
|
||||
fn is_bidi_control_character(character: char) -> bool {
|
||||
matches!(
|
||||
character,
|
||||
'\u{061C}' // ALM
|
||||
| '\u{200E}' // LRM
|
||||
| '\u{200F}' // RLM
|
||||
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
|
||||
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ExternalSourcePrompt;
|
||||
|
||||
#[test]
|
||||
fn keeps_normal_prompt_text() {
|
||||
let prompt = ExternalSourcePrompt::new("Write me a script\nThanks");
|
||||
|
||||
assert_eq!(
|
||||
prompt.as_ref().map(ExternalSourcePrompt::as_str),
|
||||
Some("Write me a script\nThanks")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_multilingual_text() {
|
||||
let prompt =
|
||||
ExternalSourcePrompt::new("日本語の依頼です。\n中文提示也应该保留。\nemoji 👩💻");
|
||||
|
||||
assert_eq!(
|
||||
prompt.as_ref().map(ExternalSourcePrompt::as_str),
|
||||
Some("日本語の依頼です。\n中文提示也应该保留。\nemoji 👩💻")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapses_newline_padding() {
|
||||
let prompt = ExternalSourcePrompt::new(
|
||||
"Review this prompt carefully.\n\nThis paragraph should stay separated.\n\n\n\n\n\n\nWrite me a script to do fizz buzz.",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
prompt.as_ref().map(ExternalSourcePrompt::as_str),
|
||||
Some(
|
||||
"Review this prompt carefully.\n\nThis paragraph should stay separated.\n\nWrite me a script to do fizz buzz."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_carriage_returns() {
|
||||
let prompt = ExternalSourcePrompt::new("Line one\r\nLine two\rLine three");
|
||||
|
||||
assert_eq!(
|
||||
prompt.as_ref().map(ExternalSourcePrompt::as_str),
|
||||
Some("Line one\nLine two\nLine three")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_bidi_control_characters() {
|
||||
let prompt = ExternalSourcePrompt::new("abc\u{202E}def\u{202C}ghi");
|
||||
|
||||
assert_eq!(
|
||||
prompt.as_ref().map(ExternalSourcePrompt::as_str),
|
||||
Some("abcdefghi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_other_control_characters() {
|
||||
let prompt = ExternalSourcePrompt::new("safe\u{0000}\u{001B}\u{007F}text");
|
||||
|
||||
assert_eq!(
|
||||
prompt.as_ref().map(ExternalSourcePrompt::as_str),
|
||||
Some("safetext")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_tabs() {
|
||||
let prompt = ExternalSourcePrompt::new("keep\tindentation");
|
||||
|
||||
assert_eq!(
|
||||
prompt.as_ref().map(ExternalSourcePrompt::as_str),
|
||||
Some("keep\tindentation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_empty_prompt() {
|
||||
assert_eq!(ExternalSourcePrompt::new(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_prompt_with_only_removed_characters() {
|
||||
assert_eq!(
|
||||
ExternalSourcePrompt::new("\u{202E}\u{202C}\u{0000}\u{001B}"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_settings::{AgentSettings, language_model_to_selection};
|
||||
use fs::Fs;
|
||||
use language_model::LanguageModel;
|
||||
use settings::{Settings as _, update_settings_file};
|
||||
use ui::App;
|
||||
|
||||
pub fn toggle_in_settings(
|
||||
model: Arc<dyn LanguageModel>,
|
||||
should_be_favorite: bool,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let current_user_selection = AgentSettings::get_global(cx)
|
||||
.default_model
|
||||
.as_ref()
|
||||
.filter(|selection| {
|
||||
selection.provider.0 == model.provider_id().0.as_ref()
|
||||
&& selection.model == model.id().0.as_ref()
|
||||
})
|
||||
.cloned();
|
||||
|
||||
let selection = language_model_to_selection(&model, current_user_selection.as_ref());
|
||||
update_settings_file(fs, cx, move |settings, _| {
|
||||
let agent = settings.agent.get_or_insert_default();
|
||||
if should_be_favorite {
|
||||
agent.add_favorite_model(selection.clone());
|
||||
} else {
|
||||
agent.remove_favorite_model(&selection);
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,896 @@
|
||||
use std::{cmp::Reverse, sync::Arc};
|
||||
|
||||
use agent_settings::AgentSettings;
|
||||
use collections::{HashMap, HashSet, IndexMap};
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, ForegroundExecutor,
|
||||
Subscription, Task,
|
||||
};
|
||||
use language_model::{
|
||||
ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelRegistry,
|
||||
};
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::Settings;
|
||||
use ui::prelude::*;
|
||||
use zed_actions::agent::OpenSettings;
|
||||
|
||||
use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
|
||||
|
||||
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
|
||||
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
|
||||
type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static>;
|
||||
|
||||
pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
|
||||
|
||||
pub fn language_model_selector(
|
||||
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
|
||||
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
|
||||
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static,
|
||||
popover_styles: bool,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<LanguageModelSelector>,
|
||||
) -> LanguageModelSelector {
|
||||
let delegate = LanguageModelPickerDelegate::new(
|
||||
get_active_model,
|
||||
on_model_changed,
|
||||
on_toggle_favorite,
|
||||
popover_styles,
|
||||
focus_handle,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
if popover_styles {
|
||||
Picker::list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems(20.))
|
||||
.max_height(Some(rems(20.).into()))
|
||||
} else {
|
||||
Picker::list(delegate, window, cx).show_scrollbar(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn all_models(cx: &App) -> GroupedModels {
|
||||
let lm_registry = LanguageModelRegistry::global(cx).read(cx);
|
||||
let providers = lm_registry.visible_providers();
|
||||
|
||||
let mut favorites_index = FavoritesIndex::default();
|
||||
|
||||
for sel in &AgentSettings::get_global(cx).favorite_models {
|
||||
favorites_index
|
||||
.entry(sel.provider.0.clone().into())
|
||||
.or_default()
|
||||
.insert(sel.model.clone().into());
|
||||
}
|
||||
|
||||
let recommended = providers
|
||||
.iter()
|
||||
.flat_map(|provider| {
|
||||
provider
|
||||
.recommended_models(cx)
|
||||
.into_iter()
|
||||
.map(|model| ModelInfo::new(&**provider, model, &favorites_index))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let all = providers
|
||||
.iter()
|
||||
.flat_map(|provider| {
|
||||
provider
|
||||
.provided_models(cx)
|
||||
.into_iter()
|
||||
.map(|model| ModelInfo::new(&**provider, model, &favorites_index))
|
||||
})
|
||||
.collect();
|
||||
|
||||
GroupedModels::new(all, recommended)
|
||||
}
|
||||
|
||||
type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ModelInfo {
|
||||
model: Arc<dyn LanguageModel>,
|
||||
icon: IconOrSvg,
|
||||
is_favorite: bool,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
fn new(
|
||||
provider: &dyn LanguageModelProvider,
|
||||
model: Arc<dyn LanguageModel>,
|
||||
favorites_index: &FavoritesIndex,
|
||||
) -> Self {
|
||||
let is_favorite = favorites_index
|
||||
.get(&provider.id())
|
||||
.map_or(false, |set| set.contains(&model.id()));
|
||||
|
||||
Self {
|
||||
model,
|
||||
icon: provider.icon(),
|
||||
is_favorite,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LanguageModelPickerDelegate {
|
||||
on_model_changed: OnModelChanged,
|
||||
get_active_model: GetActiveModel,
|
||||
on_toggle_favorite: OnToggleFavorite,
|
||||
all_models: Arc<GroupedModels>,
|
||||
filtered_entries: Vec<LanguageModelPickerEntry>,
|
||||
selected_index: usize,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
popover_styles: bool,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl LanguageModelPickerDelegate {
|
||||
fn new(
|
||||
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
|
||||
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
|
||||
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static,
|
||||
popover_styles: bool,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Self {
|
||||
let on_model_changed = Arc::new(on_model_changed);
|
||||
let models = all_models(cx);
|
||||
let entries = models.entries();
|
||||
|
||||
Self {
|
||||
on_model_changed,
|
||||
all_models: Arc::new(models),
|
||||
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
|
||||
filtered_entries: entries,
|
||||
get_active_model: Arc::new(get_active_model),
|
||||
on_toggle_favorite: Arc::new(on_toggle_favorite),
|
||||
_subscriptions: vec![cx.subscribe_in(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
window,
|
||||
|picker, _, event, window, cx| {
|
||||
match event {
|
||||
language_model::Event::ProviderStateChanged(_)
|
||||
| language_model::Event::AddedProvider(_)
|
||||
| language_model::Event::RemovedProvider(_) => {
|
||||
let query = picker.query(cx);
|
||||
picker.delegate.all_models = Arc::new(all_models(cx));
|
||||
// Update matches will automatically drop the previous task
|
||||
// if we get a provider event again
|
||||
picker.update_matches(query, window, cx)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
)],
|
||||
popover_styles,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_active_model_index(
|
||||
entries: &[LanguageModelPickerEntry],
|
||||
active_model: Option<ConfiguredModel>,
|
||||
) -> usize {
|
||||
entries
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
if let LanguageModelPickerEntry::Model(model) = entry {
|
||||
active_model
|
||||
.as_ref()
|
||||
.map(|active_model| {
|
||||
active_model.model.id() == model.model.id()
|
||||
&& active_model.provider.id() == model.model.provider_id()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
|
||||
(self.get_active_model)(cx)
|
||||
}
|
||||
|
||||
pub fn favorites_count(&self) -> usize {
|
||||
self.all_models.favorites.len()
|
||||
}
|
||||
|
||||
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if self.all_models.favorites.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let active_model = (self.get_active_model)(cx);
|
||||
let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
|
||||
let active_model_id = active_model.as_ref().map(|m| m.model.id());
|
||||
|
||||
let current_index = self
|
||||
.all_models
|
||||
.favorites
|
||||
.iter()
|
||||
.position(|info| {
|
||||
Some(info.model.provider_id()) == active_provider_id
|
||||
&& Some(info.model.id()) == active_model_id
|
||||
})
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
let next_index = if current_index == usize::MAX {
|
||||
0
|
||||
} else {
|
||||
(current_index + 1) % self.all_models.favorites.len()
|
||||
};
|
||||
|
||||
let next_model = self.all_models.favorites[next_index].model.clone();
|
||||
|
||||
(self.on_model_changed)(next_model, cx);
|
||||
|
||||
// Align the picker selection with the newly-active model
|
||||
let new_index =
|
||||
Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx));
|
||||
self.set_selected_index(new_index, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupedModels {
|
||||
favorites: Vec<ModelInfo>,
|
||||
recommended: Vec<ModelInfo>,
|
||||
all: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
|
||||
}
|
||||
|
||||
impl GroupedModels {
|
||||
pub fn new(all: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
|
||||
let favorites = all
|
||||
.iter()
|
||||
.filter(|info| info.is_favorite)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut all_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
|
||||
for model in all {
|
||||
let provider = model.model.provider_id();
|
||||
if let Some(models) = all_by_provider.get_mut(&provider) {
|
||||
models.push(model);
|
||||
} else {
|
||||
all_by_provider.insert(provider, vec![model]);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
favorites,
|
||||
recommended,
|
||||
all: all_by_provider,
|
||||
}
|
||||
}
|
||||
|
||||
fn entries(&self) -> Vec<LanguageModelPickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
if !self.favorites.is_empty() {
|
||||
entries.push(LanguageModelPickerEntry::Separator("Favorite".into()));
|
||||
for info in &self.favorites {
|
||||
entries.push(LanguageModelPickerEntry::Model(info.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.recommended.is_empty() {
|
||||
entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
|
||||
for info in &self.recommended {
|
||||
entries.push(LanguageModelPickerEntry::Model(info.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
for models in self.all.values() {
|
||||
if models.is_empty() {
|
||||
continue;
|
||||
}
|
||||
entries.push(LanguageModelPickerEntry::Separator(
|
||||
models[0].model.provider_name().0,
|
||||
));
|
||||
for info in models {
|
||||
entries.push(LanguageModelPickerEntry::Model(info.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
enum LanguageModelPickerEntry {
|
||||
Model(ModelInfo),
|
||||
Separator(SharedString),
|
||||
}
|
||||
|
||||
struct ModelMatcher {
|
||||
models: Vec<ModelInfo>,
|
||||
fg_executor: ForegroundExecutor,
|
||||
bg_executor: BackgroundExecutor,
|
||||
candidates: Vec<StringMatchCandidate>,
|
||||
}
|
||||
|
||||
impl ModelMatcher {
|
||||
fn new(
|
||||
models: Vec<ModelInfo>,
|
||||
fg_executor: ForegroundExecutor,
|
||||
bg_executor: BackgroundExecutor,
|
||||
) -> ModelMatcher {
|
||||
let candidates = Self::make_match_candidates(&models);
|
||||
Self {
|
||||
models,
|
||||
fg_executor,
|
||||
bg_executor,
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fuzzy_search(&self, query: &str) -> Vec<ModelInfo> {
|
||||
let mut matches = self.fg_executor.block_on(match_strings(
|
||||
&self.candidates,
|
||||
query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
&Default::default(),
|
||||
self.bg_executor.clone(),
|
||||
));
|
||||
|
||||
let sorting_key = |mat: &StringMatch| {
|
||||
let candidate = &self.candidates[mat.candidate_id];
|
||||
(Reverse(OrderedFloat(mat.score)), candidate.id)
|
||||
};
|
||||
matches.sort_unstable_by_key(sorting_key);
|
||||
|
||||
let matched_models: Vec<_> = matches
|
||||
.into_iter()
|
||||
.map(|mat| self.models[mat.candidate_id].clone())
|
||||
.collect();
|
||||
|
||||
matched_models
|
||||
}
|
||||
|
||||
pub fn exact_search(&self, query: &str) -> Vec<ModelInfo> {
|
||||
self.models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.model
|
||||
.name()
|
||||
.0
|
||||
.to_lowercase()
|
||||
.contains(&query.to_lowercase())
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn make_match_candidates(model_infos: &Vec<ModelInfo>) -> Vec<StringMatchCandidate> {
|
||||
model_infos
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, model)| {
|
||||
StringMatchCandidate::new(
|
||||
index,
|
||||
&format!(
|
||||
"{}/{}",
|
||||
&model.model.provider_name().0,
|
||||
&model.model.name().0
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
|
||||
match self.filtered_entries.get(ix) {
|
||||
Some(LanguageModelPickerEntry::Model(_)) => true,
|
||||
Some(LanguageModelPickerEntry::Separator(_)) | None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Select a model…".into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let all_models = self.all_models.clone();
|
||||
let active_model = (self.get_active_model)(cx);
|
||||
let fg_executor = cx.foreground_executor();
|
||||
let bg_executor = cx.background_executor();
|
||||
|
||||
let language_model_registry = LanguageModelRegistry::global(cx);
|
||||
|
||||
let configured_providers = language_model_registry
|
||||
.read(cx)
|
||||
.visible_providers()
|
||||
.into_iter()
|
||||
.filter(|provider| provider.is_authenticated(cx))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let configured_provider_ids = configured_providers
|
||||
.iter()
|
||||
.map(|provider| provider.id())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let recommended_models = all_models
|
||||
.recommended
|
||||
.iter()
|
||||
.filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let available_models = all_models
|
||||
.all
|
||||
.values()
|
||||
.flat_map(|models| models.iter())
|
||||
.filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let matcher_rec =
|
||||
ModelMatcher::new(recommended_models, fg_executor.clone(), bg_executor.clone());
|
||||
let matcher_all =
|
||||
ModelMatcher::new(available_models, fg_executor.clone(), bg_executor.clone());
|
||||
|
||||
let recommended = matcher_rec.exact_search(&query);
|
||||
let all = matcher_all.fuzzy_search(&query);
|
||||
|
||||
let filtered_models = GroupedModels::new(all, recommended);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.delegate.filtered_entries = filtered_models.entries();
|
||||
// Finds the currently selected model in the list
|
||||
let new_index =
|
||||
Self::get_active_model_index(&this.delegate.filtered_entries, active_model);
|
||||
this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if let Some(LanguageModelPickerEntry::Model(model_info)) =
|
||||
self.filtered_entries.get(self.selected_index)
|
||||
{
|
||||
let model = model_info.model.clone();
|
||||
(self.on_model_changed)(model.clone(), cx);
|
||||
|
||||
let current_index = self.selected_index;
|
||||
self.set_selected_index(current_index, window, cx);
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered_entries.get(ix)? {
|
||||
LanguageModelPickerEntry::Separator(title) => {
|
||||
Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
|
||||
}
|
||||
LanguageModelPickerEntry::Model(model_info) => {
|
||||
let active_model = (self.get_active_model)(cx);
|
||||
let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
|
||||
let active_model_id = active_model.map(|m| m.model.id());
|
||||
|
||||
let is_selected = Some(model_info.model.provider_id()) == active_provider_id
|
||||
&& Some(model_info.model.id()) == active_model_id;
|
||||
|
||||
let model_cost = model_info
|
||||
.model
|
||||
.model_cost_info()
|
||||
.map(|cost| cost.to_shared_string());
|
||||
|
||||
let is_favorite = model_info.is_favorite;
|
||||
let handle_action_click = {
|
||||
let model = model_info.model.clone();
|
||||
let on_toggle_favorite = self.on_toggle_favorite.clone();
|
||||
cx.listener(move |picker, _, window, cx| {
|
||||
on_toggle_favorite(model.clone(), !is_favorite, cx);
|
||||
picker.refresh(window, cx);
|
||||
})
|
||||
};
|
||||
|
||||
Some(
|
||||
ModelSelectorListItem::new(ix, model_info.model.name().0)
|
||||
.map(|this| match &model_info.icon {
|
||||
IconOrSvg::Icon(icon_name) => this.icon(*icon_name),
|
||||
IconOrSvg::Svg(icon_path) => this.icon_path(icon_path.clone()),
|
||||
})
|
||||
.is_selected(is_selected)
|
||||
.is_focused(selected)
|
||||
.is_latest(model_info.model.is_latest())
|
||||
.is_favorite(is_favorite)
|
||||
.cost_info(model_cost)
|
||||
.on_toggle_favorite(handle_action_click)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
if !self.popover_styles {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::{future::BoxFuture, stream::BoxStream};
|
||||
use gpui::{AsyncApp, TestAppContext};
|
||||
use language_model::{
|
||||
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
|
||||
LanguageModelName, LanguageModelProviderId, LanguageModelProviderName,
|
||||
LanguageModelRequest, LanguageModelToolChoice,
|
||||
};
|
||||
use ui::IconName;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestLanguageModel {
|
||||
name: LanguageModelName,
|
||||
id: LanguageModelId,
|
||||
provider_id: LanguageModelProviderId,
|
||||
provider_name: LanguageModelProviderName,
|
||||
}
|
||||
|
||||
impl TestLanguageModel {
|
||||
fn new(name: &str, provider: &str) -> Self {
|
||||
Self {
|
||||
name: LanguageModelName::from(name.to_string()),
|
||||
id: LanguageModelId::from(name.to_string()),
|
||||
provider_id: LanguageModelProviderId::from(provider.to_string()),
|
||||
provider_name: LanguageModelProviderName::from(provider.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModel for TestLanguageModel {
|
||||
fn id(&self) -> LanguageModelId {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn name(&self) -> LanguageModelName {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn provider_id(&self) -> LanguageModelProviderId {
|
||||
self.provider_id.clone()
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> LanguageModelProviderName {
|
||||
self.provider_name.clone()
|
||||
}
|
||||
|
||||
fn supports_tools(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_images(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn telemetry_id(&self) -> String {
|
||||
format!("{}/{}", self.provider_id.0, self.name.0)
|
||||
}
|
||||
|
||||
fn max_token_count(&self) -> u64 {
|
||||
1000
|
||||
}
|
||||
|
||||
fn stream_completion(
|
||||
&self,
|
||||
_: LanguageModelRequest,
|
||||
_: &AsyncApp,
|
||||
) -> BoxFuture<
|
||||
'static,
|
||||
Result<
|
||||
BoxStream<
|
||||
'static,
|
||||
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
|
||||
>,
|
||||
LanguageModelCompletionError,
|
||||
>,
|
||||
> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
|
||||
create_models_with_favorites(model_specs, vec![])
|
||||
}
|
||||
|
||||
fn create_models_with_favorites(
|
||||
model_specs: Vec<(&str, &str)>,
|
||||
favorites: Vec<(&str, &str)>,
|
||||
) -> Vec<ModelInfo> {
|
||||
model_specs
|
||||
.into_iter()
|
||||
.map(|(provider, name)| {
|
||||
let is_favorite = favorites
|
||||
.iter()
|
||||
.any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
|
||||
ModelInfo {
|
||||
model: Arc::new(TestLanguageModel::new(name, provider)),
|
||||
icon: IconOrSvg::Icon(IconName::ZedAgent),
|
||||
is_favorite,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_models_eq(result: Vec<ModelInfo>, expected: Vec<&str>) {
|
||||
assert_eq!(
|
||||
result.len(),
|
||||
expected.len(),
|
||||
"Number of models doesn't match"
|
||||
);
|
||||
|
||||
for (i, expected_name) in expected.iter().enumerate() {
|
||||
assert_eq!(
|
||||
result[i].model.telemetry_id(),
|
||||
*expected_name,
|
||||
"Model at position {} doesn't match expected model",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_exact_match(cx: &mut TestAppContext) {
|
||||
let models = create_models(vec![
|
||||
("zed", "Claude 3.7 Sonnet"),
|
||||
("zed", "Claude 3.7 Sonnet Thinking"),
|
||||
("zed", "gpt-5"),
|
||||
("zed", "gpt-5-mini"),
|
||||
("openai", "gpt-3.5-turbo"),
|
||||
("openai", "gpt-5"),
|
||||
("openai", "gpt-5-mini"),
|
||||
("ollama", "mistral"),
|
||||
("ollama", "deepseek"),
|
||||
]);
|
||||
let matcher = ModelMatcher::new(
|
||||
models,
|
||||
cx.foreground_executor().clone(),
|
||||
cx.background_executor.clone(),
|
||||
);
|
||||
|
||||
// The order of models should be maintained, case doesn't matter
|
||||
let results = matcher.exact_search("GPT-5");
|
||||
assert_models_eq(
|
||||
results,
|
||||
vec![
|
||||
"zed/gpt-5",
|
||||
"zed/gpt-5-mini",
|
||||
"openai/gpt-5",
|
||||
"openai/gpt-5-mini",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_fuzzy_match(cx: &mut TestAppContext) {
|
||||
let models = create_models(vec![
|
||||
("zed", "Claude 3.7 Sonnet"),
|
||||
("zed", "Claude 3.7 Sonnet Thinking"),
|
||||
("zed", "gpt-5"),
|
||||
("zed", "gpt-5-mini"),
|
||||
("openai", "gpt-3.5-turbo"),
|
||||
("openai", "gpt-5"),
|
||||
("openai", "gpt-5-mini"),
|
||||
("ollama", "mistral"),
|
||||
("ollama", "deepseek"),
|
||||
]);
|
||||
let matcher = ModelMatcher::new(
|
||||
models,
|
||||
cx.foreground_executor().clone(),
|
||||
cx.background_executor.clone(),
|
||||
);
|
||||
|
||||
// Results should preserve models order whenever possible.
|
||||
// In the case below, `zed/gpt-5-mini` and `openai/gpt-5-mini` have identical
|
||||
// similarity scores, but `zed/gpt-5-mini` was higher in the models list,
|
||||
// so it should appear first in the results.
|
||||
let results = matcher.fuzzy_search("mini");
|
||||
assert_models_eq(results, vec!["zed/gpt-5-mini", "openai/gpt-5-mini"]);
|
||||
|
||||
// Model provider should be searchable as well
|
||||
let results = matcher.fuzzy_search("ol"); // meaning "ollama"
|
||||
assert_models_eq(results, vec!["ollama/mistral", "ollama/deepseek"]);
|
||||
|
||||
// Fuzzy search - search for Claude to get the Thinking variant
|
||||
let results = matcher.fuzzy_search("thinking");
|
||||
assert_models_eq(results, vec!["zed/Claude 3.7 Sonnet Thinking"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_recommended_models_also_appear_in_other(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models(vec![
|
||||
("zed", "claude"), // Should also appear in "other"
|
||||
("zed", "gemini"),
|
||||
("copilot", "o3"),
|
||||
]);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
|
||||
let actual_all_models = grouped_models
|
||||
.all
|
||||
.values()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Recommended models should also appear in "all"
|
||||
assert_models_eq(
|
||||
actual_all_models,
|
||||
vec!["zed/claude", "zed/gemini", "copilot/o3"],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_models_from_different_providers(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models(vec![
|
||||
("zed", "claude"), // Should also appear in "other"
|
||||
("zed", "gemini"),
|
||||
("copilot", "claude"), // Different provider, should appear in "other"
|
||||
]);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
|
||||
let actual_all_models = grouped_models
|
||||
.all
|
||||
.values()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// All models should appear in "all" regardless of recommended status
|
||||
assert_models_eq(
|
||||
actual_all_models,
|
||||
vec!["zed/claude", "zed/gemini", "copilot/claude"],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models_with_favorites(
|
||||
vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
|
||||
vec![("zed", "gemini")],
|
||||
);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
let entries = grouped_models.entries();
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite"
|
||||
));
|
||||
|
||||
assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
let entries = grouped_models.entries();
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended"
|
||||
));
|
||||
|
||||
assert!(grouped_models.favorites.is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
|
||||
let recommended_models =
|
||||
create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]);
|
||||
let all_models = create_models_with_favorites(
|
||||
vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
|
||||
vec![("zed", "claude")],
|
||||
);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
let entries = grouped_models.entries();
|
||||
|
||||
for entry in &entries {
|
||||
if let LanguageModelPickerEntry::Model(info) = entry {
|
||||
if info.model.telemetry_id() == "zed/claude" {
|
||||
assert!(info.is_favorite, "zed/claude should be a favorite");
|
||||
} else {
|
||||
assert!(
|
||||
!info.is_favorite,
|
||||
"{} should not be a favorite",
|
||||
info.model.telemetry_id()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) {
|
||||
let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")];
|
||||
|
||||
let recommended_models =
|
||||
create_models_with_favorites(vec![("zed", "claude")], favorites.clone());
|
||||
|
||||
let all_models = create_models_with_favorites(
|
||||
vec![
|
||||
("zed", "claude"),
|
||||
("zed", "gemini"),
|
||||
("openai", "gpt-4"),
|
||||
("openai", "gpt-3.5"),
|
||||
],
|
||||
favorites,
|
||||
);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
|
||||
assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]);
|
||||
assert_models_eq(grouped_models.recommended, vec!["zed/claude"]);
|
||||
assert_models_eq(
|
||||
grouped_models.all.values().flatten().cloned().collect(),
|
||||
vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
use acp_thread::AgentSessionModes;
|
||||
use agent_client_protocol::schema as acp;
|
||||
use agent_servers::AgentServer;
|
||||
|
||||
use fs::Fs;
|
||||
use gpui::{Context, Entity, WeakEntity, Window, prelude::*};
|
||||
|
||||
use std::{rc::Rc, sync::Arc};
|
||||
use ui::{
|
||||
Button, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CycleModeSelector, ToggleProfileSelector,
|
||||
ui::{HoldForDefault, documentation_aside_side},
|
||||
};
|
||||
|
||||
pub struct ModeSelector {
|
||||
connection: Rc<dyn AgentSessionModes>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
fs: Arc<dyn Fs>,
|
||||
setting_mode: bool,
|
||||
}
|
||||
|
||||
impl ModeSelector {
|
||||
pub fn new(
|
||||
session_modes: Rc<dyn AgentSessionModes>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
) -> Self {
|
||||
Self {
|
||||
connection: session_modes,
|
||||
agent_server,
|
||||
menu_handle: PopoverMenuHandle::default(),
|
||||
fs,
|
||||
setting_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_handle(&self) -> PopoverMenuHandle<ContextMenu> {
|
||||
self.menu_handle.clone()
|
||||
}
|
||||
|
||||
pub fn cycle_mode(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let all_modes = self.connection.all_modes();
|
||||
let current_mode = self.connection.current_mode();
|
||||
|
||||
let current_index = all_modes
|
||||
.iter()
|
||||
.position(|mode| mode.id.0 == current_mode.0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let next_index = (current_index + 1) % all_modes.len();
|
||||
self.set_mode(all_modes[next_index].id.clone(), cx);
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> acp::SessionModeId {
|
||||
self.connection.current_mode()
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: acp::SessionModeId, cx: &mut Context<Self>) {
|
||||
let task = self.connection.set_mode(mode, cx);
|
||||
self.setting_mode = true;
|
||||
cx.notify();
|
||||
|
||||
cx.spawn(async move |this: WeakEntity<ModeSelector>, cx| {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to set session mode: {:?}", err);
|
||||
}
|
||||
this.update(cx, |this, cx| {
|
||||
this.setting_mode = false;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn build_context_menu(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<ContextMenu> {
|
||||
let weak_self = cx.weak_entity();
|
||||
|
||||
ContextMenu::build(window, cx, move |mut menu, _window, cx| {
|
||||
let all_modes = self.connection.all_modes();
|
||||
let current_mode = self.connection.current_mode();
|
||||
let default_mode = self.agent_server.default_mode(cx);
|
||||
|
||||
let side = documentation_aside_side(cx);
|
||||
|
||||
for mode in all_modes {
|
||||
let is_selected = &mode.id == ¤t_mode;
|
||||
let is_default = Some(&mode.id) == default_mode.as_ref();
|
||||
let entry = ContextMenuEntry::new(mode.name.clone())
|
||||
.toggleable(IconPosition::End, is_selected);
|
||||
|
||||
let entry = if let Some(description) = &mode.description {
|
||||
entry.documentation_aside(side, {
|
||||
let description = description.clone();
|
||||
|
||||
move |_| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new(description.clone()))
|
||||
.child(HoldForDefault::new(is_default))
|
||||
.into_any_element()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
entry
|
||||
};
|
||||
|
||||
menu.push_item(entry.handler({
|
||||
let mode_id = mode.id.clone();
|
||||
let weak_self = weak_self.clone();
|
||||
move |window, cx| {
|
||||
weak_self
|
||||
.update(cx, |this, cx| {
|
||||
if window.modifiers().secondary() {
|
||||
this.agent_server.set_default_mode(
|
||||
if is_default {
|
||||
None
|
||||
} else {
|
||||
Some(mode_id.clone())
|
||||
},
|
||||
this.fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
this.set_mode(mode_id.clone(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
menu.key_context("ModeSelector")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ModeSelector {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let current_mode_id = self.connection.current_mode();
|
||||
let current_mode_name = self
|
||||
.connection
|
||||
.all_modes()
|
||||
.iter()
|
||||
.find(|mode| mode.id == current_mode_id)
|
||||
.map(|mode| mode.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
|
||||
let this = cx.weak_entity();
|
||||
|
||||
let icon = if self.menu_handle.is_deployed() {
|
||||
IconName::ChevronUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
};
|
||||
|
||||
let trigger_button = Button::new("mode-selector-trigger", current_mode_name)
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
|
||||
.disabled(self.setting_mode);
|
||||
|
||||
PopoverMenu::new("mode-selector")
|
||||
.trigger_with_tooltip(
|
||||
trigger_button,
|
||||
Tooltip::element({
|
||||
move |_window, cx| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Label::new("Change Mode"))
|
||||
.child(KeyBinding::for_action(&ToggleProfileSelector, cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.justify_between()
|
||||
.child(Label::new("Cycle Through Modes"))
|
||||
.child(KeyBinding::for_action(&CycleModeSelector, cx)),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
}),
|
||||
)
|
||||
.anchor(gpui::Anchor::BottomRight)
|
||||
.with_handle(self.menu_handle.clone())
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(-2.0),
|
||||
})
|
||||
.menu(move |window, cx| {
|
||||
this.update(cx, |this, cx| this.build_context_menu(window, cx))
|
||||
.ok()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
use std::{cmp::Reverse, rc::Rc, sync::Arc};
|
||||
|
||||
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
|
||||
use agent_client_protocol::schema as acp;
|
||||
use agent_servers::AgentServer;
|
||||
|
||||
use anyhow::Result;
|
||||
use collections::{HashSet, IndexMap};
|
||||
use fs::Fs;
|
||||
use futures::FutureExt;
|
||||
use fuzzy::{StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
|
||||
TaskExt, WeakEntity,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::SettingsStore;
|
||||
use ui::{DocumentationAside, IntoElement, prelude::*};
|
||||
use util::ResultExt;
|
||||
use zed_actions::agent::OpenSettings;
|
||||
|
||||
use crate::ui::{
|
||||
HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem,
|
||||
documentation_aside_side,
|
||||
};
|
||||
|
||||
pub type ModelSelector = Picker<ModelPickerDelegate>;
|
||||
|
||||
pub fn acp_model_selector(
|
||||
selector: Rc<dyn AgentModelSelector>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ModelSelector>,
|
||||
) -> ModelSelector {
|
||||
let delegate = ModelPickerDelegate::new(selector, agent_server, fs, focus_handle, window, cx);
|
||||
Picker::list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems(20.))
|
||||
.max_height(Some(rems(20.).into()))
|
||||
}
|
||||
|
||||
enum ModelPickerEntry {
|
||||
Separator(SharedString),
|
||||
Model(AgentModelInfo, bool),
|
||||
}
|
||||
|
||||
pub struct ModelPickerDelegate {
|
||||
selector: Rc<dyn AgentModelSelector>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
filtered_entries: Vec<ModelPickerEntry>,
|
||||
models: Option<AgentModelList>,
|
||||
selected_index: usize,
|
||||
selected_description: Option<(usize, SharedString, bool)>,
|
||||
selected_model: Option<AgentModelInfo>,
|
||||
favorites: HashSet<acp::ModelId>,
|
||||
_refresh_models_task: Task<()>,
|
||||
_settings_subscription: Subscription,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl ModelPickerDelegate {
|
||||
fn new(
|
||||
selector: Rc<dyn AgentModelSelector>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ModelSelector>,
|
||||
) -> Self {
|
||||
let rx = selector.watch(cx);
|
||||
let refresh_models_task = {
|
||||
cx.spawn_in(window, {
|
||||
async move |this, cx| {
|
||||
async fn refresh(
|
||||
this: &WeakEntity<Picker<ModelPickerDelegate>>,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) -> Result<()> {
|
||||
let (models_task, selected_model_task) = this.update(cx, |this, cx| {
|
||||
(
|
||||
this.delegate.selector.list_models(cx),
|
||||
this.delegate.selector.selected_model(cx),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (models, selected_model) =
|
||||
futures::join!(models_task, selected_model_task);
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.delegate.models = models.ok();
|
||||
this.delegate.selected_model = selected_model.ok();
|
||||
this.refresh(window, cx)
|
||||
})
|
||||
}
|
||||
|
||||
refresh(&this, cx).await.log_err();
|
||||
if let Some(mut rx) = rx {
|
||||
while let Ok(()) = rx.recv().await {
|
||||
refresh(&this, cx).await.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let agent_server_for_subscription = agent_server.clone();
|
||||
let settings_subscription =
|
||||
cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
|
||||
// Only refresh if the favorites actually changed to avoid redundant work
|
||||
// when other settings are modified (e.g., user editing settings.json)
|
||||
let new_favorites = agent_server_for_subscription.favorite_model_ids(cx);
|
||||
if new_favorites != picker.delegate.favorites {
|
||||
picker.delegate.favorites = new_favorites;
|
||||
picker.refresh(window, cx);
|
||||
}
|
||||
});
|
||||
let favorites = agent_server.favorite_model_ids(cx);
|
||||
|
||||
Self {
|
||||
selector,
|
||||
agent_server,
|
||||
fs,
|
||||
filtered_entries: Vec::new(),
|
||||
models: None,
|
||||
selected_model: None,
|
||||
selected_index: 0,
|
||||
selected_description: None,
|
||||
favorites,
|
||||
_refresh_models_task: refresh_models_task,
|
||||
_settings_subscription: settings_subscription,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_model(&self) -> Option<&AgentModelInfo> {
|
||||
self.selected_model.as_ref()
|
||||
}
|
||||
|
||||
pub fn favorites_count(&self) -> usize {
|
||||
self.favorites.len()
|
||||
}
|
||||
|
||||
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if self.favorites.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(models) = &self.models else {
|
||||
return;
|
||||
};
|
||||
|
||||
let all_models: Vec<&AgentModelInfo> = match models {
|
||||
AgentModelList::Flat(list) => list.iter().collect(),
|
||||
AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
|
||||
};
|
||||
|
||||
let favorite_models: Vec<_> = all_models
|
||||
.into_iter()
|
||||
.filter(|model| self.favorites.contains(&model.id))
|
||||
.unique_by(|model| &model.id)
|
||||
.collect();
|
||||
|
||||
if favorite_models.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_id = self.selected_model.as_ref().map(|m| &m.id);
|
||||
|
||||
let current_index_in_favorites = current_id
|
||||
.and_then(|id| favorite_models.iter().position(|m| &m.id == id))
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
let next_index = if current_index_in_favorites == usize::MAX {
|
||||
0
|
||||
} else {
|
||||
(current_index_in_favorites + 1) % favorite_models.len()
|
||||
};
|
||||
|
||||
let next_model = favorite_models[next_index].clone();
|
||||
|
||||
self.selector
|
||||
.select_model(next_model.id.clone(), cx)
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
self.selected_model = Some(next_model);
|
||||
|
||||
// Keep the picker selection aligned with the newly-selected model
|
||||
if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
|
||||
matches!(entry, ModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
|
||||
}) {
|
||||
self.set_selected_index(new_index, window, cx);
|
||||
} else {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for ModelPickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
|
||||
match self.filtered_entries.get(ix) {
|
||||
Some(ModelPickerEntry::Model(_, _)) => true,
|
||||
Some(ModelPickerEntry::Separator(_)) | None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
"Select a model…".into()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let favorites = self.favorites.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let filtered_models = match this
|
||||
.read_with(cx, |this, cx| {
|
||||
this.delegate.models.clone().map(move |models| {
|
||||
fuzzy_search(models, query, cx.background_executor().clone())
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
Some(task) => task.await,
|
||||
None => AgentModelList::Flat(vec![]),
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.delegate.filtered_entries =
|
||||
info_list_to_picker_entries(filtered_models, &favorites);
|
||||
// Finds the currently selected model in the list
|
||||
let new_index = this
|
||||
.delegate
|
||||
.selected_model
|
||||
.as_ref()
|
||||
.and_then(|selected| {
|
||||
this.delegate.filtered_entries.iter().position(|entry| {
|
||||
if let ModelPickerEntry::Model(model_info, _) = entry {
|
||||
model_info.id == selected.id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if let Some(ModelPickerEntry::Model(model_info, _)) =
|
||||
self.filtered_entries.get(self.selected_index)
|
||||
{
|
||||
if window.modifiers().secondary() {
|
||||
let default_model = self.agent_server.default_model(cx);
|
||||
let is_default = default_model.as_ref() == Some(&model_info.id);
|
||||
|
||||
self.agent_server.set_default_model(
|
||||
if is_default {
|
||||
None
|
||||
} else {
|
||||
Some(model_info.id.clone())
|
||||
},
|
||||
self.fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
self.selector
|
||||
.select_model(model_info.id.clone(), cx)
|
||||
.detach_and_log_err(cx);
|
||||
self.selected_model = Some(model_info.clone());
|
||||
let current_index = self.selected_index;
|
||||
self.set_selected_index(current_index, window, cx);
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
cx.defer_in(window, |picker, window, cx| {
|
||||
picker.set_query("", window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered_entries.get(ix)? {
|
||||
ModelPickerEntry::Separator(title) => {
|
||||
Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
|
||||
}
|
||||
ModelPickerEntry::Model(model_info, is_favorite) => {
|
||||
let is_selected = Some(model_info) == self.selected_model.as_ref();
|
||||
let default_model = self.agent_server.default_model(cx);
|
||||
let is_default = default_model.as_ref() == Some(&model_info.id);
|
||||
|
||||
let is_favorite = *is_favorite;
|
||||
let handle_action_click = {
|
||||
let model_id = model_info.id.clone();
|
||||
let fs = self.fs.clone();
|
||||
let agent_server = self.agent_server.clone();
|
||||
|
||||
cx.listener(move |_, _, _, cx| {
|
||||
agent_server.toggle_favorite_model(
|
||||
model_id.clone(),
|
||||
!is_favorite,
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
let model_cost = model_info.cost.clone();
|
||||
|
||||
Some(
|
||||
div()
|
||||
.id(("model-picker-menu-child", ix))
|
||||
.when_some(model_info.description.clone(), |this, description| {
|
||||
this.on_hover(cx.listener(move |menu, hovered, _, cx| {
|
||||
if *hovered {
|
||||
menu.delegate.selected_description =
|
||||
Some((ix, description.clone(), is_default));
|
||||
} else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
|
||||
menu.delegate.selected_description = None;
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
ModelSelectorListItem::new(ix, model_info.name.clone())
|
||||
.map(|this| match &model_info.icon {
|
||||
Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()),
|
||||
Some(AgentModelIcon::Named(icon)) => this.icon(*icon),
|
||||
None => this,
|
||||
})
|
||||
.is_selected(is_selected)
|
||||
.is_focused(selected)
|
||||
.is_latest(model_info.is_latest)
|
||||
.is_favorite(is_favorite)
|
||||
.on_toggle_favorite(handle_action_click)
|
||||
.cost_info(model_cost)
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn documentation_aside(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<ui::DocumentationAside> {
|
||||
self.selected_description
|
||||
.as_ref()
|
||||
.map(|(_, description, is_default)| {
|
||||
let description = description.clone();
|
||||
let is_default = *is_default;
|
||||
|
||||
let side = documentation_aside_side(cx);
|
||||
|
||||
DocumentationAside::new(
|
||||
side,
|
||||
Rc::new(move |_| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new(description.clone()))
|
||||
.child(HoldForDefault::new(is_default))
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn documentation_aside_index(&self) -> Option<usize> {
|
||||
self.selected_description.as_ref().map(|(ix, _, _)| *ix)
|
||||
}
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
if !self.selector.should_render_footer() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
fn info_list_to_picker_entries(
|
||||
model_list: AgentModelList,
|
||||
favorites: &HashSet<acp::ModelId>,
|
||||
) -> Vec<ModelPickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
let all_models: Vec<_> = match &model_list {
|
||||
AgentModelList::Flat(list) => list.iter().collect(),
|
||||
AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
|
||||
};
|
||||
|
||||
let favorite_models: Vec<_> = all_models
|
||||
.iter()
|
||||
.filter(|m| favorites.contains(&m.id))
|
||||
.unique_by(|m| &m.id)
|
||||
.collect();
|
||||
|
||||
let has_favorites = !favorite_models.is_empty();
|
||||
if has_favorites {
|
||||
entries.push(ModelPickerEntry::Separator("Favorite".into()));
|
||||
for model in favorite_models {
|
||||
entries.push(ModelPickerEntry::Model((*model).clone(), true));
|
||||
}
|
||||
}
|
||||
|
||||
match model_list {
|
||||
AgentModelList::Flat(list) => {
|
||||
if has_favorites {
|
||||
entries.push(ModelPickerEntry::Separator("All".into()));
|
||||
}
|
||||
for model in list {
|
||||
let is_favorite = favorites.contains(&model.id);
|
||||
entries.push(ModelPickerEntry::Model(model, is_favorite));
|
||||
}
|
||||
}
|
||||
AgentModelList::Grouped(index_map) => {
|
||||
for (group_name, models) in index_map {
|
||||
entries.push(ModelPickerEntry::Separator(group_name.0));
|
||||
for model in models {
|
||||
let is_favorite = favorites.contains(&model.id);
|
||||
entries.push(ModelPickerEntry::Model(model, is_favorite));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
async fn fuzzy_search(
|
||||
model_list: AgentModelList,
|
||||
query: String,
|
||||
executor: BackgroundExecutor,
|
||||
) -> AgentModelList {
|
||||
async fn fuzzy_search_list(
|
||||
model_list: Vec<AgentModelInfo>,
|
||||
query: &str,
|
||||
executor: BackgroundExecutor,
|
||||
) -> Vec<AgentModelInfo> {
|
||||
let candidates = model_list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut matches = match_strings(
|
||||
&candidates,
|
||||
query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
&Default::default(),
|
||||
executor,
|
||||
)
|
||||
.await;
|
||||
|
||||
matches.sort_unstable_by_key(|mat| {
|
||||
let candidate = &candidates[mat.candidate_id];
|
||||
(Reverse(OrderedFloat(mat.score)), candidate.id)
|
||||
});
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|mat| model_list[mat.candidate_id].clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
match model_list {
|
||||
AgentModelList::Flat(model_list) => {
|
||||
AgentModelList::Flat(fuzzy_search_list(model_list, &query, executor).await)
|
||||
}
|
||||
AgentModelList::Grouped(index_map) => {
|
||||
let groups =
|
||||
futures::future::join_all(index_map.into_iter().map(|(group_name, models)| {
|
||||
fuzzy_search_list(models, &query, executor.clone())
|
||||
.map(|results| (group_name, results))
|
||||
}))
|
||||
.await;
|
||||
AgentModelList::Grouped(IndexMap::from_iter(
|
||||
groups
|
||||
.into_iter()
|
||||
.filter(|(_, results)| !results.is_empty()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_model_list(grouped_models: Vec<(&str, Vec<&str>)>) -> AgentModelList {
|
||||
AgentModelList::Grouped(IndexMap::from_iter(grouped_models.into_iter().map(
|
||||
|(group, models)| {
|
||||
(
|
||||
acp_thread::AgentModelGroupName(group.to_string().into()),
|
||||
models
|
||||
.into_iter()
|
||||
.map(|model| acp_thread::AgentModelInfo {
|
||||
id: acp::ModelId::new(model.to_string()),
|
||||
name: model.to_string().into(),
|
||||
description: None,
|
||||
icon: None,
|
||||
is_latest: false,
|
||||
cost: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
fn assert_models_eq(result: AgentModelList, expected: Vec<(&str, Vec<&str>)>) {
|
||||
let AgentModelList::Grouped(groups) = result else {
|
||||
panic!("Expected LanguageModelInfoList::Grouped, got {:?}", result);
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
groups.len(),
|
||||
expected.len(),
|
||||
"Number of groups doesn't match"
|
||||
);
|
||||
|
||||
for (i, (expected_group, expected_models)) in expected.iter().enumerate() {
|
||||
let (actual_group, actual_models) = groups.get_index(i).unwrap();
|
||||
assert_eq!(
|
||||
actual_group.0.as_ref(),
|
||||
*expected_group,
|
||||
"Group at position {} doesn't match expected group",
|
||||
i
|
||||
);
|
||||
assert_eq!(
|
||||
actual_models.len(),
|
||||
expected_models.len(),
|
||||
"Number of models in group {} doesn't match",
|
||||
expected_group
|
||||
);
|
||||
|
||||
for (j, expected_model_name) in expected_models.iter().enumerate() {
|
||||
assert_eq!(
|
||||
actual_models[j].name, *expected_model_name,
|
||||
"Model at position {} in group {} doesn't match expected model",
|
||||
j, expected_group
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_favorites(models: Vec<&str>) -> HashSet<acp::ModelId> {
|
||||
models
|
||||
.into_iter()
|
||||
.map(|m| acp::ModelId::new(m.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_entry_model_ids(entries: &[ModelPickerEntry]) -> Vec<&str> {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
ModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_entry_labels(entries: &[ModelPickerEntry]) -> Vec<&str> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
ModelPickerEntry::Model(info, _) => info.id.0.as_ref(),
|
||||
ModelPickerEntry::Separator(s) => &s,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fuzzy_match(cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
(
|
||||
"zed",
|
||||
vec![
|
||||
"Claude 3.7 Sonnet",
|
||||
"Claude 3.7 Sonnet Thinking",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
],
|
||||
),
|
||||
("openai", vec!["gpt-3.5-turbo", "gpt-5", "gpt-5-mini"]),
|
||||
("ollama", vec!["mistral", "deepseek"]),
|
||||
]);
|
||||
|
||||
// Results should preserve models order whenever possible.
|
||||
// In the case below, `zed/gpt-5-mini` and `openai/gpt-5-mini` have identical
|
||||
// similarity scores, but `zed/gpt-5-mini` was higher in the models list,
|
||||
// so it should appear first in the results.
|
||||
let results = fuzzy_search(models.clone(), "mini".into(), cx.executor()).await;
|
||||
assert_models_eq(
|
||||
results,
|
||||
vec![("zed", vec!["gpt-5-mini"]), ("openai", vec!["gpt-5-mini"])],
|
||||
);
|
||||
|
||||
// Fuzzy search - test with specific model name
|
||||
let results = fuzzy_search(models.clone(), "mistral".into(), cx.executor()).await;
|
||||
assert_models_eq(results, vec![("ollama", vec!["mistral"])]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("zed", vec!["zed/claude", "zed/gemini"]),
|
||||
("openai", vec!["openai/gpt-5"]),
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(ModelPickerEntry::Separator(s)) if s == "Favorite"
|
||||
));
|
||||
|
||||
let model_ids = get_entry_model_ids(&entries);
|
||||
assert_eq!(model_ids[0], "zed/gemini");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
|
||||
let favorites = create_favorites(vec![]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(ModelPickerEntry::Separator(s)) if s == "zed"
|
||||
));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("zed", vec!["zed/claude", "zed/gemini"]),
|
||||
("openai", vec!["openai/gpt-5"]),
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
for entry in &entries {
|
||||
if let ModelPickerEntry::Model(info, is_favorite) = entry {
|
||||
if info.id.0.as_ref() == "zed/claude" {
|
||||
assert!(is_favorite, "zed/claude should be a favorite");
|
||||
} else {
|
||||
assert!(!is_favorite, "{} should not be a favorite", info.id.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("zed", vec!["zed/claude", "zed/gemini"]),
|
||||
("openai", vec!["openai/gpt-5", "openai/gpt-4"]),
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
let model_ids = get_entry_model_ids(&entries);
|
||||
|
||||
assert_eq!(model_ids[0], "zed/gemini");
|
||||
assert_eq!(model_ids[1], "openai/gpt-5");
|
||||
|
||||
assert!(model_ids[2..].contains(&"zed/gemini"));
|
||||
assert!(model_ids[2..].contains(&"openai/gpt-5"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("Recommended", vec!["zed/claude", "anthropic/claude"]),
|
||||
("Zed", vec!["zed/claude", "zed/gpt-5"]),
|
||||
("Antropic", vec!["anthropic/claude"]),
|
||||
("OpenAI", vec!["openai/gpt-5"]),
|
||||
]);
|
||||
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
let labels = get_entry_labels(&entries);
|
||||
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"Favorite",
|
||||
"zed/claude",
|
||||
"Recommended",
|
||||
"zed/claude",
|
||||
"anthropic/claude",
|
||||
"Zed",
|
||||
"zed/claude",
|
||||
"zed/gpt-5",
|
||||
"Antropic",
|
||||
"anthropic/claude",
|
||||
"OpenAI",
|
||||
"openai/gpt-5"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) {
|
||||
let models = AgentModelList::Flat(vec![
|
||||
acp_thread::AgentModelInfo {
|
||||
id: acp::ModelId::new("zed/claude".to_string()),
|
||||
name: "Claude".into(),
|
||||
description: None,
|
||||
icon: None,
|
||||
is_latest: false,
|
||||
cost: None,
|
||||
},
|
||||
acp_thread::AgentModelInfo {
|
||||
id: acp::ModelId::new("zed/gemini".to_string()),
|
||||
name: "Gemini".into(),
|
||||
description: None,
|
||||
icon: None,
|
||||
is_latest: false,
|
||||
cost: None,
|
||||
},
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(ModelPickerEntry::Separator(s)) if s == "Favorite"
|
||||
));
|
||||
|
||||
assert!(entries.iter().any(|e| matches!(
|
||||
e,
|
||||
ModelPickerEntry::Separator(s) if s == "All"
|
||||
)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_count_returns_correct_count(_cx: &mut TestAppContext) {
|
||||
let empty_favorites: HashSet<acp::ModelId> = HashSet::default();
|
||||
assert_eq!(empty_favorites.len(), 0);
|
||||
|
||||
let one_favorite = create_favorites(vec!["model-a"]);
|
||||
assert_eq!(one_favorite.len(), 1);
|
||||
|
||||
let multiple_favorites = create_favorites(vec!["model-a", "model-b", "model-c"]);
|
||||
assert_eq!(multiple_favorites.len(), 3);
|
||||
|
||||
let with_duplicates = create_favorites(vec!["model-a", "model-a", "model-b"]);
|
||||
assert_eq!(with_duplicates.len(), 2);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_is_favorite_flag_set_correctly_in_entries(_cx: &mut TestAppContext) {
|
||||
let models = AgentModelList::Flat(vec![
|
||||
acp_thread::AgentModelInfo {
|
||||
id: acp::ModelId::new("favorite-model".to_string()),
|
||||
name: "Favorite".into(),
|
||||
description: None,
|
||||
icon: None,
|
||||
is_latest: false,
|
||||
cost: None,
|
||||
},
|
||||
acp_thread::AgentModelInfo {
|
||||
id: acp::ModelId::new("regular-model".to_string()),
|
||||
name: "Regular".into(),
|
||||
description: None,
|
||||
icon: None,
|
||||
is_latest: false,
|
||||
cost: None,
|
||||
},
|
||||
]);
|
||||
let favorites = create_favorites(vec!["favorite-model"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
for entry in &entries {
|
||||
if let ModelPickerEntry::Model(info, is_favorite) = entry {
|
||||
if info.id.0.as_ref() == "favorite-model" {
|
||||
assert!(*is_favorite, "favorite-model should have is_favorite=true");
|
||||
} else if info.id.0.as_ref() == "regular-model" {
|
||||
assert!(!*is_favorite, "regular-model should have is_favorite=false");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector};
|
||||
use fs::Fs;
|
||||
use gpui::{Entity, FocusHandle};
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use ui::{PopoverMenuHandle, Tooltip, prelude::*};
|
||||
|
||||
use crate::ui::ModelSelectorTooltip;
|
||||
use crate::{ModelSelector, model_selector::acp_model_selector};
|
||||
|
||||
pub struct ModelSelectorPopover {
|
||||
selector: Entity<ModelSelector>,
|
||||
menu_handle: PopoverMenuHandle<ModelSelector>,
|
||||
}
|
||||
|
||||
impl ModelSelectorPopover {
|
||||
pub(crate) fn new(
|
||||
selector: Rc<dyn AgentModelSelector>,
|
||||
agent_server: Rc<dyn agent_servers::AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
menu_handle: PopoverMenuHandle<ModelSelector>,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
selector: cx.new(move |cx| {
|
||||
acp_model_selector(selector, agent_server, fs, focus_handle.clone(), window, cx)
|
||||
}),
|
||||
menu_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.menu_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> {
|
||||
self.selector.read(cx).delegate.active_model()
|
||||
}
|
||||
|
||||
pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selector.update(cx, |selector, cx| {
|
||||
selector.delegate.cycle_favorite_models(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ModelSelectorPopover {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let selector = self.selector.read(cx);
|
||||
let model = selector.delegate.active_model();
|
||||
let model_name = model
|
||||
.as_ref()
|
||||
.map(|model| model.name.clone())
|
||||
.unwrap_or_else(|| SharedString::from("Select a Model"));
|
||||
|
||||
let model_icon = model.as_ref().and_then(|model| model.icon.clone());
|
||||
|
||||
let (color, icon) = if self.menu_handle.is_deployed() {
|
||||
(Color::Accent, IconName::ChevronUp)
|
||||
} else {
|
||||
(Color::Muted, IconName::ChevronDown)
|
||||
};
|
||||
|
||||
let show_cycle_row = selector.delegate.favorites_count() > 1;
|
||||
|
||||
let tooltip = Tooltip::element({
|
||||
move |_, _cx| {
|
||||
ModelSelectorTooltip::new()
|
||||
.show_cycle_row(show_cycle_row)
|
||||
.into_any_element()
|
||||
}
|
||||
});
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.selector.clone(),
|
||||
Button::new("active-model", model_name)
|
||||
.label_size(LabelSize::Small)
|
||||
.color(color)
|
||||
.when_some(model_icon, |this, icon| {
|
||||
this.start_icon(
|
||||
match icon {
|
||||
AgentModelIcon::Path(path) => Icon::from_external_svg(path),
|
||||
AgentModelIcon::Named(icon_name) => Icon::new(icon_name),
|
||||
}
|
||||
.color(color)
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
})
|
||||
.end_icon(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)),
|
||||
tooltip,
|
||||
gpui::Anchor::BottomRight,
|
||||
cx,
|
||||
)
|
||||
.with_handle(self.menu_handle.clone())
|
||||
.render(window, cx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
use crate::{
|
||||
CycleModeSelector, ManageProfiles, ToggleProfileSelector, ui::documentation_aside_side,
|
||||
};
|
||||
use agent_settings::{
|
||||
AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles,
|
||||
};
|
||||
use fs::Fs;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, AnyElement, AnyView, App, BackgroundExecutor, Context, DismissEvent, Empty, Entity,
|
||||
FocusHandle, Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu};
|
||||
use settings::{Settings as _, SettingsStore, update_settings_file};
|
||||
use std::{
|
||||
sync::atomic::Ordering,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
};
|
||||
use ui::{
|
||||
DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing,
|
||||
PopoverMenuHandle, Tooltip, prelude::*,
|
||||
};
|
||||
|
||||
/// Trait for types that can provide and manage agent profiles
|
||||
pub trait ProfileProvider {
|
||||
/// Get the current profile ID
|
||||
fn profile_id(&self, cx: &App) -> AgentProfileId;
|
||||
|
||||
/// Set the profile ID
|
||||
fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App);
|
||||
|
||||
/// Check if profiles are supported in the current context (e.g. if the model that is selected has tool support)
|
||||
fn profiles_supported(&self, cx: &App) -> bool;
|
||||
|
||||
/// Check if there is a model selected in the current context.
|
||||
fn model_selected(&self, cx: &App) -> bool;
|
||||
}
|
||||
|
||||
pub struct ProfileSelector {
|
||||
profiles: AvailableProfiles,
|
||||
pending_refresh: bool,
|
||||
fs: Arc<dyn Fs>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
picker: Option<Entity<Picker<ProfilePickerDelegate>>>,
|
||||
picker_handle: PopoverMenuHandle<Picker<ProfilePickerDelegate>>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl ProfileSelector {
|
||||
pub fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
focus_handle: FocusHandle,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let settings_subscription = cx.observe_global::<SettingsStore>(move |this, cx| {
|
||||
this.pending_refresh = true;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
profiles: AgentProfile::available_profiles(cx),
|
||||
pending_refresh: false,
|
||||
fs,
|
||||
provider,
|
||||
picker: None,
|
||||
picker_handle: PopoverMenuHandle::default(),
|
||||
focus_handle,
|
||||
_subscriptions: vec![settings_subscription],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_handle(&self) -> PopoverMenuHandle<Picker<ProfilePickerDelegate>> {
|
||||
self.picker_handle.clone()
|
||||
}
|
||||
|
||||
pub fn cycle_profile(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.provider.profiles_supported(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let profiles = AgentProfile::available_profiles(cx);
|
||||
if profiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_profile_id = self.provider.profile_id(cx);
|
||||
let current_index = profiles
|
||||
.keys()
|
||||
.position(|id| id == ¤t_profile_id)
|
||||
.unwrap_or(0);
|
||||
|
||||
let next_index = (current_index + 1) % profiles.len();
|
||||
|
||||
if let Some((next_profile_id, _)) = profiles.get_index(next_index) {
|
||||
self.provider.set_profile(next_profile_id.clone(), cx);
|
||||
telemetry::event!(
|
||||
"Agent Profile Switched",
|
||||
profile_id = next_profile_id.as_str(),
|
||||
source = "cycle"
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_picker(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<Picker<ProfilePickerDelegate>> {
|
||||
if self.picker.is_none() {
|
||||
let delegate = ProfilePickerDelegate::new(
|
||||
self.fs.clone(),
|
||||
self.provider.clone(),
|
||||
self.profiles.clone(),
|
||||
cx.foreground_executor().clone(),
|
||||
cx.background_executor().clone(),
|
||||
self.focus_handle.clone(),
|
||||
cx,
|
||||
);
|
||||
|
||||
let picker = cx.new(|cx| {
|
||||
Picker::list(delegate, window, cx)
|
||||
.show_scrollbar(true)
|
||||
.width(rems(18.))
|
||||
.max_height(Some(rems(20.).into()))
|
||||
});
|
||||
|
||||
self.picker = Some(picker);
|
||||
}
|
||||
|
||||
if self.pending_refresh {
|
||||
if let Some(picker) = &self.picker {
|
||||
let profiles = AgentProfile::available_profiles(cx);
|
||||
self.profiles = profiles.clone();
|
||||
picker.update(cx, |picker, cx| {
|
||||
let query = picker.query(cx);
|
||||
picker
|
||||
.delegate
|
||||
.refresh_profiles(profiles.clone(), query, cx);
|
||||
});
|
||||
}
|
||||
self.pending_refresh = false;
|
||||
}
|
||||
|
||||
self.picker.as_ref().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for ProfileSelector {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
if let Some(picker) = &self.picker {
|
||||
picker.focus_handle(cx)
|
||||
} else {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfileSelector {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.provider.model_selected(cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
if !self.provider.profiles_supported(cx) {
|
||||
return Button::new("tools-not-supported-button", "Tools Unsupported")
|
||||
.disabled(true)
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.tooltip(Tooltip::text("This model does not support tools."))
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let picker = self.ensure_picker(window, cx);
|
||||
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let profile_id = self.provider.profile_id(cx);
|
||||
let profile = settings.profiles.get(&profile_id);
|
||||
|
||||
let selected_profile = profile
|
||||
.map(|profile| profile.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
|
||||
let icon = if self.picker_handle.is_deployed() {
|
||||
IconName::ChevronUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
};
|
||||
|
||||
let trigger_button = Button::new("profile-selector", selected_profile)
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
|
||||
|
||||
let tooltip: Box<dyn Fn(&mut Window, &mut App) -> AnyView> = Box::new(Tooltip::element({
|
||||
move |_window, cx| {
|
||||
let container = || h_flex().gap_1().justify_between();
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
container()
|
||||
.child(Label::new("Change Profile"))
|
||||
.child(KeyBinding::for_action(&ToggleProfileSelector, cx)),
|
||||
)
|
||||
.child(
|
||||
container()
|
||||
.pt_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(Label::new("Cycle Through Profiles"))
|
||||
.child(KeyBinding::for_action(&CycleModeSelector, cx)),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
}));
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
picker,
|
||||
trigger_button,
|
||||
tooltip,
|
||||
gpui::Anchor::BottomRight,
|
||||
cx,
|
||||
)
|
||||
.with_handle(self.picker_handle.clone())
|
||||
.render(window, cx)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProfileCandidate {
|
||||
id: AgentProfileId,
|
||||
name: SharedString,
|
||||
is_builtin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProfileMatchEntry {
|
||||
candidate_index: usize,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
enum ProfilePickerEntry {
|
||||
Header(SharedString),
|
||||
Profile(ProfileMatchEntry),
|
||||
}
|
||||
|
||||
pub struct ProfilePickerDelegate {
|
||||
fs: Arc<dyn Fs>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
foreground: ForegroundExecutor,
|
||||
background: BackgroundExecutor,
|
||||
candidates: Vec<ProfileCandidate>,
|
||||
string_candidates: Arc<Vec<StringMatchCandidate>>,
|
||||
filtered_entries: Vec<ProfilePickerEntry>,
|
||||
selected_index: usize,
|
||||
hovered_index: Option<usize>,
|
||||
query: String,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl ProfilePickerDelegate {
|
||||
fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
profiles: AvailableProfiles,
|
||||
foreground: ForegroundExecutor,
|
||||
background: BackgroundExecutor,
|
||||
focus_handle: FocusHandle,
|
||||
cx: &mut Context<ProfileSelector>,
|
||||
) -> Self {
|
||||
let candidates = Self::candidates_from(profiles);
|
||||
let string_candidates = Arc::new(Self::string_candidates(&candidates));
|
||||
let filtered_entries = Self::entries_from_candidates(&candidates);
|
||||
|
||||
let mut this = Self {
|
||||
fs,
|
||||
provider,
|
||||
foreground,
|
||||
background,
|
||||
candidates,
|
||||
string_candidates,
|
||||
filtered_entries,
|
||||
selected_index: 0,
|
||||
hovered_index: None,
|
||||
query: String::new(),
|
||||
cancel: None,
|
||||
focus_handle,
|
||||
};
|
||||
|
||||
this.selected_index = this
|
||||
.index_of_profile(&this.provider.profile_id(cx))
|
||||
.unwrap_or_else(|| this.first_selectable_index().unwrap_or(0));
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
fn refresh_profiles(
|
||||
&mut self,
|
||||
profiles: AvailableProfiles,
|
||||
query: String,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) {
|
||||
self.candidates = Self::candidates_from(profiles);
|
||||
self.string_candidates = Arc::new(Self::string_candidates(&self.candidates));
|
||||
self.query = query;
|
||||
|
||||
if self.query.is_empty() {
|
||||
self.filtered_entries = Self::entries_from_candidates(&self.candidates);
|
||||
} else {
|
||||
let matches = self.search_blocking(&self.query);
|
||||
self.filtered_entries = self.entries_from_matches(matches);
|
||||
}
|
||||
|
||||
self.selected_index = self
|
||||
.index_of_profile(&self.provider.profile_id(cx))
|
||||
.unwrap_or_else(|| self.first_selectable_index().unwrap_or(0));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn candidates_from(profiles: AvailableProfiles) -> Vec<ProfileCandidate> {
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|(id, name)| ProfileCandidate {
|
||||
is_builtin: builtin_profiles::is_builtin(&id),
|
||||
id,
|
||||
name,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn string_candidates(candidates: &[ProfileCandidate]) -> Vec<StringMatchCandidate> {
|
||||
candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, candidate)| StringMatchCandidate::new(index, candidate.name.as_ref()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> {
|
||||
match candidate.id.as_str() {
|
||||
builtin_profiles::WRITE => Some("Get help to write anything."),
|
||||
builtin_profiles::ASK => Some("Chat about your codebase."),
|
||||
builtin_profiles::MINIMAL => Some("Chat about anything with no tools."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn entries_from_candidates(candidates: &[ProfileCandidate]) -> Vec<ProfilePickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut inserted_custom_header = false;
|
||||
|
||||
for (idx, candidate) in candidates.iter().enumerate() {
|
||||
if !candidate.is_builtin && !inserted_custom_header {
|
||||
if !entries.is_empty() {
|
||||
entries.push(ProfilePickerEntry::Header("Custom Profiles".into()));
|
||||
}
|
||||
inserted_custom_header = true;
|
||||
}
|
||||
|
||||
entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry {
|
||||
candidate_index: idx,
|
||||
positions: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn entries_from_matches(&self, matches: Vec<StringMatch>) -> Vec<ProfilePickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
for mat in matches {
|
||||
if self.candidates.get(mat.candidate_id).is_some() {
|
||||
entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry {
|
||||
candidate_index: mat.candidate_id,
|
||||
positions: mat.positions,
|
||||
}));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn first_selectable_index(&self) -> Option<usize> {
|
||||
self.filtered_entries
|
||||
.iter()
|
||||
.position(|entry| matches!(entry, ProfilePickerEntry::Profile(_)))
|
||||
}
|
||||
|
||||
fn index_of_profile(&self, profile_id: &AgentProfileId) -> Option<usize> {
|
||||
self.filtered_entries.iter().position(|entry| {
|
||||
matches!(entry, ProfilePickerEntry::Profile(profile) if self
|
||||
.candidates
|
||||
.get(profile.candidate_index)
|
||||
.map(|candidate| &candidate.id == profile_id)
|
||||
.unwrap_or(false))
|
||||
})
|
||||
}
|
||||
|
||||
fn search_blocking(&self, query: &str) -> Vec<StringMatch> {
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.string_candidates
|
||||
.iter()
|
||||
.map(|candidate| StringMatch {
|
||||
candidate_id: candidate.id,
|
||||
score: 0.0,
|
||||
positions: Vec::new(),
|
||||
string: candidate.string.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
|
||||
self.foreground.block_on(match_strings(
|
||||
self.string_candidates.as_ref(),
|
||||
query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
&cancel_flag,
|
||||
self.background.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for ProfilePickerDelegate {
|
||||
type ListItem = AnyElement;
|
||||
|
||||
fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc<str> {
|
||||
"Search profiles…".into()
|
||||
}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
let text = if self.candidates.is_empty() {
|
||||
"No profiles.".into()
|
||||
} else {
|
||||
"No profiles match your search.".into()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.filtered_entries.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
|
||||
match self.filtered_entries.get(ix) {
|
||||
Some(ProfilePickerEntry::Profile(_)) => true,
|
||||
Some(ProfilePickerEntry::Header(_)) | None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
if query.is_empty() {
|
||||
self.query.clear();
|
||||
self.filtered_entries = Self::entries_from_candidates(&self.candidates);
|
||||
self.selected_index = self
|
||||
.index_of_profile(&self.provider.profile_id(cx))
|
||||
.unwrap_or_else(|| self.first_selectable_index().unwrap_or(0));
|
||||
cx.notify();
|
||||
return Task::ready(());
|
||||
}
|
||||
|
||||
if let Some(prev) = &self.cancel {
|
||||
prev.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
self.cancel = Some(cancel.clone());
|
||||
|
||||
let string_candidates = self.string_candidates.clone();
|
||||
let background = self.background.clone();
|
||||
let provider = self.provider.clone();
|
||||
self.query = query.clone();
|
||||
|
||||
let cancel_for_future = cancel;
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let matches = match_strings(
|
||||
string_candidates.as_ref(),
|
||||
&query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
cancel_for_future.as_ref(),
|
||||
background,
|
||||
)
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, _, cx| {
|
||||
if this.delegate.query != query {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delegate.filtered_entries = this.delegate.entries_from_matches(matches);
|
||||
this.delegate.selected_index = this
|
||||
.delegate
|
||||
.index_of_profile(&provider.profile_id(cx))
|
||||
.unwrap_or_else(|| this.delegate.first_selectable_index().unwrap_or(0));
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
match self.filtered_entries.get(self.selected_index) {
|
||||
Some(ProfilePickerEntry::Profile(entry)) => {
|
||||
if let Some(candidate) = self.candidates.get(entry.candidate_index) {
|
||||
let profile_id = candidate.id.clone();
|
||||
let fs = self.fs.clone();
|
||||
let provider = self.provider.clone();
|
||||
|
||||
update_settings_file(fs, cx, {
|
||||
let profile_id = profile_id.clone();
|
||||
move |settings, _cx| {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_profile(profile_id.0);
|
||||
}
|
||||
});
|
||||
|
||||
provider.set_profile(profile_id.clone(), cx);
|
||||
|
||||
telemetry::event!(
|
||||
"agent_profile_switched",
|
||||
profile_id = profile_id.as_str(),
|
||||
source = "picker"
|
||||
);
|
||||
}
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
cx.defer_in(window, |picker, window, cx| {
|
||||
picker.set_query("", window, cx);
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered_entries.get(ix)? {
|
||||
ProfilePickerEntry::Header(label) => Some(
|
||||
div()
|
||||
.px_2p5()
|
||||
.pb_0p5()
|
||||
.when(ix > 0, |this| {
|
||||
this.mt_1p5()
|
||||
.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.child(
|
||||
Label::new(label.clone())
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
ProfilePickerEntry::Profile(entry) => {
|
||||
let candidate = self.candidates.get(entry.candidate_index)?;
|
||||
let active_id = self.provider.profile_id(cx);
|
||||
let is_active = active_id == candidate.id;
|
||||
let has_documentation = Self::documentation(candidate).is_some();
|
||||
|
||||
Some(
|
||||
div()
|
||||
.id(("profile-picker-item", ix))
|
||||
.when(has_documentation, |this| {
|
||||
this.on_hover(cx.listener(move |picker, hovered, _, cx| {
|
||||
if *hovered {
|
||||
picker.delegate.hovered_index = Some(ix);
|
||||
} else if picker.delegate.hovered_index == Some(ix) {
|
||||
picker.delegate.hovered_index = None;
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
ListItem::new(candidate.id.0.clone())
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(HighlightedLabel::new(
|
||||
candidate.name.clone(),
|
||||
entry.positions.clone(),
|
||||
))
|
||||
.when(is_active, |this| {
|
||||
this.end_slot(
|
||||
div()
|
||||
.pr_2()
|
||||
.child(Icon::new(IconName::Check).color(Color::Accent)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn documentation_aside(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<DocumentationAside> {
|
||||
use std::rc::Rc;
|
||||
|
||||
let hovered_index = self.hovered_index?;
|
||||
let entry = match self.filtered_entries.get(hovered_index)? {
|
||||
ProfilePickerEntry::Profile(entry) => entry,
|
||||
ProfilePickerEntry::Header(_) => return None,
|
||||
};
|
||||
|
||||
let candidate = self.candidates.get(entry.candidate_index)?;
|
||||
let docs_aside = Self::documentation(candidate)?.to_string();
|
||||
|
||||
let side = documentation_aside_side(cx);
|
||||
|
||||
Some(DocumentationAside {
|
||||
side,
|
||||
render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()),
|
||||
})
|
||||
}
|
||||
|
||||
fn documentation_aside_index(&self) -> Option<usize> {
|
||||
self.hovered_index
|
||||
}
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.p_1p5()
|
||||
.child(
|
||||
Button::new("configure", "Configure")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&ManageProfiles::default(),
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(ManageProfiles::default().boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.into_any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fs::FakeFs;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
fn entries_include_custom_profiles(_cx: &mut TestAppContext) {
|
||||
let candidates = vec![
|
||||
ProfileCandidate {
|
||||
id: AgentProfileId("write".into()),
|
||||
name: SharedString::from("Write"),
|
||||
is_builtin: true,
|
||||
},
|
||||
ProfileCandidate {
|
||||
id: AgentProfileId("my-custom".into()),
|
||||
name: SharedString::from("My Custom"),
|
||||
is_builtin: false,
|
||||
},
|
||||
];
|
||||
|
||||
let entries = ProfilePickerDelegate::entries_from_candidates(&candidates);
|
||||
|
||||
assert!(entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
ProfilePickerEntry::Profile(profile)
|
||||
if candidates[profile.candidate_index].id.as_str() == "my-custom"
|
||||
)));
|
||||
assert!(entries.iter().any(|entry| matches!(
|
||||
entry,
|
||||
ProfilePickerEntry::Header(label) if label.as_ref() == "Custom Profiles"
|
||||
)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn fuzzy_filter_returns_no_results_and_keeps_configure(cx: &mut TestAppContext) {
|
||||
let candidates = vec![ProfileCandidate {
|
||||
id: AgentProfileId("write".into()),
|
||||
name: SharedString::from("Write"),
|
||||
is_builtin: true,
|
||||
}];
|
||||
|
||||
cx.update(|cx| {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
let delegate = ProfilePickerDelegate {
|
||||
fs: FakeFs::new(cx.background_executor().clone()),
|
||||
provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))),
|
||||
foreground: cx.foreground_executor().clone(),
|
||||
background: cx.background_executor().clone(),
|
||||
candidates,
|
||||
string_candidates: Arc::new(Vec::new()),
|
||||
filtered_entries: Vec::new(),
|
||||
selected_index: 0,
|
||||
hovered_index: None,
|
||||
query: String::new(),
|
||||
cancel: None,
|
||||
focus_handle,
|
||||
};
|
||||
|
||||
let matches = Vec::new(); // No matches
|
||||
let _entries = delegate.entries_from_matches(matches);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn active_profile_selection_logic_works(cx: &mut TestAppContext) {
|
||||
let candidates = vec![
|
||||
ProfileCandidate {
|
||||
id: AgentProfileId("write".into()),
|
||||
name: SharedString::from("Write"),
|
||||
is_builtin: true,
|
||||
},
|
||||
ProfileCandidate {
|
||||
id: AgentProfileId("ask".into()),
|
||||
name: SharedString::from("Ask"),
|
||||
is_builtin: true,
|
||||
},
|
||||
];
|
||||
|
||||
cx.update(|cx| {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
let delegate = ProfilePickerDelegate {
|
||||
fs: FakeFs::new(cx.background_executor().clone()),
|
||||
provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))),
|
||||
foreground: cx.foreground_executor().clone(),
|
||||
background: cx.background_executor().clone(),
|
||||
candidates,
|
||||
string_candidates: Arc::new(Vec::new()),
|
||||
hovered_index: None,
|
||||
filtered_entries: vec![
|
||||
ProfilePickerEntry::Profile(ProfileMatchEntry {
|
||||
candidate_index: 0,
|
||||
positions: Vec::new(),
|
||||
}),
|
||||
ProfilePickerEntry::Profile(ProfileMatchEntry {
|
||||
candidate_index: 1,
|
||||
positions: Vec::new(),
|
||||
}),
|
||||
],
|
||||
selected_index: 0,
|
||||
query: String::new(),
|
||||
cancel: None,
|
||||
focus_handle,
|
||||
};
|
||||
|
||||
// Active profile should be found at index 0
|
||||
let active_index = delegate.index_of_profile(&AgentProfileId("write".into()));
|
||||
assert_eq!(active_index, Some(0));
|
||||
});
|
||||
}
|
||||
|
||||
struct TestProfileProvider {
|
||||
profile_id: AgentProfileId,
|
||||
has_model: bool,
|
||||
}
|
||||
|
||||
impl TestProfileProvider {
|
||||
fn new(profile_id: AgentProfileId) -> Self {
|
||||
Self {
|
||||
profile_id,
|
||||
has_model: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileProvider for TestProfileProvider {
|
||||
fn profile_id(&self, _cx: &App) -> AgentProfileId {
|
||||
self.profile_id.clone()
|
||||
}
|
||||
|
||||
fn set_profile(&self, _profile_id: AgentProfileId, _cx: &mut App) {}
|
||||
|
||||
fn profiles_supported(&self, _cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn model_selected(&self, _cx: &App) -> bool {
|
||||
self.has_model
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use crate::inline_prompt_editor::CodegenStatus;
|
||||
use futures::{SinkExt, StreamExt, channel::mpsc};
|
||||
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task};
|
||||
use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelRequest};
|
||||
use language_models::provider::anthropic::telemetry::{
|
||||
AnthropicCompletionType, AnthropicEventData, AnthropicEventReporter, AnthropicEventType,
|
||||
};
|
||||
use std::time::Instant;
|
||||
use terminal::Terminal;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct TerminalCodegen {
|
||||
pub status: CodegenStatus,
|
||||
terminal: Entity<Terminal>,
|
||||
generation: Task<()>,
|
||||
pub message_id: Option<String>,
|
||||
transaction: Option<TerminalTransaction>,
|
||||
session_id: Uuid,
|
||||
}
|
||||
|
||||
impl EventEmitter<CodegenEvent> for TerminalCodegen {}
|
||||
|
||||
impl TerminalCodegen {
|
||||
pub fn new(terminal: Entity<Terminal>, session_id: Uuid) -> Self {
|
||||
Self {
|
||||
terminal,
|
||||
status: CodegenStatus::Idle,
|
||||
generation: Task::ready(()),
|
||||
message_id: None,
|
||||
transaction: None,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> Uuid {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
pub fn start(&mut self, prompt_task: Task<LanguageModelRequest>, cx: &mut Context<Self>) {
|
||||
let Some(ConfiguredModel { model, .. }) =
|
||||
LanguageModelRegistry::read_global(cx).inline_assistant_model()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let anthropic_reporter = AnthropicEventReporter::new(&model, cx);
|
||||
let session_id = self.session_id;
|
||||
let model_telemetry_id = model.telemetry_id();
|
||||
let model_provider_id = model.provider_id().to_string();
|
||||
|
||||
self.status = CodegenStatus::Pending;
|
||||
self.transaction = Some(TerminalTransaction::start(self.terminal.clone()));
|
||||
self.generation = cx.spawn(async move |this, cx| {
|
||||
let prompt = prompt_task.await;
|
||||
let response = model.stream_completion_text(prompt, cx).await;
|
||||
let generate = async {
|
||||
let message_id = response
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|response| response.message_id.clone());
|
||||
|
||||
let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1);
|
||||
|
||||
let task = cx.background_spawn({
|
||||
let message_id = message_id.clone();
|
||||
let anthropic_reporter = anthropic_reporter.clone();
|
||||
async move {
|
||||
let mut response_latency = None;
|
||||
let request_start = Instant::now();
|
||||
let task = async {
|
||||
let mut chunks = response?.stream;
|
||||
while let Some(chunk) = chunks.next().await {
|
||||
if response_latency.is_none() {
|
||||
response_latency = Some(request_start.elapsed());
|
||||
}
|
||||
let chunk = chunk?;
|
||||
hunks_tx.send(chunk).await?;
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
};
|
||||
|
||||
let result = task.await;
|
||||
|
||||
let error_message = result.as_ref().err().map(|error| error.to_string());
|
||||
|
||||
telemetry::event!(
|
||||
"Assistant Responded",
|
||||
session_id = session_id.to_string(),
|
||||
kind = "inline_terminal",
|
||||
phase = "response",
|
||||
model = model_telemetry_id,
|
||||
model_provider = model_provider_id,
|
||||
language_name = Option::<&str>::None,
|
||||
message_id = message_id,
|
||||
response_latency = response_latency,
|
||||
error_message = error_message,
|
||||
);
|
||||
|
||||
anthropic_reporter.report(AnthropicEventData {
|
||||
completion_type: AnthropicCompletionType::Terminal,
|
||||
event: AnthropicEventType::Response,
|
||||
language_name: None,
|
||||
message_id,
|
||||
});
|
||||
|
||||
result?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
this.update(cx, |this, _| {
|
||||
this.message_id = message_id;
|
||||
})?;
|
||||
|
||||
while let Some(hunk) = hunks_rx.next().await {
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some(transaction) = &mut this.transaction {
|
||||
transaction.push(hunk, cx);
|
||||
cx.notify();
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
task.await?;
|
||||
anyhow::Ok(())
|
||||
};
|
||||
|
||||
let result = generate.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if let Err(error) = result {
|
||||
this.status = CodegenStatus::Error(error);
|
||||
} else {
|
||||
this.status = CodegenStatus::Done;
|
||||
}
|
||||
cx.emit(CodegenEvent::Finished);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn completion(&self) -> Option<String> {
|
||||
self.transaction
|
||||
.as_ref()
|
||||
.map(|transaction| transaction.completion.clone())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, cx: &mut Context<Self>) {
|
||||
self.status = CodegenStatus::Done;
|
||||
self.generation = Task::ready(());
|
||||
cx.emit(CodegenEvent::Finished);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn complete(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(transaction) = self.transaction.take() {
|
||||
transaction.complete(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(transaction) = self.transaction.take() {
|
||||
transaction.undo(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum CodegenEvent {
|
||||
Finished,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub const CLEAR_INPUT: &str = "\x15";
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const CLEAR_INPUT: &str = "\x03";
|
||||
const CARRIAGE_RETURN: &str = "\x0d";
|
||||
|
||||
struct TerminalTransaction {
|
||||
completion: String,
|
||||
terminal: Entity<Terminal>,
|
||||
}
|
||||
|
||||
impl TerminalTransaction {
|
||||
pub fn start(terminal: Entity<Terminal>) -> Self {
|
||||
Self {
|
||||
completion: String::new(),
|
||||
terminal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, hunk: String, cx: &mut App) {
|
||||
// Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal
|
||||
let input = Self::sanitize_input(hunk);
|
||||
self.completion.push_str(&input);
|
||||
self.terminal
|
||||
.update(cx, |terminal, _| terminal.input(input.into_bytes()));
|
||||
}
|
||||
|
||||
pub fn undo(self, cx: &mut App) {
|
||||
self.terminal
|
||||
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
|
||||
}
|
||||
|
||||
pub fn complete(self, cx: &mut App) {
|
||||
self.terminal
|
||||
.update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes()));
|
||||
}
|
||||
|
||||
fn sanitize_input(mut input: String) -> String {
|
||||
input.retain(|c| c != '\r' && c != '\n');
|
||||
input
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
use crate::{
|
||||
context::load_context,
|
||||
inline_prompt_editor::{
|
||||
CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId,
|
||||
},
|
||||
terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen},
|
||||
};
|
||||
use agent::ThreadStore;
|
||||
use agent_settings::AgentSettings;
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use collections::{HashMap, VecDeque};
|
||||
use editor::{MultiBuffer, actions::SelectAll};
|
||||
use fs::Fs;
|
||||
use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, WeakEntity};
|
||||
use language::Buffer;
|
||||
use language_model::{
|
||||
CompletionIntent, ConfiguredModel, LanguageModelRegistry, LanguageModelRequest,
|
||||
LanguageModelRequestMessage, Role,
|
||||
};
|
||||
use language_models::provider::anthropic::telemetry::{
|
||||
AnthropicCompletionType, AnthropicEventData, AnthropicEventType, report_anthropic_event,
|
||||
};
|
||||
use project::Project;
|
||||
use prompt_store::{PromptBuilder, PromptStore};
|
||||
use std::sync::Arc;
|
||||
use terminal_view::TerminalView;
|
||||
use ui::prelude::*;
|
||||
use util::ResultExt;
|
||||
use uuid::Uuid;
|
||||
use workspace::{Toast, Workspace, notifications::NotificationId};
|
||||
|
||||
pub fn init(fs: Arc<dyn Fs>, prompt_builder: Arc<PromptBuilder>, cx: &mut App) {
|
||||
cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder));
|
||||
}
|
||||
|
||||
const DEFAULT_CONTEXT_LINES: usize = 50;
|
||||
const PROMPT_HISTORY_MAX_LEN: usize = 20;
|
||||
|
||||
pub struct TerminalInlineAssistant {
|
||||
next_assist_id: TerminalInlineAssistId,
|
||||
assists: HashMap<TerminalInlineAssistId, TerminalInlineAssist>,
|
||||
prompt_history: VecDeque<String>,
|
||||
fs: Arc<dyn Fs>,
|
||||
prompt_builder: Arc<PromptBuilder>,
|
||||
}
|
||||
|
||||
impl Global for TerminalInlineAssistant {}
|
||||
|
||||
impl TerminalInlineAssistant {
|
||||
pub fn new(fs: Arc<dyn Fs>, prompt_builder: Arc<PromptBuilder>) -> Self {
|
||||
Self {
|
||||
next_assist_id: TerminalInlineAssistId::default(),
|
||||
assists: HashMap::default(),
|
||||
prompt_history: VecDeque::default(),
|
||||
fs,
|
||||
prompt_builder,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assist(
|
||||
&mut self,
|
||||
terminal_view: &Entity<TerminalView>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
initial_prompt: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let terminal = terminal_view.read(cx).terminal().clone();
|
||||
let assist_id = self.next_assist_id.post_inc();
|
||||
let session_id = Uuid::new_v4();
|
||||
let prompt_buffer = cx.new(|cx| {
|
||||
MultiBuffer::singleton(
|
||||
cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let codegen = cx.new(|_| TerminalCodegen::new(terminal, session_id));
|
||||
|
||||
let prompt_editor = cx.new(|cx| {
|
||||
PromptEditor::new_terminal(
|
||||
assist_id,
|
||||
self.prompt_history.clone(),
|
||||
prompt_buffer.clone(),
|
||||
codegen,
|
||||
session_id,
|
||||
self.fs.clone(),
|
||||
thread_store.clone(),
|
||||
prompt_store.clone(),
|
||||
project.clone(),
|
||||
workspace.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let prompt_editor_render = prompt_editor.clone();
|
||||
let block = terminal_view::BlockProperties {
|
||||
height: 4,
|
||||
render: Box::new(move |_| prompt_editor_render.clone().into_any_element()),
|
||||
};
|
||||
terminal_view.update(cx, |terminal_view, cx| {
|
||||
terminal_view.set_block_below_cursor(block, window, cx);
|
||||
});
|
||||
|
||||
let terminal_assistant = TerminalInlineAssist::new(
|
||||
assist_id,
|
||||
terminal_view,
|
||||
prompt_editor,
|
||||
workspace.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
self.assists.insert(assist_id, terminal_assistant);
|
||||
|
||||
self.focus_assist(assist_id, window, cx);
|
||||
}
|
||||
|
||||
fn focus_assist(
|
||||
&mut self,
|
||||
assist_id: TerminalInlineAssistId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let assist = &self.assists[&assist_id];
|
||||
if let Some(prompt_editor) = assist.prompt_editor.as_ref() {
|
||||
prompt_editor.update(cx, |this, cx| {
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
window.focus(&editor.focus_handle(cx), cx);
|
||||
editor.select_all(&SelectAll, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_prompt_editor_event(
|
||||
&mut self,
|
||||
prompt_editor: Entity<PromptEditor<TerminalCodegen>>,
|
||||
event: &PromptEditorEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let assist_id = prompt_editor.read(cx).id();
|
||||
match event {
|
||||
PromptEditorEvent::StartRequested => {
|
||||
self.start_assist(assist_id, cx);
|
||||
}
|
||||
PromptEditorEvent::StopRequested => {
|
||||
self.stop_assist(assist_id, cx);
|
||||
}
|
||||
PromptEditorEvent::ConfirmRequested { execute } => {
|
||||
self.finish_assist(assist_id, false, *execute, window, cx);
|
||||
}
|
||||
PromptEditorEvent::CancelRequested => {
|
||||
self.finish_assist(assist_id, true, false, window, cx);
|
||||
}
|
||||
PromptEditorEvent::Resized { height_in_lines } => {
|
||||
self.insert_prompt_editor_into_terminal(assist_id, *height_in_lines, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut App) {
|
||||
let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
|
||||
assist
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(user_prompt) = assist
|
||||
.prompt_editor
|
||||
.as_ref()
|
||||
.map(|editor| editor.read(cx).prompt(cx))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.prompt_history.retain(|prompt| *prompt != user_prompt);
|
||||
self.prompt_history.push_back(user_prompt);
|
||||
if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN {
|
||||
self.prompt_history.pop_front();
|
||||
}
|
||||
|
||||
assist
|
||||
.terminal
|
||||
.update(cx, |terminal, cx| {
|
||||
terminal
|
||||
.terminal()
|
||||
.update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
|
||||
})
|
||||
.log_err();
|
||||
|
||||
let codegen = assist.codegen.clone();
|
||||
let Some(request_task) = self.request_for_inline_assist(assist_id, cx).log_err() else {
|
||||
return;
|
||||
};
|
||||
|
||||
codegen.update(cx, |codegen, cx| codegen.start(request_task, cx));
|
||||
}
|
||||
|
||||
fn stop_assist(&mut self, assist_id: TerminalInlineAssistId, cx: &mut App) {
|
||||
let assist = if let Some(assist) = self.assists.get_mut(&assist_id) {
|
||||
assist
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
assist.codegen.update(cx, |codegen, cx| codegen.stop(cx));
|
||||
}
|
||||
|
||||
fn request_for_inline_assist(
|
||||
&self,
|
||||
assist_id: TerminalInlineAssistId,
|
||||
cx: &mut App,
|
||||
) -> Result<Task<LanguageModelRequest>> {
|
||||
let ConfiguredModel { model, .. } = LanguageModelRegistry::read_global(cx)
|
||||
.inline_assistant_model()
|
||||
.context("No inline assistant model")?;
|
||||
|
||||
let assist = self.assists.get(&assist_id).context("invalid assist")?;
|
||||
|
||||
let shell = std::env::var("SHELL").ok();
|
||||
let (latest_output, working_directory) = assist
|
||||
.terminal
|
||||
.update(cx, |terminal, cx| {
|
||||
let terminal = terminal.entity().read(cx);
|
||||
let latest_output = terminal.last_n_non_empty_lines(DEFAULT_CONTEXT_LINES);
|
||||
let working_directory = terminal
|
||||
.working_directory()
|
||||
.map(|path| path.to_string_lossy().into_owned());
|
||||
(latest_output, working_directory)
|
||||
})
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
|
||||
let prompt_editor = assist.prompt_editor.clone().context("invalid assist")?;
|
||||
|
||||
let prompt = self.prompt_builder.generate_terminal_assistant_prompt(
|
||||
&prompt_editor.read(cx).prompt(cx),
|
||||
shell.as_deref(),
|
||||
working_directory.as_deref(),
|
||||
&latest_output,
|
||||
)?;
|
||||
|
||||
let temperature = AgentSettings::temperature_for_model(&model, cx);
|
||||
|
||||
let mention_set = prompt_editor.read(cx).mention_set().clone();
|
||||
let load_context_task = load_context(&mention_set, cx);
|
||||
|
||||
Ok(cx.background_spawn(async move {
|
||||
let mut request_message = LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec![],
|
||||
cache: false,
|
||||
reasoning_details: None,
|
||||
};
|
||||
|
||||
if let Some(context) = load_context_task.await {
|
||||
context.add_to_request_message(&mut request_message);
|
||||
}
|
||||
|
||||
request_message.content.push(prompt.into());
|
||||
|
||||
LanguageModelRequest {
|
||||
thread_id: None,
|
||||
prompt_id: None,
|
||||
intent: Some(CompletionIntent::TerminalInlineAssist),
|
||||
messages: vec![request_message],
|
||||
tools: Vec::new(),
|
||||
tool_choice: None,
|
||||
stop: Vec::new(),
|
||||
temperature,
|
||||
thinking_allowed: false,
|
||||
thinking_effort: None,
|
||||
speed: None,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn finish_assist(
|
||||
&mut self,
|
||||
assist_id: TerminalInlineAssistId,
|
||||
undo: bool,
|
||||
execute: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.dismiss_assist(assist_id, window, cx);
|
||||
|
||||
if let Some(assist) = self.assists.remove(&assist_id) {
|
||||
assist
|
||||
.terminal
|
||||
.update(cx, |this, cx| {
|
||||
this.clear_block_below_cursor(cx);
|
||||
this.focus_handle(cx).focus(window, cx);
|
||||
})
|
||||
.log_err();
|
||||
|
||||
if let Some(ConfiguredModel { model, .. }) =
|
||||
LanguageModelRegistry::read_global(cx).inline_assistant_model()
|
||||
{
|
||||
let codegen = assist.codegen.read(cx);
|
||||
let session_id = codegen.session_id();
|
||||
let message_id = codegen.message_id.clone();
|
||||
let model_telemetry_id = model.telemetry_id();
|
||||
let model_provider_id = model.provider_id().to_string();
|
||||
|
||||
let (phase, event_type, anthropic_event_type) = if undo {
|
||||
(
|
||||
"rejected",
|
||||
"Assistant Response Rejected",
|
||||
AnthropicEventType::Reject,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"accepted",
|
||||
"Assistant Response Accepted",
|
||||
AnthropicEventType::Accept,
|
||||
)
|
||||
};
|
||||
|
||||
// Fire Zed telemetry
|
||||
telemetry::event!(
|
||||
event_type,
|
||||
kind = "inline_terminal",
|
||||
phase = phase,
|
||||
model = model_telemetry_id,
|
||||
model_provider = model_provider_id,
|
||||
message_id = message_id,
|
||||
session_id = session_id,
|
||||
);
|
||||
|
||||
report_anthropic_event(
|
||||
&model,
|
||||
AnthropicEventData {
|
||||
completion_type: AnthropicCompletionType::Terminal,
|
||||
event: anthropic_event_type,
|
||||
language_name: None,
|
||||
message_id,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
assist.codegen.update(cx, |codegen, cx| {
|
||||
if undo {
|
||||
codegen.undo(cx);
|
||||
} else if execute {
|
||||
codegen.complete(cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn dismiss_assist(
|
||||
&mut self,
|
||||
assist_id: TerminalInlineAssistId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let Some(assist) = self.assists.get_mut(&assist_id) else {
|
||||
return false;
|
||||
};
|
||||
if assist.prompt_editor.is_none() {
|
||||
return false;
|
||||
}
|
||||
assist.prompt_editor = None;
|
||||
assist
|
||||
.terminal
|
||||
.update(cx, |this, cx| {
|
||||
this.clear_block_below_cursor(cx);
|
||||
this.focus_handle(cx).focus(window, cx);
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn insert_prompt_editor_into_terminal(
|
||||
&mut self,
|
||||
assist_id: TerminalInlineAssistId,
|
||||
height: u8,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Some(assist) = self.assists.get_mut(&assist_id)
|
||||
&& let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned()
|
||||
{
|
||||
assist
|
||||
.terminal
|
||||
.update(cx, |terminal, cx| {
|
||||
terminal.clear_block_below_cursor(cx);
|
||||
let block = terminal_view::BlockProperties {
|
||||
height,
|
||||
render: Box::new(move |_| prompt_editor.clone().into_any_element()),
|
||||
};
|
||||
terminal.set_block_below_cursor(block, window, cx);
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalInlineAssist {
|
||||
terminal: WeakEntity<TerminalView>,
|
||||
prompt_editor: Option<Entity<PromptEditor<TerminalCodegen>>>,
|
||||
codegen: Entity<TerminalCodegen>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl TerminalInlineAssist {
|
||||
pub fn new(
|
||||
assist_id: TerminalInlineAssistId,
|
||||
terminal: &Entity<TerminalView>,
|
||||
prompt_editor: Entity<PromptEditor<TerminalCodegen>>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
let codegen = prompt_editor.read(cx).codegen().clone();
|
||||
Self {
|
||||
terminal: terminal.downgrade(),
|
||||
prompt_editor: Some(prompt_editor.clone()),
|
||||
codegen: codegen.clone(),
|
||||
workspace,
|
||||
_subscriptions: vec![
|
||||
window.subscribe(&prompt_editor, cx, |prompt_editor, event, window, cx| {
|
||||
TerminalInlineAssistant::update_global(cx, |this, cx| {
|
||||
this.handle_prompt_editor_event(prompt_editor, event, window, cx)
|
||||
})
|
||||
}),
|
||||
window.subscribe(&codegen, cx, move |codegen, event, window, cx| {
|
||||
TerminalInlineAssistant::update_global(cx, |this, cx| match event {
|
||||
CodegenEvent::Finished => {
|
||||
let assist = if let Some(assist) = this.assists.get(&assist_id) {
|
||||
assist
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let CodegenStatus::Error(error) = &codegen.read(cx).status
|
||||
&& assist.prompt_editor.is_none()
|
||||
&& let Some(workspace) = assist.workspace.upgrade()
|
||||
{
|
||||
let error = format!("Terminal inline assistant error: {}", error);
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
struct InlineAssistantError;
|
||||
|
||||
let id = NotificationId::composite::<InlineAssistantError>(
|
||||
assist_id.0,
|
||||
);
|
||||
|
||||
workspace.show_toast(Toast::new(id, error), cx);
|
||||
})
|
||||
}
|
||||
|
||||
if assist.prompt_editor.is_none() {
|
||||
this.finish_assist(assist_id, false, false, window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use acp_thread::{AgentConnection, StubAgentConnection};
|
||||
use agent_client_protocol::schema as acp;
|
||||
use agent_servers::{AgentServer, AgentServerDelegate};
|
||||
use gpui::{Entity, Task, TestAppContext, VisualTestContext};
|
||||
use project::AgentId;
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use std::any::Any;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::AgentPanel;
|
||||
use crate::agent_panel;
|
||||
|
||||
thread_local! {
|
||||
static STUB_AGENT_CONNECTION: RefCell<Option<StubAgentConnection>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Registers a `StubAgentConnection` that will be used by `Agent::Stub`.
|
||||
///
|
||||
/// Returns the same connection so callers can hold onto it and control
|
||||
/// the stub's behavior (e.g. `connection.set_next_prompt_updates(...)`).
|
||||
pub fn set_stub_agent_connection(connection: StubAgentConnection) -> StubAgentConnection {
|
||||
STUB_AGENT_CONNECTION.with(|cell| {
|
||||
*cell.borrow_mut() = Some(connection.clone());
|
||||
});
|
||||
connection
|
||||
}
|
||||
|
||||
/// Returns the shared `StubAgentConnection` used by `Agent::Stub`,
|
||||
/// creating a default one if none was registered.
|
||||
pub fn stub_agent_connection() -> StubAgentConnection {
|
||||
STUB_AGENT_CONNECTION.with(|cell| {
|
||||
let mut borrow = cell.borrow_mut();
|
||||
borrow.get_or_insert_with(StubAgentConnection::new).clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub struct StubAgentServer<C> {
|
||||
connection: C,
|
||||
agent_id: AgentId,
|
||||
}
|
||||
|
||||
impl<C> StubAgentServer<C>
|
||||
where
|
||||
C: AgentConnection,
|
||||
{
|
||||
pub fn new(connection: C) -> Self {
|
||||
Self {
|
||||
connection,
|
||||
agent_id: "Test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_connection_agent_id(mut self) -> Self {
|
||||
self.agent_id = self.connection.agent_id();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl StubAgentServer<StubAgentConnection> {
|
||||
pub fn default_response() -> Self {
|
||||
let conn = StubAgentConnection::new();
|
||||
conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("Default response".into()),
|
||||
)]);
|
||||
Self::new(conn)
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> AgentServer for StubAgentServer<C>
|
||||
where
|
||||
C: 'static + AgentConnection + Send + Clone,
|
||||
{
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::ZedAgent
|
||||
}
|
||||
|
||||
fn agent_id(&self) -> AgentId {
|
||||
self.agent_id.clone()
|
||||
}
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
_delegate: AgentServerDelegate,
|
||||
_project: Entity<Project>,
|
||||
_cx: &mut gpui::App,
|
||||
) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
|
||||
Task::ready(Ok(Rc::new(self.connection.clone())))
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
cx.set_global(acp_thread::StubSessionCounter(
|
||||
std::sync::atomic::AtomicUsize::new(0),
|
||||
));
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
editor::init(cx);
|
||||
release_channel::init("0.0.0".parse().unwrap(), cx);
|
||||
agent_panel::init(cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn open_thread_with_connection(
|
||||
panel: &Entity<AgentPanel>,
|
||||
connection: StubAgentConnection,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.open_external_thread_with_server(
|
||||
Rc::new(StubAgentServer::new(connection)),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Opens a draft thread against a stub server so the panel's `draft_thread`
|
||||
/// pointer is populated for tests that care about draft UX.
|
||||
pub fn open_draft_with_connection(
|
||||
panel: &Entity<AgentPanel>,
|
||||
connection: StubAgentConnection,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.open_draft_with_server(Rc::new(StubAgentServer::new(connection)), window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
pub fn open_thread_with_custom_connection<C>(
|
||||
panel: &Entity<AgentPanel>,
|
||||
connection: C,
|
||||
cx: &mut VisualTestContext,
|
||||
) where
|
||||
C: 'static + AgentConnection + Send + Clone,
|
||||
{
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.open_external_thread_with_server(
|
||||
Rc::new(StubAgentServer::new(connection).with_connection_agent_id()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
pub fn send_message(panel: &Entity<AgentPanel>, cx: &mut VisualTestContext) {
|
||||
let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
|
||||
let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
|
||||
message_editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("Hello", window, cx);
|
||||
});
|
||||
thread_view.update_in(cx, |view, window, cx| view.send(window, cx));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
pub fn type_draft_prompt(panel: &Entity<AgentPanel>, text: &str, cx: &mut VisualTestContext) {
|
||||
let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
|
||||
let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
|
||||
message_editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text(text, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
// Drain the debounced draft-prompt persist task so the kvp write has
|
||||
// landed by the time we return.
|
||||
cx.executor()
|
||||
.advance_clock(crate::conversation_view::DRAFT_PROMPT_PERSIST_DEBOUNCE * 2);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
pub fn active_session_id(panel: &Entity<AgentPanel>, cx: &VisualTestContext) -> acp::SessionId {
|
||||
panel.read_with(cx, |panel, cx| {
|
||||
let thread = panel.active_agent_thread(cx).unwrap();
|
||||
thread.read(cx).session_id().clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_thread_id(
|
||||
panel: &Entity<AgentPanel>,
|
||||
cx: &VisualTestContext,
|
||||
) -> crate::thread_metadata_store::ThreadId {
|
||||
panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
mod agent_notification;
|
||||
mod end_trial_upsell;
|
||||
mod hold_for_default;
|
||||
mod mention_crease;
|
||||
mod model_selector_components;
|
||||
mod undo_reject_toast;
|
||||
|
||||
pub use agent_notification::*;
|
||||
pub use end_trial_upsell::*;
|
||||
pub use hold_for_default::*;
|
||||
pub use mention_crease::*;
|
||||
pub use model_selector_components::*;
|
||||
pub use undo_reject_toast::*;
|
||||
|
||||
/// Returns the appropriate [`DocumentationSide`] for documentation asides
|
||||
/// in the agent panel, based on the current dock position.
|
||||
pub fn documentation_aside_side(cx: &gpui::App) -> ui::DocumentationSide {
|
||||
use agent_settings::AgentSettings;
|
||||
use settings::Settings;
|
||||
use ui::DocumentationSide;
|
||||
|
||||
match AgentSettings::get_global(cx).dock {
|
||||
settings::DockPosition::Left => DocumentationSide::Right,
|
||||
settings::DockPosition::Bottom | settings::DockPosition::Right => DocumentationSide::Left,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use gpui::{
|
||||
App, Context, EventEmitter, IntoElement, PlatformDisplay, Size, Window,
|
||||
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
|
||||
linear_color_stop, linear_gradient, point,
|
||||
};
|
||||
use release_channel::ReleaseChannel;
|
||||
use std::rc::Rc;
|
||||
use ui::{Render, prelude::*};
|
||||
|
||||
pub struct AgentNotification {
|
||||
title: SharedString,
|
||||
caption: Option<SharedString>,
|
||||
icon: IconName,
|
||||
project_name: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl AgentNotification {
|
||||
pub fn new(
|
||||
title: impl Into<SharedString>,
|
||||
caption: Option<SharedString>,
|
||||
icon: IconName,
|
||||
project_name: Option<impl Into<SharedString>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
caption: caption,
|
||||
icon,
|
||||
project_name: project_name.map(|name| name.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn window_options(screen: Rc<dyn PlatformDisplay>, cx: &App) -> WindowOptions {
|
||||
let size = Size {
|
||||
width: px(450.),
|
||||
height: px(72.),
|
||||
};
|
||||
|
||||
let notification_margin_width = px(16.);
|
||||
let notification_margin_height = px(-48.);
|
||||
|
||||
let bounds = gpui::Bounds::<Pixels> {
|
||||
origin: screen.bounds().top_right()
|
||||
- point(
|
||||
size.width + notification_margin_width,
|
||||
notification_margin_height,
|
||||
),
|
||||
size,
|
||||
};
|
||||
|
||||
let app_id = ReleaseChannel::global(cx).app_id();
|
||||
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: None,
|
||||
focus: false,
|
||||
show: true,
|
||||
kind: WindowKind::PopUp,
|
||||
is_movable: false,
|
||||
display_id: Some(screen.id()),
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
app_id: Some(app_id.to_owned()),
|
||||
window_min_size: None,
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
tabbing_identifier: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AgentNotificationEvent {
|
||||
Accepted,
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
impl EventEmitter<AgentNotificationEvent> for AgentNotification {}
|
||||
|
||||
impl AgentNotification {
|
||||
pub fn accept(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(AgentNotificationEvent::Accepted);
|
||||
}
|
||||
|
||||
pub fn dismiss(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(AgentNotificationEvent::Dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AgentNotification {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let ui_font = theme_settings::setup_ui_font(window, cx);
|
||||
let line_height = window.line_height();
|
||||
|
||||
let bg = cx.theme().colors().elevated_surface_background;
|
||||
let gradient_overflow = || {
|
||||
div()
|
||||
.h_full()
|
||||
.absolute()
|
||||
.w_8()
|
||||
.bottom_0()
|
||||
.right_0()
|
||||
.bg(linear_gradient(
|
||||
90.,
|
||||
linear_color_stop(bg, 1.),
|
||||
linear_color_stop(bg.opacity(0.2), 0.),
|
||||
))
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id("agent-notification")
|
||||
.size_full()
|
||||
.p_3()
|
||||
.gap_4()
|
||||
.justify_between()
|
||||
.elevation_3(cx)
|
||||
.text_ui(cx)
|
||||
.font(ui_font)
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_xl()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.child(
|
||||
h_flex().h(line_height).justify_center().child(
|
||||
Icon::new(self.icon)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::Small),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.max_w(px(300.))
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.text_size(px(14.))
|
||||
.text_color(cx.theme().colors().text)
|
||||
.truncate()
|
||||
.child(self.title.clone())
|
||||
.child(gradient_overflow()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.relative()
|
||||
.gap_1p5()
|
||||
.text_size(px(12.))
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.truncate()
|
||||
.when_some(
|
||||
self.project_name.clone(),
|
||||
|description, project_name| {
|
||||
let has_caption = self.caption.is_some();
|
||||
let project = div()
|
||||
.truncate()
|
||||
.when(has_caption, |this| this.max_w_16())
|
||||
.child(project_name);
|
||||
let mut row = h_flex().gap_1p5().child(project);
|
||||
if has_caption {
|
||||
row = row.child(
|
||||
div().size(px(3.)).rounded_full().bg(cx
|
||||
.theme()
|
||||
.colors()
|
||||
.text
|
||||
.opacity(0.5)),
|
||||
);
|
||||
}
|
||||
description.child(row)
|
||||
},
|
||||
)
|
||||
.when_some(self.caption.clone(), |description, caption| {
|
||||
description.child(caption)
|
||||
})
|
||||
.child(gradient_overflow()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
Button::new("open", "View")
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.full_width()
|
||||
.on_click({
|
||||
cx.listener(move |this, _event, _, cx| {
|
||||
this.accept(cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(Button::new("dismiss", "Dismiss").full_width().on_click({
|
||||
cx.listener(move |this, _event, _, cx| {
|
||||
this.dismiss(cx);
|
||||
})
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai_onboarding::{AgentPanelOnboardingCard, PlanDefinitions};
|
||||
use client::zed_urls;
|
||||
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
|
||||
use ui::{Divider, Tooltip, prelude::*};
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct EndTrialUpsell {
|
||||
dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
impl EndTrialUpsell {
|
||||
pub fn new(dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>) -> Self {
|
||||
Self { dismiss_upsell }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EndTrialUpsell {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let pro_section = v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("Pro")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Accent)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan())
|
||||
.child(
|
||||
Button::new("cta-button", "Upgrade to Zed Pro")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click(move |_, _window, cx| {
|
||||
telemetry::event!("Upgrade To Pro Clicked", state = "end-of-trial");
|
||||
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
|
||||
}),
|
||||
);
|
||||
|
||||
let free_section = v_flex()
|
||||
.mt_1p5()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Label::new("Free")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(
|
||||
Label::new("(Current Plan)")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6)))
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.free_plan());
|
||||
|
||||
AgentPanelOnboardingCard::new()
|
||||
.child(Headline::new("Your Zed Pro Trial has expired"))
|
||||
.child(
|
||||
Label::new("You've been automatically reset to the Free plan.")
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(pro_section)
|
||||
.child(free_section)
|
||||
.child(
|
||||
h_flex().absolute().top_4().right_4().child(
|
||||
IconButton::new("dismiss_onboarding", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Dismiss"))
|
||||
.on_click({
|
||||
let callback = self.dismiss_upsell.clone();
|
||||
move |_, window, cx| {
|
||||
telemetry::event!("Banner Dismissed", source = "AI Onboarding");
|
||||
callback(window, cx)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for EndTrialUpsell {
|
||||
fn scope() -> ComponentScope {
|
||||
ComponentScope::Onboarding
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"End of Trial Upsell Banner"
|
||||
}
|
||||
|
||||
fn sort_name() -> &'static str {
|
||||
"End of Trial Upsell Banner"
|
||||
}
|
||||
|
||||
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
|
||||
Some(
|
||||
v_flex()
|
||||
.child(EndTrialUpsell {
|
||||
dismiss_upsell: Arc::new(|_, _| {}),
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use gpui::{App, IntoElement, Modifiers, RenderOnce, Window};
|
||||
use ui::{prelude::*, render_modifiers};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct HoldForDefault {
|
||||
is_default: bool,
|
||||
more_content: bool,
|
||||
}
|
||||
|
||||
impl HoldForDefault {
|
||||
pub fn new(is_default: bool) -> Self {
|
||||
Self {
|
||||
is_default,
|
||||
more_content: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn more_content(mut self, more_content: bool) -> Self {
|
||||
self.more_content = more_content;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for HoldForDefault {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.when(self.more_content, |this| {
|
||||
this.pt_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.gap_0p5()
|
||||
.text_sm()
|
||||
.text_color(Color::Muted.color(cx))
|
||||
.child("Hold")
|
||||
.child(h_flex().flex_shrink_0().children(render_modifiers(
|
||||
&Modifiers::secondary_key(),
|
||||
PlatformStyle::platform(),
|
||||
None,
|
||||
Some(TextSize::Default.rems(cx).into()),
|
||||
false,
|
||||
)))
|
||||
.child(div().map(|this| {
|
||||
if self.is_default {
|
||||
this.child("to unset as default")
|
||||
} else {
|
||||
this.child("to set as default")
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use std::{ops::RangeInclusive, path::PathBuf, time::Duration};
|
||||
|
||||
use acp_thread::MentionUri;
|
||||
use agent_client_protocol::schema as acp;
|
||||
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window,
|
||||
pulsating_between,
|
||||
};
|
||||
use prompt_store::PromptId;
|
||||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
|
||||
use workspace::{OpenOptions, Workspace};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct MentionCrease {
|
||||
id: ElementId,
|
||||
icon: SharedString,
|
||||
label: SharedString,
|
||||
mention_uri: Option<MentionUri>,
|
||||
workspace: Option<WeakEntity<Workspace>>,
|
||||
is_toggled: bool,
|
||||
is_loading: bool,
|
||||
tooltip: Option<SharedString>,
|
||||
image_preview: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
|
||||
}
|
||||
|
||||
impl MentionCrease {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
icon: impl Into<SharedString>,
|
||||
label: impl Into<SharedString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
icon: icon.into(),
|
||||
label: label.into(),
|
||||
mention_uri: None,
|
||||
workspace: None,
|
||||
is_toggled: false,
|
||||
is_loading: false,
|
||||
tooltip: None,
|
||||
image_preview: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mention_uri(mut self, mention_uri: Option<MentionUri>) -> Self {
|
||||
self.mention_uri = mention_uri;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn workspace(mut self, workspace: Option<WeakEntity<Workspace>>) -> Self {
|
||||
self.workspace = workspace;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_toggled(mut self, is_toggled: bool) -> Self {
|
||||
self.is_toggled = is_toggled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_loading(mut self, is_loading: bool) -> Self {
|
||||
self.is_loading = is_loading;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = Some(tooltip.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn image_preview(
|
||||
mut self,
|
||||
builder: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
) -> Self {
|
||||
self.image_preview = Some(Box::new(builder));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MentionCrease {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let font_size = settings.agent_buffer_font_size(cx);
|
||||
let buffer_font = settings.buffer_font.clone();
|
||||
let is_loading = self.is_loading;
|
||||
let tooltip = self.tooltip;
|
||||
let image_preview = self.image_preview;
|
||||
|
||||
let button_height = DefiniteLength::Absolute(AbsoluteLength::Pixels(
|
||||
px(window.line_height().into()) - px(1.),
|
||||
));
|
||||
|
||||
ButtonLike::new(self.id)
|
||||
.style(ButtonStyle::Outlined)
|
||||
.size(ButtonSize::Compact)
|
||||
.height(button_height)
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.toggle_state(self.is_toggled)
|
||||
.when_some(
|
||||
self.mention_uri.clone().zip(self.workspace.clone()),
|
||||
|this, (mention_uri, workspace)| {
|
||||
this.on_click(move |_event, window, cx| {
|
||||
open_mention_uri(mention_uri.clone(), &workspace, window, cx);
|
||||
})
|
||||
},
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.pb_px()
|
||||
.gap_1()
|
||||
.font(buffer_font)
|
||||
.text_size(font_size)
|
||||
.child(
|
||||
Icon::from_path(self.icon.clone())
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.map(|this| {
|
||||
if is_loading {
|
||||
this.with_animation(
|
||||
"loading-context-crease",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.4, 0.8)),
|
||||
|label, delta| label.opacity(delta),
|
||||
)
|
||||
.into_any()
|
||||
} else {
|
||||
this.into_any()
|
||||
}
|
||||
}),
|
||||
)
|
||||
.map(|button| {
|
||||
if let Some(image_preview) = image_preview {
|
||||
button.hoverable_tooltip(image_preview)
|
||||
} else {
|
||||
button.when_some(tooltip, |this, tooltip_text| {
|
||||
this.tooltip(Tooltip::text(tooltip_text))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn open_mention_uri(
|
||||
mention_uri: MentionUri,
|
||||
workspace: &WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |workspace, cx| match mention_uri {
|
||||
MentionUri::File { abs_path } => {
|
||||
open_file(workspace, abs_path, None, window, cx);
|
||||
}
|
||||
MentionUri::Symbol {
|
||||
abs_path,
|
||||
line_range,
|
||||
..
|
||||
}
|
||||
| MentionUri::Selection {
|
||||
abs_path: Some(abs_path),
|
||||
line_range,
|
||||
} => {
|
||||
open_file(workspace, abs_path, Some(line_range), window, cx);
|
||||
}
|
||||
MentionUri::Directory { abs_path } => {
|
||||
reveal_in_project_panel(workspace, abs_path, cx);
|
||||
}
|
||||
MentionUri::Thread { id, name } => {
|
||||
open_thread(workspace, id, name, window, cx);
|
||||
}
|
||||
MentionUri::Rule { id, .. } => {
|
||||
open_rule(workspace, id, window, cx);
|
||||
}
|
||||
MentionUri::Fetch { url } => {
|
||||
cx.open_url(url.as_str());
|
||||
}
|
||||
MentionUri::PastedImage { .. }
|
||||
| MentionUri::Selection { abs_path: None, .. }
|
||||
| MentionUri::Diagnostics { .. }
|
||||
| MentionUri::TerminalSelection { .. }
|
||||
| MentionUri::GitDiff { .. }
|
||||
| MentionUri::MergeConflict { .. } => {}
|
||||
});
|
||||
}
|
||||
|
||||
fn open_file(
|
||||
workspace: &mut Workspace,
|
||||
abs_path: PathBuf,
|
||||
line_range: Option<RangeInclusive<u32>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let project = workspace.project();
|
||||
|
||||
if let Some(project_path) =
|
||||
project.update(cx, |project, cx| project.find_project_path(&abs_path, cx))
|
||||
{
|
||||
let item = workspace.open_path(project_path, None, true, window, cx);
|
||||
if let Some(line_range) = line_range {
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let Some(editor) = item.await?.downcast::<Editor>() else {
|
||||
return Ok(());
|
||||
};
|
||||
editor
|
||||
.update_in(cx, |editor, window, cx| {
|
||||
let range = Point::new(*line_range.start(), 0)
|
||||
..Point::new(*line_range.start(), 0);
|
||||
editor.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::center()),
|
||||
window,
|
||||
cx,
|
||||
|selections| selections.select_ranges(vec![range]),
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
} else {
|
||||
item.detach_and_log_err(cx);
|
||||
}
|
||||
} else if abs_path.exists() {
|
||||
workspace
|
||||
.open_abs_path(
|
||||
abs_path,
|
||||
OpenOptions {
|
||||
focus: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_in_project_panel(
|
||||
workspace: &mut Workspace,
|
||||
abs_path: PathBuf,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let project = workspace.project();
|
||||
let Some(entry_id) = project.update(cx, |project, cx| {
|
||||
let path = project.find_project_path(&abs_path, cx)?;
|
||||
project.entry_for_path(&path, cx).map(|entry| entry.id)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
project.update(cx, |_, cx| {
|
||||
cx.emit(project::Event::RevealInProjectPanel(entry_id));
|
||||
});
|
||||
}
|
||||
|
||||
fn open_thread(
|
||||
workspace: &mut Workspace,
|
||||
id: acp::SessionId,
|
||||
name: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
use crate::{Agent, AgentPanel, thread_metadata_store::ThreadMetadataStore};
|
||||
|
||||
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Right now we only support loading threads in the native agent.
|
||||
panel.update(cx, |panel, cx| {
|
||||
let thread_id = ThreadMetadataStore::try_global(cx)
|
||||
.and_then(|store| store.read(cx).entry_by_session(&id).map(|m| m.thread_id));
|
||||
if let Some(thread_id) = thread_id {
|
||||
panel.load_agent_thread(
|
||||
Agent::NativeAgent,
|
||||
thread_id,
|
||||
None,
|
||||
Some(name.into()),
|
||||
true,
|
||||
"agent_panel",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
panel.open_thread(id, None, Some(name.into()), window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn open_rule(
|
||||
_workspace: &mut Workspace,
|
||||
id: PromptId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
use zed_actions::assistant::OpenRulesLibrary;
|
||||
|
||||
let PromptId::User { uuid } = id else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.dispatch_action(
|
||||
Box::new(OpenRulesLibrary {
|
||||
prompt_to_select: Some(uuid.0),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use gpui::{Action, ClickEvent, FocusHandle, prelude::*};
|
||||
use ui::{Chip, ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
use crate::CycleFavoriteModels;
|
||||
|
||||
enum ModelIcon {
|
||||
Name(IconName),
|
||||
Path(SharedString),
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorHeader {
|
||||
title: SharedString,
|
||||
has_border: bool,
|
||||
}
|
||||
|
||||
impl ModelSelectorHeader {
|
||||
pub fn new(title: impl Into<SharedString>, has_border: bool) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
has_border,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorHeader {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.px_2()
|
||||
.pb_1()
|
||||
.when(self.has_border, |this| {
|
||||
this.mt_1()
|
||||
.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.child(
|
||||
Label::new(self.title)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorListItem {
|
||||
index: usize,
|
||||
title: SharedString,
|
||||
icon: Option<ModelIcon>,
|
||||
is_selected: bool,
|
||||
is_focused: bool,
|
||||
is_latest: bool,
|
||||
is_favorite: bool,
|
||||
on_toggle_favorite: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
cost_info: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl ModelSelectorListItem {
|
||||
pub fn new(index: usize, title: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
index,
|
||||
title: title.into(),
|
||||
icon: None,
|
||||
is_selected: false,
|
||||
is_focused: false,
|
||||
is_latest: false,
|
||||
is_favorite: false,
|
||||
on_toggle_favorite: None,
|
||||
cost_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(ModelIcon::Name(icon));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon_path(mut self, path: SharedString) -> Self {
|
||||
self.icon = Some(ModelIcon::Path(path));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_selected(mut self, is_selected: bool) -> Self {
|
||||
self.is_selected = is_selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_focused(mut self, is_focused: bool) -> Self {
|
||||
self.is_focused = is_focused;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_latest(mut self, is_latest: bool) -> Self {
|
||||
self.is_latest = is_latest;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_favorite(mut self, is_favorite: bool) -> Self {
|
||||
self.is_favorite = is_favorite;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_toggle_favorite(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_toggle_favorite = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cost_info(mut self, cost_info: Option<SharedString>) -> Self {
|
||||
self.cost_info = cost_info;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorListItem {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let model_icon_color = if self.is_selected {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
let is_favorite = self.is_favorite;
|
||||
|
||||
ListItem::new(self.index)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(self.is_focused)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.when_some(self.icon, |this, icon| {
|
||||
this.child(
|
||||
match icon {
|
||||
ModelIcon::Name(icon_name) => Icon::new(icon_name),
|
||||
ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path),
|
||||
}
|
||||
.color(model_icon_color)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
})
|
||||
.child(Label::new(self.title).truncate())
|
||||
.when(self.is_latest, |parent| parent.child(Chip::new("Latest")))
|
||||
.when_some(self.cost_info, |this, cost_info| {
|
||||
let tooltip_text = if cost_info.ends_with('×') {
|
||||
format!("Cost Multiplier: {}", cost_info)
|
||||
} else if cost_info.contains('$') {
|
||||
format!("Cost per Million Tokens: {}", cost_info)
|
||||
} else {
|
||||
format!("Cost: {}", cost_info)
|
||||
};
|
||||
|
||||
this.child(Chip::new(cost_info).tooltip(Tooltip::text(tooltip_text)))
|
||||
}),
|
||||
)
|
||||
.end_slot(div().pr_2().when(self.is_selected, |this| {
|
||||
this.child(Icon::new(IconName::Check).color(Color::Accent))
|
||||
}))
|
||||
.end_slot_on_hover(div().pr_1p5().when_some(self.on_toggle_favorite, {
|
||||
|this, handle_click| {
|
||||
let (icon, color, tooltip) = if is_favorite {
|
||||
(IconName::StarFilled, Color::Accent, "Unfavorite Model")
|
||||
} else {
|
||||
(IconName::Star, Color::Default, "Favorite Model")
|
||||
};
|
||||
this.child(
|
||||
IconButton::new(("toggle-favorite", self.index), icon)
|
||||
.layer(ElevationIndex::ElevatedSurface)
|
||||
.icon_color(color)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text(tooltip))
|
||||
.on_click(move |event, window, cx| (handle_click)(event, window, cx)),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorFooter {
|
||||
action: Box<dyn Action>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl ModelSelectorFooter {
|
||||
pub fn new(action: Box<dyn Action>, focus_handle: FocusHandle) -> Self {
|
||||
Self {
|
||||
action,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorFooter {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let action = self.action;
|
||||
let focus_handle = self.focus_handle;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.p_1p5()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
Button::new("configure", "Configure")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(action.as_ref(), &focus_handle, cx)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(action.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorTooltip {
|
||||
show_cycle_row: bool,
|
||||
}
|
||||
|
||||
impl ModelSelectorTooltip {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
show_cycle_row: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_cycle_row(mut self, show: bool) -> Self {
|
||||
self.show_cycle_row = show;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorTooltip {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Label::new("Change Model"))
|
||||
.child(KeyBinding::for_action(&ToggleModelSelector, cx)),
|
||||
)
|
||||
.when(self.show_cycle_row, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.justify_between()
|
||||
.child(Label::new("Cycle Favorited Models"))
|
||||
.child(KeyBinding::for_action(&CycleFavoriteModels, cx)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use action_log::ActionLog;
|
||||
use gpui::{App, Entity};
|
||||
use notifications::status_toast::StatusToast;
|
||||
use ui::prelude::*;
|
||||
use workspace::Workspace;
|
||||
|
||||
pub fn show_undo_reject_toast(
|
||||
workspace: &mut Workspace,
|
||||
action_log: Entity<ActionLog>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let action_log_weak = action_log.downgrade();
|
||||
let status_toast = StatusToast::new("Agent Changes Rejected", cx, move |this, _cx| {
|
||||
this.icon(
|
||||
Icon::new(IconName::Undo)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.action("Undo", move |_window, cx| {
|
||||
if let Some(action_log) = action_log_weak.upgrade() {
|
||||
action_log
|
||||
.update(cx, |action_log, cx| action_log.undo_last_reject(cx))
|
||||
.detach();
|
||||
}
|
||||
})
|
||||
.dismiss_button(true)
|
||||
});
|
||||
workspace.toggle_status_toast(status_toast, cx);
|
||||
}
|
||||
Reference in New Issue
Block a user