Compare commits

..

10 Commits

Author SHA1 Message Date
Smit Barmase
26149bfc33 fix parse 2025-08-27 18:18:57 +05:30
Smit Barmase
97bcb15e9f use defaults as fallback for register opts 2025-08-27 17:44:52 +05:30
Bennet Bo Fenner
c72e594afe acp: Fix model selector sometimes showing no models (#36995)
Release Notes:

- N/A
2025-08-27 13:08:03 +02:00
Antonio Scandurra
b4d4294bee Restore token count for text threads (#36989)
Release Notes:

- N/A

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-08-27 09:29:17 +00:00
Antonio Scandurra
e5c0614e88 Ensure we use the new agent when opening the panel for the first time (#36988)
Release Notes:

- N/A
2025-08-27 09:18:15 +00:00
Smit Barmase
ea347b0aa1 project: Handle capabilities parse for more methods when registerOptions doesn't exist (#36984)
Closes #36938

Follow up to https://github.com/zed-industries/zed/pull/36554

When `registerOptions` is `None`, we should fall back instead of
skipping capability registration.

1. `Option<OneOf<bool, T>>`, where `T` is struct – handled in the
attached PR 
2. `Option<T>`, where `T` is an enum that can be `Simple(bool)` or
`Options(S)` – this PR 
3. `Option<T>`, where `T` is struct – we should fall back to default
values for these options ⚠️

Release Notes:

- Fixed an issue where hover popovers would not appear in language
servers like Java.
2025-08-27 13:00:10 +05:30
Finn Evers
a03897012e Swap NewlineBelow and NewlineAbove bindings for default linux keymap (#36939)
Closes https://github.com/zed-industries/zed/issues/33725

The default bindings for the `editor::NewlineAbove` and
`editor::NewlineBelow` actions in the default keymap were accidentally
swapped some time ago. This causes confusion, as normally these are the
other way around.

This PR fixes this by swapping these back, which also matches what
[VSCode does by
default](https://code.visualstudio.com/shortcuts/keyboard-shortcuts-linux.pdf).

Release Notes:

- Swapped the default bindings for `editor::NewlineBelow` and
`editor::NewlineAbove` for Linux and Windows to align more with other
editors.
2025-08-27 07:06:33 +00:00
Conrad Irwin
f4071bdd8e acp: Upgrade errors (#36980)
- **Pass --engine-strict to gemini install command**
- **Make it clearer that if upgrading fails, you need to fix i**

Closes #ISSUE

Release Notes:

- N/A
2025-08-27 00:24:56 -06:00
Caio Piccirillo
abd6009b41 Enhance syntax highlight for C++20 keywords (#36817)
Closes #36439 and #32999 

## C++20 modules:
Before (Zed Preview v0.201.3):
<img width="1048" height="704" alt="image"
src="https://github.com/user-attachments/assets/8eaaf77f-4e27-4a5a-9e87-4e5ba7293990"
/>
After:
<img width="1048" height="704" alt="image"
src="https://github.com/user-attachments/assets/df8d0b2c-f2d0-4b0e-9a52-495e6be5a8c0"
/>

## C++20 coroutines:
Before (Zed Preview v0.201.3):
<img width="1048" height="704" alt="image"
src="https://github.com/user-attachments/assets/652191ec-a653-444d-a239-da3e4e4b661e"
/>
After:
<img width="1048" height="704" alt="image"
src="https://github.com/user-attachments/assets/36947eb5-8997-483a-b36c-8af84872b158"
/>

## Logical operators:
Before (Zed Preview v0.201.3):
<img width="511" height="102" alt="image"
src="https://github.com/user-attachments/assets/9bf95bac-b076-4edd-a1f3-c3dfee98c2fd"
/>

After:
<img width="511" height="102" alt="image"
src="https://github.com/user-attachments/assets/82c7564d-b94d-41f5-9c48-e39fe3ba3b3e"
/>

## Operator keyword:
Before (Zed Preview v0.201.3):
<img width="591" height="381" alt="image"
src="https://github.com/user-attachments/assets/1d9dad05-2d86-4566-97f4-aff440dcd1df"
/>

After:
<img width="591" height="381" alt="image"
src="https://github.com/user-attachments/assets/a1ca289a-8a5d-4ffd-96db-0d511405da4b"
/>

## Goto:
Before (Zed Preview v0.201.3):
<img width="610" height="430" alt="image"
src="https://github.com/user-attachments/assets/2d00382b-d1ad-4e36-a3ee-88e06ec528ed"
/>

After:
<img width="610" height="430" alt="image"
src="https://github.com/user-attachments/assets/de887b21-66f0-4a70-9ed2-e18dbb3c81c9"
/>

Release Notes:

- Enhance keyword highlighting for C++
2025-08-27 04:31:57 +00:00
Joseph T. Lyons
a3e1611fa8 Bump Zed to v0.203 (#36975)
Release Notes:

- N/A
2025-08-27 02:52:24 +00:00
111 changed files with 5705 additions and 5655 deletions

View File

@@ -81,7 +81,6 @@ jobs:
echo "run_license=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \
echo "$GITHUB_REF_NAME" | grep -qvP '^v[0-9]+\.[0-9]+\.[0-9x](-pre)?$' && \
echo "run_nix=true" >> "$GITHUB_OUTPUT" || \
echo "run_nix=false" >> "$GITHUB_OUTPUT"

23
Cargo.lock generated
View File

@@ -8,7 +8,6 @@ version = "0.1.0"
dependencies = [
"action_log",
"agent-client-protocol",
"agent_settings",
"anyhow",
"buffer_diff",
"collections",
@@ -23,7 +22,6 @@ dependencies = [
"language_model",
"markdown",
"parking_lot",
"portable-pty",
"project",
"prompt_store",
"rand 0.8.5",
@@ -31,7 +29,6 @@ dependencies = [
"serde_json",
"settings",
"smol",
"task",
"tempfile",
"terminal",
"ui",
@@ -39,7 +36,6 @@ dependencies = [
"util",
"uuid",
"watch",
"which 6.0.3",
"workspace-hack",
]
@@ -195,9 +191,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "0.2.0-alpha.4"
version = "0.0.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "603941db1d130ee275840c465b73a2312727d4acef97449550ccf033de71301f"
checksum = "289eb34ee17213dadcca47eedadd386a5e7678094095414e475965d1bcca2860"
dependencies = [
"anyhow",
"async-broadcast",
@@ -251,6 +247,7 @@ dependencies = [
"open",
"parking_lot",
"paths",
"portable-pty",
"pretty_assertions",
"project",
"prompt_store",
@@ -276,6 +273,7 @@ dependencies = [
"uuid",
"watch",
"web_search",
"which 6.0.3",
"workspace-hack",
"worktree",
"zlog",
@@ -294,21 +292,23 @@ dependencies = [
"anyhow",
"client",
"collections",
"context_server",
"env_logger 0.11.8",
"fs",
"futures 0.3.31",
"gpui",
"gpui_tokio",
"indoc",
"itertools 0.14.0",
"language",
"language_model",
"language_models",
"libc",
"log",
"nix 0.29.0",
"node_runtime",
"paths",
"project",
"rand 0.8.5",
"reqwest_client",
"schemars",
"semver",
@@ -316,10 +316,12 @@ dependencies = [
"serde_json",
"settings",
"smol",
"strum 0.27.1",
"tempfile",
"thiserror 2.0.12",
"ui",
"util",
"uuid",
"watch",
"which 6.0.3",
"workspace-hack",
@@ -416,7 +418,6 @@ dependencies = [
"serde_json",
"serde_json_lenient",
"settings",
"shlex",
"smol",
"streaming_diff",
"task",
@@ -9212,7 +9213,6 @@ dependencies = [
"language",
"lsp",
"project",
"proto",
"release_channel",
"serde_json",
"settings",
@@ -17185,8 +17185,7 @@ dependencies = [
[[package]]
name = "tree-sitter-cpp"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
source = "git+https://github.com/tree-sitter/tree-sitter-cpp?rev=5cb9b693cfd7bfacab1d9ff4acac1a4150700609#5cb9b693cfd7bfacab1d9ff4acac1a4150700609"
dependencies = [
"cc",
"tree-sitter-language",
@@ -20396,7 +20395,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.202.5"
version = "0.203.0"
dependencies = [
"acp_tools",
"activity_indicator",

View File

@@ -426,7 +426,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates
#
agent-client-protocol = { version = "0.2.0-alpha.4", features = ["unstable"]}
agent-client-protocol = "0.0.31"
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
@@ -624,7 +624,7 @@ tower-http = "0.4.4"
tree-sitter = { version = "0.25.6", features = ["wasm"] }
tree-sitter-bash = "0.25.0"
tree-sitter-c = "0.23"
tree-sitter-cpp = "0.23"
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" }
tree-sitter-css = "0.23"
tree-sitter-diff = "0.1.0"
tree-sitter-elixir = "0.3"
@@ -842,9 +842,6 @@ too_many_arguments = "allow"
# We often have large enum variants yet we rarely actually bother with splitting them up.
large_enum_variant = "allow"
# Boolean expressions can be hard to read, requiring only the minimal form gets in the way
nonminimal_bool = "allow"
[workspace.metadata.cargo-machete]
ignored = [
"bindgen",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -130,8 +130,8 @@
"bindings": {
"shift-enter": "editor::Newline",
"enter": "editor::Newline",
"ctrl-enter": "editor::NewlineAbove",
"ctrl-shift-enter": "editor::NewlineBelow",
"ctrl-enter": "editor::NewlineBelow",
"ctrl-shift-enter": "editor::NewlineAbove",
"ctrl-k ctrl-z": "editor::ToggleSoftWrap",
"ctrl-k z": "editor::ToggleSoftWrap",
"find": "buffer_search::Deploy",

View File

@@ -134,8 +134,8 @@
"bindings": {
"shift-enter": "editor::Newline",
"enter": "editor::Newline",
"ctrl-enter": "editor::NewlineAbove",
"ctrl-shift-enter": "editor::NewlineBelow",
"ctrl-enter": "editor::NewlineBelow",
"ctrl-shift-enter": "editor::NewlineAbove",
"ctrl-k ctrl-z": "editor::ToggleSoftWrap",
"ctrl-k z": "editor::ToggleSoftWrap",
"find": "buffer_search::Deploy",

View File

@@ -1583,7 +1583,7 @@
"ensure_final_newline_on_save": false
},
"Elixir": {
"language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."]
"language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."]
},
"Elm": {
"tab_size": 4
@@ -1608,7 +1608,7 @@
}
},
"HEEX": {
"language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."]
"language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."]
},
"HTML": {
"prettier": {

View File

@@ -19,7 +19,6 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
action_log.workspace = true
agent-client-protocol.workspace = true
anyhow.workspace = true
agent_settings.workspace = true
buffer_diff.workspace = true
collections.workspace = true
editor.workspace = true
@@ -31,21 +30,18 @@ language.workspace = true
language_model.workspace = true
markdown.workspace = true
parking_lot = { workspace = true, optional = true }
portable-pty.workspace = true
project.workspace = true
prompt_store.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
task.workspace = true
terminal.workspace = true
ui.workspace = true
url.workspace = true
util.workspace = true
uuid.workspace = true
watch.workspace = true
which.workspace = true
workspace-hack.workspace = true
[dev-dependencies]

View File

@@ -3,20 +3,17 @@ mod diff;
mod mention;
mod terminal;
use agent_settings::AgentSettings;
use collections::HashSet;
pub use connection::*;
pub use diff::*;
use futures::future::Shared;
use language::language_settings::FormatOnSave;
pub use mention::*;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use serde::{Deserialize, Serialize};
use settings::Settings as _;
pub use terminal::*;
use action_log::ActionLog;
use agent_client_protocol::{self as acp};
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
use editor::Bias;
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
@@ -34,8 +31,7 @@ use std::rc::Rc;
use std::time::{Duration, Instant};
use std::{fmt::Display, mem, path::PathBuf, sync::Arc};
use ui::App;
use util::{ResultExt, get_system_shell};
use uuid::Uuid;
use util::ResultExt;
#[derive(Debug)]
pub struct UserMessage {
@@ -185,46 +181,37 @@ impl ToolCall {
tool_call: acp::ToolCall,
status: ToolCallStatus,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Result<Self> {
) -> Self {
let title = if let Some((first_line, _)) = tool_call.title.split_once("\n") {
first_line.to_owned() + ""
} else {
tool_call.title
};
let mut content = Vec::with_capacity(tool_call.content.len());
for item in tool_call.content {
content.push(ToolCallContent::from_acp(
item,
language_registry.clone(),
terminals,
cx,
)?);
}
let result = Self {
Self {
id: tool_call.id,
label: cx
.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)),
kind: tool_call.kind,
content,
content: tool_call
.content
.into_iter()
.map(|content| ToolCallContent::from_acp(content, language_registry.clone(), cx))
.collect(),
locations: tool_call.locations,
resolved_locations: Vec::default(),
status,
raw_input: tool_call.raw_input,
raw_output: tool_call.raw_output,
};
Ok(result)
}
}
fn update_fields(
&mut self,
fields: acp::ToolCallUpdateFields,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Result<()> {
) {
let acp::ToolCallUpdateFields {
kind,
status,
@@ -259,15 +246,14 @@ impl ToolCall {
// Reuse existing content if we can
for (old, new) in self.content.iter_mut().zip(content.by_ref()) {
old.update_from_acp(new, language_registry.clone(), terminals, cx)?;
old.update_from_acp(new, language_registry.clone(), cx);
}
for new in content {
self.content.push(ToolCallContent::from_acp(
new,
language_registry.clone(),
terminals,
cx,
)?)
))
}
self.content.truncate(new_content_len);
}
@@ -291,7 +277,6 @@ impl ToolCall {
}
self.raw_output = Some(raw_output);
}
Ok(())
}
pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
@@ -562,16 +547,13 @@ impl ToolCallContent {
pub fn from_acp(
content: acp::ToolCallContent,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Result<Self> {
) -> Self {
match content {
acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new(
content,
&language_registry,
cx,
))),
acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| {
acp::ToolCallContent::Content { content } => {
Self::ContentBlock(ContentBlock::new(content, &language_registry, cx))
}
acp::ToolCallContent::Diff { diff } => Self::Diff(cx.new(|cx| {
Diff::finalized(
diff.path,
diff.old_text,
@@ -579,12 +561,7 @@ impl ToolCallContent {
language_registry,
cx,
)
}))),
acp::ToolCallContent::Terminal { terminal_id } => terminals
.get(&terminal_id)
.cloned()
.map(Self::Terminal)
.ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)),
})),
}
}
@@ -592,9 +569,8 @@ impl ToolCallContent {
&mut self,
new: acp::ToolCallContent,
language_registry: Arc<LanguageRegistry>,
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Result<()> {
) {
let needs_update = match (&self, &new) {
(Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => {
old_diff.read(cx).needs_update(
@@ -607,9 +583,8 @@ impl ToolCallContent {
};
if needs_update {
*self = Self::from_acp(new, language_registry, terminals, cx)?;
*self = Self::from_acp(new, language_registry, cx);
}
Ok(())
}
pub fn to_markdown(&self, cx: &App) -> String {
@@ -785,10 +760,7 @@ pub struct AcpThread {
session_id: acp::SessionId,
token_usage: Option<TokenUsage>,
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
_observe_prompt_capabilities: Task<anyhow::Result<()>>,
determine_shell: Shared<Task<String>>,
terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
}
#[derive(Debug)]
@@ -804,7 +776,6 @@ pub enum AcpThreadEvent {
Error,
LoadError(LoadError),
PromptCapabilitiesUpdated,
Refusal,
}
impl EventEmitter<AcpThreadEvent> for AcpThread {}
@@ -818,12 +789,11 @@ pub enum ThreadStatus {
#[derive(Debug, Clone)]
pub enum LoadError {
NotInstalled,
Unsupported {
command: SharedString,
current_version: SharedString,
minimum_version: SharedString,
},
FailedToInstall(SharedString),
Exited {
status: ExitStatus,
},
@@ -833,19 +803,15 @@ pub enum LoadError {
impl Display for LoadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::NotInstalled => write!(f, "not installed"),
LoadError::Unsupported {
command: path,
current_version,
minimum_version,
} => {
write!(
f,
"version {current_version} from {path} is not supported (need at least {minimum_version})"
)
write!(f, "version {current_version} from {path} is not supported")
}
LoadError::FailedToInstall(msg) => write!(f, "Failed to install: {msg}"),
LoadError::Exited { status } => write!(f, "Server exited with status {status}"),
LoadError::Other(msg) => write!(f, "{msg}"),
LoadError::Other(msg) => write!(f, "{}", msg),
}
}
}
@@ -860,7 +826,6 @@ impl AcpThread {
action_log: Entity<ActionLog>,
session_id: acp::SessionId,
mut prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
available_commands: Vec<acp::AvailableCommand>,
cx: &mut Context<Self>,
) -> Self {
let prompt_capabilities = *prompt_capabilities_rx.borrow();
@@ -874,20 +839,6 @@ impl AcpThread {
}
});
let determine_shell = cx
.background_spawn(async move {
if cfg!(windows) {
return get_system_shell();
}
if which::which("bash").is_ok() {
"bash".into()
} else {
get_system_shell()
}
})
.shared();
Self {
action_log,
shared_buffers: Default::default(),
@@ -900,10 +851,7 @@ impl AcpThread {
session_id,
token_usage: None,
prompt_capabilities,
available_commands,
_observe_prompt_capabilities: task,
terminals: HashMap::default(),
determine_shell,
}
}
@@ -911,10 +859,6 @@ impl AcpThread {
self.prompt_capabilities
}
pub fn available_commands(&self) -> Vec<acp::AvailableCommand> {
self.available_commands.clone()
}
pub fn connection(&self) -> &Rc<dyn AgentConnection> {
&self.connection
}
@@ -1131,28 +1075,27 @@ impl AcpThread {
let update = update.into();
let languages = self.project.read(cx).languages().clone();
let ix = self
.index_for_tool_call(update.id())
let (ix, current_call) = self
.tool_call_mut(update.id())
.context("Tool call not found")?;
let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
unreachable!()
};
match update {
ToolCallUpdate::UpdateFields(update) => {
let location_updated = update.fields.locations.is_some();
call.update_fields(update.fields, languages, &self.terminals, cx)?;
current_call.update_fields(update.fields, languages, cx);
if location_updated {
self.resolve_locations(update.id, cx);
}
}
ToolCallUpdate::UpdateDiff(update) => {
call.content.clear();
call.content.push(ToolCallContent::Diff(update.diff));
current_call.content.clear();
current_call
.content
.push(ToolCallContent::Diff(update.diff));
}
ToolCallUpdate::UpdateTerminal(update) => {
call.content.clear();
call.content
current_call.content.clear();
current_call
.content
.push(ToolCallContent::Terminal(update.terminal));
}
}
@@ -1175,30 +1118,21 @@ impl AcpThread {
/// Fails if id does not match an existing entry.
pub fn upsert_tool_call_inner(
&mut self,
update: acp::ToolCallUpdate,
tool_call_update: acp::ToolCallUpdate,
status: ToolCallStatus,
cx: &mut Context<Self>,
) -> Result<(), acp::Error> {
let language_registry = self.project.read(cx).languages().clone();
let id = update.id.clone();
let id = tool_call_update.id.clone();
if let Some(ix) = self.index_for_tool_call(&id) {
let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
unreachable!()
};
call.update_fields(update.fields, language_registry, &self.terminals, cx)?;
call.status = status;
if let Some((ix, current_call)) = self.tool_call_mut(&id) {
current_call.update_fields(tool_call_update.fields, language_registry, cx);
current_call.status = status;
cx.emit(AcpThreadEvent::EntryUpdated(ix));
} else {
let call = ToolCall::from_acp(
update.try_into()?,
status,
language_registry,
&self.terminals,
cx,
)?;
let call =
ToolCall::from_acp(tool_call_update.try_into()?, status, language_registry, cx);
self.push_entry(AgentThreadEntry::ToolCall(call), cx);
};
@@ -1206,22 +1140,6 @@ impl AcpThread {
Ok(())
}
fn index_for_tool_call(&self, id: &acp::ToolCallId) -> Option<usize> {
self.entries
.iter()
.enumerate()
.rev()
.find_map(|(index, entry)| {
if let AgentThreadEntry::ToolCall(tool_call) = entry
&& &tool_call.id == id
{
Some(index)
} else {
None
}
})
}
fn tool_call_mut(&mut self, id: &acp::ToolCallId) -> Option<(usize, &mut ToolCall)> {
// The tool call we are looking for is typically the last one, or very close to the end.
// At the moment, it doesn't seem like a hashmap would be a good fit for this use case.
@@ -1307,29 +1225,9 @@ impl AcpThread {
tool_call: acp::ToolCallUpdate,
options: Vec<acp::PermissionOption>,
cx: &mut Context<Self>,
) -> Result<BoxFuture<'static, acp::RequestPermissionOutcome>> {
) -> Result<oneshot::Receiver<acp::PermissionOptionId>, acp::Error> {
let (tx, rx) = oneshot::channel();
if AgentSettings::get_global(cx).always_allow_tool_actions {
// Don't use AllowAlways, because then if you were to turn off always_allow_tool_actions,
// some tools would (incorrectly) continue to auto-accept.
if let Some(allow_once_option) = options.iter().find_map(|option| {
if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) {
Some(option.id.clone())
} else {
None
}
}) {
self.upsert_tool_call_inner(tool_call, ToolCallStatus::Pending, cx)?;
return Ok(async {
acp::RequestPermissionOutcome::Selected {
option_id: allow_once_option,
}
}
.boxed());
}
}
let status = ToolCallStatus::WaitingForConfirmation {
options,
respond_tx: tx,
@@ -1337,16 +1235,7 @@ impl AcpThread {
self.upsert_tool_call_inner(tool_call, status, cx)?;
cx.emit(AcpThreadEvent::ToolAuthorizationRequired);
let fut = async {
match rx.await {
Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option },
Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled,
}
}
.boxed();
Ok(fut)
Ok(rx)
}
pub fn authorize_tool_call(
@@ -1570,42 +1459,15 @@ impl AcpThread {
this.send_task.take();
}
// Handle refusal - distinguish between user prompt and tool call refusals
// Truncate entries if the last prompt was refused.
if let Ok(Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
})) = result
&& let Some((ix, _)) = this.last_user_message()
{
if let Some((user_msg_ix, _)) = this.last_user_message() {
// Check if there's a completed tool call with results after the last user message
// This indicates the refusal is in response to tool output, not the user's prompt
let has_completed_tool_call_after_user_msg =
this.entries.iter().skip(user_msg_ix + 1).any(|entry| {
if let AgentThreadEntry::ToolCall(tool_call) = entry {
// Check if the tool call has completed and has output
matches!(tool_call.status, ToolCallStatus::Completed)
&& tool_call.raw_output.is_some()
} else {
false
}
});
if has_completed_tool_call_after_user_msg {
// Refusal is due to tool output - don't truncate, just notify
// The model refused based on what the tool returned
cx.emit(AcpThreadEvent::Refusal);
} else {
// User prompt was refused - truncate back to before the user message
let range = user_msg_ix..this.entries.len();
if range.start < range.end {
this.entries.truncate(user_msg_ix);
cx.emit(AcpThreadEvent::EntriesRemoved(range));
}
cx.emit(AcpThreadEvent::Refusal);
}
} else {
// No user message found, treat as general refusal
cx.emit(AcpThreadEvent::Refusal);
}
let range = ix..this.entries.len();
this.entries.truncate(ix);
cx.emit(AcpThreadEvent::EntriesRemoved(range));
}
cx.emit(AcpThreadEvent::Stopped);
@@ -1931,133 +1793,6 @@ impl AcpThread {
})
}
pub fn create_terminal(
&self,
mut command: String,
args: Vec<String>,
extra_env: Vec<acp::EnvVariable>,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
for arg in args {
command.push(' ');
command.push_str(&arg);
}
let shell_command = if cfg!(windows) {
format!("$null | & {{{}}}", command.replace("\"", "'"))
} else if let Some(cwd) = cwd.as_ref().and_then(|cwd| cwd.as_os_str().to_str()) {
// Make sure once we're *inside* the shell, we cd into `cwd`
format!("(cd {cwd}; {}) </dev/null", command)
} else {
format!("({}) </dev/null", command)
};
let args = vec!["-c".into(), shell_command];
let env = match &cwd {
Some(dir) => self.project.update(cx, |project, cx| {
project.directory_environment(dir.as_path().into(), cx)
}),
None => Task::ready(None).shared(),
};
let env = cx.spawn(async move |_, _| {
let mut env = env.await.unwrap_or_default();
if cfg!(unix) {
env.insert("PAGER".into(), "cat".into());
}
for var in extra_env {
env.insert(var.name, var.value);
}
env
});
let project = self.project.clone();
let language_registry = project.read(cx).languages().clone();
let determine_shell = self.determine_shell.clone();
let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into());
let terminal_task = cx.spawn({
let terminal_id = terminal_id.clone();
async move |_this, cx| {
let program = determine_shell.await;
let env = env.await;
let terminal = project
.update(cx, |project, cx| {
project.create_terminal_task(
task::SpawnInTerminal {
command: Some(program),
args,
cwd: cwd.clone(),
env,
..Default::default()
},
cx,
)
})?
.await?;
cx.new(|cx| {
Terminal::new(
terminal_id,
command,
cwd,
output_byte_limit.map(|l| l as usize),
terminal,
language_registry,
cx,
)
})
}
});
cx.spawn(async move |this, cx| {
let terminal = terminal_task.await?;
this.update(cx, |this, _cx| {
this.terminals.insert(terminal_id, terminal.clone());
terminal
})
})
}
pub fn kill_terminal(
&mut self,
terminal_id: acp::TerminalId,
cx: &mut Context<Self>,
) -> Result<()> {
self.terminals
.get(&terminal_id)
.context("Terminal not found")?
.update(cx, |terminal, cx| {
terminal.kill(cx);
});
Ok(())
}
pub fn release_terminal(
&mut self,
terminal_id: acp::TerminalId,
cx: &mut Context<Self>,
) -> Result<()> {
self.terminals
.remove(&terminal_id)
.context("Terminal not found")?
.update(cx, |terminal, cx| {
terminal.kill(cx);
});
Ok(())
}
pub fn terminal(&self, terminal_id: acp::TerminalId) -> Result<Entity<Terminal>> {
self.terminals
.get(&terminal_id)
.context("Terminal not found")
.cloned()
}
pub fn to_markdown(&self, cx: &App) -> String {
self.entries.iter().map(|e| e.to_markdown(cx)).collect()
}
@@ -2709,187 +2444,6 @@ mod tests {
assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
}
#[gpui::test]
async fn test_tool_result_refusal(cx: &mut TestAppContext) {
use std::sync::atomic::AtomicUsize;
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
// Create a connection that simulates refusal after tool result
let prompt_count = Arc::new(AtomicUsize::new(0));
let connection = Rc::new(FakeAgentConnection::new().on_user_message({
let prompt_count = prompt_count.clone();
move |_request, thread, mut cx| {
let count = prompt_count.fetch_add(1, SeqCst);
async move {
if count == 0 {
// First prompt: Generate a tool call with result
thread.update(&mut cx, |thread, cx| {
thread
.handle_session_update(
acp::SessionUpdate::ToolCall(acp::ToolCall {
id: acp::ToolCallId("tool1".into()),
title: "Test Tool".into(),
kind: acp::ToolKind::Fetch,
status: acp::ToolCallStatus::Completed,
content: vec![],
locations: vec![],
raw_input: Some(serde_json::json!({"query": "test"})),
raw_output: Some(
serde_json::json!({"result": "inappropriate content"}),
),
}),
cx,
)
.unwrap();
})?;
// Now return refusal because of the tool result
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
})
} else {
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::EndTurn,
})
}
}
.boxed_local()
}
}));
let thread = cx
.update(|cx| connection.new_thread(project, Path::new("/test"), cx))
.await
.unwrap();
// Track if we see a Refusal event
let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
let saw_refusal_event_captured = saw_refusal_event.clone();
thread.update(cx, |_thread, cx| {
cx.subscribe(
&thread,
move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
if matches!(event, AcpThreadEvent::Refusal) {
*saw_refusal_event_captured.lock().unwrap() = true;
}
},
)
.detach();
});
// Send a user message - this will trigger tool call and then refusal
let send_task = thread.update(cx, |thread, cx| {
thread.send(
vec![acp::ContentBlock::Text(acp::TextContent {
text: "Hello".into(),
annotations: None,
})],
cx,
)
});
cx.background_executor.spawn(send_task).detach();
cx.run_until_parked();
// Verify that:
// 1. A Refusal event WAS emitted (because it's a tool result refusal, not user prompt)
// 2. The user message was NOT truncated
assert!(
*saw_refusal_event.lock().unwrap(),
"Refusal event should be emitted for tool result refusals"
);
thread.read_with(cx, |thread, _| {
let entries = thread.entries();
assert!(entries.len() >= 2, "Should have user message and tool call");
// Verify user message is still there
assert!(
matches!(entries[0], AgentThreadEntry::UserMessage(_)),
"User message should not be truncated"
);
// Verify tool call is there with result
if let AgentThreadEntry::ToolCall(tool_call) = &entries[1] {
assert!(
tool_call.raw_output.is_some(),
"Tool call should have output"
);
} else {
panic!("Expected tool call at index 1");
}
});
}
#[gpui::test]
async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let refuse_next = Arc::new(AtomicBool::new(false));
let connection = Rc::new(FakeAgentConnection::new().on_user_message({
let refuse_next = refuse_next.clone();
move |_request, _thread, _cx| {
if refuse_next.load(SeqCst) {
async move {
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
})
}
.boxed_local()
} else {
async move {
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::EndTurn,
})
}
.boxed_local()
}
}
}));
let thread = cx
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
// Track if we see a Refusal event
let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
let saw_refusal_event_captured = saw_refusal_event.clone();
thread.update(cx, |_thread, cx| {
cx.subscribe(
&thread,
move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
if matches!(event, AcpThreadEvent::Refusal) {
*saw_refusal_event_captured.lock().unwrap() = true;
}
},
)
.detach();
});
// Send a message that will be refused
refuse_next.store(true, SeqCst);
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)))
.await
.unwrap();
// Verify that a Refusal event WAS emitted for user prompt refusal
assert!(
*saw_refusal_event.lock().unwrap(),
"Refusal event should be emitted for user prompt refusals"
);
// Verify the message was truncated (user prompt refusal)
thread.read_with(cx, |thread, cx| {
assert_eq!(thread.to_markdown(cx), "");
});
}
#[gpui::test]
async fn test_refusal(cx: &mut TestAppContext) {
init_test(cx);
@@ -2953,8 +2507,8 @@ mod tests {
);
});
// Simulate refusing the second message. The message should be truncated
// when a user prompt is refused.
// Simulate refusing the second message, ensuring the conversation gets
// truncated to before sending it.
refuse_next.store(true, SeqCst);
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx)))
.await
@@ -3080,7 +2634,6 @@ mod tests {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
});

View File

@@ -75,6 +75,7 @@ pub trait AgentConnection {
fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
None
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
}
@@ -231,13 +232,6 @@ impl AgentModelList {
AgentModelList::Grouped(groups) => groups.is_empty(),
}
}
pub fn len(&self) -> usize {
match self {
AgentModelList::Flat(models) => models.len(),
AgentModelList::Grouped(groups) => groups.values().len(),
}
}
}
#[cfg(feature = "test-support")]
@@ -345,7 +339,6 @@ mod test_support {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
});
@@ -400,15 +393,14 @@ mod test_support {
};
let task = cx.spawn(async move |cx| {
if let Some((tool_call, options)) = permission_request {
thread
.update(cx, |thread, cx| {
thread.request_tool_call_authorization(
tool_call.clone().into(),
options.clone(),
cx,
)
})??
.await;
let permission = thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(
tool_call.clone().into(),
options.clone(),
cx,
)
})?;
permission?.await?;
}
thread.update(cx, |thread, cx| {
thread.handle_session_update(update.clone(), cx).unwrap();

View File

@@ -1,43 +1,34 @@
use agent_client_protocol as acp;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, Context, Entity, Task};
use gpui::{App, AppContext, Context, Entity};
use language::LanguageRegistry;
use markdown::Markdown;
use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant};
pub struct Terminal {
id: acp::TerminalId,
command: Entity<Markdown>,
working_dir: Option<PathBuf>,
terminal: Entity<terminal::Terminal>,
started_at: Instant,
output: Option<TerminalOutput>,
output_byte_limit: Option<usize>,
_output_task: Shared<Task<acp::TerminalExitStatus>>,
}
pub struct TerminalOutput {
pub ended_at: Instant,
pub exit_status: Option<ExitStatus>,
pub content: String,
pub was_content_truncated: bool,
pub original_content_len: usize,
pub content_line_count: usize,
pub finished_with_empty_output: bool,
}
impl Terminal {
pub fn new(
id: acp::TerminalId,
command: String,
working_dir: Option<PathBuf>,
output_byte_limit: Option<usize>,
terminal: Entity<terminal::Terminal>,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> Self {
let command_task = terminal.read(cx).wait_for_completed_task(cx);
Self {
id,
command: cx.new(|cx| {
Markdown::new(
format!("```\n{}\n```", command).into(),
@@ -50,93 +41,27 @@ impl Terminal {
terminal,
started_at: Instant::now(),
output: None,
output_byte_limit,
_output_task: cx
.spawn(async move |this, cx| {
let exit_status = command_task.await;
this.update(cx, |this, cx| {
let (content, original_content_len) = this.truncated_output(cx);
let content_line_count = this.terminal.read(cx).total_lines();
this.output = Some(TerminalOutput {
ended_at: Instant::now(),
exit_status,
content,
original_content_len,
content_line_count,
});
cx.notify();
})
.ok();
let exit_status = exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalExitStatus {
exit_code: exit_status.as_ref().map(|e| e.exit_code()),
signal: exit_status.and_then(|e| e.signal().map(Into::into)),
}
})
.shared(),
}
}
pub fn id(&self) -> &acp::TerminalId {
&self.id
}
pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
self._output_task.clone()
}
pub fn kill(&mut self, cx: &mut App) {
self.terminal.update(cx, |terminal, _cx| {
terminal.kill_active_task();
pub fn finish(
&mut self,
exit_status: Option<ExitStatus>,
original_content_len: usize,
truncated_content_len: usize,
content_line_count: usize,
finished_with_empty_output: bool,
cx: &mut Context<Self>,
) {
self.output = Some(TerminalOutput {
ended_at: Instant::now(),
exit_status,
was_content_truncated: truncated_content_len < original_content_len,
original_content_len,
content_line_count,
finished_with_empty_output,
});
}
pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
if let Some(output) = self.output.as_ref() {
let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalOutputResponse {
output: output.content.clone(),
truncated: output.original_content_len > output.content.len(),
exit_status: Some(acp::TerminalExitStatus {
exit_code: exit_status.as_ref().map(|e| e.exit_code()),
signal: exit_status.and_then(|e| e.signal().map(Into::into)),
}),
}
} else {
let (current_content, original_len) = self.truncated_output(cx);
acp::TerminalOutputResponse {
truncated: current_content.len() < original_len,
output: current_content,
exit_status: None,
}
}
}
fn truncated_output(&self, cx: &App) -> (String, usize) {
let terminal = self.terminal.read(cx);
let mut content = terminal.get_content();
let original_content_len = content.len();
if let Some(limit) = self.output_byte_limit
&& content.len() > limit
{
let mut end_ix = limit.min(content.len());
while !content.is_char_boundary(end_ix) {
end_ix -= 1;
}
// Don't truncate mid-line, clear the remainder of the last line
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
content.truncate(end_ix);
}
(content, original_content_len)
cx.notify();
}
pub fn command(&self) -> &Entity<Markdown> {

View File

@@ -48,6 +48,7 @@ log.workspace = true
open.workspace = true
parking_lot.workspace = true
paths.workspace = true
portable-pty.workspace = true
project.workspace = true
prompt_store.workspace = true
rust-embed.workspace = true
@@ -67,6 +68,7 @@ util.workspace = true
uuid.workspace = true
watch.workspace = true
web_search.workspace = true
which.workspace = true
workspace-hack.workspace = true
zstd.workspace = true

View File

@@ -2,7 +2,7 @@ use crate::{
ContextServerRegistry, Thread, ThreadEvent, ThreadsDatabase, ToolCallAuthorization,
UserMessageContent, templates::Templates,
};
use crate::{HistoryStore, TerminalHandle, ThreadEnvironment, TitleUpdated, TokenUsageUpdated};
use crate::{HistoryStore, TitleUpdated, TokenUsageUpdated};
use acp_thread::{AcpThread, AgentModelSelector};
use action_log::ActionLog;
use agent_client_protocol as acp;
@@ -10,8 +10,7 @@ use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
use futures::channel::mpsc;
use futures::{StreamExt, future};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
@@ -24,7 +23,7 @@ use prompt_store::{
use settings::update_settings_file;
use std::any::Any;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use util::ResultExt;
@@ -94,7 +93,7 @@ impl LanguageModels {
let mut recommended = Vec::new();
for provider in &providers {
for model in provider.recommended_models(cx) {
recommended_models.insert((model.provider_id(), model.id()));
recommended_models.insert(model.id());
recommended.push(Self::map_language_model_to_info(&model, provider));
}
}
@@ -111,7 +110,7 @@ impl LanguageModels {
for model in provider.provided_models(cx) {
let model_info = Self::map_language_model_to_info(&model, &provider);
let model_id = model_info.id.clone();
if !recommended_models.contains(&(model.provider_id(), model.id())) {
if !recommended_models.contains(&model.id()) {
provider_models.push(model_info);
}
models.insert(model_id, model);
@@ -277,6 +276,13 @@ impl NativeAgent {
cx: &mut Context<Self>,
) -> Entity<AcpThread> {
let connection = Rc::new(NativeAgentConnection(cx.entity()));
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
thread.add_default_tools(cx)
});
let thread = thread_handle.read(cx);
let session_id = thread.id().clone();
@@ -292,24 +298,9 @@ impl NativeAgent {
action_log.clone(),
session_id.clone(),
prompt_capabilities_rx,
vec![],
cx,
)
});
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
thread.add_default_tools(
Rc::new(AcpThreadEnvironment {
acp_thread: acp_thread.downgrade(),
}) as _,
cx,
)
});
let subscriptions = vec![
cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
this.sessions.remove(acp_thread.session_id());
@@ -771,15 +762,18 @@ impl NativeAgentConnection {
options,
response,
}) => {
let outcome_task = acp_thread.update(cx, |thread, cx| {
let recv = acp_thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(tool_call, options, cx)
})??;
})?;
cx.background_spawn(async move {
if let acp::RequestPermissionOutcome::Selected { option_id } =
outcome_task.await
if let Some(recv) = recv.log_err()
&& let Some(option) = recv
.await
.context("authorization sender was dropped")
.log_err()
{
response
.send(option_id)
.send(option)
.map(|_| anyhow!("authorization receiver was dropped"))
.log_err();
}
@@ -1010,7 +1004,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
self.0.read_with(cx, |agent, _cx| {
agent.sessions.get(session_id).map(|session| {
Rc::new(NativeAgentSessionTruncate {
Rc::new(NativeAgentSessionEditor {
thread: session.thread.clone(),
acp_thread: session.acp_thread.clone(),
}) as _
@@ -1059,12 +1053,12 @@ impl acp_thread::AgentTelemetry for NativeAgentConnection {
}
}
struct NativeAgentSessionTruncate {
struct NativeAgentSessionEditor {
thread: Entity<Thread>,
acp_thread: WeakEntity<AcpThread>,
}
impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate {
impl acp_thread::AgentSessionTruncate for NativeAgentSessionEditor {
fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task<Result<()>> {
match self.thread.update(cx, |thread, cx| {
thread.truncate(message_id.clone(), cx)?;
@@ -1113,66 +1107,6 @@ impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
}
}
pub struct AcpThreadEnvironment {
acp_thread: WeakEntity<AcpThread>,
}
impl ThreadEnvironment for AcpThreadEnvironment {
fn create_terminal(
&self,
command: String,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn TerminalHandle>>> {
let task = self.acp_thread.update(cx, |thread, cx| {
thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx)
});
let acp_thread = self.acp_thread.clone();
cx.spawn(async move |cx| {
let terminal = task?.await?;
let (drop_tx, drop_rx) = oneshot::channel();
let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone())?;
cx.spawn(async move |cx| {
drop_rx.await.ok();
acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx))
})
.detach();
let handle = AcpTerminalHandle {
terminal,
_drop_tx: Some(drop_tx),
};
Ok(Rc::new(handle) as _)
})
}
}
pub struct AcpTerminalHandle {
terminal: Entity<acp_thread::Terminal>,
_drop_tx: Option<oneshot::Sender<()>>,
}
impl TerminalHandle for AcpTerminalHandle {
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId> {
self.terminal.read_with(cx, |term, _cx| term.id().clone())
}
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
self.terminal
.read_with(cx, |term, _cx| term.wait_for_exit())
}
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
self.terminal
.read_with(cx, |term, cx| term.current_output(cx))
}
}
#[cfg(test)]
mod tests {
use crate::HistoryEntryId;

View File

@@ -1,9 +1,10 @@
use std::{any::Any, path::Path, rc::Rc, sync::Arc};
use agent_servers::{AgentServer, AgentServerDelegate};
use agent_servers::AgentServer;
use anyhow::Result;
use fs::Fs;
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use prompt_store::PromptStore;
use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates};
@@ -29,21 +30,33 @@ impl AgentServer for NativeAgentServer {
"Zed Agent".into()
}
fn empty_state_headline(&self) -> SharedString {
self.name()
}
fn empty_state_message(&self) -> SharedString {
"".into()
}
fn logo(&self) -> ui::IconName {
ui::IconName::ZedAgent
}
fn install_command(&self) -> Option<&'static str> {
None
}
fn connect(
&self,
_root_dir: &Path,
delegate: AgentServerDelegate,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
log::debug!(
"NativeAgentServer::connect called for path: {:?}",
_root_dir
);
let project = delegate.project().clone();
let project = project.clone();
let fs = self.fs.clone();
let history = self.history.clone();
let prompt_store = PromptStore::global(cx);

View File

@@ -72,6 +72,7 @@ async fn test_echo(cx: &mut TestAppContext) {
}
#[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_thinking(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
@@ -949,7 +950,6 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
paths::settings_file(),
json!({
"agent": {
"always_allow_tool_actions": true,
"profiles": {
"test": {
"name": "Test Profile",
@@ -1348,6 +1348,7 @@ async fn test_cancellation(cx: &mut TestAppContext) {
}
#[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
@@ -1686,6 +1687,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) {
}
#[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_title_generation(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
@@ -2350,20 +2352,15 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
settings::init(cx);
Project::init_settings(cx);
agent_settings::init(cx);
gpui_tokio::init(cx);
let http_client = ReqwestClient::user_agent("agent tests").unwrap();
cx.set_http_client(Arc::new(http_client));
match model {
TestModel::Fake => {}
TestModel::Sonnet4 => {
gpui_tokio::init(cx);
let http_client = ReqwestClient::user_agent("agent tests").unwrap();
cx.set_http_client(Arc::new(http_client));
client::init_settings(cx);
let client = Client::production(cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
language_model::init(client.clone(), cx);
language_models::init(user_store, client.clone(), cx);
}
};
client::init_settings(cx);
let client = Client::production(cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
language_model::init(client.clone(), cx);
language_models::init(user_store, client.clone(), cx);
watch_settings(fs.clone(), cx);
});

View File

@@ -45,15 +45,14 @@ use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Serialize};
use settings::{Settings, update_settings_file};
use smol::stream::StreamExt;
use std::fmt::Write;
use std::{
collections::BTreeMap,
ops::RangeInclusive,
path::Path,
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};
use std::{fmt::Write, path::PathBuf};
use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock};
use uuid::Uuid;
@@ -485,15 +484,11 @@ impl AgentMessage {
};
for tool_result in self.tool_results.values() {
let mut tool_result = tool_result.clone();
// Surprisingly, the API fails if we return an empty string here.
// It thinks we are sending a tool use without a tool result.
if tool_result.content.is_empty() {
tool_result.content = "<Tool returned an empty string>".into();
}
user_message
.content
.push(language_model::MessageContent::ToolResult(tool_result));
.push(language_model::MessageContent::ToolResult(
tool_result.clone(),
));
}
let mut messages = Vec::new();
@@ -524,22 +519,6 @@ pub enum AgentMessageContent {
ToolUse(LanguageModelToolUse),
}
pub trait TerminalHandle {
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId>;
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse>;
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>>;
}
pub trait ThreadEnvironment {
fn create_terminal(
&self,
command: String,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn TerminalHandle>>>;
}
#[derive(Debug)]
pub enum ThreadEvent {
UserMessage(UserMessage),
@@ -552,14 +531,6 @@ pub enum ThreadEvent {
Stop(acp::StopReason),
}
#[derive(Debug)]
pub struct NewTerminal {
pub command: String,
pub output_byte_limit: Option<u64>,
pub cwd: Option<PathBuf>,
pub response: oneshot::Sender<Result<Entity<acp_thread::Terminal>>>,
}
#[derive(Debug)]
pub struct ToolCallAuthorization {
pub tool_call: acp::ToolCallUpdate,
@@ -1049,11 +1020,7 @@ impl Thread {
}
}
pub fn add_default_tools(
&mut self,
environment: Rc<dyn ThreadEnvironment>,
cx: &mut Context<Self>,
) {
pub fn add_default_tools(&mut self, cx: &mut Context<Self>) {
let language_registry = self.project.read(cx).languages().clone();
self.add_tool(CopyPathTool::new(self.project.clone()));
self.add_tool(CreateDirectoryTool::new(self.project.clone()));
@@ -1074,7 +1041,7 @@ impl Thread {
self.project.clone(),
self.action_log.clone(),
));
self.add_tool(TerminalTool::new(self.project.clone(), environment));
self.add_tool(TerminalTool::new(self.project.clone(), cx));
self.add_tool(ThinkingTool);
self.add_tool(WebSearchTool);
}
@@ -2418,6 +2385,19 @@ impl ToolCallEventStream {
.ok();
}
pub fn update_terminal(&self, terminal: Entity<acp_thread::Terminal>) {
self.stream
.0
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
acp_thread::ToolCallUpdateTerminal {
id: acp::ToolCallId(self.tool_use_id.to_string().into()),
terminal,
}
.into(),
)))
.ok();
}
pub fn authorize(&self, title: impl Into<String>, cx: &mut App) -> Task<Result<()>> {
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
return Task::ready(Ok(()));

View File

@@ -169,18 +169,15 @@ impl AnyAgentTool for ContextServerTool {
fn run(
self: Arc<Self>,
input: serde_json::Value,
event_stream: ToolCallEventStream,
_event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<AgentToolOutput>> {
let Some(server) = self.store.read(cx).get_running_server(&self.server_id) else {
return Task::ready(Err(anyhow!("Context server not found")));
};
let tool_name = self.tool.name.clone();
let authorize = event_stream.authorize(self.initial_title(input.clone()), cx);
cx.spawn(async move |_cx| {
authorize.await?;
let Some(protocol) = server.client() else {
bail!("Context server not initialized");
};

View File

@@ -1,19 +1,19 @@
use agent_client_protocol as acp;
use anyhow::Result;
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, Entity, SharedString, Task};
use project::{Project, terminals::TerminalKind};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use util::markdown::MarkdownInlineCode;
use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode};
use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream};
use crate::{AgentTool, ToolCallEventStream};
const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
/// Executes a shell one-liner and returns the combined output.
///
@@ -36,14 +36,25 @@ pub struct TerminalToolInput {
pub struct TerminalTool {
project: Entity<Project>,
environment: Rc<dyn ThreadEnvironment>,
determine_shell: Shared<Task<String>>,
}
impl TerminalTool {
pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
pub fn new(project: Entity<Project>, cx: &mut App) -> Self {
let determine_shell = cx.background_spawn(async move {
if cfg!(windows) {
return get_system_shell();
}
if which::which("bash").is_ok() {
"bash".into()
} else {
get_system_shell()
}
});
Self {
project,
environment,
determine_shell: determine_shell.shared(),
}
}
}
@@ -88,49 +99,128 @@ impl AgentTool for TerminalTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
let language_registry = self.project.read(cx).languages().clone();
let working_dir = match working_dir(&input, &self.project, cx) {
Ok(dir) => dir,
Err(err) => return Task::ready(Err(err)),
};
let program = self.determine_shell.clone();
let command = if cfg!(windows) {
format!("$null | & {{{}}}", input.command.replace("\"", "'"))
} else if let Some(cwd) = working_dir
.as_ref()
.and_then(|cwd| cwd.as_os_str().to_str())
{
// Make sure once we're *inside* the shell, we cd into `cwd`
format!("(cd {cwd}; {}) </dev/null", input.command)
} else {
format!("({}) </dev/null", input.command)
};
let args = vec!["-c".into(), command];
let env = match &working_dir {
Some(dir) => self.project.update(cx, |project, cx| {
project.directory_environment(dir.as_path().into(), cx)
}),
None => Task::ready(None).shared(),
};
let env = cx.spawn(async move |_| {
let mut env = env.await.unwrap_or_default();
if cfg!(unix) {
env.insert("PAGER".into(), "cat".into());
}
env
});
let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx);
cx.spawn(async move |cx| {
authorize.await?;
let terminal = self
.environment
.create_terminal(
input.command.clone(),
working_dir,
Some(COMMAND_OUTPUT_LIMIT),
cx,
)
.await?;
cx.spawn({
async move |cx| {
authorize.await?;
let terminal_id = terminal.id(cx)?;
event_stream.update_fields(acp::ToolCallUpdateFields {
content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]),
..Default::default()
});
let program = program.await;
let env = env.await;
let terminal = self
.project
.update(cx, |project, cx| {
project.create_terminal(
TerminalKind::Task(task::SpawnInTerminal {
command: Some(program),
args,
cwd: working_dir.clone(),
env,
..Default::default()
}),
cx,
)
})?
.await?;
let acp_terminal = cx.new(|cx| {
acp_thread::Terminal::new(
input.command.clone(),
working_dir.clone(),
terminal.clone(),
language_registry,
cx,
)
})?;
event_stream.update_terminal(acp_terminal.clone());
let exit_status = terminal.wait_for_exit(cx)?.await;
let output = terminal.current_output(cx)?;
let exit_status = terminal
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
.await;
let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
(terminal.get_content(), terminal.total_lines())
})?;
Ok(process_content(output, &input.command, exit_status))
let (processed_content, finished_with_empty_output) = process_content(
&content,
&input.command,
exit_status.map(portable_pty::ExitStatus::from),
);
acp_terminal
.update(cx, |terminal, cx| {
terminal.finish(
exit_status,
content.len(),
processed_content.len(),
content_line_count,
finished_with_empty_output,
cx,
);
})
.log_err();
Ok(processed_content)
}
})
}
}
fn process_content(
output: acp::TerminalOutputResponse,
content: &str,
command: &str,
exit_status: acp::TerminalExitStatus,
) -> String {
let content = output.output.trim();
let is_empty = content.is_empty();
exit_status: Option<portable_pty::ExitStatus>,
) -> (String, bool) {
let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
let content = if should_truncate {
let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
while !content.is_char_boundary(end_ix) {
end_ix -= 1;
}
// Don't truncate mid-line, clear the remainder of the last line
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
&content[..end_ix]
} else {
content
};
let content = content.trim();
let is_empty = content.is_empty();
let content = format!("```\n{content}\n```");
let content = if output.truncated {
let content = if should_truncate {
format!(
"Command output too long. The first {} bytes:\n\n{content}",
content.len(),
@@ -139,21 +229,24 @@ fn process_content(
content
};
let content = match exit_status.exit_code {
Some(0) => {
let content = match exit_status {
Some(exit_status) if exit_status.success() => {
if is_empty {
"Command executed successfully.".to_string()
} else {
content
}
}
Some(exit_code) => {
Some(exit_status) => {
if is_empty {
format!("Command \"{command}\" failed with exit code {}.", exit_code)
format!(
"Command \"{command}\" failed with exit code {}.",
exit_status.exit_code()
)
} else {
format!(
"Command \"{command}\" failed with exit code {}.\n\n{content}",
exit_code
exit_status.exit_code()
)
}
}
@@ -164,7 +257,7 @@ fn process_content(
)
}
};
content
(content, is_empty)
}
fn working_dir(
@@ -207,3 +300,169 @@ fn working_dir(
anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
}
}
#[cfg(test)]
mod tests {
use agent_settings::AgentSettings;
use editor::EditorSettings;
use fs::RealFs;
use gpui::{BackgroundExecutor, TestAppContext};
use pretty_assertions::assert_eq;
use serde_json::json;
use settings::{Settings, SettingsStore};
use terminal::terminal_settings::TerminalSettings;
use theme::ThemeSettings;
use util::test::TempTree;
use crate::ThreadEvent;
use super::*;
fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
zlog::init_test();
executor.allow_parking();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
ThemeSettings::register(cx);
TerminalSettings::register(cx);
EditorSettings::register(cx);
AgentSettings::register(cx);
});
}
#[gpui::test]
async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
if cfg!(windows) {
return;
}
init_test(&executor, cx);
let fs = Arc::new(RealFs::new(None, executor));
let tree = TempTree::new(json!({
"project": {},
}));
let project: Entity<Project> =
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
let input = TerminalToolInput {
command: "cat".to_owned(),
cd: tree
.path()
.join("project")
.as_path()
.to_string_lossy()
.to_string(),
};
let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
let auth = event_stream_rx.expect_authorization().await;
auth.response.send(auth.options[0].id.clone()).unwrap();
event_stream_rx.expect_terminal().await;
assert_eq!(result.await.unwrap(), "Command executed successfully.");
}
#[gpui::test]
async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
if cfg!(windows) {
return;
}
init_test(&executor, cx);
let fs = Arc::new(RealFs::new(None, executor));
let tree = TempTree::new(json!({
"project": {},
"other-project": {},
}));
let project: Entity<Project> =
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
let check = |input, expected, cx: &mut TestAppContext| {
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let result = cx.update(|cx| {
Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
});
cx.run_until_parked();
let event = stream_rx.try_next();
if let Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(auth)))) = event {
auth.response.send(auth.options[0].id.clone()).unwrap();
}
cx.spawn(async move |_| {
let output = result.await;
assert_eq!(output.ok(), expected);
})
};
check(
TerminalToolInput {
command: "pwd".into(),
cd: ".".into(),
},
Some(format!(
"```\n{}\n```",
tree.path().join("project").display()
)),
cx,
)
.await;
check(
TerminalToolInput {
command: "pwd".into(),
cd: "other-project".into(),
},
None, // other-project is a dir, but *not* a worktree (yet)
cx,
)
.await;
// Absolute path above the worktree root
check(
TerminalToolInput {
command: "pwd".into(),
cd: tree.path().to_string_lossy().into(),
},
None,
cx,
)
.await;
project
.update(cx, |project, cx| {
project.create_worktree(tree.path().join("other-project"), true, cx)
})
.await
.unwrap();
check(
TerminalToolInput {
command: "pwd".into(),
cd: "other-project".into(),
},
Some(format!(
"```\n{}\n```",
tree.path().join("other-project").display()
)),
cx,
)
.await;
check(
TerminalToolInput {
command: "pwd".into(),
cd: ".".into(),
},
None,
cx,
)
.await;
}
}

View File

@@ -6,7 +6,7 @@ publish.workspace = true
license = "GPL-3.0-or-later"
[features]
test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "fs", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
e2e = []
[lints]
@@ -25,19 +25,21 @@ agent_settings.workspace = true
anyhow.workspace = true
client = { workspace = true, optional = true }
collections.workspace = true
context_server.workspace = true
env_logger = { workspace = true, optional = true }
fs.workspace = true
fs = { workspace = true, optional = true }
futures.workspace = true
gpui.workspace = true
gpui_tokio = { workspace = true, optional = true }
indoc.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
node_runtime.workspace = true
paths.workspace = true
project.workspace = true
rand.workspace = true
reqwest_client = { workspace = true, optional = true }
schemars.workspace = true
semver.workspace = true
@@ -45,10 +47,12 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
strum.workspace = true
tempfile.workspace = true
thiserror.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
watch.workspace = true
which.workspace = true
workspace-hack.workspace = true

View File

@@ -6,10 +6,10 @@ use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
use anyhow::anyhow;
use collections::HashMap;
use futures::AsyncBufReadExt as _;
use futures::channel::oneshot;
use futures::io::BufReader;
use project::Project;
use serde::Deserialize;
use std::{any::Any, cell::RefCell};
use std::{path::Path, rc::Rc};
use thiserror::Error;
@@ -28,10 +28,8 @@ pub struct AcpConnection {
connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
agent_capabilities: acp::AgentCapabilities,
prompt_capabilities: acp::PromptCapabilities,
_io_task: Task<Result<()>>,
_wait_task: Task<Result<()>>,
_stderr_task: Task<Result<()>>,
}
pub struct AcpSession {
@@ -88,7 +86,7 @@ impl AcpConnection {
let io_task = cx.background_spawn(io_task);
let stderr_task = cx.background_spawn(async move {
cx.background_spawn(async move {
let mut stderr = BufReader::new(stderr);
let mut line = String::new();
while let Ok(n) = stderr.read_line(&mut line).await
@@ -97,10 +95,10 @@ impl AcpConnection {
log::warn!("agent stderr: {}", &line);
line.clear();
}
Ok(())
});
})
.detach();
let wait_task = cx.spawn({
cx.spawn({
let sessions = sessions.clone();
async move |cx| {
let status = child.status().await?;
@@ -116,7 +114,8 @@ impl AcpConnection {
anyhow::Ok(())
}
});
})
.detach();
let connection = Rc::new(connection);
@@ -134,7 +133,6 @@ impl AcpConnection {
read_text_file: true,
write_text_file: true,
},
terminal: true,
},
})
.await?;
@@ -148,15 +146,13 @@ impl AcpConnection {
connection,
server_name,
sessions,
agent_capabilities: response.agent_capabilities,
prompt_capabilities: response.agent_capabilities.prompt_capabilities,
_io_task: io_task,
_wait_task: wait_task,
_stderr_task: stderr_task,
})
}
pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities {
&self.agent_capabilities.prompt_capabilities
&self.prompt_capabilities
}
}
@@ -223,8 +219,7 @@ impl AgentConnection for AcpConnection {
action_log,
session_id.clone(),
// ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically.
watch::Receiver::constant(self.agent_capabilities.prompt_capabilities),
response.available_commands,
watch::Receiver::constant(self.prompt_capabilities),
cx,
)
})?;
@@ -344,14 +339,22 @@ impl acp::Client for ClientDelegate {
arguments: acp::RequestPermissionRequest,
) -> Result<acp::RequestPermissionResponse, acp::Error> {
let cx = &mut self.cx.clone();
let task = self
.session_thread(&arguments.session_id)?
let rx = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
})??;
})?;
let outcome = task.await;
let result = rx?.await;
let outcome = match result {
Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option },
Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled,
};
Ok(acp::RequestPermissionResponse { outcome })
}
@@ -362,7 +365,11 @@ impl acp::Client for ClientDelegate {
) -> Result<(), acp::Error> {
let cx = &mut self.cx.clone();
let task = self
.session_thread(&arguments.session_id)?
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
thread.write_text_file(arguments.path, arguments.content, cx)
})?;
@@ -376,12 +383,16 @@ impl acp::Client for ClientDelegate {
&self,
arguments: acp::ReadTextFileRequest,
) -> Result<acp::ReadTextFileResponse, acp::Error> {
let task = self.session_thread(&arguments.session_id)?.update(
&mut self.cx.clone(),
|thread, cx| {
let cx = &mut self.cx.clone();
let task = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx)
},
)?;
})?;
let content = task.await?;
@@ -392,92 +403,16 @@ impl acp::Client for ClientDelegate {
&self,
notification: acp::SessionNotification,
) -> Result<(), acp::Error> {
self.session_thread(&notification.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.handle_session_update(notification.update, cx)
})??;
Ok(())
}
async fn create_terminal(
&self,
args: acp::CreateTerminalRequest,
) -> Result<acp::CreateTerminalResponse, acp::Error> {
let terminal = self
.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.create_terminal(
args.command,
args.args,
args.env,
args.cwd,
args.output_byte_limit,
cx,
)
})?
.await?;
Ok(
terminal.read_with(&self.cx, |terminal, _| acp::CreateTerminalResponse {
terminal_id: terminal.id().clone(),
})?,
)
}
async fn kill_terminal(&self, args: acp::KillTerminalRequest) -> Result<(), acp::Error> {
self.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.kill_terminal(args.terminal_id, cx)
})??;
Ok(())
}
async fn release_terminal(&self, args: acp::ReleaseTerminalRequest) -> Result<(), acp::Error> {
self.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.release_terminal(args.terminal_id, cx)
})??;
Ok(())
}
async fn terminal_output(
&self,
args: acp::TerminalOutputRequest,
) -> Result<acp::TerminalOutputResponse, acp::Error> {
self.session_thread(&args.session_id)?
.read_with(&mut self.cx.clone(), |thread, cx| {
let out = thread
.terminal(args.terminal_id)?
.read(cx)
.current_output(cx);
Ok(out)
})?
}
async fn wait_for_terminal_exit(
&self,
args: acp::WaitForTerminalExitRequest,
) -> Result<acp::WaitForTerminalExitResponse, acp::Error> {
let exit_status = self
.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit())
})??
.await;
Ok(acp::WaitForTerminalExitResponse { exit_status })
}
}
impl ClientDelegate {
fn session_thread(&self, session_id: &acp::SessionId) -> Result<WeakEntity<AcpThread>> {
let cx = &mut self.cx.clone();
let sessions = self.sessions.borrow();
sessions
.get(session_id)
.context("Failed to get session")
.map(|session| session.thread.clone())
let session = sessions
.get(&notification.session_id)
.context("Failed to get session")?;
session.thread.update(cx, |thread, cx| {
thread.handle_session_update(notification.update, cx)
})??;
Ok(())
}
}

View File

@@ -7,29 +7,18 @@ mod settings;
#[cfg(any(test, feature = "test-support"))]
pub mod e2e_tests;
use anyhow::Context as _;
pub use claude::*;
pub use custom::*;
use fs::Fs;
use fs::RemoveOptions;
use fs::RenameOptions;
use futures::StreamExt as _;
pub use gemini::*;
use gpui::AppContext;
use node_runtime::NodeRuntime;
pub use settings::*;
use acp_thread::AgentConnection;
use acp_thread::LoadError;
use anyhow::Result;
use anyhow::anyhow;
use collections::HashMap;
use gpui::{App, AsyncApp, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use semver::Version;
use serde::{Deserialize, Serialize};
use std::str::FromStr as _;
use std::{
any::Any,
path::{Path, PathBuf},
@@ -42,205 +31,23 @@ pub fn init(cx: &mut App) {
settings::init(cx);
}
pub struct AgentServerDelegate {
project: Entity<Project>,
status_tx: Option<watch::Sender<SharedString>>,
}
impl AgentServerDelegate {
pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
Self { project, status_tx }
}
pub fn project(&self) -> &Entity<Project> {
&self.project
}
fn get_or_npm_install_builtin_agent(
self,
binary_name: SharedString,
package_name: SharedString,
entrypoint_path: PathBuf,
ignore_system_version: bool,
minimum_version: Option<Version>,
cx: &mut App,
) -> Task<Result<AgentServerCommand>> {
let project = self.project;
let fs = project.read(cx).fs().clone();
let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
return Task::ready(Err(anyhow!(
"External agents are not yet available in remote projects."
)));
};
let status_tx = self.status_tx;
cx.spawn(async move |cx| {
if !ignore_system_version {
if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
return Ok(AgentServerCommand {
path: bin,
args: Vec::new(),
env: Default::default(),
});
}
}
cx.spawn(async move |cx| {
let node_path = node_runtime.binary_path().await?;
let dir = paths::data_dir()
.join("external_agents")
.join(binary_name.as_str());
fs.create_dir(&dir).await?;
let mut stream = fs.read_dir(&dir).await?;
let mut versions = Vec::new();
let mut to_delete = Vec::new();
while let Some(entry) = stream.next().await {
let Ok(entry) = entry else { continue };
let Some(file_name) = entry.file_name() else {
continue;
};
if let Some(version) = file_name
.to_str()
.and_then(|name| semver::Version::from_str(&name).ok())
{
versions.push((version, file_name.to_owned()));
} else {
to_delete.push(file_name.to_owned())
}
}
versions.sort();
let newest_version = if let Some((version, file_name)) = versions.last().cloned()
&& minimum_version.is_none_or(|minimum_version| version >= minimum_version)
{
versions.pop();
Some(file_name)
} else {
None
};
log::debug!("existing version of {package_name}: {newest_version:?}");
to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
cx.background_spawn({
let fs = fs.clone();
let dir = dir.clone();
async move {
for file_name in to_delete {
fs.remove_dir(
&dir.join(file_name),
RemoveOptions {
recursive: true,
ignore_if_not_exists: false,
},
)
.await
.ok();
}
}
})
.detach();
let version = if let Some(file_name) = newest_version {
cx.background_spawn({
let file_name = file_name.clone();
let dir = dir.clone();
async move {
let latest_version =
node_runtime.npm_package_latest_version(&package_name).await;
if let Ok(latest_version) = latest_version
&& &latest_version != &file_name.to_string_lossy()
{
Self::download_latest_version(
fs,
dir.clone(),
node_runtime,
package_name,
)
.await
.log_err();
}
}
})
.detach();
file_name
} else {
if let Some(mut status_tx) = status_tx {
status_tx.send("Installing…".into()).ok();
}
let dir = dir.clone();
cx.background_spawn(Self::download_latest_version(
fs,
dir.clone(),
node_runtime,
package_name,
))
.await?
.into()
};
anyhow::Ok(AgentServerCommand {
path: node_path,
args: vec![
dir.join(version)
.join(entrypoint_path)
.to_string_lossy()
.to_string(),
],
env: Default::default(),
})
})
.await
.map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
})
}
async fn download_latest_version(
fs: Arc<dyn Fs>,
dir: PathBuf,
node_runtime: NodeRuntime,
package_name: SharedString,
) -> Result<String> {
log::debug!("downloading latest version of {package_name}");
let tmp_dir = tempfile::tempdir_in(&dir)?;
node_runtime
.npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
.await?;
let version = node_runtime
.npm_package_installed_version(tmp_dir.path(), &package_name)
.await?
.context("expected package to be installed")?;
fs.rename(
&tmp_dir.keep(),
&dir.join(&version),
RenameOptions {
ignore_if_exists: true,
overwrite: false,
},
)
.await?;
anyhow::Ok(version)
}
}
pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName;
fn name(&self) -> SharedString;
fn empty_state_headline(&self) -> SharedString;
fn empty_state_message(&self) -> SharedString;
fn telemetry_id(&self) -> &'static str;
fn connect(
&self,
root_dir: &Path,
delegate: AgentServerDelegate,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>>;
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
fn install_command(&self) -> Option<&'static str>;
}
impl dyn AgentServer {
@@ -274,6 +81,15 @@ impl std::fmt::Debug for AgentServerCommand {
}
}
pub enum AgentServerVersion {
Supported,
Unsupported {
error_message: SharedString,
upgrade_message: SharedString,
upgrade_command: String,
},
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
pub struct AgentServerCommand {
#[serde(rename = "command")]
@@ -288,16 +104,23 @@ impl AgentServerCommand {
path_bin_name: &'static str,
extra_args: &[&'static str],
fallback_path: Option<&Path>,
settings: Option<BuiltinAgentServerSettings>,
settings: Option<AgentServerSettings>,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Option<Self> {
if let Some(settings) = settings
&& let Some(command) = settings.custom_command()
{
Some(command)
if let Some(agent_settings) = settings {
Some(Self {
path: agent_settings.command.path,
args: agent_settings
.command
.args
.into_iter()
.chain(extra_args.iter().map(|arg| arg.to_string()))
.collect(),
env: agent_settings.command.env,
})
} else {
match find_bin_in_path(path_bin_name.into(), project, cx).await {
match find_bin_in_path(path_bin_name, project, cx).await {
Some(path) => Some(Self {
path,
args: extra_args.iter().map(|arg| arg.to_string()).collect(),
@@ -320,7 +143,7 @@ impl AgentServerCommand {
}
async fn find_bin_in_path(
bin_name: SharedString,
bin_name: &'static str,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Option<PathBuf> {
@@ -350,11 +173,11 @@ async fn find_bin_in_path(
cx.background_executor()
.spawn(async move {
let which_result = if cfg!(windows) {
which::which(bin_name.as_str())
which::which(bin_name)
} else {
let env = env_task.await.unwrap_or_default();
let shell_path = env.get("PATH").cloned();
which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref())
};
if let Err(which::Error::CannotFindBinaryPath) = which_result {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
use acp_thread::AcpThread;
use anyhow::Result;
use context_server::{
listener::{McpServerTool, ToolResponse},
types::{ToolAnnotations, ToolResponseContent},
};
use gpui::{AsyncApp, WeakEntity};
use language::unified_diff;
use util::markdown::MarkdownCodeBlock;
use crate::tools::EditToolParams;
#[derive(Clone)]
pub struct EditTool {
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
}
impl EditTool {
pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
Self { thread_rx }
}
}
impl McpServerTool for EditTool {
type Input = EditToolParams;
type Output = ();
const NAME: &'static str = "Edit";
fn annotations(&self) -> ToolAnnotations {
ToolAnnotations {
title: Some("Edit file".to_string()),
read_only_hint: Some(false),
destructive_hint: Some(false),
open_world_hint: Some(false),
idempotent_hint: Some(false),
}
}
async fn run(
&self,
input: Self::Input,
cx: &mut AsyncApp,
) -> Result<ToolResponse<Self::Output>> {
let mut thread_rx = self.thread_rx.clone();
let Some(thread) = thread_rx.recv().await?.upgrade() else {
anyhow::bail!("Thread closed");
};
let content = thread
.update(cx, |thread, cx| {
thread.read_text_file(input.abs_path.clone(), None, None, true, cx)
})?
.await?;
let (new_content, diff) = cx
.background_executor()
.spawn(async move {
let new_content = content.replace(&input.old_text, &input.new_text);
if new_content == content {
return Err(anyhow::anyhow!("Failed to find `old_text`",));
}
let diff = unified_diff(&content, &new_content);
Ok((new_content, diff))
})
.await?;
thread
.update(cx, |thread, cx| {
thread.write_text_file(input.abs_path, new_content, cx)
})?
.await?;
Ok(ToolResponse {
content: vec![ToolResponseContent::Text {
text: MarkdownCodeBlock {
tag: "diff",
text: diff.as_str().trim_end_matches('\n'),
}
.to_string(),
}],
structured_content: (),
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use acp_thread::{AgentConnection, StubAgentConnection};
use gpui::{Entity, TestAppContext};
use indoc::indoc;
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use util::path;
use super::*;
#[gpui::test]
async fn old_text_not_found(cx: &mut TestAppContext) {
let (_thread, tool) = init_test(cx).await;
let result = tool
.run(
EditToolParams {
abs_path: path!("/root/file.txt").into(),
old_text: "hi".into(),
new_text: "bye".into(),
},
&mut cx.to_async(),
)
.await;
assert_eq!(result.unwrap_err().to_string(), "Failed to find `old_text`");
}
#[gpui::test]
async fn found_and_replaced(cx: &mut TestAppContext) {
let (_thread, tool) = init_test(cx).await;
let result = tool
.run(
EditToolParams {
abs_path: path!("/root/file.txt").into(),
old_text: "hello".into(),
new_text: "hi".into(),
},
&mut cx.to_async(),
)
.await;
assert_eq!(
result.unwrap().content[0].text().unwrap(),
indoc! {
r"
```diff
@@ -1,1 +1,1 @@
-hello
+hi
```
"
}
);
}
async fn init_test(cx: &mut TestAppContext) -> (Entity<AcpThread>, EditTool) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
let connection = Rc::new(StubAgentConnection::new());
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"file.txt": "hello"
}),
)
.await;
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
let thread = cx
.update(|cx| connection.new_thread(project, path!("/test").as_ref(), cx))
.await
.unwrap();
thread_tx.send(thread.downgrade()).unwrap();
(thread, EditTool::new(thread_rx))
}
}

View File

@@ -0,0 +1,99 @@
use std::path::PathBuf;
use std::sync::Arc;
use crate::claude::edit_tool::EditTool;
use crate::claude::permission_tool::PermissionTool;
use crate::claude::read_tool::ReadTool;
use crate::claude::write_tool::WriteTool;
use acp_thread::AcpThread;
#[cfg(not(test))]
use anyhow::Context as _;
use anyhow::Result;
use collections::HashMap;
use context_server::types::{
Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities,
ToolsCapabilities, requests,
};
use gpui::{App, AsyncApp, Task, WeakEntity};
use project::Fs;
use serde::Serialize;
pub struct ClaudeZedMcpServer {
server: context_server::listener::McpServer,
}
pub const SERVER_NAME: &str = "zed";
impl ClaudeZedMcpServer {
pub async fn new(
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
fs: Arc<dyn Fs>,
cx: &AsyncApp,
) -> Result<Self> {
let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
mcp_server.add_tool(PermissionTool::new(fs.clone(), thread_rx.clone()));
mcp_server.add_tool(ReadTool::new(thread_rx.clone()));
mcp_server.add_tool(EditTool::new(thread_rx.clone()));
mcp_server.add_tool(WriteTool::new(thread_rx.clone()));
Ok(Self { server: mcp_server })
}
pub fn server_config(&self) -> Result<McpServerConfig> {
#[cfg(not(test))]
let zed_path = std::env::current_exe()
.context("finding current executable path for use in mcp_server")?;
#[cfg(test)]
let zed_path = crate::e2e_tests::get_zed_path();
Ok(McpServerConfig {
command: zed_path,
args: vec![
"--nc".into(),
self.server.socket_path().display().to_string(),
],
env: None,
})
}
fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
cx.foreground_executor().spawn(async move {
Ok(InitializeResponse {
protocol_version: ProtocolVersion("2025-06-18".into()),
capabilities: ServerCapabilities {
experimental: None,
logging: None,
completions: None,
prompts: None,
resources: None,
tools: Some(ToolsCapabilities {
list_changed: Some(false),
}),
},
server_info: Implementation {
name: SERVER_NAME.into(),
version: "0.1.0".into(),
},
meta: None,
})
})
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McpConfig {
pub mcp_servers: HashMap<String, McpServerConfig>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct McpServerConfig {
pub command: PathBuf,
pub args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
}

View File

@@ -0,0 +1,158 @@
use std::sync::Arc;
use acp_thread::AcpThread;
use agent_client_protocol as acp;
use agent_settings::AgentSettings;
use anyhow::{Context as _, Result};
use context_server::{
listener::{McpServerTool, ToolResponse},
types::ToolResponseContent,
};
use gpui::{AsyncApp, WeakEntity};
use project::Fs;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings as _, update_settings_file};
use util::debug_panic;
use crate::tools::ClaudeTool;
#[derive(Clone)]
pub struct PermissionTool {
fs: Arc<dyn Fs>,
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
}
/// Request permission for tool calls
#[derive(Deserialize, JsonSchema, Debug)]
pub struct PermissionToolParams {
tool_name: String,
input: serde_json::Value,
tool_use_id: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionToolResponse {
behavior: PermissionToolBehavior,
updated_input: serde_json::Value,
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
enum PermissionToolBehavior {
Allow,
Deny,
}
impl PermissionTool {
pub fn new(fs: Arc<dyn Fs>, thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
Self { fs, thread_rx }
}
}
impl McpServerTool for PermissionTool {
type Input = PermissionToolParams;
type Output = ();
const NAME: &'static str = "Confirmation";
async fn run(
&self,
input: Self::Input,
cx: &mut AsyncApp,
) -> Result<ToolResponse<Self::Output>> {
if agent_settings::AgentSettings::try_read_global(cx, |settings| {
settings.always_allow_tool_actions
})
.unwrap_or(false)
{
let response = PermissionToolResponse {
behavior: PermissionToolBehavior::Allow,
updated_input: input.input,
};
return Ok(ToolResponse {
content: vec![ToolResponseContent::Text {
text: serde_json::to_string(&response)?,
}],
structured_content: (),
});
}
let mut thread_rx = self.thread_rx.clone();
let Some(thread) = thread_rx.recv().await?.upgrade() else {
anyhow::bail!("Thread closed");
};
let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone());
let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into());
const ALWAYS_ALLOW: &str = "always_allow";
const ALLOW: &str = "allow";
const REJECT: &str = "reject";
let chosen_option = thread
.update(cx, |thread, cx| {
thread.request_tool_call_authorization(
claude_tool.as_acp(tool_call_id).into(),
vec![
acp::PermissionOption {
id: acp::PermissionOptionId(ALWAYS_ALLOW.into()),
name: "Always Allow".into(),
kind: acp::PermissionOptionKind::AllowAlways,
},
acp::PermissionOption {
id: acp::PermissionOptionId(ALLOW.into()),
name: "Allow".into(),
kind: acp::PermissionOptionKind::AllowOnce,
},
acp::PermissionOption {
id: acp::PermissionOptionId(REJECT.into()),
name: "Reject".into(),
kind: acp::PermissionOptionKind::RejectOnce,
},
],
cx,
)
})??
.await?;
let response = match chosen_option.0.as_ref() {
ALWAYS_ALLOW => {
cx.update(|cx| {
update_settings_file::<AgentSettings>(self.fs.clone(), cx, |settings, _| {
settings.set_always_allow_tool_actions(true);
});
})?;
PermissionToolResponse {
behavior: PermissionToolBehavior::Allow,
updated_input: input.input,
}
}
ALLOW => PermissionToolResponse {
behavior: PermissionToolBehavior::Allow,
updated_input: input.input,
},
REJECT => PermissionToolResponse {
behavior: PermissionToolBehavior::Deny,
updated_input: input.input,
},
opt => {
debug_panic!("Unexpected option: {}", opt);
PermissionToolResponse {
behavior: PermissionToolBehavior::Deny,
updated_input: input.input,
}
}
};
Ok(ToolResponse {
content: vec![ToolResponseContent::Text {
text: serde_json::to_string(&response)?,
}],
structured_content: (),
})
}
}

View File

@@ -0,0 +1,59 @@
use acp_thread::AcpThread;
use anyhow::Result;
use context_server::{
listener::{McpServerTool, ToolResponse},
types::{ToolAnnotations, ToolResponseContent},
};
use gpui::{AsyncApp, WeakEntity};
use crate::tools::ReadToolParams;
#[derive(Clone)]
pub struct ReadTool {
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
}
impl ReadTool {
pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
Self { thread_rx }
}
}
impl McpServerTool for ReadTool {
type Input = ReadToolParams;
type Output = ();
const NAME: &'static str = "Read";
fn annotations(&self) -> ToolAnnotations {
ToolAnnotations {
title: Some("Read file".to_string()),
read_only_hint: Some(true),
destructive_hint: Some(false),
open_world_hint: Some(false),
idempotent_hint: None,
}
}
async fn run(
&self,
input: Self::Input,
cx: &mut AsyncApp,
) -> Result<ToolResponse<Self::Output>> {
let mut thread_rx = self.thread_rx.clone();
let Some(thread) = thread_rx.recv().await?.upgrade() else {
anyhow::bail!("Thread closed");
};
let content = thread
.update(cx, |thread, cx| {
thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx)
})?
.await?;
Ok(ToolResponse {
content: vec![ToolResponseContent::Text { text: content }],
structured_content: (),
})
}
}

View File

@@ -0,0 +1,688 @@
use std::path::PathBuf;
use agent_client_protocol as acp;
use itertools::Itertools;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use util::ResultExt;
pub enum ClaudeTool {
Task(Option<TaskToolParams>),
NotebookRead(Option<NotebookReadToolParams>),
NotebookEdit(Option<NotebookEditToolParams>),
Edit(Option<EditToolParams>),
MultiEdit(Option<MultiEditToolParams>),
ReadFile(Option<ReadToolParams>),
Write(Option<WriteToolParams>),
Ls(Option<LsToolParams>),
Glob(Option<GlobToolParams>),
Grep(Option<GrepToolParams>),
Terminal(Option<BashToolParams>),
WebFetch(Option<WebFetchToolParams>),
WebSearch(Option<WebSearchToolParams>),
TodoWrite(Option<TodoWriteToolParams>),
ExitPlanMode(Option<ExitPlanModeToolParams>),
Other {
name: String,
input: serde_json::Value,
},
}
impl ClaudeTool {
pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
match tool_name {
// Known tools
"mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
"mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
"mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()),
"MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
"Write" => Self::Write(serde_json::from_value(input).log_err()),
"LS" => Self::Ls(serde_json::from_value(input).log_err()),
"Glob" => Self::Glob(serde_json::from_value(input).log_err()),
"Grep" => Self::Grep(serde_json::from_value(input).log_err()),
"Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
"WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
"WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
"TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
"exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
"Task" => Self::Task(serde_json::from_value(input).log_err()),
"NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
"NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
// Inferred from name
_ => {
let tool_name = tool_name.to_lowercase();
if tool_name.contains("edit") || tool_name.contains("write") {
Self::Edit(None)
} else if tool_name.contains("terminal") {
Self::Terminal(None)
} else {
Self::Other {
name: tool_name,
input,
}
}
}
}
}
pub fn label(&self) -> String {
match &self {
Self::Task(Some(params)) => params.description.clone(),
Self::Task(None) => "Task".into(),
Self::NotebookRead(Some(params)) => {
format!("Read Notebook {}", params.notebook_path.display())
}
Self::NotebookRead(None) => "Read Notebook".into(),
Self::NotebookEdit(Some(params)) => {
format!("Edit Notebook {}", params.notebook_path.display())
}
Self::NotebookEdit(None) => "Edit Notebook".into(),
Self::Terminal(Some(params)) => format!("`{}`", params.command),
Self::Terminal(None) => "Terminal".into(),
Self::ReadFile(_) => "Read File".into(),
Self::Ls(Some(params)) => {
format!("List Directory {}", params.path.display())
}
Self::Ls(None) => "List Directory".into(),
Self::Edit(Some(params)) => {
format!("Edit {}", params.abs_path.display())
}
Self::Edit(None) => "Edit".into(),
Self::MultiEdit(Some(params)) => {
format!("Multi Edit {}", params.file_path.display())
}
Self::MultiEdit(None) => "Multi Edit".into(),
Self::Write(Some(params)) => {
format!("Write {}", params.abs_path.display())
}
Self::Write(None) => "Write".into(),
Self::Glob(Some(params)) => {
format!("Glob `{params}`")
}
Self::Glob(None) => "Glob".into(),
Self::Grep(Some(params)) => format!("`{params}`"),
Self::Grep(None) => "Grep".into(),
Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
Self::WebFetch(None) => "Fetch".into(),
Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
Self::WebSearch(None) => "Web Search".into(),
Self::TodoWrite(Some(params)) => format!(
"Update TODOs: {}",
params.todos.iter().map(|todo| &todo.content).join(", ")
),
Self::TodoWrite(None) => "Update TODOs".into(),
Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
Self::Other { name, .. } => name.clone(),
}
}
pub fn content(&self) -> Vec<acp::ToolCallContent> {
match &self {
Self::Other { input, .. } => vec![
format!(
"```json\n{}```",
serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
)
.into(),
],
Self::Task(Some(params)) => vec![params.prompt.clone().into()],
Self::NotebookRead(Some(params)) => {
vec![params.notebook_path.display().to_string().into()]
}
Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()],
Self::Terminal(Some(params)) => vec![
format!(
"`{}`\n\n{}",
params.command,
params.description.as_deref().unwrap_or_default()
)
.into(),
],
Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()],
Self::Ls(Some(params)) => vec![params.path.display().to_string().into()],
Self::Glob(Some(params)) => vec![params.to_string().into()],
Self::Grep(Some(params)) => vec![format!("`{params}`").into()],
Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()],
Self::WebSearch(Some(params)) => vec![params.to_string().into()],
Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()],
Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.abs_path.clone(),
old_text: Some(params.old_text.clone()),
new_text: params.new_text.clone(),
},
}],
Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.abs_path.clone(),
old_text: None,
new_text: params.content.clone(),
},
}],
Self::MultiEdit(Some(params)) => {
// todo: show multiple edits in a multibuffer?
params
.edits
.first()
.map(|edit| {
vec![acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.file_path.clone(),
old_text: Some(edit.old_string.clone()),
new_text: edit.new_string.clone(),
},
}]
})
.unwrap_or_default()
}
Self::TodoWrite(Some(_)) => {
// These are mapped to plan updates later
vec![]
}
Self::Task(None)
| Self::NotebookRead(None)
| Self::NotebookEdit(None)
| Self::Terminal(None)
| Self::ReadFile(None)
| Self::Ls(None)
| Self::Glob(None)
| Self::Grep(None)
| Self::WebFetch(None)
| Self::WebSearch(None)
| Self::TodoWrite(None)
| Self::ExitPlanMode(None)
| Self::Edit(None)
| Self::Write(None)
| Self::MultiEdit(None) => vec![],
}
}
pub fn kind(&self) -> acp::ToolKind {
match self {
Self::Task(_) => acp::ToolKind::Think,
Self::NotebookRead(_) => acp::ToolKind::Read,
Self::NotebookEdit(_) => acp::ToolKind::Edit,
Self::Edit(_) => acp::ToolKind::Edit,
Self::MultiEdit(_) => acp::ToolKind::Edit,
Self::Write(_) => acp::ToolKind::Edit,
Self::ReadFile(_) => acp::ToolKind::Read,
Self::Ls(_) => acp::ToolKind::Search,
Self::Glob(_) => acp::ToolKind::Search,
Self::Grep(_) => acp::ToolKind::Search,
Self::Terminal(_) => acp::ToolKind::Execute,
Self::WebSearch(_) => acp::ToolKind::Search,
Self::WebFetch(_) => acp::ToolKind::Fetch,
Self::TodoWrite(_) => acp::ToolKind::Think,
Self::ExitPlanMode(_) => acp::ToolKind::Think,
Self::Other { .. } => acp::ToolKind::Other,
}
}
pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
match &self {
Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation {
path: abs_path.clone(),
line: None,
}],
Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
vec![acp::ToolCallLocation {
path: file_path.clone(),
line: None,
}]
}
Self::Write(Some(WriteToolParams {
abs_path: file_path,
..
})) => {
vec![acp::ToolCallLocation {
path: file_path.clone(),
line: None,
}]
}
Self::ReadFile(Some(ReadToolParams {
abs_path, offset, ..
})) => vec![acp::ToolCallLocation {
path: abs_path.clone(),
line: *offset,
}],
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
vec![acp::ToolCallLocation {
path: notebook_path.clone(),
line: None,
}]
}
Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
vec![acp::ToolCallLocation {
path: notebook_path.clone(),
line: None,
}]
}
Self::Glob(Some(GlobToolParams {
path: Some(path), ..
})) => vec![acp::ToolCallLocation {
path: path.clone(),
line: None,
}],
Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation {
path: path.clone(),
line: None,
}],
Self::Grep(Some(GrepToolParams {
path: Some(path), ..
})) => vec![acp::ToolCallLocation {
path: PathBuf::from(path),
line: None,
}],
Self::Task(_)
| Self::NotebookRead(None)
| Self::NotebookEdit(None)
| Self::Edit(None)
| Self::MultiEdit(None)
| Self::Write(None)
| Self::ReadFile(None)
| Self::Ls(None)
| Self::Glob(_)
| Self::Grep(_)
| Self::Terminal(_)
| Self::WebFetch(_)
| Self::WebSearch(_)
| Self::TodoWrite(_)
| Self::ExitPlanMode(_)
| Self::Other { .. } => vec![],
}
}
pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall {
acp::ToolCall {
id,
kind: self.kind(),
status: acp::ToolCallStatus::InProgress,
title: self.label(),
content: self.content(),
locations: self.locations(),
raw_input: None,
raw_output: None,
}
}
}
/// Edit a file.
///
/// In sessions with mcp__zed__Edit always use it instead of Edit as it will
/// allow the user to conveniently review changes.
///
/// File editing instructions:
/// - The `old_text` param must match existing file content, including indentation.
/// - The `old_text` param must come from the actual file, not an outline.
/// - The `old_text` section must not be empty.
/// - Be minimal with replacements:
/// - For unique lines, include only those lines.
/// - For non-unique lines, include enough context to identify them.
/// - Do not escape quotes, newlines, or other characters.
/// - Only edit the specified file.
#[derive(Deserialize, JsonSchema, Debug)]
pub struct EditToolParams {
/// The absolute path to the file to read.
pub abs_path: PathBuf,
/// The old text to replace (must be unique in the file)
pub old_text: String,
/// The new text.
pub new_text: String,
}
/// Reads the content of the given file in the project.
///
/// Never attempt to read a path that hasn't been previously mentioned.
///
/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.
#[derive(Deserialize, JsonSchema, Debug)]
pub struct ReadToolParams {
/// The absolute path to the file to read.
pub abs_path: PathBuf,
/// Which line to start reading from. Omit to start from the beginning.
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
/// How many lines to read. Omit for the whole file.
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
/// Writes content to the specified file in the project.
///
/// In sessions with mcp__zed__Write always use it instead of Write as it will
/// allow the user to conveniently review changes.
#[derive(Deserialize, JsonSchema, Debug)]
pub struct WriteToolParams {
/// The absolute path of the file to write.
pub abs_path: PathBuf,
/// The full content to write.
pub content: String,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct BashToolParams {
/// Shell command to execute
pub command: String,
/// 5-10 word description of what command does
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Timeout in ms (max 600000ms/10min, default 120000ms)
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct GlobToolParams {
/// Glob pattern like **/*.js or src/**/*.ts
pub pattern: String,
/// Directory to search in (omit for current directory)
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
}
impl std::fmt::Display for GlobToolParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(path) = &self.path {
write!(f, "{}", path.display())?;
}
write!(f, "{}", self.pattern)
}
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct LsToolParams {
/// Absolute path to directory
pub path: PathBuf,
/// Array of glob patterns to ignore
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<String>,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct GrepToolParams {
/// Regex pattern to search for
pub pattern: String,
/// File/directory to search (defaults to current directory)
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// "content" (shows lines), "files_with_matches" (default), "count"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_mode: Option<GrepOutputMode>,
/// Filter files with glob pattern like "*.js"
#[serde(skip_serializing_if = "Option::is_none")]
pub glob: Option<String>,
/// File type filter like "js", "py", "rust"
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub file_type: Option<String>,
/// Case insensitive search
#[serde(rename = "-i", default, skip_serializing_if = "is_false")]
pub case_insensitive: bool,
/// Show line numbers (content mode only)
#[serde(rename = "-n", default, skip_serializing_if = "is_false")]
pub line_numbers: bool,
/// Lines after match (content mode only)
#[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
pub after_context: Option<u32>,
/// Lines before match (content mode only)
#[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
pub before_context: Option<u32>,
/// Lines before and after match (content mode only)
#[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
pub context: Option<u32>,
/// Enable multiline/cross-line matching
#[serde(default, skip_serializing_if = "is_false")]
pub multiline: bool,
/// Limit output to first N results
#[serde(skip_serializing_if = "Option::is_none")]
pub head_limit: Option<u32>,
}
impl std::fmt::Display for GrepToolParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "grep")?;
// Boolean flags
if self.case_insensitive {
write!(f, " -i")?;
}
if self.line_numbers {
write!(f, " -n")?;
}
// Context options
if let Some(after) = self.after_context {
write!(f, " -A {}", after)?;
}
if let Some(before) = self.before_context {
write!(f, " -B {}", before)?;
}
if let Some(context) = self.context {
write!(f, " -C {}", context)?;
}
// Output mode
if let Some(mode) = &self.output_mode {
match mode {
GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
GrepOutputMode::Count => write!(f, " -c")?,
GrepOutputMode::Content => {} // Default mode
}
}
// Head limit
if let Some(limit) = self.head_limit {
write!(f, " | head -{}", limit)?;
}
// Glob pattern
if let Some(glob) = &self.glob {
write!(f, " --include=\"{}\"", glob)?;
}
// File type
if let Some(file_type) = &self.file_type {
write!(f, " --type={}", file_type)?;
}
// Multiline
if self.multiline {
write!(f, " -P")?; // Perl-compatible regex for multiline
}
// Pattern (escaped if contains special characters)
write!(f, " \"{}\"", self.pattern)?;
// Path
if let Some(path) = &self.path {
write!(f, " {}", path)?;
}
Ok(())
}
}
#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
#[serde(rename_all = "snake_case")]
pub enum TodoPriority {
High,
#[default]
Medium,
Low,
}
impl Into<acp::PlanEntryPriority> for TodoPriority {
fn into(self) -> acp::PlanEntryPriority {
match self {
TodoPriority::High => acp::PlanEntryPriority::High,
TodoPriority::Medium => acp::PlanEntryPriority::Medium,
TodoPriority::Low => acp::PlanEntryPriority::Low,
}
}
}
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
Pending,
InProgress,
Completed,
}
impl Into<acp::PlanEntryStatus> for TodoStatus {
fn into(self) -> acp::PlanEntryStatus {
match self {
TodoStatus::Pending => acp::PlanEntryStatus::Pending,
TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
TodoStatus::Completed => acp::PlanEntryStatus::Completed,
}
}
}
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
pub struct Todo {
/// Task description
pub content: String,
/// Current status of the todo
pub status: TodoStatus,
/// Priority level of the todo
#[serde(default)]
pub priority: TodoPriority,
}
impl Into<acp::PlanEntry> for Todo {
fn into(self) -> acp::PlanEntry {
acp::PlanEntry {
content: self.content,
priority: self.priority.into(),
status: self.status.into(),
}
}
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct TodoWriteToolParams {
pub todos: Vec<Todo>,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct ExitPlanModeToolParams {
/// Implementation plan in markdown format
pub plan: String,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct TaskToolParams {
/// Short 3-5 word description of task
pub description: String,
/// Detailed task for agent to perform
pub prompt: String,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct NotebookReadToolParams {
/// Absolute path to .ipynb file
pub notebook_path: PathBuf,
/// Specific cell ID to read
#[serde(skip_serializing_if = "Option::is_none")]
pub cell_id: Option<String>,
}
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
#[serde(rename_all = "snake_case")]
pub enum CellType {
Code,
Markdown,
}
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
#[serde(rename_all = "snake_case")]
pub enum EditMode {
Replace,
Insert,
Delete,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct NotebookEditToolParams {
/// Absolute path to .ipynb file
pub notebook_path: PathBuf,
/// New cell content
pub new_source: String,
/// Cell ID to edit
#[serde(skip_serializing_if = "Option::is_none")]
pub cell_id: Option<String>,
/// Type of cell (code or markdown)
#[serde(skip_serializing_if = "Option::is_none")]
pub cell_type: Option<CellType>,
/// Edit operation mode
#[serde(skip_serializing_if = "Option::is_none")]
pub edit_mode: Option<EditMode>,
}
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
pub struct MultiEditItem {
/// The text to search for and replace
pub old_string: String,
/// The replacement text
pub new_string: String,
/// Whether to replace all occurrences or just the first
#[serde(default, skip_serializing_if = "is_false")]
pub replace_all: bool,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct MultiEditToolParams {
/// Absolute path to file
pub file_path: PathBuf,
/// List of edits to apply
pub edits: Vec<MultiEditItem>,
}
fn is_false(v: &bool) -> bool {
!*v
}
#[derive(Deserialize, JsonSchema, Debug)]
#[serde(rename_all = "snake_case")]
pub enum GrepOutputMode {
Content,
FilesWithMatches,
Count,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct WebFetchToolParams {
/// Valid URL to fetch
#[serde(rename = "url")]
pub url: String,
/// What to extract from content
pub prompt: String,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct WebSearchToolParams {
/// Search query (min 2 chars)
pub query: String,
/// Only include these domains
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_domains: Vec<String>,
/// Exclude these domains
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_domains: Vec<String>,
}
impl std::fmt::Display for WebSearchToolParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "\"{}\"", self.query)?;
if !self.allowed_domains.is_empty() {
write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
}
if !self.blocked_domains.is_empty() {
write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
}
Ok(())
}
}

View File

@@ -0,0 +1,59 @@
use acp_thread::AcpThread;
use anyhow::Result;
use context_server::{
listener::{McpServerTool, ToolResponse},
types::ToolAnnotations,
};
use gpui::{AsyncApp, WeakEntity};
use crate::tools::WriteToolParams;
#[derive(Clone)]
pub struct WriteTool {
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
}
impl WriteTool {
pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
Self { thread_rx }
}
}
impl McpServerTool for WriteTool {
type Input = WriteToolParams;
type Output = ();
const NAME: &'static str = "Write";
fn annotations(&self) -> ToolAnnotations {
ToolAnnotations {
title: Some("Write file".to_string()),
read_only_hint: Some(false),
destructive_hint: Some(false),
open_world_hint: Some(false),
idempotent_hint: Some(false),
}
}
async fn run(
&self,
input: Self::Input,
cx: &mut AsyncApp,
) -> Result<ToolResponse<Self::Output>> {
let mut thread_rx = self.thread_rx.clone();
let Some(thread) = thread_rx.recv().await?.upgrade() else {
anyhow::bail!("Thread closed");
};
thread
.update(cx, |thread, cx| {
thread.write_text_file(input.abs_path, input.content, cx)
})?
.await?;
Ok(ToolResponse {
content: vec![],
structured_content: (),
})
}
}

View File

@@ -1,7 +1,8 @@
use crate::{AgentServerCommand, AgentServerDelegate};
use crate::{AgentServerCommand, AgentServerSettings};
use acp_thread::AgentConnection;
use anyhow::Result;
use gpui::{App, SharedString, Task};
use gpui::{App, Entity, SharedString, Task};
use project::Project;
use std::{path::Path, rc::Rc};
use ui::IconName;
@@ -12,8 +13,11 @@ pub struct CustomAgentServer {
}
impl CustomAgentServer {
pub fn new(name: SharedString, command: AgentServerCommand) -> Self {
Self { name, command }
pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self {
Self {
name,
command: settings.command.clone(),
}
}
}
@@ -30,16 +34,31 @@ impl crate::AgentServer for CustomAgentServer {
IconName::Terminal
}
fn empty_state_headline(&self) -> SharedString {
"No conversations yet".into()
}
fn empty_state_message(&self) -> SharedString {
format!("Start a conversation with {}", self.name).into()
}
fn connect(
&self,
root_dir: &Path,
_delegate: AgentServerDelegate,
_project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let server_name = self.name();
let command = self.command.clone();
let root_dir = root_dir.to_path_buf();
cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await)
cx.spawn(async move |mut cx| {
crate::acp::connect(server_name, command, &root_dir, &mut cx).await
})
}
fn install_command(&self) -> Option<&'static str> {
None
}
fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {

View File

@@ -1,6 +1,4 @@
use crate::{AgentServer, AgentServerDelegate};
#[cfg(test)]
use crate::{AgentServerCommand, CustomAgentServerSettings};
use crate::AgentServer;
use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
use agent_client_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
@@ -473,14 +471,12 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
#[cfg(test)]
crate::AllAgentServersSettings::override_global(
crate::AllAgentServersSettings {
claude: Some(CustomAgentServerSettings {
command: AgentServerCommand {
path: "claude-code-acp".into(),
args: vec![],
env: None,
},
claude: Some(crate::AgentServerSettings {
command: crate::claude::tests::local_command(),
}),
gemini: Some(crate::AgentServerSettings {
command: crate::gemini::tests::local_command(),
}),
gemini: Some(crate::gemini::tests::local_command().into()),
custom: collections::HashMap::default(),
},
cx,
@@ -498,10 +494,8 @@ pub async fn new_test_thread(
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
let delegate = AgentServerDelegate::new(project.clone(), None);
let connection = cx
.update(|cx| server.connect(current_dir.as_ref(), delegate, cx))
.update(|cx| server.connect(current_dir.as_ref(), &project, cx))
.await
.unwrap();

View File

@@ -2,11 +2,12 @@ use std::rc::Rc;
use std::{any::Any, path::Path};
use crate::acp::AcpConnection;
use crate::{AgentServer, AgentServerDelegate};
use crate::{AgentServer, AgentServerCommand};
use acp_thread::{AgentConnection, LoadError};
use anyhow::Result;
use gpui::{App, AppContext as _, SharedString, Task};
use gpui::{App, Entity, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider;
use project::Project;
use settings::SettingsStore;
use crate::AllAgentServersSettings;
@@ -25,47 +26,42 @@ impl AgentServer for Gemini {
"Gemini CLI".into()
}
fn empty_state_headline(&self) -> SharedString {
self.name()
}
fn empty_state_message(&self) -> SharedString {
"Ask questions, edit files, run commands".into()
}
fn logo(&self) -> ui::IconName {
ui::IconName::AiGemini
}
fn install_command(&self) -> Option<&'static str> {
Some("npm install --engine-strict -g @google/gemini-cli@latest")
}
fn connect(
&self,
root_dir: &Path,
delegate: AgentServerDelegate,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let project = project.clone();
let root_dir = root_dir.to_path_buf();
let server_name = self.name();
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).gemini.clone()
});
cx.spawn(async move |cx| {
let ignore_system_version = settings
.as_ref()
.and_then(|settings| settings.ignore_system_version)
.unwrap_or(true);
let mut command = if let Some(settings) = settings
&& let Some(command) = settings.custom_command()
{
command
} else {
cx.update(|cx| {
delegate.get_or_npm_install_builtin_agent(
Self::BINARY_NAME.into(),
Self::PACKAGE_NAME.into(),
format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(),
ignore_system_version,
Some(Self::MINIMUM_VERSION.parse().unwrap()),
cx,
)
})?
.await?
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).gemini.clone()
})?;
let Some(mut command) =
AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx)
.await
else {
return Err(LoadError::NotInstalled.into());
};
if !command.args.contains(&ACP_ARG.into()) {
command.args.push(ACP_ARG.into());
}
if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
command
@@ -88,17 +84,21 @@ impl AgentServer for Gemini {
.await;
let current_version =
String::from_utf8(version_output?.stdout)?.trim().to_owned();
log::error!("connected to gemini, but missing prompt_capabilities.image (version is {current_version})");
return Err(LoadError::Unsupported {
current_version: current_version.into(),
command: command.path.to_string_lossy().to_string().into(),
minimum_version: Self::MINIMUM_VERSION.into(),
if !connection.prompt_capabilities().image {
return Err(LoadError::Unsupported {
current_version: current_version.into(),
command: format!(
"{} {}",
command.path.to_string_lossy(),
command.args.join(" ")
)
.into(),
}
.into());
}
.into());
}
}
Err(e) => {
Err(_) => {
let version_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--version")
@@ -113,24 +113,14 @@ impl AgentServer for Gemini {
let (version_output, help_output) =
futures::future::join(version_fut, help_fut).await;
let Some(version_output) = version_output.ok().and_then(|output| String::from_utf8(output.stdout).ok()) else {
return result;
};
let Some((help_stdout, help_stderr)) = help_output.ok().and_then(|output| String::from_utf8(output.stdout).ok().zip(String::from_utf8(output.stderr).ok())) else {
return result;
};
let current_version = version_output.trim().to_string();
let supported = help_stdout.contains(ACP_ARG) || current_version.parse::<semver::Version>().is_ok_and(|version| version >= Self::MINIMUM_VERSION.parse::<semver::Version>().unwrap());
let current_version = String::from_utf8(version_output?.stdout)?;
let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
log::error!("failed to create ACP connection to gemini (version is {current_version}, supported: {supported}): {e}");
log::debug!("gemini --help stdout: {help_stdout:?}");
log::debug!("gemini --help stderr: {help_stderr:?}");
if !supported {
return Err(LoadError::Unsupported {
current_version: current_version.into(),
command: command.path.to_string_lossy().to_string().into(),
minimum_version: Self::MINIMUM_VERSION.into(),
}
.into());
}
@@ -146,11 +136,17 @@ impl AgentServer for Gemini {
}
impl Gemini {
const PACKAGE_NAME: &str = "@google/gemini-cli";
pub fn binary_name() -> &'static str {
"gemini"
}
const MINIMUM_VERSION: &str = "0.2.1";
pub fn install_command() -> &'static str {
"npm install --engine-strict -g @google/gemini-cli@latest"
}
const BINARY_NAME: &str = "gemini";
pub fn upgrade_command() -> &'static str {
"npm install -g @google/gemini-cli@latest"
}
}
#[cfg(test)]

View File

@@ -1,5 +1,3 @@
use std::path::PathBuf;
use crate::AgentServerCommand;
use anyhow::Result;
use collections::HashMap;
@@ -14,62 +12,16 @@ pub fn init(cx: &mut App) {
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AllAgentServersSettings {
pub gemini: Option<BuiltinAgentServerSettings>,
pub claude: Option<CustomAgentServerSettings>,
pub gemini: Option<AgentServerSettings>,
pub claude: Option<AgentServerSettings>,
/// Custom agent servers configured by the user
#[serde(flatten)]
pub custom: HashMap<SharedString, CustomAgentServerSettings>,
}
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
pub struct BuiltinAgentServerSettings {
/// Absolute path to a binary to be used when launching this agent.
///
/// This can be used to run a specific binary without automatic downloads or searching `$PATH`.
#[serde(rename = "command")]
pub path: Option<PathBuf>,
/// If a binary is specified in `command`, it will be passed these arguments.
pub args: Option<Vec<String>>,
/// If a binary is specified in `command`, it will be passed these environment variables.
pub env: Option<HashMap<String, String>>,
/// Whether to skip searching `$PATH` for an agent server binary when
/// launching this agent.
///
/// This has no effect if a `command` is specified. Otherwise, when this is
/// `false`, Zed will search `$PATH` for an agent server binary and, if one
/// is found, use it for threads with this agent. If no agent binary is
/// found on `$PATH`, Zed will automatically install and use its own binary.
/// When this is `true`, Zed will not search `$PATH`, and will always use
/// its own binary.
///
/// Default: true
pub ignore_system_version: Option<bool>,
}
impl BuiltinAgentServerSettings {
pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
self.path.map(|path| AgentServerCommand {
path,
args: self.args.unwrap_or_default(),
env: self.env,
})
}
}
impl From<AgentServerCommand> for BuiltinAgentServerSettings {
fn from(value: AgentServerCommand) -> Self {
BuiltinAgentServerSettings {
path: Some(value.path),
args: Some(value.args),
env: value.env,
..Default::default()
}
}
pub custom: HashMap<SharedString, AgentServerSettings>,
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
pub struct CustomAgentServerSettings {
pub struct AgentServerSettings {
#[serde(flatten)]
pub command: AgentServerCommand,
}

View File

@@ -352,19 +352,18 @@ impl JsonSchema for LanguageModelProviderSetting {
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
json_schema!({
"enum": [
"amazon-bedrock",
"anthropic",
"copilot_chat",
"deepseek",
"amazon-bedrock",
"google",
"lmstudio",
"mistral",
"ollama",
"openai",
"zed.dev",
"copilot_chat",
"deepseek",
"openrouter",
"vercel",
"x_ai",
"zed.dev"
"mistral",
"vercel"
]
})
}

View File

@@ -80,7 +80,6 @@ serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
settings.workspace = true
shlex.workspace = true
smol.workspace = true
streaming_diff.workspace = true
task.workspace = true

View File

@@ -1,4 +1,4 @@
use std::cell::{Cell, RefCell};
use std::cell::Cell;
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
@@ -13,10 +13,8 @@ use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{App, Entity, Task, WeakEntity};
use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext;
use project::lsp_store::CompletionDocumentation;
use project::{
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project,
ProjectPath, Symbol, WorktreeId,
Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId,
};
use prompt_store::PromptStore;
use rope::Point;
@@ -25,7 +23,7 @@ use ui::prelude::*;
use workspace::Workspace;
use crate::AgentPanel;
use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
use crate::acp::message_editor::MessageEditor;
use crate::context_picker::file_context_picker::{FileMatch, search_files};
use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
use crate::context_picker::symbol_context_picker::SymbolMatch;
@@ -69,7 +67,6 @@ pub struct ContextPickerCompletionProvider {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
}
impl ContextPickerCompletionProvider {
@@ -79,7 +76,6 @@ impl ContextPickerCompletionProvider {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
) -> Self {
Self {
message_editor,
@@ -87,7 +83,6 @@ impl ContextPickerCompletionProvider {
history_store,
prompt_store,
prompt_capabilities,
available_commands,
}
}
@@ -374,42 +369,7 @@ impl ContextPickerCompletionProvider {
})
}
fn search_slash_commands(
&self,
query: String,
cx: &mut App,
) -> Task<Vec<acp::AvailableCommand>> {
let commands = self.available_commands.borrow().clone();
if commands.is_empty() {
return Task::ready(Vec::new());
}
cx.spawn(async move |cx| {
let candidates = commands
.iter()
.enumerate()
.map(|(id, command)| StringMatchCandidate::new(id, &command.name))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
false,
true,
100,
&Arc::new(AtomicBool::default()),
cx.background_executor().clone(),
)
.await;
matches
.into_iter()
.map(|mat| commands[mat.candidate_id].clone())
.collect()
})
}
fn search_mentions(
fn search(
&self,
mode: Option<ContextPickerMode>,
query: String,
@@ -691,10 +651,10 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let offset_to_line = buffer.point_to_offset(line_start);
let mut lines = buffer.text_for_range(line_start..position).lines();
let line = lines.next()?;
ContextCompletion::try_parse(
MentionCompletion::try_parse(
self.prompt_capabilities.get().embedded_context,
line,
offset_to_line,
self.prompt_capabilities.get().embedded_context,
)
});
let Some(state) = state else {
@@ -707,175 +667,97 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let project = workspace.read(cx).project().clone();
let snapshot = buffer.read(cx).snapshot();
let source_range = snapshot.anchor_before(state.source_range().start)
..snapshot.anchor_after(state.source_range().end);
let source_range = snapshot.anchor_before(state.source_range.start)
..snapshot.anchor_after(state.source_range.end);
let editor = self.message_editor.clone();
match state {
ContextCompletion::SlashCommand(SlashCommandCompletion {
command, argument, ..
}) => {
let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
cx.background_spawn(async move {
let completions = search_task
.await
.into_iter()
.map(|command| {
let new_text = if let Some(argument) = argument.as_ref() {
format!("/{} {}", command.name, argument)
} else {
format!("/{} ", command.name)
let MentionCompletion { mode, argument, .. } = state;
let query = argument.unwrap_or_else(|| "".to_string());
let search_task = self.search(mode, query, Arc::<AtomicBool>::default(), cx);
cx.spawn(async move |_, cx| {
let matches = search_task.await;
let completions = cx.update(|cx| {
matches
.into_iter()
.filter_map(|mat| match mat {
Match::File(FileMatch { mat, is_recent }) => {
let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(mat.worktree_id),
path: mat.path.clone(),
};
let is_missing_argument = argument.is_none() && command.input.is_some();
Completion {
replace_range: source_range.clone(),
new_text,
label: CodeLabel::plain(command.name.to_string(), None),
documentation: Some(CompletionDocumentation::MultiLinePlainText(
command.description.into(),
)),
source: project::CompletionSource::Custom,
icon_path: None,
insert_text_mode: None,
confirm: Some(Arc::new({
let editor = editor.clone();
move |intent, _window, cx| {
if !is_missing_argument {
cx.defer({
let editor = editor.clone();
move |cx| {
editor
.update(cx, |_editor, cx| {
match intent {
CompletionIntent::Complete
| CompletionIntent::CompleteWithInsert
| CompletionIntent::CompleteWithReplace => {
if !is_missing_argument {
cx.emit(MessageEditorEvent::Send);
}
}
CompletionIntent::Compose => {}
}
})
.ok();
}
});
}
is_missing_argument
}
})),
}
})
.collect();
Self::completion_for_path(
project_path,
&mat.path_prefix,
is_recent,
mat.is_dir,
source_range.clone(),
editor.clone(),
project.clone(),
cx,
)
}
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions {
dynamic_width: true,
},
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,
}])
})
}
ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
let query = argument.unwrap_or_default();
let search_task =
self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
symbol,
source_range.clone(),
editor.clone(),
workspace.clone(),
cx,
),
cx.spawn(async move |_, cx| {
let matches = search_task.await;
Match::Thread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
false,
editor.clone(),
cx,
)),
let completions = cx.update(|cx| {
matches
.into_iter()
.filter_map(|mat| match mat {
Match::File(FileMatch { mat, is_recent }) => {
let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(mat.worktree_id),
path: mat.path.clone(),
};
Match::RecentThread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
true,
editor.clone(),
cx,
)),
Self::completion_for_path(
project_path,
&mat.path_prefix,
is_recent,
mat.is_dir,
source_range.clone(),
editor.clone(),
project.clone(),
cx,
)
}
Match::Rules(user_rules) => Some(Self::completion_for_rules(
user_rules,
source_range.clone(),
editor.clone(),
cx,
)),
Match::Symbol(SymbolMatch { symbol, .. }) => {
Self::completion_for_symbol(
symbol,
source_range.clone(),
editor.clone(),
workspace.clone(),
cx,
)
}
Match::Fetch(url) => Self::completion_for_fetch(
source_range.clone(),
url,
editor.clone(),
cx,
),
Match::Thread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
false,
editor.clone(),
cx,
)),
Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
entry,
source_range.clone(),
editor.clone(),
&workspace,
cx,
),
})
.collect()
})?;
Match::RecentThread(thread) => Some(Self::completion_for_thread(
thread,
source_range.clone(),
true,
editor.clone(),
cx,
)),
Match::Rules(user_rules) => Some(Self::completion_for_rules(
user_rules,
source_range.clone(),
editor.clone(),
cx,
)),
Match::Fetch(url) => Self::completion_for_fetch(
source_range.clone(),
url,
editor.clone(),
cx,
),
Match::Entry(EntryMatch { entry, .. }) => {
Self::completion_for_entry(
entry,
source_range.clone(),
editor.clone(),
&workspace,
cx,
)
}
})
.collect()
})?;
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions {
dynamic_width: true,
},
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,
}])
})
}
}
Ok(vec![CompletionResponse {
completions,
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,
}])
})
}
fn is_completion_trigger(
@@ -893,14 +775,14 @@ impl CompletionProvider for ContextPickerCompletionProvider {
let offset_to_line = buffer.point_to_offset(line_start);
let mut lines = buffer.text_for_range(line_start..position).lines();
if let Some(line) = lines.next() {
ContextCompletion::try_parse(
MentionCompletion::try_parse(
self.prompt_capabilities.get().embedded_context,
line,
offset_to_line,
self.prompt_capabilities.get().embedded_context,
)
.map(|completion| {
completion.source_range().start <= offset_to_line + position.column as usize
&& completion.source_range().end >= offset_to_line + position.column as usize
completion.source_range.start <= offset_to_line + position.column as usize
&& completion.source_range.end >= offset_to_line + position.column as usize
})
.unwrap_or(false)
} else {
@@ -969,7 +851,7 @@ fn confirm_completion_callback(
.clone()
.update(cx, |message_editor, cx| {
message_editor
.confirm_mention_completion(
.confirm_completion(
crease_text,
start,
content_len,
@@ -985,89 +867,6 @@ fn confirm_completion_callback(
})
}
enum ContextCompletion {
SlashCommand(SlashCommandCompletion),
Mention(MentionCompletion),
}
impl ContextCompletion {
fn source_range(&self) -> Range<usize> {
match self {
Self::SlashCommand(completion) => completion.source_range.clone(),
Self::Mention(completion) => completion.source_range.clone(),
}
}
fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option<Self> {
if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) {
Some(Self::SlashCommand(command))
} else if let Some(mention) =
MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line)
{
Some(Self::Mention(mention))
} else {
None
}
}
}
#[derive(Debug, Default, PartialEq)]
pub struct SlashCommandCompletion {
pub source_range: Range<usize>,
pub command: Option<String>,
pub argument: Option<String>,
}
impl SlashCommandCompletion {
pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
// If we decide to support commands that are not at the beginning of the prompt, we can remove this check
if !line.starts_with('/') || offset_to_line != 0 {
return None;
}
let last_command_start = line.rfind('/')?;
if last_command_start >= line.len() {
return Some(Self::default());
}
if last_command_start > 0
&& line
.chars()
.nth(last_command_start - 1)
.is_some_and(|c| !c.is_whitespace())
{
return None;
}
let rest_of_line = &line[last_command_start + 1..];
let mut command = None;
let mut argument = None;
let mut end = last_command_start + 1;
if let Some(command_text) = rest_of_line.split_whitespace().next() {
command = Some(command_text.to_string());
end += command_text.len();
// Find the start of arguments after the command
if let Some(args_start) =
rest_of_line[command_text.len()..].find(|c: char| !c.is_whitespace())
{
let args = &rest_of_line[command_text.len() + args_start..].trim_end();
if !args.is_empty() {
argument = Some(args.to_string());
end += args.len() + 1;
}
}
}
Some(Self {
source_range: last_command_start + offset_to_line..end + offset_to_line,
command,
argument,
})
}
}
#[derive(Debug, Default, PartialEq)]
struct MentionCompletion {
source_range: Range<usize>,
@@ -1133,62 +932,6 @@ impl MentionCompletion {
mod tests {
use super::*;
#[test]
fn test_slash_command_completion_parse() {
assert_eq!(
SlashCommandCompletion::try_parse("/", 0),
Some(SlashCommandCompletion {
source_range: 0..1,
command: None,
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help", 0),
Some(SlashCommandCompletion {
source_range: 0..5,
command: Some("help".to_string()),
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help ", 0),
Some(SlashCommandCompletion {
source_range: 0..5,
command: Some("help".to_string()),
argument: None,
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help arg1", 0),
Some(SlashCommandCompletion {
source_range: 0..10,
command: Some("help".to_string()),
argument: Some("arg1".to_string()),
})
);
assert_eq!(
SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
Some(SlashCommandCompletion {
source_range: 0..15,
command: Some("help".to_string()),
argument: Some("arg1 arg2".to_string()),
})
);
assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
}
#[test]
fn test_mention_completion_parse() {
assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None);

View File

@@ -1,17 +1,13 @@
use std::{
cell::{Cell, RefCell},
ops::Range,
rc::Rc,
};
use std::{cell::Cell, ops::Range, rc::Rc};
use acp_thread::{AcpThread, AgentThreadEntry};
use agent_client_protocol::{self as acp, ToolCallId};
use agent_client_protocol::{PromptCapabilities, ToolCallId};
use agent2::HistoryStore;
use collections::HashMap;
use editor::{Editor, EditorMode, MinimapVisibility};
use gpui::{
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window,
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, ScrollHandle,
TextStyleRefinement, WeakEntity, Window,
};
use language::language_settings::SoftWrap;
use project::Project;
@@ -30,9 +26,8 @@ pub struct EntryViewState {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
prevent_slash_commands: bool,
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
}
impl EntryViewState {
@@ -41,9 +36,8 @@ impl EntryViewState {
project: Entity<Project>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
prevent_slash_commands: bool,
) -> Self {
Self {
workspace,
@@ -51,9 +45,8 @@ impl EntryViewState {
history_store,
prompt_store,
entries: Vec::new(),
prevent_slash_commands,
prompt_capabilities,
available_commands,
agent_name,
}
}
@@ -92,9 +85,8 @@ impl EntryViewState {
self.history_store.clone(),
self.prompt_store.clone(),
self.prompt_capabilities.clone(),
self.available_commands.clone(),
self.agent_name.clone(),
"Edit message @ to include context",
self.prevent_slash_commands,
editor::EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
@@ -133,35 +125,22 @@ impl EntryViewState {
views
};
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()),
});
}
}
}
views.entry(terminal.entity_id()).or_insert_with(|| {
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()),
});
element
});
}
for diff in diffs {
@@ -238,7 +217,6 @@ pub struct EntryViewEvent {
pub enum ViewEvent {
NewDiff(ToolCallId),
NewTerminal(ToolCallId),
TerminalMovedToBackground(ToolCallId),
MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
}
@@ -269,13 +247,6 @@ pub enum Entry {
}
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(_) | Self::Content(_) => None,
}
}
pub fn message_editor(&self) -> Option<&Entity<MessageEditor>> {
match self {
Self::UserMessage(editor) => Some(editor),
@@ -479,8 +450,7 @@ mod tests {
history_store,
None,
Default::default(),
Default::default(),
"Test Agent".into(),
false,
)
});

View File

@@ -1,20 +1,20 @@
use crate::{
acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion},
acp::completion_provider::ContextPickerCompletionProvider,
context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content},
};
use acp_thread::{MentionUri, selection_name};
use agent_client_protocol as acp;
use agent_servers::{AgentServer, AgentServerDelegate};
use agent_servers::AgentServer;
use agent2::HistoryStore;
use anyhow::{Result, anyhow};
use assistant_slash_commands::codeblock_fence_for_path;
use collections::{HashMap, HashSet};
use editor::{
Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, InlayId,
MultiBuffer, ToOffset,
EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer,
SemanticsProvider, ToOffset,
actions::Paste,
display_map::{Crease, CreaseId, FoldId, Inlay},
display_map::{Crease, CreaseId, FoldId},
};
use futures::{
FutureExt as _,
@@ -22,20 +22,18 @@ use futures::{
};
use gpui::{
Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, SharedString,
Subscription, Task, TextStyle, WeakEntity, pulsating_between,
EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext,
Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between,
};
use language::{Buffer, Language, language_settings::InlayHintKind};
use language::{Buffer, Language};
use language_model::LanguageModelImage;
use postage::stream::Stream as _;
use project::{
CompletionIntent, InlayHint, InlayHintLabel, Project, ProjectItem, ProjectPath, Worktree,
};
use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
use settings::Settings;
use std::{
cell::{Cell, RefCell},
cell::Cell,
ffi::OsStr,
fmt::Write,
ops::{Range, RangeInclusive},
@@ -44,18 +42,20 @@ use std::{
sync::Arc,
time::Duration,
};
use text::OffsetRangeExt;
use text::{OffsetRangeExt, ToOffset as _};
use theme::ThemeSettings;
use ui::{
ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _,
FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
LabelCommon, LabelSize, ParentElement, Render, SelectableButton, Styled, TextSize, TintColor,
Toggleable, Window, div, h_flex,
LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled,
TextSize, TintColor, Toggleable, Window, div, h_flex, px,
};
use util::{ResultExt, debug_panic};
use workspace::{Workspace, notifications::NotifyResultExt as _};
use zed_actions::agent::Chat;
const PARSE_SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(50);
pub struct MessageEditor {
mention_set: MentionSet,
editor: Entity<Editor>,
@@ -63,9 +63,8 @@ pub struct MessageEditor {
workspace: WeakEntity<Workspace>,
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prevent_slash_commands: bool,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
_subscriptions: Vec<Subscription>,
_parse_slash_command_task: Task<()>,
}
@@ -80,8 +79,6 @@ pub enum MessageEditorEvent {
impl EventEmitter<MessageEditorEvent> for MessageEditor {}
const COMMAND_HINT_INLAY_ID: usize = 0;
impl MessageEditor {
pub fn new(
workspace: WeakEntity<Workspace>,
@@ -89,9 +86,8 @@ impl MessageEditor {
history_store: Entity<HistoryStore>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<Cell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
placeholder: impl Into<Arc<str>>,
prevent_slash_commands: bool,
mode: EditorMode,
window: &mut Window,
cx: &mut Context<Self>,
@@ -103,14 +99,16 @@ impl MessageEditor {
},
None,
);
let completion_provider = Rc::new(ContextPickerCompletionProvider::new(
let completion_provider = ContextPickerCompletionProvider::new(
cx.weak_entity(),
workspace.clone(),
history_store.clone(),
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
));
);
let semantics_provider = Rc::new(SlashCommandSemanticsProvider {
range: Cell::new(None),
});
let mention_set = MentionSet::default();
let editor = cx.new(|cx| {
let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
@@ -121,12 +119,15 @@ impl MessageEditor {
editor.set_show_indent_guides(false, cx);
editor.set_soft_wrap();
editor.set_use_modal_editing(true);
editor.set_completion_provider(Some(completion_provider.clone()));
editor.set_completion_provider(Some(Rc::new(completion_provider)));
editor.set_context_menu_options(ContextMenuOptions {
min_entries_visible: 12,
max_entries_visible: 12,
placement: Some(ContextMenuPlacement::Above),
});
if prevent_slash_commands {
editor.set_semantics_provider(Some(semantics_provider.clone()));
}
editor.register_addon(MessageEditorAddon::new());
editor
});
@@ -140,33 +141,21 @@ impl MessageEditor {
})
.detach();
let mut has_hint = false;
let mut subscriptions = Vec::new();
subscriptions.push(cx.subscribe_in(&editor, window, {
let semantics_provider = semantics_provider.clone();
move |this, editor, event, window, cx| {
if let EditorEvent::Edited { .. } = event {
let snapshot = editor.update(cx, |editor, cx| {
let new_hints = this
.command_hint(editor.buffer(), cx)
.into_iter()
.collect::<Vec<_>>();
let has_new_hint = !new_hints.is_empty();
editor.splice_inlays(
if has_hint {
&[InlayId::Hint(COMMAND_HINT_INLAY_ID)]
} else {
&[]
},
new_hints,
if prevent_slash_commands {
this.highlight_slash_command(
semantics_provider.clone(),
editor.clone(),
window,
cx,
);
has_hint = has_new_hint;
editor.snapshot(window, cx)
});
}
let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
this.mention_set.remove_invalid(snapshot);
cx.notify();
}
}
@@ -179,57 +168,13 @@ impl MessageEditor {
workspace,
history_store,
prompt_store,
prevent_slash_commands,
prompt_capabilities,
available_commands,
agent_name,
_subscriptions: subscriptions,
_parse_slash_command_task: Task::ready(()),
}
}
fn command_hint(&self, buffer: &Entity<MultiBuffer>, cx: &App) -> Option<Inlay> {
let available_commands = self.available_commands.borrow();
if available_commands.is_empty() {
return None;
}
let snapshot = buffer.read(cx).snapshot(cx);
let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
if parsed_command.argument.is_some() {
return None;
}
let command_name = parsed_command.command?;
let available_command = available_commands
.iter()
.find(|command| command.name == command_name)?;
let acp::AvailableCommandInput::Unstructured { mut hint } =
available_command.input.clone()?;
let mut hint_pos = parsed_command.source_range.end + 1;
if hint_pos > snapshot.len() {
hint_pos = snapshot.len();
hint.insert(0, ' ');
}
let hint_pos = snapshot.anchor_after(hint_pos);
Some(Inlay::hint(
COMMAND_HINT_INLAY_ID,
hint_pos,
&InlayHint {
position: hint_pos.text_anchor,
label: InlayHintLabel::String(hint),
kind: Some(InlayHintKind::Parameter),
padding_left: false,
padding_right: false,
tooltip: None,
resolve_state: project::ResolveState::Resolved,
},
))
}
pub fn insert_thread_summary(
&mut self,
thread: agent2::DbThreadMetadata,
@@ -246,7 +191,7 @@ impl MessageEditor {
.text_anchor
});
self.confirm_mention_completion(
self.confirm_completion(
thread.title.clone(),
start,
thread.title.len(),
@@ -282,7 +227,7 @@ impl MessageEditor {
.collect()
}
pub fn confirm_mention_completion(
pub fn confirm_completion(
&mut self,
crease_text: SharedString,
start: text::Anchor,
@@ -700,8 +645,7 @@ impl MessageEditor {
self.project.read(cx).fs().clone(),
self.history_store.clone(),
));
let delegate = AgentServerDelegate::new(self.project.clone(), None);
let connection = server.connect(Path::new(""), delegate, cx);
let connection = server.connect(Path::new(""), &self.project, cx);
cx.spawn(async move |_, cx| {
let agent = connection.await?;
let agent = agent.downcast::<agent2::NativeAgentConnection>().unwrap();
@@ -734,62 +678,21 @@ impl MessageEditor {
})
}
fn validate_slash_commands(
text: &str,
available_commands: &[acp::AvailableCommand],
agent_name: &str,
) -> Result<()> {
if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
if let Some(command_name) = parsed_command.command {
// Check if this command is in the list of available commands from the server
let is_supported = available_commands
.iter()
.any(|cmd| cmd.name == command_name);
if !is_supported {
return Err(anyhow!(
"The /{} command is not supported by {}.\n\nAvailable commands: {}",
command_name,
agent_name,
if available_commands.is_empty() {
"none".to_string()
} else {
available_commands
.iter()
.map(|cmd| format!("/{}", cmd.name))
.collect::<Vec<_>>()
.join(", ")
}
));
}
}
}
Ok(())
}
pub fn contents(
&self,
cx: &mut Context<Self>,
) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
// Check for unsupported slash commands before spawning async task
let text = self.editor.read(cx).text(cx);
let available_commands = self.available_commands.borrow().clone();
if let Err(err) =
Self::validate_slash_commands(&text, &available_commands, &self.agent_name)
{
return Task::ready(Err(err));
}
let contents = self
.mention_set
.contents(&self.prompt_capabilities.get(), cx);
let editor = self.editor.clone();
let prevent_slash_commands = self.prevent_slash_commands;
cx.spawn(async move |_, cx| {
let contents = contents.await?;
let mut all_tracked_buffers = Vec::new();
let result = editor.update(cx, |editor, cx| {
editor.update(cx, |editor, cx| {
let mut ix = 0;
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
let text = editor.text(cx);
@@ -802,16 +705,14 @@ impl MessageEditor {
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
if crease_range.start > ix {
//todo(): Custom slash command ContentBlock?
// let chunk = if prevent_slash_commands
// && ix == 0
// && parse_slash_command(&text[ix..]).is_some()
// {
// format!(" {}", &text[ix..crease_range.start]).into()
// } else {
// text[ix..crease_range.start].into()
// };
let chunk = text[ix..crease_range.start].into();
let chunk = if prevent_slash_commands
&& ix == 0
&& parse_slash_command(&text[ix..]).is_some()
{
format!(" {}", &text[ix..crease_range.start]).into()
} else {
text[ix..crease_range.start].into()
};
chunks.push(chunk);
}
let chunk = match mention {
@@ -867,24 +768,22 @@ impl MessageEditor {
}
if ix < text.len() {
//todo(): Custom slash command ContentBlock?
// let last_chunk = if prevent_slash_commands
// && ix == 0
// && parse_slash_command(&text[ix..]).is_some()
// {
// format!(" {}", text[ix..].trim_end())
// } else {
// text[ix..].trim_end().to_owned()
// };
let last_chunk = text[ix..].trim_end().to_owned();
let last_chunk = if prevent_slash_commands
&& ix == 0
&& parse_slash_command(&text[ix..]).is_some()
{
format!(" {}", text[ix..].trim_end())
} else {
text[ix..].trim_end().to_owned()
};
if !last_chunk.is_empty() {
chunks.push(last_chunk.into());
}
}
});
Ok((chunks, all_tracked_buffers))
})?;
result
(chunks, all_tracked_buffers)
})
})
}
@@ -1071,14 +970,7 @@ impl MessageEditor {
cx,
);
});
tasks.push(self.confirm_mention_completion(
file_name,
anchor,
content_len,
uri,
window,
cx,
));
tasks.push(self.confirm_completion(file_name, anchor, content_len, uri, window, cx));
}
cx.spawn(async move |_, _| {
join_all(tasks).await;
@@ -1240,6 +1132,48 @@ impl MessageEditor {
cx.notify();
}
fn highlight_slash_command(
&mut self,
semantics_provider: Rc<SlashCommandSemanticsProvider>,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
struct InvalidSlashCommand;
self._parse_slash_command_task = cx.spawn_in(window, async move |_, cx| {
cx.background_executor()
.timer(PARSE_SLASH_COMMAND_DEBOUNCE)
.await;
editor
.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
let range = parse_slash_command(&editor.text(cx));
semantics_provider.range.set(range);
if let Some((start, end)) = range {
editor.highlight_text::<InvalidSlashCommand>(
vec![
snapshot.buffer_snapshot.anchor_after(start)
..snapshot.buffer_snapshot.anchor_before(end),
],
HighlightStyle {
underline: Some(UnderlineStyle {
thickness: px(1.),
color: Some(gpui::red()),
wavy: true,
}),
..Default::default()
},
cx,
);
} else {
editor.clear_highlights::<InvalidSlashCommand>(cx);
}
})
.ok();
})
}
pub fn text(&self, cx: &App) -> String {
self.editor.read(cx).text(cx)
}
@@ -1299,7 +1233,6 @@ impl Render for MessageEditor {
local_player: cx.theme().players().local(),
text: text_style,
syntax: cx.theme().syntax().clone(),
inlay_hints_style: editor::make_inlay_hints_style(cx),
..Default::default()
},
)
@@ -1330,7 +1263,7 @@ pub(crate) fn insert_crease_for_mention(
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder {
render: render_mention_fold_button(
render: render_fold_icon_button(
crease_label,
crease_icon,
start..end,
@@ -1360,7 +1293,7 @@ pub(crate) fn insert_crease_for_mention(
Some((crease_id, tx))
}
fn render_mention_fold_button(
fn render_fold_icon_button(
label: SharedString,
icon: SharedString,
range: Range<Anchor>,
@@ -1537,6 +1470,118 @@ impl MentionSet {
}
}
struct SlashCommandSemanticsProvider {
range: Cell<Option<(usize, usize)>>,
}
impl SemanticsProvider for SlashCommandSemanticsProvider {
fn hover(
&self,
buffer: &Entity<Buffer>,
position: text::Anchor,
cx: &mut App,
) -> Option<Task<Option<Vec<project::Hover>>>> {
let snapshot = buffer.read(cx).snapshot();
let offset = position.to_offset(&snapshot);
let (start, end) = self.range.get()?;
if !(start..end).contains(&offset) {
return None;
}
let range = snapshot.anchor_after(start)..snapshot.anchor_after(end);
Some(Task::ready(Some(vec![project::Hover {
contents: vec![project::HoverBlock {
text: "Slash commands are not supported".into(),
kind: project::HoverBlockKind::PlainText,
}],
range: Some(range),
language: None,
}])))
}
fn inline_values(
&self,
_buffer_handle: Entity<Buffer>,
_range: Range<text::Anchor>,
_cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
None
}
fn inlay_hints(
&self,
_buffer_handle: Entity<Buffer>,
_range: Range<text::Anchor>,
_cx: &mut App,
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
None
}
fn resolve_inlay_hint(
&self,
_hint: project::InlayHint,
_buffer_handle: Entity<Buffer>,
_server_id: lsp::LanguageServerId,
_cx: &mut App,
) -> Option<Task<anyhow::Result<project::InlayHint>>> {
None
}
fn supports_inlay_hints(&self, _buffer: &Entity<Buffer>, _cx: &mut App) -> bool {
false
}
fn document_highlights(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_cx: &mut App,
) -> Option<Task<Result<Vec<project::DocumentHighlight>>>> {
None
}
fn definitions(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_kind: editor::GotoDefinitionKind,
_cx: &mut App,
) -> Option<Task<Result<Option<Vec<project::LocationLink>>>>> {
None
}
fn range_for_rename(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_cx: &mut App,
) -> Option<Task<Result<Option<Range<text::Anchor>>>>> {
None
}
fn perform_rename(
&self,
_buffer: &Entity<Buffer>,
_position: text::Anchor,
_new_name: String,
_cx: &mut App,
) -> Option<Task<Result<project::ProjectTransaction>>> {
None
}
}
fn parse_slash_command(text: &str) -> Option<(usize, usize)> {
if let Some(remainder) = text.strip_prefix('/') {
let pos = remainder
.find(char::is_whitespace)
.unwrap_or(remainder.len());
let command = &remainder[..pos];
if !command.is_empty() && command.chars().all(char::is_alphanumeric) {
return Some((0, 1 + command.len()));
}
}
None
}
pub struct MessageEditorAddon {}
impl MessageEditorAddon {
@@ -1564,13 +1609,7 @@ impl Addon for MessageEditorAddon {
#[cfg(test)]
mod tests {
use std::{
cell::{Cell, RefCell},
ops::Range,
path::Path,
rc::Rc,
sync::Arc,
};
use std::{cell::Cell, ops::Range, path::Path, rc::Rc, sync::Arc};
use acp_thread::MentionUri;
use agent_client_protocol as acp;
@@ -1617,9 +1656,8 @@ mod tests {
history_store.clone(),
None,
Default::default(),
Default::default(),
"Test Agent".into(),
"Test",
false,
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
@@ -1696,140 +1734,6 @@ mod tests {
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
}
#[gpui::test]
async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/test",
json!({
".zed": {
"tasks.json": r#"[{"label": "test", "command": "echo"}]"#
},
"src": {
"main.rs": "fn main() {}",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
// Start with no available commands - simulating Claude which doesn't support slash commands
let available_commands = Rc::new(RefCell::new(vec![]));
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let workspace_handle = workspace.downgrade();
let message_editor = workspace.update_in(cx, |_, window, cx| {
cx.new(|cx| {
MessageEditor::new(
workspace_handle.clone(),
project.clone(),
history_store.clone(),
None,
prompt_capabilities.clone(),
available_commands.clone(),
"Claude Code".into(),
"Test",
EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
},
window,
cx,
)
})
});
let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
// Test that slash commands fail when no available_commands are set (empty list means no commands supported)
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/file test.txt", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await;
// Should fail because available_commands is empty (no commands supported)
assert!(contents_result.is_err());
let error_message = contents_result.unwrap_err().to_string();
assert!(error_message.contains("not supported by Claude Code"));
assert!(error_message.contains("Available commands: none"));
// Now simulate Claude providing its list of available commands (which doesn't include file)
available_commands.replace(vec![acp::AvailableCommand {
name: "help".to_string(),
description: "Get help".to_string(),
input: None,
}]);
// Test that unsupported slash commands trigger an error when we have a list of available commands
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/file test.txt", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await;
assert!(contents_result.is_err());
let error_message = contents_result.unwrap_err().to_string();
assert!(error_message.contains("not supported by Claude Code"));
assert!(error_message.contains("/file"));
assert!(error_message.contains("Available commands: /help"));
// Test that supported commands work fine
editor.update_in(cx, |editor, window, cx| {
editor.set_text("/help", window, cx);
});
let contents_result = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await;
// Should succeed because /help is in available_commands
assert!(contents_result.is_ok());
// Test that regular text works fine
editor.update_in(cx, |editor, window, cx| {
editor.set_text("Hello Claude!", window, cx);
});
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await
.unwrap();
assert_eq!(content.len(), 1);
if let acp::ContentBlock::Text(text) = &content[0] {
assert_eq!(text.text, "Hello Claude!");
} else {
panic!("Expected ContentBlock::Text");
}
// Test that @ mentions still work
editor.update_in(cx, |editor, window, cx| {
editor.set_text("Check this @", window, cx);
});
// The @ mention functionality should not be affected
let (content, _) = message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx))
.await
.unwrap();
assert_eq!(content.len(), 1);
if let acp::ContentBlock::Text(text) = &content[0] {
assert_eq!(text.text, "Check this @");
} else {
panic!("Expected ContentBlock::Text");
}
}
struct MessageEditorItem(Entity<MessageEditor>);
impl Item for MessageEditorItem {
@@ -1859,192 +1763,7 @@ mod tests {
}
#[gpui::test]
async fn test_completion_provider_commands(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
language::init(cx);
editor::init(cx);
workspace::init(app_state.clone(), cx);
Project::init_settings(cx);
});
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let workspace = window.root(cx).unwrap();
let mut cx = VisualTestContext::from_window(*window, cx);
let context_store = cx.new(|cx| ContextStore::fake(project.clone(), cx));
let history_store = cx.new(|cx| HistoryStore::new(context_store, cx));
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![
acp::AvailableCommand {
name: "quick-math".to_string(),
description: "2 + 2 = 4 - 1 = 3".to_string(),
input: None,
},
acp::AvailableCommand {
name: "say-hello".to_string(),
description: "Say hello to whoever you want".to_string(),
input: Some(acp::AvailableCommandInput::Unstructured {
hint: "<name>".to_string(),
}),
},
]));
let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.clone(),
history_store.clone(),
None,
prompt_capabilities.clone(),
available_commands.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window);
message_editor.read(cx).editor().clone()
});
cx.simulate_input("/");
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[
("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
("say-hello".into(), "Say hello to whoever you want".into())
]
);
editor.set_text("", window, cx);
});
cx.simulate_input("/qui");
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/qui");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
);
editor.set_text("", window, cx);
});
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.display_text(cx), "/quick-math ");
assert!(!editor.has_visible_completions_menu());
editor.set_text("", window, cx);
});
cx.simulate_input("/say");
editor.update_in(&mut cx, |editor, _window, cx| {
assert_eq!(editor.display_text(cx), "/say");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("say-hello".into(), "Say hello to whoever you want".into())]
);
});
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, _window, cx| {
assert_eq!(editor.text(cx), "/say-hello ");
assert_eq!(editor.display_text(cx), "/say-hello <name>");
assert!(editor.has_visible_completions_menu());
assert_eq!(
current_completion_labels_with_documentation(editor),
&[("say-hello".into(), "Say hello to whoever you want".into())]
);
});
cx.simulate_input("GPT5");
editor.update_in(&mut cx, |editor, window, cx| {
assert!(editor.has_visible_completions_menu());
editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/say-hello GPT5");
assert_eq!(editor.display_text(cx), "/say-hello GPT5");
assert!(!editor.has_visible_completions_menu());
// Delete argument
for _ in 0..4 {
editor.backspace(&editor::actions::Backspace, window, cx);
}
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
assert_eq!(editor.text(cx), "/say-hello ");
// Hint is visible because argument was deleted
assert_eq!(editor.display_text(cx), "/say-hello <name>");
// Delete last command letter
editor.backspace(&editor::actions::Backspace, window, cx);
editor.backspace(&editor::actions::Backspace, window, cx);
});
cx.run_until_parked();
editor.update_in(&mut cx, |editor, _window, cx| {
// Hint goes away once command no longer matches an available one
assert_eq!(editor.text(cx), "/say-hell");
assert_eq!(editor.display_text(cx), "/say-hell");
assert!(!editor.has_visible_completions_menu());
});
}
#[gpui::test]
async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
async fn test_context_completion_provider(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
@@ -2137,9 +1856,8 @@ mod tests {
history_store.clone(),
None,
prompt_capabilities.clone(),
Default::default(),
"Test Agent".into(),
"Test",
false,
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
@@ -2169,6 +1887,7 @@ mod tests {
assert_eq!(editor.text(cx), "Lorem @");
assert!(editor.has_visible_completions_menu());
// Only files since we have default capabilities
assert_eq!(
current_completion_labels(editor),
&[
@@ -2564,20 +2283,4 @@ mod tests {
.map(|completion| completion.label.text)
.collect::<Vec<_>>()
}
fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
let completions = editor.current_completions().expect("Missing completions");
completions
.into_iter()
.map(|completion| {
(
completion.label.text,
completion
.documentation
.map(|d| d.text().to_string())
.unwrap_or_default(),
)
})
.collect::<Vec<_>>()
}
}

View File

@@ -71,10 +71,13 @@ impl AcpModelPickerDelegate {
let (models, selected_model) = futures::join!(models_task, selected_model_task);
this.update_in(cx, |this, window, cx| {
this.delegate.models = models.log_err();
this.delegate.models = models.ok();
this.delegate.selected_model = selected_model.ok();
this.refresh(window, cx)
})
this.delegate.update_matches(this.query(cx), window, cx)
})?
.await;
Ok(())
}
refresh(&this, &session_id, cx).await.log_err();
@@ -141,11 +144,6 @@ impl PickerDelegate for AcpModelPickerDelegate {
cx.spawn_in(window, async move |this, cx| {
let filtered_models = match this
.read_with(cx, |this, cx| {
if let Some(models) = this.delegate.models.as_ref() {
log::debug!("Filtering {} models.", models.len());
} else {
log::debug!("No models available.");
}
this.delegate.models.clone().map(move |models| {
fuzzy_search(models, query, cx.background_executor().clone())
})
@@ -157,8 +155,6 @@ impl PickerDelegate for AcpModelPickerDelegate {
None => AgentModelList::Flat(vec![]),
};
log::debug!("Filtered models. {} available.", filtered_models.len());
this.update_in(cx, |this, window, cx| {
this.delegate.filtered_entries =
info_list_to_picker_entries(filtered_models).collect();

View File

@@ -36,14 +36,6 @@ impl AcpModelSelectorPopover {
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
self.menu_handle.toggle(window, cx);
}
pub fn active_model_name(&self, cx: &App) -> Option<SharedString> {
self.selector
.read(cx)
.delegate
.active_model()
.map(|model| model.name.clone())
}
}
impl Render for AcpModelSelectorPopover {

View File

@@ -6,10 +6,10 @@ use acp_thread::{
use acp_thread::{AgentConnection, Plan};
use action_log::ActionLog;
use agent_client_protocol::{self as acp, PromptCapabilities};
use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
use agent_servers::{AgentServer, ClaudeCode};
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
use anyhow::{Context as _, Result, anyhow, bail};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
use anyhow::bail;
use audio::{Audio, Sound};
use buffer_diff::BufferDiff;
use client::zed_urls;
@@ -18,7 +18,6 @@ use editor::scroll::Autoscroll;
use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects};
use file_icons::FileIcons;
use fs::Fs;
use futures::FutureExt as _;
use gpui::{
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
@@ -35,18 +34,16 @@ use project::{Project, ProjectEntryId};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
use settings::{Settings as _, SettingsStore};
use std::cell::{Cell, RefCell};
use std::cell::Cell;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use std::{collections::BTreeMap, rc::Rc, time::Duration};
use task::SpawnInTerminal;
use terminal_view::terminal_panel::TerminalPanel;
use text::Anchor;
use theme::ThemeSettings;
use ui::{
Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, Workspace};
@@ -78,12 +75,10 @@ enum ThreadFeedback {
Negative,
}
#[derive(Debug)]
enum ThreadError {
PaymentRequired,
ModelRequestLimitReached(cloud_llm_client::Plan),
ToolUseLimitReached,
Refusal,
AuthenticationRequired(SharedString),
Other(SharedString),
}
@@ -98,10 +93,6 @@ impl ThreadError {
error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
{
Self::ModelRequestLimitReached(error.plan)
} else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
&& acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code
{
Self::AuthenticationRequired(acp_error.message.clone().into())
} else {
let string = error.to_string();
// TODO: we should have Gemini return better errors here.
@@ -286,14 +277,16 @@ pub struct AcpThreadView {
should_be_following: bool,
editing_message: Option<usize>,
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
is_loading_contents: bool,
install_command_markdown: Entity<Markdown>,
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 3],
}
enum ThreadState {
Loading(Entity<LoadingView>),
Loading {
_task: Task<()>,
},
Ready {
thread: Entity<AcpThread>,
title_editor: Option<Entity<Editor>>,
@@ -309,12 +302,6 @@ enum ThreadState {
},
}
struct LoadingView {
title: SharedString,
_load_task: Task<()>,
_update_title_task: Task<anyhow::Result<()>>,
}
impl AcpThreadView {
pub fn new(
agent: Rc<dyn AgentServer>,
@@ -328,15 +315,10 @@ impl AcpThreadView {
cx: &mut Context<Self>,
) -> Self {
let prompt_capabilities = Rc::new(Cell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![]));
let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().is_some();
let placeholder = if agent.name() == "Zed Agent" {
format!("Message the {} — @ to include context", agent.name())
} else if agent.name() == "Claude Code" || !available_commands.borrow().is_empty() {
format!(
"Message {} — @ to include context, / for commands",
agent.name()
)
} else {
format!("Message {} — @ to include context", agent.name())
};
@@ -348,9 +330,8 @@ impl AcpThreadView {
history_store.clone(),
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
agent.name(),
placeholder,
prevent_slash_commands,
editor::EditorMode::AutoHeight {
min_lines: MIN_EDITOR_LINES,
max_lines: Some(MAX_EDITOR_LINES),
@@ -373,8 +354,7 @@ impl AcpThreadView {
history_store.clone(),
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
agent.name(),
prevent_slash_commands,
)
});
@@ -406,13 +386,13 @@ impl AcpThreadView {
editing_message: None,
edits_expanded: false,
plan_expanded: false,
prompt_capabilities,
available_commands,
editor_expanded: false,
should_be_following: false,
history_store,
hovered_recent_history_item: None,
prompt_capabilities,
is_loading_contents: false,
install_command_markdown: cx.new(|cx| Markdown::new("".into(), None, None, cx)),
_subscriptions: subscriptions,
_cancel_task: None,
focus_handle: cx.focus_handle(),
@@ -427,34 +407,14 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> ThreadState {
if !project.read(cx).is_local() && agent.clone().downcast::<NativeAgentServer>().is_none() {
return ThreadState::LoadError(LoadError::Other(
"External agents are not yet supported for remote projects.".into(),
));
}
let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
// Pick the first non-single-file worktree for the root directory if there are any,
// and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
worktrees.sort_by(|l, r| {
l.read(cx)
.is_single_file()
.cmp(&r.read(cx).is_single_file())
});
let root_dir = worktrees
.into_iter()
.filter_map(|worktree| {
if worktree.read(cx).is_single_file() {
Some(worktree.read(cx).abs_path().parent()?.into())
} else {
Some(worktree.read(cx).abs_path())
}
})
let root_dir = project
.read(cx)
.visible_worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).abs_path())
.unwrap_or_else(|| paths::home_dir().as_path().into());
let (tx, mut rx) = watch::channel("Loading…".into());
let delegate = AgentServerDelegate::new(project.clone(), Some(tx));
let connect_task = agent.connect(&root_dir, delegate, cx);
let connect_task = agent.connect(&root_dir, &project, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
let connection = match connect_task.await {
Ok(connection) => connection,
@@ -515,38 +475,15 @@ impl AcpThreadView {
Ok(thread) => {
let action_log = thread.read(cx).action_log().clone();
let mut available_commands = thread.read(cx).available_commands();
if connection
.auth_methods()
.iter()
.any(|method| method.id.0.as_ref() == "claude-login")
{
available_commands.push(acp::AvailableCommand {
name: "login".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
available_commands.push(acp::AvailableCommand {
name: "logout".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
}
this.available_commands.replace(available_commands);
this.prompt_capabilities
.set(thread.read(cx).prompt_capabilities());
let count = thread.read(cx).entries().len();
this.list_state.splice(0..0, count);
this.entry_view_state.update(cx, |view_state, cx| {
for ix in 0..count {
view_state.sync_entry(ix, &thread, window, cx);
}
this.list_state.splice_focusable(
0..0,
(0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
);
});
if let Some(resume) = resume_thread {
@@ -627,25 +564,7 @@ impl AcpThreadView {
.log_err();
});
let loading_view = cx.new(|cx| {
let update_title_task = cx.spawn(async move |this, cx| {
loop {
let status = rx.recv().await?;
this.update(cx, |this: &mut LoadingView, cx| {
this.title = status;
cx.notify();
})?;
}
});
LoadingView {
title: "Loading…".into(),
_load_task: load_task,
_update_title_task: update_title_task,
}
});
ThreadState::Loading(loading_view)
ThreadState::Loading { _task: load_task }
}
fn handle_auth_required(
@@ -666,10 +585,6 @@ impl AcpThreadView {
move |_, ev, window, cx| {
if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
&& &provider_id == updated_provider_id
&& LanguageModelRegistry::global(cx)
.read(cx)
.provider(&provider_id)
.map_or(false, |provider| provider.is_authenticated(cx))
{
this.update(cx, |this, cx| {
this.thread_state = Self::initial_state(
@@ -749,15 +664,13 @@ impl AcpThreadView {
}
}
pub fn title(&self, cx: &App) -> SharedString {
pub fn title(&self) -> SharedString {
match &self.thread_state {
ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(),
ThreadState::Loading { .. } => "Loading".into(),
ThreadState::LoadError(error) => match error {
LoadError::NotInstalled { .. } => format!("Install {}", self.agent.name()).into(),
LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
LoadError::FailedToInstall(_) => {
format!("Failed to Install {}", self.agent.name()).into()
}
LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
},
@@ -880,9 +793,6 @@ impl AcpThreadView {
self.expanded_tool_calls.insert(tool_call_id.clone());
}
}
ViewEvent::TerminalMovedToBackground(tool_call_id) => {
self.expanded_tool_calls.remove(tool_call_id);
}
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => {
if let Some(thread) = self.thread()
&& let Some(AgentThreadEntry::UserMessage(user_message)) =
@@ -955,40 +865,6 @@ impl AcpThreadView {
return;
}
let text = self.message_editor.read(cx).text(cx);
let text = text.trim();
if text == "/login" || text == "/logout" {
let ThreadState::Ready { thread, .. } = &self.thread_state else {
return;
};
let connection = thread.read(cx).connection().clone();
if !connection
.auth_methods()
.iter()
.any(|method| method.id.0.as_ref() == "claude-login")
{
return;
};
let this = cx.weak_entity();
let agent = self.agent.clone();
window.defer(cx, |window, cx| {
Self::handle_auth_required(
this,
AuthRequired {
description: None,
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
},
agent,
connection,
window,
cx,
);
});
cx.notify();
return;
}
let contents = self
.message_editor
.update(cx, |message_editor, cx| message_editor.contents(cx));
@@ -1019,7 +895,7 @@ impl AcpThreadView {
fn send_impl(
&mut self,
contents: Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
contents: Task<anyhow::Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1029,10 +905,9 @@ impl AcpThreadView {
self.editing_message.take();
self.thread_feedback.clear();
let Some(thread) = self.thread() else {
let Some(thread) = self.thread().cloned() else {
return;
};
let thread = thread.downgrade();
if self.should_be_following {
self.workspace
.update(cx, |workspace, cx| {
@@ -1240,14 +1115,9 @@ impl AcpThreadView {
let len = thread.read(cx).entries().len();
let index = len - 1;
self.entry_view_state.update(cx, |view_state, cx| {
view_state.sync_entry(index, thread, window, cx);
self.list_state.splice_focusable(
index..index,
[view_state
.entry(index)
.and_then(|entry| entry.focus_handle(cx))],
);
view_state.sync_entry(index, thread, window, cx)
});
self.list_state.splice(index..index, 1);
}
AcpThreadEvent::EntryUpdated(index) => {
self.entry_view_state.update(cx, |view_state, cx| {
@@ -1279,14 +1149,6 @@ impl AcpThreadView {
cx,
);
}
AcpThreadEvent::Refusal => {
self.thread_retry_status.take();
self.thread_error = Some(ThreadError::Refusal);
let model_or_agent_name = self.get_current_model_name(cx);
let notification_message =
format!("{} refused to respond to this request", model_or_agent_name);
self.notify_with_sound(&notification_message, IconName::Warning, window, cx);
}
AcpThreadEvent::Error => {
self.thread_retry_status.take();
self.notify_with_sound(
@@ -1363,43 +1225,6 @@ impl AcpThreadView {
});
return;
}
} else if method.0.as_ref() == "anthropic-api-key" {
let registry = LanguageModelRegistry::global(cx);
let provider = registry
.read(cx)
.provider(&language_model::ANTHROPIC_PROVIDER_ID)
.unwrap();
let this = cx.weak_entity();
let agent = self.agent.clone();
let connection = connection.clone();
window.defer(cx, move |window, cx| {
if !provider.is_authenticated(cx) {
Self::handle_auth_required(
this,
AuthRequired {
description: Some("ANTHROPIC_API_KEY must be set".to_owned()),
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
},
agent,
connection,
window,
cx,
);
} else {
this.update(cx, |this, cx| {
this.thread_state = Self::initial_state(
agent,
None,
this.workspace.clone(),
this.project.clone(),
window,
cx,
)
})
.ok();
}
});
return;
} else if method.0.as_ref() == "vertex-ai"
&& std::env::var("GOOGLE_API_KEY").is_err()
&& (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
@@ -1431,15 +1256,7 @@ impl AcpThreadView {
self.thread_error.take();
configuration_view.take();
pending_auth_method.replace(method.clone());
let authenticate = if method.0.as_ref() == "claude-login" {
if let Some(workspace) = self.workspace.upgrade() {
Self::spawn_claude_login(&workspace, window, cx)
} else {
Task::ready(Ok(()))
}
} else {
connection.authenticate(method, cx)
};
let authenticate = connection.authenticate(method, cx);
cx.notify();
self.auth_task =
Some(cx.spawn_in(window, {
@@ -1463,13 +1280,6 @@ impl AcpThreadView {
this.update_in(cx, |this, window, cx| {
if let Err(err) = result {
if let ThreadState::Unauthenticated {
pending_auth_method,
..
} = &mut this.thread_state
{
pending_auth_method.take();
}
this.handle_thread_error(err, cx);
} else {
this.thread_state = Self::initial_state(
@@ -1488,97 +1298,6 @@ impl AcpThreadView {
}));
}
fn spawn_claude_login(
workspace: &Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
return Task::ready(Ok(()));
};
let project_entity = workspace.read(cx).project();
let project = project_entity.read(cx);
let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone();
let delegate = AgentServerDelegate::new(project_entity.clone(), None);
let command = ClaudeCode::login_command(delegate, cx);
window.spawn(cx, async move |cx| {
let login_command = command.await?;
let command = login_command
.path
.to_str()
.with_context(|| format!("invalid login command: {:?}", login_command.path))?;
let command = shlex::try_quote(command)?;
let args = login_command
.arguments
.iter()
.map(|arg| {
Ok(shlex::try_quote(arg)
.context("Failed to quote argument")?
.to_string())
})
.collect::<Result<Vec<_>>>()?;
let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
terminal_panel.spawn_task(
&SpawnInTerminal {
id: task::TaskId("claude-login".into()),
full_label: "claude /login".to_owned(),
label: "claude /login".to_owned(),
command: Some(command.into()),
args,
command_label: "claude /login".to_owned(),
cwd,
use_new_terminal: true,
allow_concurrent_runs: true,
hide: task::HideStrategy::Always,
shell,
..Default::default()
},
window,
cx,
)
})?;
let terminal = terminal.await?;
let mut exit_status = terminal
.read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
.fuse();
let logged_in = cx
.spawn({
let terminal = terminal.clone();
async move |cx| {
loop {
cx.background_executor().timer(Duration::from_secs(1)).await;
let content =
terminal.update(cx, |terminal, _cx| terminal.get_content())?;
if content.contains("Login successful") {
return anyhow::Ok(());
}
}
}
})
.fuse();
futures::pin_mut!(logged_in);
futures::select_biased! {
result = logged_in => {
if let Err(e) = result {
log::error!("{e}");
return Err(anyhow!("exited before logging in"));
}
}
_ = exit_status => {
return Err(anyhow!("exited before logging in"));
}
}
terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
Ok(())
})
}
fn authorize_tool_call(
&mut self,
tool_call_id: acp::ToolCallId,
@@ -2528,8 +2247,7 @@ impl AcpThreadView {
let output = terminal_data.output();
let command_finished = output.is_some();
let truncated_output =
output.is_some_and(|output| output.original_content_len > output.content.len());
let truncated_output = output.is_some_and(|output| output.was_content_truncated);
let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
let command_failed = command_finished
@@ -2651,14 +2369,14 @@ impl AcpThreadView {
.when(truncated_output, |header| {
let tooltip = if let Some(output) = output {
if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
format!("Output exceeded terminal max lines and was \
truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true))
"Output exceeded terminal max lines and was \
truncated, the model received the first 16 KB."
.to_string()
} else {
format!(
"Output is {} long, and to avoid unexpected token usage, \
only {} was sent back to the agent.",
only 16 KB was sent back to the model.",
format_file_size(output.original_content_len as u64, true),
format_file_size(output.content.len() as u64, true)
)
}
} else {
@@ -2757,18 +2475,7 @@ impl AcpThreadView {
.bg(cx.theme().colors().editor_background)
.rounded_b_md()
.text_ui_sm(cx)
.h_full()
.children(terminal_view.map(|terminal_view| {
if terminal_view
.read(cx)
.content_mode(window, cx)
.is_scrollable()
{
div().h_72().child(terminal_view).into_any_element()
} else {
terminal_view.into_any_element()
}
})),
.children(terminal_view.clone()),
)
})
.into_any()
@@ -3117,26 +2824,18 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let (title, message, action_slot): (_, SharedString, _) = match e {
let (message, action_slot): (SharedString, _) = match e {
LoadError::NotInstalled => {
return self.render_not_installed(None, window, cx);
}
LoadError::Unsupported {
command: path,
current_version,
minimum_version,
} => {
return self.render_unsupported(path, current_version, minimum_version, window, cx);
return self.render_not_installed(Some((path, current_version)), window, cx);
}
LoadError::FailedToInstall(msg) => (
"Failed to Install",
msg.into(),
Some(self.create_copy_button(msg.to_string()).into_any_element()),
),
LoadError::Exited { status } => (
"Failed to Launch",
format!("Server exited with status {status}").into(),
None,
),
LoadError::Exited { .. } => ("Server exited with status {status}".into(), None),
LoadError::Other(msg) => (
"Failed to Launch",
msg.into(),
Some(self.create_copy_button(msg.to_string()).into_any_element()),
),
@@ -3145,34 +2844,95 @@ impl AcpThreadView {
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircleFilled)
.title(title)
.title("Failed to Launch")
.description(message)
.actions_slot(div().children(action_slot))
.into_any_element()
}
fn render_unsupported(
fn install_agent(&self, window: &mut Window, cx: &mut Context<Self>) {
telemetry::event!("Agent Install CLI", agent = self.agent.telemetry_id());
let Some(install_command) = self.agent.install_command().map(|s| s.to_owned()) else {
return;
};
let task = self
.workspace
.update(cx, |workspace, cx| {
let project = workspace.project().read(cx);
let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId(install_command.clone()),
full_label: install_command.clone(),
label: install_command.clone(),
command: Some(install_command.clone()),
args: Vec::new(),
command_label: install_command.clone(),
cwd,
env: Default::default(),
use_new_terminal: true,
allow_concurrent_runs: true,
reveal: Default::default(),
reveal_target: Default::default(),
hide: Default::default(),
shell,
show_summary: true,
show_command: true,
show_rerun: false,
};
workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
})
.ok();
let Some(task) = task else { return };
cx.spawn_in(window, async move |this, cx| {
if let Some(Ok(_)) = task.await {
this.update_in(cx, |this, window, cx| {
this.reset(window, cx);
})
.ok();
}
})
.detach()
}
fn render_not_installed(
&self,
path: &SharedString,
version: &SharedString,
minimum_version: &SharedString,
_window: &mut Window,
existing_version: Option<(&SharedString, &SharedString)>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let (heading_label, description_label) = (
format!("Upgrade {} to work with Zed", self.agent.name()),
if version.is_empty() {
format!(
"Currently using {}, which does not report a valid --version",
path,
let install_command = self.agent.install_command().unwrap_or_default();
self.install_command_markdown.update(cx, |markdown, cx| {
if !markdown.source().contains(&install_command) {
markdown.replace(format!("```\n{}\n```", install_command), cx);
}
});
let (heading_label, description_label, button_label) =
if let Some((path, version)) = existing_version {
(
format!("Upgrade {} to work with Zed", self.agent.name()),
if version.is_empty() {
format!(
"Currently using {}, which does not report a valid --version",
path,
)
} else {
format!(
"Currently using {}, which is only version {}",
path, version
)
},
format!("Upgrade {}", self.agent.name()),
)
} else {
format!(
"Currently using {}, which is only version {} (need at least {minimum_version})",
path, version
(
format!("Get Started with {} in Zed", self.agent.name()),
"Use Google's new coding agent directly in Zed.".to_string(),
format!("Install {}", self.agent.name()),
)
},
);
};
v_flex()
.w_full()
@@ -3192,6 +2952,34 @@ impl AcpThreadView {
.color(Color::Muted),
),
)
.child(
Button::new("install_gemini", button_label)
.full_width()
.size(ButtonSize::Medium)
.style(ButtonStyle::Tinted(TintColor::Accent))
.label_size(LabelSize::Small)
.icon(IconName::TerminalGhost)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start)
.on_click(cx.listener(|this, _, window, cx| this.install_agent(window, cx))),
)
.child(
Label::new("Or, run the following command in your terminal:")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(MarkdownElement::new(
self.install_command_markdown.clone(),
default_markdown_style(false, false, window, cx),
))
.when_some(existing_version, |el, (path, _)| {
el.child(
Label::new(format!("If this does not work you will need to upgrade manually, or uninstall your existing version from {}", path))
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.into_any_element()
}
@@ -3214,12 +3002,7 @@ impl AcpThreadView {
let active_color = cx.theme().colors().element_selected;
let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
// Temporarily always enable ACP edit controls. This is temporary, to lessen the
// impact of a nasty bug that causes them to sometimes be disabled when they shouldn't
// be, which blocks you from being able to accept or reject edits. This switches the
// bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't
// block you from using the panel.
let pending_edits = false;
let pending_edits = thread.has_pending_edit_tool_calls();
v_flex()
.mt_1()
@@ -4232,7 +4015,7 @@ impl AcpThreadView {
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
) -> Task<anyhow::Result<()>> {
let markdown_language_task = workspace
.read(cx)
.app_state()
@@ -4805,7 +4588,6 @@ impl AcpThreadView {
fn render_thread_error(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
let content = match self.thread_error.as_ref()? {
ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx),
ThreadError::Refusal => self.render_refusal_error(cx),
ThreadError::AuthenticationRequired(error) => {
self.render_authentication_required_error(error.clone(), cx)
}
@@ -4821,43 +4603,6 @@ impl AcpThreadView {
Some(div().child(content))
}
fn get_current_model_name(&self, cx: &App) -> SharedString {
// For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
// For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
// This provides better clarity about what refused the request
if self
.agent
.clone()
.downcast::<agent2::NativeAgentServer>()
.is_some()
{
// Native agent - use the model name
self.model_selector
.as_ref()
.and_then(|selector| selector.read(cx).active_model_name(cx))
.unwrap_or_else(|| SharedString::from("The model"))
} else {
// ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI")
self.agent.name()
}
}
fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout {
let model_or_agent_name = self.get_current_model_name(cx);
let refusal_message = format!(
"{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
model_or_agent_name
);
Callout::new()
.severity(Severity::Error)
.title("Request Refused")
.icon(IconName::XCircle)
.description(refusal_message.clone())
.actions_slot(self.create_copy_button(&refusal_message))
.dismiss_action(self.dismiss_error_button(cx))
}
fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout {
let can_resume = self
.thread()
@@ -5123,6 +4868,18 @@ impl AcpThreadView {
}))
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.thread_state = Self::initial_state(
self.agent.clone(),
None,
self.workspace.clone(),
self.project.clone(),
window,
cx,
);
cx.notify();
}
pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context<Self>) {
let task = match entry {
HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
@@ -5489,33 +5246,6 @@ pub(crate) mod tests {
);
}
#[gpui::test]
async fn test_refusal_handling(cx: &mut TestAppContext) {
init_test(cx);
let (thread_view, cx) =
setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await;
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("Do something harmful", window, cx);
});
thread_view.update_in(cx, |thread_view, window, cx| {
thread_view.send(window, cx);
});
cx.run_until_parked();
// Check that the refusal error is set
thread_view.read_with(cx, |thread_view, _cx| {
assert!(
matches!(thread_view.thread_error, Some(ThreadError::Refusal)),
"Expected refusal error to be set"
);
});
}
#[gpui::test]
async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
init_test(cx);
@@ -5678,10 +5408,22 @@ pub(crate) mod tests {
"Test".into()
}
fn empty_state_headline(&self) -> SharedString {
"Test".into()
}
fn empty_state_message(&self) -> SharedString {
"Test".into()
}
fn install_command(&self) -> Option<&'static str> {
None
}
fn connect(
&self,
_root_dir: &Path,
_delegate: AgentServerDelegate,
_project: &Entity<Project>,
_cx: &mut App,
) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
Task::ready(Ok(Rc::new(self.connection.clone())))
@@ -5715,7 +5457,6 @@ pub(crate) mod tests {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
})))
@@ -5751,68 +5492,6 @@ pub(crate) mod tests {
}
}
/// Simulates a model which always returns a refusal response
#[derive(Clone)]
struct RefusalAgentConnection;
impl AgentConnection for RefusalAgentConnection {
fn new_thread(
self: Rc<Self>,
project: Entity<Project>,
_cwd: &Path,
cx: &mut gpui::App,
) -> Task<gpui::Result<Entity<AcpThread>>> {
Task::ready(Ok(cx.new(|cx| {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
AcpThread::new(
"RefusalAgentConnection",
self,
project,
action_log,
SessionId("test".into()),
watch::Receiver::constant(acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}),
Vec::new(),
cx,
)
})))
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
&[]
}
fn authenticate(
&self,
_method_id: acp::AuthMethodId,
_cx: &mut App,
) -> Task<gpui::Result<()>> {
unimplemented!()
}
fn prompt(
&self,
_id: Option<acp_thread::UserMessageId>,
_params: acp::PromptRequest,
_cx: &mut App,
) -> Task<gpui::Result<acp::PromptResponse>> {
Task::ready(Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Refusal,
}))
}
fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
unimplemented!()
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
}
pub(crate) fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);

View File

@@ -1002,22 +1002,8 @@ impl ActiveThread {
// Don't notify for intermediate tool use
}
Ok(StopReason::Refusal) => {
let model_name = self
.thread
.read(cx)
.configured_model()
.map(|configured| configured.model.name().0.to_string())
.unwrap_or_else(|| "The model".to_string());
let refusal_message = format!(
"{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.",
model_name
);
self.last_error = Some(ThreadError::Message {
header: SharedString::from("Request Refused"),
message: SharedString::from(refusal_message),
});
self.notify_with_sound(
format!("{} refused to respond", model_name),
"Language model refused to respond",
IconName::Warning,
window,
cx,

View File

@@ -5,7 +5,7 @@ mod tool_picker;
use std::{ops::Range, sync::Arc, time::Duration};
use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
use agent_servers::{AgentServerCommand, AgentServerSettings, AllAgentServersSettings, Gemini};
use agent_settings::AgentSettings;
use anyhow::Result;
use assistant_tool::{ToolSource, ToolWorkingSet};
@@ -27,6 +27,7 @@ use language_model::{
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
Project,
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings},
};
@@ -51,6 +52,7 @@ pub struct AgentConfiguration {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
context_server_store: Entity<ContextServerStore>,
@@ -60,6 +62,7 @@ pub struct AgentConfiguration {
_registry_subscription: Subscription,
scroll_handle: ScrollHandle,
scrollbar_state: ScrollbarState,
gemini_is_installed: bool,
_check_for_gemini: Task<()>,
}
@@ -70,6 +73,7 @@ impl AgentConfiguration {
tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -94,6 +98,11 @@ impl AgentConfiguration {
cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
.detach();
cx.observe_global_in::<SettingsStore>(window, |this, _, cx| {
this.check_for_gemini(cx);
cx.notify();
})
.detach();
let scroll_handle = ScrollHandle::new();
let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
@@ -102,6 +111,7 @@ impl AgentConfiguration {
fs,
language_registry,
workspace,
project,
focus_handle,
configuration_views_by_provider: HashMap::default(),
context_server_store,
@@ -111,9 +121,11 @@ impl AgentConfiguration {
_registry_subscription: registry_subscription,
scroll_handle,
scrollbar_state,
gemini_is_installed: false,
_check_for_gemini: Task::ready(()),
};
this.build_provider_configuration_views(window, cx);
this.check_for_gemini(cx);
this
}
@@ -143,6 +155,34 @@ impl AgentConfiguration {
self.configuration_views_by_provider
.insert(provider.id(), configuration_view);
}
fn check_for_gemini(&mut self, cx: &mut Context<Self>) {
let project = self.project.clone();
let settings = AllAgentServersSettings::get_global(cx).clone();
self._check_for_gemini = cx.spawn({
async move |this, cx| {
let Some(project) = project.upgrade() else {
return;
};
let gemini_is_installed = AgentServerCommand::resolve(
Gemini::binary_name(),
&[],
// TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here
None,
settings.gemini,
&project,
cx,
)
.await
.is_some();
this.update(cx, |this, cx| {
this.gemini_is_installed = gemini_is_installed;
cx.notify();
})
.ok();
}
});
}
}
impl Focusable for AgentConfiguration {
@@ -331,7 +371,6 @@ impl AgentConfiguration {
.gap_0p5()
.child(
h_flex()
.pr_1()
.w_full()
.gap_2()
.justify_between()
@@ -1002,8 +1041,9 @@ impl AgentConfiguration {
name.clone(),
ExternalAgent::Custom {
name: name.clone(),
command: settings.command.clone(),
settings: settings.clone(),
},
None,
cx,
)
.into_any_element()
@@ -1023,7 +1063,6 @@ impl AgentConfiguration {
.gap_0p5()
.child(
h_flex()
.pr_1()
.w_full()
.gap_2()
.justify_between()
@@ -1054,7 +1093,7 @@ impl AgentConfiguration {
)
.child(
Label::new(
"All agents connected through the Agent Client Protocol.",
"Bring the agent of your choice to Zed via our new Agent Client Protocol.",
)
.color(Color::Muted),
),
@@ -1063,14 +1102,10 @@ impl AgentConfiguration {
IconName::AiGemini,
"Gemini CLI",
ExternalAgent::Gemini,
(!self.gemini_is_installed).then_some(Gemini::install_command().into()),
cx,
))
.child(self.render_agent_server(
IconName::AiClaude,
"Claude Code",
ExternalAgent::ClaudeCode,
cx,
))
// TODO add CC
.children(user_defined_agents),
)
}
@@ -1080,6 +1115,7 @@ impl AgentConfiguration {
icon: IconName,
name: impl Into<SharedString>,
agent: ExternalAgent,
install_command: Option<SharedString>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = name.into();
@@ -1099,26 +1135,88 @@ impl AgentConfiguration {
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
.child(Label::new(name.clone())),
)
.child(
Button::new(
SharedString::from(format!("start_acp_thread-{name}")),
"Start New Thread",
)
.label_size(LabelSize::Small)
.icon(IconName::Thread)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(move |_, window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(agent.clone()),
}
.boxed_clone(),
cx,
);
}),
)
.map(|this| {
if let Some(install_command) = install_command {
this.child(
Button::new(
SharedString::from(format!("install_external_agent-{name}")),
"Install Agent",
)
.label_size(LabelSize::Small)
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(install_command.clone()))
.on_click(cx.listener(
move |this, _, window, cx| {
let Some(project) = this.project.upgrade() else {
return;
};
let Some(workspace) = this.workspace.upgrade() else {
return;
};
let cwd = project.read(cx).first_project_directory(cx);
let shell =
project.read(cx).terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId(install_command.to_string()),
full_label: install_command.to_string(),
label: install_command.to_string(),
command: Some(install_command.to_string()),
args: Vec::new(),
command_label: install_command.to_string(),
cwd,
env: Default::default(),
use_new_terminal: true,
allow_concurrent_runs: true,
reveal: Default::default(),
reveal_target: Default::default(),
hide: Default::default(),
shell,
show_summary: true,
show_command: true,
show_rerun: false,
};
let task = workspace.update(cx, |workspace, cx| {
workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
});
cx.spawn(async move |this, cx| {
task.await;
this.update(cx, |this, cx| {
this.check_for_gemini(cx);
})
.ok();
})
.detach();
},
)),
)
} else {
this.child(
h_flex().gap_1().child(
Button::new(
SharedString::from(format!("start_acp_thread-{name}")),
"Start New Thread",
)
.label_size(LabelSize::Small)
.icon(IconName::Thread)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(move |_, window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(agent.clone()),
}
.boxed_clone(),
cx,
);
}),
),
)
}
})
}
}
@@ -1295,7 +1393,7 @@ async fn open_new_agent_servers_entry_in_settings_editor(
unique_server_name = Some(server_name.clone());
file.custom.insert(
server_name,
CustomAgentServerSettings {
AgentServerSettings {
command: AgentServerCommand {
path: "path_to_executable".into(),
args: vec![],

View File

@@ -1522,10 +1522,7 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx);
}
}
AcpThreadEvent::Stopped
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::Refusal => {
AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) => {
self.update_reviewing_editors(workspace, window, cx);
}
AcpThreadEvent::TitleUpdated

View File

@@ -5,16 +5,16 @@ use std::sync::Arc;
use std::time::Duration;
use acp_thread::AcpThread;
use agent_servers::AgentServerCommand;
use agent_servers::AgentServerSettings;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
use zed_actions::OpenBrowser;
use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
use zed_actions::agent::ReauthenticateAgent;
use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
use crate::agent_diff::AgentDiffThread;
use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
use crate::ui::AcpOnboardingModal;
use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
@@ -86,7 +86,7 @@ use zed_actions::{
const AGENT_PANEL_KEY: &str = "agent_panel";
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize)]
struct SerializedAgentPanel {
width: Option<Pixels>,
selected_agent: Option<AgentType>,
@@ -207,9 +207,6 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
AcpOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|workspace, _: &OpenClaudeCodeOnboardingModal, window, cx| {
ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();
@@ -262,7 +259,7 @@ pub enum AgentType {
NativeAgent,
Custom {
name: SharedString,
command: AgentServerCommand,
settings: AgentServerSettings,
},
}
@@ -287,17 +284,6 @@ impl AgentType {
}
}
impl From<ExternalAgent> for AgentType {
fn from(value: ExternalAgent) -> Self {
match value {
ExternalAgent::Gemini => Self::Gemini,
ExternalAgent::ClaudeCode => Self::ClaudeCode,
ExternalAgent::Custom { name, command } => Self::Custom { name, command },
ExternalAgent::NativeAgent => Self::NativeAgent,
}
}
}
impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
@@ -606,7 +592,7 @@ impl AgentPanel {
.log_err()
.flatten()
{
serde_json::from_str::<SerializedAgentPanel>(&panel).log_err()
Some(serde_json::from_str::<SerializedAgentPanel>(&panel)?)
} else {
None
};
@@ -1063,11 +1049,6 @@ impl AgentPanel {
editor
});
if self.selected_agent != AgentType::TextThread {
self.selected_agent = AgentType::TextThread;
self.serialize(cx);
}
self.set_active_view(
ActiveView::prompt_editor(
context_editor.clone(),
@@ -1094,7 +1075,6 @@ impl AgentPanel {
let workspace = self.workspace.clone();
let project = self.project.clone();
let fs = self.fs.clone();
let is_not_local = !self.project.read(cx).is_local();
const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
@@ -1126,21 +1106,17 @@ impl AgentPanel {
agent
}
None => {
if is_not_local {
ExternalAgent::NativeAgent
} else {
cx.background_spawn(async move {
KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
})
.await
.log_err()
.flatten()
.and_then(|value| {
serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
})
.unwrap_or_default()
.agent
}
cx.background_spawn(async move {
KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
})
.await
.log_err()
.flatten()
.and_then(|value| {
serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
})
.unwrap_or_default()
.agent
}
};
@@ -1164,12 +1140,6 @@ impl AgentPanel {
}
}
let selected_agent = ext_agent.into();
if this.selected_agent != selected_agent {
this.selected_agent = selected_agent;
this.serialize(cx);
}
let thread_view = cx.new(|cx| {
crate::acp::AcpThreadView::new(
server,
@@ -1265,12 +1235,6 @@ impl AgentPanel {
cx,
)
});
if self.selected_agent != AgentType::TextThread {
self.selected_agent = AgentType::TextThread;
self.serialize(cx);
}
self.set_active_view(
ActiveView::prompt_editor(
editor,
@@ -1515,6 +1479,7 @@ impl AgentPanel {
tools,
self.language_registry.clone(),
self.workspace.clone(),
self.project.downgrade(),
window,
cx,
)
@@ -1896,6 +1861,11 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.selected_agent != agent {
self.selected_agent = agent.clone();
self.serialize(cx);
}
match agent {
AgentType::Zed => {
window.dispatch_action(
@@ -1919,19 +1889,15 @@ impl AgentPanel {
AgentType::Gemini => {
self.external_thread(Some(crate::ExternalAgent::Gemini), None, None, window, cx)
}
AgentType::ClaudeCode => {
self.selected_agent = AgentType::ClaudeCode;
self.serialize(cx);
self.external_thread(
Some(crate::ExternalAgent::ClaudeCode),
None,
None,
window,
cx,
)
}
AgentType::Custom { name, command } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, command }),
AgentType::ClaudeCode => self.external_thread(
Some(crate::ExternalAgent::ClaudeCode),
None,
None,
window,
cx,
),
AgentType::Custom { name, settings } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, settings }),
None,
None,
window,
@@ -2149,7 +2115,7 @@ impl AgentPanel {
.child(title_editor)
.into_any_element()
} else {
Label::new(thread_view.read(cx).title(cx))
Label::new(thread_view.read(cx).title())
.color(Color::Muted)
.truncate()
.into_any_element()
@@ -2535,9 +2501,6 @@ impl AgentPanel {
.with_handle(self.new_thread_menu_handle.clone())
.menu({
let workspace = self.workspace.clone();
let is_not_local = workspace
.update(cx, |workspace, cx| !workspace.project().read(cx).is_local())
.unwrap_or_default();
move |window, cx| {
telemetry::event!("New Thread Clicked");
@@ -2628,7 +2591,6 @@ impl AgentPanel {
ContextMenuEntry::new("New Gemini CLI Thread")
.icon(IconName::AiGemini)
.icon_color(Color::Muted)
.disabled(is_not_local)
.handler({
let workspace = workspace.clone();
move |window, cx| {
@@ -2655,7 +2617,6 @@ impl AgentPanel {
menu.item(
ContextMenuEntry::new("New Claude Code Thread")
.icon(IconName::AiClaude)
.disabled(is_not_local)
.icon_color(Color::Muted)
.handler({
let workspace = workspace.clone();
@@ -2688,7 +2649,6 @@ impl AgentPanel {
ContextMenuEntry::new(format!("New {} Thread", agent_name))
.icon(IconName::Terminal)
.icon_color(Color::Muted)
.disabled(is_not_local)
.handler({
let workspace = workspace.clone();
let agent_name = agent_name.clone();
@@ -2704,9 +2664,9 @@ impl AgentPanel {
AgentType::Custom {
name: agent_name
.clone(),
command: agent_settings
.command
.clone(),
settings:
agent_settings
.clone(),
},
window,
cx,
@@ -3535,11 +3495,6 @@ impl AgentPanel {
) -> AnyElement {
let message_with_header = format!("{}\n{}", header, message);
// Don't show Retry button for refusals
let is_refusal = header == "Request Refused";
let retry_button = self.render_retry_button(thread);
let copy_button = self.create_copy_button(message_with_header);
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
@@ -3548,8 +3503,8 @@ impl AgentPanel {
.actions_slot(
h_flex()
.gap_0p5()
.when(!is_refusal, |this| this.child(retry_button))
.child(copy_button),
.child(self.render_retry_button(thread))
.child(self.create_copy_button(message_with_header)),
)
.dismiss_action(self.dismiss_error_button(thread, cx))
.into_any_element()

View File

@@ -28,7 +28,7 @@ use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
use agent_servers::AgentServerCommand;
use agent_servers::AgentServerSettings;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
@@ -170,7 +170,7 @@ enum ExternalAgent {
NativeAgent,
Custom {
name: SharedString,
command: AgentServerCommand,
settings: AgentServerSettings,
},
}
@@ -193,9 +193,9 @@ impl ExternalAgent {
Self::Gemini => Rc::new(agent_servers::Gemini),
Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
Self::Custom { name, command } => Rc::new(agent_servers::CustomAgentServer::new(
Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new(
name.clone(),
command.clone(),
settings,
)),
}
}

View File

@@ -13,10 +13,7 @@ use http_client::HttpClientWithUrl;
use itertools::Itertools;
use language::{Buffer, CodeLabel, HighlightId};
use lsp::CompletionContext;
use project::{
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, ProjectPath,
Symbol, WorktreeId,
};
use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId};
use prompt_store::PromptStore;
use rope::Point;
use text::{Anchor, OffsetRangeExt, ToPoint};
@@ -900,7 +897,6 @@ impl CompletionProvider for ContextPickerCompletionProvider {
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
// Since this does its own filtering (see `filter_completions()` returns false),
// there is no benefit to computing whether this set of completions is incomplete.
is_incomplete: true,

View File

@@ -7,10 +7,7 @@ use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window};
use language::{Anchor, Buffer, ToPoint};
use parking_lot::Mutex;
use project::{
CompletionDisplayOptions, CompletionIntent, CompletionSource,
lsp_store::CompletionDocumentation,
};
use project::{CompletionIntent, CompletionSource, lsp_store::CompletionDocumentation};
use rope::Point;
use std::{
ops::Range,
@@ -136,7 +133,6 @@ impl SlashCommandCompletionProvider {
vec![project::CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]
})
@@ -241,7 +237,6 @@ impl SlashCommandCompletionProvider {
Ok(vec![project::CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
// TODO: Could have slash commands indicate whether their completions are incomplete.
is_incomplete: true,
}])
@@ -249,7 +244,6 @@ impl SlashCommandCompletionProvider {
} else {
Task::ready(Ok(vec![project::CompletionResponse {
completions: Vec::new(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: true,
}]))
}
@@ -311,7 +305,6 @@ impl CompletionProvider for SlashCommandCompletionProvider {
else {
return Task::ready(Ok(vec![project::CompletionResponse {
completions: Vec::new(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]));
};

View File

@@ -1,7 +1,6 @@
mod acp_onboarding_modal;
mod agent_notification;
mod burn_mode_tooltip;
mod claude_code_onboarding_modal;
mod context_pill;
mod end_trial_upsell;
mod onboarding_modal;
@@ -11,7 +10,6 @@ mod unavailable_editing_tooltip;
pub use acp_onboarding_modal::*;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use claude_code_onboarding_modal::*;
pub use context_pill::*;
pub use end_trial_upsell::*;
pub use onboarding_modal::*;

View File

@@ -141,12 +141,20 @@ impl Render for AcpOnboardingModal {
.bg(gpui::black().opacity(0.15)),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(257.),
rems_from_px(47.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
h_flex()
.gap_4()
.child(
Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.))
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(111.),
rems_from_px(41.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
),
)
.child(
v_flex()

View File

@@ -1,254 +0,0 @@
use client::zed_urls;
use gpui::{
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
linear_color_stop, linear_gradient,
};
use ui::{TintColor, Vector, VectorName, prelude::*};
use workspace::{ModalView, Workspace};
use crate::agent_panel::{AgentPanel, AgentType};
macro_rules! claude_code_onboarding_event {
($name:expr) => {
telemetry::event!($name, source = "ACP Claude Code Onboarding");
};
($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+);
};
}
pub struct ClaudeCodeOnboardingModal {
focus_handle: FocusHandle,
workspace: Entity<Workspace>,
}
impl ClaudeCodeOnboardingModal {
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
let workspace_entity = cx.entity();
workspace.toggle_modal(window, cx, |_window, cx| Self {
workspace: workspace_entity,
focus_handle: cx.focus_handle(),
});
}
fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
self.workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.new_agent_thread(AgentType::ClaudeCode, window, cx);
});
}
});
cx.emit(DismissEvent);
claude_code_onboarding_event!("Open Panel Clicked");
}
fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
cx.open_url(&zed_urls::external_agents_docs(cx));
cx.notify();
claude_code_onboarding_event!("Documentation Link Clicked");
}
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for ClaudeCodeOnboardingModal {}
impl Focusable for ClaudeCodeOnboardingModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for ClaudeCodeOnboardingModal {}
impl Render for ClaudeCodeOnboardingModal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let illustration_element = |icon: IconName, label: Option<SharedString>, opacity: f32| {
h_flex()
.px_1()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.05))
.border_1()
.border_color(cx.theme().colors().border)
.border_dashed()
.child(
Icon::new(icon)
.size(IconSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
)
.map(|this| {
if let Some(label_text) = label {
this.child(
Label::new(label_text)
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.child(
div().w_16().h_1().rounded_full().bg(cx
.theme()
.colors()
.element_active
.opacity(0.6)),
)
}
})
.opacity(opacity)
};
let illustration = h_flex()
.relative()
.h(rems_from_px(126.))
.bg(cx.theme().colors().editor_background)
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.justify_center()
.gap_8()
.rounded_t_md()
.overflow_hidden()
.child(
div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
),
)
.child(div().absolute().inset_0().size_full().bg(linear_gradient(
0.,
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.1),
0.9,
),
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.),
0.,
),
)))
.child(
div()
.absolute()
.inset_0()
.size_full()
.bg(gpui::black().opacity(0.15)),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(257.),
rems_from_px(47.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
v_flex()
.gap_1p5()
.child(illustration_element(IconName::Stop, None, 0.15))
.child(illustration_element(
IconName::AiGemini,
Some("New Gemini CLI Thread".into()),
0.3,
))
.child(
h_flex()
.pl_1()
.pr_2()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.2))
.border_1()
.border_color(cx.theme().colors().border)
.child(
Icon::new(IconName::AiClaude)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new("New Claude Code Thread").size(LabelSize::Small)),
)
.child(illustration_element(
IconName::Stop,
Some("Your Agent Here".into()),
0.3,
))
.child(illustration_element(IconName::Stop, None, 0.15)),
);
let heading = v_flex()
.w_full()
.gap_1()
.child(
Label::new("Beta Release")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(Headline::new("Claude Code: Natively in Zed").size(HeadlineSize::Large));
let copy = "Powered by the Agent Client Protocol, you can now run Claude Code as\na first-class citizen in Zed's agent panel.";
let open_panel_button = Button::new("open-panel", "Start with Claude Code")
.icon_size(IconSize::Indicator)
.style(ButtonStyle::Tinted(TintColor::Accent))
.full_width()
.on_click(cx.listener(Self::open_panel));
let docs_button = Button::new("add-other-agents", "Add Other Agents")
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::Indicator)
.icon_color(Color::Muted)
.full_width()
.on_click(cx.listener(Self::view_docs));
let close_button = h_flex().absolute().top_2().right_2().child(
IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
claude_code_onboarding_event!("Canceled", trigger = "X click");
cx.emit(DismissEvent);
},
)),
);
v_flex()
.id("acp-onboarding")
.key_context("AcpOnboardingModal")
.relative()
.w(rems(34.))
.h_full()
.elevation_3(cx)
.track_focus(&self.focus_handle(cx))
.overflow_hidden()
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
claude_code_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
}))
.child(illustration)
.child(
v_flex()
.p_4()
.gap_2()
.child(heading)
.child(Label::new(copy).color(Color::Muted))
.child(
v_flex()
.w_full()
.mt_2()
.gap_1()
.child(open_panel_button)
.child(docs_button),
),
)
.child(close_button)
}
}

View File

@@ -15,7 +15,7 @@ use language::LineEnding;
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use project::Project;
use project::{Project, terminals::TerminalKind};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
@@ -213,16 +213,17 @@ impl Tool for TerminalTool {
async move |cx| {
let program = program.await;
let env = env.await;
project
.update(cx, |project, cx| {
project.create_terminal_task(
task::SpawnInTerminal {
project.create_terminal(
TerminalKind::Task(task::SpawnInTerminal {
command: Some(program),
args,
cwd,
env,
..Default::default()
},
}),
cx,
)
})?

View File

@@ -3,7 +3,6 @@ mod models;
use anyhow::{Context, Error, Result, anyhow};
use aws_sdk_bedrockruntime as bedrock;
pub use aws_sdk_bedrockruntime as bedrock_client;
use aws_sdk_bedrockruntime::types::InferenceConfiguration;
pub use aws_sdk_bedrockruntime::types::{
AnyToolChoice as BedrockAnyToolChoice, AutoToolChoice as BedrockAutoToolChoice,
ContentBlock as BedrockInnerContent, Tool as BedrockTool, ToolChoice as BedrockToolChoice,
@@ -18,8 +17,7 @@ pub use bedrock::types::{
ConverseOutput as BedrockResponse, ConverseStreamOutput as BedrockStreamingResponse,
ImageBlock as BedrockImageBlock, Message as BedrockMessage,
ReasoningContentBlock as BedrockThinkingBlock, ReasoningTextBlock as BedrockThinkingTextBlock,
ResponseStream as BedrockResponseStream, SystemContentBlock as BedrockSystemContentBlock,
ToolResultBlock as BedrockToolResultBlock,
ResponseStream as BedrockResponseStream, ToolResultBlock as BedrockToolResultBlock,
ToolResultContentBlock as BedrockToolResultContentBlock,
ToolResultStatus as BedrockToolResultStatus, ToolUseBlock as BedrockToolUseBlock,
};
@@ -60,20 +58,6 @@ pub async fn stream_completion(
response = response.set_tool_config(request.tools);
}
let inference_config = InferenceConfiguration::builder()
.max_tokens(request.max_tokens as i32)
.set_temperature(request.temperature)
.set_top_p(request.top_p)
.build();
response = response.inference_config(inference_config);
if let Some(system) = request.system {
if !system.is_empty() {
response = response.system(BedrockSystemContentBlock::Text(system));
}
}
let output = response
.send()
.await

View File

@@ -151,12 +151,12 @@ impl Model {
pub fn id(&self) -> &str {
match self {
Model::ClaudeSonnet4 => "claude-sonnet-4",
Model::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking",
Model::ClaudeOpus4 => "claude-opus-4",
Model::ClaudeOpus4_1 => "claude-opus-4-1",
Model::ClaudeOpus4Thinking => "claude-opus-4-thinking",
Model::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking",
Model::ClaudeSonnet4 => "claude-4-sonnet",
Model::ClaudeSonnet4Thinking => "claude-4-sonnet-thinking",
Model::ClaudeOpus4 => "claude-4-opus",
Model::ClaudeOpus4_1 => "claude-4-opus-1",
Model::ClaudeOpus4Thinking => "claude-4-opus-thinking",
Model::ClaudeOpus4_1Thinking => "claude-4-opus-1-thinking",
Model::Claude3_5SonnetV2 => "claude-3-5-sonnet-v2",
Model::Claude3_5Sonnet => "claude-3-5-sonnet",
Model::Claude3Opus => "claude-3-opus",
@@ -359,12 +359,14 @@ impl Model {
pub fn max_output_tokens(&self) -> u64 {
match self {
Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3_5Haiku => 4_096,
Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => 128_000,
Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => 64_000,
Self::ClaudeOpus4
| Self::ClaudeOpus4Thinking
Self::Claude3_7Sonnet
| Self::Claude3_7SonnetThinking
| Self::ClaudeSonnet4
| Self::ClaudeSonnet4Thinking
| Self::ClaudeOpus4
| Model::ClaudeOpus4Thinking
| Self::ClaudeOpus4_1
| Self::ClaudeOpus4_1Thinking => 32_000,
| Model::ClaudeOpus4_1Thinking => 128_000,
Self::Claude3_5SonnetV2 | Self::PalmyraWriterX4 | Self::PalmyraWriterX5 => 8_192,
Self::Custom {
max_output_tokens, ..
@@ -782,10 +784,10 @@ mod tests {
);
// Test thinking models have different friendly IDs but same request IDs
assert_eq!(Model::ClaudeSonnet4.id(), "claude-sonnet-4");
assert_eq!(Model::ClaudeSonnet4.id(), "claude-4-sonnet");
assert_eq!(
Model::ClaudeSonnet4Thinking.id(),
"claude-sonnet-4-thinking"
"claude-4-sonnet-thinking"
);
assert_eq!(
Model::ClaudeSonnet4.request_id(),

View File

@@ -175,7 +175,6 @@ CREATE TABLE "language_servers" (
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"name" VARCHAR NOT NULL,
"capabilities" TEXT NOT NULL,
"worktree_id" BIGINT,
PRIMARY KEY (project_id, id)
);

View File

@@ -1,2 +0,0 @@
ALTER TABLE language_servers
ADD COLUMN worktree_id BIGINT;

View File

@@ -694,7 +694,6 @@ impl Database {
project_id: ActiveValue::set(project_id),
id: ActiveValue::set(server.id as i64),
name: ActiveValue::set(server.name.clone()),
worktree_id: ActiveValue::set(server.worktree_id.map(|id| id as i64)),
capabilities: ActiveValue::set(update.capabilities.clone()),
})
.on_conflict(
@@ -705,7 +704,6 @@ impl Database {
.update_columns([
language_server::Column::Name,
language_server::Column::Capabilities,
language_server::Column::WorktreeId,
])
.to_owned(),
)
@@ -1067,7 +1065,7 @@ impl Database {
server: proto::LanguageServer {
id: language_server.id as u64,
name: language_server.name,
worktree_id: language_server.worktree_id.map(|id| id as u64),
worktree_id: None,
},
capabilities: language_server.capabilities,
})

View File

@@ -809,7 +809,7 @@ impl Database {
server: proto::LanguageServer {
id: language_server.id as u64,
name: language_server.name,
worktree_id: language_server.worktree_id.map(|id| id as u64),
worktree_id: None,
},
capabilities: language_server.capabilities,
})

View File

@@ -10,7 +10,6 @@ pub struct Model {
pub id: i64,
pub name: String,
pub capabilities: String,
pub worktree_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -476,9 +476,7 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
.add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)
.add_message_handler(update_context)
.add_request_handler(forward_mutating_project_request::<proto::ToggleLspLogs>)
.add_message_handler(broadcast_project_message_from_host::<proto::LanguageServerLog>);
.add_message_handler(update_context);
Arc::new(server)
}

View File

@@ -12,9 +12,7 @@ use language::{
Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset,
language_settings::SoftWrap,
};
use project::{
Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, search::SearchQuery,
};
use project::{Completion, CompletionResponse, CompletionSource, search::SearchQuery};
use settings::Settings;
use std::{
ops::Range,
@@ -277,7 +275,6 @@ impl MessageEditor {
Task::ready(Ok(vec![CompletionResponse {
completions: Vec::new(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]))
}
@@ -320,7 +317,6 @@ impl MessageEditor {
CompletionResponse {
is_incomplete: completions.len() >= LIMIT,
display_options: CompletionDisplayOptions::default(),
completions,
}
}

View File

@@ -153,8 +153,6 @@ pub enum ModelVendor {
OpenAI,
Google,
Anthropic,
#[serde(rename = "xAI")]
XAI,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]

View File

@@ -36,6 +36,7 @@ use module_list::ModuleList;
use project::{
DebugScenarioContext, Project, WorktreeId,
debugger::session::{self, Session, SessionEvent, SessionStateEvent, ThreadId, ThreadStatus},
terminals::TerminalKind,
};
use rpc::proto::ViewId;
use serde_json::Value;
@@ -1016,11 +1017,12 @@ impl RunningState {
};
let terminal = project
.update(cx, |project, cx| {
project.create_terminal_task(
task_with_shell.clone(),
project.create_terminal(
TerminalKind::Task(task_with_shell.clone()),
cx,
)
})?.await?;
})?
.await?;
let terminal_view = cx.new_window_entity(|window, cx| {
TerminalView::new(
@@ -1164,7 +1166,7 @@ impl RunningState {
.filter(|title| !title.is_empty())
.or_else(|| command.clone())
.unwrap_or_else(|| "Debug terminal".to_string());
let kind = task::SpawnInTerminal {
let kind = TerminalKind::Task(task::SpawnInTerminal {
id: task::TaskId("debug".to_string()),
full_label: title.clone(),
label: title.clone(),
@@ -1182,13 +1184,12 @@ impl RunningState {
show_summary: false,
show_command: false,
show_rerun: false,
};
});
let workspace = self.workspace.clone();
let weak_project = project.downgrade();
let terminal_task =
project.update(cx, |project, cx| project.create_terminal_task(kind, cx));
let terminal_task = project.update(cx, |project, cx| project.create_terminal(kind, cx));
let terminal_task = cx.spawn_in(window, async move |_, cx| {
let terminal = terminal_task.await?;

View File

@@ -15,7 +15,7 @@ use gpui::{
use language::{Anchor, Buffer, CodeLabel, TextBufferSnapshot, ToOffset};
use menu::{Confirm, SelectNext, SelectPrevious};
use project::{
Completion, CompletionDisplayOptions, CompletionResponse,
Completion, CompletionResponse,
debugger::session::{CompletionsQuery, OutputToken, Session},
lsp_store::CompletionDocumentation,
search_history::{SearchHistory, SearchHistoryCursor},
@@ -685,7 +685,6 @@ impl ConsoleQueryBarCompletionProvider {
Ok(vec![project::CompletionResponse {
is_incomplete: completions.len() >= LIMIT,
display_options: CompletionDisplayOptions::default(),
completions,
}])
})
@@ -798,7 +797,6 @@ impl ConsoleQueryBarCompletionProvider {
Ok(vec![project::CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}])
})

View File

@@ -9,9 +9,9 @@ use language::{Buffer, LanguageName, LanguageRegistry};
use markdown::{Markdown, MarkdownElement};
use multi_buffer::{Anchor, ExcerptId};
use ordered_float::OrderedFloat;
use project::CompletionSource;
use project::lsp_store::CompletionDocumentation;
use project::{CodeAction, Completion, TaskSourceKind};
use project::{CompletionDisplayOptions, CompletionSource};
use task::DebugScenario;
use task::TaskContext;
@@ -213,7 +213,6 @@ pub struct CompletionsMenu {
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
language_registry: Option<Arc<LanguageRegistry>>,
language: Option<LanguageName>,
display_options: CompletionDisplayOptions,
snippet_sort_order: SnippetSortOrder,
}
@@ -253,7 +252,6 @@ impl CompletionsMenu {
is_incomplete: bool,
buffer: Entity<Buffer>,
completions: Box<[Completion]>,
display_options: CompletionDisplayOptions,
snippet_sort_order: SnippetSortOrder,
language_registry: Option<Arc<LanguageRegistry>>,
language: Option<LanguageName>,
@@ -286,7 +284,6 @@ impl CompletionsMenu {
markdown_cache: RefCell::new(VecDeque::new()).into(),
language_registry,
language,
display_options,
snippet_sort_order,
};
@@ -357,7 +354,6 @@ impl CompletionsMenu {
markdown_cache: RefCell::new(VecDeque::new()).into(),
language_registry: None,
language: None,
display_options: CompletionDisplayOptions::default(),
snippet_sort_order,
}
}
@@ -720,33 +716,6 @@ impl CompletionsMenu {
cx: &mut Context<Editor>,
) -> AnyElement {
let show_completion_documentation = self.show_completion_documentation;
let widest_completion_ix = if self.display_options.dynamic_width {
let completions = self.completions.borrow();
let widest_completion_ix = self
.entries
.borrow()
.iter()
.enumerate()
.max_by_key(|(_, mat)| {
let completion = &completions[mat.candidate_id];
let documentation = &completion.documentation;
let mut len = completion.label.text.chars().count();
if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
if show_completion_documentation {
len += text.chars().count();
}
}
len
})
.map(|(ix, _)| ix);
drop(completions);
widest_completion_ix
} else {
None
};
let selected_item = self.selected_item;
let completions = self.completions.clone();
let entries = self.entries.clone();
@@ -873,13 +842,7 @@ impl CompletionsMenu {
.max_h(max_height_in_lines as f32 * window.line_height())
.track_scroll(self.scroll_handle.clone())
.with_sizing_behavior(ListSizingBehavior::Infer)
.map(|this| {
if self.display_options.dynamic_width {
this.with_width_from_item(widest_completion_ix)
} else {
this.w(rems(34.))
}
});
.w(rems(34.));
Popover::new().child(list).into_any_element()
}

View File

@@ -147,22 +147,21 @@ use multi_buffer::{
use parking_lot::Mutex;
use persistence::DB;
use project::{
BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint,
Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, ProjectPath,
ProjectTransaction, TaskSourceKind,
BreakpointWithPosition, CodeAction, Completion, CompletionIntent, CompletionResponse,
CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, Location, LocationLink,
PrepareRenameResponse, Project, ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
debugger::breakpoint_store::Breakpoint,
debugger::{
breakpoint_store::{
Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
BreakpointStore, BreakpointStoreEvent,
BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore,
BreakpointStoreEvent,
},
session::{Session, SessionEvent},
},
git_store::{GitStoreEvent, RepositoryEvent},
lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
project_settings::{
DiagnosticSeverity, GitGutterSetting, GoToDiagnosticSeverityFilter, ProjectSettings,
},
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter},
project_settings::{GitGutterSetting, ProjectSettings},
};
use rand::{seq::SliceRandom, thread_rng};
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
@@ -5637,25 +5636,17 @@ impl Editor {
// that having one source with `is_incomplete: true` doesn't cause all to be re-queried.
let mut completions = Vec::new();
let mut is_incomplete = false;
let mut display_options: Option<CompletionDisplayOptions> = None;
if let Some(provider_responses) = provider_responses.await.log_err()
&& !provider_responses.is_empty()
{
for response in provider_responses {
completions.extend(response.completions);
is_incomplete = is_incomplete || response.is_incomplete;
match display_options.as_mut() {
None => {
display_options = Some(response.display_options);
}
Some(options) => options.merge(&response.display_options),
}
}
if completion_settings.words == WordsCompletionMode::Fallback {
words = Task::ready(BTreeMap::default());
}
}
let display_options = display_options.unwrap_or_default();
let mut words = words.await;
if let Some(word_to_exclude) = &word_to_exclude {
@@ -5697,7 +5688,6 @@ impl Editor {
is_incomplete,
buffer.clone(),
completions.into(),
display_options,
snippet_sort_order,
languages,
language,
@@ -22073,7 +22063,6 @@ fn snippet_completions(
if scopes.is_empty() {
return Task::ready(Ok(CompletionResponse {
completions: vec![],
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}));
}
@@ -22098,7 +22087,6 @@ fn snippet_completions(
if last_word.is_empty() {
return Ok(CompletionResponse {
completions: vec![],
display_options: CompletionDisplayOptions::default(),
is_incomplete: true,
});
}
@@ -22220,7 +22208,6 @@ fn snippet_completions(
Ok(CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete,
})
})

View File

@@ -108,10 +108,6 @@ pub struct ClaudeCodeFeatureFlag;
impl FeatureFlag for ClaudeCodeFeatureFlag {
const NAME: &'static str = "claude-code";
fn enabled_for_all() -> bool {
true
}
}
pub trait FeatureFlagViewExt<V: 'static> {

View File

@@ -341,6 +341,7 @@ impl PickerDelegate for BranchListDelegate {
};
picker
.update(cx, |picker, _| {
#[allow(clippy::nonminimal_bool)]
if !query.is_empty()
&& !matches
.first()

View File

@@ -14,10 +14,7 @@ use language::{
DiagnosticSeverity, LanguageServerId, Point, ToOffset as _, ToPoint as _,
};
use project::lsp_store::CompletionDocumentation;
use project::{
Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, Project,
ProjectPath,
};
use project::{Completion, CompletionResponse, CompletionSource, Project, ProjectPath};
use std::fmt::Write as _;
use std::ops::Range;
use std::path::Path;
@@ -667,7 +664,6 @@ impl CompletionProvider for RustStyleCompletionProvider {
confirm: None,
})
.collect(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]))
}

View File

@@ -11,14 +11,13 @@ use std::{
use async_trait::async_trait;
use collections::HashMap;
use fs::Fs;
use gpui::{AsyncApp, SharedString};
use settings::WorktreeId;
use crate::{LanguageName, ManifestName};
/// Represents a single toolchain.
#[derive(Clone, Eq, Debug)]
#[derive(Clone, Debug, Eq)]
pub struct Toolchain {
/// User-facing label
pub name: SharedString,
@@ -30,29 +29,21 @@ pub struct Toolchain {
impl std::hash::Hash for Toolchain {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let Self {
name,
path,
language_name,
as_json: _,
} = self;
name.hash(state);
path.hash(state);
language_name.hash(state);
self.name.hash(state);
self.path.hash(state);
self.language_name.hash(state);
}
}
impl PartialEq for Toolchain {
fn eq(&self, other: &Self) -> bool {
let Self {
name,
path,
language_name,
as_json: _,
} = self;
// Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced.
// Thus, there could be multiple entries that look the same in the UI.
(name, path, language_name).eq(&(&other.name, &other.path, &other.language_name))
(&self.name, &self.path, &self.language_name).eq(&(
&other.name,
&other.path,
&other.language_name,
))
}
}
@@ -68,7 +59,6 @@ pub trait ToolchainLister: Send + Sync {
fn term(&self) -> SharedString;
/// Returns the name of the manifest file for this toolchain.
fn manifest_name(&self) -> ManifestName;
async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option<String>;
}
#[async_trait(?Send)]
@@ -92,7 +82,7 @@ pub trait LocalLanguageToolchainStore: Send + Sync + 'static {
) -> Option<Toolchain>;
}
#[async_trait(?Send)]
#[async_trait(?Send )]
impl<T: LocalLanguageToolchainStore> LanguageToolchainStore for T {
async fn active_toolchain(
self: Arc<Self>,

View File

@@ -208,7 +208,6 @@ impl LanguageModelRegistry {
) -> impl Iterator<Item = Arc<dyn LanguageModel>> + 'a {
self.providers
.values()
.filter(|provider| provider.is_authenticated(cx))
.flat_map(|provider| provider.provided_models(cx))
}

View File

@@ -32,8 +32,6 @@ use std::time::Duration;
use ui::prelude::*;
use util::debug_panic;
use crate::provider::x_ai::count_xai_tokens;
use super::anthropic::count_anthropic_tokens;
use super::google::count_google_tokens;
use super::open_ai::count_open_ai_tokens;
@@ -230,9 +228,7 @@ impl LanguageModel for CopilotChatLanguageModel {
ModelVendor::OpenAI | ModelVendor::Anthropic => {
LanguageModelToolSchemaFormat::JsonSchema
}
ModelVendor::Google | ModelVendor::XAI => {
LanguageModelToolSchemaFormat::JsonSchemaSubset
}
ModelVendor::Google => LanguageModelToolSchemaFormat::JsonSchemaSubset,
}
}
@@ -260,10 +256,6 @@ impl LanguageModel for CopilotChatLanguageModel {
match self.model.vendor() {
ModelVendor::Anthropic => count_anthropic_tokens(request, cx),
ModelVendor::Google => count_google_tokens(request, cx),
ModelVendor::XAI => {
let model = x_ai::Model::from_id(self.model.id()).unwrap_or_default();
count_xai_tokens(request, model, cx)
}
ModelVendor::OpenAI => {
let model = open_ai::Model::from_id(self.model.id()).unwrap_or_default();
count_open_ai_tokens(request, model, cx)
@@ -483,6 +475,7 @@ fn into_copilot_chat(
}
}
let mut tool_called = false;
let mut messages: Vec<ChatMessage> = Vec::new();
for message in request_messages {
match message.role {
@@ -552,6 +545,7 @@ fn into_copilot_chat(
let mut tool_calls = Vec::new();
for content in &message.content {
if let MessageContent::ToolUse(tool_use) = content {
tool_called = true;
tool_calls.push(ToolCall {
id: tool_use.id.to_string(),
content: copilot::copilot_chat::ToolCallContent::Function {
@@ -596,7 +590,7 @@ fn into_copilot_chat(
}
}
let tools = request
let mut tools = request
.tools
.iter()
.map(|tool| Tool::Function {
@@ -608,6 +602,22 @@ fn into_copilot_chat(
})
.collect::<Vec<_>>();
// The API will return a Bad Request (with no error message) when tools
// were used previously in the conversation but no tools are provided as
// part of this request. Inserting a dummy tool seems to circumvent this
// error.
if tool_called && tools.is_empty() {
tools.push(Tool::Function {
function: copilot::copilot_chat::Function {
name: "noop".to_string(),
description: "No operation".to_string(),
parameters: serde_json::json!({
"type": "object"
}),
},
});
}
Ok(CopilotChatRequest {
intent: true,
n: 1,

View File

@@ -381,7 +381,7 @@ impl LanguageModel for OpenRouterLanguageModel {
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
let model_id = self.model.id().trim().to_lowercase();
if model_id.contains("gemini") || model_id.contains("grok") {
if model_id.contains("gemini") || model_id.contains("grok-4") {
LanguageModelToolSchemaFormat::JsonSchemaSubset
} else {
LanguageModelToolSchemaFormat::JsonSchema

View File

@@ -24,7 +24,6 @@ itertools.workspace = true
language.workspace = true
lsp.workspace = true
project.workspace = true
proto.workspace = true
serde_json.workspace = true
settings.workspace = true
theme.workspace = true

View File

@@ -1,20 +1,20 @@
mod key_context_view;
pub mod lsp_button;
pub mod lsp_log_view;
mod lsp_log;
pub mod lsp_tool;
mod syntax_tree_view;
#[cfg(test)]
mod lsp_log_view_tests;
mod lsp_log_tests;
use gpui::{App, AppContext, Entity};
pub use lsp_log_view::LspLogView;
pub use lsp_log::{LogStore, LspLogToolbarItemView, LspLogView};
pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView};
use ui::{Context, Window};
use workspace::{Item, ItemHandle, SplitDirection, Workspace};
pub fn init(cx: &mut App) {
lsp_log_view::init(true, cx);
lsp_log::init(cx);
syntax_tree_view::init(cx);
key_context_view::init(cx);
}

View File

@@ -1,22 +1,20 @@
use std::sync::Arc;
use crate::lsp_log_view::LogMenuItem;
use crate::lsp_log::LogMenuItem;
use super::*;
use futures::StreamExt;
use gpui::{AppContext as _, SemanticVersion, TestAppContext, VisualTestContext};
use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
use lsp::LanguageServerName;
use project::{
FakeFs, Project,
lsp_store::log_store::{LanguageServerKind, LogKind, LogStore},
};
use lsp_log::LogKind;
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use util::path;
#[gpui::test]
async fn test_lsp_log_view(cx: &mut TestAppContext) {
async fn test_lsp_logs(cx: &mut TestAppContext) {
zlog::init_test();
init_test(cx);
@@ -53,7 +51,7 @@ async fn test_lsp_log_view(cx: &mut TestAppContext) {
},
);
let log_store = cx.new(|cx| LogStore::new(true, cx));
let log_store = cx.new(LogStore::new);
log_store.update(cx, |store, cx| store.add_project(&project, cx));
let _rust_buffer = project
@@ -96,7 +94,7 @@ async fn test_lsp_log_view(cx: &mut TestAppContext) {
rpc_trace_enabled: false,
selected_entry: LogKind::Logs,
trace_level: lsp::TraceValue::Off,
server_kind: LanguageServerKind::Local {
server_kind: lsp_log::LanguageServerKind::Local {
project: project.downgrade()
}
}]

View File

@@ -11,10 +11,7 @@ use editor::{Editor, EditorEvent};
use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions};
use language::{BinaryStatus, BufferId, ServerHealth};
use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector};
use project::{
LspStore, LspStoreEvent, Worktree, lsp_store::log_store::GlobalLogStore,
project_settings::ProjectSettings,
};
use project::{LspStore, LspStoreEvent, Worktree, project_settings::ProjectSettings};
use settings::{Settings as _, SettingsStore};
use ui::{
Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide,
@@ -23,7 +20,7 @@ use ui::{
use workspace::{StatusItemView, Workspace};
use crate::lsp_log_view;
use crate::lsp_log::GlobalLogStore;
actions!(
lsp_tool,
@@ -33,7 +30,7 @@ actions!(
]
);
pub struct LspButton {
pub struct LspTool {
server_state: Entity<LanguageServerState>,
popover_menu_handle: PopoverMenuHandle<ContextMenu>,
lsp_menu: Option<Entity<ContextMenu>>,
@@ -124,8 +121,9 @@ impl LanguageServerState {
menu = menu.align_popover_bottom();
let lsp_logs = cx
.try_global::<GlobalLogStore>()
.map(|lsp_logs| lsp_logs.0.clone());
let Some(lsp_logs) = lsp_logs else {
.and_then(|lsp_logs| lsp_logs.0.upgrade());
let lsp_store = self.lsp_store.upgrade();
let Some((lsp_logs, lsp_store)) = lsp_logs.zip(lsp_store) else {
return menu;
};
@@ -212,11 +210,10 @@ impl LanguageServerState {
};
let server_selector = server_info.server_selector();
let is_remote = self
.lsp_store
.update(cx, |lsp_store, _| lsp_store.as_remote().is_some())
.unwrap_or(false);
let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector);
// TODO currently, Zed remote does not work well with the LSP logs
// https://github.com/zed-industries/zed/issues/28557
let has_logs = lsp_store.read(cx).as_local().is_some()
&& lsp_logs.read(cx).has_server_logs(&server_selector);
let status_color = server_info
.binary_status
@@ -244,10 +241,10 @@ impl LanguageServerState {
.as_ref()
.or_else(|| server_info.binary_status.as_ref()?.message.as_ref())
.cloned();
let hover_label = if message.is_some() {
Some("View Message")
} else if has_logs {
let hover_label = if has_logs {
Some("View Logs")
} else if message.is_some() {
Some("View Message")
} else {
None
};
@@ -291,7 +288,16 @@ impl LanguageServerState {
let server_name = server_info.name.clone();
let workspace = self.workspace.clone();
move |window, cx| {
if let Some(message) = &message {
if has_logs {
lsp_logs.update(cx, |lsp_logs, cx| {
lsp_logs.open_server_trace(
workspace.clone(),
server_selector.clone(),
window,
cx,
);
});
} else if let Some(message) = &message {
let Some(create_buffer) = workspace
.update(cx, |workspace, cx| {
workspace
@@ -341,14 +347,6 @@ impl LanguageServerState {
anyhow::Ok(())
})
.detach();
} else if has_logs {
lsp_log_view::open_server_trace(
&lsp_logs,
workspace.clone(),
server_selector.clone(),
window,
cx,
);
} else {
cx.propagate();
}
@@ -512,7 +510,7 @@ impl ServerData<'_> {
}
}
impl LspButton {
impl LspTool {
pub fn new(
workspace: &Workspace,
popover_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -520,59 +518,37 @@ impl LspButton {
cx: &mut Context<Self>,
) -> Self {
let settings_subscription =
cx.observe_global_in::<SettingsStore>(window, move |lsp_button, window, cx| {
cx.observe_global_in::<SettingsStore>(window, move |lsp_tool, window, cx| {
if ProjectSettings::get_global(cx).global_lsp_settings.button {
if lsp_button.lsp_menu.is_none() {
lsp_button.refresh_lsp_menu(true, window, cx);
if lsp_tool.lsp_menu.is_none() {
lsp_tool.refresh_lsp_menu(true, window, cx);
}
} else if lsp_button.lsp_menu.take().is_some() {
} else if lsp_tool.lsp_menu.take().is_some() {
cx.notify();
}
});
let lsp_store = workspace.project().read(cx).lsp_store();
let mut language_servers = LanguageServers::default();
for (_, status) in lsp_store.read(cx).language_server_statuses() {
language_servers.binary_statuses.insert(
status.name.clone(),
LanguageServerBinaryStatus {
status: BinaryStatus::None,
message: None,
},
);
}
let lsp_store_subscription =
cx.subscribe_in(&lsp_store, window, |lsp_button, _, e, window, cx| {
lsp_button.on_lsp_store_event(e, window, cx)
cx.subscribe_in(&lsp_store, window, |lsp_tool, _, e, window, cx| {
lsp_tool.on_lsp_store_event(e, window, cx)
});
let server_state = cx.new(|_| LanguageServerState {
let state = cx.new(|_| LanguageServerState {
workspace: workspace.weak_handle(),
items: Vec::new(),
lsp_store: lsp_store.downgrade(),
active_editor: None,
language_servers,
language_servers: LanguageServers::default(),
});
let mut lsp_button = Self {
server_state,
Self {
server_state: state,
popover_menu_handle,
lsp_menu: None,
lsp_menu_refresh: Task::ready(()),
_subscriptions: vec![settings_subscription, lsp_store_subscription],
};
if !lsp_button
.server_state
.read(cx)
.language_servers
.binary_statuses
.is_empty()
{
lsp_button.refresh_lsp_menu(true, window, cx);
}
lsp_button
}
fn on_lsp_store_event(
@@ -732,25 +708,6 @@ impl LspButton {
}
}
}
state
.lsp_store
.update(cx, |lsp_store, cx| {
for (server_id, status) in lsp_store.language_server_statuses() {
if let Some(worktree) = status.worktree.and_then(|worktree_id| {
lsp_store
.worktree_store()
.read(cx)
.worktree_for_id(worktree_id, cx)
}) {
server_ids_to_worktrees.insert(server_id, worktree.clone());
server_names_to_worktrees
.entry(status.name.clone())
.or_default()
.insert((worktree, server_id));
}
}
})
.ok();
let mut servers_per_worktree = BTreeMap::<SharedString, Vec<ServerData>>::new();
let mut servers_without_worktree = Vec::<ServerData>::new();
@@ -895,18 +852,18 @@ impl LspButton {
) {
if create_if_empty || self.lsp_menu.is_some() {
let state = self.server_state.clone();
self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_button, cx| {
self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_tool, cx| {
cx.background_executor()
.timer(Duration::from_millis(30))
.await;
lsp_button
.update_in(cx, |lsp_button, window, cx| {
lsp_button.regenerate_items(cx);
lsp_tool
.update_in(cx, |lsp_tool, window, cx| {
lsp_tool.regenerate_items(cx);
let menu = ContextMenu::build(window, cx, |menu, _, cx| {
state.update(cx, |state, cx| state.fill_menu(menu, cx))
});
lsp_button.lsp_menu = Some(menu.clone());
lsp_button.popover_menu_handle.refresh_menu(
lsp_tool.lsp_menu = Some(menu.clone());
lsp_tool.popover_menu_handle.refresh_menu(
window,
cx,
Rc::new(move |_, _| Some(menu.clone())),
@@ -919,7 +876,7 @@ impl LspButton {
}
}
impl StatusItemView for LspButton {
impl StatusItemView for LspTool {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn workspace::ItemHandle>,
@@ -942,9 +899,9 @@ impl StatusItemView for LspButton {
let _editor_subscription = cx.subscribe_in(
&editor,
window,
|lsp_button, _, e: &EditorEvent, window, cx| match e {
|lsp_tool, _, e: &EditorEvent, window, cx| match e {
EditorEvent::ExcerptsAdded { buffer, .. } => {
let updated = lsp_button.server_state.update(cx, |state, cx| {
let updated = lsp_tool.server_state.update(cx, |state, cx| {
if let Some(active_editor) = state.active_editor.as_mut() {
let buffer_id = buffer.read(cx).remote_id();
active_editor.editor_buffers.insert(buffer_id)
@@ -953,13 +910,13 @@ impl StatusItemView for LspButton {
}
});
if updated {
lsp_button.refresh_lsp_menu(false, window, cx);
lsp_tool.refresh_lsp_menu(false, window, cx);
}
}
EditorEvent::ExcerptsRemoved {
removed_buffer_ids, ..
} => {
let removed = lsp_button.server_state.update(cx, |state, _| {
let removed = lsp_tool.server_state.update(cx, |state, _| {
let mut removed = false;
if let Some(active_editor) = state.active_editor.as_mut() {
for id in removed_buffer_ids {
@@ -973,7 +930,7 @@ impl StatusItemView for LspButton {
removed
});
if removed {
lsp_button.refresh_lsp_menu(false, window, cx);
lsp_tool.refresh_lsp_menu(false, window, cx);
}
}
_ => {}
@@ -1003,7 +960,7 @@ impl StatusItemView for LspButton {
}
}
impl Render for LspButton {
impl Render for LspTool {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() {
return div();
@@ -1048,11 +1005,11 @@ impl Render for LspButton {
(None, "All Servers Operational")
};
let lsp_button = cx.entity();
let lsp_tool = cx.entity();
div().child(
PopoverMenu::new("lsp-tool")
.menu(move |_, cx| lsp_button.read(cx).lsp_menu.clone())
.menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone())
.anchor(Corner::BottomLeft)
.with_handle(self.popover_menu_handle.clone())
.trigger_with_tooltip(

View File

@@ -3,8 +3,27 @@
(namespace_identifier) @namespace
(concept_definition
(identifier) @concept)
name: (identifier) @concept)
(requires_clause
constraint: (template_type
name: (type_identifier) @concept))
(module_name
(identifier) @module)
(module_declaration
name: (module_name
(identifier) @module))
(import_declaration
name: (module_name
(identifier) @module))
(import_declaration
partition: (module_partition
(module_name
(identifier) @module)))
(call_expression
function: (qualified_identifier
@@ -61,6 +80,9 @@
(operator_name
(identifier)? @operator) @function
(operator_name
"<=>" @operator.spaceship)
(destructor_name (identifier) @function)
((namespace_identifier) @type
@@ -68,21 +90,17 @@
(auto) @type
(type_identifier) @type
type :(primitive_type) @type.primitive
(sized_type_specifier) @type.primitive
(requires_clause
constraint: (template_type
name: (type_identifier) @concept))
type: (primitive_type) @type.builtin
(sized_type_specifier) @type.builtin
(attribute
name: (identifier) @keyword)
name: (identifier) @attribute)
((identifier) @constant
(#match? @constant "^_*[A-Z][A-Z\\d_]*$"))
((identifier) @constant.builtin
(#match? @constant.builtin "^_*[A-Z][A-Z\\d_]*$"))
(statement_identifier) @label
(this) @variable.special
(this) @variable.builtin
("static_assert") @function.builtin
[
@@ -96,7 +114,9 @@ type :(primitive_type) @type.primitive
"co_return"
"co_yield"
"concept"
"consteval"
"constexpr"
"constinit"
"continue"
"decltype"
"default"
@@ -105,15 +125,20 @@ type :(primitive_type) @type.primitive
"else"
"enum"
"explicit"
"export"
"extern"
"final"
"for"
"friend"
"goto"
"if"
"import"
"inline"
"module"
"namespace"
"new"
"noexcept"
"operator"
"override"
"private"
"protected"
@@ -124,6 +149,7 @@ type :(primitive_type) @type.primitive
"struct"
"switch"
"template"
"thread_local"
"throw"
"try"
"typedef"
@@ -146,7 +172,7 @@ type :(primitive_type) @type.primitive
"#ifndef"
"#include"
(preproc_directive)
] @keyword
] @keyword.directive
(comment) @comment
@@ -224,10 +250,24 @@ type :(primitive_type) @type.primitive
">"
"<="
">="
"<=>"
"||"
"?"
"and"
"and_eq"
"bitand"
"bitor"
"compl"
"not"
"not_eq"
"or"
"or_eq"
"xor"
"xor_eq"
] @operator
"<=>" @operator.spaceship
(binary_expression
operator: "<=>" @operator.spaceship)
(conditional_expression ":" @operator)
(user_defined_literal (literal_suffix) @operator)

View File

@@ -2,7 +2,6 @@ use anyhow::{Context as _, ensure};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use collections::HashMap;
use futures::AsyncBufReadExt;
use gpui::{App, Task};
use gpui::{AsyncApp, SharedString};
use language::Toolchain;
@@ -31,6 +30,8 @@ use std::{
borrow::Cow,
ffi::OsString,
fmt::Write,
fs,
io::{self, BufRead},
path::{Path, PathBuf},
sync::Arc,
};
@@ -740,16 +741,14 @@ fn env_priority(kind: Option<PythonEnvironmentKind>) -> usize {
/// Return the name of environment declared in <worktree-root/.venv.
///
/// https://virtualfish.readthedocs.io/en/latest/plugins.html#auto-activation-auto-activation
async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
let file = async_fs::File::open(worktree_root.join(".venv"))
.await
.ok()?;
let mut venv_name = String::new();
smol::io::BufReader::new(file)
.read_line(&mut venv_name)
.await
.ok()?;
Some(venv_name.trim().to_string())
fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
fs::File::open(worktree_root.join(".venv"))
.and_then(|file| {
let mut venv_name = String::new();
io::BufReader::new(file).read_line(&mut venv_name)?;
Ok(venv_name.trim().to_string())
})
.ok()
}
#[async_trait]
@@ -792,7 +791,7 @@ impl ToolchainLister for PythonToolchainProvider {
.map_or(Vec::new(), |mut guard| std::mem::take(&mut guard));
let wr = worktree_root;
let wr_venv = get_worktree_venv_declaration(&wr).await;
let wr_venv = get_worktree_venv_declaration(&wr);
// Sort detected environments by:
// environment name matching activation file (<workdir>/.venv)
// environment project dir matching worktree_root
@@ -857,7 +856,7 @@ impl ToolchainLister for PythonToolchainProvider {
.into_iter()
.filter_map(|toolchain| {
let mut name = String::from("Python");
if let Some(version) = &toolchain.version {
if let Some(ref version) = toolchain.version {
_ = write!(name, " {version}");
}
@@ -878,7 +877,7 @@ impl ToolchainLister for PythonToolchainProvider {
name: name.into(),
path: toolchain.executable.as_ref()?.to_str()?.to_owned().into(),
language_name: LanguageName::new("Python"),
as_json: serde_json::to_value(toolchain.clone()).ok()?,
as_json: serde_json::to_value(toolchain).ok()?,
})
})
.collect();
@@ -892,23 +891,6 @@ impl ToolchainLister for PythonToolchainProvider {
fn term(&self) -> SharedString {
self.term.clone()
}
async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option<String> {
let toolchain = serde_json::from_value::<pet_core::python_environment::PythonEnvironment>(
toolchain.as_json.clone(),
)
.ok()?;
let mut activation_script = None;
if let Some(prefix) = &toolchain.prefix {
#[cfg(not(target_os = "windows"))]
let path = prefix.join(BINARY_DIR).join("activate");
#[cfg(target_os = "windows")]
let path = prefix.join(BINARY_DIR).join("activate.ps1");
if fs.is_file(&path).await {
activation_script = Some(format!(". {}", path.display()));
}
}
activation_script
}
}
pub struct EnvironmentApi<'a> {

View File

@@ -276,7 +276,6 @@ impl DapStore {
&binary.arguments,
&binary.envs,
binary.cwd.map(|path| path.display().to_string()),
None,
port_forwarding,
)
})??;

View File

@@ -11,22 +11,18 @@
//! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate.
pub mod clangd_ext;
pub mod json_language_server_ext;
pub mod log_store;
pub mod lsp_ext_command;
pub mod rust_analyzer_ext;
use crate::{
CodeAction, ColorPresentation, Completion, CompletionDisplayOptions, CompletionResponse,
CompletionSource, CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction,
LspPullDiagnostics, ManifestProvidersStore, Project, ProjectItem, ProjectPath,
ProjectTransaction, PulledDiagnostics, ResolveState, Symbol,
CodeAction, ColorPresentation, Completion, CompletionResponse, CompletionSource,
CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction, LspPullDiagnostics,
ManifestProvidersStore, ProjectItem, ProjectPath, ProjectTransaction, PulledDiagnostics,
ResolveState, Symbol,
buffer_store::{BufferStore, BufferStoreEvent},
environment::ProjectEnvironment,
lsp_command::{self, *},
lsp_store::{
self,
log_store::{GlobalLogStore, LanguageServerKind},
},
lsp_store,
manifest_tree::{
LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, ManifestQueryDelegate,
ManifestTree,
@@ -981,9 +977,7 @@ impl LocalLspStore {
this.update(&mut cx, |_, cx| {
cx.emit(LspStoreEvent::LanguageServerLog(
server_id,
LanguageServerLogType::Trace {
verbose_info: params.verbose,
},
LanguageServerLogType::Trace(params.verbose),
params.message,
));
})
@@ -3488,13 +3482,13 @@ pub struct LspStore {
buffer_store: Entity<BufferStore>,
worktree_store: Entity<WorktreeStore>,
pub languages: Arc<LanguageRegistry>,
pub language_server_statuses: BTreeMap<LanguageServerId, LanguageServerStatus>,
language_server_statuses: BTreeMap<LanguageServerId, LanguageServerStatus>,
active_entry: Option<ProjectEntryId>,
_maintain_workspace_config: (Task<Result<()>>, watch::Sender<()>),
_maintain_buffer_languages: Task<()>,
diagnostic_summaries:
HashMap<WorktreeId, HashMap<Arc<Path>, HashMap<LanguageServerId, DiagnosticSummary>>>,
pub lsp_server_capabilities: HashMap<LanguageServerId, lsp::ServerCapabilities>,
pub(super) lsp_server_capabilities: HashMap<LanguageServerId, lsp::ServerCapabilities>,
lsp_document_colors: HashMap<BufferId, DocumentColorData>,
lsp_code_lens: HashMap<BufferId, CodeLensData>,
running_lsp_requests: HashMap<TypeId, (Global, HashMap<LspRequestId, Task<()>>)>,
@@ -3571,7 +3565,6 @@ pub struct LanguageServerStatus {
pub pending_work: BTreeMap<String, LanguageServerProgress>,
pub has_pending_diagnostic_updates: bool,
progress_tokens: HashSet<String>,
pub worktree: Option<WorktreeId>,
}
#[derive(Clone, Debug)]
@@ -5828,7 +5821,6 @@ impl LspStore {
.await;
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: completion_response.is_incomplete,
}])
})
@@ -5921,7 +5913,6 @@ impl LspStore {
.await;
Some(CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: completion_response.is_incomplete,
})
});
@@ -7492,7 +7483,7 @@ impl LspStore {
server: Some(proto::LanguageServer {
id: server_id.to_proto(),
name: status.name.to_string(),
worktree_id: status.worktree.map(|id| id.to_proto()),
worktree_id: None,
}),
capabilities: serde_json::to_string(&server.capabilities())
.expect("serializing server LSP capabilities"),
@@ -7517,15 +7508,9 @@ impl LspStore {
pub(crate) fn set_language_server_statuses_from_proto(
&mut self,
project: WeakEntity<Project>,
language_servers: Vec<proto::LanguageServer>,
server_capabilities: Vec<String>,
cx: &mut Context<Self>,
) {
let lsp_logs = cx
.try_global::<GlobalLogStore>()
.map(|lsp_store| lsp_store.0.clone());
self.language_server_statuses = language_servers
.into_iter()
.zip(server_capabilities)
@@ -7535,34 +7520,13 @@ impl LspStore {
self.lsp_server_capabilities
.insert(server_id, server_capabilities);
}
let name = LanguageServerName::from_proto(server.name);
let worktree = server.worktree_id.map(WorktreeId::from_proto);
if let Some(lsp_logs) = &lsp_logs {
lsp_logs.update(cx, |lsp_logs, cx| {
lsp_logs.add_language_server(
// Only remote clients get their language servers set from proto
LanguageServerKind::Remote {
project: project.clone(),
},
server_id,
Some(name.clone()),
worktree,
None,
cx,
);
});
}
(
server_id,
LanguageServerStatus {
name,
name: LanguageServerName::from_proto(server.name),
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
worktree,
},
)
})
@@ -8928,7 +8892,6 @@ impl LspStore {
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
worktree: server.worktree_id.map(WorktreeId::from_proto),
},
);
cx.emit(LspStoreEvent::LanguageServerAdded(
@@ -10942,7 +10905,6 @@ impl LspStore {
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
worktree: Some(key.worktree_id),
},
);
@@ -11741,135 +11703,108 @@ impl LspStore {
// Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings.
}
"workspace/symbol" => {
let options = parse_register_capabilities(reg)?;
let options = parse_register_or(&reg, || OneOf::Left(true))?;
server.update_capabilities(|capabilities| {
capabilities.workspace_symbol_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"workspace/fileOperations" => {
if let Some(options) = reg.register_options {
let caps = serde_json::from_value(options)?;
server.update_capabilities(|capabilities| {
capabilities
.workspace
.get_or_insert_default()
.file_operations = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
let caps = parse_register_or_default(&reg)?;
server.update_capabilities(|capabilities| {
capabilities
.workspace
.get_or_insert_default()
.file_operations = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
"workspace/executeCommand" => {
if let Some(options) = reg.register_options {
let options = serde_json::from_value(options)?;
server.update_capabilities(|capabilities| {
capabilities.execute_command_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
let options = parse_register_or_default(&reg)?;
server.update_capabilities(|capabilities| {
capabilities.execute_command_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/rangeFormatting" => {
let options = parse_register_capabilities(reg)?;
let options = parse_register_or(&reg, || OneOf::Left(true))?;
server.update_capabilities(|capabilities| {
capabilities.document_range_formatting_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/onTypeFormatting" => {
if let Some(options) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.document_on_type_formatting_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
let options = parse_register_or_default(&reg)?;
server.update_capabilities(|capabilities| {
capabilities.document_on_type_formatting_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/formatting" => {
let options = parse_register_capabilities(reg)?;
let options = parse_register_or(&reg, || OneOf::Left(true))?;
server.update_capabilities(|capabilities| {
capabilities.document_formatting_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/rename" => {
let options = parse_register_capabilities(reg)?;
let options = parse_register_or(&reg, || OneOf::Left(true))?;
server.update_capabilities(|capabilities| {
capabilities.rename_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/inlayHint" => {
let options = parse_register_capabilities(reg)?;
let options = parse_register_or(&reg, || OneOf::Left(true))?;
server.update_capabilities(|capabilities| {
capabilities.inlay_hint_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/documentSymbol" => {
let options = parse_register_capabilities(reg)?;
let options = parse_register_or(&reg, || OneOf::Left(true))?;
server.update_capabilities(|capabilities| {
capabilities.document_symbol_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/codeAction" => {
if let Some(options) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.code_action_provider =
Some(lsp::CodeActionProviderCapability::Options(options));
});
notify_server_capabilities_updated(&server, cx);
}
let provider = parse_register_or(&reg, || {
lsp::CodeActionProviderCapability::Simple(true)
})?;
server.update_capabilities(|capabilities| {
capabilities.code_action_provider = Some(provider);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/definition" => {
let options = parse_register_capabilities(reg)?;
let options = parse_register_or(&reg, || OneOf::Left(true))?;
server.update_capabilities(|capabilities| {
capabilities.definition_provider = Some(options);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/completion" => {
if let Some(caps) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.completion_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
let caps = parse_register_or_default(&reg)?;
server.update_capabilities(|capabilities| {
capabilities.completion_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/hover" => {
if let Some(caps) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.hover_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
let provider =
parse_register_or(&reg, || lsp::HoverProviderCapability::Simple(true))?;
server.update_capabilities(|capabilities| {
capabilities.hover_provider = Some(provider);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/signatureHelp" => {
if let Some(caps) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.signature_help_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
let caps = parse_register_or_default(&reg)?;
server.update_capabilities(|capabilities| {
capabilities.signature_help_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/didChange" => {
if let Some(sync_kind) = reg
@@ -11918,40 +11853,30 @@ impl LspStore {
}
}
"textDocument/codeLens" => {
if let Some(caps) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.code_lens_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
let caps = parse_register_or(&reg, || lsp::CodeLensOptions {
resolve_provider: None,
})?;
server.update_capabilities(|capabilities| {
capabilities.code_lens_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/diagnostic" => {
if let Some(caps) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.diagnostic_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
let caps = parse_register_or(&reg, || {
lsp::DiagnosticServerCapabilities::Options(lsp::DiagnosticOptions::default())
})?;
server.update_capabilities(|capabilities| {
capabilities.diagnostic_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
"textDocument/documentColor" => {
if let Some(caps) = reg
.register_options
.map(serde_json::from_value)
.transpose()?
{
server.update_capabilities(|capabilities| {
capabilities.color_provider = Some(caps);
});
notify_server_capabilities_updated(&server, cx);
}
let provider =
parse_register_or(&reg, || lsp::ColorProviderCapability::Simple(true))?;
server.update_capabilities(|capabilities| {
capabilities.color_provider = Some(provider);
});
notify_server_capabilities_updated(&server, cx);
}
_ => log::warn!("unhandled capability registration: {reg:?}"),
}
@@ -12206,27 +12131,29 @@ impl LspStore {
let data = self.lsp_code_lens.get_mut(&buffer_id)?;
Some(data.update.take()?.1)
}
pub fn downstream_client(&self) -> Option<(AnyProtoClient, u64)> {
self.downstream_client.clone()
}
pub fn worktree_store(&self) -> Entity<WorktreeStore> {
self.worktree_store.clone()
}
}
// Registration with registerOptions as null, should fallback to true.
// https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/client.ts#L2133
fn parse_register_capabilities<T: serde::de::DeserializeOwned>(
reg: lsp::Registration,
) -> Result<OneOf<bool, T>> {
Ok(match reg.register_options {
Some(options) => OneOf::Right(serde_json::from_value::<T>(options)?),
None => OneOf::Left(true),
// Parse register_options into T or return a provided default if None.
fn parse_register_or<T, F>(reg: &lsp::Registration, default: F) -> Result<T>
where
T: serde::de::DeserializeOwned,
F: FnOnce() -> T,
{
Ok(match reg.register_options.as_ref() {
Some(options) => serde_json::from_value::<T>(options.clone())?,
None => default(),
})
}
// Parse register_options into T or default() if None.
fn parse_register_or_default<T>(reg: &lsp::Registration) -> Result<T>
where
T: serde::de::DeserializeOwned + Default,
{
parse_register_or(reg, T::default)
}
fn subscribe_to_binary_statuses(
languages: &Arc<LanguageRegistry>,
cx: &mut Context<'_, LspStore>,
@@ -12723,69 +12650,45 @@ impl PartialEq for LanguageServerPromptRequest {
#[derive(Clone, Debug, PartialEq)]
pub enum LanguageServerLogType {
Log(MessageType),
Trace { verbose_info: Option<String> },
Rpc { received: bool },
Trace(Option<String>),
}
impl LanguageServerLogType {
pub fn to_proto(&self) -> proto::language_server_log::LogType {
match self {
Self::Log(log_type) => {
use proto::log_message::LogLevel;
let level = match *log_type {
MessageType::ERROR => LogLevel::Error,
MessageType::WARNING => LogLevel::Warning,
MessageType::INFO => LogLevel::Info,
MessageType::LOG => LogLevel::Log,
let message_type = match *log_type {
MessageType::ERROR => 1,
MessageType::WARNING => 2,
MessageType::INFO => 3,
MessageType::LOG => 4,
other => {
log::warn!("Unknown lsp log message type: {other:?}");
LogLevel::Log
log::warn!("Unknown lsp log message type: {:?}", other);
4
}
};
proto::language_server_log::LogType::Log(proto::LogMessage {
level: level as i32,
})
proto::language_server_log::LogType::LogMessageType(message_type)
}
Self::Trace { verbose_info } => {
proto::language_server_log::LogType::Trace(proto::TraceMessage {
verbose_info: verbose_info.to_owned(),
Self::Trace(message) => {
proto::language_server_log::LogType::LogTrace(proto::LspLogTrace {
message: message.clone(),
})
}
Self::Rpc { received } => {
let kind = if *received {
proto::rpc_message::Kind::Received
} else {
proto::rpc_message::Kind::Sent
};
let kind = kind as i32;
proto::language_server_log::LogType::Rpc(proto::RpcMessage { kind })
}
}
}
pub fn from_proto(log_type: proto::language_server_log::LogType) -> Self {
use proto::log_message::LogLevel;
use proto::rpc_message;
match log_type {
proto::language_server_log::LogType::Log(message_type) => Self::Log(
match LogLevel::from_i32(message_type.level).unwrap_or(LogLevel::Log) {
LogLevel::Error => MessageType::ERROR,
LogLevel::Warning => MessageType::WARNING,
LogLevel::Info => MessageType::INFO,
LogLevel::Log => MessageType::LOG,
},
),
proto::language_server_log::LogType::Trace(trace_message) => Self::Trace {
verbose_info: trace_message.verbose_info,
},
proto::language_server_log::LogType::Rpc(message) => Self::Rpc {
received: match rpc_message::Kind::from_i32(message.kind)
.unwrap_or(rpc_message::Kind::Received)
{
rpc_message::Kind::Received => true,
rpc_message::Kind::Sent => false,
},
},
proto::language_server_log::LogType::LogMessageType(message_type) => {
Self::Log(match message_type {
1 => MessageType::ERROR,
2 => MessageType::WARNING,
3 => MessageType::INFO,
4 => MessageType::LOG,
_ => MessageType::LOG,
})
}
proto::language_server_log::LogType::LogTrace(trace) => Self::Trace(trace.message),
}
}
}
@@ -12924,21 +12827,6 @@ pub enum CompletionDocumentation {
},
}
impl CompletionDocumentation {
#[cfg(any(test, feature = "test-support"))]
pub fn text(&self) -> SharedString {
match self {
CompletionDocumentation::Undocumented => "".into(),
CompletionDocumentation::SingleLine(s) => s.clone(),
CompletionDocumentation::MultiLinePlainText(s) => s.clone(),
CompletionDocumentation::MultiLineMarkdown(s) => s.clone(),
CompletionDocumentation::SingleLineAndMultiLinePlainText { single_line, .. } => {
single_line.clone()
}
}
}
}
impl From<lsp::Documentation> for CompletionDocumentation {
fn from(docs: lsp::Documentation) -> Self {
match docs {

View File

@@ -1,704 +0,0 @@
use std::{collections::VecDeque, sync::Arc};
use collections::HashMap;
use futures::{StreamExt, channel::mpsc};
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, WeakEntity};
use lsp::{
IoKind, LanguageServer, LanguageServerId, LanguageServerName, LanguageServerSelector,
MessageType, TraceValue,
};
use rpc::proto;
use settings::WorktreeId;
use crate::{LanguageServerLogType, LspStore, Project, ProjectItem as _};
const SEND_LINE: &str = "\n// Send:";
const RECEIVE_LINE: &str = "\n// Receive:";
const MAX_STORED_LOG_ENTRIES: usize = 2000;
const RPC_MESSAGES: &str = "RPC Messages";
const SERVER_LOGS: &str = "Server Logs";
const SERVER_TRACE: &str = "Server Trace";
const SERVER_INFO: &str = "Server Info";
pub fn init(store_logs: bool, cx: &mut App) -> Entity<LogStore> {
let log_store = cx.new(|cx| LogStore::new(store_logs, cx));
cx.set_global(GlobalLogStore(log_store.clone()));
log_store
}
pub struct GlobalLogStore(pub Entity<LogStore>);
impl Global for GlobalLogStore {}
#[derive(Debug)]
pub enum Event {
NewServerLogEntry {
id: LanguageServerId,
kind: LanguageServerLogType,
text: String,
},
}
impl EventEmitter<Event> for LogStore {}
pub struct LogStore {
store_logs: bool,
projects: HashMap<WeakEntity<Project>, ProjectState>,
pub copilot_log_subscription: Option<lsp::Subscription>,
pub language_servers: HashMap<LanguageServerId, LanguageServerState>,
io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String)>,
}
struct ProjectState {
_subscriptions: [Subscription; 2],
}
pub trait Message: AsRef<str> {
type Level: Copy + std::fmt::Debug;
fn should_include(&self, _: Self::Level) -> bool {
true
}
}
#[derive(Debug)]
pub struct LogMessage {
message: String,
typ: MessageType,
}
impl AsRef<str> for LogMessage {
fn as_ref(&self) -> &str {
&self.message
}
}
impl Message for LogMessage {
type Level = MessageType;
fn should_include(&self, level: Self::Level) -> bool {
match (self.typ, level) {
(MessageType::ERROR, _) => true,
(_, MessageType::ERROR) => false,
(MessageType::WARNING, _) => true,
(_, MessageType::WARNING) => false,
(MessageType::INFO, _) => true,
(_, MessageType::INFO) => false,
_ => true,
}
}
}
#[derive(Debug)]
pub struct TraceMessage {
message: String,
is_verbose: bool,
}
impl AsRef<str> for TraceMessage {
fn as_ref(&self) -> &str {
&self.message
}
}
impl Message for TraceMessage {
type Level = TraceValue;
fn should_include(&self, level: Self::Level) -> bool {
match level {
TraceValue::Off => false,
TraceValue::Messages => !self.is_verbose,
TraceValue::Verbose => true,
}
}
}
#[derive(Debug)]
pub struct RpcMessage {
message: String,
}
impl AsRef<str> for RpcMessage {
fn as_ref(&self) -> &str {
&self.message
}
}
impl Message for RpcMessage {
type Level = ();
}
pub struct LanguageServerState {
pub name: Option<LanguageServerName>,
pub worktree_id: Option<WorktreeId>,
pub kind: LanguageServerKind,
log_messages: VecDeque<LogMessage>,
trace_messages: VecDeque<TraceMessage>,
pub rpc_state: Option<LanguageServerRpcState>,
pub trace_level: TraceValue,
pub log_level: MessageType,
io_logs_subscription: Option<lsp::Subscription>,
}
impl std::fmt::Debug for LanguageServerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LanguageServerState")
.field("name", &self.name)
.field("worktree_id", &self.worktree_id)
.field("kind", &self.kind)
.field("log_messages", &self.log_messages)
.field("trace_messages", &self.trace_messages)
.field("rpc_state", &self.rpc_state)
.field("trace_level", &self.trace_level)
.field("log_level", &self.log_level)
.finish_non_exhaustive()
}
}
#[derive(PartialEq, Clone)]
pub enum LanguageServerKind {
Local { project: WeakEntity<Project> },
Remote { project: WeakEntity<Project> },
LocalSsh { lsp_store: WeakEntity<LspStore> },
Global,
}
impl std::fmt::Debug for LanguageServerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"),
LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"),
LanguageServerKind::LocalSsh { .. } => write!(f, "LanguageServerKind::LocalSsh"),
LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"),
}
}
}
impl LanguageServerKind {
pub fn project(&self) -> Option<&WeakEntity<Project>> {
match self {
Self::Local { project } => Some(project),
Self::Remote { project } => Some(project),
Self::LocalSsh { .. } => None,
Self::Global { .. } => None,
}
}
}
#[derive(Debug)]
pub struct LanguageServerRpcState {
pub rpc_messages: VecDeque<RpcMessage>,
last_message_kind: Option<MessageKind>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum MessageKind {
Send,
Receive,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum LogKind {
Rpc,
Trace,
#[default]
Logs,
ServerInfo,
}
impl LogKind {
pub fn from_server_log_type(log_type: &LanguageServerLogType) -> Self {
match log_type {
LanguageServerLogType::Log(_) => Self::Logs,
LanguageServerLogType::Trace { .. } => Self::Trace,
LanguageServerLogType::Rpc { .. } => Self::Rpc,
}
}
pub fn label(&self) -> &'static str {
match self {
LogKind::Rpc => RPC_MESSAGES,
LogKind::Trace => SERVER_TRACE,
LogKind::Logs => SERVER_LOGS,
LogKind::ServerInfo => SERVER_INFO,
}
}
}
impl LogStore {
pub fn new(store_logs: bool, cx: &mut Context<Self>) -> Self {
let (io_tx, mut io_rx) = mpsc::unbounded();
let log_store = Self {
projects: HashMap::default(),
language_servers: HashMap::default(),
copilot_log_subscription: None,
store_logs,
io_tx,
};
cx.spawn(async move |log_store, cx| {
while let Some((server_id, io_kind, message)) = io_rx.next().await {
if let Some(log_store) = log_store.upgrade() {
log_store.update(cx, |log_store, cx| {
log_store.on_io(server_id, io_kind, &message, cx);
})?;
}
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
log_store
}
pub fn add_project(&mut self, project: &Entity<Project>, cx: &mut Context<Self>) {
let weak_project = project.downgrade();
self.projects.insert(
project.downgrade(),
ProjectState {
_subscriptions: [
cx.observe_release(project, move |this, _, _| {
this.projects.remove(&weak_project);
this.language_servers
.retain(|_, state| state.kind.project() != Some(&weak_project));
}),
cx.subscribe(project, move |log_store, project, event, cx| {
let server_kind = if project.read(cx).is_local() {
LanguageServerKind::Local {
project: project.downgrade(),
}
} else {
LanguageServerKind::Remote {
project: project.downgrade(),
}
};
match event {
crate::Event::LanguageServerAdded(id, name, worktree_id) => {
log_store.add_language_server(
server_kind,
*id,
Some(name.clone()),
*worktree_id,
project
.read(cx)
.lsp_store()
.read(cx)
.language_server_for_id(*id),
cx,
);
}
crate::Event::LanguageServerBufferRegistered {
server_id,
buffer_id,
name,
..
} => {
let worktree_id = project
.read(cx)
.buffer_for_id(*buffer_id, cx)
.and_then(|buffer| {
Some(buffer.read(cx).project_path(cx)?.worktree_id)
});
let name = name.clone().or_else(|| {
project
.read(cx)
.lsp_store()
.read(cx)
.language_server_statuses
.get(server_id)
.map(|status| status.name.clone())
});
log_store.add_language_server(
server_kind,
*server_id,
name,
worktree_id,
None,
cx,
);
}
crate::Event::LanguageServerRemoved(id) => {
log_store.remove_language_server(*id, cx);
}
crate::Event::LanguageServerLog(id, typ, message) => {
log_store.add_language_server(
server_kind,
*id,
None,
None,
None,
cx,
);
match typ {
crate::LanguageServerLogType::Log(typ) => {
log_store.add_language_server_log(*id, *typ, message, cx);
}
crate::LanguageServerLogType::Trace { verbose_info } => {
log_store.add_language_server_trace(
*id,
message,
verbose_info.clone(),
cx,
);
}
crate::LanguageServerLogType::Rpc { received } => {
let kind = if *received {
MessageKind::Receive
} else {
MessageKind::Send
};
log_store.add_language_server_rpc(*id, kind, message, cx);
}
}
}
crate::Event::ToggleLspLogs { server_id, enabled } => {
// we do not support any other log toggling yet
if *enabled {
log_store.enable_rpc_trace_for_language_server(*server_id);
} else {
log_store.disable_rpc_trace_for_language_server(*server_id);
}
}
_ => {}
}
}),
],
},
);
}
pub fn get_language_server_state(
&mut self,
id: LanguageServerId,
) -> Option<&mut LanguageServerState> {
self.language_servers.get_mut(&id)
}
pub fn add_language_server(
&mut self,
kind: LanguageServerKind,
server_id: LanguageServerId,
name: Option<LanguageServerName>,
worktree_id: Option<WorktreeId>,
server: Option<Arc<LanguageServer>>,
cx: &mut Context<Self>,
) -> Option<&mut LanguageServerState> {
let server_state = self.language_servers.entry(server_id).or_insert_with(|| {
cx.notify();
LanguageServerState {
name: None,
worktree_id: None,
kind,
rpc_state: None,
log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
trace_level: TraceValue::Off,
log_level: MessageType::LOG,
io_logs_subscription: None,
}
});
if let Some(name) = name {
server_state.name = Some(name);
}
if let Some(worktree_id) = worktree_id {
server_state.worktree_id = Some(worktree_id);
}
if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) {
let io_tx = self.io_tx.clone();
let server_id = server.server_id();
server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| {
io_tx
.unbounded_send((server_id, io_kind, message.to_string()))
.ok();
}));
}
Some(server_state)
}
pub fn add_language_server_log(
&mut self,
id: LanguageServerId,
typ: MessageType,
message: &str,
cx: &mut Context<Self>,
) -> Option<()> {
let store_logs = self.store_logs;
let language_server_state = self.get_language_server_state(id)?;
let log_lines = &mut language_server_state.log_messages;
let message = message.trim_end().to_string();
if !store_logs {
// Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway
self.emit_event(
Event::NewServerLogEntry {
id,
kind: LanguageServerLogType::Log(typ),
text: message,
},
cx,
);
} else if let Some(new_message) = Self::push_new_message(
log_lines,
LogMessage { message, typ },
language_server_state.log_level,
) {
self.emit_event(
Event::NewServerLogEntry {
id,
kind: LanguageServerLogType::Log(typ),
text: new_message,
},
cx,
);
}
Some(())
}
fn add_language_server_trace(
&mut self,
id: LanguageServerId,
message: &str,
verbose_info: Option<String>,
cx: &mut Context<Self>,
) -> Option<()> {
let store_logs = self.store_logs;
let language_server_state = self.get_language_server_state(id)?;
let log_lines = &mut language_server_state.trace_messages;
if !store_logs {
// Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway
self.emit_event(
Event::NewServerLogEntry {
id,
kind: LanguageServerLogType::Trace { verbose_info },
text: message.trim().to_string(),
},
cx,
);
} else if let Some(new_message) = Self::push_new_message(
log_lines,
TraceMessage {
message: message.trim().to_string(),
is_verbose: false,
},
TraceValue::Messages,
) {
if let Some(verbose_message) = verbose_info.as_ref() {
Self::push_new_message(
log_lines,
TraceMessage {
message: verbose_message.clone(),
is_verbose: true,
},
TraceValue::Verbose,
);
}
self.emit_event(
Event::NewServerLogEntry {
id,
kind: LanguageServerLogType::Trace { verbose_info },
text: new_message,
},
cx,
);
}
Some(())
}
fn push_new_message<T: Message>(
log_lines: &mut VecDeque<T>,
message: T,
current_severity: <T as Message>::Level,
) -> Option<String> {
while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
log_lines.pop_front();
}
let visible = message.should_include(current_severity);
let visible_message = visible.then(|| message.as_ref().to_string());
log_lines.push_back(message);
visible_message
}
fn add_language_server_rpc(
&mut self,
language_server_id: LanguageServerId,
kind: MessageKind,
message: &str,
cx: &mut Context<'_, Self>,
) {
let store_logs = self.store_logs;
let Some(state) = self
.get_language_server_state(language_server_id)
.and_then(|state| state.rpc_state.as_mut())
else {
return;
};
let received = kind == MessageKind::Receive;
let rpc_log_lines = &mut state.rpc_messages;
if state.last_message_kind != Some(kind) {
while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
rpc_log_lines.pop_front();
}
let line_before_message = match kind {
MessageKind::Send => SEND_LINE,
MessageKind::Receive => RECEIVE_LINE,
};
if store_logs {
rpc_log_lines.push_back(RpcMessage {
message: line_before_message.to_string(),
});
}
// Do not send a synthetic message over the wire, it will be derived from the actual RPC message
cx.emit(Event::NewServerLogEntry {
id: language_server_id,
kind: LanguageServerLogType::Rpc { received },
text: line_before_message.to_string(),
});
}
while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
rpc_log_lines.pop_front();
}
if store_logs {
rpc_log_lines.push_back(RpcMessage {
message: message.trim().to_owned(),
});
}
self.emit_event(
Event::NewServerLogEntry {
id: language_server_id,
kind: LanguageServerLogType::Rpc { received },
text: message.to_owned(),
},
cx,
);
}
pub fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context<Self>) {
self.language_servers.remove(&id);
cx.notify();
}
pub fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque<LogMessage>> {
Some(&self.language_servers.get(&server_id)?.log_messages)
}
pub fn server_trace(&self, server_id: LanguageServerId) -> Option<&VecDeque<TraceMessage>> {
Some(&self.language_servers.get(&server_id)?.trace_messages)
}
pub fn server_ids_for_project<'a>(
&'a self,
lookup_project: &'a WeakEntity<Project>,
) -> impl Iterator<Item = LanguageServerId> + 'a {
self.language_servers
.iter()
.filter_map(move |(id, state)| match &state.kind {
LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => {
if project == lookup_project {
Some(*id)
} else {
None
}
}
LanguageServerKind::Global | LanguageServerKind::LocalSsh { .. } => Some(*id),
})
}
pub fn enable_rpc_trace_for_language_server(
&mut self,
server_id: LanguageServerId,
) -> Option<&mut LanguageServerRpcState> {
let rpc_state = self
.language_servers
.get_mut(&server_id)?
.rpc_state
.get_or_insert_with(|| LanguageServerRpcState {
rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
last_message_kind: None,
});
Some(rpc_state)
}
pub fn disable_rpc_trace_for_language_server(
&mut self,
server_id: LanguageServerId,
) -> Option<()> {
self.language_servers.get_mut(&server_id)?.rpc_state.take();
Some(())
}
pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool {
match server {
LanguageServerSelector::Id(id) => self.language_servers.contains_key(id),
LanguageServerSelector::Name(name) => self
.language_servers
.iter()
.any(|(_, state)| state.name.as_ref() == Some(name)),
}
}
fn on_io(
&mut self,
language_server_id: LanguageServerId,
io_kind: IoKind,
message: &str,
cx: &mut Context<Self>,
) -> Option<()> {
let is_received = match io_kind {
IoKind::StdOut => true,
IoKind::StdIn => false,
IoKind::StdErr => {
self.add_language_server_log(language_server_id, MessageType::LOG, message, cx);
return Some(());
}
};
let kind = if is_received {
MessageKind::Receive
} else {
MessageKind::Send
};
self.add_language_server_rpc(language_server_id, kind, message, cx);
cx.notify();
Some(())
}
fn emit_event(&mut self, e: Event, cx: &mut Context<Self>) {
match &e {
Event::NewServerLogEntry { id, kind, text } => {
if let Some(state) = self.get_language_server_state(*id) {
let downstream_client = match &state.kind {
LanguageServerKind::Remote { project }
| LanguageServerKind::Local { project } => project
.upgrade()
.map(|project| project.read(cx).lsp_store()),
LanguageServerKind::LocalSsh { lsp_store } => lsp_store.upgrade(),
LanguageServerKind::Global => None,
}
.and_then(|lsp_store| lsp_store.read(cx).downstream_client());
if let Some((client, project_id)) = downstream_client {
client
.send(proto::LanguageServerLog {
project_id,
language_server_id: id.to_proto(),
message: text.clone(),
log_type: Some(kind.to_proto()),
})
.ok();
}
}
}
}
cx.emit(e);
}
}

View File

@@ -280,11 +280,6 @@ pub enum Event {
server_id: LanguageServerId,
buffer_id: BufferId,
buffer_abs_path: PathBuf,
name: Option<LanguageServerName>,
},
ToggleLspLogs {
server_id: LanguageServerId,
enabled: bool,
},
Toast {
notification_id: SharedString,
@@ -573,23 +568,11 @@ impl std::fmt::Debug for Completion {
/// Response from a source of completions.
pub struct CompletionResponse {
pub completions: Vec<Completion>,
pub display_options: CompletionDisplayOptions,
/// When false, indicates that the list is complete and so does not need to be re-queried if it
/// can be filtered instead.
pub is_incomplete: bool,
}
#[derive(Default)]
pub struct CompletionDisplayOptions {
pub dynamic_width: bool,
}
impl CompletionDisplayOptions {
pub fn merge(&mut self, other: &CompletionDisplayOptions) {
self.dynamic_width = self.dynamic_width && other.dynamic_width;
}
}
/// Response from language server completion request.
#[derive(Clone, Debug, Default)]
pub(crate) struct CoreCompletionResponse {
@@ -677,6 +660,7 @@ pub enum ResolveState {
CanResolve(LanguageServerId, Option<lsp::LSPAny>),
Resolving,
}
impl InlayHint {
pub fn text(&self) -> Rope {
match &self.label {
@@ -1017,7 +1001,6 @@ impl Project {
client.add_entity_request_handler(Self::handle_open_buffer_by_path);
client.add_entity_request_handler(Self::handle_open_new_buffer);
client.add_entity_message_handler(Self::handle_create_buffer_for_peer);
client.add_entity_message_handler(Self::handle_toggle_lsp_logs);
WorktreeStore::init(&client);
BufferStore::init(&client);
@@ -1492,7 +1475,7 @@ impl Project {
})?;
let lsp_store = cx.new(|cx| {
LspStore::new_remote(
let mut lsp_store = LspStore::new_remote(
buffer_store.clone(),
worktree_store.clone(),
languages.clone(),
@@ -1500,7 +1483,12 @@ impl Project {
remote_id,
fs.clone(),
cx,
)
);
lsp_store.set_language_server_statuses_from_proto(
response.payload.language_servers,
response.payload.language_server_capabilities,
);
lsp_store
})?;
let task_store = cx.new(|cx| {
@@ -1534,7 +1522,7 @@ impl Project {
)
})?;
let project = cx.new(|cx| {
let this = cx.new(|cx| {
let replica_id = response.payload.replica_id as ReplicaId;
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
@@ -1565,7 +1553,7 @@ impl Project {
cx.subscribe(&dap_store, Self::on_dap_store_event).detach();
let mut project = Self {
let mut this = Self {
buffer_ordered_messages_tx: tx,
buffer_store: buffer_store.clone(),
image_store,
@@ -1608,25 +1596,13 @@ impl Project {
toolchain_store: None,
agent_location: None,
};
project.set_role(role, cx);
this.set_role(role, cx);
for worktree in worktrees {
project.add_worktree(&worktree, cx);
this.add_worktree(&worktree, cx);
}
project
this
})?;
let weak_project = project.downgrade();
lsp_store
.update(&mut cx, |lsp_store, cx| {
lsp_store.set_language_server_statuses_from_proto(
weak_project,
response.payload.language_servers,
response.payload.language_server_capabilities,
cx,
);
})
.ok();
let subscriptions = subscriptions
.into_iter()
.map(|s| match s {
@@ -1642,7 +1618,7 @@ impl Project {
EntitySubscription::SettingsObserver(subscription) => {
subscription.set_entity(&settings_observer, &cx)
}
EntitySubscription::Project(subscription) => subscription.set_entity(&project, &cx),
EntitySubscription::Project(subscription) => subscription.set_entity(&this, &cx),
EntitySubscription::LspStore(subscription) => {
subscription.set_entity(&lsp_store, &cx)
}
@@ -1662,13 +1638,13 @@ impl Project {
.update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))?
.await?;
project.update(&mut cx, |this, cx| {
this.update(&mut cx, |this, cx| {
this.set_collaborators_from_proto(response.payload.collaborators, cx)?;
this.client_subscriptions.extend(subscriptions);
anyhow::Ok(())
})??;
Ok(project)
Ok(this)
}
fn new_search_history() -> SearchHistory {
@@ -2339,14 +2315,10 @@ impl Project {
self.join_project_response_message_id = message_id;
self.set_worktrees_from_proto(message.worktrees, cx)?;
self.set_collaborators_from_proto(message.collaborators, cx)?;
let project = cx.weak_entity();
self.lsp_store.update(cx, |lsp_store, cx| {
self.lsp_store.update(cx, |lsp_store, _| {
lsp_store.set_language_server_statuses_from_proto(
project,
message.language_servers,
message.language_server_capabilities,
cx,
)
});
self.enqueue_buffer_ordered_message(BufferOrderedMessage::Resync)
@@ -2999,7 +2971,6 @@ impl Project {
buffer_id,
server_id: *language_server_id,
buffer_abs_path: PathBuf::from(&update.buffer_abs_path),
name: name.clone(),
});
}
}
@@ -4726,20 +4697,6 @@ impl Project {
})?
}
async fn handle_toggle_lsp_logs(
project: Entity<Self>,
envelope: TypedEnvelope<proto::ToggleLspLogs>,
mut cx: AsyncApp,
) -> Result<()> {
project.update(&mut cx, |_, cx| {
cx.emit(Event::ToggleLspLogs {
server_id: LanguageServerId::from_proto(envelope.payload.server_id),
enabled: envelope.payload.enabled,
})
})?;
Ok(())
}
async fn handle_synchronize_buffers(
this: Entity<Self>,
envelope: TypedEnvelope<proto::SynchronizeBuffers>,

View File

@@ -1951,7 +1951,6 @@ async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppC
server_id: LanguageServerId(1),
buffer_id,
buffer_abs_path: PathBuf::from(path!("/dir/a.rs")),
name: Some(fake_server.server.name())
}
);
assert_eq!(
@@ -9221,9 +9220,6 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
fn manifest_name(&self) -> ManifestName {
SharedString::new_static("pyproject.toml").into()
}
async fn activation_script(&self, _: &Toolchain, _: &dyn Fs) -> Option<String> {
None
}
}
Arc::new(
Language::new(

View File

@@ -1,28 +1,44 @@
use anyhow::Result;
use crate::{Project, ProjectPath};
use anyhow::{Context as _, Result};
use collections::HashMap;
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
use itertools::Itertools;
use language::LanguageName;
use remote::RemoteClient;
use settings::{Settings, SettingsLocation};
use smol::channel::bounded;
use std::{
borrow::Cow,
env::{self},
path::{Path, PathBuf},
sync::Arc,
};
use task::{Shell, ShellBuilder, SpawnInTerminal};
use terminal::{
TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
TaskState, TaskStatus, Terminal, TerminalBuilder,
terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings},
};
use util::{get_default_system_shell, get_system_shell, maybe};
use util::{ResultExt, paths::RemotePathBuf};
use crate::{Project, ProjectPath};
/// The directory inside a Python virtual environment that contains executables
const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") {
"Scripts"
} else {
"bin"
};
pub struct Terminals {
pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
}
/// Terminals are opened either for the users shell, or to run a task.
#[derive(Debug)]
pub enum TerminalKind {
/// Run a shell at the given path (or $HOME if None)
Shell(Option<PathBuf>),
/// Run a task.
Task(SpawnInTerminal),
}
impl Project {
pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
self.active_entry()
@@ -42,362 +58,46 @@ impl Project {
}
}
pub fn create_terminal_task(
pub fn create_terminal(
&mut self,
spawn_task: SpawnInTerminal,
kind: TerminalKind,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
let is_via_remote = self.remote_client.is_some();
let project_path_context = self
.active_entry()
.and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
.or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
.map(|worktree_id| ProjectPath {
worktree_id,
path: Arc::from(Path::new("")),
});
let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
if is_via_remote {
Some(Arc::from(cwd.as_ref()))
} else {
let cwd = cwd.to_string_lossy();
let tilde_substituted = shellexpand::tilde(&cwd);
Some(Arc::from(Path::new(tilde_substituted.as_ref())))
}
} else {
self.active_project_directory(cx)
};
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
path,
});
}
let settings = TerminalSettings::get(settings_location, cx).clone();
let detect_venv = settings.detect_venv.as_option().is_some();
let (completion_tx, completion_rx) = bounded(1);
// Start with the environment that we might have inherited from the Zed CLI.
let mut env = self
.environment
.read(cx)
.get_cli_environment()
.unwrap_or_default();
// Then extend it with the explicit env variables from the settings, so they take
// precedence.
env.extend(settings.env);
let local_path = if is_via_remote { None } else { path.clone() };
let task_state = Some(TaskState {
id: spawn_task.id,
full_label: spawn_task.full_label,
label: spawn_task.label,
command_label: spawn_task.command_label,
hide: spawn_task.hide,
status: TaskStatus::Running,
show_summary: spawn_task.show_summary,
show_command: spawn_task.show_command,
show_rerun: spawn_task.show_rerun,
completion_rx,
});
let remote_client = self.remote_client.clone();
let shell = match &remote_client {
Some(remote_client) => remote_client
.read(cx)
.shell()
.unwrap_or_else(get_default_system_shell),
None => match &settings.shell {
Shell::Program(program) => program.clone(),
Shell::WithArguments {
program,
args: _,
title_override: _,
} => program.clone(),
Shell::System => get_system_shell(),
},
};
let toolchain = project_path_context
.filter(|_| detect_venv)
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
let lang_registry = self.languages.clone();
let fs = self.fs.clone();
cx.spawn(async move |project, cx| {
let activation_script = maybe!(async {
let toolchain = toolchain?.await?;
lang_registry
.language_for_name(&toolchain.language_name.0)
.await
.ok()?
.toolchain_lister()?
.activation_script(&toolchain, fs.as_ref())
.await
})
.await;
project.update(cx, move |this, cx| {
let shell = {
env.extend(spawn_task.env);
match remote_client {
Some(remote_client) => create_remote_shell(
spawn_task
.command
.as_ref()
.map(|command| (command, &spawn_task.args)),
&mut env,
path,
remote_client,
activation_script.clone(),
cx,
)?,
None => match activation_script.clone() {
Some(activation_script) => {
let to_run = if let Some(command) = spawn_task.command {
let command: Option<Cow<str>> = shlex::try_quote(&command).ok();
let args = spawn_task
.args
.iter()
.filter_map(|arg| shlex::try_quote(arg).ok());
command.into_iter().chain(args).join(" ")
} else {
format!("exec {shell} -l")
};
Shell::WithArguments {
program: get_default_system_shell(),
args: vec![
"-c".to_owned(),
format!("{activation_script}; {to_run}",),
],
title_override: None,
}
}
None => {
if let Some(program) = spawn_task.command {
Shell::WithArguments {
program,
args: spawn_task.args,
title_override: None,
}
} else {
Shell::System
}
}
},
}
};
TerminalBuilder::new(
local_path.map(|path| path.to_path_buf()),
task_state,
shell,
env,
settings.cursor_shape.unwrap_or_default(),
settings.alternate_scroll,
settings.max_scroll_history_lines,
is_via_remote,
cx.entity_id().as_u64(),
Some(completion_tx),
cx,
activation_script,
)
.map(|builder| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
this.terminals
.local_handles
.push(terminal_handle.downgrade());
let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
let handles = &mut project.terminals.local_handles;
if let Some(index) = handles
.iter()
.position(|terminal| terminal.entity_id() == id)
{
handles.remove(index);
cx.notify();
}
})
.detach();
terminal_handle
})
})?
})
}
pub fn create_terminal_shell(
&mut self,
cwd: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
let project_path_context = self
.active_entry()
.and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
.or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
.map(|worktree_id| ProjectPath {
worktree_id,
path: Arc::from(Path::new("")),
});
let path = cwd.map(|p| Arc::from(&*p));
let is_via_remote = self.remote_client.is_some();
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
path,
});
}
let settings = TerminalSettings::get(settings_location, cx).clone();
let detect_venv = settings.detect_venv.as_option().is_some();
// Start with the environment that we might have inherited from the Zed CLI.
let mut env = self
.environment
.read(cx)
.get_cli_environment()
.unwrap_or_default();
// Then extend it with the explicit env variables from the settings, so they take
// precedence.
env.extend(settings.env);
let local_path = if is_via_remote { None } else { path.clone() };
let toolchain = project_path_context
.filter(|_| detect_venv)
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
let remote_client = self.remote_client.clone();
let shell = match &remote_client {
Some(remote_client) => remote_client
.read(cx)
.shell()
.unwrap_or_else(get_default_system_shell),
None => match &settings.shell {
Shell::Program(program) => program.clone(),
Shell::WithArguments {
program,
args: _,
title_override: _,
} => program.clone(),
Shell::System => get_system_shell(),
},
};
let lang_registry = self.languages.clone();
let fs = self.fs.clone();
cx.spawn(async move |project, cx| {
let activation_script = maybe!(async {
let toolchain = toolchain?.await?;
let language = lang_registry
.language_for_name(&toolchain.language_name.0)
.await
.ok();
let lister = language?.toolchain_lister();
lister?.activation_script(&toolchain, fs.as_ref()).await
})
.await;
project.update(cx, move |this, cx| {
let shell = {
match remote_client {
Some(remote_client) => create_remote_shell(
None,
&mut env,
path,
remote_client,
activation_script.clone(),
cx,
)?,
None => match activation_script.clone() {
Some(activation_script) => Shell::WithArguments {
program: get_default_system_shell(),
args: vec![
"-c".to_owned(),
format!("{activation_script}; exec {shell} -l",),
],
title_override: Some(shell.into()),
},
None => settings.shell,
},
}
};
TerminalBuilder::new(
local_path.map(|path| path.to_path_buf()),
None,
shell,
env,
settings.cursor_shape.unwrap_or_default(),
settings.alternate_scroll,
settings.max_scroll_history_lines,
is_via_remote,
cx.entity_id().as_u64(),
None,
cx,
activation_script,
)
.map(|builder| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
this.terminals
.local_handles
.push(terminal_handle.downgrade());
let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
let handles = &mut project.terminals.local_handles;
if let Some(index) = handles
.iter()
.position(|terminal| terminal.entity_id() == id)
{
handles.remove(index);
cx.notify();
}
})
.detach();
terminal_handle
})
})?
})
}
pub fn clone_terminal(
&mut self,
terminal: &Entity<Terminal>,
cx: &mut Context<'_, Project>,
cwd: impl FnOnce() -> Option<PathBuf>,
) -> Result<Entity<Terminal>> {
terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
self.terminals
.local_handles
.push(terminal_handle.downgrade());
let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
let handles = &mut project.terminals.local_handles;
if let Some(index) = handles
.iter()
.position(|terminal| terminal.entity_id() == id)
{
handles.remove(index);
cx.notify();
let path: Option<Arc<Path>> = match &kind {
TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
TerminalKind::Task(spawn_task) => {
if let Some(cwd) = &spawn_task.cwd {
Some(Arc::from(cwd.as_ref()))
} else {
self.active_project_directory(cx)
}
})
.detach();
}
};
terminal_handle
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
path,
});
}
let venv = TerminalSettings::get(settings_location, cx)
.detect_venv
.clone();
cx.spawn(async move |project, cx| {
let python_venv_directory = if let Some(path) = path {
project
.update(cx, |this, cx| this.python_venv_directory(path, venv, cx))?
.await
} else {
None
};
project.update(cx, |project, cx| {
project.create_terminal_with_venv(kind, python_venv_directory, cx)
})?
})
}
@@ -437,15 +137,10 @@ impl Project {
match remote_client {
Some(remote_client) => {
let command_template = remote_client.read(cx).build_command(
Some(command),
&args,
&env,
None,
// todo
None,
None,
)?;
let command_template =
remote_client
.read(cx)
.build_command(Some(command), &args, &env, None, None)?;
let mut command = std::process::Command::new(command_template.program);
command.args(command_template.args);
command.envs(command_template.env);
@@ -463,6 +158,382 @@ impl Project {
}
}
pub fn create_terminal_with_venv(
&mut self,
kind: TerminalKind,
python_venv_directory: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Result<Entity<Terminal>> {
let is_via_remote = self.remote_client.is_some();
let path: Option<Arc<Path>> = match &kind {
TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
TerminalKind::Task(spawn_task) => {
if let Some(cwd) = &spawn_task.cwd {
if is_via_remote {
Some(Arc::from(cwd.as_ref()))
} else {
let cwd = cwd.to_string_lossy();
let tilde_substituted = shellexpand::tilde(&cwd);
Some(Arc::from(Path::new(tilde_substituted.as_ref())))
}
} else {
self.active_project_directory(cx)
}
}
};
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
path,
});
}
let settings = TerminalSettings::get(settings_location, cx).clone();
let (completion_tx, completion_rx) = bounded(1);
// Start with the environment that we might have inherited from the Zed CLI.
let mut env = self
.environment
.read(cx)
.get_cli_environment()
.unwrap_or_default();
// Then extend it with the explicit env variables from the settings, so they take
// precedence.
env.extend(settings.env);
let local_path = if is_via_remote { None } else { path.clone() };
let mut python_venv_activate_command = Task::ready(None);
let remote_client = self.remote_client.clone();
let spawn_task;
let shell;
match kind {
TerminalKind::Shell(_) => {
if let Some(python_venv_directory) = &python_venv_directory {
python_venv_activate_command = self.python_activate_command(
python_venv_directory,
&settings.detect_venv,
&settings.shell,
cx,
);
}
spawn_task = None;
shell = match remote_client {
Some(remote_client) => {
create_remote_shell(None, &mut env, path, remote_client, cx)?
}
None => settings.shell,
};
}
TerminalKind::Task(task) => {
env.extend(task.env);
if let Some(venv_path) = &python_venv_directory {
env.insert(
"VIRTUAL_ENV".to_string(),
venv_path.to_string_lossy().to_string(),
);
}
spawn_task = Some(TaskState {
id: task.id,
full_label: task.full_label,
label: task.label,
command_label: task.command_label,
hide: task.hide,
status: TaskStatus::Running,
show_summary: task.show_summary,
show_command: task.show_command,
show_rerun: task.show_rerun,
completion_rx,
});
shell = match remote_client {
Some(remote_client) => {
let path_style = remote_client.read(cx).path_style();
if let Some(venv_directory) = &python_venv_directory
&& let Ok(str) =
shlex::try_quote(venv_directory.to_string_lossy().as_ref())
{
let path =
RemotePathBuf::new(PathBuf::from(str.to_string()), path_style)
.to_string();
env.insert("PATH".into(), format!("{}:$PATH ", path));
}
create_remote_shell(
task.command.as_ref().map(|command| (command, &task.args)),
&mut env,
path,
remote_client,
cx,
)?
}
None => {
if let Some(venv_path) = &python_venv_directory {
add_environment_path(&mut env, &venv_path.join(PYTHON_VENV_BIN_DIR))
.log_err();
}
if let Some(program) = task.command {
Shell::WithArguments {
program,
args: task.args,
title_override: None,
}
} else {
Shell::System
}
}
};
}
};
TerminalBuilder::new(
local_path.map(|path| path.to_path_buf()),
python_venv_directory,
spawn_task,
shell,
env,
settings.cursor_shape.unwrap_or_default(),
settings.alternate_scroll,
settings.max_scroll_history_lines,
is_via_remote,
cx.entity_id().as_u64(),
completion_tx,
cx,
)
.map(|builder| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
self.terminals
.local_handles
.push(terminal_handle.downgrade());
let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
let handles = &mut project.terminals.local_handles;
if let Some(index) = handles
.iter()
.position(|terminal| terminal.entity_id() == id)
{
handles.remove(index);
cx.notify();
}
})
.detach();
self.activate_python_virtual_environment(
python_venv_activate_command,
&terminal_handle,
cx,
);
terminal_handle
})
}
fn python_venv_directory(
&self,
abs_path: Arc<Path>,
venv_settings: VenvSettings,
cx: &Context<Project>,
) -> Task<Option<PathBuf>> {
cx.spawn(async move |this, cx| {
if let Some((worktree, relative_path)) = this
.update(cx, |this, cx| this.find_worktree(&abs_path, cx))
.ok()?
{
let toolchain = this
.update(cx, |this, cx| {
this.active_toolchain(
ProjectPath {
worktree_id: worktree.read(cx).id(),
path: relative_path.into(),
},
LanguageName::new("Python"),
cx,
)
})
.ok()?
.await;
if let Some(toolchain) = toolchain {
let toolchain_path = Path::new(toolchain.path.as_ref());
return Some(toolchain_path.parent()?.parent()?.to_path_buf());
}
}
let venv_settings = venv_settings.as_option()?;
this.update(cx, move |this, cx| {
if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) {
return Some(path);
}
this.find_venv_on_filesystem(&abs_path, &venv_settings, cx)
})
.ok()
.flatten()
})
}
fn find_venv_in_worktree(
&self,
abs_path: &Path,
venv_settings: &terminal_settings::VenvSettingsContent,
cx: &App,
) -> Option<PathBuf> {
venv_settings
.directories
.iter()
.map(|name| abs_path.join(name))
.find(|venv_path| {
let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
self.find_worktree(&bin_path, cx)
.and_then(|(worktree, relative_path)| {
worktree.read(cx).entry_for_path(&relative_path)
})
.is_some_and(|entry| entry.is_dir())
})
}
fn find_venv_on_filesystem(
&self,
abs_path: &Path,
venv_settings: &terminal_settings::VenvSettingsContent,
cx: &App,
) -> Option<PathBuf> {
let (worktree, _) = self.find_worktree(abs_path, cx)?;
let fs = worktree.read(cx).as_local()?.fs();
venv_settings
.directories
.iter()
.map(|name| abs_path.join(name))
.find(|venv_path| {
let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
// One-time synchronous check is acceptable for terminal/task initialization
smol::block_on(fs.metadata(&bin_path))
.ok()
.flatten()
.is_some_and(|meta| meta.is_dir)
})
}
fn activate_script_kind(shell: Option<&str>) -> ActivateScript {
let shell_env = std::env::var("SHELL").ok();
let shell_path = shell.or_else(|| shell_env.as_deref());
let shell = std::path::Path::new(shell_path.unwrap_or(""))
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
match shell {
"fish" => ActivateScript::Fish,
"tcsh" => ActivateScript::Csh,
"nu" => ActivateScript::Nushell,
"powershell" | "pwsh" => ActivateScript::PowerShell,
_ => ActivateScript::Default,
}
}
fn python_activate_command(
&self,
venv_base_directory: &Path,
venv_settings: &VenvSettings,
shell: &Shell,
cx: &mut App,
) -> Task<Option<String>> {
let Some(venv_settings) = venv_settings.as_option() else {
return Task::ready(None);
};
let activate_keyword = match venv_settings.activate_script {
terminal_settings::ActivateScript::Default => match std::env::consts::OS {
"windows" => ".",
_ => ".",
},
terminal_settings::ActivateScript::Nushell => "overlay use",
terminal_settings::ActivateScript::PowerShell => ".",
terminal_settings::ActivateScript::Pyenv => "pyenv",
_ => "source",
};
let script_kind =
if venv_settings.activate_script == terminal_settings::ActivateScript::Default {
match shell {
Shell::Program(program) => Self::activate_script_kind(Some(program)),
Shell::WithArguments {
program,
args: _,
title_override: _,
} => Self::activate_script_kind(Some(program)),
Shell::System => Self::activate_script_kind(None),
}
} else {
venv_settings.activate_script
};
let activate_script_name = match script_kind {
terminal_settings::ActivateScript::Default
| terminal_settings::ActivateScript::Pyenv => "activate",
terminal_settings::ActivateScript::Csh => "activate.csh",
terminal_settings::ActivateScript::Fish => "activate.fish",
terminal_settings::ActivateScript::Nushell => "activate.nu",
terminal_settings::ActivateScript::PowerShell => "activate.ps1",
};
let line_ending = match std::env::consts::OS {
"windows" => "\r",
_ => "\n",
};
if venv_settings.venv_name.is_empty() {
let path = venv_base_directory
.join(PYTHON_VENV_BIN_DIR)
.join(activate_script_name)
.to_string_lossy()
.to_string();
let is_valid_path = self.resolve_abs_path(path.as_ref(), cx);
cx.background_spawn(async move {
let quoted = shlex::try_quote(&path).ok()?;
if is_valid_path.await.is_some_and(|meta| meta.is_file()) {
Some(format!(
"{} {} ; clear{}",
activate_keyword, quoted, line_ending
))
} else {
None
}
})
} else {
Task::ready(Some(format!(
"{activate_keyword} {activate_script_name} {name}; clear{line_ending}",
name = venv_settings.venv_name
)))
}
}
fn activate_python_virtual_environment(
&self,
command: Task<Option<String>>,
terminal_handle: &Entity<Terminal>,
cx: &mut App,
) {
terminal_handle.update(cx, |_, cx| {
cx.spawn(async move |this, cx| {
if let Some(command) = command.await {
this.update(cx, |this, _| {
this.input(command.into_bytes());
})
.ok();
}
})
.detach()
});
}
pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
&self.terminals.local_handles
}
@@ -473,7 +544,6 @@ fn create_remote_shell(
env: &mut HashMap<String, String>,
working_directory: Option<Arc<Path>>,
remote_client: Entity<RemoteClient>,
activation_script: Option<String>,
cx: &mut App,
) -> Result<Shell> {
// Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
@@ -493,7 +563,6 @@ fn create_remote_shell(
args.as_slice(),
env,
working_directory.map(|path| path.display().to_string()),
activation_script,
None,
)?;
*env = command.env;
@@ -507,3 +576,57 @@ fn create_remote_shell(
title_override: Some(format!("{} — Terminal", host).into()),
})
}
fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> Result<()> {
let mut env_paths = vec![new_path.to_path_buf()];
if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
env_paths.append(&mut paths);
}
let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use collections::HashMap;
#[test]
fn test_add_environment_path_with_existing_path() {
let tmp_path = std::path::PathBuf::from("/tmp/new");
let mut env = HashMap::default();
let old_path = if cfg!(windows) {
"/usr/bin;/usr/local/bin"
} else {
"/usr/bin:/usr/local/bin"
};
env.insert("PATH".to_string(), old_path.to_string());
env.insert("OTHER".to_string(), "aaa".to_string());
super::add_environment_path(&mut env, &tmp_path).unwrap();
if cfg!(windows) {
assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
} else {
assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
}
assert_eq!(env.get("OTHER").unwrap(), "aaa");
}
#[test]
fn test_add_environment_path_with_empty_path() {
let tmp_path = std::path::PathBuf::from("/tmp/new");
let mut env = HashMap::default();
env.insert("OTHER".to_string(), "aaa".to_string());
let os_path = std::env::var("PATH").unwrap();
super::add_environment_path(&mut env, &tmp_path).unwrap();
if cfg!(windows) {
assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
} else {
assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
}
assert_eq!(env.get("OTHER").unwrap(), "aaa");
}
}

View File

@@ -1,5 +1,4 @@
fn main() {
println!("cargo:rerun-if-changed=proto");
let mut build = prost_build::Config::new();
build
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")

View File

@@ -610,36 +610,11 @@ message ServerMetadataUpdated {
message LanguageServerLog {
uint64 project_id = 1;
uint64 language_server_id = 2;
string message = 3;
oneof log_type {
LogMessage log = 4;
TraceMessage trace = 5;
RpcMessage rpc = 6;
}
}
message LogMessage {
LogLevel level = 1;
enum LogLevel {
LOG = 0;
INFO = 1;
WARNING = 2;
ERROR = 3;
}
}
message TraceMessage {
optional string verbose_info = 1;
}
message RpcMessage {
Kind kind = 1;
enum Kind {
RECEIVED = 0;
SENT = 1;
uint32 log_message_type = 3;
LspLogTrace log_trace = 4;
}
string message = 5;
}
message LspLogTrace {
@@ -957,16 +932,3 @@ message MultiLspQuery {
message MultiLspQueryResponse {
repeated LspResponse responses = 1;
}
message ToggleLspLogs {
uint64 project_id = 1;
LogType log_type = 2;
uint64 server_id = 3;
bool enabled = 4;
enum LogType {
LOG = 0;
TRACE = 1;
RPC = 2;
}
}

View File

@@ -396,8 +396,7 @@ message Envelope {
GitCloneResponse git_clone_response = 364;
LspQuery lsp_query = 365;
LspQueryResponse lsp_query_response = 366;
ToggleLspLogs toggle_lsp_logs = 367; // current max
LspQueryResponse lsp_query_response = 366; // current max
}
reserved 87 to 88;

View File

@@ -312,8 +312,7 @@ messages!(
(GetDefaultBranch, Background),
(GetDefaultBranchResponse, Background),
(GitClone, Background),
(GitCloneResponse, Background),
(ToggleLspLogs, Background),
(GitCloneResponse, Background)
);
request_messages!(
@@ -482,8 +481,7 @@ request_messages!(
(GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
(PullWorkspaceDiagnostics, Ack),
(GetDefaultBranch, GetDefaultBranchResponse),
(GitClone, GitCloneResponse),
(ToggleLspLogs, Ack),
(GitClone, GitCloneResponse)
);
lsp_messages!(
@@ -614,7 +612,6 @@ entity_messages!(
GitReset,
GitCheckoutFiles,
SetIndexText,
ToggleLspLogs,
Push,
Fetch,

View File

@@ -757,7 +757,6 @@ impl RemoteClient {
args: &[String],
env: &HashMap<String, String>,
working_dir: Option<String>,
activation_script: Option<String>,
port_forward: Option<(u16, String, u16)>,
) -> Result<CommandTemplate> {
let Some(connection) = self
@@ -767,14 +766,7 @@ impl RemoteClient {
else {
return Err(anyhow!("no connection"));
};
connection.build_command(
program,
args,
env,
working_dir,
activation_script,
port_forward,
)
connection.build_command(program, args, env, working_dir, port_forward)
}
pub fn upload_directory(
@@ -1006,7 +998,6 @@ pub(crate) trait RemoteConnection: Send + Sync {
args: &[String],
env: &HashMap<String, String>,
working_dir: Option<String>,
activation_script: Option<String>,
port_forward: Option<(u16, String, u16)>,
) -> Result<CommandTemplate>;
fn connection_options(&self) -> SshConnectionOptions;
@@ -1373,7 +1364,6 @@ mod fake {
args: &[String],
env: &HashMap<String, String>,
_: Option<String>,
_: Option<String>,
_: Option<(u16, String, u16)>,
) -> Result<CommandTemplate> {
let ssh_program = program.unwrap_or_else(|| "sh".to_string());

View File

@@ -30,10 +30,7 @@ use std::{
time::Instant,
};
use tempfile::TempDir;
use util::{
get_default_system_shell,
paths::{PathStyle, RemotePathBuf},
};
use util::paths::{PathStyle, RemotePathBuf};
pub(crate) struct SshRemoteConnection {
socket: SshSocket,
@@ -116,7 +113,6 @@ impl RemoteConnection for SshRemoteConnection {
input_args: &[String],
input_env: &HashMap<String, String>,
working_dir: Option<String>,
activation_script: Option<String>,
port_forward: Option<(u16, String, u16)>,
) -> Result<CommandTemplate> {
use std::fmt::Write as _;
@@ -138,9 +134,6 @@ impl RemoteConnection for SshRemoteConnection {
} else {
write!(&mut script, "cd; ").unwrap();
};
if let Some(activation_script) = activation_script {
write!(&mut script, " {activation_script};").unwrap();
}
for (k, v) in input_env.iter() {
if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
@@ -162,8 +155,7 @@ impl RemoteConnection for SshRemoteConnection {
write!(&mut script, "exec {shell} -l").unwrap();
};
let sys_shell = get_default_system_shell();
let shell_invocation = format!("{sys_shell} -c {}", shlex::try_quote(&script).unwrap());
let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&script).unwrap());
let mut args = Vec::new();
args.extend(self.socket.ssh_args());
@@ -175,6 +167,7 @@ impl RemoteConnection for SshRemoteConnection {
args.push("-t".into());
args.push(shell_invocation);
Ok(CommandTemplate {
program: "ssh".into(),
args,

View File

@@ -1,6 +1,5 @@
use ::proto::{FromProto, ToProto};
use anyhow::{Context as _, Result, anyhow};
use lsp::LanguageServerId;
use extension::ExtensionHostProxy;
use extension_host::headless_host::HeadlessExtensionStore;
@@ -15,7 +14,6 @@ use project::{
buffer_store::{BufferStore, BufferStoreEvent},
debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
git_store::GitStore,
lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind},
project_settings::SettingsObserver,
search::SearchQuery,
task_store::TaskStore,
@@ -67,7 +65,6 @@ impl HeadlessProject {
settings::init(cx);
language::init(cx);
project::Project::init_settings(cx);
log_store::init(false, cx);
}
pub fn new(
@@ -238,7 +235,6 @@ impl HeadlessProject {
session.add_entity_request_handler(Self::handle_open_new_buffer);
session.add_entity_request_handler(Self::handle_find_search_candidates);
session.add_entity_request_handler(Self::handle_open_server_settings);
session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
session.add_entity_request_handler(BufferStore::handle_update_buffer);
session.add_entity_message_handler(BufferStore::handle_close_buffer);
@@ -302,40 +298,11 @@ impl HeadlessProject {
fn on_lsp_store_event(
&mut self,
lsp_store: Entity<LspStore>,
_lsp_store: Entity<LspStore>,
event: &LspStoreEvent,
cx: &mut Context<Self>,
) {
match event {
LspStoreEvent::LanguageServerAdded(id, name, worktree_id) => {
let log_store = cx
.try_global::<GlobalLogStore>()
.map(|lsp_logs| lsp_logs.0.clone());
if let Some(log_store) = log_store {
log_store.update(cx, |log_store, cx| {
log_store.add_language_server(
LanguageServerKind::LocalSsh {
lsp_store: self.lsp_store.downgrade(),
},
*id,
Some(name.clone()),
*worktree_id,
lsp_store.read(cx).language_server_for_id(*id),
cx,
);
});
}
}
LspStoreEvent::LanguageServerRemoved(id) => {
let log_store = cx
.try_global::<GlobalLogStore>()
.map(|lsp_logs| lsp_logs.0.clone());
if let Some(log_store) = log_store {
log_store.update(cx, |log_store, cx| {
log_store.remove_language_server(*id, cx);
});
}
}
LspStoreEvent::LanguageServerUpdate {
language_server_id,
name,
@@ -359,6 +326,16 @@ impl HeadlessProject {
})
.log_err();
}
LspStoreEvent::LanguageServerLog(language_server_id, log_type, message) => {
self.session
.send(proto::LanguageServerLog {
project_id: REMOTE_SERVER_PROJECT_ID,
language_server_id: language_server_id.to_proto(),
message: message.clone(),
log_type: Some(log_type.to_proto()),
})
.log_err();
}
LspStoreEvent::LanguageServerPrompt(prompt) => {
let request = self.session.request(proto::LanguageServerPromptRequest {
project_id: REMOTE_SERVER_PROJECT_ID,
@@ -532,31 +509,7 @@ impl HeadlessProject {
})
}
async fn handle_toggle_lsp_logs(
_: Entity<Self>,
envelope: TypedEnvelope<proto::ToggleLspLogs>,
mut cx: AsyncApp,
) -> Result<()> {
let server_id = LanguageServerId::from_proto(envelope.payload.server_id);
let lsp_logs = cx
.update(|cx| {
cx.try_global::<GlobalLogStore>()
.map(|lsp_logs| lsp_logs.0.clone())
})?
.context("lsp logs store is missing")?;
lsp_logs.update(&mut cx, |lsp_logs, _| {
// we do not support any other log toggling yet
if envelope.payload.enabled {
lsp_logs.enable_rpc_trace_for_language_server(server_id);
} else {
lsp_logs.disable_rpc_trace_for_language_server(server_id);
}
})?;
Ok(())
}
async fn handle_open_server_settings(
pub async fn handle_open_server_settings(
this: Entity<Self>,
_: TypedEnvelope<proto::OpenServerSettings>,
mut cx: AsyncApp,
@@ -609,7 +562,7 @@ impl HeadlessProject {
})
}
async fn handle_find_search_candidates(
pub async fn handle_find_search_candidates(
this: Entity<Self>,
envelope: TypedEnvelope<proto::FindSearchCandidates>,
mut cx: AsyncApp,
@@ -641,7 +594,7 @@ impl HeadlessProject {
Ok(response)
}
async fn handle_list_remote_directory(
pub async fn handle_list_remote_directory(
this: Entity<Self>,
envelope: TypedEnvelope<proto::ListRemoteDirectory>,
cx: AsyncApp,
@@ -673,7 +626,7 @@ impl HeadlessProject {
})
}
async fn handle_get_path_metadata(
pub async fn handle_get_path_metadata(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GetPathMetadata>,
cx: AsyncApp,
@@ -691,7 +644,7 @@ impl HeadlessProject {
})
}
async fn handle_shutdown_remote_server(
pub async fn handle_shutdown_remote_server(
_this: Entity<Self>,
_envelope: TypedEnvelope<proto::ShutdownRemoteServer>,
cx: AsyncApp,

View File

@@ -30,7 +30,7 @@ pub struct ActiveSettingsProfileName(pub String);
impl Global for ActiveSettingsProfileName {}
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord, serde::Serialize)]
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub struct WorktreeId(usize);
impl From<WorktreeId> for usize {

View File

@@ -20,7 +20,7 @@ use gpui::{
};
use language::{Language, LanguageConfig, ToOffset as _};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{CompletionDisplayOptions, Project};
use project::Project;
use settings::{BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets};
use ui::{
ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, Indicator,
@@ -2927,7 +2927,6 @@ impl CompletionProvider for KeyContextCompletionProvider {
confirm: None,
})
.collect(),
display_options: CompletionDisplayOptions::default(),
is_incomplete: false,
}]))
}

View File

@@ -268,7 +268,7 @@ impl TabMatch {
.flatten();
let colored_icon = icon.color(git_status_color.unwrap_or_default());
let most_severe_diagnostic_level = if show_diagnostics == ShowDiagnostics::Off {
let most_sever_diagostic_level = if show_diagnostics == ShowDiagnostics::Off {
None
} else {
let buffer_store = project.read(cx).buffer_store().read(cx);
@@ -287,7 +287,7 @@ impl TabMatch {
};
let decorations =
entry_diagnostic_aware_icon_decoration_and_color(most_severe_diagnostic_level)
entry_diagnostic_aware_icon_decoration_and_color(most_sever_diagostic_level)
.filter(|(d, _)| {
*d != IconDecorationKind::Triangle
|| show_diagnostics != ShowDiagnostics::Errors

View File

@@ -1,7 +1,3 @@
use std::fmt;
use util::get_system_shell;
use crate::Shell;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
@@ -15,22 +11,9 @@ pub enum ShellKind {
Cmd,
}
impl fmt::Display for ShellKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ShellKind::Posix => write!(f, "sh"),
ShellKind::Csh => write!(f, "csh"),
ShellKind::Fish => write!(f, "fish"),
ShellKind::Powershell => write!(f, "powershell"),
ShellKind::Nushell => write!(f, "nu"),
ShellKind::Cmd => write!(f, "cmd"),
}
}
}
impl ShellKind {
pub fn system() -> Self {
Self::new(&get_system_shell())
Self::new(&system_shell())
}
pub fn new(program: &str) -> Self {
@@ -39,12 +22,12 @@ impl ShellKind {
#[cfg(not(windows))]
let (_, program) = program.rsplit_once('/').unwrap_or(("", program));
if program == "powershell"
|| program.ends_with("powershell.exe")
|| program == "powershell.exe"
|| program == "pwsh"
|| program.ends_with("pwsh.exe")
|| program == "pwsh.exe"
{
ShellKind::Powershell
} else if program == "cmd" || program.ends_with("cmd.exe") {
} else if program == "cmd" || program == "cmd.exe" {
ShellKind::Cmd
} else if program == "nu" {
ShellKind::Nushell
@@ -195,6 +178,18 @@ impl ShellKind {
}
}
fn system_shell() -> String {
if cfg!(target_os = "windows") {
// `alacritty_terminal` uses this as default on Windows. See:
// https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130
// We could use `util::get_windows_system_shell()` here, but we are running tasks here, so leave it to `powershell.exe`
// should be okay.
"powershell.exe".to_string()
} else {
std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
}
}
/// ShellBuilder is used to turn a user-requested task into a
/// program that can be executed by the shell.
pub struct ShellBuilder {
@@ -211,7 +206,7 @@ impl ShellBuilder {
let (program, args) = match remote_system_shell {
Some(program) => (program.to_string(), Vec::new()),
None => match shell {
Shell::System => (get_system_shell(), Vec::new()),
Shell::System => (system_shell(), Vec::new()),
Shell::Program(shell) => (shell.clone(), Vec::new()),
Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
},

View File

@@ -344,6 +344,7 @@ pub struct TerminalBuilder {
impl TerminalBuilder {
pub fn new(
working_directory: Option<PathBuf>,
python_venv_directory: Option<PathBuf>,
task: Option<TaskState>,
shell: Shell,
mut env: HashMap<String, String>,
@@ -352,9 +353,8 @@ impl TerminalBuilder {
max_scroll_history_lines: Option<usize>,
is_ssh_terminal: bool,
window_id: u64,
completion_tx: Option<Sender<Option<ExitStatus>>>,
completion_tx: Sender<Option<ExitStatus>>,
cx: &App,
activation_script: Option<String>,
) -> Result<TerminalBuilder> {
// If the parent environment doesn't have a locale set
// (As is the case when launched from a .app on MacOS),
@@ -428,10 +428,13 @@ impl TerminalBuilder {
.clone()
.or_else(|| Some(home_dir().to_path_buf())),
drain_on_exit: true,
env: env.clone().into_iter().collect(),
env: env.into_iter().collect(),
}
};
// Setup Alacritty's env, which modifies the current process's environment
alacritty_terminal::tty::setup_env();
let default_cursor_style = AlacCursorStyle::from(cursor_shape);
let scrolling_history = if task.is_some() {
// Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling.
@@ -514,19 +517,11 @@ impl TerminalBuilder {
hyperlink_regex_searches: RegexSearches::new(),
vi_mode_enabled: false,
is_ssh_terminal,
python_venv_directory,
last_mouse_move_time: Instant::now(),
last_hyperlink_search_position: None,
#[cfg(windows)]
shell_program,
activation_script,
template: CopyTemplate {
shell,
env,
cursor_shape,
alternate_scroll,
max_scroll_history_lines,
window_id,
},
};
Ok(TerminalBuilder {
@@ -688,7 +683,7 @@ pub enum SelectionPhase {
pub struct Terminal {
pty_tx: Notifier,
completion_tx: Option<Sender<Option<ExitStatus>>>,
completion_tx: Sender<Option<ExitStatus>>,
term: Arc<FairMutex<Term<ZedListener>>>,
term_config: Config,
events: VecDeque<InternalEvent>,
@@ -700,6 +695,7 @@ pub struct Terminal {
pub breadcrumb_text: String,
pub pty_info: PtyProcessInfo,
title_override: Option<SharedString>,
pub python_venv_directory: Option<PathBuf>,
scroll_px: Pixels,
next_link_id: usize,
selection_phase: SelectionPhase,
@@ -711,17 +707,6 @@ pub struct Terminal {
last_hyperlink_search_position: Option<Point<Pixels>>,
#[cfg(windows)]
shell_program: Option<String>,
template: CopyTemplate,
activation_script: Option<String>,
}
struct CopyTemplate {
shell: Shell,
env: HashMap<String, String>,
cursor_shape: CursorShape,
alternate_scroll: AlternateScroll,
max_scroll_history_lines: Option<usize>,
window_id: u64,
}
pub struct TaskState {
@@ -1910,9 +1895,7 @@ impl Terminal {
}
});
if let Some(tx) = &self.completion_tx {
tx.try_send(e).ok();
}
self.completion_tx.try_send(e).ok();
let task = match &mut self.task {
Some(task) => task,
None => {
@@ -1967,28 +1950,6 @@ impl Terminal {
pub fn vi_mode_enabled(&self) -> bool {
self.vi_mode_enabled
}
pub fn clone_builder(
&self,
cx: &App,
cwd: impl FnOnce() -> Option<PathBuf>,
) -> Result<TerminalBuilder> {
let working_directory = self.working_directory().or_else(cwd);
TerminalBuilder::new(
working_directory,
None,
self.template.shell.clone(),
self.template.env.clone(),
self.template.cursor_shape,
self.template.alternate_scroll,
self.template.max_scroll_history_lines,
self.is_ssh_terminal,
self.template.window_id,
None,
cx,
self.activation_script.clone(),
)
}
}
// Helper function to convert a grid row to a string
@@ -2203,6 +2164,7 @@ mod tests {
let (completion_tx, completion_rx) = smol::channel::unbounded();
let terminal = cx.new(|cx| {
TerminalBuilder::new(
None,
None,
None,
task::Shell::WithArguments {
@@ -2216,9 +2178,8 @@ mod tests {
None,
false,
0,
Some(completion_tx),
completion_tx,
cx,
None,
)
.unwrap()
.subscribe(cx)

View File

@@ -3,9 +3,9 @@ use async_recursion::async_recursion;
use collections::HashSet;
use futures::{StreamExt as _, stream::FuturesUnordered};
use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity};
use project::Project;
use project::{Project, terminals::TerminalKind};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use ui::{App, Context, Pixels, Window};
use util::ResultExt as _;
@@ -246,9 +246,11 @@ async fn deserialize_pane_group(
.update(cx, |workspace, cx| default_working_directory(workspace, cx))
.ok()
.flatten();
let terminal = project.update(cx, |project, cx| {
project.create_terminal_shell(working_directory, cx)
});
let kind = TerminalKind::Shell(
working_directory.as_deref().map(Path::to_path_buf),
);
let terminal =
project.update(cx, |project, cx| project.create_terminal(kind, cx));
Some(Some(terminal))
} else {
Some(None)

View File

@@ -16,7 +16,7 @@ use gpui::{
Task, WeakEntity, Window, actions,
};
use itertools::Itertools;
use project::{Fs, Project, ProjectEntryId};
use project::{Fs, Project, ProjectEntryId, terminals::TerminalKind};
use search::{BufferSearchBar, buffer_search::DivRegistrar};
use settings::Settings;
use task::{RevealStrategy, RevealTarget, ShellBuilder, SpawnInTerminal, TaskId};
@@ -376,19 +376,14 @@ impl TerminalPanel {
}
self.serialize(cx);
}
&pane::Event::Split(direction) => {
let fut = self.new_pane_with_cloned_active_terminal(window, cx);
pane::Event::Split(direction) => {
let Some(new_pane) = self.new_pane_with_cloned_active_terminal(window, cx) else {
return;
};
let pane = pane.clone();
cx.spawn_in(window, async move |panel, cx| {
let Some(new_pane) = fut.await else {
return;
};
_ = panel.update_in(cx, |panel, window, cx| {
panel.center.split(&pane, &new_pane, direction).log_err();
window.focus(&new_pane.focus_handle(cx));
});
})
.detach();
let direction = *direction;
self.center.split(&pane, &new_pane, direction).log_err();
window.focus(&new_pane.focus_handle(cx));
}
pane::Event::Focus => {
self.active_pane = pane.clone();
@@ -405,62 +400,57 @@ impl TerminalPanel {
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Pane>>> {
let Some(workspace) = self.workspace.upgrade() else {
return Task::ready(None);
};
) -> Option<Entity<Pane>> {
let workspace = self.workspace.upgrade()?;
let workspace = workspace.read(cx);
let database_id = workspace.database_id();
let weak_workspace = self.workspace.clone();
let project = workspace.project().clone();
let active_pane = &self.active_pane;
let terminal_view = active_pane
let (working_directory, python_venv_directory) = self
.active_pane
.read(cx)
.active_item()
.and_then(|item| item.downcast::<TerminalView>());
let working_directory = terminal_view.as_ref().and_then(|terminal_view| {
let terminal = terminal_view.read(cx).terminal().read(cx);
terminal
.working_directory()
.or_else(|| default_working_directory(workspace, cx))
});
let is_zoomed = active_pane.read(cx).is_zoomed();
cx.spawn_in(window, async move |panel, cx| {
let terminal = project
.update(cx, |project, cx| match terminal_view {
Some(view) => Task::ready(project.clone_terminal(
&view.read(cx).terminal.clone(),
cx,
|| working_directory,
)),
None => project.create_terminal_shell(working_directory, cx),
})
.ok()?
.await
.ok()?;
.and_then(|item| item.downcast::<TerminalView>())
.map(|terminal_view| {
let terminal = terminal_view.read(cx).terminal().read(cx);
(
terminal
.working_directory()
.or_else(|| default_working_directory(workspace, cx)),
terminal.python_venv_directory.clone(),
)
})
.unwrap_or((None, None));
let kind = TerminalKind::Shell(working_directory);
let terminal = project
.update(cx, |project, cx| {
project.create_terminal_with_venv(kind, python_venv_directory, cx)
})
.ok()?;
panel
.update_in(cx, move |terminal_panel, window, cx| {
let terminal_view = Box::new(cx.new(|cx| {
TerminalView::new(
terminal.clone(),
weak_workspace.clone(),
database_id,
project.downgrade(),
window,
cx,
)
}));
let pane = new_terminal_pane(weak_workspace, project, is_zoomed, window, cx);
terminal_panel.apply_tab_bar_buttons(&pane, cx);
pane.update(cx, |pane, cx| {
pane.add_item(terminal_view, true, true, None, window, cx);
});
Some(pane)
})
.ok()
.flatten()
})
let terminal_view = Box::new(cx.new(|cx| {
TerminalView::new(
terminal.clone(),
weak_workspace.clone(),
database_id,
project.downgrade(),
window,
cx,
)
}));
let pane = new_terminal_pane(
weak_workspace,
project,
self.active_pane.read(cx).is_zoomed(),
window,
cx,
);
self.apply_tab_bar_buttons(&pane, cx);
pane.update(cx, |pane, cx| {
pane.add_item(terminal_view, true, true, None, window, cx);
});
Some(pane)
}
pub fn open_terminal(
@@ -475,8 +465,8 @@ impl TerminalPanel {
terminal_panel
.update(cx, |panel, cx| {
panel.add_terminal_shell(
Some(action.working_directory.clone()),
panel.add_terminal(
TerminalKind::Shell(Some(action.working_directory.clone())),
RevealStrategy::Always,
window,
cx,
@@ -485,7 +475,7 @@ impl TerminalPanel {
.detach_and_log_err(cx);
}
pub fn spawn_task(
fn spawn_task(
&mut self,
task: &SpawnInTerminal,
window: &mut Window,
@@ -581,16 +571,15 @@ impl TerminalPanel {
) -> Task<Result<WeakEntity<Terminal>>> {
let reveal = spawn_task.reveal;
let reveal_target = spawn_task.reveal_target;
let kind = TerminalKind::Task(spawn_task);
match reveal_target {
RevealTarget::Center => self
.workspace
.update(cx, |workspace, cx| {
Self::add_center_terminal(workspace, window, cx, |project, cx| {
project.create_terminal_task(spawn_task, cx)
})
Self::add_center_terminal(workspace, kind, window, cx)
})
.unwrap_or_else(|e| Task::ready(Err(e))),
RevealTarget::Dock => self.add_terminal_task(spawn_task, reveal, window, cx),
RevealTarget::Dock => self.add_terminal(kind, reveal, window, cx),
}
}
@@ -605,14 +594,11 @@ impl TerminalPanel {
return;
};
let kind = TerminalKind::Shell(default_working_directory(workspace, cx));
terminal_panel
.update(cx, |this, cx| {
this.add_terminal_shell(
default_working_directory(workspace, cx),
RevealStrategy::Always,
window,
cx,
)
this.add_terminal(kind, RevealStrategy::Always, window, cx)
})
.detach_and_log_err(cx);
}
@@ -674,13 +660,9 @@ impl TerminalPanel {
pub fn add_center_terminal(
workspace: &mut Workspace,
kind: TerminalKind,
window: &mut Window,
cx: &mut Context<Workspace>,
create_terminal: impl FnOnce(
&mut Project,
&mut Context<Project>,
) -> Task<Result<Entity<Terminal>>>
+ 'static,
) -> Task<Result<WeakEntity<Terminal>>> {
if !is_enabled_in_workspace(workspace, cx) {
return Task::ready(Err(anyhow!(
@@ -689,7 +671,9 @@ impl TerminalPanel {
}
let project = workspace.project().downgrade();
cx.spawn_in(window, async move |workspace, cx| {
let terminal = project.update(cx, create_terminal)?.await?;
let terminal = project
.update(cx, |project, cx| project.create_terminal(kind, cx))?
.await?;
workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = cx.new(|cx| {
@@ -708,9 +692,9 @@ impl TerminalPanel {
})
}
pub fn add_terminal_task(
pub fn add_terminal(
&mut self,
task: SpawnInTerminal,
kind: TerminalKind,
reveal_strategy: RevealStrategy,
window: &mut Window,
cx: &mut Context<Self>,
@@ -726,66 +710,7 @@ impl TerminalPanel {
})?;
let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?;
let terminal = project
.update(cx, |project, cx| project.create_terminal_task(task, cx))?
.await?;
let result = workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = Box::new(cx.new(|cx| {
TerminalView::new(
terminal.clone(),
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
window,
cx,
)
}));
match reveal_strategy {
RevealStrategy::Always => {
workspace.focus_panel::<Self>(window, cx);
}
RevealStrategy::NoFocus => {
workspace.open_panel::<Self>(window, cx);
}
RevealStrategy::Never => {}
}
pane.update(cx, |pane, cx| {
let focus = pane.has_focus(window, cx)
|| matches!(reveal_strategy, RevealStrategy::Always);
pane.add_item(terminal_view, true, focus, None, window, cx);
});
Ok(terminal.downgrade())
})?;
terminal_panel.update(cx, |terminal_panel, cx| {
terminal_panel.pending_terminals_to_add =
terminal_panel.pending_terminals_to_add.saturating_sub(1);
terminal_panel.serialize(cx)
})?;
result
})
}
pub fn add_terminal_shell(
&mut self,
cwd: Option<PathBuf>,
reveal_strategy: RevealStrategy,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |terminal_panel, cx| {
if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? {
anyhow::bail!("terminal not yet supported for remote projects");
}
let pane = terminal_panel.update(cx, |terminal_panel, _| {
terminal_panel.pending_terminals_to_add += 1;
terminal_panel.active_pane.clone()
})?;
let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(cwd, cx))?
.update(cx, |project, cx| project.create_terminal(kind, cx))?
.await?;
let result = workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = Box::new(cx.new(|cx| {
@@ -894,7 +819,7 @@ impl TerminalPanel {
})??;
let new_terminal = project
.update(cx, |project, cx| {
project.create_terminal_task(spawn_task, cx)
project.create_terminal(TerminalKind::Task(spawn_task), cx)
})?
.await?;
terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| {
@@ -1323,29 +1248,18 @@ impl Render for TerminalPanel {
let panes = terminal_panel.center.panes();
if let Some(&pane) = panes.get(action.0) {
window.focus(&pane.read(cx).focus_handle(cx));
} else {
let future =
terminal_panel.new_pane_with_cloned_active_terminal(window, cx);
cx.spawn_in(window, async move |terminal_panel, cx| {
if let Some(new_pane) = future.await {
_ = terminal_panel.update_in(
cx,
|terminal_panel, window, cx| {
terminal_panel
.center
.split(
&terminal_panel.active_pane,
&new_pane,
SplitDirection::Right,
)
.log_err();
let new_pane = new_pane.read(cx);
window.focus(&new_pane.focus_handle(cx));
},
);
}
})
.detach();
} else if let Some(new_pane) =
terminal_panel.new_pane_with_cloned_active_terminal(window, cx)
{
terminal_panel
.center
.split(
&terminal_panel.active_pane,
&new_pane,
SplitDirection::Right,
)
.log_err();
window.focus(&new_pane.focus_handle(cx));
}
}),
)
@@ -1481,14 +1395,13 @@ impl Panel for TerminalPanel {
return;
}
cx.defer_in(window, |this, window, cx| {
let Ok(kind) = this
.workspace
.update(cx, |workspace, cx| default_working_directory(workspace, cx))
else {
let Ok(kind) = this.workspace.update(cx, |workspace, cx| {
TerminalKind::Shell(default_working_directory(workspace, cx))
}) else {
return;
};
this.add_terminal_shell(kind, RevealStrategy::Always, window, cx)
this.add_terminal(kind, RevealStrategy::Always, window, cx)
.detach_and_log_err(cx)
})
}

View File

@@ -364,7 +364,7 @@ fn possibly_open_target(
mod tests {
use super::*;
use gpui::TestAppContext;
use project::Project;
use project::{Project, terminals::TerminalKind};
use serde_json::json;
use std::path::{Path, PathBuf};
use terminal::{HoveredWord, alacritty_terminal::index::Point as AlacPoint};
@@ -405,8 +405,8 @@ mod tests {
app_cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let terminal = project
.update(cx, |project: &mut Project, cx| {
project.create_terminal_shell(None, cx)
.update(cx, |project, cx| {
project.create_terminal(TerminalKind::Shell(None), cx)
})
.await
.expect("Failed to create a terminal");

Some files were not shown because too many files have changed in this diff Show More