Compare commits

...

70 Commits
sentry ... mcp2

Author SHA1 Message Date
Agus Zubiaga
9da17dd894 move old acp support to its own module
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-07-24 13:14:16 -03:00
Agus Zubiaga
4605b80b44 Remove deps 2025-07-24 13:06:39 -03:00
Agus Zubiaga
715e96de6f Remove dead code
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-07-24 13:02:43 -03:00
Agus Zubiaga
0f4e64026b Remove todo
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-07-24 12:59:56 -03:00
Agus Zubiaga
561a5f1f4f Fix padding
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com> Co-authored-by: Richard
Feldman <oss@rtfeldman.com>

Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-07-24 12:50:14 -03:00
Agus Zubiaga
ac3f8f7fa1 Return all diffs
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-07-24 12:48:26 -03:00
Agus Zubiaga
153ffb2e6b Tests passing
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-07-24 12:37:06 -03:00
Agus Zubiaga
cdfe91ce9e Move process creation to ClaudeConnection::new_thread
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-07-24 11:37:55 -03:00
Ben Brandt
bfa6a2290e Merge branch 'main' into mcp2 2025-07-24 12:48:21 +02:00
Ben Brandt
56ca10dccd Fix authenticate not working without a thread 2025-07-24 12:38:50 +02:00
Ben Brandt
17684703da Move mcp_server back to Claude 2025-07-24 12:14:50 +02:00
Ben Brandt
755cff59a1 Put name on connection as well for Thread Title 2025-07-24 12:06:05 +02:00
Ben Brandt
44cae51ea3 Make new_thread nicer with RC receiver 2025-07-24 11:51:19 +02:00
Ben Brandt
45452a81d4 Cleanup unused fields 2025-07-24 11:41:20 +02:00
Ben Brandt
c03585359a Remove dead code 2025-07-24 11:01:49 +02:00
Ben Brandt
e9a9f6dace Remove unused code 2025-07-24 10:53:54 +02:00
Finn Evers
dd52fb58fe terminal_view: Ensure breadcrumbs are updated on settings change (#35016)
Currently, terminal breadcrumbs are only updated after a settings change
once the terminal view is focused again. This change ensures that the
breadcrumbs are updated instantaneously once the breadcrumb settings
changes.

Release Notes:

- Fixed an issue where terminal breadcrumbs would not react instantly to
settings changes.
2025-07-24 08:51:40 +00:00
Joseph T. Lyons
913b9296d7 Add editor: convert to sentence case (#35015)
This PR adds an `editor: convert to sentence case` action.

I frequently find myself copying branch names and then removing the
hyphens and ensuring the first letter is capitalized, and then using the
result text for the commit message.

For example:

<img width="927" height="482" alt="image"
src="https://github.com/user-attachments/assets/adf14a37-a92e-44df-8c0e-267b5c7677fb"
/>

You can achieve this with a combination of other text manipulation
commands, but this action makes it even easier.

Also, moved `toggle_case` down into the area where all other commands
internally using `manipulate_text` are located.

Release Notes:

- Added `editor: convert to sentence case`
2025-07-24 08:49:04 +00:00
Joseph T. Lyons
5c9363b1c4 Differentiate between file and selection diff events (#35014)
Release Notes:

- N/A
2025-07-24 08:43:28 +00:00
Finn Evers
cd9bcc7f09 agent_ui: Improve wrapping behavior in provider configuration header (#34989)
This ensures that the "Add provider" button does not move offscreen too
fast and ensures the text wraps for smaller panel sizes.

| Before | After |
| --- | --- |
| <img width="413" height="84" alt="image"
src="https://github.com/user-attachments/assets/565f7503-bddb-4b05-83c1-8f8745ac3ce3"
/> | <img width="392" height="84" alt="image"
src="https://github.com/user-attachments/assets/18469e4d-d94c-4641-9081-1af8981bfffd"
/> |

Release Notes:

- N/A
2025-07-24 10:40:36 +02:00
Ben Brandt
0915e4b34b Fix clippy warnings 2025-07-24 10:40:05 +02:00
Jason Lee
65759d4316 gpui: Fix Interactivity prepaint to update scroll_handle bounds (#35013)
It took a long time to check this problem.

Finally, I found that due to a detail missing when changing #34832, the
bounds of `ScrollHandle` was not updated in the Interactivity `prepaint`
phase.

```diff
- scroll_handle_state.padded_content_size = padded_content_size;
+ scroll_handle_state.max_offset = scroll_max;
```

It was correct before the change, because the `padded_content_size`
(including `bounds.size`) was saved before, and the bounds was missing
after changing to `max_offset`, but the bounds were not updated
anywhere.

So when `scroll_handle.bounds()` is obtained outside, it is always 0px
here.

@MrSubidubi

Release Notes:

- N/A
2025-07-24 08:27:29 +00:00
Joseph T. Lyons
ddd50aabba Fix some bugs with editor: diff clipboard with selection (#34999)
Improves testing around `editor: diff clipboard with selection` as well.

Release Notes:

- Fixed some bugs with `editor: diff clipboard with selection`
2025-07-24 02:52:02 -04:00
Conrad Irwin
34bf6ebba6 Disable auto-close in search (#35005)
Currently if you type `\(`, it auto-closes to `\()` which is broken.

It's arguably nice that if you type `(` it auto-closes to `()`, but I am
much more likely to be looking for a function call `name\(` than to be
starting a group in search.

Release Notes:

- search: Regex search will no longer try to close parenthesis
automatically.
2025-07-24 05:45:01 +00:00
Pablo Ramón Guevara
a6956eebcb Improve Helix insert (#34765)
Closes #34763 

Release Notes:

- Improved insert in `helix_mode` when a selection exists to better
match helix's behavior: collapse selection to avoid replacing it
- Improved append (`insert_after`) to better match helix's behavior:
move cursor to end of selection if it exists
2025-07-23 23:27:07 -06:00
AidanV
8b0ec287a5 vim: Add :norm support (#33232)
Closes #21198

Release Notes:

- Adds support for `:norm`
- Allows for vim and zed style modified keys specified in issue
  - Vim style <C-w> and zed style <ctrl-w>
- Differs from vim in how multi-line is handled 
  - vim is sequential
  - zed is combinational (with multi-cursor)
2025-07-23 23:06:05 -06:00
versecafe
c08851a85e ollama: Add Magistral to Ollama (#35000)
See also: #34983

Release Notes:

- Added magistral support to ollama
2025-07-24 00:17:54 -04:00
Agus Zubiaga
8f768439b2 Compiling! 2025-07-24 01:03:37 -03:00
Peter Tripp
b93e1c736b mistral: Add support for magistral-small and magistral-medium (#34983)
Release Notes:

- mistral: Added support for magistral-small and magistral-medium
2025-07-23 23:13:49 -04:00
Agus Zubiaga
2ffc027c26 Fix all agent_servers errors 2025-07-23 23:26:31 -03:00
Agus Zubiaga
158867f42c Moar fixes 2025-07-23 21:25:04 -03:00
Piotr Osiewicz
67027bb241 agent: Fix Zed header in settings view (#34993)
Follow-up to taffy bump (#34939), fixes an issue reported by @MrSubidubi


Release Notes:

- N/A
2025-07-24 00:13:47 +00:00
Smit Barmase
31afda3c0c project_panel: Automatically open project panel when Rename or Duplicate is triggered from workspace (#34988)
In project panel, `rename` and `duplicate` action further needs user
input for editing, so if panel is closed we should open it.

Release Notes:

- Fixed project panel not opening when `project panel: rename` and
`project panel: duplicate` actions are triggered from workspace.
2025-07-24 05:26:12 +05:30
Agus Zubiaga
172d206e02 Replace codex impl with todo! 2025-07-23 20:32:20 -03:00
Marshall Bowers
3d4266bb8f collab: Remove POST /billing/subscriptions/manage endpoint (#34986)
This PR removes the `POST /billing/subscriptions/manage` endpoint, as it
has been moved to `cloud.zed.dev`.

Release Notes:

- N/A
2025-07-23 23:30:00 +00:00
Agus Zubiaga
7f20caf208 Moar progress 2025-07-23 20:11:38 -03:00
Maksim Bondarenkov
4a87397d37 livekit_client: Revert a change that broke MinGW builds (#34977)
the change was made in https://github.com/zed-industries/zed/pull/34223
for unknown reason. it wasn't required actually, and the code can be
safely left as before

update: after this revert Zed compiles with MinGW as before

Release Notes:

- N/A
2025-07-24 01:53:13 +03:00
Piotr Osiewicz
3da23cc65b Re-land taffy 0.8.3 (#34939)
Re #34938
- **chore: Bump taffy to 0.8.3**
- **editor: Fix sticky multi-buffer header not extending to the full
width**


Release Notes:

- N/A
2025-07-24 00:33:43 +02:00
Smit Barmase
b63d820be2 editor: Fix move line up panic when selection is at end of line next to fold marker (#34982)
Closes #34826

In move line up method, make use of `prev_line_boundary` which accounts
for fold map, etc., for selection start row so that we don't incorrectly
calculate row range to move up.

Release Notes:

- Fixed an issue where `editor: move line up` action sometimes crashed
if the cursor was at the end of a line beside a fold marker.
2025-07-24 03:46:29 +05:30
Renato Lochetti
7e9d6cc25c mistral: Add support for Mistral Devstral Medium (#34888)
Mistral released their new DevstralMedium model to be used via API:
https://mistral.ai/news/devstral-2507

Release Notes:

- Add support for Mistral Devstral Medium
2025-07-23 17:27:25 -04:00
Danilo Leal
8bf7dcb613 agent: Fix follow button disabled state (#34978)
Release Notes:

- N/A
2025-07-23 17:09:05 -04:00
Peter Tripp
edceb7284f Redact secrets from environment in LSP Server Info (#34971)
In "Server Info" view of LSP logs:
- Redacts sensitive values from environment
- Sorts environment by name

| Before | After | 
| - | - | 
| <img width="797" height="327" alt="Screenshot 2025-07-23 at 14 10 14"
src="https://github.com/user-attachments/assets/75781f30-9099-4994-9824-94d9c46f63e1"
/> | <img width="972" height="571" alt="image"
src="https://github.com/user-attachments/assets/c5bef744-a1b7-415f-9eb7-8314275c59b9"
/> |


Release Notes:

- Improved display of environment variables in LSP Logs: Server Info
view
2025-07-23 16:55:13 -04:00
Joseph T. Lyons
50985b7d23 Fix telemetry event type names (#34974)
Release Notes:

- N/A
2025-07-23 20:30:21 +00:00
Agus Zubiaga
2d222f7752 Fix tool call confirmation 2025-07-23 16:17:33 -03:00
Conrad Irwin
1724bb2084 more wip 2025-07-23 10:59:09 -06:00
Agus Zubiaga
8affe205bd More progress 2025-07-23 12:17:02 -03:00
Ben Brandt
ebf94e20c9 wip
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-07-23 16:21:20 +02:00
Ben Brandt
edbcc71b87 Merge branch 'main' into mcp2 2025-07-23 14:36:06 +02:00
Ben Brandt
c478cdf30e Use acp_old everywhere 2025-07-23 14:33:26 +02:00
Ben Brandt
af6ab9d4a6 Update plan + read/write tools 2025-07-23 13:29:31 +02:00
Ben Brandt
eff09b0384 Remove ToolCallConfirmation 2025-07-23 13:22:51 +02:00
Ben Brandt
ce5614cc76 Reject pending tool call confirmations 2025-07-23 13:21:33 +02:00
Ben Brandt
cea8e61fd9 Push request and insert further 2025-07-23 13:11:54 +02:00
Ben Brandt
c3e4f3800d Convert new toolcallcontent 2025-07-23 12:31:54 +02:00
Conrad Irwin
4cd5cceae8 MOAR 2025-07-23 00:12:09 -06:00
Conrad Irwin
c4d920ff5b WIPWIPWIPW 2025-07-22 19:01:29 -06:00
Conrad Irwin
966d29dcd9 WIp 2025-07-22 13:49:28 -06:00
Conrad Irwin
cede9d757a Eliciting better 2025-07-22 13:10:06 -06:00
Conrad Irwin
c0c698b883 WIP elciication
t
2025-07-22 12:29:27 -06:00
Ben Brandt
0fa7d58a3e Pass Zed MCP Server as command args 2025-07-22 15:11:48 +02:00
Ben Brandt
9b91445967 Use pathbuf in McpServerConfig 2025-07-22 15:06:39 +02:00
Ben Brandt
480adade63 Add correct codex path and setup e2e tests (not running yet) 2025-07-22 14:13:05 +02:00
Ben Brandt
47dec0df99 Add local command for codex 2025-07-22 13:56:55 +02:00
Ben Brandt
f20edf1b50 Remove unneeded todo 2025-07-22 12:20:52 +02:00
Ben Brandt
03b94f5831 Merge branch 'main' into mcp-codex 2025-07-22 12:20:13 +02:00
Agus Zubiaga
e7298c0736 Display tool calls 2025-07-21 20:23:27 -03:00
Agus Zubiaga
a822711e99 Stop generation 2025-07-21 19:46:14 -03:00
Agus Zubiaga
4b1ace9a54 Handle Agent Reasoning 2025-07-21 18:29:13 -03:00
Agus Zubiaga
769d6dc632 Stream text 2025-07-21 18:26:28 -03:00
Agus Zubiaga
f56910556f Connect to Codex MCP server 2025-07-21 15:19:50 -03:00
48 changed files with 2800 additions and 2233 deletions

22
Cargo.lock generated
View File

@@ -6,6 +6,7 @@ version = 4
name = "acp_thread"
version = "0.1.0"
dependencies = [
"agent-client-protocol",
"agentic-coding-protocol",
"anyhow",
"assistant_tool",
@@ -135,11 +136,21 @@ dependencies = [
"zstd",
]
[[package]]
name = "agent-client-protocol"
version = "0.0.10"
dependencies = [
"schemars",
"serde",
"serde_json",
]
[[package]]
name = "agent_servers"
version = "0.1.0"
dependencies = [
"acp_thread",
"agent-client-protocol",
"agentic-coding-protocol",
"anyhow",
"collections",
@@ -195,9 +206,9 @@ version = "0.1.0"
dependencies = [
"acp_thread",
"agent",
"agent-client-protocol",
"agent_servers",
"agent_settings",
"agentic-coding-protocol",
"ai_onboarding",
"anyhow",
"assistant_context",
@@ -7402,9 +7413,9 @@ dependencies = [
[[package]]
name = "grid"
version = "0.14.0"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82"
checksum = "71b01d27060ad58be4663b9e4ac9e2d4806918e8876af8912afbddd1a91d5eaa"
[[package]]
name = "group"
@@ -15961,13 +15972,12 @@ dependencies = [
[[package]]
name = "taffy"
version = "0.5.1"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61630cba2afd2c851821add2e1bb1b7851a2436e839ab73b56558b009035e"
checksum = "7aaef0ac998e6527d6d0d5582f7e43953bb17221ac75bb8eb2fcc2db3396db1c"
dependencies = [
"arrayvec",
"grid",
"num-traits",
"serde",
"slotmap",
]

View File

@@ -413,6 +413,7 @@ zlog_settings = { path = "crates/zlog_settings" }
#
agentic-coding-protocol = "0.0.10"
agent-client-protocol = { path = "../agent-client-protocol" }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"

View File

@@ -220,6 +220,8 @@
{
"context": "vim_mode == normal",
"bindings": {
"i": "vim::InsertBefore",
"a": "vim::InsertAfter",
"ctrl-[": "editor::Cancel",
":": "command_palette::Toggle",
"c": "vim::PushChange",
@@ -353,9 +355,7 @@
"shift-d": "vim::DeleteToEndOfLine",
"shift-j": "vim::JoinLines",
"shift-y": "vim::YankLine",
"i": "vim::InsertBefore",
"shift-i": "vim::InsertFirstNonWhitespace",
"a": "vim::InsertAfter",
"shift-a": "vim::InsertEndOfLine",
"o": "vim::InsertLineBelow",
"shift-o": "vim::InsertLineAbove",
@@ -377,6 +377,8 @@
{
"context": "vim_mode == helix_normal && !menu",
"bindings": {
"i": "vim::HelixInsert",
"a": "vim::HelixAppend",
"ctrl-[": "editor::Cancel",
";": "vim::HelixCollapseSelection",
":": "command_palette::Toggle",

View File

@@ -16,6 +16,7 @@ doctest = false
test-support = ["gpui/test-support", "project/test-support"]
[dependencies]
agent-client-protocol.workspace = true
agentic-coding-protocol.workspace = true
anyhow.workspace = true
assistant_tool.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,26 @@
use agentic_coding_protocol as acp;
use std::{path::Path, rc::Rc};
use agent_client_protocol as acp;
use anyhow::Result;
use futures::future::{FutureExt as _, LocalBoxFuture};
use gpui::{AsyncApp, Entity, Task};
use project::Project;
use ui::App;
use crate::AcpThread;
pub trait AgentConnection {
fn request_any(
&self,
params: acp::AnyAgentRequest,
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>>;
}
fn name(&self) -> &'static str;
impl AgentConnection for acp::AgentConnection {
fn request_any(
&self,
params: acp::AnyAgentRequest,
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
let task = self.request_any(params);
async move { Ok(task.await?) }.boxed_local()
}
fn new_thread(
self: Rc<Self>,
project: Entity<Project>,
cwd: &Path,
cx: &mut AsyncApp,
) -> Task<Result<Entity<AcpThread>>>;
fn authenticate(&self, cx: &mut App) -> Task<Result<()>>;
fn prompt(&self, params: acp::PromptToolArguments, cx: &mut App) -> Task<Result<()>>;
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
}

View File

@@ -0,0 +1,461 @@
///! Translates old acp agents into the new schema
use agent_client_protocol as acp;
use agentic_coding_protocol::{self as acp_old, AgentRequest as _};
use anyhow::{Context as _, Result};
use futures::channel::oneshot;
use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use project::Project;
use std::{cell::RefCell, error::Error, fmt, path::Path, rc::Rc};
use ui::App;
use crate::{AcpThread, AcpThreadEvent, AgentConnection, ToolCallContent, ToolCallStatus};
#[derive(Clone)]
pub struct OldAcpClientDelegate {
thread: Rc<RefCell<WeakEntity<AcpThread>>>,
cx: AsyncApp,
next_tool_call_id: Rc<RefCell<u64>>,
// sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
}
impl OldAcpClientDelegate {
pub fn new(thread: Rc<RefCell<WeakEntity<AcpThread>>>, cx: AsyncApp) -> Self {
Self {
thread,
cx,
next_tool_call_id: Rc::new(RefCell::new(0)),
}
}
}
impl acp_old::Client for OldAcpClientDelegate {
async fn stream_assistant_message_chunk(
&self,
params: acp_old::StreamAssistantMessageChunkParams,
) -> Result<(), acp_old::Error> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread
.borrow()
.update(cx, |thread, cx| match params.chunk {
acp_old::AssistantMessageChunk::Text { text } => {
thread.push_assistant_chunk(text.into(), false, cx)
}
acp_old::AssistantMessageChunk::Thought { thought } => {
thread.push_assistant_chunk(thought.into(), true, cx)
}
})
.ok();
})?;
Ok(())
}
async fn request_tool_call_confirmation(
&self,
request: acp_old::RequestToolCallConfirmationParams,
) -> Result<acp_old::RequestToolCallConfirmationResponse, acp_old::Error> {
let cx = &mut self.cx.clone();
let old_acp_id = *self.next_tool_call_id.borrow() + 1;
self.next_tool_call_id.replace(old_acp_id);
let tool_call = into_new_tool_call(
acp::ToolCallId(old_acp_id.to_string().into()),
request.tool_call,
);
let mut options = match request.confirmation {
acp_old::ToolCallConfirmation::Edit { .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
"Always Allow Edits".to_string(),
)],
acp_old::ToolCallConfirmation::Execute { root_command, .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
format!("Always Allow {}", root_command),
)],
acp_old::ToolCallConfirmation::Mcp {
server_name,
tool_name,
..
} => vec![
(
acp_old::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
acp::PermissionOptionKind::AllowAlways,
format!("Always Allow {}", server_name),
),
(
acp_old::ToolCallConfirmationOutcome::AlwaysAllowTool,
acp::PermissionOptionKind::AllowAlways,
format!("Always Allow {}", tool_name),
),
],
acp_old::ToolCallConfirmation::Fetch { .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
"Always Allow".to_string(),
)],
acp_old::ToolCallConfirmation::Other { .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
"Always Allow".to_string(),
)],
};
options.extend([
(
acp_old::ToolCallConfirmationOutcome::Allow,
acp::PermissionOptionKind::AllowOnce,
"Allow".to_string(),
),
(
acp_old::ToolCallConfirmationOutcome::Reject,
acp::PermissionOptionKind::RejectOnce,
"Reject".to_string(),
),
]);
let mut outcomes = Vec::with_capacity(options.len());
let mut acp_options = Vec::with_capacity(options.len());
for (index, (outcome, kind, label)) in options.into_iter().enumerate() {
outcomes.push(outcome);
acp_options.push(acp::PermissionOption {
id: acp::PermissionOptionId(index.to_string().into()),
label,
kind,
})
}
let response = cx
.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.request_tool_call_permission(tool_call, acp_options, cx)
})
})?
.context("Failed to update thread")?
.await;
let outcome = match response {
Ok(option_id) => outcomes[option_id.0.parse::<usize>().unwrap_or(0)],
Err(oneshot::Canceled) => acp_old::ToolCallConfirmationOutcome::Cancel,
};
Ok(acp_old::RequestToolCallConfirmationResponse {
id: acp_old::ToolCallId(old_acp_id),
outcome: outcome,
})
}
async fn push_tool_call(
&self,
request: acp_old::PushToolCallParams,
) -> Result<acp_old::PushToolCallResponse, acp_old::Error> {
let cx = &mut self.cx.clone();
let old_acp_id = *self.next_tool_call_id.borrow() + 1;
self.next_tool_call_id.replace(old_acp_id);
cx.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.upsert_tool_call(
into_new_tool_call(acp::ToolCallId(old_acp_id.to_string().into()), request),
cx,
)
})
})?
.context("Failed to update thread")?;
Ok(acp_old::PushToolCallResponse {
id: acp_old::ToolCallId(old_acp_id),
})
}
async fn update_tool_call(
&self,
request: acp_old::UpdateToolCallParams,
) -> Result<(), acp_old::Error> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
let languages = thread.project.read(cx).languages().clone();
if let Some((ix, tool_call)) = thread
.tool_call_mut(&acp::ToolCallId(request.tool_call_id.0.to_string().into()))
{
tool_call.status = ToolCallStatus::Allowed {
status: into_new_tool_call_status(request.status),
};
tool_call.content = request
.content
.into_iter()
.map(|content| {
ToolCallContent::from_acp(
into_new_tool_call_content(content),
languages.clone(),
cx,
)
})
.collect();
cx.emit(AcpThreadEvent::EntryUpdated(ix));
anyhow::Ok(())
} else {
anyhow::bail!("Tool call not found")
}
})
})?
.context("Failed to update thread")??;
Ok(())
}
async fn update_plan(&self, request: acp_old::UpdatePlanParams) -> Result<(), acp_old::Error> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.update_plan(
acp::Plan {
entries: request
.entries
.into_iter()
.map(into_new_plan_entry)
.collect(),
},
cx,
)
})
})?
.context("Failed to update thread")?;
Ok(())
}
async fn read_text_file(
&self,
acp_old::ReadTextFileParams { path, line, limit }: acp_old::ReadTextFileParams,
) -> Result<acp_old::ReadTextFileResponse, acp_old::Error> {
let content = self
.cx
.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.read_text_file(path, line, limit, false, cx)
})
})?
.context("Failed to update thread")?
.await?;
Ok(acp_old::ReadTextFileResponse { content })
}
async fn write_text_file(
&self,
acp_old::WriteTextFileParams { path, content }: acp_old::WriteTextFileParams,
) -> Result<(), acp_old::Error> {
self.cx
.update(|cx| {
self.thread
.borrow()
.update(cx, |thread, cx| thread.write_text_file(path, content, cx))
})?
.context("Failed to update thread")?
.await?;
Ok(())
}
}
fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) -> acp::ToolCall {
acp::ToolCall {
id: id,
label: request.label,
kind: acp_kind_from_old_icon(request.icon),
status: acp::ToolCallStatus::InProgress,
content: request
.content
.into_iter()
.map(into_new_tool_call_content)
.collect(),
locations: request
.locations
.into_iter()
.map(into_new_tool_call_location)
.collect(),
}
}
fn acp_kind_from_old_icon(icon: acp_old::Icon) -> acp::ToolKind {
match icon {
acp_old::Icon::FileSearch => acp::ToolKind::Search,
acp_old::Icon::Folder => acp::ToolKind::Search,
acp_old::Icon::Globe => acp::ToolKind::Search,
acp_old::Icon::Hammer => acp::ToolKind::Other,
acp_old::Icon::LightBulb => acp::ToolKind::Think,
acp_old::Icon::Pencil => acp::ToolKind::Edit,
acp_old::Icon::Regex => acp::ToolKind::Search,
acp_old::Icon::Terminal => acp::ToolKind::Execute,
}
}
fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallStatus {
match status {
acp_old::ToolCallStatus::Running => acp::ToolCallStatus::InProgress,
acp_old::ToolCallStatus::Finished => acp::ToolCallStatus::Completed,
acp_old::ToolCallStatus::Error => acp::ToolCallStatus::Failed,
}
}
fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent {
match content {
acp_old::ToolCallContent::Markdown { markdown } => acp::ToolCallContent::ContentBlock {
content: acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: markdown,
}),
},
acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff {
diff: into_new_diff(diff),
},
}
}
fn into_new_diff(diff: acp_old::Diff) -> acp::Diff {
acp::Diff {
path: diff.path,
old_text: diff.old_text,
new_text: diff.new_text,
}
}
fn into_new_tool_call_location(location: acp_old::ToolCallLocation) -> acp::ToolCallLocation {
acp::ToolCallLocation {
path: location.path,
line: location.line,
}
}
fn into_new_plan_entry(entry: acp_old::PlanEntry) -> acp::PlanEntry {
acp::PlanEntry {
content: entry.content,
priority: into_new_plan_priority(entry.priority),
status: into_new_plan_status(entry.status),
}
}
fn into_new_plan_priority(priority: acp_old::PlanEntryPriority) -> acp::PlanEntryPriority {
match priority {
acp_old::PlanEntryPriority::Low => acp::PlanEntryPriority::Low,
acp_old::PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium,
acp_old::PlanEntryPriority::High => acp::PlanEntryPriority::High,
}
}
fn into_new_plan_status(status: acp_old::PlanEntryStatus) -> acp::PlanEntryStatus {
match status {
acp_old::PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending,
acp_old::PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress,
acp_old::PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed,
}
}
#[derive(Debug)]
pub struct Unauthenticated;
impl Error for Unauthenticated {}
impl fmt::Display for Unauthenticated {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Unauthenticated")
}
}
pub struct OldAcpAgentConnection {
pub name: &'static str,
pub connection: acp_old::AgentConnection,
pub child_status: Task<Result<()>>,
}
impl AgentConnection for OldAcpAgentConnection {
fn name(&self) -> &'static str {
self.name
}
fn new_thread(
self: Rc<Self>,
project: Entity<Project>,
_cwd: &Path,
cx: &mut AsyncApp,
) -> Task<Result<Entity<AcpThread>>> {
let task = self.connection.request_any(
acp_old::InitializeParams {
protocol_version: acp_old::ProtocolVersion::latest(),
}
.into_any(),
);
cx.spawn(async move |cx| {
let result = task.await?;
let result = acp_old::InitializeParams::response_from_any(result)?;
if !result.is_authenticated {
anyhow::bail!(Unauthenticated)
}
cx.update(|cx| {
let thread = cx.new(|cx| {
let session_id = acp::SessionId("acp-old-no-id".into());
AcpThread::new(self.clone(), project, session_id, cx)
});
thread
})
})
}
fn authenticate(&self, cx: &mut App) -> Task<Result<()>> {
let task = self
.connection
.request_any(acp_old::AuthenticateParams.into_any());
cx.foreground_executor().spawn(async move {
task.await?;
Ok(())
})
}
fn prompt(&self, params: acp::PromptToolArguments, cx: &mut App) -> Task<Result<()>> {
let chunks = params
.prompt
.into_iter()
.filter_map(|block| match block {
acp::ContentBlock::Text(text) => {
Some(acp_old::UserMessageChunk::Text { text: text.text })
}
acp::ContentBlock::ResourceLink(link) => Some(acp_old::UserMessageChunk::Path {
path: link.uri.into(),
}),
_ => None,
})
.collect();
let task = self
.connection
.request_any(acp_old::SendUserMessageParams { chunks }.into_any());
cx.foreground_executor().spawn(async move {
task.await?;
anyhow::Ok(())
})
}
fn cancel(&self, _session_id: &acp::SessionId, cx: &mut App) {
let task = self
.connection
.request_any(acp_old::CancelSendMessageParams.into_any());
cx.foreground_executor()
.spawn(async move {
task.await?;
anyhow::Ok(())
})
.detach_and_log_err(cx)
}
}

View File

@@ -18,6 +18,7 @@ doctest = false
[dependencies]
acp_thread.workspace = true
agent-client-protocol.workspace = true
agentic-coding-protocol.workspace = true
anyhow.workspace = true
collections.workspace = true

View File

@@ -1,7 +1,6 @@
mod claude;
mod gemini;
mod settings;
mod stdio_agent_server;
#[cfg(test)]
mod e2e_tests;
@@ -9,9 +8,8 @@ mod e2e_tests;
pub use claude::*;
pub use gemini::*;
pub use settings::*;
pub use stdio_agent_server::*;
use acp_thread::AcpThread;
use acp_thread::AgentConnection;
use anyhow::Result;
use collections::HashMap;
use gpui::{App, AsyncApp, Entity, SharedString, Task};
@@ -20,6 +18,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use util::ResultExt as _;
@@ -33,14 +32,14 @@ pub trait AgentServer: Send {
fn name(&self) -> &'static str;
fn empty_state_headline(&self) -> &'static str;
fn empty_state_message(&self) -> &'static str;
fn supports_always_allow(&self) -> bool;
fn new_thread(
fn connect(
&self,
// these will go away when old_acp is fully removed
root_dir: &Path,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>>;
) -> Task<Result<Rc<dyn AgentConnection>>>;
}
impl std::fmt::Debug for AgentServerCommand {

View File

@@ -1,5 +1,5 @@
mod mcp_server;
mod tools;
pub mod tools;
use collections::HashMap;
use project::Project;
@@ -12,28 +12,24 @@ use std::pin::pin;
use std::rc::Rc;
use uuid::Uuid;
use agentic_coding_protocol::{
self as acp, AnyAgentRequest, AnyAgentResult, Client, ProtocolVersion,
StreamAssistantMessageChunkParams, ToolCallContent, UpdateToolCallParams,
};
use agent_client_protocol as acp;
use anyhow::{Result, anyhow};
use futures::channel::oneshot;
use futures::future::LocalBoxFuture;
use futures::{AsyncBufReadExt, AsyncWriteExt, SinkExt};
use futures::{AsyncBufReadExt, AsyncWriteExt};
use futures::{
AsyncRead, AsyncWrite, FutureExt, StreamExt,
channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
io::BufReader,
select_biased,
};
use gpui::{App, AppContext, Entity, Task};
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
use serde::{Deserialize, Serialize};
use util::ResultExt;
use crate::claude::mcp_server::ClaudeMcpServer;
use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
use crate::claude::tools::ClaudeTool;
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
use acp_thread::{AcpClientDelegate, AcpThread, AgentConnection};
use acp_thread::{AcpThread, AgentConnection};
#[derive(Clone)]
pub struct ClaudeCode;
@@ -55,29 +51,57 @@ impl AgentServer for ClaudeCode {
ui::IconName::AiClaude
}
fn supports_always_allow(&self) -> bool {
false
fn connect(
&self,
_root_dir: &Path,
_project: &Entity<Project>,
_cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let connection = ClaudeAgentConnection {
sessions: Default::default(),
};
Task::ready(Ok(Rc::new(connection) as _))
}
}
#[cfg(unix)]
fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> {
let pid = nix::unistd::Pid::from_raw(pid);
nix::sys::signal::kill(pid, nix::sys::signal::SIGINT)
.map_err(|e| anyhow!("Failed to interrupt process: {}", e))
}
#[cfg(windows)]
fn send_interrupt(_pid: i32) -> anyhow::Result<()> {
panic!("Cancel not implemented on Windows")
}
struct ClaudeAgentConnection {
sessions: Rc<RefCell<HashMap<acp::SessionId, ClaudeAgentSession>>>,
}
impl AgentConnection for ClaudeAgentConnection {
fn name(&self) -> &'static str {
ClaudeCode.name()
}
fn new_thread(
&self,
root_dir: &Path,
project: &Entity<Project>,
cx: &mut App,
self: Rc<Self>,
project: Entity<Project>,
cwd: &Path,
cx: &mut AsyncApp,
) -> Task<Result<Entity<AcpThread>>> {
let project = project.clone();
let root_dir = root_dir.to_path_buf();
let title = self.name().into();
let cwd = cwd.to_owned();
cx.spawn(async move |cx| {
let (mut delegate_tx, delegate_rx) = watch::channel(None);
let tool_id_map = Rc::new(RefCell::new(HashMap::default()));
let mcp_server = ClaudeMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?;
let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), cx).await?;
let mut mcp_servers = HashMap::default();
mcp_servers.insert(
mcp_server::SERVER_NAME.to_string(),
mcp_server.server_config()?,
permission_mcp_server.server_config()?,
);
let mcp_config = McpConfig { mcp_servers };
@@ -104,177 +128,180 @@ impl AgentServer for ClaudeCode {
let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
let (cancel_tx, mut cancel_rx) = mpsc::unbounded::<oneshot::Sender<Result<()>>>();
let session_id = Uuid::new_v4();
let session_id = acp::SessionId(Uuid::new_v4().to_string().into());
log::trace!("Starting session with id: {}", session_id);
cx.background_spawn(async move {
let mut outgoing_rx = Some(outgoing_rx);
let mut mode = ClaudeSessionMode::Start;
cx.background_spawn({
let session_id = session_id.clone();
async move {
let mut outgoing_rx = Some(outgoing_rx);
let mut mode = ClaudeSessionMode::Start;
loop {
let mut child =
spawn_claude(&command, mode, session_id, &mcp_config_path, &root_dir)
.await?;
mode = ClaudeSessionMode::Resume;
let pid = child.id();
log::trace!("Spawned (pid: {})", pid);
let mut io_fut = pin!(
ClaudeAgentConnection::handle_io(
outgoing_rx.take().unwrap(),
incoming_message_tx.clone(),
child.stdin.take().unwrap(),
child.stdout.take().unwrap(),
loop {
let mut child = spawn_claude(
&command,
mode,
session_id.clone(),
&mcp_config_path,
&cwd,
)
.fuse()
);
.await?;
mode = ClaudeSessionMode::Resume;
select_biased! {
done_tx = cancel_rx.next() => {
if let Some(done_tx) = done_tx {
log::trace!("Interrupted (pid: {})", pid);
let result = send_interrupt(pid as i32);
outgoing_rx.replace(io_fut.await?);
done_tx.send(result).log_err();
continue;
let pid = child.id();
log::trace!("Spawned (pid: {})", pid);
let mut io_fut = pin!(
ClaudeAgentSession::handle_io(
outgoing_rx.take().unwrap(),
incoming_message_tx.clone(),
child.stdin.take().unwrap(),
child.stdout.take().unwrap(),
)
.fuse()
);
select_biased! {
done_tx = cancel_rx.next() => {
if let Some(done_tx) = done_tx {
log::trace!("Interrupted (pid: {})", pid);
let result = send_interrupt(pid as i32);
outgoing_rx.replace(io_fut.await?);
done_tx.send(result).log_err();
continue;
}
}
result = io_fut => {
result?;
}
}
result = io_fut => {
result?;
}
log::trace!("Stopped (pid: {})", pid);
break;
}
log::trace!("Stopped (pid: {})", pid);
break;
drop(mcp_config_path);
anyhow::Ok(())
}
drop(mcp_config_path);
anyhow::Ok(())
})
.detach();
cx.new(|cx| {
let end_turn_tx = Rc::new(RefCell::new(None));
let delegate = AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async());
delegate_tx.send(Some(delegate.clone())).log_err();
let handler_task = cx.foreground_executor().spawn({
let end_turn_tx = end_turn_tx.clone();
let tool_id_map = tool_id_map.clone();
let delegate = delegate.clone();
async move {
while let Some(message) = incoming_message_rx.next().await {
ClaudeAgentConnection::handle_message(
delegate.clone(),
message,
end_turn_tx.clone(),
tool_id_map.clone(),
)
.await
}
let end_turn_tx = Rc::new(RefCell::new(None));
let handler_task = cx.spawn({
let end_turn_tx = end_turn_tx.clone();
let thread_rx = thread_rx.clone();
async move |cx| {
while let Some(message) = incoming_message_rx.next().await {
ClaudeAgentSession::handle_message(
thread_rx.clone(),
message,
end_turn_tx.clone(),
cx,
)
.await
}
});
}
});
let mut connection = ClaudeAgentConnection {
delegate,
outgoing_tx,
end_turn_tx,
cancel_tx,
session_id,
_handler_task: handler_task,
_mcp_server: None,
};
let thread =
cx.new(|cx| AcpThread::new(self.clone(), project, session_id.clone(), cx))?;
connection._mcp_server = Some(mcp_server);
acp_thread::AcpThread::new(connection, title, None, project.clone(), cx)
})
thread_tx.send(thread.downgrade())?;
let session = ClaudeAgentSession {
outgoing_tx,
end_turn_tx,
cancel_tx,
_handler_task: handler_task,
_mcp_server: Some(permission_mcp_server),
};
self.sessions.borrow_mut().insert(session_id, session);
Ok(thread)
})
}
}
#[cfg(unix)]
fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> {
let pid = nix::unistd::Pid::from_raw(pid);
fn authenticate(&self, _cx: &mut App) -> Task<Result<()>> {
Task::ready(Err(anyhow!("Authentication not supported")))
}
nix::sys::signal::kill(pid, nix::sys::signal::SIGINT)
.map_err(|e| anyhow!("Failed to interrupt process: {}", e))
}
fn prompt(&self, params: acp::PromptToolArguments, cx: &mut App) -> Task<Result<()>> {
let sessions = self.sessions.borrow();
let Some(session) = sessions.get(&params.session_id) else {
return Task::ready(Err(anyhow!(
"Attempted to send message to nonexistent session {}",
params.session_id
)));
};
#[cfg(windows)]
fn send_interrupt(_pid: i32) -> anyhow::Result<()> {
panic!("Cancel not implemented on Windows")
}
let (tx, rx) = oneshot::channel();
session.end_turn_tx.borrow_mut().replace(tx);
impl AgentConnection for ClaudeAgentConnection {
/// Send a request to the agent and wait for a response.
fn request_any(
&self,
params: AnyAgentRequest,
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
let delegate = self.delegate.clone();
let end_turn_tx = self.end_turn_tx.clone();
let outgoing_tx = self.outgoing_tx.clone();
let mut cancel_tx = self.cancel_tx.clone();
let session_id = self.session_id;
async move {
match params {
// todo: consider sending an empty request so we get the init response?
AnyAgentRequest::InitializeParams(_) => Ok(AnyAgentResult::InitializeResponse(
acp::InitializeResponse {
is_authenticated: true,
protocol_version: ProtocolVersion::latest(),
},
)),
AnyAgentRequest::AuthenticateParams(_) => {
Err(anyhow!("Authentication not supported"))
let mut content = String::new();
for chunk in params.prompt {
match chunk {
acp::ContentBlock::Text(text_content) => {
content.push_str(&text_content.text);
}
AnyAgentRequest::SendUserMessageParams(message) => {
delegate.clear_completed_plan_entries().await?;
let (tx, rx) = oneshot::channel();
end_turn_tx.borrow_mut().replace(tx);
let mut content = String::new();
for chunk in message.chunks {
match chunk {
agentic_coding_protocol::UserMessageChunk::Text { text } => {
content.push_str(&text)
}
agentic_coding_protocol::UserMessageChunk::Path { path } => {
content.push_str(&format!("@{path:?}"))
}
}
}
outgoing_tx.unbounded_send(SdkMessage::User {
message: Message {
role: Role::User,
content: Content::UntaggedText(content),
id: None,
model: None,
stop_reason: None,
stop_sequence: None,
usage: None,
},
session_id: Some(session_id),
})?;
rx.await??;
Ok(AnyAgentResult::SendUserMessageResponse(
acp::SendUserMessageResponse,
))
acp::ContentBlock::ResourceLink(resource_link) => {
content.push_str(&format!("@{}", resource_link.uri));
}
AnyAgentRequest::CancelSendMessageParams(_) => {
let (done_tx, done_rx) = oneshot::channel();
cancel_tx.send(done_tx).await?;
done_rx.await??;
Ok(AnyAgentResult::CancelSendMessageResponse(
acp::CancelSendMessageResponse,
))
acp::ContentBlock::Audio(_)
| acp::ContentBlock::Image(_)
| acp::ContentBlock::Resource(_) => {
// TODO
}
}
}
.boxed_local()
if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User {
message: Message {
role: Role::User,
content: Content::UntaggedText(content),
id: None,
model: None,
stop_reason: None,
stop_sequence: None,
usage: None,
},
session_id: Some(params.session_id.to_string()),
}) {
return Task::ready(Err(anyhow!(err)));
}
cx.foreground_executor().spawn(async move {
rx.await??;
Ok(())
})
}
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
let sessions = self.sessions.borrow();
let Some(session) = sessions.get(&session_id) else {
log::warn!("Attempted to cancel nonexistent session {}", session_id);
return;
};
let (done_tx, done_rx) = oneshot::channel();
if session
.cancel_tx
.unbounded_send(done_tx)
.log_err()
.is_some()
{
let end_turn_tx = session.end_turn_tx.clone();
cx.foreground_executor()
.spawn(async move {
done_rx.await??;
if let Some(end_turn_tx) = end_turn_tx.take() {
end_turn_tx.send(Ok(())).ok();
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
}
}
@@ -287,7 +314,7 @@ enum ClaudeSessionMode {
async fn spawn_claude(
command: &AgentServerCommand,
mode: ClaudeSessionMode,
session_id: Uuid,
session_id: acp::SessionId,
mcp_config_path: &Path,
root_dir: &Path,
) -> Result<Child> {
@@ -327,88 +354,103 @@ async fn spawn_claude(
Ok(child)
}
struct ClaudeAgentConnection {
delegate: AcpClientDelegate,
session_id: Uuid,
struct ClaudeAgentSession {
outgoing_tx: UnboundedSender<SdkMessage>,
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
cancel_tx: UnboundedSender<oneshot::Sender<Result<()>>>,
_mcp_server: Option<ClaudeMcpServer>,
_mcp_server: Option<ClaudeZedMcpServer>,
_handler_task: Task<()>,
}
impl ClaudeAgentConnection {
impl ClaudeAgentSession {
async fn handle_message(
delegate: AcpClientDelegate,
mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
message: SdkMessage,
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
cx: &mut AsyncApp,
) {
match message {
SdkMessage::Assistant { message, .. } | SdkMessage::User { message, .. } => {
SdkMessage::Assistant {
message,
session_id: _,
}
| SdkMessage::User {
message,
session_id: _,
} => {
let Some(thread) = thread_rx
.recv()
.await
.log_err()
.and_then(|entity| entity.upgrade())
else {
log::error!("Received an SDK message but thread is gone");
return;
};
for chunk in message.content.chunks() {
match chunk {
ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
delegate
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
chunk: acp::AssistantMessageChunk::Text { text },
thread
.update(cx, |thread, cx| {
thread.push_assistant_chunk(text.into(), false, cx)
})
.await
.log_err();
}
ContentChunk::ToolUse { id, name, input } => {
let claude_tool = ClaudeTool::infer(&name, input);
if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
delegate
.update_plan(acp::UpdatePlanParams {
entries: params.todos.into_iter().map(Into::into).collect(),
})
.await
.log_err();
} else if let Some(resp) = delegate
.push_tool_call(claude_tool.as_acp())
.await
.log_err()
{
tool_id_map.borrow_mut().insert(id, resp.id);
}
thread
.update(cx, |thread, cx| {
if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
thread.update_plan(
acp::Plan {
entries: params
.todos
.into_iter()
.map(Into::into)
.collect(),
},
cx,
)
} else {
thread.upsert_tool_call(
claude_tool.as_acp(acp::ToolCallId(id.into())),
cx,
);
}
})
.log_err();
}
ContentChunk::ToolResult {
content,
tool_use_id,
} => {
let id = tool_id_map.borrow_mut().remove(&tool_use_id);
if let Some(id) = id {
let content = content.to_string();
delegate
.update_tool_call(UpdateToolCallParams {
tool_call_id: id,
status: acp::ToolCallStatus::Finished,
// Don't unset existing content
content: (!content.is_empty()).then_some(
ToolCallContent::Markdown {
// For now we only include text content
markdown: content,
},
),
})
.await
.log_err();
}
let content = content.to_string();
thread
.update(cx, |thread, cx| {
thread.update_tool_call(
acp::ToolCallId(tool_use_id.into()),
acp::ToolCallStatus::Completed,
(!content.is_empty()).then(|| vec![content.into()]),
cx,
)
})
.log_err();
}
ContentChunk::Image
| ContentChunk::Document
| ContentChunk::Thinking
| ContentChunk::RedactedThinking
| ContentChunk::WebSearchToolResult => {
delegate
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
chunk: acp::AssistantMessageChunk::Text {
text: format!("Unsupported content: {:?}", chunk),
},
thread
.update(cx, |thread, cx| {
thread.push_assistant_chunk(
format!("Unsupported content: {:?}", chunk).into(),
false,
cx,
)
})
.await
.log_err();
}
}
@@ -592,14 +634,14 @@ enum SdkMessage {
Assistant {
message: Message, // from Anthropic SDK
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<Uuid>,
session_id: Option<String>,
},
// A user message
User {
message: Message, // from Anthropic SDK
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<Uuid>,
session_id: Option<String>,
},
// Emitted as the last message in a conversation
@@ -661,21 +703,6 @@ enum PermissionMode {
Plan,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct McpConfig {
mcp_servers: HashMap<String, McpServerConfig>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct McpServerConfig {
command: String,
args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<HashMap<String, String>>,
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;

View File

@@ -1,29 +1,22 @@
use std::{cell::RefCell, rc::Rc};
use std::path::PathBuf;
use acp_thread::AcpClientDelegate;
use agentic_coding_protocol::{self as acp, Client, ReadTextFileParams, WriteTextFileParams};
use acp_thread::AcpThread;
use agent_client_protocol as acp;
use anyhow::{Context, Result};
use collections::HashMap;
use context_server::{
listener::McpServer,
types::{
CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
ToolResponseContent, ToolsCapabilities, requests,
},
use context_server::types::{
CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
ToolResponseContent, ToolsCapabilities, requests,
};
use gpui::{App, AsyncApp, Task};
use gpui::{App, AsyncApp, Entity, Task, WeakEntity};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use util::debug_panic;
use crate::claude::{
McpServerConfig,
tools::{ClaudeTool, EditToolParams, ReadToolParams},
};
use crate::claude::tools::{ClaudeTool, EditToolParams, ReadToolParams};
pub struct ClaudeMcpServer {
server: McpServer,
pub struct ClaudeZedMcpServer {
server: context_server::listener::McpServer,
}
pub const SERVER_NAME: &str = "zed";
@@ -52,17 +45,16 @@ enum PermissionToolBehavior {
Deny,
}
impl ClaudeMcpServer {
impl ClaudeZedMcpServer {
pub async fn new(
delegate: watch::Receiver<Option<AcpClientDelegate>>,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
cx: &AsyncApp,
) -> Result<Self> {
let mut mcp_server = McpServer::new(cx).await?;
let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
mcp_server.handle_request::<requests::ListTools>(Self::handle_list_tools);
mcp_server.handle_request::<requests::CallTool>(move |request, cx| {
Self::handle_call_tool(request, delegate.clone(), tool_id_map.clone(), cx)
Self::handle_call_tool(request, thread_rx.clone(), cx)
});
Ok(Self { server: mcp_server })
@@ -70,9 +62,7 @@ impl ClaudeMcpServer {
pub fn server_config(&self) -> Result<McpServerConfig> {
let zed_path = std::env::current_exe()
.context("finding current executable path for use in mcp_server")?
.to_string_lossy()
.to_string();
.context("finding current executable path for use in mcp_server")?;
Ok(McpServerConfig {
command: zed_path,
@@ -152,22 +142,19 @@ impl ClaudeMcpServer {
fn handle_call_tool(
request: CallToolParams,
mut delegate_watch: watch::Receiver<Option<AcpClientDelegate>>,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
cx: &App,
) -> Task<Result<CallToolResponse>> {
cx.spawn(async move |cx| {
let Some(delegate) = delegate_watch.recv().await? else {
debug_panic!("Sent None delegate");
anyhow::bail!("Server not available");
let Some(thread) = thread_rx.recv().await?.upgrade() else {
anyhow::bail!("Thread closed");
};
if request.name.as_str() == PERMISSION_TOOL {
let input =
serde_json::from_value(request.arguments.context("Arguments required")?)?;
let result =
Self::handle_permissions_tool_call(input, delegate, tool_id_map, cx).await?;
let result = Self::handle_permissions_tool_call(input, thread, cx).await?;
Ok(CallToolResponse {
content: vec![ToolResponseContent::Text {
text: serde_json::to_string(&result)?,
@@ -179,7 +166,7 @@ impl ClaudeMcpServer {
let input =
serde_json::from_value(request.arguments.context("Arguments required")?)?;
let content = Self::handle_read_tool_call(input, delegate, cx).await?;
let content = Self::handle_read_tool_call(input, thread, cx).await?;
Ok(CallToolResponse {
content,
is_error: None,
@@ -189,7 +176,7 @@ impl ClaudeMcpServer {
let input =
serde_json::from_value(request.arguments.context("Arguments required")?)?;
Self::handle_edit_tool_call(input, delegate, cx).await?;
Self::handle_edit_tool_call(input, thread, cx).await?;
Ok(CallToolResponse {
content: vec![],
is_error: None,
@@ -202,49 +189,46 @@ impl ClaudeMcpServer {
}
fn handle_read_tool_call(
params: ReadToolParams,
delegate: AcpClientDelegate,
ReadToolParams {
abs_path,
offset,
limit,
}: ReadToolParams,
thread: Entity<AcpThread>,
cx: &AsyncApp,
) -> Task<Result<Vec<ToolResponseContent>>> {
cx.foreground_executor().spawn(async move {
let response = delegate
.read_text_file(ReadTextFileParams {
path: params.abs_path,
line: params.offset,
limit: params.limit,
})
cx.spawn(async move |cx| {
let content = thread
.update(cx, |thread, cx| {
thread.read_text_file(abs_path, offset, limit, false, cx)
})?
.await?;
Ok(vec![ToolResponseContent::Text {
text: response.content,
}])
Ok(vec![ToolResponseContent::Text { text: content }])
})
}
fn handle_edit_tool_call(
params: EditToolParams,
delegate: AcpClientDelegate,
thread: Entity<AcpThread>,
cx: &AsyncApp,
) -> Task<Result<()>> {
cx.foreground_executor().spawn(async move {
let response = delegate
.read_text_file_reusing_snapshot(ReadTextFileParams {
path: params.abs_path.clone(),
line: None,
limit: None,
})
cx.spawn(async move |cx| {
let content = thread
.update(cx, |threads, cx| {
threads.read_text_file(params.abs_path.clone(), None, None, true, cx)
})?
.await?;
let new_content = response.content.replace(&params.old_text, &params.new_text);
if new_content == response.content {
let new_content = content.replace(&params.old_text, &params.new_text);
if new_content == content {
return Err(anyhow::anyhow!("The old_text was not found in the content"));
}
delegate
.write_text_file(WriteTextFileParams {
path: params.abs_path,
content: new_content,
})
thread
.update(cx, |threads, cx| {
threads.write_text_file(params.abs_path, new_content, cx)
})?
.await?;
Ok(())
@@ -253,44 +237,65 @@ impl ClaudeMcpServer {
fn handle_permissions_tool_call(
params: PermissionToolParams,
delegate: AcpClientDelegate,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
thread: Entity<AcpThread>,
cx: &AsyncApp,
) -> Task<Result<PermissionToolResponse>> {
cx.foreground_executor().spawn(async move {
cx.spawn(async move |cx| {
let claude_tool = ClaudeTool::infer(&params.tool_name, params.input.clone());
let tool_call_id = match params.tool_use_id {
Some(tool_use_id) => tool_id_map
.borrow()
.get(&tool_use_id)
.cloned()
.context("Tool call ID not found")?,
let tool_call_id =
acp::ToolCallId(params.tool_use_id.context("Tool ID required")?.into());
None => delegate.push_tool_call(claude_tool.as_acp()).await?.id,
};
let allow_option_id = acp::PermissionOptionId("allow".into());
let reject_option_id = acp::PermissionOptionId("reject".into());
let outcome = delegate
.request_existing_tool_call_confirmation(
tool_call_id,
claude_tool.confirmation(None),
)
let chosen_option = thread
.update(cx, |thread, cx| {
thread.request_tool_call_permission(
claude_tool.as_acp(tool_call_id),
vec![
acp::PermissionOption {
id: allow_option_id.clone(),
label: "Allow".into(),
kind: acp::PermissionOptionKind::AllowOnce,
},
acp::PermissionOption {
id: reject_option_id,
label: "Reject".into(),
kind: acp::PermissionOptionKind::RejectOnce,
},
],
cx,
)
})?
.await?;
match outcome {
acp::ToolCallConfirmationOutcome::Allow
| acp::ToolCallConfirmationOutcome::AlwaysAllow
| acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer
| acp::ToolCallConfirmationOutcome::AlwaysAllowTool => Ok(PermissionToolResponse {
if chosen_option == allow_option_id {
Ok(PermissionToolResponse {
behavior: PermissionToolBehavior::Allow,
updated_input: params.input,
}),
acp::ToolCallConfirmationOutcome::Reject
| acp::ToolCallConfirmationOutcome::Cancel => Ok(PermissionToolResponse {
})
} else {
Ok(PermissionToolResponse {
behavior: PermissionToolBehavior::Deny,
updated_input: params.input,
}),
})
}
})
}
}
#[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

@@ -1,6 +1,6 @@
use std::path::PathBuf;
use agentic_coding_protocol::{self as acp, PushToolCallParams, ToolCallLocation};
use agent_client_protocol as acp;
use itertools::Itertools;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -115,51 +115,36 @@ impl ClaudeTool {
Self::Other { name, .. } => name.clone(),
}
}
pub fn content(&self) -> Option<acp::ToolCallContent> {
pub fn content(&self) -> Vec<acp::ToolCallContent> {
match &self {
Self::Other { input, .. } => Some(acp::ToolCallContent::Markdown {
markdown: format!(
Self::Other { input, .. } => vec![
format!(
"```json\n{}```",
serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
),
}),
Self::Task(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.prompt.clone(),
}),
Self::NotebookRead(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.notebook_path.display().to_string(),
}),
Self::NotebookEdit(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.new_source.clone(),
}),
Self::Terminal(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: format!(
)
.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()
),
}),
Self::ReadFile(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.abs_path.display().to_string(),
}),
Self::Ls(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.path.display().to_string(),
}),
Self::Glob(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.to_string(),
}),
Self::Grep(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: format!("`{params}`"),
}),
Self::WebFetch(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.prompt.clone(),
}),
Self::WebSearch(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.to_string(),
}),
Self::TodoWrite(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params
)
.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::TodoWrite(Some(params)) => vec![
params
.todos
.iter()
.map(|todo| {
@@ -174,34 +159,39 @@ impl ClaudeTool {
todo.content
)
})
.join("\n"),
}),
Self::ExitPlanMode(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.plan.clone(),
}),
Self::Edit(Some(params)) => Some(acp::ToolCallContent::Diff {
.join("\n")
.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)) => Some(acp::ToolCallContent::Diff {
}],
Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.file_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| acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.file_path.clone(),
old_text: Some(edit.old_string.clone()),
new_text: edit.new_string.clone(),
},
})
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::Task(None)
| Self::NotebookRead(None)
@@ -217,181 +207,80 @@ impl ClaudeTool {
| Self::ExitPlanMode(None)
| Self::Edit(None)
| Self::Write(None)
| Self::MultiEdit(None) => None,
| Self::MultiEdit(None) => vec![],
}
}
pub fn icon(&self) -> acp::Icon {
pub fn kind(&self) -> acp::ToolKind {
match self {
Self::Task(_) => acp::Icon::Hammer,
Self::NotebookRead(_) => acp::Icon::FileSearch,
Self::NotebookEdit(_) => acp::Icon::Pencil,
Self::Edit(_) => acp::Icon::Pencil,
Self::MultiEdit(_) => acp::Icon::Pencil,
Self::Write(_) => acp::Icon::Pencil,
Self::ReadFile(_) => acp::Icon::FileSearch,
Self::Ls(_) => acp::Icon::Folder,
Self::Glob(_) => acp::Icon::FileSearch,
Self::Grep(_) => acp::Icon::Regex,
Self::Terminal(_) => acp::Icon::Terminal,
Self::WebSearch(_) => acp::Icon::Globe,
Self::WebFetch(_) => acp::Icon::Globe,
Self::TodoWrite(_) => acp::Icon::LightBulb,
Self::ExitPlanMode(_) => acp::Icon::Hammer,
Self::Other { .. } => acp::Icon::Hammer,
}
}
pub fn confirmation(&self, description: Option<String>) -> acp::ToolCallConfirmation {
match &self {
Self::Edit(_) | Self::Write(_) | Self::NotebookEdit(_) | Self::MultiEdit(_) => {
acp::ToolCallConfirmation::Edit { description }
}
Self::WebFetch(params) => acp::ToolCallConfirmation::Fetch {
urls: params
.as_ref()
.map(|p| vec![p.url.clone()])
.unwrap_or_default(),
description,
},
Self::Terminal(Some(BashToolParams {
description,
command,
..
})) => acp::ToolCallConfirmation::Execute {
command: command.clone(),
root_command: command.clone(),
description: description.clone(),
},
Self::ExitPlanMode(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {}", params.plan)
} else {
params.plan.clone()
},
},
Self::Task(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {}", params.description)
} else {
params.description.clone()
},
},
Self::Ls(Some(LsToolParams { path, .. }))
| Self::ReadFile(Some(ReadToolParams { abs_path: path, .. })) => {
let path = path.display();
acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {path}")
} else {
path.to_string()
},
}
}
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
let path = notebook_path.display();
acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {path}")
} else {
path.to_string()
},
}
}
Self::Glob(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params.to_string()
},
},
Self::Grep(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params.to_string()
},
},
Self::WebSearch(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params.to_string()
},
},
Self::TodoWrite(Some(params)) => {
let params = params.todos.iter().map(|todo| &todo.content).join(", ");
acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params
},
}
}
Self::Terminal(None)
| Self::Task(None)
| Self::NotebookRead(None)
| Self::ExitPlanMode(None)
| Self::Ls(None)
| Self::Glob(None)
| Self::Grep(None)
| Self::ReadFile(None)
| Self::WebSearch(None)
| Self::TodoWrite(None)
| Self::Other { .. } => acp::ToolCallConfirmation::Other {
description: description.unwrap_or("".to_string()),
},
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![ToolCallLocation {
Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation {
path: abs_path.clone(),
line: None,
}],
Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
vec![ToolCallLocation {
vec![acp::ToolCallLocation {
path: file_path.clone(),
line: None,
}]
}
Self::Write(Some(WriteToolParams { file_path, .. })) => {
vec![acp::ToolCallLocation {
path: file_path.clone(),
line: None,
}]
}
Self::Write(Some(WriteToolParams { file_path, .. })) => vec![ToolCallLocation {
path: file_path.clone(),
line: None,
}],
Self::ReadFile(Some(ReadToolParams {
abs_path, offset, ..
})) => vec![ToolCallLocation {
})) => vec![acp::ToolCallLocation {
path: abs_path.clone(),
line: *offset,
}],
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
vec![ToolCallLocation {
vec![acp::ToolCallLocation {
path: notebook_path.clone(),
line: None,
}]
}
Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
vec![ToolCallLocation {
vec![acp::ToolCallLocation {
path: notebook_path.clone(),
line: None,
}]
}
Self::Glob(Some(GlobToolParams {
path: Some(path), ..
})) => vec![ToolCallLocation {
})) => vec![acp::ToolCallLocation {
path: path.clone(),
line: None,
}],
Self::Ls(Some(LsToolParams { path, .. })) => vec![ToolCallLocation {
Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation {
path: path.clone(),
line: None,
}],
Self::Grep(Some(GrepToolParams {
path: Some(path), ..
})) => vec![ToolCallLocation {
})) => vec![acp::ToolCallLocation {
path: PathBuf::from(path),
line: None,
}],
@@ -414,11 +303,13 @@ impl ClaudeTool {
}
}
pub fn as_acp(&self) -> PushToolCallParams {
PushToolCallParams {
pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall {
acp::ToolCall {
id,
kind: self.kind(),
status: acp::ToolCallStatus::InProgress,
label: self.label(),
content: self.content(),
icon: self.icon(),
locations: self.locations(),
}
}

View File

@@ -1,10 +1,9 @@
use std::{path::Path, sync::Arc, time::Duration};
use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings};
use acp_thread::{
AcpThread, AgentThreadEntry, ToolCall, ToolCallConfirmation, ToolCallContent, ToolCallStatus,
};
use agentic_coding_protocol as acp;
use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
use agent_client_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
use gpui::{Entity, TestAppContext};
use indoc::indoc;
@@ -54,19 +53,25 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes
thread
.update(cx, |thread, cx| {
thread.send(
acp::SendUserMessageParams {
chunks: vec![
acp::UserMessageChunk::Text {
text: "Read the file ".into(),
},
acp::UserMessageChunk::Path {
path: Path::new("foo.rs").into(),
},
acp::UserMessageChunk::Text {
text: " and tell me what the content of the println! is".into(),
},
],
},
vec![
acp::ContentBlock::Text(acp::TextContent {
text: "Read the file ".into(),
annotations: None,
}),
acp::ContentBlock::ResourceLink(acp::ResourceLink {
uri: "foo.rs".into(),
name: "foo.rs".into(),
annotations: None,
description: None,
mime_type: None,
size: None,
title: None,
}),
acp::ContentBlock::Text(acp::TextContent {
text: " and tell me what the content of the println! is".into(),
annotations: None,
}),
],
cx,
)
})
@@ -161,11 +166,8 @@ pub async fn test_tool_call_with_confirmation(
let tool_call_id = thread.read_with(cx, |thread, _cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
status:
ToolCallStatus::WaitingForConfirmation {
confirmation: ToolCallConfirmation::Execute { root_command, .. },
..
},
content,
status: ToolCallStatus::WaitingForConfirmation { .. },
..
}) = &thread
.entries()
@@ -176,13 +178,18 @@ pub async fn test_tool_call_with_confirmation(
panic!();
};
assert!(root_command.contains("touch"));
assert!(content.iter().any(|c| c.to_markdown(_cx).contains("touch")));
*id
id.clone()
});
thread.update(cx, |thread, cx| {
thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
thread.authorize_tool_call(
tool_call_id,
acp::PermissionOptionId("0".into()),
acp::PermissionOptionKind::AllowOnce,
cx,
);
assert!(thread.entries().iter().any(|entry| matches!(
entry,
@@ -197,7 +204,7 @@ pub async fn test_tool_call_with_confirmation(
thread.read_with(cx, |thread, cx| {
let AgentThreadEntry::ToolCall(ToolCall {
content: Some(ToolCallContent::Markdown { markdown }),
content,
status: ToolCallStatus::Allowed { .. },
..
}) = thread
@@ -209,13 +216,10 @@ pub async fn test_tool_call_with_confirmation(
panic!();
};
markdown.read_with(cx, |md, _cx| {
assert!(
md.source().contains("Hello"),
r#"Expected '{}' to contain "Hello""#,
md.source()
);
});
assert!(
content.iter().any(|c| c.to_markdown(cx).contains("Hello")),
"Expected content to contain 'Hello'"
);
});
}
@@ -249,26 +253,20 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon
thread.read_with(cx, |thread, _cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
status:
ToolCallStatus::WaitingForConfirmation {
confirmation: ToolCallConfirmation::Execute { root_command, .. },
..
},
content,
status: ToolCallStatus::WaitingForConfirmation { .. },
..
}) = &thread.entries()[first_tool_call_ix]
else {
panic!("{:?}", thread.entries()[1]);
};
assert!(root_command.contains("touch"));
assert!(content.iter().any(|c| c.to_markdown(_cx).contains("touch")));
*id
id.clone()
});
thread
.update(cx, |thread, cx| thread.cancel(cx))
.await
.unwrap();
let _ = thread.update(cx, |thread, cx| thread.cancel(cx));
full_turn.await.unwrap();
thread.read_with(cx, |thread, _| {
let AgentThreadEntry::ToolCall(ToolCall {
@@ -369,15 +367,16 @@ pub async fn new_test_thread(
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
let thread = cx
.update(|cx| server.new_thread(current_dir.as_ref(), &project, cx))
let connection = cx
.update(|cx| server.connect(current_dir.as_ref(), &project, cx))
.await
.unwrap();
thread
.update(cx, |thread, _| thread.initialize())
let thread = connection
.new_thread(project.clone(), current_dir.as_ref(), &mut cx.to_async())
.await
.unwrap();
thread
}

View File

@@ -1,9 +1,17 @@
use crate::stdio_agent_server::StdioAgentServer;
use crate::{AgentServerCommand, AgentServerVersion};
use anyhow::anyhow;
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use util::ResultExt as _;
use crate::{AgentServer, AgentServerCommand, AgentServerVersion};
use acp_thread::{AgentConnection, LoadError, OldAcpAgentConnection, OldAcpClientDelegate};
use agentic_coding_protocol as acp_old;
use anyhow::{Context as _, Result};
use gpui::{AsyncApp, Entity};
use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use project::Project;
use settings::SettingsStore;
use ui::App;
use crate::AllAgentServersSettings;
@@ -12,7 +20,7 @@ pub struct Gemini;
const ACP_ARG: &str = "--experimental-acp";
impl StdioAgentServer for Gemini {
impl AgentServer for Gemini {
fn name(&self) -> &'static str {
"Gemini"
}
@@ -25,14 +33,88 @@ impl StdioAgentServer for Gemini {
"Ask questions, edit files, run commands.\nBe specific for the best results."
}
fn supports_always_allow(&self) -> bool {
true
}
fn logo(&self) -> ui::IconName {
ui::IconName::AiGemini
}
fn connect(
&self,
root_dir: &Path,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let root_dir = root_dir.to_path_buf();
let project = project.clone();
let this = self.clone();
let name = self.name();
cx.spawn(async move |cx| {
let command = this.command(&project, cx).await?;
let mut child = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.current_dir(root_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()?;
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let foreground_executor = cx.foreground_executor().clone();
let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid()));
let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent(
OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()),
stdin,
stdout,
move |fut| foreground_executor.spawn(fut).detach(),
);
let io_task = cx.background_spawn(async move {
io_fut.await.log_err();
});
let child_status = cx.background_spawn(async move {
let result = match child.status().await {
Err(e) => Err(anyhow!(e)),
Ok(result) if result.success() => Ok(()),
Ok(result) => {
if let Some(AgentServerVersion::Unsupported {
error_message,
upgrade_message,
upgrade_command,
}) = this.version(&command).await.log_err()
{
Err(anyhow!(LoadError::Unsupported {
error_message,
upgrade_message,
upgrade_command
}))
} else {
Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
}
}
};
drop(io_task);
result
});
let connection: Rc<dyn AgentConnection> = Rc::new(OldAcpAgentConnection {
name,
connection,
child_status,
});
Ok(connection)
})
}
}
impl Gemini {
async fn command(
&self,
project: &Entity<Project>,

View File

@@ -1,119 +0,0 @@
use crate::{AgentServer, AgentServerCommand, AgentServerVersion};
use acp_thread::{AcpClientDelegate, AcpThread, LoadError};
use agentic_coding_protocol as acp;
use anyhow::{Result, anyhow};
use gpui::{App, AsyncApp, Entity, Task, prelude::*};
use project::Project;
use std::path::Path;
use util::ResultExt;
pub trait StdioAgentServer: Send + Clone {
fn logo(&self) -> ui::IconName;
fn name(&self) -> &'static str;
fn empty_state_headline(&self) -> &'static str;
fn empty_state_message(&self) -> &'static str;
fn supports_always_allow(&self) -> bool;
fn command(
&self,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> impl Future<Output = Result<AgentServerCommand>>;
fn version(
&self,
command: &AgentServerCommand,
) -> impl Future<Output = Result<AgentServerVersion>> + Send;
}
impl<T: StdioAgentServer + 'static> AgentServer for T {
fn name(&self) -> &'static str {
self.name()
}
fn empty_state_headline(&self) -> &'static str {
self.empty_state_headline()
}
fn empty_state_message(&self) -> &'static str {
self.empty_state_message()
}
fn logo(&self) -> ui::IconName {
self.logo()
}
fn supports_always_allow(&self) -> bool {
self.supports_always_allow()
}
fn new_thread(
&self,
root_dir: &Path,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>> {
let root_dir = root_dir.to_path_buf();
let project = project.clone();
let this = self.clone();
let title = self.name().into();
cx.spawn(async move |cx| {
let command = this.command(&project, cx).await?;
let mut child = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.current_dir(root_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()?;
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
cx.new(|cx| {
let foreground_executor = cx.foreground_executor().clone();
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
stdin,
stdout,
move |fut| foreground_executor.spawn(fut).detach(),
);
let io_task = cx.background_spawn(async move {
io_fut.await.log_err();
});
let child_status = cx.background_spawn(async move {
let result = match child.status().await {
Err(e) => Err(anyhow!(e)),
Ok(result) if result.success() => Ok(()),
Ok(result) => {
if let Some(AgentServerVersion::Unsupported {
error_message,
upgrade_message,
upgrade_command,
}) = this.version(&command).await.log_err()
{
Err(anyhow!(LoadError::Unsupported {
error_message,
upgrade_message,
upgrade_command
}))
} else {
Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
}
}
};
drop(io_task);
result
});
AcpThread::new(connection, title, Some(child_status), project.clone(), cx)
})
})
}
}

View File

@@ -17,10 +17,10 @@ test-support = ["gpui/test-support", "language/test-support"]
[dependencies]
acp_thread.workspace = true
agent-client-protocol.workspace = true
agent.workspace = true
agentic-coding-protocol.workspace = true
agent_settings.workspace = true
agent_servers.workspace = true
agent_settings.workspace = true
ai_onboarding.workspace = true
anyhow.workspace = true
assistant_context.workspace = true

View File

@@ -1,4 +1,4 @@
use acp_thread::Plan;
use acp_thread::{AgentConnection, Plan};
use agent_servers::AgentServer;
use std::cell::RefCell;
use std::collections::BTreeMap;
@@ -7,7 +7,7 @@ use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use agentic_coding_protocol::{self as acp};
use agent_client_protocol as acp;
use assistant_tool::ActionLog;
use buffer_diff::BufferDiff;
use collections::{HashMap, HashSet};
@@ -16,7 +16,6 @@ use editor::{
EditorStyle, MinimapVisibility, MultiBuffer, PathKey,
};
use file_icons::FileIcons;
use futures::channel::oneshot;
use gpui::{
Action, Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId,
FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement,
@@ -39,8 +38,7 @@ use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
use ::acp_thread::{
AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff,
LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent,
ToolCallId, ToolCallStatus,
LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
};
use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet};
@@ -64,12 +62,13 @@ pub struct AcpThreadView {
last_error: Option<Entity<Markdown>>,
list_state: ListState,
auth_task: Option<Task<()>>,
expanded_tool_calls: HashSet<ToolCallId>,
expanded_tool_calls: HashSet<acp::ToolCallId>,
expanded_thinking_blocks: HashSet<(usize, usize)>,
edits_expanded: bool,
plan_expanded: bool,
editor_expanded: bool,
message_history: Rc<RefCell<MessageHistory<acp::SendUserMessageParams>>>,
message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
_cancel_task: Option<Task<()>>,
}
enum ThreadState {
@@ -82,22 +81,16 @@ enum ThreadState {
},
LoadError(LoadError),
Unauthenticated {
thread: Entity<AcpThread>,
connection: Rc<dyn AgentConnection>,
},
}
struct AlwaysAllowOption {
id: &'static str,
label: SharedString,
outcome: acp::ToolCallConfirmationOutcome,
}
impl AcpThreadView {
pub fn new(
agent: Rc<dyn AgentServer>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
message_history: Rc<RefCell<MessageHistory<acp::SendUserMessageParams>>>,
message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
min_lines: usize,
max_lines: Option<usize>,
window: &mut Window,
@@ -191,6 +184,7 @@ impl AcpThreadView {
plan_expanded: false,
editor_expanded: false,
message_history,
_cancel_task: None,
}
}
@@ -208,9 +202,9 @@ impl AcpThreadView {
.map(|worktree| worktree.read(cx).abs_path())
.unwrap_or_else(|| paths::home_dir().as_path().into());
let task = agent.new_thread(&root_dir, &project, cx);
let connect_task = agent.connect(&root_dir, &project, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
let thread = match task.await {
let connection = match connect_task.await {
Ok(thread) => thread,
Err(err) => {
this.update(cx, |this, cx| {
@@ -222,48 +216,30 @@ impl AcpThreadView {
}
};
let init_response = async {
let resp = thread
.read_with(cx, |thread, _cx| thread.initialize())?
.await?;
anyhow::Ok(resp)
};
let result = match init_response.await {
let result = match connection
.clone()
.new_thread(project.clone(), &root_dir, cx)
.await
{
Err(e) => {
let mut cx = cx.clone();
if e.downcast_ref::<oneshot::Canceled>().is_some() {
let child_status = thread
.update(&mut cx, |thread, _| thread.child_status())
.ok()
.flatten();
if let Some(child_status) = child_status {
match child_status.await {
Ok(_) => Err(e),
Err(e) => Err(e),
}
} else {
Err(e)
}
if e.downcast_ref::<acp_thread::Unauthenticated>().is_some() {
this.update(&mut cx, |this, cx| {
this.thread_state = ThreadState::Unauthenticated { connection };
cx.notify();
})
.ok();
return;
} else {
Err(e)
}
}
Ok(response) => {
if !response.is_authenticated {
this.update(cx, |this, _| {
this.thread_state = ThreadState::Unauthenticated { thread };
})
.ok();
return;
};
Ok(())
}
Ok(session_id) => Ok(session_id),
};
this.update_in(cx, |this, window, cx| {
match result {
Ok(()) => {
Ok(thread) => {
let thread_subscription =
cx.subscribe_in(&thread, window, Self::handle_thread_event);
@@ -305,10 +281,10 @@ impl AcpThreadView {
pub fn thread(&self) -> Option<&Entity<AcpThread>> {
match &self.thread_state {
ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
Some(thread)
}
ThreadState::Loading { .. } | ThreadState::LoadError(..) => None,
ThreadState::Ready { thread, .. } => Some(thread),
ThreadState::Unauthenticated { .. }
| ThreadState::Loading { .. }
| ThreadState::LoadError(..) => None,
}
}
@@ -325,7 +301,7 @@ impl AcpThreadView {
self.last_error.take();
if let Some(thread) = self.thread() {
thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
}
}
@@ -362,7 +338,7 @@ impl AcpThreadView {
self.last_error.take();
let mut ix = 0;
let mut chunks: Vec<acp::UserMessageChunk> = Vec::new();
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
let project = self.project.clone();
self.message_editor.update(cx, |editor, cx| {
let text = editor.text(cx);
@@ -374,12 +350,19 @@ impl AcpThreadView {
{
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
if crease_range.start > ix {
chunks.push(acp::UserMessageChunk::Text {
text: text[ix..crease_range.start].to_string(),
});
chunks.push(text[ix..crease_range.start].into());
}
if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) {
chunks.push(acp::UserMessageChunk::Path { path: abs_path });
let path_str = abs_path.display().to_string();
chunks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink {
uri: path_str.clone(),
name: path_str,
annotations: None,
description: None,
mime_type: None,
size: None,
title: None,
}));
}
ix = crease_range.end;
}
@@ -388,9 +371,7 @@ impl AcpThreadView {
if ix < text.len() {
let last_chunk = text[ix..].trim();
if !last_chunk.is_empty() {
chunks.push(acp::UserMessageChunk::Text {
text: last_chunk.into(),
});
chunks.push(last_chunk.into());
}
}
})
@@ -401,8 +382,7 @@ impl AcpThreadView {
}
let Some(thread) = self.thread() else { return };
let message = acp::SendUserMessageParams { chunks };
let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx));
let task = thread.update(cx, |thread, cx| thread.send(chunks.clone(), cx));
cx.spawn(async move |this, cx| {
let result = task.await;
@@ -424,7 +404,7 @@ impl AcpThreadView {
editor.remove_creases(mention_set.lock().drain(), cx)
});
self.message_history.borrow_mut().push(message);
self.message_history.borrow_mut().push(chunks);
}
fn previous_history_message(
@@ -490,7 +470,7 @@ impl AcpThreadView {
message_editor: Entity<Editor>,
mention_set: Arc<Mutex<MentionSet>>,
project: Entity<Project>,
message: Option<&acp::SendUserMessageParams>,
message: Option<&Vec<acp::ContentBlock>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
@@ -503,18 +483,19 @@ impl AcpThreadView {
let mut text = String::new();
let mut mentions = Vec::new();
for chunk in &message.chunks {
for chunk in message {
match chunk {
acp::UserMessageChunk::Text { text: chunk } => {
text.push_str(&chunk);
acp::ContentBlock::Text(text_content) => {
text.push_str(&text_content.text);
}
acp::UserMessageChunk::Path { path } => {
acp::ContentBlock::ResourceLink(resource_link) => {
let path = Path::new(&resource_link.uri);
let start = text.len();
let content = MentionPath::new(path).to_string();
let content = MentionPath::new(&path).to_string();
text.push_str(&content);
let end = text.len();
if let Some(project_path) =
project.read(cx).project_path_for_absolute_path(path, cx)
project.read(cx).project_path_for_absolute_path(&path, cx)
{
let filename: SharedString = path
.file_name()
@@ -525,6 +506,9 @@ impl AcpThreadView {
mentions.push((start..end, project_path, filename));
}
}
acp::ContentBlock::Image(_)
| acp::ContentBlock::Audio(_)
| acp::ContentBlock::Resource(_) => {}
}
}
@@ -590,71 +574,79 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(multibuffer) = self.entry_diff_multibuffer(entry_ix, cx) else {
let Some(multibuffers) = self.entry_diff_multibuffers(entry_ix, cx) else {
return;
};
if self.diff_editors.contains_key(&multibuffer.entity_id()) {
return;
}
let multibuffers = multibuffers.collect::<Vec<_>>();
let editor = cx.new(|cx| {
let mut editor = Editor::new(
EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
sized_by_content: true,
},
multibuffer.clone(),
None,
window,
cx,
);
editor.set_show_gutter(false, cx);
editor.disable_inline_diagnostics();
editor.disable_expand_excerpt_buttons(cx);
editor.set_show_vertical_scrollbar(false, cx);
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
editor.set_soft_wrap_mode(SoftWrap::None, cx);
editor.scroll_manager.set_forbid_vertical_scroll(true);
editor.set_show_indent_guides(false, cx);
editor.set_read_only(true);
editor.set_show_breakpoints(false, cx);
editor.set_show_code_actions(false, cx);
editor.set_show_git_diff_gutter(false, cx);
editor.set_expand_all_diff_hunks(cx);
editor.set_text_style_refinement(TextStyleRefinement {
font_size: Some(
TextSize::Small
.rems(cx)
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
.into(),
),
..Default::default()
for multibuffer in multibuffers {
if self.diff_editors.contains_key(&multibuffer.entity_id()) {
return;
}
let editor = cx.new(|cx| {
let mut editor = Editor::new(
EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
sized_by_content: true,
},
multibuffer.clone(),
None,
window,
cx,
);
editor.set_show_gutter(false, cx);
editor.disable_inline_diagnostics();
editor.disable_expand_excerpt_buttons(cx);
editor.set_show_vertical_scrollbar(false, cx);
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
editor.set_soft_wrap_mode(SoftWrap::None, cx);
editor.scroll_manager.set_forbid_vertical_scroll(true);
editor.set_show_indent_guides(false, cx);
editor.set_read_only(true);
editor.set_show_breakpoints(false, cx);
editor.set_show_code_actions(false, cx);
editor.set_show_git_diff_gutter(false, cx);
editor.set_expand_all_diff_hunks(cx);
editor.set_text_style_refinement(TextStyleRefinement {
font_size: Some(
TextSize::Small
.rems(cx)
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
.into(),
),
..Default::default()
});
editor
});
editor
});
let entity_id = multibuffer.entity_id();
cx.observe_release(&multibuffer, move |this, _, _| {
this.diff_editors.remove(&entity_id);
})
.detach();
let entity_id = multibuffer.entity_id();
cx.observe_release(&multibuffer, move |this, _, _| {
this.diff_editors.remove(&entity_id);
})
.detach();
self.diff_editors.insert(entity_id, editor);
self.diff_editors.insert(entity_id, editor);
}
}
fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option<Entity<MultiBuffer>> {
fn entry_diff_multibuffers(
&self,
entry_ix: usize,
cx: &App,
) -> Option<impl Iterator<Item = Entity<MultiBuffer>>> {
let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
entry.diff().map(|diff| diff.multibuffer.clone())
Some(entry.diffs().map(|diff| diff.multibuffer.clone()))
}
fn authenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(thread) = self.thread().cloned() else {
let ThreadState::Unauthenticated { ref connection } = self.thread_state else {
return;
};
self.last_error.take();
let authenticate = thread.read(cx).authenticate();
let authenticate = connection.authenticate(cx);
self.auth_task = Some(cx.spawn_in(window, {
let project = self.project.clone();
let agent = self.agent.clone();
@@ -684,15 +676,16 @@ impl AcpThreadView {
fn authorize_tool_call(
&mut self,
id: ToolCallId,
outcome: acp::ToolCallConfirmationOutcome,
tool_call_id: acp::ToolCallId,
option_id: acp::PermissionOptionId,
option_kind: acp::PermissionOptionKind,
cx: &mut Context<Self>,
) {
let Some(thread) = self.thread() else {
return;
};
thread.update(cx, |thread, cx| {
thread.authorize_tool_call(id, outcome, cx);
thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
});
cx.notify();
}
@@ -719,10 +712,12 @@ impl AcpThreadView {
.border_1()
.border_color(cx.theme().colors().border)
.text_xs()
.child(self.render_markdown(
message.content.clone(),
user_message_markdown_style(window, cx),
)),
.children(message.content.markdown().map(|md| {
self.render_markdown(
md.clone(),
user_message_markdown_style(window, cx),
)
})),
)
.into_any(),
AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
@@ -730,20 +725,28 @@ impl AcpThreadView {
let message_body = v_flex()
.w_full()
.gap_2p5()
.children(chunks.iter().enumerate().map(|(chunk_ix, chunk)| {
match chunk {
AssistantMessageChunk::Text { chunk } => self
.render_markdown(chunk.clone(), style.clone())
.into_any_element(),
AssistantMessageChunk::Thought { chunk } => self.render_thinking_block(
index,
chunk_ix,
chunk.clone(),
window,
cx,
),
}
}))
.children(chunks.iter().enumerate().filter_map(
|(chunk_ix, chunk)| match chunk {
AssistantMessageChunk::Message { block } => {
block.markdown().map(|md| {
self.render_markdown(md.clone(), style.clone())
.into_any_element()
})
}
AssistantMessageChunk::Thought { block } => {
block.markdown().map(|md| {
self.render_thinking_block(
index,
chunk_ix,
md.clone(),
window,
cx,
)
.into_any_element()
})
}
},
))
.into_any();
v_flex()
@@ -871,7 +874,7 @@ impl AcpThreadView {
let status_icon = match &tool_call.status {
ToolCallStatus::WaitingForConfirmation { .. } => None,
ToolCallStatus::Allowed {
status: acp::ToolCallStatus::Running,
status: acp::ToolCallStatus::InProgress,
..
} => Some(
Icon::new(IconName::ArrowCircle)
@@ -885,13 +888,13 @@ impl AcpThreadView {
.into_any(),
),
ToolCallStatus::Allowed {
status: acp::ToolCallStatus::Finished,
status: acp::ToolCallStatus::Completed,
..
} => None,
ToolCallStatus::Rejected
| ToolCallStatus::Canceled
| ToolCallStatus::Allowed {
status: acp::ToolCallStatus::Error,
status: acp::ToolCallStatus::Failed,
..
} => Some(
Icon::new(IconName::X)
@@ -909,34 +912,9 @@ impl AcpThreadView {
.any(|content| matches!(content, ToolCallContent::Diff { .. })),
};
let is_collapsible = tool_call.content.is_some() && !needs_confirmation;
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id);
let content = if is_open {
match &tool_call.status {
ToolCallStatus::WaitingForConfirmation { confirmation, .. } => {
Some(self.render_tool_call_confirmation(
tool_call.id,
confirmation,
tool_call.content.as_ref(),
window,
cx,
))
}
ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
tool_call.content.as_ref().map(|content| {
div()
.py_1p5()
.child(self.render_tool_call_content(content, window, cx))
.into_any_element()
})
}
ToolCallStatus::Rejected => None,
}
} else {
None
};
v_flex()
.when(needs_confirmation, |this| {
this.rounded_lg()
@@ -976,9 +954,17 @@ impl AcpThreadView {
})
.gap_1p5()
.child(
Icon::new(tool_call.icon)
.size(IconSize::Small)
.color(Color::Muted),
Icon::new(match tool_call.kind {
acp::ToolKind::Read => IconName::ToolRead,
acp::ToolKind::Edit => IconName::ToolPencil,
acp::ToolKind::Search => IconName::ToolSearch,
acp::ToolKind::Execute => IconName::ToolTerminal,
acp::ToolKind::Think => IconName::ToolBulb,
acp::ToolKind::Fetch => IconName::ToolWeb,
acp::ToolKind::Other => IconName::ToolHammer,
})
.size(IconSize::Small)
.color(Color::Muted),
)
.child(if tool_call.locations.len() == 1 {
let name = tool_call.locations[0]
@@ -1023,16 +1009,16 @@ impl AcpThreadView {
.gap_0p5()
.when(is_collapsible, |this| {
this.child(
Disclosure::new(("expand", tool_call.id.0), is_open)
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.on_click(cx.listener({
let id = tool_call.id;
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id);
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
@@ -1042,12 +1028,12 @@ impl AcpThreadView {
.children(status_icon),
)
.on_click(cx.listener({
let id = tool_call.id;
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id);
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
@@ -1055,7 +1041,7 @@ impl AcpThreadView {
)
.when(is_open, |this| {
this.child(
div()
v_flex()
.text_xs()
.when(is_collapsible, |this| {
this.mt_1()
@@ -1064,7 +1050,44 @@ impl AcpThreadView {
.bg(cx.theme().colors().editor_background)
.rounded_lg()
})
.children(content),
.map(|this| {
if is_open {
match &tool_call.status {
ToolCallStatus::WaitingForConfirmation { options, .. } => this
.children(tool_call.content.iter().map(|content| {
div()
.py_1p5()
.child(
self.render_tool_call_content(
content, window, cx,
),
)
.into_any_element()
}))
.child(self.render_permission_buttons(
options,
entry_ix,
tool_call.id.clone(),
cx,
)),
ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => {
this.children(tool_call.content.iter().map(|content| {
div()
.py_1p5()
.child(
self.render_tool_call_content(
content, window, cx,
),
)
.into_any_element()
}))
}
ToolCallStatus::Rejected => this,
}
} else {
this
}
}),
)
})
}
@@ -1076,14 +1099,20 @@ impl AcpThreadView {
cx: &Context<Self>,
) -> AnyElement {
match content {
ToolCallContent::Markdown { markdown } => {
div()
.p_2()
.child(self.render_markdown(
markdown.clone(),
default_markdown_style(false, window, cx),
))
.into_any_element()
ToolCallContent::ContentBlock { content } => {
if let Some(md) = content.markdown() {
div()
.p_2()
.child(
self.render_markdown(
md.clone(),
default_markdown_style(false, window, cx),
),
)
.into_any_element()
} else {
Empty.into_any_element()
}
}
ToolCallContent::Diff {
diff: Diff { multibuffer, .. },
@@ -1092,223 +1121,53 @@ impl AcpThreadView {
}
}
fn render_tool_call_confirmation(
fn render_permission_buttons(
&self,
tool_call_id: ToolCallId,
confirmation: &ToolCallConfirmation,
content: Option<&ToolCallContent>,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let confirmation_container = v_flex().mt_1().py_1p5();
match confirmation {
ToolCallConfirmation::Edit { description } => confirmation_container
.child(
div()
.px_2()
.children(description.clone().map(|description| {
self.render_markdown(
description,
default_markdown_style(false, window, cx),
)
})),
)
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
.child(self.render_confirmation_buttons(
&[AlwaysAllowOption {
id: "always_allow",
label: "Always Allow Edits".into(),
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
}],
tool_call_id,
cx,
))
.into_any(),
ToolCallConfirmation::Execute {
command,
root_command,
description,
} => confirmation_container
.child(v_flex().px_2().pb_1p5().child(command.clone()).children(
description.clone().map(|description| {
self.render_markdown(description, default_markdown_style(false, window, cx))
.on_url_click({
let workspace = self.workspace.clone();
move |text, window, cx| {
Self::open_link(text, &workspace, window, cx);
}
})
}),
))
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
.child(self.render_confirmation_buttons(
&[AlwaysAllowOption {
id: "always_allow",
label: format!("Always Allow {root_command}").into(),
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
}],
tool_call_id,
cx,
))
.into_any(),
ToolCallConfirmation::Mcp {
server_name,
tool_name: _,
tool_display_name,
description,
} => confirmation_container
.child(
v_flex()
.px_2()
.pb_1p5()
.child(format!("{server_name} - {tool_display_name}"))
.children(description.clone().map(|description| {
self.render_markdown(
description,
default_markdown_style(false, window, cx),
)
})),
)
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
.child(self.render_confirmation_buttons(
&[
AlwaysAllowOption {
id: "always_allow_server",
label: format!("Always Allow {server_name}").into(),
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
},
AlwaysAllowOption {
id: "always_allow_tool",
label: format!("Always Allow {tool_display_name}").into(),
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
},
],
tool_call_id,
cx,
))
.into_any(),
ToolCallConfirmation::Fetch { description, urls } => confirmation_container
.child(
v_flex()
.px_2()
.pb_1p5()
.gap_1()
.children(urls.iter().map(|url| {
h_flex().child(
Button::new(url.clone(), url)
.icon(IconName::ArrowUpRight)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.on_click({
let url = url.clone();
move |_, _, cx| cx.open_url(&url)
}),
)
}))
.children(description.clone().map(|description| {
self.render_markdown(
description,
default_markdown_style(false, window, cx),
)
})),
)
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
.child(self.render_confirmation_buttons(
&[AlwaysAllowOption {
id: "always_allow",
label: "Always Allow".into(),
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
}],
tool_call_id,
cx,
))
.into_any(),
ToolCallConfirmation::Other { description } => confirmation_container
.child(v_flex().px_2().pb_1p5().child(self.render_markdown(
description.clone(),
default_markdown_style(false, window, cx),
)))
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
.child(self.render_confirmation_buttons(
&[AlwaysAllowOption {
id: "always_allow",
label: "Always Allow".into(),
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
}],
tool_call_id,
cx,
))
.into_any(),
}
}
fn render_confirmation_buttons(
&self,
always_allow_options: &[AlwaysAllowOption],
tool_call_id: ToolCallId,
options: &[acp::PermissionOption],
entry_ix: usize,
tool_call_id: acp::ToolCallId,
cx: &Context<Self>,
) -> Div {
h_flex()
.pt_1p5()
.py_1p5()
.px_1p5()
.gap_1()
.justify_end()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.when(self.agent.supports_always_allow(), |this| {
this.children(always_allow_options.into_iter().map(|always_allow_option| {
let outcome = always_allow_option.outcome;
Button::new(
(always_allow_option.id, tool_call_id.0),
always_allow_option.label.clone(),
)
.icon(IconName::CheckDouble)
.children(options.iter().map(|option| {
let option_id = SharedString::from(option.id.0.clone());
Button::new((option_id, entry_ix), option.label.clone())
.map(|this| match option.kind {
acp::PermissionOptionKind::AllowOnce => {
this.icon(IconName::Check).icon_color(Color::Success)
}
acp::PermissionOptionKind::AllowAlways => {
this.icon(IconName::CheckDouble).icon_color(Color::Success)
}
acp::PermissionOptionKind::RejectOnce => {
this.icon(IconName::X).icon_color(Color::Error)
}
acp::PermissionOptionKind::RejectAlways => {
this.icon(IconName::X).icon_color(Color::Error)
}
})
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Success)
.on_click(cx.listener({
let id = tool_call_id;
let tool_call_id = tool_call_id.clone();
let option_id = option.id.clone();
let option_kind = option.kind;
move |this, _, _, cx| {
this.authorize_tool_call(id, outcome, cx);
this.authorize_tool_call(
tool_call_id.clone(),
option_id.clone(),
option_kind,
cx,
);
}
}))
}))
})
.child(
Button::new(("allow", tool_call_id.0), "Allow")
.icon(IconName::Check)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Success)
.on_click(cx.listener({
let id = tool_call_id;
move |this, _, _, cx| {
this.authorize_tool_call(
id,
acp::ToolCallConfirmationOutcome::Allow,
cx,
);
}
})),
)
.child(
Button::new(("reject", tool_call_id.0), "Reject")
.icon(IconName::X)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Error)
.on_click(cx.listener({
let id = tool_call_id;
move |this, _, _, cx| {
this.authorize_tool_call(
id,
acp::ToolCallConfirmationOutcome::Reject,
cx,
);
}
})),
)
}))
}
fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
@@ -2245,12 +2104,11 @@ impl AcpThreadView {
.languages
.language_for_name("Markdown");
let (thread_summary, markdown) = match &self.thread_state {
ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => {
let thread = thread.read(cx);
(thread.title().to_string(), thread.to_markdown(cx))
}
ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())),
let (thread_summary, markdown) = if let Some(thread) = self.thread() {
let thread = thread.read(cx);
(thread.title().to_string(), thread.to_markdown(cx))
} else {
return Task::ready(Ok(()));
};
window.spawn(cx, async move |cx| {

View File

@@ -186,6 +186,7 @@ impl AgentConfiguration {
};
v_flex()
.w_full()
.when(is_expanded, |this| this.mb_2())
.child(
div()
@@ -216,6 +217,7 @@ impl AgentConfiguration {
.hover(|hover| hover.bg(cx.theme().colors().element_hover))
.child(
h_flex()
.w_full()
.gap_2()
.child(
Icon::new(provider.icon())
@@ -224,6 +226,7 @@ impl AgentConfiguration {
)
.child(
h_flex()
.w_full()
.gap_1()
.child(
Label::new(provider_name.clone())
@@ -307,6 +310,7 @@ impl AgentConfiguration {
let providers = LanguageModelRegistry::read_global(cx).providers();
v_flex()
.w_full()
.child(
h_flex()
.p(DynamicSpacing::Base16.rems(cx))
@@ -317,50 +321,67 @@ impl AgentConfiguration {
.justify_between()
.child(
v_flex()
.w_full()
.gap_0p5()
.child(Headline::new("LLM Providers"))
.child(
h_flex()
.w_full()
.gap_2()
.justify_between()
.child(Headline::new("LLM Providers"))
.child(
PopoverMenu::new("add-provider-popover")
.trigger(
Button::new("add-provider", "Add Provider")
.icon_position(IconPosition::Start)
.icon(IconName::Plus)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.label_size(LabelSize::Small),
)
.anchor(gpui::Corner::TopRight)
.menu({
let workspace = self.workspace.clone();
move |window, cx| {
Some(ContextMenu::build(
window,
cx,
|menu, _window, _cx| {
menu.header("Compatible APIs").entry(
"OpenAI",
None,
{
let workspace =
workspace.clone();
move |window, cx| {
workspace
.update(cx, |workspace, cx| {
AddLlmProviderModal::toggle(
LlmCompatibleProvider::OpenAi,
workspace,
window,
cx,
);
})
.log_err();
}
},
)
},
))
}
}),
),
)
.child(
Label::new("Add at least one provider to use AI-powered features.")
.color(Color::Muted),
),
)
.child(
PopoverMenu::new("add-provider-popover")
.trigger(
Button::new("add-provider", "Add Provider")
.icon_position(IconPosition::Start)
.icon(IconName::Plus)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.label_size(LabelSize::Small),
)
.anchor(gpui::Corner::TopRight)
.menu({
let workspace = self.workspace.clone();
move |window, cx| {
Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
menu.header("Compatible APIs").entry("OpenAI", None, {
let workspace = workspace.clone();
move |window, cx| {
workspace
.update(cx, |workspace, cx| {
AddLlmProviderModal::toggle(
LlmCompatibleProvider::OpenAi,
workspace,
window,
cx,
);
})
.log_err();
}
})
}))
}
}),
),
)
.child(
div()
.w_full()
.pl(DynamicSpacing::Base08.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.children(

View File

@@ -1506,8 +1506,7 @@ impl AgentDiff {
.read(cx)
.entries()
.last()
.and_then(|entry| entry.diff())
.is_some()
.map_or(false, |entry| entry.diffs().next().is_some())
{
self.update_reviewing_editors(workspace, window, cx);
}
@@ -1517,8 +1516,7 @@ impl AgentDiff {
.read(cx)
.entries()
.get(*ix)
.and_then(|entry| entry.diff())
.is_some()
.map_or(false, |entry| entry.diffs().next().is_some())
{
self.update_reviewing_editors(workspace, window, cx);
}

View File

@@ -440,7 +440,7 @@ pub struct AgentPanel {
local_timezone: UtcOffset,
active_view: ActiveView,
acp_message_history:
Rc<RefCell<crate::acp::MessageHistory<agentic_coding_protocol::SendUserMessageParams>>>,
Rc<RefCell<crate::acp::MessageHistory<Vec<agent_client_protocol::ContentBlock>>>>,
previous_view: Option<ActiveView>,
history_store: Entity<HistoryStore>,
history: Entity<ThreadHistory>,

View File

@@ -625,7 +625,7 @@ impl MessageEditor {
.unwrap_or(false);
IconButton::new("follow-agent", IconName::Crosshair)
.disabled(is_model_selected)
.disabled(!is_model_selected)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.toggle_state(following)

View File

@@ -237,7 +237,7 @@ impl ZedAiOnboarding {
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.on_click(move |_, _window, cx| {
telemetry::event!("Review Terms of Service Click");
telemetry::event!("Review Terms of Service Clicked");
cx.open_url(&zed_urls::terms_of_service(cx))
}),
)
@@ -248,7 +248,7 @@ impl ZedAiOnboarding {
.on_click({
let callback = self.accept_terms_of_service.clone();
move |_, window, cx| {
telemetry::event!("Accepted Terms of Service");
telemetry::event!("Terms of Service Accepted");
(callback)(window, cx)}
}),
)

View File

@@ -5,16 +5,8 @@ use collections::{HashMap, HashSet};
use reqwest::StatusCode;
use sea_orm::ActiveValue;
use serde::{Deserialize, Serialize};
use std::{str::FromStr, sync::Arc, time::Duration};
use stripe::{
BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession,
CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion,
CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm,
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems,
CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents,
PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
};
use std::{sync::Arc, time::Duration};
use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus};
use util::{ResultExt, maybe};
use zed_llm_client::LanguageModelProvider;
@@ -31,7 +23,7 @@ use crate::{AppState, Error, Result};
use crate::{db::UserId, llm::db::LlmDatabase};
use crate::{
db::{
BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams,
CreateBillingCustomerParams, CreateBillingSubscriptionParams,
CreateProcessedStripeEventParams, UpdateBillingCustomerParams,
UpdateBillingSubscriptionParams, billing_customer,
},
@@ -39,260 +31,10 @@ use crate::{
};
pub fn router() -> Router {
Router::new()
.route(
"/billing/subscriptions/manage",
post(manage_billing_subscription),
)
.route(
"/billing/subscriptions/sync",
post(sync_billing_subscription),
)
}
#[derive(Debug, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ManageSubscriptionIntent {
/// The user intends to manage their subscription.
///
/// This will open the Stripe billing portal without putting the user in a specific flow.
ManageSubscription,
/// The user intends to update their payment method.
UpdatePaymentMethod,
/// The user intends to upgrade to Zed Pro.
UpgradeToPro,
/// The user intends to cancel their subscription.
Cancel,
/// The user intends to stop the cancellation of their subscription.
StopCancellation,
}
#[derive(Debug, Deserialize)]
struct ManageBillingSubscriptionBody {
github_user_id: i32,
intent: ManageSubscriptionIntent,
/// The ID of the subscription to manage.
subscription_id: BillingSubscriptionId,
redirect_to: Option<String>,
}
#[derive(Debug, Serialize)]
struct ManageBillingSubscriptionResponse {
billing_portal_session_url: Option<String>,
}
/// Initiates a Stripe customer portal session for managing a billing subscription.
async fn manage_billing_subscription(
Extension(app): Extension<Arc<AppState>>,
extract::Json(body): extract::Json<ManageBillingSubscriptionBody>,
) -> Result<Json<ManageBillingSubscriptionResponse>> {
let user = app
.db
.get_user_by_github_user_id(body.github_user_id)
.await?
.context("user not found")?;
let Some(stripe_client) = app.real_stripe_client.clone() else {
log::error!("failed to retrieve Stripe client");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let Some(stripe_billing) = app.stripe_billing.clone() else {
log::error!("failed to retrieve Stripe billing object");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let customer = app
.db
.get_billing_customer_by_user_id(user.id)
.await?
.context("billing customer not found")?;
let customer_id = CustomerId::from_str(&customer.stripe_customer_id)
.context("failed to parse customer ID")?;
let subscription = app
.db
.get_billing_subscription_by_id(body.subscription_id)
.await?
.context("subscription not found")?;
let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id)
.context("failed to parse subscription ID")?;
if body.intent == ManageSubscriptionIntent::StopCancellation {
let updated_stripe_subscription = Subscription::update(
&stripe_client,
&subscription_id,
stripe::UpdateSubscription {
cancel_at_period_end: Some(false),
..Default::default()
},
)
.await?;
app.db
.update_billing_subscription(
subscription.id,
&UpdateBillingSubscriptionParams {
stripe_cancel_at: ActiveValue::set(
updated_stripe_subscription
.cancel_at
.and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0))
.map(|time| time.naive_utc()),
),
..Default::default()
},
)
.await?;
return Ok(Json(ManageBillingSubscriptionResponse {
billing_portal_session_url: None,
}));
}
let flow = match body.intent {
ManageSubscriptionIntent::ManageSubscription => None,
ManageSubscriptionIntent::UpgradeToPro => {
let zed_pro_price_id: stripe::PriceId =
stripe_billing.zed_pro_price_id().await?.try_into()?;
let zed_free_price_id: stripe::PriceId =
stripe_billing.zed_free_price_id().await?.try_into()?;
let stripe_subscription =
Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?;
let is_on_zed_pro_trial = stripe_subscription.status == SubscriptionStatus::Trialing
&& stripe_subscription.items.data.iter().any(|item| {
item.price
.as_ref()
.map_or(false, |price| price.id == zed_pro_price_id)
});
if is_on_zed_pro_trial {
let payment_methods = PaymentMethod::list(
&stripe_client,
&stripe::ListPaymentMethods {
customer: Some(stripe_subscription.customer.id()),
..Default::default()
},
)
.await?;
let has_payment_method = !payment_methods.data.is_empty();
if !has_payment_method {
return Err(Error::http(
StatusCode::BAD_REQUEST,
"missing payment method".into(),
));
}
// If the user is already on a Zed Pro trial and wants to upgrade to Pro, we just need to end their trial early.
Subscription::update(
&stripe_client,
&stripe_subscription.id,
stripe::UpdateSubscription {
trial_end: Some(stripe::Scheduled::now()),
..Default::default()
},
)
.await?;
return Ok(Json(ManageBillingSubscriptionResponse {
billing_portal_session_url: None,
}));
}
let subscription_item_to_update = stripe_subscription
.items
.data
.iter()
.find_map(|item| {
let price = item.price.as_ref()?;
if price.id == zed_free_price_id {
Some(item.id.clone())
} else {
None
}
})
.context("No subscription item to update")?;
Some(CreateBillingPortalSessionFlowData {
type_: CreateBillingPortalSessionFlowDataType::SubscriptionUpdateConfirm,
subscription_update_confirm: Some(
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm {
subscription: subscription.stripe_subscription_id,
items: vec![
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems {
id: subscription_item_to_update.to_string(),
price: Some(zed_pro_price_id.to_string()),
quantity: Some(1),
},
],
discounts: None,
},
),
..Default::default()
})
}
ManageSubscriptionIntent::UpdatePaymentMethod => Some(CreateBillingPortalSessionFlowData {
type_: CreateBillingPortalSessionFlowDataType::PaymentMethodUpdate,
after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
return_url: format!(
"{}{path}",
app.config.zed_dot_dev_url(),
path = body.redirect_to.unwrap_or_else(|| "/account".to_string())
),
}),
..Default::default()
}),
..Default::default()
}),
ManageSubscriptionIntent::Cancel => {
if subscription.kind == Some(SubscriptionKind::ZedFree) {
return Err(Error::http(
StatusCode::BAD_REQUEST,
"free subscription cannot be canceled".into(),
));
}
Some(CreateBillingPortalSessionFlowData {
type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,
after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
return_url: format!("{}/account", app.config.zed_dot_dev_url()),
}),
..Default::default()
}),
subscription_cancel: Some(
stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel {
subscription: subscription.stripe_subscription_id,
retention: None,
},
),
..Default::default()
})
}
ManageSubscriptionIntent::StopCancellation => unreachable!(),
};
let mut params = CreateBillingPortalSession::new(customer_id);
params.flow_data = flow;
let return_url = format!("{}/account", app.config.zed_dot_dev_url());
params.return_url = Some(&return_url);
let session = BillingPortalSession::create(&stripe_client, params).await?;
Ok(Json(ManageBillingSubscriptionResponse {
billing_portal_session_url: Some(session.url),
}))
Router::new().route(
"/billing/subscriptions/sync",
post(sync_billing_subscription),
)
}
#[derive(Debug, Deserialize)]

View File

@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result, anyhow};
use collections::HashMap;
use futures::{FutureExt, StreamExt, channel::oneshot, select};
use futures::{FutureExt, StreamExt, channel::oneshot, future, select};
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task};
use parking_lot::Mutex;
use postage::barrier;
@@ -10,15 +10,19 @@ use smol::channel;
use std::{
fmt,
path::PathBuf,
pin::pin,
sync::{
Arc,
atomic::{AtomicI32, Ordering::SeqCst},
},
time::{Duration, Instant},
};
use util::TryFutureExt;
use util::{ResultExt, TryFutureExt};
use crate::transport::{StdioTransport, Transport};
use crate::{
transport::{StdioTransport, Transport},
types::{CancelledParams, ClientNotification, Notification as _, notifications::Cancelled},
};
const JSON_RPC_VERSION: &str = "2.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
@@ -32,6 +36,7 @@ pub const INTERNAL_ERROR: i32 = -32603;
type ResponseHandler = Box<dyn Send + FnOnce(Result<String, Error>)>;
type NotificationHandler = Box<dyn Send + FnMut(Value, AsyncApp)>;
type RequestHandler = Box<dyn Send + FnMut(RequestId, &RawValue, AsyncApp)>;
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(untagged)]
@@ -78,6 +83,15 @@ pub struct Request<'a, T> {
pub params: T,
}
#[derive(Serialize, Deserialize)]
pub struct AnyRequest<'a> {
pub jsonrpc: &'a str,
pub id: RequestId,
pub method: &'a str,
#[serde(skip_serializing_if = "is_null_value")]
pub params: Option<&'a RawValue>,
}
#[derive(Serialize, Deserialize)]
struct AnyResponse<'a> {
jsonrpc: &'a str,
@@ -176,15 +190,23 @@ impl Client {
Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default()));
let response_handlers =
Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default())));
let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default()));
let receive_input_task = cx.spawn({
let notification_handlers = notification_handlers.clone();
let response_handlers = response_handlers.clone();
let request_handlers = request_handlers.clone();
let transport = transport.clone();
async move |cx| {
Self::handle_input(transport, notification_handlers, response_handlers, cx)
.log_err()
.await
Self::handle_input(
transport,
notification_handlers,
request_handlers,
response_handlers,
cx,
)
.log_err()
.await
}
});
let receive_err_task = cx.spawn({
@@ -230,13 +252,24 @@ impl Client {
async fn handle_input(
transport: Arc<dyn Transport>,
notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
request_handlers: Arc<Mutex<HashMap<&'static str, RequestHandler>>>,
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
cx: &mut AsyncApp,
) -> anyhow::Result<()> {
let mut receiver = transport.receive();
while let Some(message) = receiver.next().await {
if let Ok(response) = serde_json::from_str::<AnyResponse>(&message) {
log::trace!("recv: {}", &message);
if let Ok(request) = serde_json::from_str::<AnyRequest>(&message) {
let mut request_handlers = request_handlers.lock();
if let Some(handler) = request_handlers.get_mut(request.method) {
handler(
request.id,
request.params.unwrap_or(RawValue::NULL),
cx.clone(),
);
}
} else if let Ok(response) = serde_json::from_str::<AnyResponse>(&message) {
if let Some(handlers) = response_handlers.lock().as_mut() {
if let Some(handler) = handlers.remove(&response.id) {
handler(Ok(message.to_string()));
@@ -247,6 +280,8 @@ impl Client {
if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) {
handler(notification.params.unwrap_or(Value::Null), cx.clone());
}
} else {
log::error!("Unhandled JSON from context_server: {}", message);
}
}
@@ -294,6 +329,24 @@ impl Client {
&self,
method: &str,
params: impl Serialize,
) -> Result<T> {
self.request_impl(method, params, None).await
}
pub async fn cancellable_request<T: DeserializeOwned>(
&self,
method: &str,
params: impl Serialize,
cancel_rx: oneshot::Receiver<()>,
) -> Result<T> {
self.request_impl(method, params, Some(cancel_rx)).await
}
pub async fn request_impl<T: DeserializeOwned>(
&self,
method: &str,
params: impl Serialize,
cancel_rx: Option<oneshot::Receiver<()>>,
) -> Result<T> {
let id = self.next_id.fetch_add(1, SeqCst);
let request = serde_json::to_string(&Request {
@@ -330,6 +383,16 @@ impl Client {
send?;
let mut timeout = executor.timer(REQUEST_TIMEOUT).fuse();
let mut cancel_fut = pin!(
match cancel_rx {
Some(rx) => future::Either::Left(async {
rx.await.log_err();
}),
None => future::Either::Right(future::pending()),
}
.fuse()
);
select! {
response = rx.fuse() => {
let elapsed = started.elapsed();
@@ -348,6 +411,16 @@ impl Client {
Err(_) => anyhow::bail!("cancelled")
}
}
_ = cancel_fut => {
self.notify(
Cancelled::METHOD,
ClientNotification::Cancelled(CancelledParams {
request_id: RequestId::Int(id),
reason: None
})
).log_err();
anyhow::bail!("Request cancelled")
}
_ = timeout => {
log::error!("cancelled csp request task for {method:?} id {id} which took over {:?}", REQUEST_TIMEOUT);
anyhow::bail!("Context server request timeout");

View File

@@ -6,6 +6,9 @@
//! of messages.
use anyhow::Result;
use futures::channel::oneshot;
use gpui::AsyncApp;
use serde_json::Value;
use crate::client::Client;
use crate::types::{self, Notification, Request};
@@ -95,7 +98,24 @@ impl InitializedContextServerProtocol {
self.inner.request(T::METHOD, params).await
}
pub async fn cancellable_request<T: Request>(
&self,
params: T::Params,
cancel_rx: oneshot::Receiver<()>,
) -> Result<T::Response> {
self.inner
.cancellable_request(T::METHOD, params, cancel_rx)
.await
}
pub fn notify<T: Notification>(&self, params: T::Params) -> Result<()> {
self.inner.notify(T::METHOD, params)
}
pub fn on_notification<F>(&self, method: &'static str, f: F)
where
F: 'static + Send + FnMut(Value, AsyncApp),
{
self.inner.on_notification(method, f);
}
}

View File

@@ -3,6 +3,8 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::client::RequestId;
pub const LATEST_PROTOCOL_VERSION: &str = "2025-03-26";
pub const VERSION_2024_11_05: &str = "2024-11-05";
@@ -100,6 +102,7 @@ pub mod notifications {
notification!("notifications/initialized", Initialized, ());
notification!("notifications/progress", Progress, ProgressParams);
notification!("notifications/message", Message, MessageParams);
notification!("notifications/cancelled", Cancelled, CancelledParams);
notification!(
"notifications/resources/updated",
ResourcesUpdated,
@@ -617,11 +620,14 @@ pub enum ClientNotification {
Initialized,
Progress(ProgressParams),
RootsListChanged,
Cancelled {
request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Cancelled(CancelledParams),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CancelledParams {
pub request_id: RequestId,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -365,6 +365,8 @@ actions!(
ConvertToLowerCase,
/// Toggles the case of selected text.
ConvertToOppositeCase,
/// Converts selected text to sentence case.
ConvertToSentenceCase,
/// Converts selected text to snake_case.
ConvertToSnakeCase,
/// Converts selected text to Title Case.

View File

@@ -844,7 +844,7 @@ impl CompletionsMenu {
.with_sizing_behavior(ListSizingBehavior::Infer)
.w(rems(34.));
Popover::new().child(list).into_any_element()
Popover::new().child(div().child(list)).into_any_element()
}
fn render_aside(

View File

@@ -10878,17 +10878,6 @@ impl Editor {
});
}
pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context<Self>) {
self.manipulate_text(window, cx, |text| {
let has_upper_case_characters = text.chars().any(|c| c.is_uppercase());
if has_upper_case_characters {
text.to_lowercase()
} else {
text.to_uppercase()
}
})
}
fn manipulate_immutable_lines<Fn>(
&mut self,
window: &mut Window,
@@ -11144,6 +11133,26 @@ impl Editor {
})
}
pub fn convert_to_sentence_case(
&mut self,
_: &ConvertToSentenceCase,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.manipulate_text(window, cx, |text| text.to_case(Case::Sentence))
}
pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context<Self>) {
self.manipulate_text(window, cx, |text| {
let has_upper_case_characters = text.chars().any(|c| c.is_uppercase());
if has_upper_case_characters {
text.to_lowercase()
} else {
text.to_uppercase()
}
})
}
pub fn convert_to_rot13(
&mut self,
_: &ConvertToRot13,
@@ -16968,7 +16977,7 @@ impl Editor {
now: Instant,
window: &mut Window,
cx: &mut Context<Self>,
) {
) -> Option<TransactionId> {
self.end_selection(window, cx);
if let Some(tx_id) = self
.buffer
@@ -16978,7 +16987,10 @@ impl Editor {
.insert_transaction(tx_id, self.selections.disjoint_anchors());
cx.emit(EditorEvent::TransactionBegun {
transaction_id: tx_id,
})
});
Some(tx_id)
} else {
None
}
}
@@ -17006,6 +17018,17 @@ impl Editor {
}
}
pub fn modify_transaction_selection_history(
&mut self,
transaction_id: TransactionId,
modify: impl FnOnce(&mut (Arc<[Selection<Anchor>]>, Option<Arc<[Selection<Anchor>]>>)),
) -> bool {
self.selection_history
.transaction_mut(transaction_id)
.map(modify)
.is_some()
}
pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context<Self>) {
if self.selection_mark_mode {
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
@@ -22258,7 +22281,7 @@ fn consume_contiguous_rows(
selections: &mut Peekable<std::slice::Iter<Selection<Point>>>,
) -> (MultiBufferRow, MultiBufferRow) {
contiguous_row_selections.push(selection.clone());
let start_row = MultiBufferRow(selection.start.row);
let start_row = starting_row(selection, display_map);
let mut end_row = ending_row(selection, display_map);
while let Some(next_selection) = selections.peek() {
@@ -22272,6 +22295,14 @@ fn consume_contiguous_rows(
(start_row, end_row)
}
fn starting_row(selection: &Selection<Point>, display_map: &DisplaySnapshot) -> MultiBufferRow {
if selection.start.column > 0 {
MultiBufferRow(display_map.prev_line_boundary(selection.start).0.row)
} else {
MultiBufferRow(selection.start.row)
}
}
fn ending_row(next_selection: &Selection<Point>, display_map: &DisplaySnapshot) -> MultiBufferRow {
if next_selection.end.column > 0 || next_selection.is_empty() {
MultiBufferRow(display_map.next_line_boundary(next_selection.end).0.row + 1)

View File

@@ -4724,6 +4724,23 @@ async fn test_toggle_case(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
async fn test_convert_to_sentence_case(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.set_state(indoc! {"
«implement-windows-supportˇ»
"});
cx.update_editor(|e, window, cx| {
e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx)
});
cx.assert_editor_state(indoc! {"
«Implement windows supportˇ»
"});
}
#[gpui::test]
async fn test_manipulate_text(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -5069,6 +5086,33 @@ fn test_move_line_up_down(cx: &mut TestAppContext) {
});
}
#[gpui::test]
fn test_move_line_up_selection_at_end_of_fold(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let editor = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple("\n\n\n\n\n\naaaa\nbbbb\ncccc", cx);
build_editor(buffer, window, cx)
});
_ = editor.update(cx, |editor, window, cx| {
editor.fold_creases(
vec![Crease::simple(
Point::new(6, 4)..Point::new(7, 4),
FoldPlaceholder::test(),
)],
true,
window,
cx,
);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(7, 4)..Point::new(7, 4)])
});
assert_eq!(editor.display_text(cx), "\n\n\n\n\n\naaaa⋯\ncccc");
editor.move_line_up(&MoveLineUp, window, cx);
let buffer_text = editor.buffer.read(cx).snapshot(cx).text();
assert_eq!(buffer_text, "\n\n\n\n\naaaa\nbbbb\n\ncccc");
});
}
#[gpui::test]
fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -16837,7 +16881,7 @@ async fn test_multibuffer_reverts(cx: &mut TestAppContext) {
}
#[gpui::test]
async fn test_mutlibuffer_in_navigation_history(cx: &mut TestAppContext) {
async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let cols = 4;

View File

@@ -230,7 +230,6 @@ impl EditorElement {
register_action(editor, window, Editor::sort_lines_case_insensitive);
register_action(editor, window, Editor::reverse_lines);
register_action(editor, window, Editor::shuffle_lines);
register_action(editor, window, Editor::toggle_case);
register_action(editor, window, Editor::convert_indentation_to_spaces);
register_action(editor, window, Editor::convert_indentation_to_tabs);
register_action(editor, window, Editor::convert_to_upper_case);
@@ -241,6 +240,8 @@ impl EditorElement {
register_action(editor, window, Editor::convert_to_upper_camel_case);
register_action(editor, window, Editor::convert_to_lower_camel_case);
register_action(editor, window, Editor::convert_to_opposite_case);
register_action(editor, window, Editor::convert_to_sentence_case);
register_action(editor, window, Editor::toggle_case);
register_action(editor, window, Editor::convert_to_rot13);
register_action(editor, window, Editor::convert_to_rot47);
register_action(editor, window, Editor::delete_to_previous_word_start);
@@ -4010,6 +4011,7 @@ impl EditorElement {
let available_width = hitbox.bounds.size.width - right_margin;
let mut header = v_flex()
.w_full()
.relative()
.child(
div()

View File

@@ -12,6 +12,7 @@ use language::{self, Buffer, Point};
use project::Project;
use std::{
any::{Any, TypeId},
cmp,
ops::Range,
pin::pin,
sync::Arc,
@@ -45,38 +46,60 @@ impl TextDiffView {
) -> Option<Task<Result<Entity<Self>>>> {
let source_editor = diff_data.editor.clone();
let source_editor_buffer_and_range = source_editor.update(cx, |editor, cx| {
let selection_data = source_editor.update(cx, |editor, cx| {
let multibuffer = editor.buffer().read(cx);
let source_buffer = multibuffer.as_singleton()?.clone();
let selections = editor.selections.all::<Point>(cx);
let buffer_snapshot = source_buffer.read(cx);
let first_selection = selections.first()?;
let selection_range = if first_selection.is_empty() {
Point::new(0, 0)..buffer_snapshot.max_point()
} else {
first_selection.start..first_selection.end
};
let max_point = buffer_snapshot.max_point();
Some((source_buffer, selection_range))
if first_selection.is_empty() {
let full_range = Point::new(0, 0)..max_point;
return Some((source_buffer, full_range));
}
let start = first_selection.start;
let end = first_selection.end;
let expanded_start = Point::new(start.row, 0);
let expanded_end = if end.column > 0 {
let next_row = end.row + 1;
cmp::min(max_point, Point::new(next_row, 0))
} else {
end
};
Some((source_buffer, expanded_start..expanded_end))
});
let Some((source_buffer, selected_range)) = source_editor_buffer_and_range else {
let Some((source_buffer, expanded_selection_range)) = selection_data else {
log::warn!("There should always be at least one selection in Zed. This is a bug.");
return None;
};
let clipboard_text = diff_data.clipboard_text.clone();
let workspace = workspace.weak_handle();
let diff_buffer = cx.new(|cx| {
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
let diff = BufferDiff::new(&source_buffer_snapshot.text, cx);
diff
source_editor.update(cx, |source_editor, cx| {
source_editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(vec![
expanded_selection_range.start..expanded_selection_range.end,
]);
})
});
let clipboard_buffer =
build_clipboard_buffer(clipboard_text, &source_buffer, selected_range.clone(), cx);
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
let mut clipboard_text = diff_data.clipboard_text.clone();
if !clipboard_text.ends_with("\n") {
clipboard_text.push_str("\n");
}
let workspace = workspace.weak_handle();
let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
let clipboard_buffer = build_clipboard_buffer(
clipboard_text,
&source_buffer,
expanded_selection_range.clone(),
cx,
);
let task = window.spawn(cx, async move |cx| {
let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
@@ -89,7 +112,7 @@ impl TextDiffView {
clipboard_buffer,
source_editor,
source_buffer,
selected_range,
expanded_selection_range,
diff_buffer,
project,
window,
@@ -208,9 +231,9 @@ impl TextDiffView {
}
fn build_clipboard_buffer(
clipboard_text: String,
text: String,
source_buffer: &Entity<Buffer>,
selected_range: Range<Point>,
replacement_range: Range<Point>,
cx: &mut App,
) -> Entity<Buffer> {
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
@@ -219,9 +242,9 @@ fn build_clipboard_buffer(
let language = source_buffer.read(cx).language().cloned();
buffer.set_language(language, cx);
let range_start = source_buffer_snapshot.point_to_offset(selected_range.start);
let range_end = source_buffer_snapshot.point_to_offset(selected_range.end);
buffer.edit([(range_start..range_end, clipboard_text)], None, cx);
let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
buffer.edit([(range_start..range_end, text)], None, cx);
buffer
})
@@ -293,7 +316,7 @@ impl Item for TextDiffView {
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Diff View Opened")
Some("Selection Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -395,21 +418,13 @@ pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
let buffer_snapshot = buffer.snapshot(cx);
let first_selection = editor.selections.disjoint.first()?;
let (start_row, start_column, end_row, end_column) =
if first_selection.start == first_selection.end {
let max_point = buffer_snapshot.max_point();
(0, 0, max_point.row, max_point.column)
} else {
let selection_start = first_selection.start.to_point(&buffer_snapshot);
let selection_end = first_selection.end.to_point(&buffer_snapshot);
let selection_start = first_selection.start.to_point(&buffer_snapshot);
let selection_end = first_selection.end.to_point(&buffer_snapshot);
(
selection_start.row,
selection_start.column,
selection_end.row,
selection_end.column,
)
};
let start_row = selection_start.row;
let start_column = selection_start.column;
let end_row = selection_end.row;
let end_column = selection_end.column;
let range_text = if start_row == end_row {
format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
@@ -435,14 +450,13 @@ impl Render for TextDiffView {
#[cfg(test)]
mod tests {
use super::*;
use editor::{actions, test::editor_test_context::assert_state_with_diff};
use editor::test::editor_test_context::assert_state_with_diff;
use gpui::{TestAppContext, VisualContext};
use project::{FakeFs, Project};
use serde_json::json;
use settings::{Settings, SettingsStore};
use unindent::unindent;
use util::path;
use util::{path, test::marked_text_ranges};
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
@@ -457,52 +471,236 @@ mod tests {
}
#[gpui::test]
async fn test_diffing_clipboard_against_specific_selection(cx: &mut TestAppContext) {
base_test(true, cx).await;
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
"def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
&unindent(
"
- def process_incoming_inventory(items, warehouse_id):
+ ˇdef process_outgoing_inventory(items, warehouse_id):
pass
",
),
"Clipboard ↔ text.txt @ L1:1-L3:1",
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer(
async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
cx: &mut TestAppContext,
) {
base_test(false, cx).await;
base_test(
path!("/test"),
path!("/test/text.txt"),
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
"«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
&unindent(
"
- def process_incoming_inventory(items, warehouse_id):
+ ˇdef process_outgoing_inventory(items, warehouse_id):
pass
",
),
"Clipboard ↔ text.txt @ L1:1-L3:1",
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
cx,
)
.await;
}
async fn base_test(select_all_text: bool, cx: &mut TestAppContext) {
#[gpui::test]
async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"«bbˇ»",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
"«bbˇ»",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
" «bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"« bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
" «bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
"« bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"«bˇ»b",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
async fn base_test(
project_root: &str,
file_path: &str,
clipboard_text: &str,
editor_text: &str,
expected_diff: &str,
expected_tab_title: &str,
expected_tab_tooltip: &str,
cx: &mut TestAppContext,
) {
init_test(cx);
let file_name = std::path::Path::new(file_path)
.file_name()
.unwrap()
.to_str()
.unwrap();
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/test"),
project_root,
json!({
"a": {
"b": {
"text.txt": "new line 1\nline 2\nnew line 3\nline 4"
}
}
file_name: editor_text
}),
)
.await;
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
let project = Project::test(fs, [project_root.as_ref()], cx).await;
let (workspace, mut cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/test/a/b/text.txt"), cx)
})
.update(cx, |project, cx| project.open_local_buffer(file_path, cx))
.await
.unwrap();
let editor = cx.new_window_entity(|window, cx| {
let mut editor = Editor::for_buffer(buffer, None, window, cx);
editor.set_text("new line 1\nline 2\nnew line 3\nline 4\n", window, cx);
if select_all_text {
editor.select_all(&actions::SelectAll, window, cx);
}
let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
editor.set_text(unmarked_text, window, cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(selection_ranges)
});
editor
});
@@ -511,7 +709,7 @@ mod tests {
.update_in(cx, |workspace, window, cx| {
TextDiffView::open(
&DiffClipboardWithSelectionData {
clipboard_text: "old line 1\nline 2\nold line 3\nline 4\n".to_string(),
clipboard_text: clipboard_text.to_string(),
editor,
},
workspace,
@@ -528,26 +726,14 @@ mod tests {
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
&mut cx,
&unindent(
"
- old line 1
+ ˇnew line 1
line 2
- old line 3
+ new line 3
line 4
",
),
expected_diff,
);
diff_view.read_with(cx, |diff_view, cx| {
assert_eq!(
diff_view.tab_content_text(0, cx),
"Clipboard ↔ text.txt @ L1:1-L5:1"
);
assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
assert_eq!(
diff_view.tab_tooltip_text(cx).unwrap(),
format!("Clipboard ↔ {}", path!("test/a/b/text.txt @ L1:1-L5:1"))
expected_tab_tooltip
);
});
}

View File

@@ -121,7 +121,7 @@ smallvec.workspace = true
smol.workspace = true
strum.workspace = true
sum_tree.workspace = true
taffy = "=0.5.1"
taffy = "=0.8.3"
thiserror.workspace = true
util.workspace = true
uuid.workspace = true

View File

@@ -1334,7 +1334,6 @@ impl Element for Div {
} else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() {
let mut state = scroll_handle.0.borrow_mut();
state.child_bounds = Vec::with_capacity(request_layout.child_layout_ids.len());
state.bounds = bounds;
for child_layout_id in &request_layout.child_layout_ids {
let child_bounds = window.layout_bounds(*child_layout_id);
child_min = child_min.min(&child_bounds.origin);
@@ -1706,6 +1705,7 @@ impl Interactivity {
if let Some(mut scroll_handle_state) = tracked_scroll_handle {
scroll_handle_state.max_offset = scroll_max;
scroll_handle_state.bounds = bounds;
}
*scroll_offset
@@ -3007,11 +3007,6 @@ impl ScrollHandle {
self.0.borrow().bounds
}
/// Set the bounds into which this child is painted
pub(super) fn set_bounds(&self, bounds: Bounds<Pixels>) {
self.0.borrow_mut().bounds = bounds;
}
/// Get the bounds for a specific child.
pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
self.0.borrow().child_bounds.get(ix).cloned()

View File

@@ -295,9 +295,8 @@ impl Element for UniformList {
bounds.bottom_right() - point(border.right + padding.right, border.bottom),
);
let y_flipped = if let Some(scroll_handle) = self.scroll_handle.as_mut() {
let mut scroll_state = scroll_handle.0.borrow_mut();
scroll_state.base_handle.set_bounds(bounds);
let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
let scroll_state = scroll_handle.0.borrow();
scroll_state.y_flipped
} else {
false

View File

@@ -283,7 +283,7 @@ impl ToTaffy<taffy::style::LengthPercentageAuto> for Length {
fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::LengthPercentageAuto {
match self {
Length::Definite(length) => length.to_taffy(rem_size),
Length::Auto => taffy::prelude::LengthPercentageAuto::Auto,
Length::Auto => taffy::prelude::LengthPercentageAuto::auto(),
}
}
}
@@ -292,7 +292,7 @@ impl ToTaffy<taffy::style::Dimension> for Length {
fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::Dimension {
match self {
Length::Definite(length) => length.to_taffy(rem_size),
Length::Auto => taffy::prelude::Dimension::Auto,
Length::Auto => taffy::prelude::Dimension::auto(),
}
}
}
@@ -302,14 +302,14 @@ impl ToTaffy<taffy::style::LengthPercentage> for DefiniteLength {
match self {
DefiniteLength::Absolute(length) => match length {
AbsoluteLength::Pixels(pixels) => {
taffy::style::LengthPercentage::Length(pixels.into())
taffy::style::LengthPercentage::length(pixels.into())
}
AbsoluteLength::Rems(rems) => {
taffy::style::LengthPercentage::Length((*rems * rem_size).into())
taffy::style::LengthPercentage::length((*rems * rem_size).into())
}
},
DefiniteLength::Fraction(fraction) => {
taffy::style::LengthPercentage::Percent(*fraction)
taffy::style::LengthPercentage::percent(*fraction)
}
}
}
@@ -320,14 +320,14 @@ impl ToTaffy<taffy::style::LengthPercentageAuto> for DefiniteLength {
match self {
DefiniteLength::Absolute(length) => match length {
AbsoluteLength::Pixels(pixels) => {
taffy::style::LengthPercentageAuto::Length(pixels.into())
taffy::style::LengthPercentageAuto::length(pixels.into())
}
AbsoluteLength::Rems(rems) => {
taffy::style::LengthPercentageAuto::Length((*rems * rem_size).into())
taffy::style::LengthPercentageAuto::length((*rems * rem_size).into())
}
},
DefiniteLength::Fraction(fraction) => {
taffy::style::LengthPercentageAuto::Percent(*fraction)
taffy::style::LengthPercentageAuto::percent(*fraction)
}
}
}
@@ -337,12 +337,12 @@ impl ToTaffy<taffy::style::Dimension> for DefiniteLength {
fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Dimension {
match self {
DefiniteLength::Absolute(length) => match length {
AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::Length(pixels.into()),
AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::length(pixels.into()),
AbsoluteLength::Rems(rems) => {
taffy::style::Dimension::Length((*rems * rem_size).into())
taffy::style::Dimension::length((*rems * rem_size).into())
}
},
DefiniteLength::Fraction(fraction) => taffy::style::Dimension::Percent(*fraction),
DefiniteLength::Fraction(fraction) => taffy::style::Dimension::percent(*fraction),
}
}
}
@@ -350,9 +350,9 @@ impl ToTaffy<taffy::style::Dimension> for DefiniteLength {
impl ToTaffy<taffy::style::LengthPercentage> for AbsoluteLength {
fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentage {
match self {
AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::Length(pixels.into()),
AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::length(pixels.into()),
AbsoluteLength::Rems(rems) => {
taffy::style::LengthPercentage::Length((*rems * rem_size).into())
taffy::style::LengthPercentage::length((*rems * rem_size).into())
}
}
}

View File

@@ -3,16 +3,41 @@ use collections::HashMap;
mod remote_video_track_view;
pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent};
#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))]
#[cfg(not(any(
test,
feature = "test-support",
all(target_os = "windows", target_env = "gnu"),
target_os = "freebsd"
)))]
mod livekit_client;
#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))]
#[cfg(not(any(
test,
feature = "test-support",
all(target_os = "windows", target_env = "gnu"),
target_os = "freebsd"
)))]
pub use livekit_client::*;
#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
#[cfg(any(
test,
feature = "test-support",
all(target_os = "windows", target_env = "gnu"),
target_os = "freebsd"
))]
mod mock_client;
#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
#[cfg(any(
test,
feature = "test-support",
all(target_os = "windows", target_env = "gnu"),
target_os = "freebsd"
))]
pub mod test;
#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
#[cfg(any(
test,
feature = "test-support",
all(target_os = "windows", target_env = "gnu"),
target_os = "freebsd"
))]
pub use mock_client::*;
#[derive(Debug, Clone)]

View File

@@ -4,7 +4,7 @@ pub use lsp_types::request::*;
pub use lsp_types::*;
use anyhow::{Context as _, Result, anyhow};
use collections::HashMap;
use collections::{BTreeMap, HashMap};
use futures::{
AsyncRead, AsyncWrite, Future, FutureExt,
channel::oneshot::{self, Canceled},
@@ -40,7 +40,7 @@ use std::{
time::{Duration, Instant},
};
use std::{path::Path, process::Stdio};
use util::{ConnectionResult, ResultExt, TryFutureExt};
use util::{ConnectionResult, ResultExt, TryFutureExt, redact};
const JSON_RPC_VERSION: &str = "2.0";
const CONTENT_LEN_HEADER: &str = "Content-Length: ";
@@ -62,7 +62,7 @@ pub enum IoKind {
/// Represents a launchable language server. This can either be a standalone binary or the path
/// to a runtime with arguments to instruct it to launch the actual language server file.
#[derive(Debug, Clone, Deserialize)]
#[derive(Clone, Deserialize)]
pub struct LanguageServerBinary {
pub path: PathBuf,
pub arguments: Vec<OsString>,
@@ -1448,6 +1448,33 @@ impl fmt::Debug for LanguageServer {
}
}
impl fmt::Debug for LanguageServerBinary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("LanguageServerBinary");
debug.field("path", &self.path);
debug.field("arguments", &self.arguments);
if let Some(env) = &self.env {
let redacted_env: BTreeMap<String, String> = env
.iter()
.map(|(key, value)| {
let redacted_value = if redact::should_redact(key) {
"REDACTED".to_string()
} else {
value.clone()
};
(key.clone(), redacted_value)
})
.collect();
debug.field("env", &Some(redacted_env));
} else {
debug.field("env", &self.env);
}
debug.finish()
}
}
impl Drop for Subscription {
fn drop(&mut self) {
match self {

View File

@@ -48,18 +48,29 @@ pub enum Model {
#[serde(rename = "codestral-latest", alias = "codestral-latest")]
#[default]
CodestralLatest,
#[serde(rename = "mistral-large-latest", alias = "mistral-large-latest")]
MistralLargeLatest,
#[serde(rename = "mistral-medium-latest", alias = "mistral-medium-latest")]
MistralMediumLatest,
#[serde(rename = "mistral-small-latest", alias = "mistral-small-latest")]
MistralSmallLatest,
#[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")]
MagistralMediumLatest,
#[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")]
MagistralSmallLatest,
#[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")]
OpenMistralNemo,
#[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")]
OpenCodestralMamba,
#[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")]
DevstralMediumLatest,
#[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")]
DevstralSmallLatest,
#[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")]
Pixtral12BLatest,
#[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")]
@@ -89,8 +100,11 @@ impl Model {
"mistral-large-latest" => Ok(Self::MistralLargeLatest),
"mistral-medium-latest" => Ok(Self::MistralMediumLatest),
"mistral-small-latest" => Ok(Self::MistralSmallLatest),
"magistral-medium-latest" => Ok(Self::MagistralMediumLatest),
"magistral-small-latest" => Ok(Self::MagistralSmallLatest),
"open-mistral-nemo" => Ok(Self::OpenMistralNemo),
"open-codestral-mamba" => Ok(Self::OpenCodestralMamba),
"devstral-medium-latest" => Ok(Self::DevstralMediumLatest),
"devstral-small-latest" => Ok(Self::DevstralSmallLatest),
"pixtral-12b-latest" => Ok(Self::Pixtral12BLatest),
"pixtral-large-latest" => Ok(Self::PixtralLargeLatest),
@@ -104,8 +118,11 @@ impl Model {
Self::MistralLargeLatest => "mistral-large-latest",
Self::MistralMediumLatest => "mistral-medium-latest",
Self::MistralSmallLatest => "mistral-small-latest",
Self::MagistralMediumLatest => "magistral-medium-latest",
Self::MagistralSmallLatest => "magistral-small-latest",
Self::OpenMistralNemo => "open-mistral-nemo",
Self::OpenCodestralMamba => "open-codestral-mamba",
Self::DevstralMediumLatest => "devstral-medium-latest",
Self::DevstralSmallLatest => "devstral-small-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest",
@@ -119,8 +136,11 @@ impl Model {
Self::MistralLargeLatest => "mistral-large-latest",
Self::MistralMediumLatest => "mistral-medium-latest",
Self::MistralSmallLatest => "mistral-small-latest",
Self::MagistralMediumLatest => "magistral-medium-latest",
Self::MagistralSmallLatest => "magistral-small-latest",
Self::OpenMistralNemo => "open-mistral-nemo",
Self::OpenCodestralMamba => "open-codestral-mamba",
Self::DevstralMediumLatest => "devstral-medium-latest",
Self::DevstralSmallLatest => "devstral-small-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest",
@@ -136,8 +156,11 @@ impl Model {
Self::MistralLargeLatest => 131000,
Self::MistralMediumLatest => 128000,
Self::MistralSmallLatest => 32000,
Self::MagistralMediumLatest => 40000,
Self::MagistralSmallLatest => 40000,
Self::OpenMistralNemo => 131000,
Self::OpenCodestralMamba => 256000,
Self::DevstralMediumLatest => 128000,
Self::DevstralSmallLatest => 262144,
Self::Pixtral12BLatest => 128000,
Self::PixtralLargeLatest => 128000,
@@ -160,8 +183,11 @@ impl Model {
| Self::MistralLargeLatest
| Self::MistralMediumLatest
| Self::MistralSmallLatest
| Self::MagistralMediumLatest
| Self::MagistralSmallLatest
| Self::OpenMistralNemo
| Self::OpenCodestralMamba
| Self::DevstralMediumLatest
| Self::DevstralSmallLatest
| Self::Pixtral12BLatest
| Self::PixtralLargeLatest => true,
@@ -177,8 +203,11 @@ impl Model {
| Self::MistralSmallLatest => true,
Self::CodestralLatest
| Self::MistralLargeLatest
| Self::MagistralMediumLatest
| Self::MagistralSmallLatest
| Self::OpenMistralNemo
| Self::OpenCodestralMamba
| Self::DevstralMediumLatest
| Self::DevstralSmallLatest => false,
Self::Custom {
supports_images, ..

View File

@@ -55,6 +55,7 @@ fn get_max_tokens(name: &str) -> u64 {
"codellama" | "starcoder2" => 16384,
"mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder"
| "dolphin-mixtral" => 32768,
"magistral" => 40000,
"llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r"
| "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder"
| "devstral" => 128000,

View File

@@ -322,6 +322,7 @@ pub fn init(cx: &mut App) {
});
workspace.register_action(|workspace, action: &Rename, window, cx| {
workspace.open_panel::<ProjectPanel>(window, cx);
if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
panel.update(cx, |panel, cx| {
if let Some(first_marked) = panel.marked_entries.first() {
@@ -335,6 +336,7 @@ pub fn init(cx: &mut App) {
});
workspace.register_action(|workspace, action: &Duplicate, window, cx| {
workspace.open_panel::<ProjectPanel>(window, cx);
if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.duplicate(action, window, cx);

View File

@@ -700,7 +700,11 @@ impl BufferSearchBar {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let query_editor = cx.new(|cx| Editor::single_line(window, cx));
let query_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_use_autoclose(false);
editor
});
cx.subscribe_in(&query_editor, window, Self::on_query_editor_event)
.detach();
let replacement_editor = cx.new(|cx| Editor::single_line(window, cx));

View File

@@ -430,6 +430,7 @@ impl TerminalView {
fn settings_changed(&mut self, cx: &mut Context<Self>) {
let settings = TerminalSettings::get_global(cx);
let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs;
self.show_breadcrumbs = settings.toolbar.breadcrumbs;
let new_cursor_shape = settings.cursor_shape.unwrap_or_default();
@@ -441,6 +442,9 @@ impl TerminalView {
});
}
if breadcrumb_visibility_changed {
cx.emit(ItemEvent::UpdateBreadcrumbs);
}
cx.notify();
}

View File

@@ -6,7 +6,7 @@ use editor::{
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
display_map::ToDisplayPoint,
};
use gpui::{Action, App, AppContext as _, Context, Global, Window, actions};
use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions};
use itertools::Itertools;
use language::Point;
use multi_buffer::MultiBufferRow;
@@ -202,6 +202,7 @@ actions!(
ArgumentRequired
]
);
/// Opens the specified file for editing.
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
@@ -209,6 +210,13 @@ struct VimEdit {
pub filename: String,
}
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
struct VimNorm {
pub range: Option<CommandRange>,
pub command: String,
}
#[derive(Debug)]
struct WrappedAction(Box<dyn Action>);
@@ -447,6 +455,81 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
});
});
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
let keystrokes = action
.command
.chars()
.map(|c| Keystroke::parse(&c.to_string()).unwrap())
.collect();
vim.switch_mode(Mode::Normal, true, window, cx);
let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| {
editor.selections.disjoint_anchors()
});
if let Some(range) = &action.range {
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
let range = range.buffer_range(vim, editor, window, cx)?;
editor.change_selections(
SelectionEffects::no_scroll().nav_history(false),
window,
cx,
|s| {
s.select_ranges(
(range.start.0..=range.end.0)
.map(|line| Point::new(line, 0)..Point::new(line, 0)),
);
},
);
anyhow::Ok(())
});
if let Some(Err(err)) = result {
log::error!("Error selecting range: {}", err);
return;
}
};
let Some(workspace) = vim.workspace(window) else {
return;
};
let task = workspace.update(cx, |workspace, cx| {
workspace.send_keystrokes_impl(keystrokes, window, cx)
});
let had_range = action.range.is_some();
cx.spawn_in(window, async move |vim, cx| {
task.await;
vim.update_in(cx, |vim, window, cx| {
vim.update_editor(window, cx, |_, editor, window, cx| {
if had_range {
editor.change_selections(SelectionEffects::default(), window, cx, |s| {
s.select_anchor_ranges([s.newest_anchor().range()]);
})
}
});
if matches!(vim.mode, Mode::Insert | Mode::Replace) {
vim.normal_before(&Default::default(), window, cx);
} else {
vim.switch_mode(Mode::Normal, true, window, cx);
}
vim.update_editor(window, cx, |_, editor, _, cx| {
if let Some(first_sel) = initial_selections {
if let Some(tx_id) = editor
.buffer()
.update(cx, |multi, cx| multi.last_transaction_id(cx))
{
let last_sel = editor.selections.disjoint_anchors();
editor.modify_transaction_selection_history(tx_id, |old| {
old.0 = first_sel;
old.1 = Some(last_sel);
});
}
}
});
})
.ok();
})
.detach();
});
Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
let Some(workspace) = vim.workspace(window) else {
return;
@@ -675,14 +758,15 @@ impl VimCommand {
} else {
return None;
};
if !args.is_empty() {
let action = if args.is_empty() {
action
} else {
// if command does not accept args and we have args then we should do no action
if let Some(args_fn) = &self.args {
args_fn.deref()(action, args)
} else {
None
}
} else if let Some(range) = range {
self.args.as_ref()?(action, args)?
};
if let Some(range) = range {
self.range.as_ref().and_then(|f| f(action, range))
} else {
Some(action)
@@ -1061,6 +1145,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
save_intent: Some(SaveIntent::Skip),
close_pinned: true,
}),
VimCommand::new(
("norm", "al"),
VimNorm {
command: "".into(),
range: None,
},
)
.args(|_, args| {
Some(
VimNorm {
command: args,
range: None,
}
.boxed_clone(),
)
})
.range(|action, range| {
let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
action.range.replace(range.clone());
Some(Box::new(action))
}),
VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
@@ -2298,4 +2403,78 @@ mod test {
});
assert!(mark.is_none())
}
#[gpui::test]
async fn test_normal_command(cx: &mut TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
The quick
brown« fox
jumpsˇ» over
the lazy dog
"})
.await;
cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
.await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick
brown word
jumps worˇd
the lazy dog
"});
cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
.await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick
brown word
jumps tesˇt
the lazy dog
"});
cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
.await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick
brown word
lˇaumps test
the lazy dog
"});
cx.set_shared_state(indoc! {"
ˇThe quick
brown fox
jumps over
the lazy dog
"})
.await;
cx.simulate_shared_keystrokes("c i w M y escape").await;
cx.shared_state().await.assert_eq(indoc! {"
Mˇy quick
brown fox
jumps over
the lazy dog
"});
cx.simulate_shared_keystrokes(": n o r m space u").await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
ˇThe quick
brown fox
jumps over
the lazy dog
"});
// Once ctrl-v to input character literals is added there should be a test for redo
}
}

View File

@@ -4,18 +4,28 @@ use gpui::{Context, Window};
use language::{CharClassifier, CharKind};
use text::SelectionGoal;
use crate::{Vim, motion::Motion, state::Mode};
use crate::{
Vim,
motion::{Motion, right},
state::Mode,
};
actions!(
vim,
[
/// Switches to normal mode after the cursor (Helix-style).
HelixNormalAfter
HelixNormalAfter,
/// Inserts at the beginning of the selection.
HelixInsert,
/// Appends at the end of the selection.
HelixAppend,
]
);
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, Vim::helix_normal_after);
Vim::action(editor, cx, Vim::helix_insert);
Vim::action(editor, cx, Vim::helix_append);
}
impl Vim {
@@ -299,6 +309,38 @@ impl Vim {
_ => self.helix_move_and_collapse(motion, times, window, cx),
}
}
fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx);
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|_map, selection| {
// In helix normal mode, move cursor to start of selection and collapse
if !selection.is_empty() {
selection.collapse_to(selection.start, SelectionGoal::None);
}
});
});
});
self.switch_mode(Mode::Insert, false, window, cx);
}
fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx);
self.switch_mode(Mode::Insert, false, window, cx);
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let point = if selection.is_empty() {
right(map, selection.head(), 1)
} else {
selection.end
};
selection.collapse_to(point, SelectionGoal::None);
});
});
});
}
}
#[cfg(test)]
@@ -497,4 +539,68 @@ mod test {
cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
}
#[gpui::test]
async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state(
indoc! {"
«The ˇ»quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("i");
cx.assert_state(
indoc! {"
ˇThe quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
}
#[gpui::test]
async fn test_append(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// test from the end of the selection
cx.set_state(
indoc! {"
«Theˇ» quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("a");
cx.assert_state(
indoc! {"
Theˇ quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
// test from the beginning of the selection
cx.set_state(
indoc! {"
«ˇThe» quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("a");
cx.assert_state(
indoc! {"
Theˇ quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
}
}

View File

@@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
}
impl Vim {
fn normal_before(
pub(crate) fn normal_before(
&mut self,
action: &NormalBefore,
window: &mut Window,

View File

@@ -0,0 +1,64 @@
{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"w"}
{"Key":"C"}
{"Key":"w"}
{"Key":"o"}
{"Key":"r"}
{"Key":"d"}
{"Key":"enter"}
{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"_"}
{"Key":"w"}
{"Key":"c"}
{"Key":"i"}
{"Key":"w"}
{"Key":"t"}
{"Key":"e"}
{"Key":"s"}
{"Key":"t"}
{"Key":"enter"}
{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}}
{"Key":"_"}
{"Key":"l"}
{"Key":"v"}
{"Key":"l"}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"s"}
{"Key":"l"}
{"Key":"a"}
{"Key":"enter"}
{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}}
{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}}
{"Key":"c"}
{"Key":"i"}
{"Key":"w"}
{"Key":"M"}
{"Key":"y"}
{"Key":"escape"}
{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"u"}
{"Key":"enter"}
{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}

View File

@@ -32,7 +32,7 @@ use futures::{
mpsc::{self, UnboundedReceiver, UnboundedSender},
oneshot,
},
future::try_join_all,
future::{Shared, try_join_all},
};
use gpui::{
Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
@@ -87,7 +87,7 @@ use std::{
borrow::Cow,
cell::RefCell,
cmp,
collections::hash_map::DefaultHasher,
collections::{VecDeque, hash_map::DefaultHasher},
env,
hash::{Hash, Hasher},
path::{Path, PathBuf},
@@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box<
) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
>;
#[derive(Default)]
struct DispatchingKeystrokes {
dispatched: HashSet<Vec<Keystroke>>,
queue: VecDeque<Keystroke>,
task: Option<Shared<Task<()>>>,
}
/// Collects everything project-related for a certain window opened.
/// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`.
///
@@ -1080,7 +1087,7 @@ pub struct Workspace {
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
database_id: Option<WorkspaceId>,
app_state: Arc<AppState>,
dispatching_keystrokes: Rc<RefCell<(HashSet<String>, Vec<Keystroke>)>>,
dispatching_keystrokes: Rc<RefCell<DispatchingKeystrokes>>,
_subscriptions: Vec<Subscription>,
_apply_leader_updates: Task<Result<()>>,
_observe_current_user: Task<Result<()>>,
@@ -2311,49 +2318,65 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut state = self.dispatching_keystrokes.borrow_mut();
if !state.0.insert(action.0.clone()) {
cx.propagate();
return;
}
let mut keystrokes: Vec<Keystroke> = action
let keystrokes: Vec<Keystroke> = action
.0
.split(' ')
.flat_map(|k| Keystroke::parse(k).log_err())
.collect();
keystrokes.reverse();
let _ = self.send_keystrokes_impl(keystrokes, window, cx);
}
state.1.append(&mut keystrokes);
drop(state);
pub fn send_keystrokes_impl(
&mut self,
keystrokes: Vec<Keystroke>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Shared<Task<()>> {
let mut state = self.dispatching_keystrokes.borrow_mut();
if !state.dispatched.insert(keystrokes.clone()) {
cx.propagate();
return state.task.clone().unwrap();
}
state.queue.extend(keystrokes);
let keystrokes = self.dispatching_keystrokes.clone();
window
.spawn(cx, async move |cx| {
// limit to 100 keystrokes to avoid infinite recursion.
for _ in 0..100 {
let Some(keystroke) = keystrokes.borrow_mut().1.pop() else {
keystrokes.borrow_mut().0.clear();
return Ok(());
};
cx.update(|window, cx| {
let focused = window.focused(cx);
window.dispatch_keystroke(keystroke.clone(), cx);
if window.focused(cx) != focused {
// dispatch_keystroke may cause the focus to change.
// draw's side effect is to schedule the FocusChanged events in the current flush effect cycle
// And we need that to happen before the next keystroke to keep vim mode happy...
// (Note that the tests always do this implicitly, so you must manually test with something like:
// "bindings": { "g z": ["workspace::SendKeystrokes", ": j <enter> u"]}
// )
window.draw(cx).clear();
if state.task.is_none() {
state.task = Some(
window
.spawn(cx, async move |cx| {
// limit to 100 keystrokes to avoid infinite recursion.
for _ in 0..100 {
let mut state = keystrokes.borrow_mut();
let Some(keystroke) = state.queue.pop_front() else {
state.dispatched.clear();
state.task.take();
return;
};
drop(state);
cx.update(|window, cx| {
let focused = window.focused(cx);
window.dispatch_keystroke(keystroke.clone(), cx);
if window.focused(cx) != focused {
// dispatch_keystroke may cause the focus to change.
// draw's side effect is to schedule the FocusChanged events in the current flush effect cycle
// And we need that to happen before the next keystroke to keep vim mode happy...
// (Note that the tests always do this implicitly, so you must manually test with something like:
// "bindings": { "g z": ["workspace::SendKeystrokes", ": j <enter> u"]}
// )
window.draw(cx).clear();
}
})
.ok();
}
})?;
}
*keystrokes.borrow_mut() = Default::default();
anyhow::bail!("over 100 keystrokes passed to send_keystrokes");
})
.detach_and_log_err(cx);
*keystrokes.borrow_mut() = Default::default();
log::error!("over 100 keystrokes passed to send_keystrokes");
})
.shared(),
);
}
state.task.clone().unwrap()
}
fn save_all_internal(