Compare commits

...

62 Commits

Author SHA1 Message Date
Zed Bot
2d27a1a119 Bump to 0.202.6 for @agu-z 2025-09-03 23:30:58 +00:00
Agus Zubiaga
22f6b6425c acp: Receive available commands over notifications (#37499)
See: https://github.com/zed-industries/agent-client-protocol/pull/62

Release Notes:

- Agent Panel: Fixes an issue where Claude Code would timeout waiting
for slash commands to be loaded

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 20:15:50 -03:00
Cole Miller
6aecc15ae3 acp: Improve handling of invalid external agent server downloads (#37465)
Related to #37213, #37150

When listing previously-downloaded versions of an external agent, don't
try to use any downloads that are missing the agent entrypoint
(indicating that they're corrupt/unusable), and delete those versions,
so that we can attempt to download the latest version again.

Also report clearer errors when failing to start a session due to an
agent server entrypoint or root directory not existing.

Release Notes:

- N/A
2025-09-03 20:15:44 -03:00
Agus Zubiaga
f40ef20e6b acp: Display a new version call out when one is available (#37479)
<img width="500" alt="CleanShot 2025-09-03 at 16 13 59@2x"
src="https://github.com/user-attachments/assets/beb91365-28e2-4f87-a2c5-7136d37382c7"></img>

Release Notes:

- Agent Panel: Display a callout when a new version of an external agent
is available

---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 20:15:33 -03:00
Cole Miller
05d44ca24c agent: Fix agent panel header not updating when opening a history entry (#37189)
Closes #37171

Release Notes:

- agent: Fixed a bug that caused the agent information in the panel
header to be incorrect when opening a thread from history.
2025-09-03 08:30:26 -04:00
Bennet Bo Fenner
829ee0348c acp: Fix issue with claude code /logout command (#37452)
### First issue

In the scenario where you have an API key configured in Zed and you run
`/logout`, clicking on `Use Anthropic API Key` would show `Method not
implemented`.

This happened because we were only intercepting the `Use Anthropic API
Key` click if the provider was NOT authenticated, which would not be the
case when the user has an API key set.

### Second issue

When clicking on `Reset API Key` the modal would be dismissed even
though you picked no Authentication Method (which means you still would
be unauthenticated)

---

This PR fixes both of these issues

Release Notes:

- N/A
2025-09-03 14:09:23 +02:00
Bennet Bo Fenner
ff200dbdd1 Add onboarding banner for claude code support (#37443)
Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-09-03 12:59:58 +02:00
Cole Miller
9a14ca8c5d acp: Fix handling of single-file worktrees (#37412)
When the first visible worktree is a single-file worktree, we would
previously try to use the absolute path of that file as the root
directory for external agents, causing an error. This PR changes how we
handle this situation: we'll use the root of the first non-single-file
visible worktree if there are any, and if there are none, the parent
directory of the first single-file visible worktree.

Related to #37213

Release Notes:

- acp: Fixed being unable to run external agents when a single file (not
part of a project) was opened in Zed.
2025-09-03 07:14:47 -03:00
Danilo Leal
d8032f5a73 agent: Update message editor placeholder (#37441)
Release Notes:

- N/A
2025-09-03 11:55:27 +02:00
Danilo Leal
91a4d451c9 agent: Add CC item in the settings view (#37164)
Release Notes:

- N/A
2025-09-03 11:11:35 +02:00
Cole Miller
168ef570d2 agent2: Fix terminal tool call content not being shown once truncated (#37318)
We render terminals as inline if their content is below a certain line
count, and scrollable past that point. In the scrollable case we weren't
setting a height for the terminal's container, causing it to be rendered
at height 0, which means no lines would be displayed. This PR fixes that
by setting an explicit height for the scrollable case, like we do in the
agent1 UI code.

Release Notes:

- agent: Fixed a bug that caused terminals in the panel to be empty
after their content reached a certain size.
2025-09-03 03:12:05 -04:00
Peter Tripp
16d41fc39e v0.202.x stable 2025-09-02 20:58:50 -04:00
Joseph T. Lyons
cb0fa2533d Add xAI to supported language model providers (#37206)
After setting a `grok` model via the agent panel, the settings complains
that it doesn't recognize the language model provider:

<img width="1005" height="188" alt="SCR-20250829-tqqd"
src="https://github.com/user-attachments/assets/a25fc7e0-60f0-44fd-96d2-b1cb316d06b6"
/>

Also, sorted the list, in the follow-up commit.

Release Notes:

- N/A
2025-09-02 20:52:35 -04:00
Richard Feldman
70a471bcf8 Nice errors for unsupported ACP slash commands (#37393)
If we get back slash-commands that aren't supported, tell the user that
this is the problem.

Release Notes:

- Improve error messages for unsupported ACP slash-commands

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-09-02 20:44:40 -04:00
Ben Brandt
b0b700727f agent2: Only setup real client for real models (#37403)
Before we were setting up lots of test setup regardless of if we were
actually going to be making real requests or not.

This will hopefully help with intermittent test errors we're seeing on
Windows in CI.

Release Notes:

- N/A
2025-09-02 20:41:39 -04:00
Peter Tripp
0858782b27 ci: Skip Nix for commits on release branches and tags (#37407)
When doing stable/preview releases simultaneously there are two tags and
two branches pushed. Previously nix was attempting 1 job for each. Our
current mac parallelism is 4.
 
Can't easily test this. 🤷 

Release Notes:

- N/A
2025-09-02 20:41:07 -04:00
Danilo Leal
5df6d44f93 agent: Fix cut off slash command descriptions (#37408)
Release Notes:

- N/A
2025-09-02 20:35:40 -04:00
Danilo Leal
3b2bbef737 Add option for code context menu items to have dynamic width (#37404)
Follow up to https://github.com/zed-industries/zed/pull/30598

This PR introduces the `display_options` field in the
`CompletionResponse`, allowing a code context menu width to be
dynamically dictated based on its larger item. This will allow us to
have the @-mentions and slash commands completion menus in the agent
panel not be bigger than it needs to be. It may also be relevant/useful
in the future for other use cases.

For now, we set all instances of code context menus to use a fixed
width, as defined in the PR linked above, which means this PR shouldn't
cause any visual change.

Release Notes:

- N/A

Co-authored-by: Michael Sloan <mgsloan+github@gmail.com>
2025-09-02 20:35:35 -04:00
Kirill Bulatov
a605bb5b0d Remote LSP logs (#37083)
Take 2: https://github.com/zed-industries/zed/pull/36709 but without the
very bad `cfg`-based approach for storing the RPC logs.

--------------

Enables LSP log tracing in both remote collab and remote ssh
environments.
Server logs and server RPC traces can now be viewed remotely, and the
LSP button is now shown in such projects too.

Closes https://github.com/zed-industries/zed/issues/28557

Co-Authored-By: Kirill <kirill@zed.dev>
Co-Authored-By: Lukas <lukas@zed.dev>

Release Notes:

- Enabled LSP log tracing in both remote collab and remote ssh
environments

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Lukas Wirth <lukas@zed.dev>
2025-09-02 20:34:44 -04:00
Richard Feldman
d376483d9e Handle model refusal in ACP threads (#37383)
If the model refuses a prompt, we now:
* Show an error if it was a user prompt (and truncate it out of the
history)
* Respond with a failed tool call if the refusal was for a tool call

<img width="607" height="260" alt="Screenshot 2025-09-02 at 5 11 45 PM"
src="https://github.com/user-attachments/assets/070b5ee7-6ad6-4a63-8395-f9a5093cc40e"
/>
<img width="607" height="265" alt="Screenshot 2025-09-02 at 5 11 38 PM"
src="https://github.com/user-attachments/assets/98862586-390b-494e-b1f8-71d8341c8d9d"
/>



Release Notes:

- Improve handling of model refusals in ACP threads
2025-09-02 20:28:14 -04:00
Cole Miller
76e7c78144 acp: Disable external agents over SSH (#37402)
Follow-up to #37377 

Show a clearer error here until SSH support is implemented.

Release Notes:

- N/A
2025-09-02 20:27:15 -04:00
Conrad Irwin
5d70933fd6 Disable external agents over collab (#37377)
Release Notes:

- Disable UI to boot external agents in collab projects (as they don't
work)
2025-09-02 20:26:52 -04:00
Bennet Bo Fenner
4943d4db8e acp: Enable claude code feature flag for everyone (#37390)
Release Notes:

- N/A
2025-09-02 17:57:57 -04:00
Peter Tripp
3f1a3c1bf9 zed 0.202.5 2025-09-02 15:24:52 -04:00
Richard Feldman
cee3c5307a Fix race condition between feature flag and deserialization (#37381)
Right now if you open Zed, and we deserialize an agent that's behind a
feature flag (e.g. CC), we don't restore it because the feature flag
check hasn't happened yet at the time we're deserializing (due to auth
not having finished yet).

This is a simple fix: assume that if you had serialized it in the first
place, you must have had the feature flag enabled, so go ahead and
reopen it for you.

Release Notes:

- N/A
2025-09-02 15:22:33 -04:00
Agus Zubiaga
65f076cd45 acp: Display slash command hints (#37376)
Displays the slash command's argument hint while it hasn't been
provided:


https://github.com/user-attachments/assets/f3bb148c-247d-43bc-810d-92055a313514


Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-02 15:22:28 -04:00
Bennet Bo Fenner
288a2eb462 acp: Add support for slash commands (#37304)
Depends on
https://github.com/zed-industries/agent-client-protocol/pull/45

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-02 15:22:23 -04:00
Agus Zubiaga
b51a8eb169 ACP Terminal support (#37129)
Exposes terminal support via ACP and migrates our agent to use it.

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-02 15:22:16 -04:00
Lukas Wirth
cc5af08923 Inject venv environment via the toolchain (#36576)
Instead of manually constructing the venv we now ask the python
toolchain for the relevant information, unifying the approach of vent
inspection

Fixes https://github.com/zed-industries/zed/issues/27350

Release Notes:

- Improved the detection of python virtual environments for terminals
and tasks in remote projects.
2025-09-02 15:21:42 -04:00
Umesh Yadav
49f48dc0ee language_models: Fix GitHub Copilot thread summary by removing unnecessary noop tool logic (#37152)
Closes #37025 

This PR fixes GitHub Copilot thread summary failures by removing the
unnecessary `noop` tool insertion logic. The code was originally added
as a workaround in https://github.com/zed-industries/zed/pull/30007 for
supposed GitHub Copilot API issues when tools were used previously in a
conversation but no tools are provided in the current request. However,
testing revealed that this scenario works fine without the workaround,
and the `noop` tool insertion was actually causing "Invalid schema for
function 'noop'" errors that prevented thread summarization from
working. Removing this logic eliminates the errors and allows thread
summarization to function correctly with GitHub Copilot models.

The best way to see if removing that part of code works is just
triggering thread summarisation.

Error Log:
```
2025-08-27T13:47:50-04:00 ERROR [workspace::notifications] "Failed to connect to API: 400 Bad Request {"error":{"message":"Invalid schema for function 'noop': In context=(), object schema missing properties.","code":"invalid_function_parameters"}}\n"
```

Release Notes:

- Fixed GitHub Copilot thread summary failures by removing unnecessary
noop tool insertion logic.
2025-08-31 20:35:29 -04:00
Umesh Yadav
c7080e4a59 language_models: Fix grok-code-fast-1 support for Copilot (#37116)
This PR fixes a deserialization issue in GitHub Copilot Chat that was
causing warnings when encountering xAI models from the GitHub Copilot
API and skipping the Grok model from model selector.

Release Notes:

- Fixed support for xAI models that are now available through GitHub
Copilot Chat.
2025-08-31 18:51:52 -04:00
Cole Miller
cc5fae976a zed 0.202.4 2025-08-29 18:52:21 -04:00
Cole Miller
4d01b76b74 agent: Re-add workaround for language model behavior with empty tool result (#37196)
This is just copying over the same workaround here:


a790e514af/crates/agent/src/thread.rs (L1455-L1459)

Into the agent2 code.

Release Notes:

- agent: Fixed an issue where some tool calls in the Zed agent could
return an error like "`tool_use` ids were found without `tool_result`
blocks immediately after"
2025-08-29 18:46:23 -04:00
Shardul Vaidya
ce02bfc3c2 bedrock: Inference Config updates (#35808)
Fixes #36866

- Updated internal naming for Claude 4 models to be consistent.
- Corrected max output tokens for Anthropic Bedrock models to match docs

Shoutout to @tlehn for noticing the bug, and finding the resolution.

Release Notes:

- bedrock: Fixed inference config errors causing Opus 4 Thinking and
Opus 4.1 Thinking to fail (thanks [@tlehn](https://github.com/tlehn) and
[@5herlocked](https://github.com/5herlocked])
- bedrock: Fixed an issue which prevented Rules / System prompts not
functioning with Bedrock models (thanks
[@tlehn](https://github.com/tlehn) and
[@5herlocked](https://github.com/5herlocked])
2025-08-29 18:13:48 -04:00
Agus Zubiaga
6967c17105 Fix ACP permission request with new tool calls (#37182)
Release Notes:

- Gemini integration: Fixed a bug with permission requests when
`always_allow_tool_calls` is enabled
2025-08-29 14:31:02 -04:00
Cole Miller
92f4a15182 acp: Improve error reporting and log more information when failing to launch gemini (#37178)
In the case where we fail to create an ACP connection to Gemini, only
report the "unsupported version" error if the version for the found
binary is at least our minimum version. That means we'll surface the
real error in this situation.

This also fixes incorrect sorting of downloaded Gemini versions--as @kpe
pointed out we were effectively using the version string as a key. Now
we'll correctly use the parsed semver::Version instead.

Release Notes:

- N/A
2025-08-29 13:49:49 -04:00
Joseph T. Lyons
7864f767d3 zed 0.202.3 2025-08-29 12:35:11 -04:00
Antonio Scandurra
50d73696dd acp: Use the custom claude installation to perform login (#37169)
Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: morgankrey <morgan@zed.dev>
2025-08-29 12:15:09 -04:00
Antonio Scandurra
ffa9a78883 Require authorization for MCP tools (#37155)
Release Notes:

- Fixed a regression that caused MCP tools to run without requesting
authorization first.
2025-08-29 10:03:06 -04:00
Richard Feldman
a02e0d0fb7 Always enable acp accept/reject buttons for now (#37121)
We have a bug in our ACP implementation where sometimes the
Accept/Reject buttons are disabled (and stay disabled even after the
thread has finished). I haven't found a complete fix for this yet, so in
the meantime I'm putting out the fire by making it so those buttons are
always enabled. That way you're never blocked, and the only consequence
of the bug is that sometimes they should be disabled but are enabled
instead.

Release Notes:

- N/A
2025-08-29 10:02:50 -04:00
Cole Miller
d304e042cd acp: Support automatic installation of Claude Code (#37120)
Release Notes:

- N/A
2025-08-29 10:02:36 -04:00
Ben Brandt
e1dc736642 acp: Bump to 0.1.1 (#37119)
No big changes, just tracking the latest version after the official
release

Release Notes:

- N/A
2025-08-29 10:02:22 -04:00
Conrad Irwin
d25b5d56ce Add support for Claude Code auth (#37103)
Co-authored-by: Antonio Scandurra <me@as-cii.com>

Closes #ISSUE

Release Notes:

- N/A

Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-08-29 10:02:05 -04:00
Marshall Bowers
593c644401 Disable Expert language server by default for Elixir (#37126)
This PR updates the language server configuration for Elixir and HEEx to
not start the [Expert](https://github.com/elixir-lang/expert) language
server by default.

While Expert is the official Elixir language server, it is still early,
so we don't want to make it the default just yet.

Release Notes:

- Updated the default Elixir and HEEx language server settings to not
start the Expert language server.
2025-08-29 09:29:35 -04:00
Cole Miller
138b23bdd9 acp: Install new versions of agent binaries in the background (#37141)
Release Notes:

- acp: New releases of external agents are now installed in the
background.

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-08-29 00:31:13 -04:00
Conrad Irwin
9a4a332067 acp: Load agent panel even if serialized config is bogus (#37134)
Closes #ISSUE

Release Notes:

- N/A
2025-08-28 22:13:13 -04:00
Michael Sloan
7771cfa1d1 Cherry pick #37052 onto preview (#37115)
Co-authored-by: Julia <julia@zed.dev>
2025-08-28 15:35:46 -06:00
Joseph T. Lyons
0d45870650 zed 0.202.2 2025-08-28 15:54:51 -04:00
Cole Miller
e61e782ef7 acp: Automatically install gemini under Zed's data dir (#37054)
Closes: https://github.com/zed-industries/zed/issues/37089

Instead of looking for the gemini command on `$PATH`, by default we'll
install our own copy on demand under our data dir, as we already do for
language servers and debug adapters. This also means we can handle
keeping the binary up to date instead of prompting the user to upgrade.

Notes:

- The download is only triggered if you open a new Gemini thread
- Custom commands from `agent_servers.gemini` in settings are respected
as before
- A new `agent_servers.gemini.ignore_system_version` setting is added,
similar to the existing settings for language servers. It's `true` by
default, and setting it to `false` disables the automatic download and
makes Zed search `$PATH` as before.
- If `agent_servers.gemini.ignore_system_version` is `false` and no
binary is found on `$PATH`, we'll fall back to automatic installation.
If it's `false` and a binary is found, but the version is older than
v0.2.1, we'll show an error.

Release Notes:

- acp: By default, Zed will now download and use a private copy of the
Gemini CLI binary, instead of searching your `$PATH`. To make Zed search
your `$PATH` for Gemini CLI before attempting to download it, use the
following setting:

```
{
  "agent_servers": {
    "gemini": {
      "ignore_system_version": false
    }
  }
}
```
2025-08-28 15:51:51 -04:00
Richard Feldman
f98d1d67bd Have ACP respect always_allow_tool_actions (#37104)
Release Notes:

- ACP agents now respect the always_allow_tool_actions setting
2025-08-28 14:57:11 -04:00
Umesh Yadav
25c8bb7714 agent2: Fix model deduplication to use provider ID and model ID (#37088)
Closes #37043

Previously claude sonnet 4 was missing from copilot as it was colliding
with zed's claude-sonnet-4 model id. Now we do deduplication based upon
model and provider id both.

| Before | After |
|--------|--------|
| <img width="784" height="950" alt="CleanShot 2025-08-28 at 18 31
28@2x"
src="https://github.com/user-attachments/assets/d49d5a17-7271-417d-bb5e-bc380071e810"
/> | <img width="720" height="876" alt="CleanShot 2025-08-28 at 18 31
42@2x"
src="https://github.com/user-attachments/assets/a5100c05-994e-4e19-ab20-34c0258b977c"
/> |

Release Notes:

- Fixed an issue where models with the same ID from different providers
(such as Claude Sonnet 4 from both Zed and Copilot) were incorrectly
deduplicated in the model selector—now all variants are shown.
2025-08-28 14:56:57 -04:00
Antonio Scandurra
a5bb868d31 acp: Don't cancel editing when scrolling message out of view (#37020)
Release Notes:

- agent: Fixed a bug that canceled editing when scrolling the user
message out of view.

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-08-28 14:56:39 -04:00
Bennet Bo Fenner
1c37b7ed6a acp: Add more logs to model selector to diagnose issue (#36997)
Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
Co-authored-by: Katie Geer <katie@zed.dev>
2025-08-28 14:56:32 -04:00
Umesh Yadav
159c78a0fe language_models: Fix tool calling for x-ai/grok-code-fast-1 model via OpenRouter (#37094)
Closes #37022
Closes #36994

This update ensures all Grok models use the JsonSchemaSubset format for
tool schemas.

A previous fix for this issue was too specific, only targeting grok-4
models. This caused other variants, like grok-code-fast-1, to be missed.
We've now broadened the logic to correctly apply the setting to the
entire Grok model family.

Release Notes:

- Fix tool calling for `x-ai/grok-code-fast-1` model via OpenRouter.
2025-08-28 11:29:14 -04:00
Cole Miller
ccba82a111 zed 0.202.1 2025-08-27 12:49:06 -04:00
Bennet Bo Fenner
b2c63ed2ab acp: Fix model selector sometimes showing no models (#37006)
Release Notes:

- acp: Fix an issue where the model selector would sometimes be empty

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-08-27 12:31:36 -04:00
Cole Miller
33b54e3759 acp: Fix gemini process being leaked (#37012)
Release Notes:

- acp: Fixed a bug that caused external agent server subprocesses to be
leaked.

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-08-27 12:28:54 -04:00
Bennet Bo Fenner
9df0b3ae20 acp: Fix model selector sometimes showing no models 2025-08-27 13:07:21 +02:00
Antonio Scandurra
ae82fdaf4e Restore token count for text threads (#36989)
Release Notes:

- N/A

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

- N/A
2025-08-27 11:40:27 +02:00
Conrad Irwin
adbf0636da acp: Upgrade errors (#36980)
- **Pass --engine-strict to gemini install command**
- **Make it clearer that if upgrading fails, you need to fix i**

Closes #ISSUE

Release Notes:

- N/A
2025-08-27 00:25:36 -06:00
Joseph T. Lyons
4b2355ed3c v0.202.x preview 2025-08-26 22:13:27 -04:00
109 changed files with 5718 additions and 5606 deletions

View File

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

20
Cargo.lock generated
View File

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

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ use crate::{
ContextServerRegistry, Thread, ThreadEvent, ThreadsDatabase, ToolCallAuthorization,
UserMessageContent, templates::Templates,
};
use crate::{HistoryStore, TitleUpdated, TokenUsageUpdated};
use crate::{HistoryStore, TerminalHandle, ThreadEnvironment, TitleUpdated, TokenUsageUpdated};
use acp_thread::{AcpThread, AgentModelSelector};
use action_log::ActionLog;
use agent_client_protocol as acp;
@@ -10,7 +10,8 @@ use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::channel::mpsc;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
use futures::{StreamExt, future};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
@@ -23,7 +24,7 @@ use prompt_store::{
use settings::update_settings_file;
use std::any::Any;
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use util::ResultExt;
@@ -61,16 +62,19 @@ pub struct LanguageModels {
model_list: acp_thread::AgentModelList,
refresh_models_rx: watch::Receiver<()>,
refresh_models_tx: watch::Sender<()>,
_authenticate_all_providers_task: Task<()>,
}
impl LanguageModels {
fn new(cx: &App) -> Self {
fn new(cx: &mut App) -> Self {
let (refresh_models_tx, refresh_models_rx) = watch::channel(());
let mut this = Self {
models: HashMap::default(),
model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()),
refresh_models_rx,
refresh_models_tx,
_authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx),
};
this.refresh_list(cx);
this
@@ -90,7 +94,7 @@ impl LanguageModels {
let mut recommended = Vec::new();
for provider in &providers {
for model in provider.recommended_models(cx) {
recommended_models.insert(model.id());
recommended_models.insert((model.provider_id(), model.id()));
recommended.push(Self::map_language_model_to_info(&model, provider));
}
}
@@ -107,7 +111,7 @@ impl LanguageModels {
for model in provider.provided_models(cx) {
let model_info = Self::map_language_model_to_info(&model, &provider);
let model_id = model_info.id.clone();
if !recommended_models.contains(&model.id()) {
if !recommended_models.contains(&(model.provider_id(), model.id())) {
provider_models.push(model_info);
}
models.insert(model_id, model);
@@ -150,6 +154,52 @@ impl LanguageModels {
fn model_id(model: &Arc<dyn LanguageModel>) -> acp_thread::AgentModelId {
acp_thread::AgentModelId(format!("{}/{}", model.provider_id().0, model.id().0).into())
}
fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
let authenticate_all_providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.iter()
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
cx.background_spawn(async move {
for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
if let Err(err) = authenticate_task.await {
if matches!(err, language_model::AuthenticateError::CredentialsNotFound) {
// Since we're authenticating these providers in the
// background for the purposes of populating the
// language selector, we don't care about providers
// where the credentials are not found.
} else {
// Some providers have noisy failure states that we
// don't want to spam the logs with every time the
// language model selector is initialized.
//
// Ideally these should have more clear failure modes
// that we know are safe to ignore here, like what we do
// with `CredentialsNotFound` above.
match provider_id.0.as_ref() {
"lmstudio" | "ollama" => {
// LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
//
// These fail noisily, so we don't log them.
}
"copilot_chat" => {
// Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
}
_ => {
log::error!(
"Failed to authenticate provider: {}: {err}",
provider_name.0
);
}
}
}
}
}
})
}
}
pub struct NativeAgent {
@@ -227,13 +277,6 @@ impl NativeAgent {
cx: &mut Context<Self>,
) -> Entity<AcpThread> {
let connection = Rc::new(NativeAgentConnection(cx.entity()));
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
thread.add_default_tools(cx)
});
let thread = thread_handle.read(cx);
let session_id = thread.id().clone();
@@ -252,6 +295,20 @@ impl NativeAgent {
cx,
)
});
let registry = LanguageModelRegistry::read_global(cx);
let summarization_model = registry.thread_summary_model().map(|c| c.model);
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
thread.add_default_tools(
Rc::new(AcpThreadEnvironment {
acp_thread: acp_thread.downgrade(),
}) as _,
cx,
)
});
let subscriptions = vec![
cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
this.sessions.remove(acp_thread.session_id());
@@ -713,18 +770,15 @@ impl NativeAgentConnection {
options,
response,
}) => {
let recv = acp_thread.update(cx, |thread, cx| {
let outcome_task = acp_thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(tool_call, options, cx)
})?;
})??;
cx.background_spawn(async move {
if let Some(recv) = recv.log_err()
&& let Some(option) = recv
.await
.context("authorization sender was dropped")
.log_err()
if let acp::RequestPermissionOutcome::Selected { option_id } =
outcome_task.await
{
response
.send(option)
.send(option_id)
.map(|_| anyhow!("authorization receiver was dropped"))
.log_err();
}
@@ -955,7 +1009,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
self.0.read_with(cx, |agent, _cx| {
agent.sessions.get(session_id).map(|session| {
Rc::new(NativeAgentSessionEditor {
Rc::new(NativeAgentSessionTruncate {
thread: session.thread.clone(),
acp_thread: session.acp_thread.clone(),
}) as _
@@ -1004,12 +1058,12 @@ impl acp_thread::AgentTelemetry for NativeAgentConnection {
}
}
struct NativeAgentSessionEditor {
struct NativeAgentSessionTruncate {
thread: Entity<Thread>,
acp_thread: WeakEntity<AcpThread>,
}
impl acp_thread::AgentSessionTruncate for NativeAgentSessionEditor {
impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate {
fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task<Result<()>> {
match self.thread.update(cx, |thread, cx| {
thread.truncate(message_id.clone(), cx)?;
@@ -1058,6 +1112,66 @@ impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle {
}
}
pub struct AcpThreadEnvironment {
acp_thread: WeakEntity<AcpThread>,
}
impl ThreadEnvironment for AcpThreadEnvironment {
fn create_terminal(
&self,
command: String,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn TerminalHandle>>> {
let task = self.acp_thread.update(cx, |thread, cx| {
thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx)
});
let acp_thread = self.acp_thread.clone();
cx.spawn(async move |cx| {
let terminal = task?.await?;
let (drop_tx, drop_rx) = oneshot::channel();
let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone())?;
cx.spawn(async move |cx| {
drop_rx.await.ok();
acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx))
})
.detach();
let handle = AcpTerminalHandle {
terminal,
_drop_tx: Some(drop_tx),
};
Ok(Rc::new(handle) as _)
})
}
}
pub struct AcpTerminalHandle {
terminal: Entity<acp_thread::Terminal>,
_drop_tx: Option<oneshot::Sender<()>>,
}
impl TerminalHandle for AcpTerminalHandle {
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId> {
self.terminal.read_with(cx, |term, _cx| term.id().clone())
}
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
self.terminal
.read_with(cx, |term, _cx| term.wait_for_exit())
}
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
self.terminal
.read_with(cx, |term, cx| term.current_output(cx))
}
}
#[cfg(test)]
mod tests {
use crate::HistoryEntryId;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1522,7 +1522,10 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx);
}
}
AcpThreadEvent::Stopped | AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) => {
AcpThreadEvent::Stopped
| AcpThreadEvent::Error
| AcpThreadEvent::LoadError(_)
| AcpThreadEvent::Refusal => {
self.update_reviewing_editors(workspace, window, cx);
}
AcpThreadEvent::TitleUpdated
@@ -1530,6 +1533,7 @@ impl AgentDiff {
| AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::ToolAuthorizationRequired
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::Retry(_) => {}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1857,6 +1857,53 @@ impl TextThreadEditor {
.update(cx, |context, cx| context.summarize(true, cx));
}
fn render_remaining_tokens(&self, cx: &App) -> Option<impl IntoElement + use<>> {
let (token_count_color, token_count, max_token_count, tooltip) =
match token_state(&self.context, cx)? {
TokenState::NoTokensLeft {
max_token_count,
token_count,
} => (
Color::Error,
token_count,
max_token_count,
Some("Token Limit Reached"),
),
TokenState::HasMoreTokens {
max_token_count,
token_count,
over_warn_threshold,
} => {
let (color, tooltip) = if over_warn_threshold {
(Color::Warning, Some("Token Limit is Close to Exhaustion"))
} else {
(Color::Muted, None)
};
(color, token_count, max_token_count, tooltip)
}
};
Some(
h_flex()
.id("token-count")
.gap_0p5()
.child(
Label::new(humanize_token_count(token_count))
.size(LabelSize::Small)
.color(token_count_color),
)
.child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
.child(
Label::new(humanize_token_count(max_token_count))
.size(LabelSize::Small)
.color(Color::Muted),
)
.when_some(tooltip, |element, tooltip| {
element.tooltip(Tooltip::text(tooltip))
}),
)
}
fn render_send_button(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
@@ -2420,9 +2467,14 @@ impl Render for TextThreadEditor {
)
.child(
h_flex()
.gap_1()
.child(self.render_language_model_selector(window, cx))
.child(self.render_send_button(window, cx)),
.gap_2p5()
.children(self.render_remaining_tokens(cx))
.child(
h_flex()
.gap_1()
.child(self.render_language_model_selector(window, cx))
.child(self.render_send_button(window, cx)),
),
),
)
}
@@ -2710,58 +2762,6 @@ impl FollowableItem for TextThreadEditor {
}
}
pub fn render_remaining_tokens(
context_editor: &Entity<TextThreadEditor>,
cx: &App,
) -> Option<impl IntoElement + use<>> {
let context = &context_editor.read(cx).context;
let (token_count_color, token_count, max_token_count, tooltip) = match token_state(context, cx)?
{
TokenState::NoTokensLeft {
max_token_count,
token_count,
} => (
Color::Error,
token_count,
max_token_count,
Some("Token Limit Reached"),
),
TokenState::HasMoreTokens {
max_token_count,
token_count,
over_warn_threshold,
} => {
let (color, tooltip) = if over_warn_threshold {
(Color::Warning, Some("Token Limit is Close to Exhaustion"))
} else {
(Color::Muted, None)
};
(color, token_count, max_token_count, tooltip)
}
};
Some(
h_flex()
.id("token-count")
.gap_0p5()
.child(
Label::new(humanize_token_count(token_count))
.size(LabelSize::Small)
.color(token_count_color),
)
.child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
.child(
Label::new(humanize_token_count(max_token_count))
.size(LabelSize::Small)
.color(Color::Muted),
)
.when_some(tooltip, |element, tooltip| {
element.tooltip(Tooltip::text(tooltip))
}),
)
}
enum PendingSlashCommand {}
fn invoked_slash_command_fold_placeholder(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,18 +11,22 @@
//! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate.
pub mod clangd_ext;
pub mod json_language_server_ext;
pub mod log_store;
pub mod lsp_ext_command;
pub mod rust_analyzer_ext;
use crate::{
CodeAction, ColorPresentation, Completion, CompletionResponse, CompletionSource,
CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction, LspPullDiagnostics,
ManifestProvidersStore, ProjectItem, ProjectPath, ProjectTransaction, PulledDiagnostics,
ResolveState, Symbol,
CodeAction, ColorPresentation, Completion, CompletionDisplayOptions, CompletionResponse,
CompletionSource, CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction,
LspPullDiagnostics, ManifestProvidersStore, Project, ProjectItem, ProjectPath,
ProjectTransaction, PulledDiagnostics, ResolveState, Symbol,
buffer_store::{BufferStore, BufferStoreEvent},
environment::ProjectEnvironment,
lsp_command::{self, *},
lsp_store,
lsp_store::{
self,
log_store::{GlobalLogStore, LanguageServerKind},
},
manifest_tree::{
LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, ManifestQueryDelegate,
ManifestTree,
@@ -977,7 +981,9 @@ impl LocalLspStore {
this.update(&mut cx, |_, cx| {
cx.emit(LspStoreEvent::LanguageServerLog(
server_id,
LanguageServerLogType::Trace(params.verbose),
LanguageServerLogType::Trace {
verbose_info: params.verbose,
},
params.message,
));
})
@@ -3482,13 +3488,13 @@ pub struct LspStore {
buffer_store: Entity<BufferStore>,
worktree_store: Entity<WorktreeStore>,
pub languages: Arc<LanguageRegistry>,
language_server_statuses: BTreeMap<LanguageServerId, LanguageServerStatus>,
pub language_server_statuses: BTreeMap<LanguageServerId, LanguageServerStatus>,
active_entry: Option<ProjectEntryId>,
_maintain_workspace_config: (Task<Result<()>>, watch::Sender<()>),
_maintain_buffer_languages: Task<()>,
diagnostic_summaries:
HashMap<WorktreeId, HashMap<Arc<Path>, HashMap<LanguageServerId, DiagnosticSummary>>>,
pub(super) lsp_server_capabilities: HashMap<LanguageServerId, lsp::ServerCapabilities>,
pub lsp_server_capabilities: HashMap<LanguageServerId, lsp::ServerCapabilities>,
lsp_document_colors: HashMap<BufferId, DocumentColorData>,
lsp_code_lens: HashMap<BufferId, CodeLensData>,
running_lsp_requests: HashMap<TypeId, (Global, HashMap<LspRequestId, Task<()>>)>,
@@ -3565,6 +3571,7 @@ pub struct LanguageServerStatus {
pub pending_work: BTreeMap<String, LanguageServerProgress>,
pub has_pending_diagnostic_updates: bool,
progress_tokens: HashSet<String>,
pub worktree: Option<WorktreeId>,
}
#[derive(Clone, Debug)]
@@ -5821,6 +5828,7 @@ impl LspStore {
.await;
Ok(vec![CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: completion_response.is_incomplete,
}])
})
@@ -5913,6 +5921,7 @@ impl LspStore {
.await;
Some(CompletionResponse {
completions,
display_options: CompletionDisplayOptions::default(),
is_incomplete: completion_response.is_incomplete,
})
});
@@ -7483,7 +7492,7 @@ impl LspStore {
server: Some(proto::LanguageServer {
id: server_id.to_proto(),
name: status.name.to_string(),
worktree_id: None,
worktree_id: status.worktree.map(|id| id.to_proto()),
}),
capabilities: serde_json::to_string(&server.capabilities())
.expect("serializing server LSP capabilities"),
@@ -7508,9 +7517,15 @@ impl LspStore {
pub(crate) fn set_language_server_statuses_from_proto(
&mut self,
project: WeakEntity<Project>,
language_servers: Vec<proto::LanguageServer>,
server_capabilities: Vec<String>,
cx: &mut Context<Self>,
) {
let lsp_logs = cx
.try_global::<GlobalLogStore>()
.map(|lsp_store| lsp_store.0.clone());
self.language_server_statuses = language_servers
.into_iter()
.zip(server_capabilities)
@@ -7520,13 +7535,34 @@ impl LspStore {
self.lsp_server_capabilities
.insert(server_id, server_capabilities);
}
let name = LanguageServerName::from_proto(server.name);
let worktree = server.worktree_id.map(WorktreeId::from_proto);
if let Some(lsp_logs) = &lsp_logs {
lsp_logs.update(cx, |lsp_logs, cx| {
lsp_logs.add_language_server(
// Only remote clients get their language servers set from proto
LanguageServerKind::Remote {
project: project.clone(),
},
server_id,
Some(name.clone()),
worktree,
None,
cx,
);
});
}
(
server_id,
LanguageServerStatus {
name: LanguageServerName::from_proto(server.name),
name,
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
worktree,
},
)
})
@@ -8892,6 +8928,7 @@ impl LspStore {
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
worktree: server.worktree_id.map(WorktreeId::from_proto),
},
);
cx.emit(LspStoreEvent::LanguageServerAdded(
@@ -10905,6 +10942,7 @@ impl LspStore {
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
worktree: Some(key.worktree_id),
},
);
@@ -12168,6 +12206,14 @@ impl LspStore {
let data = self.lsp_code_lens.get_mut(&buffer_id)?;
Some(data.update.take()?.1)
}
pub fn downstream_client(&self) -> Option<(AnyProtoClient, u64)> {
self.downstream_client.clone()
}
pub fn worktree_store(&self) -> Entity<WorktreeStore> {
self.worktree_store.clone()
}
}
// Registration with registerOptions as null, should fallback to true.
@@ -12677,45 +12723,69 @@ impl PartialEq for LanguageServerPromptRequest {
#[derive(Clone, Debug, PartialEq)]
pub enum LanguageServerLogType {
Log(MessageType),
Trace(Option<String>),
Trace { verbose_info: Option<String> },
Rpc { received: bool },
}
impl LanguageServerLogType {
pub fn to_proto(&self) -> proto::language_server_log::LogType {
match self {
Self::Log(log_type) => {
let message_type = match *log_type {
MessageType::ERROR => 1,
MessageType::WARNING => 2,
MessageType::INFO => 3,
MessageType::LOG => 4,
use proto::log_message::LogLevel;
let level = match *log_type {
MessageType::ERROR => LogLevel::Error,
MessageType::WARNING => LogLevel::Warning,
MessageType::INFO => LogLevel::Info,
MessageType::LOG => LogLevel::Log,
other => {
log::warn!("Unknown lsp log message type: {:?}", other);
4
log::warn!("Unknown lsp log message type: {other:?}");
LogLevel::Log
}
};
proto::language_server_log::LogType::LogMessageType(message_type)
}
Self::Trace(message) => {
proto::language_server_log::LogType::LogTrace(proto::LspLogTrace {
message: message.clone(),
proto::language_server_log::LogType::Log(proto::LogMessage {
level: level as i32,
})
}
Self::Trace { verbose_info } => {
proto::language_server_log::LogType::Trace(proto::TraceMessage {
verbose_info: verbose_info.to_owned(),
})
}
Self::Rpc { received } => {
let kind = if *received {
proto::rpc_message::Kind::Received
} else {
proto::rpc_message::Kind::Sent
};
let kind = kind as i32;
proto::language_server_log::LogType::Rpc(proto::RpcMessage { kind })
}
}
}
pub fn from_proto(log_type: proto::language_server_log::LogType) -> Self {
use proto::log_message::LogLevel;
use proto::rpc_message;
match log_type {
proto::language_server_log::LogType::LogMessageType(message_type) => {
Self::Log(match message_type {
1 => MessageType::ERROR,
2 => MessageType::WARNING,
3 => MessageType::INFO,
4 => MessageType::LOG,
_ => MessageType::LOG,
})
}
proto::language_server_log::LogType::LogTrace(trace) => Self::Trace(trace.message),
proto::language_server_log::LogType::Log(message_type) => Self::Log(
match LogLevel::from_i32(message_type.level).unwrap_or(LogLevel::Log) {
LogLevel::Error => MessageType::ERROR,
LogLevel::Warning => MessageType::WARNING,
LogLevel::Info => MessageType::INFO,
LogLevel::Log => MessageType::LOG,
},
),
proto::language_server_log::LogType::Trace(trace_message) => Self::Trace {
verbose_info: trace_message.verbose_info,
},
proto::language_server_log::LogType::Rpc(message) => Self::Rpc {
received: match rpc_message::Kind::from_i32(message.kind)
.unwrap_or(rpc_message::Kind::Received)
{
rpc_message::Kind::Received => true,
rpc_message::Kind::Sent => false,
},
},
}
}
}
@@ -12854,6 +12924,21 @@ pub enum CompletionDocumentation {
},
}
impl CompletionDocumentation {
#[cfg(any(test, feature = "test-support"))]
pub fn text(&self) -> SharedString {
match self {
CompletionDocumentation::Undocumented => "".into(),
CompletionDocumentation::SingleLine(s) => s.clone(),
CompletionDocumentation::MultiLinePlainText(s) => s.clone(),
CompletionDocumentation::MultiLineMarkdown(s) => s.clone(),
CompletionDocumentation::SingleLineAndMultiLinePlainText { single_line, .. } => {
single_line.clone()
}
}
}
}
impl From<lsp::Documentation> for CompletionDocumentation {
fn from(docs: lsp::Documentation) -> Self {
match docs {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ use gpui::{
deferred, div,
};
use persistence::TERMINAL_DB;
use project::{Project, search::SearchQuery, terminals::TerminalKind};
use project::{Project, search::SearchQuery};
use schemars::JsonSchema;
use task::TaskId;
use terminal::{
@@ -204,12 +204,9 @@ impl TerminalView {
cx: &mut Context<Workspace>,
) {
let working_directory = default_working_directory(workspace, cx);
TerminalPanel::add_center_terminal(
workspace,
TerminalKind::Shell(working_directory),
window,
cx,
)
TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| {
project.create_terminal_shell(working_directory, cx)
})
.detach_and_log_err(cx);
}
@@ -1333,16 +1330,10 @@ impl Item for TerminalView {
let terminal = self
.project
.update(cx, |project, cx| {
let terminal = self.terminal().read(cx);
let working_directory = terminal
.working_directory()
.or_else(|| Some(project.active_project_directory(cx)?.to_path_buf()));
let python_venv_directory = terminal.python_venv_directory.clone();
project.create_terminal_with_venv(
TerminalKind::Shell(working_directory),
python_venv_directory,
cx,
)
let cwd = project
.active_project_directory(cx)
.map(|it| it.to_path_buf());
project.clone_terminal(self.terminal(), cx, || cwd)
})
.ok()?
.log_err()?;
@@ -1498,9 +1489,7 @@ impl SerializableItem for TerminalView {
.flatten();
let terminal = project
.update(cx, |project, cx| {
project.create_terminal(TerminalKind::Shell(cwd), cx)
})?
.update(cx, |project, cx| project.create_terminal_shell(cwd, cx))?
.await?;
cx.update(|window, cx| {
cx.new(|cx| {

View File

@@ -7,6 +7,7 @@ pub struct OnboardingBanner {
dismissed: bool,
source: String,
details: BannerDetails,
visible_when: Option<Box<dyn Fn(&mut App) -> bool>>,
}
#[derive(Clone)]
@@ -42,12 +43,18 @@ impl OnboardingBanner {
label: label.into(),
subtitle: subtitle.or(Some(SharedString::from("Introducing:"))),
},
visible_when: None,
dismissed: get_dismissed(source),
}
}
fn should_show(&self, _cx: &mut App) -> bool {
!self.dismissed
pub fn visible_when(mut self, predicate: impl Fn(&mut App) -> bool + 'static) -> Self {
self.visible_when = Some(Box::new(predicate));
self
}
fn should_show(&self, cx: &mut App) -> bool {
!self.dismissed && self.visible_when.as_ref().map_or(true, |f| f(cx))
}
fn dismiss(&mut self, cx: &mut Context<Self>) {

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