Compare commits

..

168 Commits

Author SHA1 Message Date
Peter Tripp
19a9c03e78 wip: linewise cut/copy improvements 2025-07-24 03:31:47 -04:00
versecafe
c08851a85e ollama: Add Magistral to Ollama (#35000)
See also: #34983

Release Notes:

- Added magistral support to ollama
2025-07-24 00:17:54 -04:00
Peter Tripp
b93e1c736b mistral: Add support for magistral-small and magistral-medium (#34983)
Release Notes:

- mistral: Added support for magistral-small and magistral-medium
2025-07-23 23:13:49 -04:00
Piotr Osiewicz
67027bb241 agent: Fix Zed header in settings view (#34993)
Follow-up to taffy bump (#34939), fixes an issue reported by @MrSubidubi


Release Notes:

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

Release Notes:

- Fixed project panel not opening when `project panel: rename` and
`project panel: duplicate` actions are triggered from workspace.
2025-07-24 05:26:12 +05:30
Marshall Bowers
3d4266bb8f collab: Remove POST /billing/subscriptions/manage endpoint (#34986)
This PR removes the `POST /billing/subscriptions/manage` endpoint, as it
has been moved to `cloud.zed.dev`.

Release Notes:

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

update: after this revert Zed compiles with MinGW as before

Release Notes:

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


Release Notes:

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

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

Release Notes:

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

Release Notes:

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

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

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


Release Notes:

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

- N/A
2025-07-23 20:30:21 +00:00
Nicolas Rodriguez
be0d9eecb7 Add collapse functionality to outline entries (#33490)
partly Closes #23075 

Release Notes:

- Now provides collapse and enables functionality to outline entries
- Add a new expand_outlines_with_depth setting to customize how deep the
tree is expanded by when a file is opened

part 2 is in #34164 

**Visual examples**

![image](https://github.com/user-attachments/assets/5dcdb83b-6e3e-4bfd-8ef4-76ae2ce4d3e6)

![image](https://github.com/user-attachments/assets/7b786a5a-1a8c-4f34-aaa5-4a8d0afa9668)

![image](https://github.com/user-attachments/assets/1817be06-ac71-4480-8f17-0bd862e913c8)
2025-07-23 18:52:44 +00:00
Umesh Yadav
9863c8a44e agent_ui: Show keybindings for NewThread and NewTextThread in new thread button (#34967)
I believe in this PR: #34829 we moved to context menu entry from action
but the side effect of that was we also removed the Keybindings from
showing it in the new thread button dropdown. This PR fixes that. cc
@danilo-leal

| Before | After |
|--------|--------|
| <img width="900" height="1962" alt="CleanShot 2025-07-23 at 23 36
28@2x"
src="https://github.com/user-attachments/assets/760cbe75-09b9-404b-9d33-1db73785234f"
/> | <img width="850" height="1964" alt="CleanShot 2025-07-23 at 23 37
17@2x"
src="https://github.com/user-attachments/assets/24a7e871-aebc-475c-845f-b76f02527b8f"
/> |

Release Notes:

- N/A
2025-07-23 18:28:05 +00:00
Joseph T. Lyons
a48247a313 Bump Zed to v0.198 (#34964)
Release Notes:

-N/A
2025-07-23 18:14:39 +00:00
Julia Ryan
5f0edd38f8 Add TestPanic feature flag (#34963)
Now the `dev: panic` action can be run on all release channels if the
user has the feature flag enabled.

Release Notes:

- N/A
2025-07-23 18:01:16 +00:00
Anthony Eid
56b64b1d3f keymap ui: Improve resize columns on double click (#34961)
This PR splits the resize logic into separate left/right propagation
methods and improve code organization around column width adjustments.
It also allows resize to work for both the left and right sides as well,
instead of only checking the right side for room

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-23 13:45:49 -04:00
Danilo Leal
fdcd86617a ai onboarding: Add telemetry event capturing (#34960)
Release Notes:

- N/A

Co-authored-by: Katie Geer <katie@zed.dev>
Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
2025-07-23 17:33:53 +00:00
Michael Sloan
3b428e2ecc Remove !menu from j k binding in initial keymap examples (#34959)
See
https://github.com/zed-industries/zed/pull/34912#issuecomment-3108802582

Release Notes:

- N/A
2025-07-23 17:33:40 +00:00
Anthony Eid
986b446749 keymap ui: Resizable column follow up (#34955)
I cherry picked a small fix that didn't get into the original column
resizable branch PR because I turned on auto merge.

Release Notes:

- N/A
2025-07-23 12:18:55 -04:00
Finn Evers
8713c556d6 keymap_ui: Dim keybinds that are overridden by other keybinds (#34952)
This change dims rows in the keymap editor for which the corresponding
keybind is overridden by other keybinds coming from higher priority
sources.

Release Notes:

- N/A
2025-07-23 16:03:04 +00:00
Mikayla Maki
326fe05b33 Resizable columns (#34794)
This PR adds resizable columns to the keymap editor and the ability to
double-click on a resizable column to set a column back to its default
size.

The table uses a column's width to calculate what position it should be
laid out at. So `column[i]` x position is calculated by the summation of
`column[..i]`. When resizing `column[i]`, `column[i+1]`’s size is
adjusted to keep all columns’ relative positions the same. If
`column[i+1]` is at its minimum size, we keep seeking to the right to
find a column with space left to take.

An improvement to resizing behavior and double-clicking could be made by
checking both column ranges `0..i-1` and `i+1..COLS`, since only one
range of columns is checked for resize capacity.

Release Notes:

- N/A

---------

Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Remco Smits <djsmits12@gmail.com>
2025-07-23 15:44:45 +00:00
claytonrcarter
1f4c9b9427 language: Update block_comment and documentation comment (#34861)
As suggested in https://github.com/zed-industries/zed/pull/34418, this
proposes various changes to language configs to make block comments and
doc-block-style comments more similar. In doing so, it introduces some
breaking changes into the extension schema.

This change is needed to support the changes I'm working on in #34418,
to be able to support `rewrap` in block comments like `/* really long
comment ... */`. As is, we can do this in C-style doc-block comments (eg
`/** ... */`) because of the config in `documentation`, but we can't do
this in regular block comments because we lack the info about what the
line prefix and indentation should be.

And while I was here, I did various other clean-ups, many of which feel
nice but are optional.

I would love special attention on the changes to the schema, version and
related changes; I'm totally unfamiliar with that part of Zed.

**Summary of changes**
- break: changes type of `block_comment` to same type as
`documentation_comment` (**this is the important change**)
- break: rename `documentation` to `documentation_comment` (optional,
but improves consistency w/ `line_comments` and `block_comment`)
- break/refactor?: removes some whitespace in the declaration of
`block_comment` delimiters (optional, may break things, need input; some
langs had no spaces, others did)
- refactor: change `tab_size` from `NonZeroU32` to just a `u32` (some
block comments don't seem to need/want indent past the initial
delimiter, so we need this be 0 sometimes)
- refactor: moves the `documentation_comment` declarations to appear
next to `block_comment`, rearranges the order of the fields in the TOML
for `documentation_comment`, rename backing `struct` (all optional)

**Future scope**
I believe that this will also allow us to extend regular block comments
on newline – as we do doc-block comments – but I haven't looked into
this yet. (eg, in JS try pressing enter in both of these: `/* */` and
`/** */`; the latter should extend w/ a `*` prefixed line, while the
former does not.)

Release Notes:

- BREAKING CHANGE: update extension schema version from 1 to 2, change
format of `block_comment` and rename `documentation_comment`

/cc @smitbarmase
2025-07-23 20:38:52 +05:30
Marshall Bowers
14171e0721 collab: Add POST /users/:id/update_plan endpoint (#34953)
This PR adds a new `POST /users/:id/update_plan` endpoint to Collab to
allow Cloud to push down plan updates to users.

Release Notes:

- N/A
2025-07-23 14:30:08 +00:00
Peter Tripp
326ab5fa3f Improve collab channel organization keybinds (#34821)
Change channel reorganization (move up/down) from `cmd-up/down` (mac) /
`ctrl-up/down` (linux) to `alt-up/down` (both) to match moving lines in
the editor.

Also fix an issue where if you selected channels using down/up in the
filter field, the movement shortcuts would not work (`editing` vs
`not_editing`).

Release Notes:

- N/A
2025-07-23 10:04:53 -04:00
Finn Evers
2bc6e18ac9 Ensure disable_ai is properly respected (#34941)
Quick follow up to #34896 which ensures that the Agent Panel cannot be
caught by actions like `workspace: toggle left dock` when `disable_ai`
is set to true.

Also removes a method that was introduced but unused in the workspace
because `first_enabled_panel_idx` already covers all cases this method
could be useful for.

Release Notes:

- N/A
2025-07-23 12:20:09 +02:00
Piotr Osiewicz
c2c2264a60 gpui: Add tree example (#34942)
This commit adds an example with deep children hierarchy.
The depth of a tree can be tweaked with GPUI_TREE_DEPTH env variable.
With depth=100
<img width="301" height="330" alt="image"
src="https://github.com/user-attachments/assets/844cd285-c5f3-4410-a74e-981bf093ba2e"
/>
With this example, I can trigger a stack overflow at depth=633 (and
higher).


Release Notes:

- N/A
2025-07-23 12:17:23 +02:00
Piotr Osiewicz
6cd3726a5a Revert "chore: Bump taffy to 0.8.3" (#34938)
Reverts zed-industries/zed#34876

From our Slack:
<img width="1694" height="1610" alt="image"
src="https://github.com/user-attachments/assets/c7b8f02a-8609-4ed3-9cd5-7d05d152e40e"
/>


https://github.com/user-attachments/assets/828964be-9b6e-4496-9361-9e3a2e9aa208

Release Notes:
- N/A
2025-07-23 10:28:06 +02:00
tidely
7db110f48d sum_tree: Utilize size_hint in TreeSet::extend (#34936)
Collect the iterator instead of manually looping over it to utilize
possible size hints. Zed usually passes in owned `Vec`'s, meaning we get
to reuse memory as well.

Release Notes:

- N/A
2025-07-23 09:57:57 +02:00
maan2003
6122f46095 project: Fix search filter patterns on remote projects (#34748)
we were join(",") and split(",") to serialize the patterns.

This doesn't work when pattern includes a ","
example: *.{ts,tsx} (very common pattern used by agent)


help needed:

how will this work on version mismatch?

Release Notes:

- Fixed search filter patterns on remote projects.
2025-07-23 00:18:45 -06:00
Joseph T. Lyons
500ceaabcd Add an editor: diff clipboard with selection action (#33283)
https://github.com/user-attachments/assets/d472fbdd-7736-4bd7-8a90-8cca356b2815

This PR adds `editor: diff clipboard with selection` - good for spotting
the differences in eerily-similar code, which is when refactoring code,
as you need to see what needs to be passed in in order to maintain
previous behavior of both snippets.

1. Copy some text from anywhere
2. Highlight some text in Zed
3. Run `editor: diff clipboard with selection`

Like JetBrains' IDEs and VS Code with the `PartialDiff` package, if the
selection is empty, we take the entire buffer as the selection.

Caveats:

- We do not know the language of the text in the clipboard. I went ahead
and just assumed that in most cases, it will be the same language as the
selected text, which does mean we will highlight the old text
incorrectly if they are copying from a different language, but I think
in most cases, it will be the same, and the alternative of always having
no syntax highlighting is worse. PyCharm seems to do the same thing.

Release Notes:

- Added an `editor: diff clipboard with selection` action

---------

Co-authored-by: Junkui Zhang <364772080@qq.com>
Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-23 02:39:32 +00:00
Jason Lee
3e27fa1d92 gpui: Allow Animation to be cloned (#34933)
Release Notes:

- N/A

---

Let `Animation` able to clone, then we can define one to share for
multiple places.

For example:

<img width="914" height="637" alt="image"
src="https://github.com/user-attachments/assets/8eafb318-afba-4399-a975-d83cb7afe74c"
/>
2025-07-23 02:10:40 +00:00
Smit Barmase
11ac83f3d4 workspace: Fix closing remote workspace restores last local workspace on startup (#34931)
Closes #7759

While opening a new SSH project if we are reusing an existing window,
i.e. drop the existing workspace and create a new one, then before
dropping it, we should remove `session_id` from that workspace's
serialized entry. That way:

1. Upon closing (cmd-w) this remote workspace (which also clears
`session_id` for this), and then quitting. No workspace with that
`session_id` is found, and it starts fresh.
2. Upon directly quitting (cmd-q) this remote workspace, only this
workspace exists in db (among two of them) with that `session_id`, and
it restores correctly.
 
Release Notes:

- Fixed an issue while closing remote workspace restores last local
workspace on startup.
2025-07-23 06:48:31 +05:30
Smit Barmase
e90cf0b941 workspace: Fix last removed folder from workspace used to reopen on Zed startup (#34925)
Closes #34924

Now, when `local_paths` are empty, we detach `session_id` from that
workspace serialization item. This way, when we restore it using the
default "last_session", we don't restore this workspace back. This is
same as when we use `cmd-w` to close window, which also sets
`session_id` to `None` before serialization.

Release Notes:

- Fixed an issue where last removed folder from workspace used to reopen
on Zed startup.
2025-07-23 06:46:24 +05:30
Marshall Bowers
056003860a collab: Remove POST /billing/subscriptions endpoint (#34928)
This PR removes the `POST /billing/subscriptions` endpoint, as it has
been moved to `cloud.zed.dev`.

Release Notes:

- N/A
2025-07-22 18:57:07 -04:00
Agus Zubiaga
5d985fa1d8 Improve MCP server responses (#34927)
Release Notes:

- N/A
2025-07-22 22:14:34 +00:00
Marshall Bowers
7f70325a93 language_models: Rename handler to handle in Bedrock provider (#34923)
This PR renames the `handler` field to `handle` on the
`BedrockLanguageModelProvider` and `BedrockModel` structs.

Release Notes:

- N/A
2025-07-22 20:04:08 +00:00
Umesh Yadav
f3c332d839 Fix new crate license symlink (#34922)
The license file is not properly linked to actual license. This was
casued due to new-crate script linking the license to wrong file. Fixed
both of them.

Reference logs:

```
2025-07-22T17:16:19+05:30 ERROR [worktree] error reading target of symlink "/Users/umesh/code/zed/crates/onboarding/LICENSE-GPL": canonicalizing
```

Release Notes:

- N/A
2025-07-22 15:46:25 -04:00
Remco Smits
446d333515 debugger: Fix debug console persist to history when reusing a previous item (#34893)
Closes #34887

Release Notes:

- Debugger: Fix debug console persist to history when reusing a previous
item
2025-07-22 15:40:11 -04:00
Remco Smits
c0f75e1a17 debugger: Fix built-in JavaScript debug tasks were not working due missing type field value (#34894)
Release Notes:

- Debugger: Fix built-in JavaScript debug tasks were not working due
missing `type` field value
2025-07-22 15:39:52 -04:00
Piotr Osiewicz
708c2645d1 collab: Tweak screen selector appearance (#34919)
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>


Release Notes:

- N/A

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-07-22 18:53:57 +00:00
Danilo Leal
4272c1508e ai onboarding: Copyedit the whole flow (#34916)
Release Notes:

- N/A

Co-authored-by: Katie Geer <katie@zed.dev>
2025-07-22 15:41:12 -03:00
Peter Tripp
99466f4aeb Make zooming from menus not persist (#34910)
Closes: https://github.com/zed-industries/zed/issues/34479
Follow-up to: https://github.com/zed-industries/zed/issues/23505

View->Zoom In / Zoom Out / Reset Zoom were not reverted to match when
the default keybindings were reverted.

Release Notes:

- N/A
2025-07-22 13:57:36 -04:00
Marshall Bowers
9e280d0905 collab: Remove unneeded caching of Stripe price IDs by meter ID (#34915)
This PR removes the caching of Stripe price IDs by meter ID on the
`StripeBilling` object, as we weren't actually reading them anywhere.

Release Notes:

- N/A
2025-07-22 17:42:07 +00:00
Michael Sloan
d81a8178e9 Bind "j k" to NormalBefore in initial keymap examples (#34912)
It looks like typically vim configurations bind "j k" to be the same as
escape, which has the "NormalBefore" behavior positioning the block
cursor on the character before the insertion cursor. The [vim mode
docs](https://zed.dev/docs/vim#useful-contexts-for-vim-mode-key-bindings)
also use NormalBefore here.

Thanks to @omniwrench for mentioning this in
https://github.com/zed-industries/zed/discussions/6661#discussioncomment-13848043
. This was a mistake in #31163.

Release Notes:

- N/A
2025-07-22 11:35:58 -06:00
Ben Kunkle
14cea06f0f keymap_ui: Fix panic in clear keystrokes (#34909)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-22 13:18:59 -04:00
Piotr Osiewicz
caa520c499 workspace: Clean up empty panes left over from file opening failures (#34908)
Closes #34583

Release Notes:

- Fixed empty pane being left after a binary file is dropped into a new
pane.s
2025-07-22 16:52:17 +00:00
Piotr Osiewicz
64d0fec699 sum_tree: Store context on cursor (#34904)
This gets rid of the need to pass context to all cursor functions. In
practice context is always immutable when interacting with cursors.

A nicety of this is in the follow-up PR we will be able to implement
Iterator for all Cursors/filter cursors (hell, we may be able to get rid
of filter cursor altogether, as it is just a custom `filter` impl on
iterator trait).
Release Notes:

- N/A
2025-07-22 18:20:48 +02:00
Piotr Osiewicz
fa3e1ccc37 chore: Bump taffy to 0.8.3 (#34876)
That's the latest release. Note that we have an opportunity to simplify
our size types per
https://github.com/DioxusLabs/taffy/blob/main/CHANGELOG.md#highlights-1
(though that's left out of this PR)

<img width="1156" height="603" alt="image"
src="https://github.com/user-attachments/assets/cb9501b9-541a-4080-998a-b6347a0c6887"
/>


Release Notes:

- N/A
2025-07-22 18:19:51 +02:00
tiagoq
56b99f49fd bedrock: Fix remaining streaming delays (#33931)
Closes #26030

*Note: This is my first contribution to Zed*

This addresses a second streaming bottleneck in Bedrock that remained
after the initial fix in #28281 (released in preview 194).

The issue is in the mechanism used to convert Zed's internal `AsyncBody`
into the `SdkBody` expected by the Bedrock language provider. We are
using a non-streaming converter that buffers responses.

**How the fix works:**
The AWS SDK provides streaming-compatible converters to create `SdkBody`
instances, but these require the input body to implement the `Body`
trait from the `http-body` crate.

This PR enables streaming by implementing the required trait and
switching to the streaming-compatible converter.

**Changes (2 commits):**

 * 1st Commit - **Implement http-body Body trait for AsyncBody:**
   - Add `http-body = 1.0` dependency (already an indirect dependency)
   - Implement the `Body` trait for our existing `AsyncBody` type
- Uses `poll_frame` to read data chunks asynchronously, preserving
streaming behavior

 * 2nd Commit - **Use streaming-compatible AWS SDK converter:**
- Create `SdkBody` using `SdkBody::from_body_1_x()` with the new `Body`
trait implementation

**Details/FAQ:**

**Q: Why add another dependency?**
A: We tried to avoid adding a dependency, but the AWS SDK requires the
`Body` trait and `http-body` is where it's defined. The crate is already
an indirect dependency, making this a reasonable solution.

**Q: Why modify the shared `http_client` crate instead of just
`aws_bedrock_client`?**
A: We considered implementing the `Body` trait on a wrapper in
`aws_bedrock_client`, but since `AsyncBody` already uses `http` crate
types, extending support to the companion `http-body` crate seems
reasonable and may benefit other integrations.

**Q: How was this bottleneck discovered?**
A: After @5herlocked's initial streaming fix in #28281, I tested preview
194 and noticed streaming still had issues. I found a way to reproduce
the problem and chatted with @5herlocked about it. He immediately
pinpointed the exact location where the issue was occurring, his
diagnosis made this fix possible.

**Q: How does this relate to the previous fix?**
A: #28281 fixed buffering issues higher in the stack, but unfortunately
there was another bottleneck lower-down in the aws-http-client. This PR
addresses that separate buffering issue.

**Q: Does this use zero-copy or one-copy?**
A: The `Body` implementation includes one copy. Someone more
knowledgeable might be able to achieve a zero-copy approach, but we
opted for a conservative approach. The performance impact should not be
perceptible in typical usage.

**Testing:**
Confirmed that Bedrock streaming now works without buffering delays in a
local build.


Release Notes:

- Improved Bedrock streaming by eliminating response buffering delays

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-07-22 11:55:24 -04:00
Ben Kunkle
2b888e1d30 Fix redo after noop format (#34898)
Closes #31917

Previously, as of #28457 we used a hack, creating an empty transaction
in the history that we then merged formatting changes into in order to
correctly identify concurrent edits to the buffer while formatting was
happening. This caused issues with noop formatting however, as using the
normal API of the buffer history (in an albeit weird way) resulted in
the redo stack being cleared, regardless of whether the formatting
transaction included edits or not, which is the correct behavior in all
other contexts.

This PR fixes the redo issue by codifying the behavior formatting wants,
that being the ability to push an empty transaction to the history with
no other side-effects (i.e. clearing the redo stack) to detect
concurrent edits, with the tradeoff being that it must then manually
remove the transaction later if no changes occurred from the formatting.
The redo stack is still cleared when there are formatting edits, as the
individual format steps use the normal `{start,end}_transaction` methods
which clear the redo stack if the finished transaction isn't empty.

Release Notes:

- Fixed an issue where redo would not work after buffer formatting
(including formatting on save) when the formatting did not result in any
changes
2025-07-22 11:45:42 -04:00
Richard Feldman
96f9942791 Add setting to disable all AI features (#34896)
https://github.com/user-attachments/assets/674bba41-40ac-4a98-99e4-0b47f9097b6a


Release Notes:

- Added setting to disable all AI features
2025-07-22 11:32:39 -04:00
Marshall Bowers
939f9fffa3 collab: Remove unneeded caching of Stripe meters (#34900)
This PR removes the caching of Stripe meters on the `StripeBilling`
object, as we weren't actually reading them anywhere.

Release Notes:

- N/A
2025-07-22 15:27:58 +00:00
Bennet Bo Fenner
230061a6cb Support multiple OpenAI compatible providers (#34212)
TODO
- [x] OpenAI Compatible API Icon
- [x] Docs
- [x] Link to docs in OpenAI provider section about configuring OpenAI
API compatible providers

Closes #33992

Related to #30010

Release Notes:

- agent: Add support for adding multiple OpenAI API compatible providers

---------

Co-authored-by: MrSubidubi <dev@bahn.sh>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-07-22 12:20:07 -03:00
Ben Kunkle
1a76a6b0bf gpui: Simplify bindings_for_action API (#34857)
Closes #ISSUE

Simplifies the API to no longer have a variant that returns indices. The
downside is that a few places that used to call
`bindings_for_action_with_indices` now compare `Box<dyn Action>` instead
of indices, however the result is the removal of wrapper code and index
handling that is largely unnecessary

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Co-authored-by: Conrad <conrad@zed.dev>
2025-07-22 10:59:51 -04:00
Peter Tripp
2eeab5b0bf textmate: Correct context for 'Editor && mode == full' keybinds (#34895)
Closes https://github.com/zed-industries/zed/issues/34891

Release Notes:

- Fixed textmate keymap misbehaving in certain contexts
2025-07-22 14:52:04 +00:00
Ben Kunkle
30177b87d6 Fix detection of pending bindings when binding in parent context matches (#34856)
Broke in #34664

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Co-authored-by: Conrad <conrad@zed.dev>
2025-07-22 13:51:30 +00:00
Umesh Yadav
31aab89ab0 ai_onboarding: Fix API key onboarding callout not showing properly (#34880)
The current onboarding callout for ApiKeysWithProviders is broken.

| Before | After |
|--------|--------|
| <img width="822" height="1914" alt="CleanShot 2025-07-22 at 16 21
53@2x"
src="https://github.com/user-attachments/assets/5a611a8c-1ca2-4a13-965e-6fbd7cfe757a"
/> | <img width="814" height="1956" alt="CleanShot 2025-07-22 at 16 22
38@2x"
src="https://github.com/user-attachments/assets/3263b804-671a-4637-b5dc-ee7c87befa48"
/> |

cc @danilo-leal 

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-07-22 12:54:06 +00:00
Danilo Leal
a3850b3d38 agent: Add use_modifier_to_send section in the settings view (#34866)
This PR also converts all of these switch-based settings to use the new
`SwitchField` component, introduced in
https://github.com/zed-industries/zed/pull/34713.

Release Notes:

- agent: Added the ability to change the `use_modifier_to_send` setting
from the agent panel settings UI.
2025-07-22 09:42:34 -03:00
Oleksiy Syvokon
c7158f0bd7 Improve distinguishing user from agent edits (#34716)
We no longer rely on the `author` field to tell if a change was made by
the user or the agent. The `author` can be set to `User` in many
situations that are not really user-made edits, such as saving a file,
accepting a change, auto-formatting, and more. I started tracking and
fixing some of these cases, but found that inspecting changes in
`diff_base` is a more reliable method.

Also, we no longer show empty diffs. For example, if the user adds a
line and then removes the same line, the final diff is empty, even
though the buffer is marked as user-changed. Now we won't show such
edit.

There are still some issues to address:

- When a user edits within an unaccepted agent-written block, this
change becomes a part of the agent's edit. Rejecting this block will
lose user edits. It won't be displayed in project notifications, either.

- Accepting an agent block counts as a user-made edit.

- Agent start to call `project_notifications` tool after seeing enough
auto-calls.

Release Notes:

- N/A
2025-07-22 14:23:50 +03:00
Ben Brandt
3a651c546b context_server: Change command string field to PathBuf (#34873)
Release Notes:

- N/A
2025-07-22 12:12:07 +02:00
Bret Comnes
87014cec71 theme: Add panel.overlay_background and panel.overlay_hover (#34655)
In https://github.com/zed-industries/zed/pull/33994 sticky scroll was
added to project_panel.

I love this feature! 

This introduces a new element layering not seen before. On themes that
use transparency, the overlapping elements can make it difficult to read
project panel entries. This PR introduces a new selector:
~~panel.sticky_entry.background~~ `panel.overlay_background` This
selector lets you set the background of entries when they become sticky.

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

Before:

<img width="373" height="104" alt="Screenshot 2025-07-17 at 10 19 11 AM"
src="https://github.com/user-attachments/assets/d5bab065-53ca-4b27-b5d8-3b3f8d1f7a81"
/>

After:

<img width="292" height="445" alt="Screenshot 2025-07-17 at 11 46 57 AM"
src="https://github.com/user-attachments/assets/4cd2b87b-2989-4489-972f-872d2dc13a33"
/>

<img width="348" height="390" alt="Screenshot 2025-07-17 at 11 39 57 AM"
src="https://github.com/user-attachments/assets/49c0757f-2c50-4e01-92c6-2ae7e4132a53"
/>

<img width="668" height="187" alt="Screenshot 2025-07-17 at 11 39 29 AM"
src="https://github.com/user-attachments/assets/167536c2-5872-4306-90c6-c6b68276b618"
/>

Release Notes:

- Add `panel.sticky_entry.background` theme selector for modifying
project panel entries when they become sticky when scrolling and overlap
with entries below them.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-07-22 15:20:26 +05:30
Danilo Leal
2b671a46f2 ai onboarding: Don't show API keys section if user is already in Pro (#34867)
Release Notes:

- N/A
2025-07-22 05:39:22 +00:00
Danilo Leal
eaccd542fd Add fast-follows to the AI onboarding flow (#34737)
Follow-up to https://github.com/zed-industries/zed/pull/33738.

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-07-22 02:09:05 -03:00
Marshall Bowers
5a530ecd39 zed: Add support for zed://agent links (#34862)
This PR adds support for `zed://agent` links for opening the Agent
Panel.

Release Notes:

- N/A
2025-07-22 01:40:33 +00:00
Daste
233e66d35f Add editor::BlameHover action for triggering the blame popover via keyboard (#32096)
Make the git blame popover available via the keymap by making it an
action. The blame popover stays open after being shown via the action,
similar to the `editor::Hover` action.

I added a default vim-mode key binding for `g b`, which goes in hand
with `g h` for hover. I'm not sure what the keybind would be for regular
layouts, if any would be set by default.

I'm opening this as a draft because I coludn't figure out a way to
position the popover correctly above/under the cursor head. I saw some
uses of `content_origin` in other places for calculating absolute pixel
positions, but I'm not sure how to make use of it here without doing a
big refactor of the blame popover code 🤔. I would appreciate some
help/tips with positioning, because it seems like the last thing to
implement here.

Opening as a draft for now because I think without the correct
positioning this feature is not complete.

Closes https://github.com/zed-industries/zed/discussions/26447

Release Notes:

- Added `editor::BlameHover` action for showing the git blame popover
under the cursor. By default bound to `ctrl-k ctrl-b` and to `g h` in
vim mode.
2025-07-21 19:30:23 -06:00
Marshall Bowers
15353630e4 zed: Add OpenRequestKind (#34860)
This PR refactors the `OpenRequest` to introduce an `OpenRequestKind`
enum.

It seems most of the fields on `OpenRequest` are mutually-exclusive, so
it is better to model it as an enum rather than using a bunch of
`Option`s.

There are likely more of the existing fields that can be converted into
`OpenRequestKind` variants, but I'm being conservative for this first
pass.

Release Notes:

- N/A
2025-07-22 01:11:11 +00:00
Marshall Bowers
5289b815fe ai_onboarding: Send users directly into the trial checkout flow when starting the trial (#34859)
This PR makes it so users will be sent immediately into the trial
checkout flow (by hitting zed.dev/account/start-trial) when they click
the "Start Pro Trial" button.

Release Notes:

- N/A
2025-07-21 23:58:16 +00:00
Danilo Leal
8515487bbc agent: Add new thread start buttons to the empty state (#34829)
Release Notes:

- N/A
2025-07-21 20:39:29 -03:00
Sergei Surovtsev
19ab1eb792 Fix an issue where xkb defined hotkeys for arrows would not work (#34823)
Addresses
https://github.com/zed-industries/zed/pull/34053#issuecomment-3096447601
where custom-defined arrows would stop working in Zed.

How to reproduce:

1. Define custom keyboard layout

```bash
cd /usr/share/X11/xkb/symbols/
sudo nano mykbd
```

```
default partial alphanumeric_keys
xkb_symbols "custom" {

    name[Group1]= "Custom Layout";

    key <AD01> { [ q,  Q,  Escape,     Escape      ] };
    key <AD02> { [ w,  W,  Home,       Home        ] };
    key <AD03> { [ e,  E,  Up,         Up          ] };
    key <AD04> { [ r,  R,  End,        End         ] };
    key <AD05> { [ t,  T,  Tab,        Tab         ] };

    key <AC01> { [ a,  A,  Return,     Return      ] };
    key <AC02> { [ s,  S,  Left,       Left        ] };
    key <AC03> { [ d,  D,  Down,       Down        ] };
    key <AC04> { [ f,  F,  Right,      Right       ] };
    key <AC05> { [ g,  G,  BackSpace,  BackSpace   ] };

    // include a base layout to inherit the rest
    include "us(basic)"
};
```

2. Activate custom layout with win-key as AltGr

```bash
setxkbmap mykbd -variant custom -option lv3:win_switch
```

3. Now Win-S should produce left arrow, Win-F right arrow
4. Test whether it works in Zed

Release Notes:

 - linux: xkb-defined hotkeys for arrow keys should behave as expected.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-07-21 17:16:44 -06:00
Agus Zubiaga
722a05bc21 Wire up stop button in claude threads (#34839)
Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-07-21 20:33:59 +00:00
Finn Evers
5b3e371812 gpui: Round scroll_max to two decimal places (#34832)
Follow up to #31836 

After enabling rounding in the Taffy layout engine, we frequently run
into cases where the bounds produced by Taffy and ours slightly differ
after 5 or more decimal places. This leads to cases where containers
become scrollable for less than 0.0000x Pixels. In case this happens for
e.g. hover popovers, we render a scrollbar due to the container being
technically scrollable, even though the scroll amount here will in
practice never be visible.

This change fixes this by rounding the `scroll_max` by which we clamp
the current scroll position to two decimal places. We don't benefit from
the additional floating point precision here at all and it stops such
containers from becoming scrollable altogether. Furthermore, we now
store the `scroll_max` instead of the `padded_content_size` as the
former gives a much better idea on whether the corresponding container
is scrollable or not.

| `main` | After these changes |
| -- | -- |
| <img width="610" height="316" alt="main"
src="https://github.com/user-attachments/assets/ffcc0322-6d6e-4f79-a916-bd3c57fe4211"
/> | <img width="610" height="316" alt="scroll_max_rounded"
src="https://github.com/user-attachments/assets/5fe530f5-2e21-4aaa-81f4-e5c53ab73e4f"
/> |

Release Notes:

- Fixed an issue where scrollbars would appear in containers where no
scrolling was possible.
2025-07-21 22:24:33 +02:00
Peter Tripp
8eca7f32e2 Fix for vim bindings in Pickers on Linux (#34840)
Closes: https://github.com/zed-industries/zed/issues/34780

Also relocated undo/redo selection in the keymap (no-op) as they are
from Sublime, not VSCode.

Release Notes:

- vim: Fixed an issue so `ctrl-w` / `ctrl-h` and `ctrl-u` work in
pickers on Linux when Vim mode is enabled.
2025-07-21 20:09:02 +00:00
Ben Kunkle
1a1715766f Fix enter to select model in agent panel (#34846)
Broken by #34664

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-21 19:16:47 +00:00
Anthony Eid
241acbe4be Stop onboarding page from showing up instead of welcome page (#34845)
This is from PR #34723 where I was working on developing the onboarding
page, but I forgot to switch the first page back to our current version.

Release Notes:

- N/A
2025-07-21 19:02:02 +00:00
Piotr Osiewicz
6ea09beea8 terminal: Handle spaces in cwds of remote terminals (#34844)
Closes #34807

Release Notes:

- Fixed "Open in terminal" action not working with paths that contain
spaces in SSH projects.
2025-07-21 18:51:27 +00:00
Piotr Osiewicz
3e50d997dd agent: Fix double-lease panic when clicking on thread to jump (#34843)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-21 18:44:45 +00:00
Richard Feldman
da8bf9ad79 Auto-retry agent errors by default (#34842)
Now we explicitly carve out exceptions for which HTTP responses we do
*not* retry for, and retry at least once on all others.

Release Notes:

- The Agent panel now automatically retries failed requests under more
circumstances.
2025-07-21 14:32:22 -04:00
Bennet Bo Fenner
589af59dfe collab: Refresh the LLM token once the terms of service have been accepted (#34833)
Release Notes:

- N/A
2025-07-21 20:02:28 +02:00
Kirill Bulatov
254c7a330a Regroup LSP context menu items by the worktree name (#34838)
Also 

* remove the feature gate
* open buffers with an error when no logs are present
* adjust the hover text to indicate that difference

<img width="480" height="380" alt="image"
src="https://github.com/user-attachments/assets/6b2350fc-5121-4b1e-bc22-503d964531a2"
/>

Release Notes:

- N/A
2025-07-21 17:48:07 +00:00
Balboa Codes
b6cf398eab docs: Fix PHP docs typo (#34836)
This fixes a minor typo in the PHP docs.

Release Notes:

- N/A
2025-07-21 17:25:59 +00:00
Anthony Eid
bc5c5cf5d6 onboarding: Create basic onboarding page (#34723)
Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-21 16:20:38 +00:00
Marshall Bowers
bf8aba566c collab: Remove unused billing preferences queries (#34830)
This PR removes some billing preferences queries that are no longer in
use.

Release Notes:

- N/A
2025-07-21 15:47:40 +00:00
Piotr Osiewicz
e14c9479e4 chore: Pin taffy version (#34827)
Follow-up to 34817

Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-21 15:36:52 +00:00
Marshall Bowers
97af7e1bd9 collab: Remove PUT /billing/preferences endpoint (#34825)
This PR removes the `PUT /billing/preferences` endpoint, as it has been
moved to `cloud.zed.dev`.

Release Notes:

- N/A
2025-07-21 15:29:16 +00:00
Kamal Ahmad
c251f2a2d4 gpui: Throttle interactive resize events on Wayland (#34760)
Wayland compositors can potentially generate thousands of resize
requests when drag-resizing a window, which Zed is unable to keep up
with. This commit changes the behavior to only resize once per vblank
cycle, which helps significantly when resizing the window with a high
poll-rate mouse (1000Hz is common these days)

Here is an example of the behavior pre and post this commit, with some
print-debugging added for tracking resize calls. I have a wireless mouse
with a 2000Hz polling rate and a 165Hz display:

Before: 


https://github.com/user-attachments/assets/4c657f90-5fd2-4809-97ef-363fd48e81b8

After: 


https://github.com/user-attachments/assets/4a0f5fbd-c3c4-40a1-9f71-3b4358c827cf






Closes #20660

Release Notes:

- Improved: Make resizing smoother on Wayland/Linux
2025-07-21 17:48:01 +03:00
Agus Zubiaga
cc56196152 Fix loading agent server settings (#34662)
Release Notes:

- N/A
2025-07-21 14:26:00 +00:00
Agus Zubiaga
405244d422 Display ACP plans (#34816)
Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-07-21 11:11:37 -03:00
Piotr Osiewicz
35b4a918c9 chore: Bump taffy to 0.5.1 (#34817)
We've had problems in the past with bumping past 0.5.2 due to perf
regressions reported by @huacnlee; 0.5.1 was fine though.

Hence, let's bump taffy to 0.5.1 as a safe bet and then try to push past
0.5.2 (after we identify the root cause of regression
Related to #19189
Release Notes:

- N/A
2025-07-21 15:43:51 +02:00
Danilo Leal
56fd950d94 thread view: Add ability to expand message editor and fix scroll (#34766)
Release Notes:

- N/A
2025-07-21 09:26:31 -03:00
Piotr Osiewicz
88af35fe47 collab: Add screen selector (#31506)
Instead of selecting a screen to share arbitrarily, we'll now allow user
to select the screen to share. Note that sharing multiple screens at the
time is still not supported (though prolly not too far-fetched).

Related to #4666

![image](https://github.com/user-attachments/assets/1afb664f-3cdb-4e0a-bb29-9d7093d87fa5)

Release Notes:

- Added screen selector dropdown to screen share button

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
Co-authored-by: Cole Miller <cole@zed.dev>
2025-07-21 13:44:51 +02:00
Ben Brandt
57ab09c2da claude: Don't quote executable path in mcp configuration (#34805)
This was generating an invalid string for the configuration, removing
the extra quotes makes it work. This affected the versions of Zed that
have a space in their name.

Release Notes:

- N/A
2025-07-21 09:53:05 +00:00
Jason Lee
caa4b529e4 gpui: Add tab focus support (#33008)
Release Notes:

- N/A

With a `tab_index` and `tab_stop` option to `FocusHandle` to us can
switch focus by `Tab`, `Shift-Tab`.

The `tab_index` is from
[WinUI](https://learn.microsoft.com/en-us/uwp/api/windows.ui.xaml.controls.control.tabindex?view=winrt-26100)
and [HTML
tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex),
only the `tab_stop` is enabled that can be added into the `tab_handles`
list.

- Added `window.focus_next()` and `window.focus_previous()` method to
switch focus.
- Added `tab_index` to `InteractiveElement`.

```bash
cargo run -p gpui --example tab_stop
```


https://github.com/user-attachments/assets/ac4e3e49-8359-436c-9a6e-badba2225211
2025-07-20 16:38:54 -07:00
Michael Sloan
137081f050 Misc code cleanups accumulated while working on other changes (#34787)
Release Notes:

- N/A
2025-07-20 23:22:13 +00:00
Ben Kunkle
7c1040bc93 keymap_ui: Auto complete action arguments (#34785)
Supersedes: #34242

Creates an `ActionArgumentsEditor` that implements the required logic to
have a JSON language server run when editing keybinds so that there is
auto-complete for action arguments.

This is the first time action argument schemas are required by
themselves rather than inlined in the keymap schema. Rather than add all
action schemas to the configuration options we send to the JSON LSP on
startup, this PR implements support for the
`vscode-json-language-server` extension to the LSP whereby the server
will request the client (Zed) to resolve URLs with URI schemes it does
not recognize, in our case `zed://`. This limits the impact on the size
of the configuration options to ~1KB as we send URLs for the language
server to resolve on demand rather than the schema itself. My
understanding is that this is how VSCode handles JSON schemas as well. I
plan to investigate converting the rest of our schema generation logic
to this method in a follow up PR.

Co-Authored-By: Cole <cole@zed.dev>

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-20 16:24:17 -04:00
Michael Sloan
ff79b29f38 Set stripe-mock version to 0.178.0 to match stripe API version used (#34786)
Release Notes:

- N/A
2025-07-20 19:39:04 +00:00
Bennet Bo Fenner
2e41e312ad component preview: Fix Zed AI onboarding young account preview (#34783)
Release Notes:

- N/A
2025-07-20 16:27:18 +00:00
Peter Tripp
5f92ac25a7 docs: Consolidate backend setup docs into local-collaboration.md (#34653)
Simplify docs for mac/linux/windows by consolidating the backend
dependencies (collaboration) docs into local-collaboration.md. Most
users building zed will not need to do this -- streamline them into
getting setup to build the zed client app first.

Release Notes:

- N/A
2025-07-19 12:01:33 -04:00
Peter Tripp
fb88de9223 Fix error in OpenRouter svg logo (#34764)
Fix a spurious error in Zed logs from the OpenRouter svg Logo introduced
in https://github.com/zed-industries/zed/pull/29496:

```log
WARN  [usvg::parser::svgtree] Failed to parse clip-path value: 'url(#clip0_205_3)'.
```

Release Notes:

- N/A
2025-07-19 16:01:18 +00:00
Oleksandr Mykhailenko
29111304dd agent: Fix Mistral tool use error message (#34692)
Closes #32675

Exactly the same changes as in #33640 by @sviande

The PR has been in WIP state for 3 weeks with no activity, and the issue
basically makes Mistral models unusable. I have tested the changes
locally, and it does indeed work. Full credit goes to @sviande, I just
want this feature to be finished.

Release Notes:

- agent: Fixed an issue with tool calling with the Mistral provider
(thanks [@sviande](https://github.com/sviande) and
[@armyhaylenko](https://github.com/armyhaylenko))

Co-authored-by: sviande <sviande@gmail.com>
2025-07-19 11:59:57 -04:00
Vitaly Slobodin
0ffd93774c Fix Tailwind support for HTML/ERB files (#34743)
Closes #27118
Closes #34165

Fix a small issue after we landed
https://github.com/zed-extensions/ruby/pull/113+ where we introduced
`HTML/ERB` and `YAML/ERB` language IDs to improve user experience. Sorry
about that. Thanks!

Release Notes:

- N/A
2025-07-19 11:06:35 -04:00
Mikayla Maki
2da2ae65a0 gpui: Add use state APIs (#34741)
This PR adds a component level state API to GPUI, as well as a few
utilities for simplified interactions with entities

Release Notes:

- N/A
2025-07-19 01:27:54 +00:00
morgankrey
4bdac8026c collab: Enable automatic tax calculation for all new subscriptions (#34720)
Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-07-18 21:42:48 +00:00
Peter Tripp
70bde54a2c ci: Lint GitHub Actions workflows with actionlint (#34729)
Added [rhysd/actionlint](https://github.com/rhysd/actionlint/) a static
checker for GitHub Actions workflow files.
Install locally with `brew install actionlint` the run with
`actionlint`.

Inspired by: https://github.com/zed-industries/zed/pull/34704 which
yielded this observation:

> In github actions: 
> 1. strings are truthy
> 2. `${{ }}` will become a string if it doesn't wrap the whole value.
>
> So `if: false && true` becomes `false`
> and `if: ${{ false && true }}` becomes `false`
> but `if: false && ${{ true }}` becomes `"false && true"` which
evaluates true
> The reason you sometimes need `${{ }}` is because YAML doesn't like
`!`
> so `if: !false` is invalid yaml
> and `if: ${{ !false }}` works just fine.

Changes:
- Add `actionlint` job
- Refactor `job_spec` job to be more readable
- Fix all `actionlint` and `shellcheck` errors in Actions workflows (62
in all)
- Add `self-mini-macos` and `self-32vcpu-windows-2022` labels to
self-hosted runners. Not strictly related, but useful if you need to
take a runner out of the rotation (since `macOS`, `self-hosted`, and
`ARM64` are auto-set and cannot be added/removed).
- Change ci.yml macos_relase to target `self-mini-macos` instead of
`bundle` which was previously deprecated.

This would've caught the error fixed in
https://github.com/zed-industries/zed/pull/34704. Here's what that [job
failure](https://github.com/zed-industries/zed/actions/runs/16376993944/job/46279281842?pr=34729)
would've looked like.

Release Notes:

- N/A
2025-07-18 15:15:07 -04:00
Conrad Irwin
43486c416c Fix enter in branch view (#34731)
Broken by #34664

Release Notes:

- N/A
2025-07-18 19:04:05 +00:00
Michael Sloan
d197c96cdc Add stripe-mock to docker compose configuration (#34732)
Release Notes:

- N/A
2025-07-18 18:58:55 +00:00
Peter Tripp
7b6b75b63f ci: Skip generating Windows release artifacts (#34704)
Release Notes:

- N/A
2025-07-18 14:31:08 -04:00
Anthony Eid
fd64ee1bb6 keymap ui: Fix remove key mapping bug (#34683)
Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-18 14:09:28 -04:00
Danilo Leal
e1d28ff957 agent: Add use_modifier_to_send setting (#34709)
When `use_modifier_to_send` is turned on, holding `cmd`/`ctrl` is
necessary to send a message in the agent panel. Text threads already use
`cmd-enter` by default to submit a message, and it was done this way to
have the usual text editing bindings not taken over when writing a
prompt, sort of stimulating more thoughtful writing. While `enter` to
send is still somewhat a huge pattern in chat-like LLM UIs, it still
makes sense to allow this for the new agent panel... hence the existence
of this setting now!

Release Notes:

- agent: Added the `use_modifier_to_send` setting, which makes holding a
modifier (`cmd`/`ctrl`), together with `enter`, required to send a new
message.
2025-07-18 15:03:31 -03:00
Danilo Leal
64ce696aae ui: Add the SwitchField component (#34713)
This will be useful for both the current agent panel and some other
onboarding stuff we're working on. Also ended up removing the
`SwitchWithLabel` as it was unused.

Release Notes:

- N/A
2025-07-18 15:03:14 -03:00
tidely
87555d3f0b project: Remove clones from git blame serialization (#34727)
Release Notes:

- N/A
2025-07-18 20:41:12 +03:00
Finn Evers
5b18ce79ab editor: Ensure topmost buffer header can be properly folded (#34721)
This PR fixes an issue where the topmost header in a multibuffer would
jump when the corresponding buffer was folded.
The issue arose because for the topmost header, the offset within the
scroll anchor is negative, as the corresponding buffer only starts below
the header itself and thus the offset for the scroll position has to be
negative.
However, upon collapsing that buffer, we end up with a negative vertical
scroll position, which causes all kinds of different problems. The issue
has been present for a long time, but became more visible after
https://github.com/zed-industries/zed/pull/34295 landed, as that change
removed the case distinction for buffers scrolled all the way to the
top.

This PR fixes this by clamping just the vertical scroll position upon
return, which ensures the negative offset works as expected when the
buffer is expanded, but the vertical scroll position does not turn
negative once the buffer is folded.

Release Notes:

- Fixed an issue where folding the topmost buffer in a multibuffer would
cause the header to jump slightly.
2025-07-18 19:16:31 +02:00
Smit Barmase
1dd470ca48 editor: Fix double $ sign on completion accept in PHP (#34726)
Closes #33510 https://github.com/zed-extensions/php/issues/29

If certain language servers do not provide an insert/replace range, we
use `surrounding_word` as a fallback for that range, which internally
uses `word_characters`. It makes sense to use
`completion_query_characters` instead of `word_characters` to get that
range, because we use `completion_query_characters` to query completions
in the first place.

That means, for some hypothetical reason (e.g., if the Tailwind server
stops providing insert/replace ranges), we would correctly fall back to
the range "bg-blue-200^" instead of "200^", because
`completion_query_characters` includes "-" in this case.

For this particular fix, right now the default PHP language server
`phpactor` does not provide an insert/replace range, and hence
completion query character is used, which is `$` in this case.

Note that `$` isn't in word characters for reasons mentioned here:
https://github.com/zed-extensions/php/issues/14

Release Notes:

- Fixed an issue where accepting variable completion in PHP would result
in a double $ sign in the prefix.
2025-07-18 22:39:00 +05:30
Finn Evers
8bc8d61fa6 theme_importer: Add missing color imports for the minimap thumb (#34724)
These should have been part of
https://github.com/zed-industries/zed/pull/30785 but I forgot to add
them there.

Release Notes:

- N/A
2025-07-18 16:55:03 +00:00
Marshall Bowers
750ceeb760 collab: Don't use screen-capture feature from gpui (#34725)
This PR removes the `screen-capture` feature from `gpui` when depending
on it in `collab`.

Release Notes:

- N/A
2025-07-18 16:28:58 +00:00
Danilo Leal
4476860664 Add refinements to the AI onboarding flow (#33738)
This includes making sure that both the agent panel and Zed's edit
prediction have a consistent narrative when it comes to onboarding users
into the AI features, considering the possible different plans and
conditions (such as being signed in/out, account age, etc.)

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-07-18 18:25:36 +02:00
Joseph T. Lyons
9a20843ba2 Revert "gpui: Improve path rendering & global multisample anti-aliasing" (#34722)
Reverts zed-industries/zed#29718

We've noticed some issues with Zed on Intel-based Macs where typing has
become sluggish, and git bisect has seemed to point towards this PR.
Reverting for now, until we can understand why it is causing this issue.
2025-07-18 16:03:08 +00:00
Conrad Irwin
e421fc7a2d Update keymap context binding behavior of > and ! (#34664)
Now ! means "no ancestors matches this", and > means "any descendent"
not "any child".

Updates #34570

Co-authored-by: Ben Kunkle <ben@zed.dev>

Release Notes:

- *Breaking change*. The context predicates in the keymap file now
handle ! and > differently. Before this change ! meant "this node does
not match", now it means "none of these nodes match". Before this change
> meant "child of", now it means "descendent of". We do not expect these
changes to break many keymaps, but they may cause subtle changes for
complex context queries.

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-18 09:25:18 -06:00
Marshall Bowers
f461290ac3 collab: Add POST /users/:id/refresh_llm_tokens endpoint (#34714)
This PR adds a new `POST /users/:id/refresh_llm_tokens` endpoint to
Collab so that we can refresh LLM tokens from Cloud.

Release Notes:

- N/A
2025-07-18 14:52:42 +00:00
Peter Tripp
6a24b2479c Redact license keys in environment variables from log output (#34711)
Release Notes:

- N/A
2025-07-18 14:28:20 +00:00
Piotr Osiewicz
2ac99e7a11 debugger: Fix attaching with DebugPy (#34706)
@cole-miller found a root cause of our struggles with attach scenarios;
we did not fetch .so files necessary for attaching to work,
as we were downloading DebugPy source tarballs from GitHub.

This PR does away with it by setting up a virtualenv instead that has
debugpy installed.

Closes #34660
Closes #34575

Release Notes:

- debugger: Fixed attaching with DebugPy. DebugPy is now installed
automatically from pip (instead of GitHub), unless it is present in
active virtual environment. Additionally this should resolve any startup
issues with missing .so on Linux.
2025-07-18 14:28:03 +00:00
Peter Tripp
d604b3b291 Fix incorrect minimum_contrast comment (#34710)
Actual default is 45


fd05f17fa7/assets/settings/default.json (L1402)

Release Notes:

- N/A
2025-07-18 10:01:09 -04:00
Bennet Bo Fenner
4002801034 agent: Fix new thread model selection when starting new thread (#34708)
Release Notes:

- agent: Fixed an issue where clicking on "Start New Thread" in the
agent configuration would not always switch to the correct
provider/model
2025-07-18 13:58:44 +00:00
Peter Tripp
fd8480a9dc Document terminal.cursor_shape (#34707)
Release Notes:

- N/A
2025-07-18 13:35:36 +00:00
Danilo Leal
1070de47ec component preview: Add separators between sections in sidebar (#34701)
Small improvement for navigating inside the component preview.

Release Notes:

- N/A
2025-07-18 10:24:57 -03:00
Ben Brandt
cfe1adc792 E2E Claude tests (#34702)
- **Fix cancellation of tool calls**
- **Make tool_call test more resilient**
- **Fix tool call confirmation test**

Release Notes:

- N/A
2025-07-18 13:17:41 +00:00
Lukas Spiss
fd05f17fa7 go: Support raw string subtest names (#34636)
Currently, we're not able to run Go sub-tests that have a raw string
(e.g. we're using multi-line names a lot) via the UI. I added the
changes that are needed, plus a handful of tests to cover the basics.

Quick comparison:

Before:
<img width="901" height="370" alt="before"
src="https://github.com/user-attachments/assets/4e5cadeb-9a0c-49e2-b976-2223e1010f85"
/>



After:
<img width="901" height="505" alt="after"
src="https://github.com/user-attachments/assets/994fc69b-f720-488c-a14b-853a3ca2f53c"
/>


Release Notes:

- Added support for Go subtest runner with raw string names
2025-07-18 13:38:18 +02:00
tidely
7e3fd7bb02 gpui: Use static keyword with LazyLock when loading system fonts (#34555)
Use the `static` keyword to actually make the `LazyLock` static, which
previously would reinitialize on every call to `SvgRenderer::new`.

Related: #26335 

Release Notes:

- N/A
2025-07-18 12:35:38 +02:00
Arseny Kapoulkine
00097df0d5 Improve C/C++ indentation flow for single statement blocks (#34549)
Before this, indentation did not automatically increase after
if/for/while/do/else statements in C++, and only increased after if/for
in C. This led to Zed using last line logic when inserting lines *after*
the indented statement, as well as not indenting the statement itself,
resulting in irregular indentation during typing.

Just adding indentation (similar to C) creates a new problem: now if a
scope is started with a brace on a new line, that brace is indented.
Thus we need to deindent it.

Using else_clause in the indent guide results in the else statement
being indented forward as well, so we need to deindent that too.

Note: the most significant issue for me is the one where indentation
jumps forward when inserting lines after indented lines. Unfortunately,
it appears that fixing that issue requires all of these other changes. I
would have preferred a simpler fix, but I'm not sure if disabling last
line behavior for C/C++ is appropriate as it probably breaks something
else, like cases where the file is incomplete and the statements can't
be parsed properly.

Editing flow before this change:

[Screencast From 2025-07-16
08-31-36.webm](https://github.com/user-attachments/assets/3dea86c5-47bd-47c2-aee8-b0aa613948e6)

Editing flow after this change:

[Screencast From 2025-07-16
08-35-36.webm](https://github.com/user-attachments/assets/7ef23e60-1ee3-49fd-90f9-d53f909ca674)

(note: the "else" snippet is completely breaking the flow here, but I
think that comes from clangd by default? Unfortunately I haven't found a
way to disable it cleanly but that is a separate problem that happens
right now too.)

Release Notes:

- Improve indentation during typing for C/C++ around if/for/while/do
blocks
2025-07-18 12:24:20 +02:00
Andy Waite
c13322397e docs: Document pull diagnostics support for Ruby (#34028)
This is now supported.

Release Notes:

- N/A
2025-07-18 12:46:36 +03:00
Daniel Sauble
c1307cead4 Add ; key binding for Helix mode (#34315)
Closes #34111

In Helix mode, the `;` key should collapse the current selection without
moving the cursor. I've added a new action `vim::HelixCollapseSelection`
to support this behavior.


https://github.com/user-attachments/assets/1a40821a-f56f-456e-9d37-532500bef17b

Release Notes:

- Added `;` key binding to collapse the current text selection in Helix
mode
2025-07-17 22:30:01 -06:00
Danilo Leal
8a7bd5f47b agent: Adjust retry on Burn Mode layout (#34680)
Quick follow-up to https://github.com/zed-industries/zed/pull/34669 so
that the buttons don't look so big in comparison to the callout.

Release Notes:

- N/A
2025-07-18 01:19:27 +00:00
Danilo Leal
c287397a18 Rename "CloseInactiveItems" action to "CloseOtherItems" (#34676)
This is following feedback from folks that were searching the "close
others" action, available in the tab's context menu, and not finding it
because it was actually named "close inactive", which was confusing. So,
this PR makes sure the tab's menu item and the action have consistent
naming.

Release Notes:

- Rename "CloseInactiveItems" action to "CloseOtherItems" for naming
consistency.
2025-07-17 21:40:02 -03:00
Smit Barmase
a7284adafa editor: Fix cursor doesn’t move up and down on arrow keys when no completions are shown (#34678)
Closes #34338

After https://github.com/zed-industries/zed/pull/31872, to avoid
re-querying language servers, we keep the context menu around, which
stores initial query, completions items, etc., even though it may not
contain any items and hence not be rendered on screen. In this state,
up/down arrows try to switch focus in the context menu instead of
propagating it to the editor. Hence blocking buffer movement.

This PR fixes it by changing the context for `menu`,
`showing_completions`, and `showing_code_actions` to only be added when
the menu is actually being rendered (i.e., not empty).

Release Notes:

- Fix an issue where the cursor doesn’t move up and down on arrow keys
when no completions are shown.
2025-07-18 06:07:52 +05:30
Ben Kunkle
4314b35288 keymap_ui: Don't panic on KeybindSource::from_meta (#34652)
Closes #ISSUE

Log error instead of panicking when `from_meta` is passed an invalid
value

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-17 20:03:10 -04:00
Danilo Leal
ed4deaa738 agent: Remove layout shift due to the "waiting for confirmation" label (#34674)
Take 2 on https://github.com/zed-industries/zed/pull/33046.

Release Notes:

- N/A
2025-07-17 20:46:16 -03:00
Danilo Leal
f0a91502a9 keymap_ui: Add some design refinements (#34673)
Mostly small stuff over here.

Release Notes:

- N/A
2025-07-17 20:22:21 -03:00
Richard Feldman
1ab659c71f Retry on burn mode (#34669)
Now we only auto-retry if burn mode is enabled. We also show a "Retry"
button (so you don't have to type "continue") if you think that's the
right remedy, and additionally we show a "Retry and Enable Burn Mode"
button if you don't have it enabled.

<img width="484" height="260" alt="Screenshot 2025-07-17 at 6 25 27 PM"
src="https://github.com/user-attachments/assets/dc5bf1f6-8b11-4041-87aa-4f37c95ea9f0"
/>

<img width="478" height="307" alt="Screenshot 2025-07-17 at 6 22 36 PM"
src="https://github.com/user-attachments/assets/1ed6578a-1696-449d-96d1-e447d11959fa"
/>


Release Notes:

- Only auto-retry Agent requests when Burn Mode is enabled
2025-07-17 23:04:03 +00:00
Richard Feldman
d470411725 Improve upstream error reporting (#34668)
Now we handle more upstream error cases using the same auto-retry logic.

Release Notes:

- N/A
2025-07-17 18:12:48 -04:00
Finn Evers
6c741292df keymap_ui: Fix various keymap editor issues (#34647)
This PR tackles miscellaneous nits for the new keymap editor UI.

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-17 18:12:10 -04:00
Danilo Leal
29030a243c docs: Add instructions about how to use MCP servers (#34656)
Release Notes:

- N/A
2025-07-17 18:49:44 -03:00
Cole Miller
237ce5c8e8 Report build SHA to Slack for Zed Nightly panics (#34665)
There can be a bunch of nightlies with the same version number and it's
helpful to know exactly which one reported a panic.

Release Notes:

- N/A
2025-07-17 21:38:45 +00:00
Peter Tripp
0c88189aab Refine JetBrains keymaps (#34658)
Follow-up to: https://github.com/zed-industries/zed/pull/34641

Release Notes:

- N/A
2025-07-17 19:23:00 +00:00
Marshall Bowers
1e60ebb2c6 collab: Remove GET /billing/usage endpoint (#34651)
This PR removes the `GET /billing/usage` endpoint, as it has been moved
to `cloud.zed.dev`.

Release Notes:

- N/A
2025-07-17 18:34:04 +00:00
Peter Tripp
9efe9df800 Harmonize buffer_font_size between default.json and initial_settings.json (#34650)
Release Notes:

- N/A
2025-07-17 18:32:04 +00:00
Agus Zubiaga
dab0b3509d Unify agent server settings and extract e2e tests out (#34642)
Release Notes:

- N/A
2025-07-17 17:34:38 +00:00
Smit Barmase
0f72d7ed52 editor: Fix sometimes green (+) cursor style appearing when cmd-clicking in same buffer (#34638)
Follow-up for https://github.com/zed-industries/zed/pull/34557

This PR clears the selection drag state on click, because mouse up
doesn't trigger on click event because of `cx.stop_propagation`. The
issue occurs with similar repro steps as mentioned in the attached PR.

Release Notes:

- Fixed the issue where the green (+) cursor style sometimes appears
when navigating to the definition in buffer.
2025-07-17 22:46:44 +05:30
Peter Tripp
1ceda2babd JetBrains keymap improvements (July 2025) (#34641)
Closes: https://github.com/zed-industries/zed/issues/14639
Closes: https://github.com/zed-industries/zed/issues/33020

If would have ideas for future enhancements, please see:
- https://github.com/zed-industries/zed/discussions/34643

Various Jetbrains keymaps improvements for macOS and Linux/Windows:

| Area | Action | macOS | Linux |
| ------------- | -------------------------- |
--------------------------------- | --------------------------------- |
| Workspace | Toggle Git Panel | `cmd-0` | `ctrl-0` |
| Workspace | Toggle Project Panel | `cmd-1` | `alt-0` |
| Workspace | Toggle Debug Panel | `cmd-5` | `alt-1` |
| Workspace | Toggle Diagnostics | `cmd-6` | `alt-6` |
| Workspace | Toggle Outline Panel | `cmd-7` | `alt-7` |
| Workspace | Toggle Terminal Panel | `alt-f12` | `alt-f12` |
| Workspace | File Finder | `cmd-e` | `ctrl-e` |
| Workspace | Task Spawn | `ctrl-alt-r` | `alt-shift-f10` |
| Workspace | Close All Docks | `ctrl-shift-f12` | `ctrl-shift-f12` |
| Project Panel | Search in Directory | `cmd-shift-f` | `ctrl-shift-f` |
| Search | Replace in Files | `cmd-shift-r` | `ctrl-shift-r` |
| Search | Replace in Buffer | `cmd-r` | `ctrl-r` |
| Search | Toggle Case Sensitive | `ctrl-alt-c` / `alt-c` | `ctrl-alt-c`
|
| Search | Toggle Search in Selection | `ctrl-alt-s` / `alt-s` |
`ctrl-alt-s` |
| Search | Toggle Regex | `ctrl-alt-x` / `alt-x` | `ctrl-alt-x` |
| Search | Toggle Whole Word | `ctrl-alt-w` / `alt-w` | `ctrl-alt-w` |
| Terminal | New Terminal Tab | `cmd-t` | `ctrl-shift-t` |
| Terminal | Scroll Line | `cmd-up` / `cmd-down` | `ctrl-up` /
`ctrl-down` |
| Terminal | Scroll Page | `shift-pageup` / `shift-pagedown` |
`shift-pageup` / `shift-pagedown` |
| Git | Git Panel | `cmd-k` | `ctrl-k` |
| Git | Git Push | `cmd-shift-k` | `ctrl-shift-k` |

In addition, with the help of the recently merged
https://github.com/zed-industries/zed/pull/34495, no matter where you
are mashing `escape` will refocus you back to your most recent editor
buffer similar to the behavior of JetBrains.

Release Notes:

- jetbrains: Added 25+ keybinds to the macOS and Linux/Windows JetBrains
compatibility keymaps
2025-07-17 17:14:24 +00:00
Anthony Eid
ae0d4f6a0d debugger: Add data breakpoint access type support (#34639)
Release Notes:

- Support specifying a data breakpoint's access type - Read, Write, Read
& Write
2025-07-17 17:05:58 +00:00
Piotr Osiewicz
8980526a85 chore: Bump lsp-types rev (#34345)
Closes #ISSUE

Release Notes:

- N/A
2025-07-17 15:58:54 +00:00
Umesh Yadav
b4dc7f8a8a debugger: Add support for running test methods with function receiver in Go (#34613)
![CleanShot 2025-07-17 at 16 35
10](https://github.com/user-attachments/assets/bad794fb-198e-40a1-958c-6ff30a0a4e53)


Closes #33759

Release Notes:

- debugger: Add support for running test methods with function receiver
in Go

Signed-off-by: Umesh Yadav <git@umesh.dev>
2025-07-17 17:44:40 +02:00
Ben Kunkle
b94649ce1e keymap_ui: Show edit icon on hovered and selected row (#34630)
Closes #ISSUE

Improves the behavior of the edit icon in the far left column of the
keymap UI table. It is now shown in both the selected and the hovered
row as an indicator that the row is editable in this configuration. When
hovered a row can be double clicked or the edit icon can be clicked, and
when selected it can be edited via keyboard shortcuts. Additionally, the
edit icon and all other hover tooltips will now disappear when the table
is navigated via keyboard shortcuts.

<details><summary>Video</summary>



https://github.com/user-attachments/assets/6584810f-4c6d-4e6f-bdca-25b16c920cfc

</details>

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-07-17 11:34:34 -04:00
Ben Kunkle
948c1f22bb keymap_ui: Improve keybind display in menus (#34587)
Closes #ISSUE

Defines keybindings for `keymap_editor::EditBinding` and
`keymap_editor::CreateBinding`, making sure those actions are used in
tooltips.

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Finn <dev@bahn.sh>
2025-07-17 11:10:07 -04:00
Ben Brandt
b0eac4267d Mark glob/grep as code blocks (#34628)
Release Notes:

- N/A
2025-07-17 15:01:02 +00:00
Agus Zubiaga
8e4555455c Claude experiment (#34577)
Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Oleksiy Syvokon <oleksiy.syvokon@gmail.com>
2025-07-17 14:25:55 +00:00
Kirill Bulatov
5b97cd1900 Better serialize the git panel (#34622)
Follow-up of https://github.com/zed-industries/zed/pull/29874
Closes https://github.com/zed-industries/zed/issues/34618
Closes https://github.com/zed-industries/zed/issues/34611

Release Notes:

- N/A
2025-07-17 12:39:41 +00:00
Kirill Bulatov
ceab139f54 Rework extension-related errors (#34620)
Before:
<img width="1728" height="1079" alt="before"
src="https://github.com/user-attachments/assets/4ab19211-8de4-458d-a835-52de859b7b20"
/>

After:
<img width="1728" height="1079" alt="after"
src="https://github.com/user-attachments/assets/231c9362-a0b0-47ae-b92e-de6742781d36"
/>

Makes clear which path is causing the FS error and removes backtraces
from logging.

Release Notes:

- N/A
2025-07-17 12:20:47 +00:00
Oleksiy Syvokon
4df7f52bf3 agent: Disable project_notifications by default (#34615)
This tool needs more polishing before being generally available.

Release Notes:

- agent: Disabled `project_notifications` tool by default for the time
being
2025-07-17 11:55:38 +00:00
Oleksiy Syvokon
1e67e30034 Fix shortcuts with Shift (#34614)
Closes #34605, #34606, #34609

Release Notes:

- Fixed shortcuts involving Shift
2025-07-17 11:21:20 +00:00
Arseny Kapoulkine
758c5fb955 Allow disabling snippet completion by setting snippet_sort_order to none (#34565)
This mirrors VSCode setting that inspired `snippet_sort_order` to begin
with; VSCode supports inline/top/bottom/none, with none completely
disabling snippet completion. See
https://code.visualstudio.com/docs/editing/intellisense#_snippets-in-suggestions

This is helpful for LSPs that do not allow configuring snippets via
configuration such as clangd.

Release Notes:

- Added `none` as one of the values for `snippet_sort_order` to
completely disable snippet completion.
2025-07-17 16:22:26 +05:30
Oleksiy Syvokon
acb3ecef0c Do not send project notifications when agent creates a file (#34610)
Release Notes:

- N/A
2025-07-17 13:08:20 +03:00
Finn Evers
ad2bfa3edd Disable minimap in the inspector (#34607)
This disables the minimap in the inspector UI as it doesn't bring any
value to it and just takes up unnecessary space.

Release Notes:

- N/A
2025-07-17 09:22:04 +00:00
Eric Cornelissen
1d72fa8e9e git: Add ability to pass --signoff (#29874)
This adds an option for `--signoff` to the git panel and commit modal.
It allows users to enable the [`--signoff`
flag](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt-code--signoffcode)
when committing through Zed. The option is added to the context menu of
the commit button (following the style of the "Editor Controls").

To support this, the commit+amend experience was revamped (following the
ideas of [this
comment](https://github.com/zed-industries/zed/pull/29874#issuecomment-2950848000)).
Amending is now also a toggle in the commit button's dropdown menu. I've
kept some of the original experience such as the changed button text and
ability to cancel outside the context menu.

The tooltip of the commit buttons now also includes the flags that will
be used based on the amending and signoff status (which I couldn't
capture in screenshots unfortunately). So, by default the tooltip will
say `git commit` and if you toggle, e.g., amending on it will say `git
commit --amend`.

| What | Panel | Modal |
| --- | --- | --- |
| Not amending, dropdown | ![git modal preview, not amending,
dropdown](https://github.com/user-attachments/assets/82c2b338-b3b5-418c-97bf-98c33202d7dd)
| ![commit modal preview, not amending,
dropdown](https://github.com/user-attachments/assets/f7a6f2fb-902d-447d-a473-2efb4ba0f444)
|
| Amending, dropdown | ![git modal preview, amending,
dropdown](https://github.com/user-attachments/assets/9e755975-4a27-43f0-aa62-be002ecd3a92)
| ![commit modal preview, amending,
dropdown](https://github.com/user-attachments/assets/cad03817-14e1-46f6-ba39-8ccc7dd12161)
|
| Amending | ![git modal preview,
amending](https://github.com/user-attachments/assets/e1ec4eba-174e-4e5f-9659-5867d6b0fdc2)
| - |

The initial implementation was based on the changeset of
https://github.com/zed-industries/zed/pull/28187.

Closes https://github.com/zed-industries/zed/discussions/26114

Release Notes:

- Added git `--signoff` support.
- Update the git `--amend` experience.
- Improved git panel to persist width as well as amend and signoff on a
per-workspace basis.
2025-07-17 03:39:54 +00:00
Conrad Irwin
1ce384bbda Fix ctrl-q on AZERTY on Linux (#34597)
Closes #ISSUE

Release Notes:

- N/A
2025-07-16 21:27:46 -06:00
Conrad Irwin
9f302df6d6 Don't override ascii graphical shortcuts (#34592)
Closes #34536

Release Notes:

- (preview only) Fix shortcuts on Extended Latin keyboards on Linux
2025-07-16 20:09:38 -06:00
Smit Barmase
ebad5ca50e linux: Fix buttons clicks wouldn’t work on startup until clicked on center pane (#34590)
Closes #31805

This is an issue with Linux currently that `window.focus` is `None` upon
startup in both X11 and Wayland. Specifically, the order in which
[this](8d05a3d389/crates/gpui/src/window.rs (L3116))
and
[this](8d05a3d389/crates/gpui/src/app.rs (L956))
are executed varies between Linux and macOS. That is, one tries to
remove (blur) focus from a window, while other checks window focus to
put that focus id to a frame. In macOS, blur happens afterwards setting
focus on a frame, but in Linux, the inverse of it happens, leading to
`window.focus` to `None`.

For the time being, we handle all visible buttons to take care of this
**focus can be `None`** case, and make it work anyway. But, we should
look at the deeper issue mentioned above with GPUI. Created new issue to
track that https://github.com/zed-industries/zed/issues/34591.

Release Notes:

- Fixed an issue where button clicks wouldn’t work on startup until
clicked on the center pane on Linux.
2025-07-17 06:36:02 +05:30
Cole Miller
b9ff538747 docs: Discuss inlay_hints.show_value_hints in debugger docs (#34581)
This isn't under the `debugger` settings key, but it seems good to
document on the debugger page anyway.

Release Notes:

- N/A
2025-07-16 19:35:30 -04:00
342 changed files with 19003 additions and 8080 deletions

View File

@@ -24,7 +24,7 @@ workspace-members = [
third-party = [
{ name = "reqwest", version = "0.11.27" },
# build of remote_server should not include scap / its x11 dependency
{ name = "scap", git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318" },
{ name = "scap", git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7" },
]
[final-excludes]

30
.github/actionlint.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# Configuration related to self-hosted runner.
self-hosted-runner:
# Labels of self-hosted runner in array of strings.
labels:
# GitHub-hosted Runners
- github-8vcpu-ubuntu-2404
- github-16vcpu-ubuntu-2404
- windows-2025-16
- windows-2025-32
- windows-2025-64
# Buildjet Ubuntu 20.04 - AMD x86_64
- buildjet-2vcpu-ubuntu-2004
- buildjet-4vcpu-ubuntu-2004
- buildjet-8vcpu-ubuntu-2004
- buildjet-16vcpu-ubuntu-2004
- buildjet-32vcpu-ubuntu-2004
# Buildjet Ubuntu 22.04 - AMD x86_64
- buildjet-2vcpu-ubuntu-2204
- buildjet-4vcpu-ubuntu-2204
- buildjet-8vcpu-ubuntu-2204
- buildjet-16vcpu-ubuntu-2204
- buildjet-32vcpu-ubuntu-2204
# Buildjet Ubuntu 22.04 - Graviton aarch64
- buildjet-8vcpu-ubuntu-2204-arm
- buildjet-16vcpu-ubuntu-2204-arm
- buildjet-32vcpu-ubuntu-2204-arm
- buildjet-64vcpu-ubuntu-2204-arm
# Self Hosted Runners
- self-mini-macos
- self-32vcpu-windows-2022

View File

@@ -28,7 +28,7 @@ jobs:
run: |
set -eux
channel=$(cat crates/zed/RELEASE_CHANNEL)
channel="$(cat crates/zed/RELEASE_CHANNEL)"
tag_suffix=""
case $channel in
@@ -43,9 +43,9 @@ jobs:
;;
esac
which cargo-set-version > /dev/null || cargo install cargo-edit
output=$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')
output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
export GIT_COMMITTER_NAME="Zed Bot"
export GIT_COMMITTER_EMAIL="hi@zed.dev"
git commit -am "Bump to $output for @$GITHUB_ACTOR" --author "Zed Bot <hi@zed.dev>"
git tag v${output}${tag_suffix}
git push origin HEAD v${output}${tag_suffix}
git tag "v${output}${tag_suffix}"
git push origin HEAD "v${output}${tag_suffix}"

View File

@@ -34,6 +34,7 @@ jobs:
run_license: ${{ steps.filter.outputs.run_license }}
run_docs: ${{ steps.filter.outputs.run_docs }}
run_nix: ${{ steps.filter.outputs.run_nix }}
run_actionlint: ${{ steps.filter.outputs.run_actionlint }}
runs-on:
- ubuntu-latest
steps:
@@ -47,39 +48,40 @@ jobs:
run: |
if [ -z "$GITHUB_BASE_REF" ]; then
echo "Not in a PR context (i.e., push to main/stable/preview)"
COMPARE_REV=$(git rev-parse HEAD~1)
COMPARE_REV="$(git rev-parse HEAD~1)"
else
echo "In a PR context comparing to pull_request.base.ref"
git fetch origin "$GITHUB_BASE_REF" --depth=350
COMPARE_REV=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)
COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)"
fi
# Specify anything which should skip full CI in this regex:
CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})"
# Specify anything which should potentially skip full test suite in this regex:
# - docs/
# - script/update_top_ranking_issues/
# - .github/ISSUE_TEMPLATE/
# - .github/workflows/ (except .github/workflows/ci.yml)
SKIP_REGEX='^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!ci)))'
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -vP "$SKIP_REGEX") ]]; then
echo "run_tests=true" >> $GITHUB_OUTPUT
else
echo "run_tests=false" >> $GITHUB_OUTPUT
fi
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^docs/') ]]; then
echo "run_docs=true" >> $GITHUB_OUTPUT
else
echo "run_docs=false" >> $GITHUB_OUTPUT
fi
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P '^(Cargo.lock|script/.*licenses)') ]]; then
echo "run_license=true" >> $GITHUB_OUTPUT
else
echo "run_license=false" >> $GITHUB_OUTPUT
fi
NIX_REGEX='^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)'
if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P "$NIX_REGEX") ]]; then
echo "run_nix=true" >> $GITHUB_OUTPUT
else
echo "run_nix=false" >> $GITHUB_OUTPUT
fi
echo "$CHANGED_FILES" | grep -qvP "$SKIP_REGEX" && \
echo "run_tests=true" >> "$GITHUB_OUTPUT" || \
echo "run_tests=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" | grep -qP '^docs/' && \
echo "run_docs=true" >> "$GITHUB_OUTPUT" || \
echo "run_docs=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" | grep -qP '^\.github/(workflows/|actions/|actionlint.yml)' && \
echo "run_actionlint=true" >> "$GITHUB_OUTPUT" || \
echo "run_actionlint=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" | grep -qP '^(Cargo.lock|script/.*licenses)' && \
echo "run_license=true" >> "$GITHUB_OUTPUT" || \
echo "run_license=false" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \
echo "run_nix=true" >> "$GITHUB_OUTPUT" || \
echo "run_nix=false" >> "$GITHUB_OUTPUT"
migration_checks:
name: Check Postgres and Protobuf migrations, mergability
@@ -89,8 +91,7 @@ jobs:
needs.job_spec.outputs.run_tests == 'true'
timeout-minutes: 60
runs-on:
- self-hosted
- macOS
- self-mini-macos
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -112,11 +113,11 @@ jobs:
run: |
if [ -z "$GITHUB_BASE_REF" ];
then
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> $GITHUB_ENV
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV"
else
git checkout -B temp
git merge -q origin/$GITHUB_BASE_REF -m "merge main into temp"
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> $GITHUB_ENV
git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp"
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV"
fi
- uses: bufbuild/buf-setup-action@v1
@@ -140,7 +141,7 @@ jobs:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install cargo-hakari
uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2
with:
@@ -178,7 +179,7 @@ jobs:
- name: Prettier Check on /docs
working-directory: ./docs
run: |
pnpm dlx prettier@${PRETTIER_VERSION} . --check || {
pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || {
echo "To fix, run from the root of the Zed repo:"
echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .."
false
@@ -188,7 +189,7 @@ jobs:
- name: Prettier Check on default.json
run: |
pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --check || {
pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --check || {
echo "To fix, run from the root of the Zed repo:"
echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --write"
false
@@ -234,6 +235,20 @@ jobs:
- name: Build docs
uses: ./.github/actions/build_docs
actionlint:
runs-on: ubuntu-latest
if: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_actionlint == 'true'
needs: [job_spec]
steps:
- uses: actions/checkout@v4
- name: Download actionlint
id: get_actionlint
run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
shell: bash
- name: Check workflow files
run: ${{ steps.get_actionlint.outputs.executable }} -color
shell: bash
macos_tests:
timeout-minutes: 60
name: (macOS) Run Clippy and tests
@@ -242,8 +257,7 @@ jobs:
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- self-hosted
- macOS
- self-mini-macos
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -312,7 +326,7 @@ jobs:
- buildjet-16vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -364,7 +378,7 @@ jobs:
- buildjet-8vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -444,6 +458,7 @@ jobs:
- job_spec
- style
- check_docs
- actionlint
- migration_checks
# run_tests: If adding required tests, add them here and to script below.
- workspace_hack
@@ -465,6 +480,11 @@ jobs:
if [[ "${{ needs.job_spec.outputs.run_docs }}" == "true" ]]; then
[[ "${{ needs.check_docs.result }}" != 'success' ]] && { RET_CODE=1; echo "docs checks failed"; }
fi
if [[ "${{ needs.job_spec.outputs.run_actionlint }}" == "true" ]]; then
[[ "${{ needs.actionlint.result }}" != 'success' ]] && { RET_CODE=1; echo "actionlint checks failed"; }
fi
# Only check test jobs if they were supposed to run
if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then
[[ "${{ needs.workspace_hack.result }}" != 'success' ]] && { RET_CODE=1; echo "Workspace Hack failed"; }
@@ -484,8 +504,7 @@ jobs:
timeout-minutes: 120
name: Create a macOS bundle
runs-on:
- self-hosted
- bundle
- self-mini-macos
if: |
startsWith(github.ref, 'refs/tags/v')
|| contains(github.event.pull_request.labels.*.name, 'run-bundling')
@@ -748,7 +767,7 @@ jobs:
timeout-minutes: 120
name: Create a Windows installer
runs-on: [self-hosted, Windows, X64]
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
if: false && (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling'))
needs: [windows_tests]
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }}
@@ -787,7 +806,7 @@ jobs:
- name: Upload Artifacts to release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
# Re-enable when we are ready to publish windows preview releases
if: false && ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview
if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview
with:
draft: true
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
@@ -802,10 +821,9 @@ jobs:
&& endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64]
runs-on:
- self-hosted
- bundle
- self-mini-macos
steps:
- name: gh release
run: gh release edit $GITHUB_REF_NAME --draft=false
run: gh release edit "$GITHUB_REF_NAME" --draft=false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
URL="https://zed.dev/releases/stable/latest"
fi
echo "URL=$URL" >> $GITHUB_OUTPUT
echo "URL=$URL" >> "$GITHUB_OUTPUT"
- name: Get content
uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 # v1.4.1
id: get-content
@@ -50,9 +50,9 @@ jobs:
PREVIEW_TAG="${VERSION}-pre"
if git rev-parse "$PREVIEW_TAG" > /dev/null 2>&1; then
echo "was_promoted_from_preview=true" >> $GITHUB_OUTPUT
echo "was_promoted_from_preview=true" >> "$GITHUB_OUTPUT"
else
echo "was_promoted_from_preview=false" >> $GITHUB_OUTPUT
echo "was_promoted_from_preview=false" >> "$GITHUB_OUTPUT"
fi
- name: Send release notes email

View File

@@ -79,12 +79,12 @@ jobs:
- name: Build docker image
run: |
docker build -f Dockerfile-collab \
--build-arg GITHUB_SHA=$GITHUB_SHA \
--tag registry.digitalocean.com/zed/collab:$GITHUB_SHA \
--build-arg "GITHUB_SHA=$GITHUB_SHA" \
--tag "registry.digitalocean.com/zed/collab:$GITHUB_SHA" \
.
- name: Publish docker image
run: docker push registry.digitalocean.com/zed/collab:${GITHUB_SHA}
run: docker push "registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
- name: Prune Docker system
run: docker system prune --filter 'until=72h' -f
@@ -131,7 +131,8 @@ jobs:
source script/lib/deploy-helpers.sh
export_vars_for_environment $ZED_KUBE_NAMESPACE
export ZED_DO_CERTIFICATE_ID=$(doctl compute certificate list --format ID --no-header)
ZED_DO_CERTIFICATE_ID="$(doctl compute certificate list --format ID --no-header)"
export ZED_DO_CERTIFICATE_ID
export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
export ZED_SERVICE_NAME=collab

View File

@@ -35,7 +35,7 @@ jobs:
- buildjet-16vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

View File

@@ -43,8 +43,8 @@ jobs:
- name: Set path
if: ${{ ! matrix.system.install_nix }}
run: |
echo "/nix/var/nix/profiles/default/bin" >> $GITHUB_PATH
echo "/Users/administrator/.nix-profile/bin" >> $GITHUB_PATH
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH"
- uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31
if: ${{ matrix.system.install_nix }}
@@ -56,11 +56,13 @@ jobs:
name: zed
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
pushFilter: "${{ inputs.cachix-filter }}"
cachixArgs: '-v'
cachixArgs: "-v"
- run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config
- name: Limit /nix/store to 50GB on macs
if: ${{ ! matrix.system.install_nix }}
run: |
[ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d || :
if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then
nix-collect-garbage -d || true
fi

View File

@@ -85,8 +85,7 @@ jobs:
name: Create a macOS bundle
if: github.repository_owner == 'zed-industries'
runs-on:
- self-hosted
- bundle
- self-mini-macos
needs: tests
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
@@ -132,7 +131,7 @@ jobs:
clean: false
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install Linux dependencies
run: ./script/linux && ./script/install-mold 2.34.0

View File

@@ -26,7 +26,7 @@ jobs:
- buildjet-16vcpu-ubuntu-2204
steps:
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4

123
Cargo.lock generated
View File

@@ -3,10 +3,9 @@
version = 4
[[package]]
name = "acp"
name = "acp_thread"
version = "0.1.0"
dependencies = [
"agent_servers",
"agentic-coding-protocol",
"anyhow",
"assistant_tool",
@@ -21,6 +20,7 @@ dependencies = [
"language",
"markdown",
"project",
"serde",
"serde_json",
"settings",
"smol",
@@ -139,16 +139,33 @@ dependencies = [
name = "agent_servers"
version = "0.1.0"
dependencies = [
"acp_thread",
"agentic-coding-protocol",
"anyhow",
"collections",
"context_server",
"env_logger 0.11.8",
"futures 0.3.31",
"gpui",
"indoc",
"itertools 0.14.0",
"language",
"libc",
"log",
"nix 0.29.0",
"paths",
"project",
"schemars",
"serde",
"serde_json",
"settings",
"smol",
"strum 0.27.1",
"tempfile",
"ui",
"util",
"uuid",
"watch",
"which 6.0.3",
"workspace-hack",
]
@@ -176,11 +193,12 @@ dependencies = [
name = "agent_ui"
version = "0.1.0"
dependencies = [
"acp",
"acp_thread",
"agent",
"agent_servers",
"agent_settings",
"agentic-coding-protocol",
"ai_onboarding",
"anyhow",
"assistant_context",
"assistant_slash_command",
@@ -192,6 +210,7 @@ dependencies = [
"chrono",
"client",
"collections",
"command_palette_hooks",
"component",
"context_server",
"db",
@@ -213,6 +232,7 @@ dependencies = [
"jsonschema",
"language",
"language_model",
"language_models",
"languages",
"log",
"lsp",
@@ -251,6 +271,7 @@ dependencies = [
"time_format",
"tree-sitter-md",
"ui",
"ui_input",
"unindent",
"urlencoding",
"util",
@@ -264,9 +285,9 @@ dependencies = [
[[package]]
name = "agentic-coding-protocol"
version = "0.0.9"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e276b798eddd02562a339340a96919d90bbfcf78de118fdddc932524646fac7"
checksum = "a3e6ae951b36fa2f8d9dd6e1af6da2fcaba13d7c866cf6a9e65deda9dc6c5fe4"
dependencies = [
"anyhow",
"chrono",
@@ -315,6 +336,23 @@ dependencies = [
"memchr",
]
[[package]]
name = "ai_onboarding"
version = "0.1.0"
dependencies = [
"client",
"component",
"gpui",
"language_model",
"proto",
"serde",
"smallvec",
"telemetry",
"ui",
"workspace-hack",
"zed_actions",
]
[[package]]
name = "alacritty_terminal"
version = "0.25.1-dev"
@@ -1835,9 +1873,7 @@ version = "0.1.0"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"futures 0.3.31",
"http_client",
"tokio",
"workspace-hack",
]
@@ -2148,7 +2184,7 @@ dependencies = [
[[package]]
name = "blade-graphics"
version = "0.6.0"
source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
dependencies = [
"ash",
"ash-window",
@@ -2181,7 +2217,7 @@ dependencies = [
[[package]]
name = "blade-macros"
version = "0.3.0"
source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
dependencies = [
"proc-macro2",
"quote",
@@ -2191,7 +2227,7 @@ dependencies = [
[[package]]
name = "blade-util"
version = "0.2.0"
source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
dependencies = [
"blade-graphics",
"bytemuck",
@@ -3411,12 +3447,14 @@ dependencies = [
"futures 0.3.31",
"gpui",
"log",
"net",
"parking_lot",
"postage",
"schemars",
"serde",
"serde_json",
"smol",
"tempfile",
"url",
"util",
"workspace-hack",
@@ -4232,6 +4270,7 @@ dependencies = [
"serde",
"serde_json",
"shlex",
"smol",
"task",
"util",
"workspace-hack",
@@ -4412,6 +4451,7 @@ dependencies = [
"pretty_assertions",
"project",
"rpc",
"schemars",
"serde",
"serde_json",
"serde_json_lenient",
@@ -6320,6 +6360,7 @@ dependencies = [
"buffer_diff",
"call",
"chrono",
"client",
"collections",
"command_palette_hooks",
"component",
@@ -7361,9 +7402,9 @@ dependencies = [
[[package]]
name = "grid"
version = "0.13.0"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d196ffc1627db18a531359249b2bf8416178d84b729f3cebeb278f285fb9b58c"
checksum = "71b01d27060ad58be4663b9e4ac9e2d4806918e8876af8912afbddd1a91d5eaa"
[[package]]
name = "group"
@@ -7815,6 +7856,7 @@ dependencies = [
"derive_more 0.99.19",
"futures 0.3.31",
"http 1.3.1",
"http-body 1.0.1",
"log",
"serde",
"serde_json",
@@ -8977,6 +9019,7 @@ dependencies = [
"task",
"text",
"theme",
"toml 0.8.20",
"tree-sitter",
"tree-sitter-elixir",
"tree-sitter-embedded-template",
@@ -9048,6 +9091,7 @@ dependencies = [
name = "language_models"
version = "0.1.0"
dependencies = [
"ai_onboarding",
"anthropic",
"anyhow",
"aws-config",
@@ -9058,11 +9102,11 @@ dependencies = [
"client",
"collections",
"component",
"convert_case 0.8.0",
"copilot",
"credentials_provider",
"deepseek",
"editor",
"fs",
"futures 0.3.31",
"google_ai",
"gpui",
@@ -9129,7 +9173,6 @@ dependencies = [
"collections",
"copilot",
"editor",
"feature_flags",
"futures 0.3.31",
"gpui",
"itertools 0.14.0",
@@ -9689,7 +9732,7 @@ dependencies = [
[[package]]
name = "lsp-types"
version = "0.95.1"
source = "git+https://github.com/zed-industries/lsp-types?rev=6add7052b598ea1f40f7e8913622c3958b009b60#6add7052b598ea1f40f7e8913622c3958b009b60"
source = "git+https://github.com/zed-industries/lsp-types?rev=39f629bdd03d59abd786ed9fc27e8bca02c0c0ec#39f629bdd03d59abd786ed9fc27e8bca02c0c0ec"
dependencies = [
"bitflags 1.3.2",
"serde",
@@ -10288,6 +10331,17 @@ dependencies = [
"uuid",
]
[[package]]
name = "nc"
version = "0.1.0"
dependencies = [
"anyhow",
"futures 0.3.31",
"net",
"smol",
"workspace-hack",
]
[[package]]
name = "ndk"
version = "0.8.0"
@@ -10936,6 +10990,23 @@ dependencies = [
"workspace-hack",
]
[[package]]
name = "onboarding"
version = "0.1.0"
dependencies = [
"anyhow",
"command_palette_hooks",
"db",
"feature_flags",
"fs",
"gpui",
"settings",
"theme",
"ui",
"workspace",
"workspace-hack",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -14138,7 +14209,7 @@ dependencies = [
[[package]]
name = "scap"
version = "0.0.8"
source = "git+https://github.com/zed-industries/scap?rev=270538dc780f5240723233ff901e1054641ed318#270538dc780f5240723233ff901e1054641ed318"
source = "git+https://github.com/zed-industries/scap?rev=808aa5c45b41e8f44729d02e38fd00a2fe2722e7#808aa5c45b41e8f44729d02e38fd00a2fe2722e7"
dependencies = [
"anyhow",
"cocoa 0.25.0",
@@ -14709,6 +14780,7 @@ dependencies = [
"fs",
"fuzzy",
"gpui",
"itertools 0.14.0",
"language",
"log",
"menu",
@@ -14721,6 +14793,7 @@ dependencies = [
"serde_json",
"settings",
"telemetry",
"tempfile",
"theme",
"tree-sitter-json",
"tree-sitter-rust",
@@ -15888,13 +15961,12 @@ dependencies = [
[[package]]
name = "taffy"
version = "0.4.4"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ec17858c2d465b2f734b798b920818a974faf0babb15d7fef81818a4b2d16f1"
checksum = "7aaef0ac998e6527d6d0d5582f7e43953bb17221ac75bb8eb2fcc2db3396db1c"
dependencies = [
"arrayvec",
"grid",
"num-traits",
"serde",
"slotmap",
]
@@ -16436,6 +16508,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
name = "title_bar"
version = "0.1.0"
dependencies = [
"anyhow",
"auto_update",
"call",
"chrono",
@@ -18681,8 +18754,7 @@ dependencies = [
[[package]]
name = "windows-capture"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59d10b4be8b907c7055bc7270dd68d2b920978ffacc1599dcb563a79f0e68d16"
source = "git+https://github.com/zed-industries/windows-capture.git?rev=f0d6c1b6691db75461b732f6d5ff56eed002eeb9#f0d6c1b6691db75461b732f6d5ff56eed002eeb9"
dependencies = [
"clap",
"ctrlc",
@@ -20097,7 +20169,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.197.0"
version = "0.198.0"
dependencies = [
"activity_indicator",
"agent",
@@ -20138,6 +20210,7 @@ dependencies = [
"extension",
"extension_host",
"extensions_ui",
"feature_flags",
"feedback",
"file_finder",
"fs",
@@ -20171,9 +20244,11 @@ dependencies = [
"menu",
"migrator",
"mimalloc",
"nc",
"nix 0.29.0",
"node_runtime",
"notifications",
"onboarding",
"outline",
"outline_panel",
"parking_lot",
@@ -20480,6 +20555,7 @@ dependencies = [
name = "zeta"
version = "0.1.0"
dependencies = [
"ai_onboarding",
"anyhow",
"arrayvec",
"call",
@@ -20487,6 +20563,7 @@ dependencies = [
"clock",
"collections",
"command_palette_hooks",
"copilot",
"ctor",
"db",
"editor",
@@ -20501,8 +20578,6 @@ dependencies = [
"language_model",
"log",
"menu",
"migrator",
"paths",
"postage",
"project",
"proto",

View File

@@ -2,10 +2,11 @@
resolver = "2"
members = [
"crates/activity_indicator",
"crates/acp",
"crates/acp_thread",
"crates/agent_ui",
"crates/agent",
"crates/agent_settings",
"crates/ai_onboarding",
"crates/agent_servers",
"crates/anthropic",
"crates/askpass",
@@ -102,10 +103,12 @@ members = [
"crates/migrator",
"crates/mistral",
"crates/multi_buffer",
"crates/nc",
"crates/net",
"crates/node_runtime",
"crates/notifications",
"crates/ollama",
"crates/onboarding",
"crates/open_ai",
"crates/open_router",
"crates/outline",
@@ -219,13 +222,14 @@ edition = "2024"
# Workspace member crates
#
acp = { path = "crates/acp" }
acp_thread = { path = "crates/acp_thread" }
agent = { path = "crates/agent" }
activity_indicator = { path = "crates/activity_indicator" }
agent_ui = { path = "crates/agent_ui" }
agent_settings = { path = "crates/agent_settings" }
agent_servers = { path = "crates/agent_servers" }
ai = { path = "crates/ai" }
ai_onboarding = { path = "crates/ai_onboarding" }
anthropic = { path = "crates/anthropic" }
askpass = { path = "crates/askpass" }
assets = { path = "crates/assets" }
@@ -317,10 +321,12 @@ menu = { path = "crates/menu" }
migrator = { path = "crates/migrator" }
mistral = { path = "crates/mistral" }
multi_buffer = { path = "crates/multi_buffer" }
nc = { path = "crates/nc" }
net = { path = "crates/net" }
node_runtime = { path = "crates/node_runtime" }
notifications = { path = "crates/notifications" }
ollama = { path = "crates/ollama" }
onboarding = { path = "crates/onboarding" }
open_ai = { path = "crates/open_ai" }
open_router = { path = "crates/open_router", features = ["schemars"] }
outline = { path = "crates/outline" }
@@ -406,7 +412,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates
#
agentic-coding-protocol = { version = "0.0.9" }
agentic-coding-protocol = "0.0.10"
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
@@ -434,9 +440,9 @@ aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
base64 = "0.22"
bitflags = "2.6.0"
blade-graphics = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
blade-macros = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
blade-util = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
blade-graphics = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
blade-macros = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
blade-util = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
blake3 = "1.5.3"
bytes = "1.0"
cargo_metadata = "0.19"
@@ -476,6 +482,7 @@ heed = { version = "0.21.0", features = ["read-txn-no-tls"] }
hex = "0.4.3"
html5ever = "0.27.0"
http = "1.1"
http-body = "1.0"
hyper = "0.14"
ignore = "0.4.22"
image = "0.25.1"
@@ -489,18 +496,18 @@ json_dotpath = "1.1"
jsonschema = "0.30.0"
jsonwebtoken = "9.3"
jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed" ,rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
libc = "0.2"
libsqlite3-sys = { version = "0.30.1", features = ["bundled"] }
linkify = "0.10.0"
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "6add7052b598ea1f40f7e8913622c3958b009b60" }
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "39f629bdd03d59abd786ed9fc27e8bca02c0c0ec" }
markup5ever_rcdom = "0.3.0"
metal = "0.29"
moka = { version = "0.12.10", features = ["sync"] }
naga = { version = "25.0", features = ["wgsl-in"] }
nanoid = "0.4"
nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
nix = "0.29"
num-format = "0.4.4"
objc = "0.2"
@@ -541,7 +548,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c77
"stream",
] }
rsa = "0.9.6"
runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
"async-dispatcher-runtime",
] }
rust-embed = { version = "8.4", features = ["include-exclude"] }
@@ -549,8 +556,7 @@ rustc-demangle = "0.1.23"
rustc-hash = "2.1.0"
rustls = { version = "0.23.26" }
rustls-platform-verifier = "0.5.0"
# When updating scap rev, also update it in .config/hakari.toml
scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false }
scap = { git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7", default-features = false }
schemars = { version = "1.0", features = ["indexmap2"] }
semver = "1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
@@ -704,6 +710,7 @@ features = [
[patch.crates-io]
notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" }
# Makes the workspace hack crate refer to the local one, but only when you're building locally
workspace-hack = { path = "tooling/workspace-hack" }

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.35443 9.97775L6.71495 8.65418L6.75443 8.53883L6.71495 8.47508H6.59948L6.20456 8.45081L4.8557 8.41436L3.68608 8.36579L2.55291 8.30507L2.26734 8.24438L2 7.89224L2.02734 7.71617L2.26734 7.55528L2.61063 7.58564L3.37013 7.63724L4.50937 7.71617L5.3357 7.76474L6.56 7.89224H6.75443L6.78176 7.81331L6.71495 7.76474L6.66329 7.71617L5.48456 6.91778L4.20861 6.07388L3.54025 5.58815L3.17873 5.34227L2.99646 5.11157L2.91747 4.60764L3.24557 4.24639L3.68608 4.27675L3.79848 4.30711L4.24506 4.65014L5.19899 5.38781L6.44456 6.30458L6.62684 6.45635L6.69974 6.40475L6.70886 6.36833L6.62684 6.23171L5.94938 5.00834L5.22632 3.76372L4.9043 3.24766L4.81924 2.93802C4.78886 2.81053 4.7676 2.70427 4.7676 2.57374L5.14127 2.06678L5.34785 2L5.84609 2.06678L6.0557 2.24893L6.36557 2.95624L6.86684 4.07033L7.64456 5.58512L7.87241 6.0344L7.99391 6.45029L8.03948 6.57779H8.11847V6.50492L8.18228 5.6519L8.30075 4.6046L8.41619 3.25677L8.4557 2.87731L8.64404 2.42196L9.01772 2.17607L9.30938 2.31571L9.54938 2.65874L9.51596 2.88034L9.37316 3.80622L9.09368 5.25728L8.9114 6.22868H9.01772L9.13925 6.10727L9.6314 5.45459L10.4577 4.42246L10.8223 4.01265L11.2476 3.56033L11.521 3.3448H12.0375L12.4172 3.90944L12.2471 4.49228L11.7154 5.1662L11.275 5.73692L10.643 6.58691L10.2481 7.26689L10.2846 7.32152L10.3787 7.31243L11.8066 7.00886L12.5782 6.86921L13.4987 6.71135L13.915 6.90563L13.9605 7.10297L13.7965 7.50671L12.8122 7.74956L11.6577 7.98026L9.93824 8.38706L9.91697 8.40224L9.94127 8.43257L10.716 8.50544L11.0471 8.52365H11.8582L13.3681 8.63597L13.763 8.89703L14 9.21578L13.9605 9.45863L13.3529 9.76829L12.5327 9.57398L10.6187 9.11864L9.96254 8.95472H9.8714V9.00935L10.4182 9.54365L11.4208 10.4483L12.6754 11.614L12.7393 11.9023L12.5782 12.13L12.4081 12.1057L11.3053 11.277L10.88 10.9036L9.91697 10.0931H9.85316V10.1781L10.075 10.5029L11.2476 12.2636L11.3083 12.804L11.2233 12.98L10.9195 13.0863L10.5853 13.0255L9.89873 12.0632L9.19088 10.9795L8.61974 10.0081L8.54987 10.0476L8.21267 13.6752L8.05469 13.8604L7.69013 14L7.38632 13.7693L7.22531 13.3959L7.38632 12.6582L7.58075 11.6959L7.73873 10.9309L7.88153 9.98078L7.96658 9.66506L7.96052 9.64382L7.89062 9.65291L7.17368 10.6365L6.08303 12.1088L5.22026 13.0316L5.01368 13.1136L4.65519 12.9284L4.68861 12.5975L4.88911 12.303L6.08303 10.7852L6.80303 9.84416L7.26785 9.30077L7.26482 9.22187H7.23746L4.06582 11.2801L3.50076 11.3529L3.25772 11.1252L3.2881 10.7518L3.40354 10.6304L4.35747 9.97469L4.35443 9.97775Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Gemini</title><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.44 12.27C7.81333 13.1217 8 14.0317 8 15C8 14.0317 8.18083 13.1217 8.5425 12.27C8.91583 11.4183 9.4175 10.6775 10.0475 10.0475C10.6775 9.4175 11.4183 8.92167 12.27 8.56C13.1217 8.18667 14.0317 8 15 8C14.0317 8 13.1217 7.81917 12.27 7.4575C11.4411 7.1001 10.6871 6.5895 10.0475 5.9525C9.4105 5.31293 8.8999 4.55891 8.5425 3.73C8.18083 2.87833 8 1.96833 8 1C8 1.96833 7.81333 2.87833 7.44 3.73C7.07833 4.58167 6.5825 5.3225 5.9525 5.9525C5.31293 6.5895 4.55891 7.1001 3.73 7.4575C2.87833 7.81917 1.96833 8 1 8C1.96833 8 2.87833 8.18667 3.73 8.56C4.58167 8.92167 5.3225 9.4175 5.9525 10.0475C6.5825 10.6775 7.07833 11.4183 7.44 12.27Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 762 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.25669 0.999943C8.27509 0.993825 9.24655 1.42125 9.9227 2.17279C11.4427 1.85079 12.9991 2.53518 13.7733 3.86518C14.159 4.5149 14.3171 5.26409 14.2372 5.99994H13.2967C13.3789 5.42185 13.265 4.8321 12.9686 4.32514C12.2353 3.06961 10.6088 2.63919 9.33676 3.36322L6.48032 4.98822C6.46926 4.99697 6.46284 5.01135 6.46372 5.02533V6.38568L9.91294 4.42084C10.0565 4.33818 10.2336 4.33823 10.3768 4.42084L13.1502 5.99994H11.2948L9.88364 5.19623C9.87034 5.19054 9.85459 5.19128 9.84262 5.19916L8.64926 5.87983L8.8602 5.99994H7.99985C6.89539 6.00004 5.99988 6.89547 5.99985 7.99994V9.34955L3.90219 8.15522C3.75815 8.07431 3.66897 7.92228 3.66977 7.75873V4.53803C3.66977 4.50828 3.67172 4.4654 3.67172 4.44135C3.08836 4.65262 2.59832 5.0599 2.28794 5.59174C1.55635 6.84647 1.99122 8.44936 3.26059 9.17475L5.99985 10.7363V11.6162C5.87564 11.6568 5.73827 11.6456 5.6229 11.579L2.7977 9.96869C2.77156 9.95382 2.73449 9.9311 2.71372 9.91889C2.60687 10.5231 2.7194 11.1466 3.0311 11.6777C3.6435 12.7209 4.87159 13.1902 5.99985 12.9023V13.8398C4.50443 14.1233 2.98758 13.4424 2.22641 12.1347C1.71174 11.2677 1.60096 10.2237 1.9227 9.27045C0.880739 8.13295 0.703328 6.46023 1.48325 5.13373C1.98739 4.26024 2.84863 3.64401 3.84653 3.44233C4.3245 1.9837 5.70306 0.996447 7.25669 0.999943ZM7.25766 1.91498C5.78932 1.9143 4.59839 3.08914 4.59751 4.53803V7.79193C4.59926 7.80578 4.60735 7.81796 4.61997 7.82416L5.8143 8.50483L5.81626 4.57611C5.81537 4.41216 5.90431 4.2606 6.04868 4.17963L8.87387 2.56928C8.89868 2.55441 8.93612 2.53379 8.95786 2.5224C8.48035 2.13046 7.8788 1.91498 7.25766 1.91498Z" fill="black"/>
<path d="M13.5 6C14.6046 6 15.5 6.89543 15.5 8V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H8C6.89543 15.5 6 14.6046 6 13.5V8C6 6.89543 6.89543 6 8 6H13.5ZM10.8916 8.02539C10.0563 8.02539 9.33453 8.27982 8.81934 8.76562C8.30213 9.25335 8.02547 9.94371 8.02539 10.748C8.02539 11.557 8.29852 12.2492 8.81543 12.7373C9.33013 13.2232 10.0521 13.4746 10.8916 13.4746C11.9865 13.4745 12.8545 13.1022 13.3076 12.3525C13.3894 12.2176 13.4521 12.0693 13.4521 11.8857C13.4521 11.4795 13.0933 11.2773 12.7842 11.2773C12.6604 11.2774 12.5292 11.3025 12.4072 11.3779C12.2862 11.4529 12.2058 11.5586 12.1494 11.666L12.1475 11.6689C11.9677 12.0213 11.5535 12.246 10.8955 12.2461C10.4219 12.2461 10.0667 12.0932 9.83008 11.8506C9.59255 11.607 9.44141 11.2389 9.44141 10.748C9.44148 10.264 9.59319 9.89628 9.83203 9.65137C10.0702 9.40725 10.4255 9.25391 10.8916 9.25391C11.4912 9.25399 11.9415 9.50614 12.1289 9.8916V9.89062C12.1888 10.0157 12.276 10.1311 12.4023 10.2129C12.5303 10.2956 12.6724 10.3271 12.8115 10.3271C12.9661 10.3271 13.1303 10.2857 13.2627 10.1758C13.4018 10.0603 13.4746 9.89383 13.4746 9.71582C13.4746 9.61857 13.4542 9.52036 13.4199 9.42773L13.3818 9.33691C12.9749 8.49175 11.9927 8.02548 10.8916 8.02539ZM10.3203 8.97852L10.1494 9.03516C10.2095 9.01178 10.2716 8.99089 10.3359 8.97363C10.3307 8.97505 10.3256 8.97706 10.3203 8.97852ZM10.4814 8.94141C10.4969 8.9385 10.5126 8.93616 10.5283 8.93359C10.5126 8.93617 10.4969 8.9385 10.4814 8.94141ZM10.6709 8.91504C10.6819 8.91399 10.693 8.913 10.7041 8.91211C10.693 8.913 10.6819 8.91399 10.6709 8.91504Z" fill="black" fill-opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
<g clip-path="url(#clip0_205_3)">
<g>
<path d="M0.094 7.78c0.469 0 2.281 -0.405 3.219 -0.936s0.938 -0.531 2.875 -1.906c2.453 -1.741 4.188 -1.158 7.031 -1.158" stroke-width="2.8125" />
<path d="m15.969 3.797 -4.805 2.774V1.023z" />
<path d="M0 7.781c0.469 0 2.281 0.405 3.219 0.936s0.938 0.531 2.875 1.906C8.547 12.364 10.281 11.781 13.125 11.781" stroke-width="2.8125" />

Before

Width:  |  Height:  |  Size: 575 B

After

Width:  |  Height:  |  Size: 545 B

View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 4H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66667 8H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66667 12H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.00016 12C8.41993 12.5597 9.00515 12.9731 9.67294 13.1817C10.3407 13.3903 11.0572 13.3835 11.7209 13.1623C12.3846 12.941 12.9619 12.5166 13.371 11.949C13.78 11.3815 14.0002 10.6996 14.0002 10C14.0002 9.20435 13.6841 8.44129 13.1215 7.87868C12.5589 7.31607 11.7958 7 11.0002 7C10.1135 7 9.30683 7.36 8.72683 7.94L7.3335 9.33333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.3335 6.66669V9.33335H10.0002" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.33333 8H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6667 5H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 11H2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 7V11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 9H10" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.31254 12.549C7.3841 13.0987 8.61676 13.2476 9.78839 12.9688C10.96 12.6901 11.9936 12.0021 12.7028 11.0287C13.412 10.0554 13.7503 8.8607 13.6566 7.66002C13.5629 6.45934 13.0435 5.33159 12.1919 4.48C11.3403 3.62841 10.2126 3.10898 9.01188 3.01531C7.8112 2.92164 6.61655 3.2599 5.64319 3.96912C4.66984 4.67834 3.9818 5.71188 3.70306 6.88351C3.42432 8.05514 3.5732 9.2878 4.12289 10.3594L3 13.6719L6.31254 12.549Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 609 B

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 8L7.33333 9L10 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 3C7.66045 3 8.33955 3 9 3" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 4C11.3949 4.26602 11.7345 4.60558 12 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 7C13 7.66045 13 8.33955 13 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 11C11.734 11.3949 11.3944 11.7345 11 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 13C8.33954 13 7.66046 13 7 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 12C4.6051 11.734 4.26554 11.3944 4 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9C3 8.33955 3 7.66045 3 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 5C4.26602 4.6051 4.60558 4.26554 5 4" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 3C7.66045 3 8.33955 3 9 3" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 4C11.3949 4.26602 11.7345 4.60558 12 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 7C13 7.66045 13 8.33955 13 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 11C11.734 11.3949 11.3944 11.7345 11 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 13C8.33954 13 7.66046 13 7 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 12C4.6051 11.734 4.26554 11.3944 4 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9C3 8.33955 3 7.66045 3 7" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 5C4.26602 4.6051 4.60558 4.26554 5 4" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.00016 8.66665C8.36835 8.66665 8.66683 8.36817 8.66683 7.99998C8.66683 7.63179 8.36835 7.33331 8.00016 7.33331C7.63197 7.33331 7.3335 7.63179 7.3335 7.99998C7.3335 8.36817 7.63197 8.66665 8.00016 8.66665Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -269,15 +269,15 @@
}
},
{
"context": "AgentPanel && acp_thread",
"context": "AgentPanel && external_agent_thread",
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewAcpThread",
"ctrl-n": "agent::NewExternalAgentThread",
"ctrl-alt-t": "agent::NewThread"
}
},
{
"context": "MessageEditor > Editor",
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"bindings": {
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
@@ -287,6 +287,17 @@
"ctrl-shift-n": "agent::RejectAll"
}
},
{
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
"bindings": {
"ctrl-enter": "agent::Chat",
"enter": "editor::Newline",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll"
}
},
{
"context": "EditMessageEditor > Editor",
"bindings": {
@@ -419,7 +430,7 @@
"ctrl-shift-pagedown": "pane::SwapItemRight",
"ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }],
"ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }],
"alt-ctrl-t": ["pane::CloseInactiveItems", { "close_pinned": false }],
"alt-ctrl-t": ["pane::CloseOtherItems", { "close_pinned": false }],
"alt-ctrl-shift-w": "workspace::CloseInactiveTabsAndPanes",
"ctrl-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
"ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
@@ -472,9 +483,8 @@
"ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip
"ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch
"ctrl-k ctrl-i": "editor::Hover",
"ctrl-k ctrl-b": "editor::BlameHover",
"ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }],
"ctrl-u": "editor::UndoSelection",
"ctrl-shift-u": "editor::RedoSelection",
"f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"f2": "editor::Rename",
@@ -652,6 +662,8 @@
{
"context": "Editor",
"bindings": {
"ctrl-u": "editor::UndoSelection",
"ctrl-shift-u": "editor::RedoSelection",
"ctrl-shift-j": "editor::JoinLines",
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
@@ -917,7 +929,7 @@
}
},
{
"context": "GitPanel > Editor",
"context": "CommitEditor > Editor",
"bindings": {
"escape": "git_panel::FocusChanges",
"tab": "git_panel::FocusChanges",
@@ -963,9 +975,14 @@
"context": "CollabPanel && not_editing",
"bindings": {
"ctrl-backspace": "collab_panel::Remove",
"space": "menu::Confirm",
"ctrl-up": "collab_panel::MoveChannelUp",
"ctrl-down": "collab_panel::MoveChannelDown"
"space": "menu::Confirm"
}
},
{
"context": "CollabPanel",
"bindings": {
"alt-up": "collab_panel::MoveChannelUp",
"alt-down": "collab_panel::MoveChannelDown"
}
},
{
@@ -1118,7 +1135,12 @@
"ctrl-f": "search::FocusSearch",
"alt-find": "keymap_editor::ToggleKeystrokeSearch",
"alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch",
"alt-c": "keymap_editor::ToggleConflictFilter"
"alt-c": "keymap_editor::ToggleConflictFilter",
"enter": "keymap_editor::EditBinding",
"alt-enter": "keymap_editor::CreateBinding",
"ctrl-c": "keymap_editor::CopyAction",
"ctrl-shift-c": "keymap_editor::CopyContext",
"ctrl-t": "keymap_editor::ShowMatchingKeybinds"
}
},
{

View File

@@ -310,15 +310,15 @@
}
},
{
"context": "AgentPanel && acp_thread",
"context": "AgentPanel && external_agent_thread",
"use_key_equivalents": true,
"bindings": {
"cmd-n": "agent::NewAcpThread",
"cmd-n": "agent::NewExternalAgentThread",
"cmd-alt-t": "agent::NewThread"
}
},
{
"context": "MessageEditor > Editor",
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
@@ -329,6 +329,18 @@
"cmd-shift-n": "agent::RejectAll"
}
},
{
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"cmd-enter": "agent::Chat",
"enter": "editor::Newline",
"cmd-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll"
}
},
{
"context": "EditMessageEditor > Editor",
"use_key_equivalents": true,
@@ -477,7 +489,7 @@
"ctrl-shift-pageup": "pane::SwapItemLeft",
"ctrl-shift-pagedown": "pane::SwapItemRight",
"cmd-w": ["pane::CloseActiveItem", { "close_pinned": false }],
"alt-cmd-t": ["pane::CloseInactiveItems", { "close_pinned": false }],
"alt-cmd-t": ["pane::CloseOtherItems", { "close_pinned": false }],
"ctrl-alt-cmd-w": "workspace::CloseInactiveTabsAndPanes",
"cmd-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
"cmd-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
@@ -525,9 +537,8 @@
"ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch
"cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch
"cmd-k cmd-i": "editor::Hover",
"cmd-k cmd-b": "editor::BlameHover",
"cmd-/": ["editor::ToggleComments", { "advance_downwards": false }],
"cmd-u": "editor::UndoSelection",
"cmd-shift-u": "editor::RedoSelection",
"f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
"f2": "editor::Rename",
@@ -714,6 +725,8 @@
"context": "Editor",
"use_key_equivalents": true,
"bindings": {
"cmd-u": "editor::UndoSelection",
"cmd-shift-u": "editor::RedoSelection",
"ctrl-j": "editor::JoinLines",
"ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
"ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
@@ -963,7 +976,7 @@
}
},
{
"context": "GitPanel > Editor",
"context": "CommitEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"enter": "editor::Newline",
@@ -1024,9 +1037,15 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-backspace": "collab_panel::Remove",
"space": "menu::Confirm",
"cmd-up": "collab_panel::MoveChannelUp",
"cmd-down": "collab_panel::MoveChannelDown"
"space": "menu::Confirm"
}
},
{
"context": "CollabPanel",
"use_key_equivalents": true,
"bindings": {
"alt-up": "collab_panel::MoveChannelUp",
"alt-down": "collab_panel::MoveChannelDown"
}
},
{
@@ -1216,8 +1235,14 @@
"context": "KeymapEditor",
"use_key_equivalents": true,
"bindings": {
"cmd-f": "search::FocusSearch",
"cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch",
"cmd-alt-c": "keymap_editor::ToggleConflictFilter"
"cmd-alt-c": "keymap_editor::ToggleConflictFilter",
"enter": "keymap_editor::EditBinding",
"alt-enter": "keymap_editor::CreateBinding",
"cmd-c": "keymap_editor::CopyAction",
"cmd-shift-c": "keymap_editor::CopyContext",
"cmd-t": "keymap_editor::ShowMatchingKeybinds"
}
},
{

View File

@@ -13,9 +13,9 @@
}
},
{
"context": "Editor && vim_mode == insert && !menu",
"context": "Editor && vim_mode == insert",
"bindings": {
// "j k": "vim::SwitchToNormalMode"
// "j k": "vim::NormalBefore"
}
}
]

View File

@@ -114,7 +114,7 @@
"ctrl-x o": "workspace::ActivateNextPane", // other-window
"ctrl-x k": "pane::CloseActiveItem", // kill-buffer
"ctrl-x 0": "pane::CloseActiveItem", // delete-window
"ctrl-x 1": "pane::CloseInactiveItems", // delete-other-windows
"ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows
"ctrl-x 2": "pane::SplitDown", // split-window-below
"ctrl-x 3": "pane::SplitRight", // split-window-right
"ctrl-x ctrl-f": "file_finder::Toggle", // find-file

View File

@@ -66,22 +66,51 @@
"context": "Editor && mode == full",
"bindings": {
"ctrl-f12": "outline::Toggle",
"alt-7": "outline::Toggle",
"ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }],
"ctrl-shift-n": "file_finder::Toggle",
"ctrl-g": "go_to_line::Toggle",
"alt-enter": "editor::ToggleCodeActions"
}
},
{
"context": "BufferSearchBar",
"bindings": {
"shift-enter": "search::SelectPreviousMatch"
}
},
{
"context": "BufferSearchBar || ProjectSearchBar",
"bindings": {
"alt-c": "search::ToggleCaseSensitive",
"alt-e": "search::ToggleSelection",
"alt-x": "search::ToggleRegex",
"alt-w": "search::ToggleWholeWord"
}
},
{
"context": "Workspace",
"bindings": {
"ctrl-shift-f12": "workspace::CloseAllDocks",
"ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
"alt-shift-f10": "task::Spawn",
"ctrl-e": "file_finder::Toggle",
"ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
"ctrl-shift-n": "file_finder::Toggle",
"ctrl-shift-a": "command_palette::Toggle",
"shift shift": "command_palette::Toggle",
"ctrl-alt-shift-n": "project_symbols::Toggle",
"alt-0": "git_panel::ToggleFocus",
"alt-1": "workspace::ToggleLeftDock",
"ctrl-e": "tab_switcher::Toggle",
"alt-6": "diagnostics::Deploy"
"alt-5": "debug_panel::ToggleFocus",
"alt-6": "diagnostics::Deploy",
"alt-7": "outline_panel::ToggleFocus"
}
},
{
"context": "Workspace || Editor",
"bindings": {
"alt-f12": "terminal_panel::ToggleFocus",
"ctrl-shift-k": "git::Push"
}
},
{
@@ -95,10 +124,33 @@
"context": "ProjectPanel",
"bindings": {
"enter": "project_panel::Open",
"ctrl-shift-f": "project_panel::NewSearchInDirectory",
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
"delete": ["project_panel::Trash", { "skip_prompt": false }],
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
"shift-f6": "project_panel::Rename"
}
},
{
"context": "Terminal",
"bindings": {
"ctrl-shift-t": "workspace::NewTerminal",
"alt-f12": "workspace::CloseActiveDock",
"alt-left": "pane::ActivatePreviousItem",
"alt-right": "pane::ActivateNextItem",
"ctrl-up": "terminal::ScrollLineUp",
"ctrl-down": "terminal::ScrollLineDown",
"shift-pageup": "terminal::ScrollPageUp",
"shift-pagedown": "terminal::ScrollPageDown"
}
},
{ "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } },
{ "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } },
{ "context": "DebugPanel", "bindings": { "alt-5": "workspace::CloseActiveDock" } },
{ "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } },
{ "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } },
{
"context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
"bindings": { "escape": "editor::ToggleFocus" }
}
]

View File

@@ -114,7 +114,7 @@
"ctrl-x o": "workspace::ActivateNextPane", // other-window
"ctrl-x k": "pane::CloseActiveItem", // kill-buffer
"ctrl-x 0": "pane::CloseActiveItem", // delete-window
"ctrl-x 1": "pane::CloseInactiveItems", // delete-other-windows
"ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows
"ctrl-x 2": "pane::SplitDown", // split-window-below
"ctrl-x 3": "pane::SplitRight", // split-window-right
"ctrl-x ctrl-f": "file_finder::Toggle", // find-file

View File

@@ -3,6 +3,7 @@
"bindings": {
"cmd-{": "pane::ActivatePreviousItem",
"cmd-}": "pane::ActivateNextItem",
"cmd-0": "git_panel::ToggleFocus", // overrides `cmd-0` zoom reset
"ctrl-f2": "debugger::Stop",
"f6": "debugger::Pause",
"f7": "debugger::StepInto",
@@ -63,28 +64,55 @@
"context": "Editor && mode == full",
"bindings": {
"cmd-f12": "outline::Toggle",
"cmd-7": "outline::Toggle",
"cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }],
"cmd-shift-o": "file_finder::Toggle",
"cmd-l": "go_to_line::Toggle",
"alt-enter": "editor::ToggleCodeActions"
}
},
{
"context": "BufferSearchBar > Editor",
"context": "BufferSearchBar",
"bindings": {
"shift-enter": "search::SelectPreviousMatch"
}
},
{
"context": "BufferSearchBar || ProjectSearchBar",
"bindings": {
"alt-c": "search::ToggleCaseSensitive",
"alt-e": "search::ToggleSelection",
"alt-x": "search::ToggleRegex",
"alt-w": "search::ToggleWholeWord",
"ctrl-alt-c": "search::ToggleCaseSensitive",
"ctrl-alt-e": "search::ToggleSelection",
"ctrl-alt-w": "search::ToggleWholeWord",
"ctrl-alt-x": "search::ToggleRegex"
}
},
{
"context": "Workspace",
"bindings": {
"cmd-shift-f12": "workspace::CloseAllDocks",
"cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
"ctrl-alt-r": "task::Spawn",
"cmd-e": "file_finder::Toggle",
"cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
"cmd-shift-o": "file_finder::Toggle",
"cmd-shift-a": "command_palette::Toggle",
"shift shift": "command_palette::Toggle",
"cmd-alt-o": "project_symbols::Toggle", // JetBrains: Go to Symbol
"cmd-o": "project_symbols::Toggle", // JetBrains: Go to Class
"cmd-1": "workspace::ToggleLeftDock",
"cmd-6": "diagnostics::Deploy"
"cmd-1": "project_panel::ToggleFocus",
"cmd-5": "debug_panel::ToggleFocus",
"cmd-6": "diagnostics::Deploy",
"cmd-7": "outline_panel::ToggleFocus"
}
},
{
"context": "Workspace || Editor",
"bindings": {
"alt-f12": "terminal_panel::ToggleFocus",
"cmd-shift-k": "git::Push"
}
},
{
@@ -98,11 +126,31 @@
"context": "ProjectPanel",
"bindings": {
"enter": "project_panel::Open",
"cmd-shift-f": "project_panel::NewSearchInDirectory",
"cmd-backspace": ["project_panel::Trash", { "skip_prompt": false }],
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
"delete": ["project_panel::Trash", { "skip_prompt": false }],
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
"shift-f6": "project_panel::Rename"
}
},
{
"context": "Terminal",
"bindings": {
"cmd-t": "workspace::NewTerminal",
"alt-f12": "workspace::CloseActiveDock",
"cmd-up": "terminal::ScrollLineUp",
"cmd-down": "terminal::ScrollLineDown",
"shift-pageup": "terminal::ScrollPageUp",
"shift-pagedown": "terminal::ScrollPageDown"
}
},
{ "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } },
{ "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } },
{ "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } },
{ "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } },
{
"context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
"bindings": { "escape": "editor::ToggleFocus" }
}
]

View File

@@ -6,7 +6,7 @@
}
},
{
"context": "Editor",
"context": "Editor && mode == full",
"bindings": {
"cmd-l": "go_to_line::Toggle",
"ctrl-shift-d": "editor::DuplicateLineDown",
@@ -15,7 +15,12 @@
"cmd-enter": "editor::NewlineBelow",
"cmd-alt-enter": "editor::NewlineAbove",
"cmd-shift-l": "editor::SelectLine",
"cmd-shift-t": "outline::Toggle",
"cmd-shift-t": "outline::Toggle"
}
},
{
"context": "Editor",
"bindings": {
"alt-backspace": "editor::DeleteToPreviousWordStart",
"alt-shift-backspace": "editor::DeleteToNextWordEnd",
"alt-delete": "editor::DeleteToNextWordEnd",
@@ -39,10 +44,6 @@
"ctrl-_": "editor::ConvertToSnakeCase"
}
},
{
"context": "Editor && mode == full",
"bindings": {}
},
{
"context": "BufferSearchBar",
"bindings": {

View File

@@ -124,6 +124,7 @@
"g r a": "editor::ToggleCodeActions",
"g g": "vim::StartOfDocument",
"g h": "editor::Hover",
"g B": "editor::BlameHover",
"g t": "pane::ActivateNextItem",
"g shift-t": "pane::ActivatePreviousItem",
"g d": "editor::GoToDefinition",
@@ -377,6 +378,7 @@
"context": "vim_mode == helix_normal && !menu",
"bindings": {
"ctrl-[": "editor::Cancel",
";": "vim::HelixCollapseSelection",
":": "command_palette::Toggle",
"left": "vim::WrappingLeft",
"right": "vim::WrappingRight",
@@ -723,7 +725,7 @@
}
},
{
"context": "AgentPanel || GitPanel || ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || DebugPanel",
"context": "VimControl || !Editor && !Terminal",
"bindings": {
// window related commands (ctrl-w X)
"ctrl-w": null,
@@ -781,7 +783,7 @@
}
},
{
"context": "ChangesList || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome",
"context": "!Editor && !Terminal",
"bindings": {
":": "command_palette::Toggle",
"g /": "pane::DeploySearch"
@@ -857,6 +859,14 @@
"shift-n": null
}
},
{
"context": "Picker > Editor",
"bindings": {
"ctrl-h": "editor::Backspace",
"ctrl-u": "editor::DeleteToBeginningOfLine",
"ctrl-w": "editor::DeleteToPreviousWordStart"
}
},
{
"context": "GitCommit > Editor && VimControl && vim_mode == normal",
"bindings": {

View File

@@ -197,6 +197,8 @@
// "inline"
// 3. Place snippets at the bottom of the completion list:
// "bottom"
// 4. Do not show snippets in the completion list:
// "none"
"snippet_sort_order": "inline",
// How to highlight the current line in the editor.
//
@@ -689,7 +691,10 @@
// 5. Never show the scrollbar:
// "never"
"show": null
}
},
// Default depth to expand outline items in the current file.
// Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper.
"expand_outlines_with_depth": 100
},
"collaboration_panel": {
// Whether to show the collaboration panel button in the status bar.
@@ -817,7 +822,7 @@
"edit_file": true,
"fetch": true,
"list_directory": true,
"project_notifications": true,
"project_notifications": false,
"move_path": true,
"now": true,
"find_path": true,
@@ -837,7 +842,7 @@
"diagnostics": true,
"fetch": true,
"list_directory": true,
"project_notifications": true,
"project_notifications": false,
"now": true,
"find_path": true,
"read_file": true,
@@ -1074,6 +1079,10 @@
// Send anonymized usage data like what languages you're using Zed with.
"metrics": true
},
// Whether to disable all AI features in Zed.
//
// Default: false
"disable_ai": false,
// Automatically update Zed. This setting may be ignored on Linux if
// installed through a package manager.
"auto_update": true,
@@ -1710,6 +1719,7 @@
"openai": {
"api_url": "https://api.openai.com/v1"
},
"openai_compatible": {},
"open_router": {
"api_url": "https://openrouter.ai/api/v1"
},

View File

@@ -15,13 +15,15 @@
"adapter": "JavaScript",
"program": "$ZED_FILE",
"request": "launch",
"cwd": "$ZED_WORKTREE_ROOT"
"cwd": "$ZED_WORKTREE_ROOT",
"type": "pwa-node"
},
{
"label": "JavaScript debug terminal",
"adapter": "JavaScript",
"request": "launch",
"cwd": "$ZED_WORKTREE_ROOT",
"console": "integratedTerminal"
"console": "integratedTerminal",
"type": "pwa-node"
}
]

View File

@@ -8,7 +8,7 @@
// command palette (cmd-shift-p / ctrl-shift-p)
{
"ui_font_size": 16,
"buffer_font_size": 16,
"buffer_font_size": 15,
"theme": {
"mode": "system",
"light": "One Light",

View File

@@ -59,5 +59,11 @@ services:
depends_on:
- postgres
stripe-mock:
image: stripe/stripe-mock:v0.178.0
ports:
- 12111:12111
- 12112:12112
volumes:
postgres_data:

View File

@@ -1,5 +1,5 @@
[package]
name = "acp"
name = "acp_thread"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
@@ -9,15 +9,13 @@ license = "GPL-3.0-or-later"
workspace = true
[lib]
path = "src/acp.rs"
path = "src/acp_thread.rs"
doctest = false
[features]
test-support = ["gpui/test-support", "project/test-support"]
gemini = []
[dependencies]
agent_servers.workspace = true
agentic-coding-protocol.workspace = true
anyhow.workspace = true
assistant_tool.workspace = true
@@ -29,6 +27,8 @@ itertools.workspace = true
language.workspace = true
markdown.workspace = true
project.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
ui.workspace = true
@@ -41,7 +41,6 @@ env_logger.workspace = true
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
project = { workspace = true, "features" = ["test-support"] }
serde_json.workspace = true
tempfile.workspace = true
util.workspace = true
settings.workspace = true

View File

@@ -1,7 +1,12 @@
mod connection;
pub use connection::*;
pub use acp::ToolCallId;
use agent_servers::AgentServer;
use agentic_coding_protocol::{self as acp, ToolCallLocation, UserMessageChunk};
use anyhow::{Context as _, Result, anyhow};
use agentic_coding_protocol::{
self as acp, AgentRequest, ProtocolVersion, ToolCallConfirmationOutcome, ToolCallLocation,
UserMessageChunk,
};
use anyhow::{Context as _, Result};
use assistant_tool::ActionLog;
use buffer_diff::BufferDiff;
use editor::{Bias, MultiBuffer, PathKey};
@@ -97,7 +102,7 @@ pub struct AssistantMessage {
}
impl AssistantMessage {
fn to_markdown(&self, cx: &App) -> String {
pub fn to_markdown(&self, cx: &App) -> String {
format!(
"## Assistant\n\n{}\n\n",
self.chunks
@@ -448,16 +453,75 @@ impl Diff {
}
}
#[derive(Debug, Default)]
pub struct Plan {
pub entries: Vec<PlanEntry>,
}
#[derive(Debug)]
pub struct PlanStats<'a> {
pub in_progress_entry: Option<&'a PlanEntry>,
pub pending: u32,
pub completed: u32,
}
impl Plan {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn stats(&self) -> PlanStats<'_> {
let mut stats = PlanStats {
in_progress_entry: None,
pending: 0,
completed: 0,
};
for entry in &self.entries {
match &entry.status {
acp::PlanEntryStatus::Pending => {
stats.pending += 1;
}
acp::PlanEntryStatus::InProgress => {
stats.in_progress_entry = stats.in_progress_entry.or(Some(entry));
}
acp::PlanEntryStatus::Completed => {
stats.completed += 1;
}
}
}
stats
}
}
#[derive(Debug)]
pub struct PlanEntry {
pub content: Entity<Markdown>,
pub priority: acp::PlanEntryPriority,
pub status: acp::PlanEntryStatus,
}
impl PlanEntry {
pub fn from_acp(entry: acp::PlanEntry, cx: &mut App) -> Self {
Self {
content: cx.new(|cx| Markdown::new_text(entry.content.into(), cx)),
priority: entry.priority,
status: entry.status,
}
}
}
pub struct AcpThread {
entries: Vec<AgentThreadEntry>,
title: SharedString,
entries: Vec<AgentThreadEntry>,
plan: Plan,
project: Entity<Project>,
action_log: Entity<ActionLog>,
shared_buffers: HashMap<Entity<Buffer>, BufferSnapshot>,
send_task: Option<Task<()>>,
connection: Arc<acp::AgentConnection>,
connection: Arc<dyn AgentConnection>,
child_status: Option<Task<Result<()>>>,
_io_task: Task<()>,
}
pub enum AcpThreadEvent {
@@ -476,7 +540,11 @@ pub enum ThreadStatus {
#[derive(Debug, Clone)]
pub enum LoadError {
Unsupported { current_version: SharedString },
Unsupported {
error_message: SharedString,
upgrade_message: SharedString,
upgrade_command: String,
},
Exited(i32),
Other(SharedString),
}
@@ -484,13 +552,7 @@ pub enum LoadError {
impl Display for LoadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::Unsupported { current_version } => {
write!(
f,
"Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
current_version
)
}
LoadError::Unsupported { error_message, .. } => write!(f, "{}", error_message),
LoadError::Exited(status) => write!(f, "Server exited with status {}", status),
LoadError::Other(msg) => write!(f, "{}", msg),
}
@@ -500,75 +562,39 @@ impl Display for LoadError {
impl Error for LoadError {}
impl AcpThread {
pub async fn spawn(
server: impl AgentServer + 'static,
root_dir: &Path,
pub fn new(
connection: impl AgentConnection + 'static,
title: SharedString,
child_status: Option<Task<Result<()>>>,
project: Entity<Project>,
cx: &mut AsyncApp,
) -> Result<Entity<Self>> {
let command = match server.command(&project, cx).await {
Ok(command) => command,
Err(e) => return Err(anyhow!(LoadError::Other(format!("{e}").into()))),
};
cx: &mut Context<Self>,
) -> Self {
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let mut child = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.current_dir(root_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()?;
Self {
action_log,
shared_buffers: Default::default(),
entries: Default::default(),
plan: Default::default(),
title,
project,
send_task: None,
connection: Arc::new(connection),
child_status,
}
}
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
cx.new(|cx| {
let foreground_executor = cx.foreground_executor().clone();
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
stdin,
stdout,
move |fut| foreground_executor.spawn(fut).detach(),
);
let io_task = cx.background_spawn(async move {
io_fut.await.log_err();
});
let child_status = cx.background_spawn(async move {
match child.status().await {
Err(e) => Err(anyhow!(e)),
Ok(result) if result.success() => Ok(()),
Ok(result) => {
if let Some(version) = server.version(&command).await.log_err()
&& !version.supported
{
Err(anyhow!(LoadError::Unsupported {
current_version: version.current_version
}))
} else {
Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
}
}
}
});
let action_log = cx.new(|_| ActionLog::new(project.clone()));
Self {
action_log,
shared_buffers: Default::default(),
entries: Default::default(),
title: "ACP Thread".into(),
project,
send_task: None,
connection: Arc::new(connection),
child_status: Some(child_status),
_io_task: io_task,
}
})
/// Send a request to the agent and wait for a response.
pub fn request<R: AgentRequest + 'static>(
&self,
params: R,
) -> impl use<R> + Future<Output = Result<R::Response>> {
let params = params.into_any();
let result = self.connection.request_any(params);
async move {
let result = result.await?;
Ok(R::response_from_any(result)?)
}
}
pub fn action_log(&self) -> &Entity<ActionLog> {
@@ -579,45 +605,6 @@ impl AcpThread {
&self.project
}
#[cfg(test)]
pub fn fake(
stdin: async_pipe::PipeWriter,
stdout: async_pipe::PipeReader,
project: Entity<Project>,
cx: &mut Context<Self>,
) -> Self {
let foreground_executor = cx.foreground_executor().clone();
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
stdin,
stdout,
move |fut| {
foreground_executor.spawn(fut).detach();
},
);
let io_task = cx.background_spawn({
async move {
io_fut.await.log_err();
}
});
let action_log = cx.new(|_| ActionLog::new(project.clone()));
Self {
action_log,
shared_buffers: Default::default(),
entries: Default::default(),
title: "ACP Thread".into(),
project,
send_task: None,
connection: Arc::new(connection),
child_status: None,
_io_task: io_task,
}
}
pub fn title(&self) -> SharedString {
self.title.clone()
}
@@ -711,7 +698,7 @@ impl AcpThread {
}
}
pub fn request_tool_call(
pub fn request_new_tool_call(
&mut self,
tool_call: acp::RequestToolCallConfirmationParams,
cx: &mut Context<Self>,
@@ -731,6 +718,32 @@ impl AcpThread {
ToolCallRequest { id, outcome: rx }
}
pub fn request_tool_call_confirmation(
&mut self,
tool_call_id: ToolCallId,
confirmation: acp::ToolCallConfirmation,
cx: &mut Context<Self>,
) -> Result<ToolCallRequest> {
let project = self.project.read(cx).languages().clone();
let Some((idx, call)) = self.tool_call_mut(tool_call_id) else {
anyhow::bail!("Tool call not found");
};
let (tx, rx) = oneshot::channel();
call.status = ToolCallStatus::WaitingForConfirmation {
confirmation: ToolCallConfirmation::from_acp(confirmation, project, cx),
respond_tx: tx,
};
cx.emit(AcpThreadEvent::EntryUpdated(idx));
Ok(ToolCallRequest {
id: tool_call_id,
outcome: rx,
})
}
pub fn push_tool_call(
&mut self,
request: acp::PushToolCallParams,
@@ -818,8 +831,13 @@ impl AcpThread {
let language_registry = self.project.read(cx).languages().clone();
let (ix, call) = self.tool_call_mut(id).context("Entry not found")?;
call.content = new_content
.map(|new_content| ToolCallContent::from_acp(new_content, language_registry, cx));
if let Some(new_content) = new_content {
call.content = Some(ToolCallContent::from_acp(
new_content,
language_registry,
cx,
));
}
match &mut call.status {
ToolCallStatus::Allowed { status } => {
@@ -862,6 +880,29 @@ impl AcpThread {
}
}
pub fn plan(&self) -> &Plan {
&self.plan
}
pub fn update_plan(&mut self, request: acp::UpdatePlanParams, cx: &mut Context<Self>) {
self.plan = Plan {
entries: request
.entries
.into_iter()
.map(|entry| PlanEntry::from_acp(entry, cx))
.collect(),
};
cx.notify();
}
pub fn clear_completed_plan_entries(&mut self, cx: &mut Context<Self>) {
self.plan
.entries
.retain(|entry| !matches!(entry.status, acp::PlanEntryStatus::Completed));
cx.notify();
}
pub fn set_project_location(&self, location: ToolCallLocation, cx: &mut Context<Self>) {
self.project.update(cx, |project, cx| {
let Some(path) = project.project_path_for_absolute_path(&location.path, cx) else {
@@ -912,19 +953,17 @@ impl AcpThread {
false
}
pub fn initialize(
&self,
) -> impl use<> + Future<Output = Result<acp::InitializeResponse, acp::Error>> {
let connection = self.connection.clone();
async move { connection.initialize().await }
pub fn initialize(&self) -> impl use<> + Future<Output = Result<acp::InitializeResponse>> {
self.request(acp::InitializeParams {
protocol_version: ProtocolVersion::latest(),
})
}
pub fn authenticate(&self) -> impl use<> + Future<Output = Result<(), acp::Error>> {
let connection = self.connection.clone();
async move { connection.request(acp::AuthenticateParams).await }
pub fn authenticate(&self) -> impl use<> + Future<Output = Result<()>> {
self.request(acp::AuthenticateParams)
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn send_raw(
&mut self,
message: &str,
@@ -945,7 +984,6 @@ impl AcpThread {
message: acp::SendUserMessageParams,
cx: &mut Context<Self>,
) -> BoxFuture<'static, Result<(), acp::Error>> {
let agent = self.connection.clone();
self.push_entry(
AgentThreadEntry::UserMessage(UserMessage::from_acp(
&message,
@@ -959,11 +997,16 @@ impl AcpThread {
let cancel = self.cancel(cx);
self.send_task = Some(cx.spawn(async move |this, cx| {
cancel.await.log_err();
async {
cancel.await.log_err();
let result = agent.request(message).await;
tx.send(result).log_err();
this.update(cx, |this, _cx| this.send_task.take()).log_err();
let result = this.update(cx, |this, _| this.request(message))?.await;
tx.send(result).log_err();
this.update(cx, |this, _cx| this.send_task.take())?;
anyhow::Ok(())
}
.await
.log_err();
}));
async move {
@@ -976,12 +1019,10 @@ impl AcpThread {
}
pub fn cancel(&mut self, cx: &mut Context<Self>) -> Task<Result<(), acp::Error>> {
let agent = self.connection.clone();
if self.send_task.take().is_some() {
let request = self.request(acp::CancelSendMessageParams);
cx.spawn(async move |this, cx| {
agent.request(acp::CancelSendMessageParams).await?;
request.await?;
this.update(cx, |this, _cx| {
for entry in this.entries.iter_mut() {
if let AgentThreadEntry::ToolCall(call) = entry {
@@ -1019,6 +1060,7 @@ impl AcpThread {
pub fn read_text_file(
&self,
request: acp::ReadTextFileParams,
reuse_shared_snapshot: bool,
cx: &mut Context<Self>,
) -> Task<Result<String>> {
let project = self.project.clone();
@@ -1032,28 +1074,60 @@ impl AcpThread {
});
let buffer = load??.await?;
action_log.update(cx, |action_log, cx| {
action_log.buffer_read(buffer.clone(), cx);
})?;
project.update(cx, |project, cx| {
let position = buffer
.read(cx)
.snapshot()
.anchor_before(Point::new(request.line.unwrap_or_default(), 0));
project.set_agent_location(
Some(AgentLocation {
buffer: buffer.downgrade(),
position,
}),
cx,
);
})?;
let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
let snapshot = if reuse_shared_snapshot {
this.read_with(cx, |this, _| {
this.shared_buffers.get(&buffer.clone()).cloned()
})
.log_err()
.flatten()
} else {
None
};
let snapshot = if let Some(snapshot) = snapshot {
snapshot
} else {
action_log.update(cx, |action_log, cx| {
action_log.buffer_read(buffer.clone(), cx);
})?;
project.update(cx, |project, cx| {
let position = buffer
.read(cx)
.snapshot()
.anchor_before(Point::new(request.line.unwrap_or_default(), 0));
project.set_agent_location(
Some(AgentLocation {
buffer: buffer.downgrade(),
position,
}),
cx,
);
})?;
buffer.update(cx, |buffer, _| buffer.snapshot())?
};
this.update(cx, |this, _| {
let text = snapshot.text();
this.shared_buffers.insert(buffer.clone(), snapshot);
text
})
if request.line.is_none() && request.limit.is_none() {
return Ok(text);
}
let limit = request.limit.unwrap_or(u32::MAX) as usize;
let Some(line) = request.line else {
return Ok(text.lines().take(limit).collect::<String>());
};
let count = text.lines().count();
if count < line as usize {
anyhow::bail!("There are only {} lines", count);
}
Ok(text
.lines()
.skip(line as usize + 1)
.take(limit)
.collect::<String>())
})?
})
}
@@ -1134,16 +1208,60 @@ impl AcpThread {
}
}
struct AcpClientDelegate {
#[derive(Clone)]
pub struct AcpClientDelegate {
thread: WeakEntity<AcpThread>,
cx: AsyncApp,
// sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
}
impl AcpClientDelegate {
fn new(thread: WeakEntity<AcpThread>, cx: AsyncApp) -> Self {
pub fn new(thread: WeakEntity<AcpThread>, cx: AsyncApp) -> Self {
Self { thread, cx }
}
pub async fn clear_completed_plan_entries(&self) -> Result<()> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread
.update(cx, |thread, cx| thread.clear_completed_plan_entries(cx))
})?
.context("Failed to update thread")?;
Ok(())
}
pub async fn request_existing_tool_call_confirmation(
&self,
tool_call_id: ToolCallId,
confirmation: acp::ToolCallConfirmation,
) -> Result<ToolCallConfirmationOutcome> {
let cx = &mut self.cx.clone();
let ToolCallRequest { outcome, .. } = cx
.update(|cx| {
self.thread.update(cx, |thread, cx| {
thread.request_tool_call_confirmation(tool_call_id, confirmation, cx)
})
})?
.context("Failed to update thread")??;
Ok(outcome.await?)
}
pub async fn read_text_file_reusing_snapshot(
&self,
request: acp::ReadTextFileParams,
) -> Result<acp::ReadTextFileResponse, acp::Error> {
let content = self
.cx
.update(|cx| {
self.thread
.update(cx, |thread, cx| thread.read_text_file(request, true, cx))
})?
.context("Failed to update thread")?
.await?;
Ok(acp::ReadTextFileResponse { content })
}
}
impl acp::Client for AcpClientDelegate {
@@ -1172,7 +1290,7 @@ impl acp::Client for AcpClientDelegate {
let ToolCallRequest { id, outcome } = cx
.update(|cx| {
self.thread
.update(cx, |thread, cx| thread.request_tool_call(request, cx))
.update(cx, |thread, cx| thread.request_new_tool_call(request, cx))
})?
.context("Failed to update thread")?;
@@ -1210,6 +1328,18 @@ impl acp::Client for AcpClientDelegate {
Ok(())
}
async fn update_plan(&self, request: acp::UpdatePlanParams) -> Result<(), acp::Error> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread
.update(cx, |thread, cx| thread.update_plan(request, cx))
})?
.context("Failed to update thread")?;
Ok(())
}
async fn read_text_file(
&self,
request: acp::ReadTextFileParams,
@@ -1218,7 +1348,7 @@ impl acp::Client for AcpClientDelegate {
.cx
.update(|cx| {
self.thread
.update(cx, |thread, cx| thread.read_text_file(request, cx))
.update(cx, |thread, cx| thread.read_text_file(request, false, cx))
})?
.context("Failed to update thread")?
.await?;
@@ -1260,7 +1390,7 @@ pub struct ToolCallRequest {
#[cfg(test)]
mod tests {
use super::*;
use agent_servers::{AgentServerCommand, AgentServerVersion};
use anyhow::anyhow;
use async_pipe::{PipeReader, PipeWriter};
use futures::{channel::mpsc, future::LocalBoxFuture, select};
use gpui::{AsyncApp, TestAppContext};
@@ -1269,7 +1399,7 @@ mod tests {
use serde_json::json;
use settings::SettingsStore;
use smol::{future::BoxedLocal, stream::StreamExt as _};
use std::{cell::RefCell, env, path::Path, rc::Rc, time::Duration};
use std::{cell::RefCell, rc::Rc, time::Duration};
use util::path;
fn init_test(cx: &mut TestAppContext) {
@@ -1515,265 +1645,6 @@ mod tests {
});
}
#[gpui::test]
#[cfg_attr(not(feature = "gemini"), ignore)]
async fn test_gemini_basic(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
.await
.unwrap();
thread.read_with(cx, |thread, _| {
assert_eq!(thread.entries.len(), 2);
assert!(matches!(
thread.entries[0],
AgentThreadEntry::UserMessage(_)
));
assert!(matches!(
thread.entries[1],
AgentThreadEntry::AssistantMessage(_)
));
});
}
#[gpui::test]
#[cfg_attr(not(feature = "gemini"), ignore)]
async fn test_gemini_path_mentions(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let tempdir = tempfile::tempdir().unwrap();
std::fs::write(
tempdir.path().join("foo.rs"),
indoc! {"
fn main() {
println!(\"Hello, world!\");
}
"},
)
.expect("failed to write file");
let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
let thread = gemini_acp_thread(project.clone(), tempdir.path(), cx).await;
thread
.update(cx, |thread, cx| {
thread.send(
acp::SendUserMessageParams {
chunks: vec![
acp::UserMessageChunk::Text {
text: "Read the file ".into(),
},
acp::UserMessageChunk::Path {
path: Path::new("foo.rs").into(),
},
acp::UserMessageChunk::Text {
text: " and tell me what the content of the println! is".into(),
},
],
},
cx,
)
})
.await
.unwrap();
thread.read_with(cx, |thread, cx| {
assert_eq!(thread.entries.len(), 3);
assert!(matches!(
thread.entries[0],
AgentThreadEntry::UserMessage(_)
));
assert!(matches!(thread.entries[1], AgentThreadEntry::ToolCall(_)));
let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries[2] else {
panic!("Expected AssistantMessage")
};
assert!(
assistant_message.to_markdown(cx).contains("Hello, world!"),
"unexpected assistant message: {:?}",
assistant_message.to_markdown(cx)
);
});
}
#[gpui::test]
#[cfg_attr(not(feature = "gemini"), ignore)]
async fn test_gemini_tool_call(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/private/tmp"),
json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
)
.await;
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| {
thread.send_raw(
"Read the '/private/tmp/foo' file and tell me what you see.",
cx,
)
})
.await
.unwrap();
thread.read_with(cx, |thread, _cx| {
assert!(matches!(
&thread.entries()[2],
AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::Allowed { .. },
..
})
));
assert!(matches!(
thread.entries[3],
AgentThreadEntry::AssistantMessage(_)
));
});
}
#[gpui::test]
#[cfg_attr(not(feature = "gemini"), ignore)]
async fn test_gemini_tool_call_with_confirmation(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
let full_turn = thread.update(cx, |thread, cx| {
thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
});
run_until_first_tool_call(&thread, cx).await;
let tool_call_id = thread.read_with(cx, |thread, _cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
status:
ToolCallStatus::WaitingForConfirmation {
confirmation: ToolCallConfirmation::Execute { root_command, .. },
..
},
..
}) = &thread.entries()[2]
else {
panic!();
};
assert_eq!(root_command, "echo");
*id
});
thread.update(cx, |thread, cx| {
thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
assert!(matches!(
&thread.entries()[2],
AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::Allowed { .. },
..
})
));
});
full_turn.await.unwrap();
thread.read_with(cx, |thread, cx| {
let AgentThreadEntry::ToolCall(ToolCall {
content: Some(ToolCallContent::Markdown { markdown }),
status: ToolCallStatus::Allowed { .. },
..
}) = &thread.entries()[2]
else {
panic!();
};
markdown.read_with(cx, |md, _cx| {
assert!(
md.source().contains("Hello, world!"),
r#"Expected '{}' to contain "Hello, world!""#,
md.source()
);
});
});
}
#[gpui::test]
#[cfg_attr(not(feature = "gemini"), ignore)]
async fn test_gemini_cancel(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
let full_turn = thread.update(cx, |thread, cx| {
thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
});
let first_tool_call_ix = run_until_first_tool_call(&thread, cx).await;
thread.read_with(cx, |thread, _cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
status:
ToolCallStatus::WaitingForConfirmation {
confirmation: ToolCallConfirmation::Execute { root_command, .. },
..
},
..
}) = &thread.entries()[first_tool_call_ix]
else {
panic!("{:?}", thread.entries()[1]);
};
assert_eq!(root_command, "echo");
*id
});
thread
.update(cx, |thread, cx| thread.cancel(cx))
.await
.unwrap();
full_turn.await.unwrap();
thread.read_with(cx, |thread, _| {
let AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::Canceled,
..
}) = &thread.entries()[first_tool_call_ix]
else {
panic!();
};
});
thread
.update(cx, |thread, cx| {
thread.send_raw(r#"Stop running and say goodbye to me."#, cx)
})
.await
.unwrap();
thread.read_with(cx, |thread, _| {
assert!(matches!(
&thread.entries().last().unwrap(),
AgentThreadEntry::AssistantMessage(..),
))
});
}
async fn run_until_first_tool_call(
thread: &Entity<AcpThread>,
cx: &mut TestAppContext,
@@ -1801,66 +1672,39 @@ mod tests {
}
}
pub async fn gemini_acp_thread(
project: Entity<Project>,
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
struct DevGemini;
impl agent_servers::AgentServer for DevGemini {
async fn command(
&self,
_project: &Entity<Project>,
_cx: &mut AsyncApp,
) -> Result<agent_servers::AgentServerCommand> {
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../gemini-cli/packages/cli")
.to_string_lossy()
.to_string();
Ok(AgentServerCommand {
path: "node".into(),
args: vec![cli_path, "--experimental-acp".into()],
env: None,
})
}
async fn version(
&self,
_command: &agent_servers::AgentServerCommand,
) -> Result<AgentServerVersion> {
Ok(AgentServerVersion {
current_version: "0.1.0".into(),
supported: true,
})
}
}
let thread = AcpThread::spawn(DevGemini, current_dir.as_ref(), project, &mut cx.to_async())
.await
.unwrap();
thread
.update(cx, |thread, _| thread.initialize())
.await
.unwrap();
thread
}
pub fn fake_acp_thread(
project: Entity<Project>,
cx: &mut TestAppContext,
) -> (Entity<AcpThread>, Entity<FakeAcpServer>) {
let (stdin_tx, stdin_rx) = async_pipe::pipe();
let (stdout_tx, stdout_rx) = async_pipe::pipe();
let thread = cx.update(|cx| cx.new(|cx| AcpThread::fake(stdin_tx, stdout_rx, project, cx)));
let thread = cx.new(|cx| {
let foreground_executor = cx.foreground_executor().clone();
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
stdin_tx,
stdout_rx,
move |fut| {
foreground_executor.spawn(fut).detach();
},
);
let io_task = cx.background_spawn({
async move {
io_fut.await.log_err();
Ok(())
}
});
AcpThread::new(connection, "Test".into(), Some(io_task), project, cx)
});
let agent = cx.update(|cx| cx.new(|cx| FakeAcpServer::new(stdin_rx, stdout_tx, cx)));
(thread, agent)
}
pub struct FakeAcpServer {
connection: acp::ClientConnection,
_io_task: Task<()>,
on_user_message: Option<
Rc<

View File

@@ -0,0 +1,20 @@
use agentic_coding_protocol as acp;
use anyhow::Result;
use futures::future::{FutureExt as _, LocalBoxFuture};
pub trait AgentConnection {
fn request_any(
&self,
params: acp::AnyAgentRequest,
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>>;
}
impl AgentConnection for acp::AgentConnection {
fn request_any(
&self,
params: acp::AnyAgentRequest,
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
let task = self.request_any(params);
async move { Ok(task.await?) }.boxed_local()
}
}

View File

@@ -231,7 +231,6 @@ impl ActivityIndicator {
status,
} => {
let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
let project = project.clone();
let status = status.clone();
let server_name = server_name.clone();
cx.spawn_in(window, async move |workspace, cx| {
@@ -247,8 +246,7 @@ impl ActivityIndicator {
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(cx.new(|cx| {
let mut editor =
Editor::for_buffer(buffer, Some(project.clone()), window, cx);
let mut editor = Editor::for_buffer(buffer, None, window, cx);
editor.set_read_only(true);
editor
})),

View File

@@ -38,7 +38,7 @@ impl Tool for ContextServerTool {
}
fn icon(&self) -> IconName {
IconName::Cog
IconName::ToolHammer
}
fn source(&self) -> ToolSource {

View File

@@ -47,11 +47,11 @@ use std::{
time::{Duration, Instant},
};
use thiserror::Error;
use util::{ResultExt as _, debug_panic, post_inc};
use util::{ResultExt as _, post_inc};
use uuid::Uuid;
use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
const MAX_RETRY_ATTEMPTS: u8 = 3;
const MAX_RETRY_ATTEMPTS: u8 = 4;
const BASE_RETRY_DELAY: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
@@ -396,6 +396,7 @@ pub struct Thread {
remaining_turns: u32,
configured_model: Option<ConfiguredModel>,
profile: AgentProfile,
last_error_context: Option<(Arc<dyn LanguageModel>, CompletionIntent)>,
}
#[derive(Clone, Debug)]
@@ -489,10 +490,11 @@ impl Thread {
retry_state: None,
message_feedback: HashMap::default(),
last_auto_capture_at: None,
last_error_context: None,
last_received_chunk_at: None,
request_callback: None,
remaining_turns: u32::MAX,
configured_model,
configured_model: configured_model.clone(),
profile: AgentProfile::new(profile_id, tools),
}
}
@@ -613,6 +615,7 @@ impl Thread {
feedback: None,
message_feedback: HashMap::default(),
last_auto_capture_at: None,
last_error_context: None,
last_received_chunk_at: None,
request_callback: None,
remaining_turns: u32::MAX,
@@ -1264,9 +1267,58 @@ impl Thread {
self.flush_notifications(model.clone(), intent, cx);
let request = self.to_completion_request(model.clone(), intent, cx);
let _checkpoint = self.finalize_pending_checkpoint(cx);
self.stream_completion(
self.to_completion_request(model.clone(), intent, cx),
model,
intent,
window,
cx,
);
}
self.stream_completion(request, model, intent, window, cx);
pub fn retry_last_completion(
&mut self,
window: Option<AnyWindowHandle>,
cx: &mut Context<Self>,
) {
// Clear any existing error state
self.retry_state = None;
// Use the last error context if available, otherwise fall back to configured model
let (model, intent) = if let Some((model, intent)) = self.last_error_context.take() {
(model, intent)
} else if let Some(configured_model) = self.configured_model.as_ref() {
let model = configured_model.model.clone();
let intent = if self.has_pending_tool_uses() {
CompletionIntent::ToolResults
} else {
CompletionIntent::UserPrompt
};
(model, intent)
} else if let Some(configured_model) = self.get_or_init_configured_model(cx) {
let model = configured_model.model.clone();
let intent = if self.has_pending_tool_uses() {
CompletionIntent::ToolResults
} else {
CompletionIntent::UserPrompt
};
(model, intent)
} else {
return;
};
self.send_to_model(model, intent, window, cx);
}
pub fn enable_burn_mode_and_retry(
&mut self,
window: Option<AnyWindowHandle>,
cx: &mut Context<Self>,
) {
self.completion_mode = CompletionMode::Burn;
cx.emit(ThreadEvent::ProfileChanged);
self.retry_last_completion(window, cx);
}
pub fn used_tools_since_last_user_message(&self) -> bool {
@@ -1530,20 +1582,18 @@ impl Thread {
model: Arc<dyn LanguageModel>,
cx: &mut App,
) -> Option<PendingToolUse> {
let action_log = self.action_log.read(cx);
// Represent notification as a simulated `project_notifications` tool call
let tool_name = Arc::from("project_notifications");
let tool = self.tools.read(cx).tool(&tool_name, cx)?;
if !action_log.has_unnotified_user_edits() {
if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) {
return None;
}
// Represent notification as a simulated `project_notifications` tool call
let tool_name = Arc::from("project_notifications");
let Some(tool) = self.tools.read(cx).tool(&tool_name, cx) else {
debug_panic!("`project_notifications` tool not found");
return None;
};
if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) {
if self
.action_log
.update(cx, |log, cx| log.unnotified_user_edits(cx).is_none())
{
return None;
}
@@ -2130,8 +2180,8 @@ impl Thread {
// General strategy here:
// - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all.
// - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), try multiple times with exponential backoff.
// - If it's an issue that *might* be fixed by retrying (e.g. internal server error), just retry once.
// - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff.
// - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times.
match error {
HttpResponseError {
status_code: StatusCode::TOO_MANY_REQUESTS,
@@ -2146,16 +2196,48 @@ impl Thread {
max_attempts: MAX_RETRY_ATTEMPTS,
})
}
UpstreamProviderError {
status,
retry_after,
..
} => match *status {
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => {
Some(RetryStrategy::Fixed {
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
max_attempts: MAX_RETRY_ATTEMPTS,
})
}
StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed {
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
// Internal Server Error could be anything, retry up to 3 times.
max_attempts: 3,
}),
status => {
// There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"),
// but we frequently get them in practice. See https://http.dev/529
if status.as_u16() == 529 {
Some(RetryStrategy::Fixed {
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
max_attempts: MAX_RETRY_ATTEMPTS,
})
} else {
Some(RetryStrategy::Fixed {
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
max_attempts: 2,
})
}
}
},
ApiInternalServerError { .. } => Some(RetryStrategy::Fixed {
delay: BASE_RETRY_DELAY,
max_attempts: 1,
max_attempts: 3,
}),
ApiReadResponseError { .. }
| HttpSend { .. }
| DeserializeResponse { .. }
| BadRequestFormat { .. } => Some(RetryStrategy::Fixed {
delay: BASE_RETRY_DELAY,
max_attempts: 1,
max_attempts: 3,
}),
// Retrying these errors definitely shouldn't help.
HttpResponseError {
@@ -2163,24 +2245,31 @@ impl Thread {
StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED,
..
}
| SerializeRequest { .. }
| AuthenticationError { .. }
| PermissionError { .. } => None,
// These errors might be transient, so retry them
SerializeRequest { .. }
| BuildRequestBody { .. }
| PromptTooLarge { .. }
| AuthenticationError { .. }
| PermissionError { .. }
| ApiEndpointNotFound { .. }
| NoApiKey { .. } => None,
| NoApiKey { .. } => Some(RetryStrategy::Fixed {
delay: BASE_RETRY_DELAY,
max_attempts: 2,
}),
// Retry all other 4xx and 5xx errors once.
HttpResponseError { status_code, .. }
if status_code.is_client_error() || status_code.is_server_error() =>
{
Some(RetryStrategy::Fixed {
delay: BASE_RETRY_DELAY,
max_attempts: 1,
max_attempts: 3,
})
}
// Conservatively assume that any other errors are non-retryable
HttpResponseError { .. } | Other(..) => None,
HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed {
delay: BASE_RETRY_DELAY,
max_attempts: 2,
}),
}
}
@@ -2193,6 +2282,23 @@ impl Thread {
window: Option<AnyWindowHandle>,
cx: &mut Context<Self>,
) -> bool {
// Store context for the Retry button
self.last_error_context = Some((model.clone(), intent));
// Only auto-retry if Burn Mode is enabled
if self.completion_mode != CompletionMode::Burn {
// Show error with retry options
cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError {
message: format!(
"{}\n\nTo automatically retry when similar errors happen, enable Burn Mode.",
error
)
.into(),
can_enable_burn_mode: true,
}));
return false;
}
let Some(strategy) = strategy.or_else(|| Self::get_retry_strategy(error)) else {
return false;
};
@@ -2273,6 +2379,13 @@ impl Thread {
// Stop generating since we're giving up on retrying.
self.pending_completions.clear();
// Show error alongside a Retry button, but no
// Enable Burn Mode button (since it's already enabled)
cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError {
message: format!("Failed after retrying: {}", error).into(),
can_enable_burn_mode: false,
}));
false
}
}
@@ -3183,6 +3296,11 @@ pub enum ThreadError {
header: SharedString,
message: SharedString,
},
#[error("Retryable error: {message}")]
RetryableError {
message: SharedString,
can_enable_burn_mode: bool,
},
}
#[derive(Debug, Clone)]
@@ -3583,6 +3701,7 @@ fn main() {{
}
#[gpui::test]
#[ignore] // turn this test on when project_notifications tool is re-enabled
async fn test_stale_buffer_notification(cx: &mut TestAppContext) {
init_test_settings(cx);
@@ -4137,6 +4256,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// Create model that returns overloaded error
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
@@ -4210,6 +4334,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// Create model that returns internal server error
let model = Arc::new(ErrorInjector::new(TestError::InternalServerError));
@@ -4231,7 +4360,7 @@ fn main() {{
let retry_state = thread.retry_state.as_ref().unwrap();
assert_eq!(retry_state.attempt, 1, "Should be first retry attempt");
assert_eq!(
retry_state.max_attempts, 1,
retry_state.max_attempts, 3,
"Should have correct max attempts"
);
});
@@ -4247,8 +4376,9 @@ fn main() {{
if let MessageSegment::Text(text) = seg {
text.contains("internal")
&& text.contains("Fake")
&& text.contains("Retrying in")
&& !text.contains("attempt")
&& text.contains("Retrying")
&& text.contains("attempt 1 of 3")
&& text.contains("seconds")
} else {
false
}
@@ -4286,6 +4416,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// Create model that returns internal server error
let model = Arc::new(ErrorInjector::new(TestError::InternalServerError));
@@ -4338,8 +4473,8 @@ fn main() {{
let retry_state = thread.retry_state.as_ref().unwrap();
assert_eq!(retry_state.attempt, 1, "Should be first retry attempt");
assert_eq!(
retry_state.max_attempts, 1,
"Internal server errors should only retry once"
retry_state.max_attempts, 3,
"Internal server errors should retry up to 3 times"
);
});
@@ -4347,7 +4482,15 @@ fn main() {{
cx.executor().advance_clock(BASE_RETRY_DELAY);
cx.run_until_parked();
// Should have scheduled second retry - count retry messages
// Advance clock for second retry
cx.executor().advance_clock(BASE_RETRY_DELAY);
cx.run_until_parked();
// Advance clock for third retry
cx.executor().advance_clock(BASE_RETRY_DELAY);
cx.run_until_parked();
// Should have completed all retries - count retry messages
let retry_count = thread.update(cx, |thread, _| {
thread
.messages
@@ -4365,24 +4508,24 @@ fn main() {{
.count()
});
assert_eq!(
retry_count, 1,
"Should have only one retry for internal server errors"
retry_count, 3,
"Should have 3 retries for internal server errors"
);
// For internal server errors, we only retry once and then give up
// Check that retry_state is cleared after the single retry
// For internal server errors, we retry 3 times and then give up
// Check that retry_state is cleared after all retries
thread.read_with(cx, |thread, _| {
assert!(
thread.retry_state.is_none(),
"Retry state should be cleared after single retry"
"Retry state should be cleared after all retries"
);
});
// Verify total attempts (1 initial + 1 retry)
// Verify total attempts (1 initial + 3 retries)
assert_eq!(
*completion_count.lock(),
2,
"Should have attempted once plus 1 retry"
4,
"Should have attempted once plus 3 retries"
);
}
@@ -4393,6 +4536,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// Create model that returns overloaded error
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
@@ -4479,6 +4627,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// We'll use a wrapper to switch behavior after first failure
struct RetryTestModel {
inner: Arc<FakeLanguageModel>,
@@ -4647,6 +4800,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// Create a model that fails once then succeeds
struct FailOnceModel {
inner: Arc<FakeLanguageModel>,
@@ -4808,6 +4966,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// Create a model that returns rate limit error with retry_after
struct RateLimitModel {
inner: Arc<FakeLanguageModel>,
@@ -5081,6 +5244,79 @@ fn main() {{
);
}
#[gpui::test]
async fn test_no_retry_without_burn_mode(cx: &mut TestAppContext) {
init_test_settings(cx);
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Ensure we're in Normal mode (not Burn mode)
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Normal);
});
// Track error events
let error_events = Arc::new(Mutex::new(Vec::new()));
let error_events_clone = error_events.clone();
let _subscription = thread.update(cx, |_, cx| {
cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| {
if let ThreadEvent::ShowError(error) = event {
error_events_clone.lock().push(error.clone());
}
})
});
// Create model that returns overloaded error
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
// Insert a user message
thread.update(cx, |thread, cx| {
thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx);
});
// Start completion
thread.update(cx, |thread, cx| {
thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx);
});
cx.run_until_parked();
// Verify no retry state was created
thread.read_with(cx, |thread, _| {
assert!(
thread.retry_state.is_none(),
"Should not have retry state in Normal mode"
);
});
// Check that a retryable error was reported
let errors = error_events.lock();
assert!(!errors.is_empty(), "Should have received an error event");
if let ThreadError::RetryableError {
message: _,
can_enable_burn_mode,
} = &errors[0]
{
assert!(
*can_enable_burn_mode,
"Error should indicate burn mode can be enabled"
);
} else {
panic!("Expected RetryableError, got {:?}", errors[0]);
}
// Verify the thread is no longer generating
thread.read_with(cx, |thread, _| {
assert!(
!thread.is_generating(),
"Should not be generating after error without retry"
);
});
}
#[gpui::test]
async fn test_retry_cancelled_on_stop(cx: &mut TestAppContext) {
init_test_settings(cx);
@@ -5088,6 +5324,11 @@ fn main() {{
let project = create_test_project(cx, json!({})).await;
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
// Enable Burn Mode to allow retries
thread.update(cx, |thread, _| {
thread.set_completion_mode(CompletionMode::Burn);
});
// Create model that returns overloaded error
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
@@ -5249,7 +5490,7 @@ fn main() {{
let thread = thread_store.update(cx, |store, cx| store.create_thread(cx));
let context_store = cx.new(|_cx| ContextStore::new(project.downgrade(), None));
let provider = Arc::new(FakeLanguageModelProvider);
let provider = Arc::new(FakeLanguageModelProvider::default());
let model = provider.test_model();
let model: Arc<dyn LanguageModel> = Arc::new(model);

View File

@@ -5,6 +5,10 @@ edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[features]
test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support"]
e2e = []
[lints]
workspace = true
@@ -13,15 +17,38 @@ path = "src/agent_servers.rs"
doctest = false
[dependencies]
acp_thread.workspace = true
agentic-coding-protocol.workspace = true
anyhow.workspace = true
collections.workspace = true
context_server.workspace = true
futures.workspace = true
gpui.workspace = true
itertools.workspace = true
log.workspace = true
paths.workspace = true
project.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
strum.workspace = true
tempfile.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
watch.workspace = true
which.workspace = true
workspace-hack.workspace = true
[target.'cfg(unix)'.dependencies]
libc.workspace = true
nix.workspace = true
[dev-dependencies]
env_logger.workspace = true
language.workspace = true
indoc.workspace = true
acp_thread = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }

View File

@@ -1,30 +1,80 @@
mod claude;
mod gemini;
mod settings;
mod stdio_agent_server;
#[cfg(test)]
mod e2e_tests;
pub use claude::*;
pub use gemini::*;
pub use settings::*;
pub use stdio_agent_server::*;
use acp_thread::AcpThread;
use anyhow::Result;
use collections::HashMap;
use gpui::{App, AsyncApp, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{Context as _, Result};
use collections::HashMap;
use gpui::{App, AsyncApp, Entity, SharedString};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources, SettingsStore};
use util::{ResultExt, paths};
use util::ResultExt as _;
pub fn init(cx: &mut App) {
AllAgentServersSettings::register(cx);
settings::init(cx);
}
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AllAgentServersSettings {
gemini: Option<AgentServerSettings>,
pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName;
fn name(&self) -> &'static str;
fn empty_state_headline(&self) -> &'static str;
fn empty_state_message(&self) -> &'static str;
fn supports_always_allow(&self) -> bool;
fn new_thread(
&self,
root_dir: &Path,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>>;
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AgentServerSettings {
#[serde(flatten)]
command: AgentServerCommand,
impl std::fmt::Debug for AgentServerCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let filtered_env = self.env.as_ref().map(|env| {
env.iter()
.map(|(k, v)| {
(
k,
if util::redact::should_redact(k) {
"[REDACTED]"
} else {
v
},
)
})
.collect::<Vec<_>>()
});
f.debug_struct("AgentServerCommand")
.field("path", &self.path)
.field("args", &self.args)
.field("env", &filtered_env)
.finish()
}
}
pub enum AgentServerVersion {
Supported,
Unsupported {
error_message: SharedString,
upgrade_message: SharedString,
upgrade_command: String,
},
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
@@ -36,105 +86,34 @@ pub struct AgentServerCommand {
pub env: Option<HashMap<String, String>>,
}
pub struct Gemini;
pub struct AgentServerVersion {
pub current_version: SharedString,
pub supported: bool,
}
pub trait AgentServer: Send {
fn command(
&self,
impl AgentServerCommand {
pub(crate) async fn resolve(
path_bin_name: &'static str,
extra_args: &[&'static str],
settings: Option<AgentServerSettings>,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> impl Future<Output = Result<AgentServerCommand>>;
fn version(
&self,
command: &AgentServerCommand,
) -> impl Future<Output = Result<AgentServerVersion>> + Send;
}
const GEMINI_ACP_ARG: &str = "--experimental-acp";
impl AgentServer for Gemini {
async fn command(
&self,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<AgentServerCommand> {
let custom_command = cx.read_global(|settings: &SettingsStore, _| {
let settings = settings.get::<AllAgentServersSettings>(None);
settings
.gemini
.as_ref()
.map(|gemini_settings| AgentServerCommand {
path: gemini_settings.command.path.clone(),
args: gemini_settings
.command
.args
.iter()
.cloned()
.chain(std::iter::once(GEMINI_ACP_ARG.into()))
.collect(),
env: gemini_settings.command.env.clone(),
})
})?;
if let Some(custom_command) = custom_command {
return Ok(custom_command);
}
if let Some(path) = find_bin_in_path("gemini", project, cx).await {
return Ok(AgentServerCommand {
path,
args: vec![GEMINI_ACP_ARG.into()],
env: None,
) -> Option<Self> {
if let Some(agent_settings) = settings {
return Some(Self {
path: agent_settings.command.path,
args: agent_settings
.command
.args
.into_iter()
.chain(extra_args.iter().map(|arg| arg.to_string()))
.collect(),
env: agent_settings.command.env,
});
} else {
find_bin_in_path(path_bin_name, project, cx)
.await
.map(|path| Self {
path,
args: extra_args.iter().map(|arg| arg.to_string()).collect(),
env: None,
})
}
let (fs, node_runtime) = project.update(cx, |project, _| {
(project.fs().clone(), project.node_runtime().cloned())
})?;
let node_runtime = node_runtime.context("gemini not found on path")?;
let directory = ::paths::agent_servers_dir().join("gemini");
fs.create_dir(&directory).await?;
node_runtime
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
.await?;
let path = directory.join("node_modules/.bin/gemini");
Ok(AgentServerCommand {
path,
args: vec![GEMINI_ACP_ARG.into()],
env: None,
})
}
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
let version_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--version")
.kill_on_drop(true)
.output();
let help_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--help")
.kill_on_drop(true)
.output();
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
let current_version = String::from_utf8(version_output?.stdout)?.into();
let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG);
Ok(AgentServerVersion {
current_version,
supported,
})
}
}
@@ -184,48 +163,3 @@ async fn find_bin_in_path(
})
.await
}
impl std::fmt::Debug for AgentServerCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let filtered_env = self.env.as_ref().map(|env| {
env.iter()
.map(|(k, v)| {
(
k,
if util::redact::should_redact(k) {
"[REDACTED]"
} else {
v
},
)
})
.collect::<Vec<_>>()
});
f.debug_struct("AgentServerCommand")
.field("path", &self.path)
.field("args", &self.args)
.field("env", &filtered_env)
.finish()
}
}
impl settings::Settings for AllAgentServersSettings {
const KEY: Option<&'static str> = Some("agent_servers");
type FileContent = Self;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let mut settings = AllAgentServersSettings::default();
for value in sources.defaults_and_customizations() {
if value.gemini.is_some() {
settings.gemini = value.gemini.clone();
}
}
Ok(settings)
}
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
}

View File

@@ -0,0 +1,805 @@
mod mcp_server;
mod tools;
use collections::HashMap;
use project::Project;
use settings::SettingsStore;
use smol::process::Child;
use std::cell::RefCell;
use std::fmt::Display;
use std::path::Path;
use std::pin::pin;
use std::rc::Rc;
use uuid::Uuid;
use agentic_coding_protocol::{
self as acp, AnyAgentRequest, AnyAgentResult, Client, ProtocolVersion,
StreamAssistantMessageChunkParams, ToolCallContent, UpdateToolCallParams,
};
use anyhow::{Result, anyhow};
use futures::channel::oneshot;
use futures::future::LocalBoxFuture;
use futures::{AsyncBufReadExt, AsyncWriteExt, SinkExt};
use futures::{
AsyncRead, AsyncWrite, FutureExt, StreamExt,
channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
io::BufReader,
select_biased,
};
use gpui::{App, AppContext, Entity, Task};
use serde::{Deserialize, Serialize};
use util::ResultExt;
use crate::claude::mcp_server::ClaudeMcpServer;
use crate::claude::tools::ClaudeTool;
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
use acp_thread::{AcpClientDelegate, AcpThread, AgentConnection};
#[derive(Clone)]
pub struct ClaudeCode;
impl AgentServer for ClaudeCode {
fn name(&self) -> &'static str {
"Claude Code"
}
fn empty_state_headline(&self) -> &'static str {
self.name()
}
fn empty_state_message(&self) -> &'static str {
""
}
fn logo(&self) -> ui::IconName {
ui::IconName::AiClaude
}
fn supports_always_allow(&self) -> bool {
false
}
fn new_thread(
&self,
root_dir: &Path,
project: &Entity<Project>,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>> {
let project = project.clone();
let root_dir = root_dir.to_path_buf();
let title = self.name().into();
cx.spawn(async move |cx| {
let (mut delegate_tx, delegate_rx) = watch::channel(None);
let tool_id_map = Rc::new(RefCell::new(HashMap::default()));
let mcp_server = ClaudeMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?;
let mut mcp_servers = HashMap::default();
mcp_servers.insert(
mcp_server::SERVER_NAME.to_string(),
mcp_server.server_config()?,
);
let mcp_config = McpConfig { mcp_servers };
let mcp_config_file = tempfile::NamedTempFile::new()?;
let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
mcp_config_file
.write_all(serde_json::to_string(&mcp_config)?.as_bytes())
.await?;
mcp_config_file.flush().await?;
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
})?;
let Some(command) =
AgentServerCommand::resolve("claude", &[], settings, &project, cx).await
else {
anyhow::bail!("Failed to find claude binary");
};
let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
let (cancel_tx, mut cancel_rx) = mpsc::unbounded::<oneshot::Sender<Result<()>>>();
let session_id = Uuid::new_v4();
log::trace!("Starting session with id: {}", session_id);
cx.background_spawn(async move {
let mut outgoing_rx = Some(outgoing_rx);
let mut mode = ClaudeSessionMode::Start;
loop {
let mut child =
spawn_claude(&command, mode, session_id, &mcp_config_path, &root_dir)
.await?;
mode = ClaudeSessionMode::Resume;
let pid = child.id();
log::trace!("Spawned (pid: {})", pid);
let mut io_fut = pin!(
ClaudeAgentConnection::handle_io(
outgoing_rx.take().unwrap(),
incoming_message_tx.clone(),
child.stdin.take().unwrap(),
child.stdout.take().unwrap(),
)
.fuse()
);
select_biased! {
done_tx = cancel_rx.next() => {
if let Some(done_tx) = done_tx {
log::trace!("Interrupted (pid: {})", pid);
let result = send_interrupt(pid as i32);
outgoing_rx.replace(io_fut.await?);
done_tx.send(result).log_err();
continue;
}
}
result = io_fut => {
result?;
}
}
log::trace!("Stopped (pid: {})", pid);
break;
}
drop(mcp_config_path);
anyhow::Ok(())
})
.detach();
cx.new(|cx| {
let end_turn_tx = Rc::new(RefCell::new(None));
let delegate = AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async());
delegate_tx.send(Some(delegate.clone())).log_err();
let handler_task = cx.foreground_executor().spawn({
let end_turn_tx = end_turn_tx.clone();
let tool_id_map = tool_id_map.clone();
let delegate = delegate.clone();
async move {
while let Some(message) = incoming_message_rx.next().await {
ClaudeAgentConnection::handle_message(
delegate.clone(),
message,
end_turn_tx.clone(),
tool_id_map.clone(),
)
.await
}
}
});
let mut connection = ClaudeAgentConnection {
delegate,
outgoing_tx,
end_turn_tx,
cancel_tx,
session_id,
_handler_task: handler_task,
_mcp_server: None,
};
connection._mcp_server = Some(mcp_server);
acp_thread::AcpThread::new(connection, title, None, project.clone(), cx)
})
})
}
}
#[cfg(unix)]
fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> {
let pid = nix::unistd::Pid::from_raw(pid);
nix::sys::signal::kill(pid, nix::sys::signal::SIGINT)
.map_err(|e| anyhow!("Failed to interrupt process: {}", e))
}
#[cfg(windows)]
fn send_interrupt(_pid: i32) -> anyhow::Result<()> {
panic!("Cancel not implemented on Windows")
}
impl AgentConnection for ClaudeAgentConnection {
/// Send a request to the agent and wait for a response.
fn request_any(
&self,
params: AnyAgentRequest,
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
let delegate = self.delegate.clone();
let end_turn_tx = self.end_turn_tx.clone();
let outgoing_tx = self.outgoing_tx.clone();
let mut cancel_tx = self.cancel_tx.clone();
let session_id = self.session_id;
async move {
match params {
// todo: consider sending an empty request so we get the init response?
AnyAgentRequest::InitializeParams(_) => Ok(AnyAgentResult::InitializeResponse(
acp::InitializeResponse {
is_authenticated: true,
protocol_version: ProtocolVersion::latest(),
},
)),
AnyAgentRequest::AuthenticateParams(_) => {
Err(anyhow!("Authentication not supported"))
}
AnyAgentRequest::SendUserMessageParams(message) => {
delegate.clear_completed_plan_entries().await?;
let (tx, rx) = oneshot::channel();
end_turn_tx.borrow_mut().replace(tx);
let mut content = String::new();
for chunk in message.chunks {
match chunk {
agentic_coding_protocol::UserMessageChunk::Text { text } => {
content.push_str(&text)
}
agentic_coding_protocol::UserMessageChunk::Path { path } => {
content.push_str(&format!("@{path:?}"))
}
}
}
outgoing_tx.unbounded_send(SdkMessage::User {
message: Message {
role: Role::User,
content: Content::UntaggedText(content),
id: None,
model: None,
stop_reason: None,
stop_sequence: None,
usage: None,
},
session_id: Some(session_id),
})?;
rx.await??;
Ok(AnyAgentResult::SendUserMessageResponse(
acp::SendUserMessageResponse,
))
}
AnyAgentRequest::CancelSendMessageParams(_) => {
let (done_tx, done_rx) = oneshot::channel();
cancel_tx.send(done_tx).await?;
done_rx.await??;
Ok(AnyAgentResult::CancelSendMessageResponse(
acp::CancelSendMessageResponse,
))
}
}
}
.boxed_local()
}
}
#[derive(Clone, Copy)]
enum ClaudeSessionMode {
Start,
Resume,
}
async fn spawn_claude(
command: &AgentServerCommand,
mode: ClaudeSessionMode,
session_id: Uuid,
mcp_config_path: &Path,
root_dir: &Path,
) -> Result<Child> {
let child = util::command::new_smol_command(&command.path)
.args([
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--print",
"--verbose",
"--mcp-config",
mcp_config_path.to_string_lossy().as_ref(),
"--permission-prompt-tool",
&format!(
"mcp__{}__{}",
mcp_server::SERVER_NAME,
mcp_server::PERMISSION_TOOL
),
"--allowedTools",
"mcp__zed__Read,mcp__zed__Edit",
"--disallowedTools",
"Read,Edit",
])
.args(match mode {
ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
})
.args(command.args.iter().map(|arg| arg.as_str()))
.current_dir(root_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()?;
Ok(child)
}
struct ClaudeAgentConnection {
delegate: AcpClientDelegate,
session_id: Uuid,
outgoing_tx: UnboundedSender<SdkMessage>,
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
cancel_tx: UnboundedSender<oneshot::Sender<Result<()>>>,
_mcp_server: Option<ClaudeMcpServer>,
_handler_task: Task<()>,
}
impl ClaudeAgentConnection {
async fn handle_message(
delegate: AcpClientDelegate,
message: SdkMessage,
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
) {
match message {
SdkMessage::Assistant { message, .. } | SdkMessage::User { message, .. } => {
for chunk in message.content.chunks() {
match chunk {
ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
delegate
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
chunk: acp::AssistantMessageChunk::Text { text },
})
.await
.log_err();
}
ContentChunk::ToolUse { id, name, input } => {
let claude_tool = ClaudeTool::infer(&name, input);
if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
delegate
.update_plan(acp::UpdatePlanParams {
entries: params.todos.into_iter().map(Into::into).collect(),
})
.await
.log_err();
} else if let Some(resp) = delegate
.push_tool_call(claude_tool.as_acp())
.await
.log_err()
{
tool_id_map.borrow_mut().insert(id, resp.id);
}
}
ContentChunk::ToolResult {
content,
tool_use_id,
} => {
let id = tool_id_map.borrow_mut().remove(&tool_use_id);
if let Some(id) = id {
let content = content.to_string();
delegate
.update_tool_call(UpdateToolCallParams {
tool_call_id: id,
status: acp::ToolCallStatus::Finished,
// Don't unset existing content
content: (!content.is_empty()).then_some(
ToolCallContent::Markdown {
// For now we only include text content
markdown: content,
},
),
})
.await
.log_err();
}
}
ContentChunk::Image
| ContentChunk::Document
| ContentChunk::Thinking
| ContentChunk::RedactedThinking
| ContentChunk::WebSearchToolResult => {
delegate
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
chunk: acp::AssistantMessageChunk::Text {
text: format!("Unsupported content: {:?}", chunk),
},
})
.await
.log_err();
}
}
}
}
SdkMessage::Result {
is_error, subtype, ..
} => {
if let Some(end_turn_tx) = end_turn_tx.borrow_mut().take() {
if is_error {
end_turn_tx.send(Err(anyhow!("Error: {subtype}"))).ok();
} else {
end_turn_tx.send(Ok(())).ok();
}
}
}
SdkMessage::System { .. } => {}
}
}
async fn handle_io(
mut outgoing_rx: UnboundedReceiver<SdkMessage>,
incoming_tx: UnboundedSender<SdkMessage>,
mut outgoing_bytes: impl Unpin + AsyncWrite,
incoming_bytes: impl Unpin + AsyncRead,
) -> Result<UnboundedReceiver<SdkMessage>> {
let mut output_reader = BufReader::new(incoming_bytes);
let mut outgoing_line = Vec::new();
let mut incoming_line = String::new();
loop {
select_biased! {
message = outgoing_rx.next() => {
if let Some(message) = message {
outgoing_line.clear();
serde_json::to_writer(&mut outgoing_line, &message)?;
log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
outgoing_line.push(b'\n');
outgoing_bytes.write_all(&outgoing_line).await.ok();
} else {
break;
}
}
bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
if bytes_read? == 0 {
break
}
log::trace!("recv: {}", &incoming_line);
match serde_json::from_str::<SdkMessage>(&incoming_line) {
Ok(message) => {
incoming_tx.unbounded_send(message).log_err();
}
Err(error) => {
log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
}
}
incoming_line.clear();
}
}
}
Ok(outgoing_rx)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Message {
role: Role,
content: Content,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stop_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stop_sequence: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
usage: Option<Usage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum Content {
UntaggedText(String),
Chunks(Vec<ContentChunk>),
}
impl Content {
pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
match self {
Self::Chunks(chunks) => chunks.into_iter(),
Self::UntaggedText(text) => vec![ContentChunk::Text { text: text.clone() }].into_iter(),
}
}
}
impl Display for Content {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Content::UntaggedText(txt) => write!(f, "{}", txt),
Content::Chunks(chunks) => {
for chunk in chunks {
write!(f, "{}", chunk)?;
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentChunk {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
content: Content,
tool_use_id: String,
},
// TODO
Image,
Document,
Thinking,
RedactedThinking,
WebSearchToolResult,
#[serde(untagged)]
UntaggedText(String),
}
impl Display for ContentChunk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContentChunk::Text { text } => write!(f, "{}", text),
ContentChunk::UntaggedText(text) => write!(f, "{}", text),
ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
ContentChunk::Image
| ContentChunk::Document
| ContentChunk::Thinking
| ContentChunk::RedactedThinking
| ContentChunk::ToolUse { .. }
| ContentChunk::WebSearchToolResult => {
write!(f, "\n{:?}\n", &self)
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Usage {
input_tokens: u32,
cache_creation_input_tokens: u32,
cache_read_input_tokens: u32,
output_tokens: u32,
service_tier: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Role {
System,
Assistant,
User,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MessageParam {
role: Role,
content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum SdkMessage {
// An assistant message
Assistant {
message: Message, // from Anthropic SDK
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<Uuid>,
},
// A user message
User {
message: Message, // from Anthropic SDK
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<Uuid>,
},
// Emitted as the last message in a conversation
Result {
subtype: ResultErrorType,
duration_ms: f64,
duration_api_ms: f64,
is_error: bool,
num_turns: i32,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<String>,
session_id: String,
total_cost_usd: f64,
},
// Emitted as the first message at the start of a conversation
System {
cwd: String,
session_id: String,
tools: Vec<String>,
model: String,
mcp_servers: Vec<McpServer>,
#[serde(rename = "apiKeySource")]
api_key_source: String,
#[serde(rename = "permissionMode")]
permission_mode: PermissionMode,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ResultErrorType {
Success,
ErrorMaxTurns,
ErrorDuringExecution,
}
impl Display for ResultErrorType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResultErrorType::Success => write!(f, "success"),
ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct McpServer {
name: String,
status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum PermissionMode {
Default,
AcceptEdits,
BypassPermissions,
Plan,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct McpConfig {
mcp_servers: HashMap<String, McpServerConfig>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct McpServerConfig {
command: String,
args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<HashMap<String, String>>,
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use serde_json::json;
crate::common_e2e_tests!(ClaudeCode);
pub fn local_command() -> AgentServerCommand {
AgentServerCommand {
path: "claude".into(),
args: vec![],
env: None,
}
}
#[test]
fn test_deserialize_content_untagged_text() {
let json = json!("Hello, world!");
let content: Content = serde_json::from_value(json).unwrap();
match content {
Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
_ => panic!("Expected UntaggedText variant"),
}
}
#[test]
fn test_deserialize_content_chunks() {
let json = json!([
{
"type": "text",
"text": "Hello"
},
{
"type": "tool_use",
"id": "tool_123",
"name": "calculator",
"input": {"operation": "add", "a": 1, "b": 2}
}
]);
let content: Content = serde_json::from_value(json).unwrap();
match content {
Content::Chunks(chunks) => {
assert_eq!(chunks.len(), 2);
match &chunks[0] {
ContentChunk::Text { text } => assert_eq!(text, "Hello"),
_ => panic!("Expected Text chunk"),
}
match &chunks[1] {
ContentChunk::ToolUse { id, name, input } => {
assert_eq!(id, "tool_123");
assert_eq!(name, "calculator");
assert_eq!(input["operation"], "add");
assert_eq!(input["a"], 1);
assert_eq!(input["b"], 2);
}
_ => panic!("Expected ToolUse chunk"),
}
}
_ => panic!("Expected Chunks variant"),
}
}
#[test]
fn test_deserialize_tool_result_untagged_text() {
let json = json!({
"type": "tool_result",
"content": "Result content",
"tool_use_id": "tool_456"
});
let chunk: ContentChunk = serde_json::from_value(json).unwrap();
match chunk {
ContentChunk::ToolResult {
content,
tool_use_id,
} => {
match content {
Content::UntaggedText(text) => assert_eq!(text, "Result content"),
_ => panic!("Expected UntaggedText content"),
}
assert_eq!(tool_use_id, "tool_456");
}
_ => panic!("Expected ToolResult variant"),
}
}
#[test]
fn test_deserialize_tool_result_chunks() {
let json = json!({
"type": "tool_result",
"content": [
{
"type": "text",
"text": "Processing complete"
},
{
"type": "text",
"text": "Result: 42"
}
],
"tool_use_id": "tool_789"
});
let chunk: ContentChunk = serde_json::from_value(json).unwrap();
match chunk {
ContentChunk::ToolResult {
content,
tool_use_id,
} => {
match content {
Content::Chunks(chunks) => {
assert_eq!(chunks.len(), 2);
match &chunks[0] {
ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
_ => panic!("Expected Text chunk"),
}
match &chunks[1] {
ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
_ => panic!("Expected Text chunk"),
}
}
_ => panic!("Expected Chunks content"),
}
assert_eq!(tool_use_id, "tool_789");
}
_ => panic!("Expected ToolResult variant"),
}
}
}

View File

@@ -0,0 +1,296 @@
use std::{cell::RefCell, rc::Rc};
use acp_thread::AcpClientDelegate;
use agentic_coding_protocol::{self as acp, Client, ReadTextFileParams, WriteTextFileParams};
use anyhow::{Context, Result};
use collections::HashMap;
use context_server::{
listener::McpServer,
types::{
CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
ToolResponseContent, ToolsCapabilities, requests,
},
};
use gpui::{App, AsyncApp, Task};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use util::debug_panic;
use crate::claude::{
McpServerConfig,
tools::{ClaudeTool, EditToolParams, ReadToolParams},
};
pub struct ClaudeMcpServer {
server: McpServer,
}
pub const SERVER_NAME: &str = "zed";
pub const READ_TOOL: &str = "Read";
pub const EDIT_TOOL: &str = "Edit";
pub const PERMISSION_TOOL: &str = "Confirmation";
#[derive(Deserialize, JsonSchema, Debug)]
struct PermissionToolParams {
tool_name: String,
input: serde_json::Value,
tool_use_id: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PermissionToolResponse {
behavior: PermissionToolBehavior,
updated_input: serde_json::Value,
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
enum PermissionToolBehavior {
Allow,
Deny,
}
impl ClaudeMcpServer {
pub async fn new(
delegate: watch::Receiver<Option<AcpClientDelegate>>,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
cx: &AsyncApp,
) -> Result<Self> {
let mut mcp_server = McpServer::new(cx).await?;
mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
mcp_server.handle_request::<requests::ListTools>(Self::handle_list_tools);
mcp_server.handle_request::<requests::CallTool>(move |request, cx| {
Self::handle_call_tool(request, delegate.clone(), tool_id_map.clone(), cx)
});
Ok(Self { server: mcp_server })
}
pub fn server_config(&self) -> Result<McpServerConfig> {
let zed_path = std::env::current_exe()
.context("finding current executable path for use in mcp_server")?
.to_string_lossy()
.to_string();
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,
})
})
}
fn handle_list_tools(_: (), cx: &App) -> Task<Result<ListToolsResponse>> {
cx.foreground_executor().spawn(async move {
Ok(ListToolsResponse {
tools: vec![
Tool {
name: PERMISSION_TOOL.into(),
input_schema: schemars::schema_for!(PermissionToolParams).into(),
description: None,
annotations: None,
},
Tool {
name: READ_TOOL.into(),
input_schema: schemars::schema_for!(ReadToolParams).into(),
description: Some("Read the contents of a file. In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.".to_string()),
annotations: Some(ToolAnnotations {
title: Some("Read file".to_string()),
read_only_hint: Some(true),
destructive_hint: Some(false),
open_world_hint: Some(false),
// if time passes the contents might change, but it's not going to do anything different
// true or false seem too strong, let's try a none.
idempotent_hint: None,
}),
},
Tool {
name: EDIT_TOOL.into(),
input_schema: schemars::schema_for!(EditToolParams).into(),
description: Some("Edits a file. In sessions with mcp__zed__Edit always use it instead of Edit as it will show the diff to the user better.".to_string()),
annotations: Some(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),
}),
},
],
next_cursor: None,
meta: None,
})
})
}
fn handle_call_tool(
request: CallToolParams,
mut delegate_watch: watch::Receiver<Option<AcpClientDelegate>>,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
cx: &App,
) -> Task<Result<CallToolResponse>> {
cx.spawn(async move |cx| {
let Some(delegate) = delegate_watch.recv().await? else {
debug_panic!("Sent None delegate");
anyhow::bail!("Server not available");
};
if request.name.as_str() == PERMISSION_TOOL {
let input =
serde_json::from_value(request.arguments.context("Arguments required")?)?;
let result =
Self::handle_permissions_tool_call(input, delegate, tool_id_map, cx).await?;
Ok(CallToolResponse {
content: vec![ToolResponseContent::Text {
text: serde_json::to_string(&result)?,
}],
is_error: None,
meta: None,
})
} else if request.name.as_str() == READ_TOOL {
let input =
serde_json::from_value(request.arguments.context("Arguments required")?)?;
let content = Self::handle_read_tool_call(input, delegate, cx).await?;
Ok(CallToolResponse {
content,
is_error: None,
meta: None,
})
} else if request.name.as_str() == EDIT_TOOL {
let input =
serde_json::from_value(request.arguments.context("Arguments required")?)?;
Self::handle_edit_tool_call(input, delegate, cx).await?;
Ok(CallToolResponse {
content: vec![],
is_error: None,
meta: None,
})
} else {
anyhow::bail!("Unsupported tool");
}
})
}
fn handle_read_tool_call(
params: ReadToolParams,
delegate: AcpClientDelegate,
cx: &AsyncApp,
) -> Task<Result<Vec<ToolResponseContent>>> {
cx.foreground_executor().spawn(async move {
let response = delegate
.read_text_file(ReadTextFileParams {
path: params.abs_path,
line: params.offset,
limit: params.limit,
})
.await?;
Ok(vec![ToolResponseContent::Text {
text: response.content,
}])
})
}
fn handle_edit_tool_call(
params: EditToolParams,
delegate: AcpClientDelegate,
cx: &AsyncApp,
) -> Task<Result<()>> {
cx.foreground_executor().spawn(async move {
let response = delegate
.read_text_file_reusing_snapshot(ReadTextFileParams {
path: params.abs_path.clone(),
line: None,
limit: None,
})
.await?;
let new_content = response.content.replace(&params.old_text, &params.new_text);
if new_content == response.content {
return Err(anyhow::anyhow!("The old_text was not found in the content"));
}
delegate
.write_text_file(WriteTextFileParams {
path: params.abs_path,
content: new_content,
})
.await?;
Ok(())
})
}
fn handle_permissions_tool_call(
params: PermissionToolParams,
delegate: AcpClientDelegate,
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
cx: &AsyncApp,
) -> Task<Result<PermissionToolResponse>> {
cx.foreground_executor().spawn(async move {
let claude_tool = ClaudeTool::infer(&params.tool_name, params.input.clone());
let tool_call_id = match params.tool_use_id {
Some(tool_use_id) => tool_id_map
.borrow()
.get(&tool_use_id)
.cloned()
.context("Tool call ID not found")?,
None => delegate.push_tool_call(claude_tool.as_acp()).await?.id,
};
let outcome = delegate
.request_existing_tool_call_confirmation(
tool_call_id,
claude_tool.confirmation(None),
)
.await?;
match outcome {
acp::ToolCallConfirmationOutcome::Allow
| acp::ToolCallConfirmationOutcome::AlwaysAllow
| acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer
| acp::ToolCallConfirmationOutcome::AlwaysAllowTool => Ok(PermissionToolResponse {
behavior: PermissionToolBehavior::Allow,
updated_input: params.input,
}),
acp::ToolCallConfirmationOutcome::Reject
| acp::ToolCallConfirmationOutcome::Cancel => Ok(PermissionToolResponse {
behavior: PermissionToolBehavior::Deny,
updated_input: params.input,
}),
}
})
}
}

View File

@@ -0,0 +1,783 @@
use std::path::PathBuf;
use agentic_coding_protocol::{self as acp, PushToolCallParams, ToolCallLocation};
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()),
"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.to_string(),
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.file_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) -> Option<acp::ToolCallContent> {
match &self {
Self::Other { input, .. } => Some(acp::ToolCallContent::Markdown {
markdown: format!(
"```json\n{}```",
serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
),
}),
Self::Task(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.prompt.clone(),
}),
Self::NotebookRead(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.notebook_path.display().to_string(),
}),
Self::NotebookEdit(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.new_source.clone(),
}),
Self::Terminal(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: format!(
"`{}`\n\n{}",
params.command,
params.description.as_deref().unwrap_or_default()
),
}),
Self::ReadFile(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.abs_path.display().to_string(),
}),
Self::Ls(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.path.display().to_string(),
}),
Self::Glob(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.to_string(),
}),
Self::Grep(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: format!("`{params}`"),
}),
Self::WebFetch(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.prompt.clone(),
}),
Self::WebSearch(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.to_string(),
}),
Self::TodoWrite(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params
.todos
.iter()
.map(|todo| {
format!(
"- {} {}: {}",
match todo.status {
TodoStatus::Completed => "",
TodoStatus::InProgress => "🚧",
TodoStatus::Pending => "",
},
todo.priority,
todo.content
)
})
.join("\n"),
}),
Self::ExitPlanMode(Some(params)) => Some(acp::ToolCallContent::Markdown {
markdown: params.plan.clone(),
}),
Self::Edit(Some(params)) => Some(acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.abs_path.clone(),
old_text: Some(params.old_text.clone()),
new_text: params.new_text.clone(),
},
}),
Self::Write(Some(params)) => Some(acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.file_path.clone(),
old_text: None,
new_text: params.content.clone(),
},
}),
Self::MultiEdit(Some(params)) => {
// todo: show multiple edits in a multibuffer?
params.edits.first().map(|edit| acp::ToolCallContent::Diff {
diff: acp::Diff {
path: params.file_path.clone(),
old_text: Some(edit.old_string.clone()),
new_text: edit.new_string.clone(),
},
})
}
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) => None,
}
}
pub fn icon(&self) -> acp::Icon {
match self {
Self::Task(_) => acp::Icon::Hammer,
Self::NotebookRead(_) => acp::Icon::FileSearch,
Self::NotebookEdit(_) => acp::Icon::Pencil,
Self::Edit(_) => acp::Icon::Pencil,
Self::MultiEdit(_) => acp::Icon::Pencil,
Self::Write(_) => acp::Icon::Pencil,
Self::ReadFile(_) => acp::Icon::FileSearch,
Self::Ls(_) => acp::Icon::Folder,
Self::Glob(_) => acp::Icon::FileSearch,
Self::Grep(_) => acp::Icon::Regex,
Self::Terminal(_) => acp::Icon::Terminal,
Self::WebSearch(_) => acp::Icon::Globe,
Self::WebFetch(_) => acp::Icon::Globe,
Self::TodoWrite(_) => acp::Icon::LightBulb,
Self::ExitPlanMode(_) => acp::Icon::Hammer,
Self::Other { .. } => acp::Icon::Hammer,
}
}
pub fn confirmation(&self, description: Option<String>) -> acp::ToolCallConfirmation {
match &self {
Self::Edit(_) | Self::Write(_) | Self::NotebookEdit(_) | Self::MultiEdit(_) => {
acp::ToolCallConfirmation::Edit { description }
}
Self::WebFetch(params) => acp::ToolCallConfirmation::Fetch {
urls: params
.as_ref()
.map(|p| vec![p.url.clone()])
.unwrap_or_default(),
description,
},
Self::Terminal(Some(BashToolParams {
description,
command,
..
})) => acp::ToolCallConfirmation::Execute {
command: command.clone(),
root_command: command.clone(),
description: description.clone(),
},
Self::ExitPlanMode(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {}", params.plan)
} else {
params.plan.clone()
},
},
Self::Task(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {}", params.description)
} else {
params.description.clone()
},
},
Self::Ls(Some(LsToolParams { path, .. }))
| Self::ReadFile(Some(ReadToolParams { abs_path: path, .. })) => {
let path = path.display();
acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {path}")
} else {
path.to_string()
},
}
}
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
let path = notebook_path.display();
acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {path}")
} else {
path.to_string()
},
}
}
Self::Glob(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params.to_string()
},
},
Self::Grep(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params.to_string()
},
},
Self::WebSearch(Some(params)) => acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params.to_string()
},
},
Self::TodoWrite(Some(params)) => {
let params = params.todos.iter().map(|todo| &todo.content).join(", ");
acp::ToolCallConfirmation::Other {
description: if let Some(description) = description {
format!("{description} {params}")
} else {
params
},
}
}
Self::Terminal(None)
| Self::Task(None)
| Self::NotebookRead(None)
| Self::ExitPlanMode(None)
| Self::Ls(None)
| Self::Glob(None)
| Self::Grep(None)
| Self::ReadFile(None)
| Self::WebSearch(None)
| Self::TodoWrite(None)
| Self::Other { .. } => acp::ToolCallConfirmation::Other {
description: description.unwrap_or("".to_string()),
},
}
}
pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
match &self {
Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![ToolCallLocation {
path: abs_path.clone(),
line: None,
}],
Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
vec![ToolCallLocation {
path: file_path.clone(),
line: None,
}]
}
Self::Write(Some(WriteToolParams { file_path, .. })) => vec![ToolCallLocation {
path: file_path.clone(),
line: None,
}],
Self::ReadFile(Some(ReadToolParams {
abs_path, offset, ..
})) => vec![ToolCallLocation {
path: abs_path.clone(),
line: *offset,
}],
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
vec![ToolCallLocation {
path: notebook_path.clone(),
line: None,
}]
}
Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
vec![ToolCallLocation {
path: notebook_path.clone(),
line: None,
}]
}
Self::Glob(Some(GlobToolParams {
path: Some(path), ..
})) => vec![ToolCallLocation {
path: path.clone(),
line: None,
}],
Self::Ls(Some(LsToolParams { path, .. })) => vec![ToolCallLocation {
path: path.clone(),
line: None,
}],
Self::Grep(Some(GrepToolParams {
path: Some(path), ..
})) => vec![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) -> PushToolCallParams {
PushToolCallParams {
label: self.label(),
content: self.content(),
icon: self.icon(),
locations: self.locations(),
}
}
}
#[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,
}
#[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>,
}
#[derive(Deserialize, JsonSchema, Debug)]
pub struct WriteToolParams {
/// Absolute path for new file
pub file_path: PathBuf,
/// File content
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(Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
#[serde(rename_all = "snake_case")]
pub enum TodoPriority {
High,
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 {
/// Unique identifier
pub id: String,
/// Task description
pub content: String,
/// Priority level of the todo
pub priority: TodoPriority,
/// Current status of the todo
pub status: TodoStatus,
}
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,118 +0,0 @@
use crate::stdio_agent_server::{StdioAgentServer, find_bin_in_path};
use crate::{AgentServerCommand, AgentServerVersion};
use anyhow::{Context as _, Result};
use gpui::{AsyncApp, Entity};
use project::Project;
use settings::SettingsStore;
use crate::AllAgentServersSettings;
#[derive(Clone)]
pub struct Codex;
const ACP_ARG: &str = "acp";
impl StdioAgentServer for Codex {
fn name(&self) -> &'static str {
"Codex"
}
fn empty_state_headline(&self) -> &'static str {
"Welcome to Codex"
}
fn empty_state_message(&self) -> &'static str {
"Ask questions, edit files, run commands.\nBe specific for the best results."
}
fn logo(&self) -> ui::IconName {
ui::IconName::AiOpenAi
}
async fn command(
&self,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<AgentServerCommand> {
let custom_command = cx.read_global(|settings: &SettingsStore, _| {
let settings = settings.get::<AllAgentServersSettings>(None);
settings
.codex
.as_ref()
.map(|codex_settings| AgentServerCommand {
path: codex_settings.command.path.clone(),
args: codex_settings
.command
.args
.iter()
.cloned()
.chain(std::iter::once(ACP_ARG.into()))
.collect(),
env: codex_settings.command.env.clone(),
})
})?;
if let Some(custom_command) = custom_command {
return Ok(custom_command);
}
if let Some(path) = find_bin_in_path("codex", project, cx).await {
return Ok(AgentServerCommand {
path,
args: vec![ACP_ARG.into()],
env: None,
});
}
todo!()
// let (fs, node_runtime) = project.update(cx, |project, _| {
// (project.fs().clone(), project.node_runtime().cloned())
// })?;
// let node_runtime = node_runtime.context("codex not found on path")?;
// let directory = ::paths::agent_servers_dir().join("codex");
// fs.create_dir(&directory).await?;
// node_runtime
// .npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
// .await?;
// let path = directory.join("node_modules/.bin/gemini");
// Ok(AgentServerCommand {
// path,
// args: vec![ACP_ARG.into()],
// env: None,
// })
}
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
let version_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--version")
.kill_on_drop(true)
.output();
let help_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--help")
.kill_on_drop(true)
.output();
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
let current_version = String::from_utf8(version_output?.stdout)?;
let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
if supported {
Ok(AgentServerVersion::Supported)
} else {
Ok(AgentServerVersion::Unsupported {
error_message: format!(
"Your installed version of Codex {} doesn't support the Agentic Coding Protocol (ACP).",
current_version
).into(),
upgrade_message: "Upgrade Codex to Latest".into(),
upgrade_command: "npm install -g @openai/codex@latest".into(),
})
}
}
}

View File

@@ -0,0 +1,412 @@
use std::{path::Path, sync::Arc, time::Duration};
use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings};
use acp_thread::{
AcpThread, AgentThreadEntry, ToolCall, ToolCallConfirmation, ToolCallContent, ToolCallStatus,
};
use agentic_coding_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
use gpui::{Entity, TestAppContext};
use indoc::indoc;
use project::{FakeFs, Project};
use serde_json::json;
use settings::{Settings, SettingsStore};
use util::path;
pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
let fs = init_test(cx).await;
let project = Project::test(fs, [], cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
.await
.unwrap();
thread.read_with(cx, |thread, _| {
assert_eq!(thread.entries().len(), 2);
assert!(matches!(
thread.entries()[0],
AgentThreadEntry::UserMessage(_)
));
assert!(matches!(
thread.entries()[1],
AgentThreadEntry::AssistantMessage(_)
));
});
}
pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
let _fs = init_test(cx).await;
let tempdir = tempfile::tempdir().unwrap();
std::fs::write(
tempdir.path().join("foo.rs"),
indoc! {"
fn main() {
println!(\"Hello, world!\");
}
"},
)
.expect("failed to write file");
let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
thread
.update(cx, |thread, cx| {
thread.send(
acp::SendUserMessageParams {
chunks: vec![
acp::UserMessageChunk::Text {
text: "Read the file ".into(),
},
acp::UserMessageChunk::Path {
path: Path::new("foo.rs").into(),
},
acp::UserMessageChunk::Text {
text: " and tell me what the content of the println! is".into(),
},
],
},
cx,
)
})
.await
.unwrap();
thread.read_with(cx, |thread, cx| {
assert_eq!(thread.entries().len(), 3);
assert!(matches!(
thread.entries()[0],
AgentThreadEntry::UserMessage(_)
));
assert!(matches!(thread.entries()[1], AgentThreadEntry::ToolCall(_)));
let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries()[2] else {
panic!("Expected AssistantMessage")
};
assert!(
assistant_message.to_markdown(cx).contains("Hello, world!"),
"unexpected assistant message: {:?}",
assistant_message.to_markdown(cx)
);
});
}
pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
let fs = init_test(cx).await;
fs.insert_tree(
path!("/private/tmp"),
json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
)
.await;
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
thread
.update(cx, |thread, cx| {
thread.send_raw(
"Read the '/private/tmp/foo' file and tell me what you see.",
cx,
)
})
.await
.unwrap();
thread.read_with(cx, |thread, _cx| {
assert!(thread.entries().iter().any(|entry| {
matches!(
entry,
AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::Allowed { .. },
..
})
)
}));
assert!(
thread
.entries()
.iter()
.any(|entry| { matches!(entry, AgentThreadEntry::AssistantMessage(_)) })
);
});
}
pub async fn test_tool_call_with_confirmation(
server: impl AgentServer + 'static,
cx: &mut TestAppContext,
) {
let fs = init_test(cx).await;
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
let full_turn = thread.update(cx, |thread, cx| {
thread.send_raw(
r#"Run `touch hello.txt && echo "Hello, world!" | tee hello.txt`"#,
cx,
)
});
run_until_first_tool_call(
&thread,
|entry| {
matches!(
entry,
AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::WaitingForConfirmation { .. },
..
})
)
},
cx,
)
.await;
let tool_call_id = thread.read_with(cx, |thread, _cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
status:
ToolCallStatus::WaitingForConfirmation {
confirmation: ToolCallConfirmation::Execute { root_command, .. },
..
},
..
}) = &thread
.entries()
.iter()
.find(|entry| matches!(entry, AgentThreadEntry::ToolCall(_)))
.unwrap()
else {
panic!();
};
assert!(root_command.contains("touch"));
*id
});
thread.update(cx, |thread, cx| {
thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
assert!(thread.entries().iter().any(|entry| matches!(
entry,
AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::Allowed { .. },
..
})
)));
});
full_turn.await.unwrap();
thread.read_with(cx, |thread, cx| {
let AgentThreadEntry::ToolCall(ToolCall {
content: Some(ToolCallContent::Markdown { markdown }),
status: ToolCallStatus::Allowed { .. },
..
}) = thread
.entries()
.iter()
.find(|entry| matches!(entry, AgentThreadEntry::ToolCall(_)))
.unwrap()
else {
panic!();
};
markdown.read_with(cx, |md, _cx| {
assert!(
md.source().contains("Hello"),
r#"Expected '{}' to contain "Hello""#,
md.source()
);
});
});
}
pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
let fs = init_test(cx).await;
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
let full_turn = thread.update(cx, |thread, cx| {
thread.send_raw(
r#"Run `touch hello.txt && echo "Hello, world!" >> hello.txt`"#,
cx,
)
});
let first_tool_call_ix = run_until_first_tool_call(
&thread,
|entry| {
matches!(
entry,
AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::WaitingForConfirmation { .. },
..
})
)
},
cx,
)
.await;
thread.read_with(cx, |thread, _cx| {
let AgentThreadEntry::ToolCall(ToolCall {
id,
status:
ToolCallStatus::WaitingForConfirmation {
confirmation: ToolCallConfirmation::Execute { root_command, .. },
..
},
..
}) = &thread.entries()[first_tool_call_ix]
else {
panic!("{:?}", thread.entries()[1]);
};
assert!(root_command.contains("touch"));
*id
});
thread
.update(cx, |thread, cx| thread.cancel(cx))
.await
.unwrap();
full_turn.await.unwrap();
thread.read_with(cx, |thread, _| {
let AgentThreadEntry::ToolCall(ToolCall {
status: ToolCallStatus::Canceled,
..
}) = &thread.entries()[first_tool_call_ix]
else {
panic!();
};
});
thread
.update(cx, |thread, cx| {
thread.send_raw(r#"Stop running and say goodbye to me."#, cx)
})
.await
.unwrap();
thread.read_with(cx, |thread, _| {
assert!(matches!(
&thread.entries().last().unwrap(),
AgentThreadEntry::AssistantMessage(..),
))
});
}
#[macro_export]
macro_rules! common_e2e_tests {
($server:expr) => {
mod common_e2e {
use super::*;
#[::gpui::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn basic(cx: &mut ::gpui::TestAppContext) {
$crate::e2e_tests::test_basic($server, cx).await;
}
#[::gpui::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn path_mentions(cx: &mut ::gpui::TestAppContext) {
$crate::e2e_tests::test_path_mentions($server, cx).await;
}
#[::gpui::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn tool_call(cx: &mut ::gpui::TestAppContext) {
$crate::e2e_tests::test_tool_call($server, cx).await;
}
#[::gpui::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn tool_call_with_confirmation(cx: &mut ::gpui::TestAppContext) {
$crate::e2e_tests::test_tool_call_with_confirmation($server, cx).await;
}
#[::gpui::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn cancel(cx: &mut ::gpui::TestAppContext) {
$crate::e2e_tests::test_cancel($server, cx).await;
}
}
};
}
// Helpers
pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
env_logger::try_init().ok();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
Project::init_settings(cx);
language::init(cx);
crate::settings::init(cx);
crate::AllAgentServersSettings::override_global(
AllAgentServersSettings {
claude: Some(AgentServerSettings {
command: crate::claude::tests::local_command(),
}),
gemini: Some(AgentServerSettings {
command: crate::gemini::tests::local_command(),
}),
},
cx,
);
});
cx.executor().allow_parking();
FakeFs::new(cx.executor())
}
pub async fn new_test_thread(
server: impl AgentServer + 'static,
project: Entity<Project>,
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
let thread = cx
.update(|cx| server.new_thread(current_dir.as_ref(), &project, cx))
.await
.unwrap();
thread
.update(cx, |thread, _| thread.initialize())
.await
.unwrap();
thread
}
pub async fn run_until_first_tool_call(
thread: &Entity<AcpThread>,
wait_until: impl Fn(&AgentThreadEntry) -> bool + 'static,
cx: &mut TestAppContext,
) -> usize {
let (mut tx, mut rx) = mpsc::channel::<usize>(1);
let subscription = cx.update(|cx| {
cx.subscribe(thread, move |thread, _, cx| {
for (ix, entry) in thread.read(cx).entries().iter().enumerate() {
if wait_until(entry) {
return tx.try_send(ix).unwrap();
}
}
})
});
select! {
// We have to use a smol timer here because
// cx.background_executor().timer isn't real in the test context
_ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))) => {
panic!("Timeout waiting for tool call")
}
ix = rx.next().fuse() => {
drop(subscription);
ix.unwrap()
}
}
}

View File

@@ -0,0 +1,123 @@
use crate::stdio_agent_server::StdioAgentServer;
use crate::{AgentServerCommand, AgentServerVersion};
use anyhow::{Context as _, Result};
use gpui::{AsyncApp, Entity};
use project::Project;
use settings::SettingsStore;
use crate::AllAgentServersSettings;
#[derive(Clone)]
pub struct Gemini;
const ACP_ARG: &str = "--experimental-acp";
impl StdioAgentServer for Gemini {
fn name(&self) -> &'static str {
"Gemini"
}
fn empty_state_headline(&self) -> &'static str {
"Welcome to Gemini"
}
fn empty_state_message(&self) -> &'static str {
"Ask questions, edit files, run commands.\nBe specific for the best results."
}
fn supports_always_allow(&self) -> bool {
true
}
fn logo(&self) -> ui::IconName {
ui::IconName::AiGemini
}
async fn command(
&self,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<AgentServerCommand> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).gemini.clone()
})?;
if let Some(command) =
AgentServerCommand::resolve("gemini", &[ACP_ARG], settings, &project, cx).await
{
return Ok(command);
};
let (fs, node_runtime) = project.update(cx, |project, _| {
(project.fs().clone(), project.node_runtime().cloned())
})?;
let node_runtime = node_runtime.context("gemini not found on path")?;
let directory = ::paths::agent_servers_dir().join("gemini");
fs.create_dir(&directory).await?;
node_runtime
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
.await?;
let path = directory.join("node_modules/.bin/gemini");
Ok(AgentServerCommand {
path,
args: vec![ACP_ARG.into()],
env: None,
})
}
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
let version_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--version")
.kill_on_drop(true)
.output();
let help_fut = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.arg("--help")
.kill_on_drop(true)
.output();
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
let current_version = String::from_utf8(version_output?.stdout)?;
let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
if supported {
Ok(AgentServerVersion::Supported)
} else {
Ok(AgentServerVersion::Unsupported {
error_message: format!(
"Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
current_version
).into(),
upgrade_message: "Upgrade Gemini to Latest".into(),
upgrade_command: "npm install -g @google/gemini-cli@latest".into(),
})
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::AgentServerCommand;
use std::path::Path;
crate::common_e2e_tests!(Gemini);
pub fn local_command() -> AgentServerCommand {
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../gemini-cli/packages/cli")
.to_string_lossy()
.to_string();
AgentServerCommand {
path: "node".into(),
args: vec![cli_path, ACP_ARG.into()],
env: None,
}
}
}

View File

@@ -0,0 +1,45 @@
use crate::AgentServerCommand;
use anyhow::Result;
use gpui::App;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
pub fn init(cx: &mut App) {
AllAgentServersSettings::register(cx);
}
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AllAgentServersSettings {
pub gemini: Option<AgentServerSettings>,
pub claude: Option<AgentServerSettings>,
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AgentServerSettings {
#[serde(flatten)]
pub command: AgentServerCommand,
}
impl settings::Settings for AllAgentServersSettings {
const KEY: Option<&'static str> = Some("agent_servers");
type FileContent = Self;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
let mut settings = AllAgentServersSettings::default();
for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() {
if gemini.is_some() {
settings.gemini = gemini.clone();
}
if claude.is_some() {
settings.claude = claude.clone();
}
}
Ok(settings)
}
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
}

View File

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

View File

@@ -69,6 +69,7 @@ pub struct AgentSettings {
pub enable_feedback: bool,
pub expand_edit_card: bool,
pub expand_terminal_card: bool,
pub use_modifier_to_send: bool,
}
impl AgentSettings {
@@ -174,6 +175,10 @@ impl AgentSettingsContent {
self.single_file_review = Some(allow);
}
pub fn set_use_modifier_to_send(&mut self, always_use: bool) {
self.use_modifier_to_send = Some(always_use);
}
pub fn set_profile(&mut self, profile_id: AgentProfileId) {
self.default_profile = Some(profile_id);
}
@@ -301,6 +306,10 @@ pub struct AgentSettingsContent {
///
/// Default: true
expand_terminal_card: Option<bool>,
/// Whether to always use cmd-enter (or ctrl-enter on Linux) to send messages in the agent panel.
///
/// Default: false
use_modifier_to_send: Option<bool>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
@@ -456,6 +465,10 @@ impl Settings for AgentSettings {
&mut settings.expand_terminal_card,
value.expand_terminal_card,
);
merge(
&mut settings.use_modifier_to_send,
value.use_modifier_to_send,
);
settings
.model_parameters

View File

@@ -16,11 +16,12 @@ doctest = false
test-support = ["gpui/test-support", "language/test-support"]
[dependencies]
acp.workspace = true
acp_thread.workspace = true
agent.workspace = true
agentic-coding-protocol.workspace = true
agent_settings.workspace = true
agent_servers.workspace = true
ai_onboarding.workspace = true
anyhow.workspace = true
assistant_context.workspace = true
assistant_slash_command.workspace = true
@@ -31,6 +32,7 @@ buffer_diff.workspace = true
chrono.workspace = true
client.workspace = true
collections.workspace = true
command_palette_hooks.workspace = true
component.workspace = true
context_server.workspace = true
db.workspace = true
@@ -52,6 +54,7 @@ itertools.workspace = true
jsonschema.workspace = true
language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
lsp.workspace = true
markdown.workspace = true
@@ -86,6 +89,7 @@ theme.workspace = true
time.workspace = true
time_format.workspace = true
ui.workspace = true
ui_input.workspace = true
urlencoding.workspace = true
util.workspace = true
uuid.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -1036,7 +1036,7 @@ impl ActiveThread {
.collect::<Vec<_>>()
.join("\n");
self.last_error = Some(ThreadError::Message {
header: "Error interacting with language model".into(),
header: "Error".into(),
message: error_message.into(),
});
}
@@ -3202,7 +3202,10 @@ impl ActiveThread {
.border_color(self.tool_card_border_color(cx))
.rounded_b_lg()
.child(
LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)
div()
.min_w(rems_from_px(145.))
.child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)
)
)
.child(
h_flex()
@@ -3247,7 +3250,6 @@ impl ActiveThread {
},
))
})
.child(ui::Divider::vertical())
.child({
let tool_id = tool_use.id.clone();
Button::new("allow-tool-action", "Allow")
@@ -3722,8 +3724,11 @@ pub(crate) fn open_context(
AgentContextHandle::Thread(thread_context) => workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.open_thread(thread_context.thread.clone(), window, cx);
let thread = thread_context.thread.clone();
window.defer(cx, move |window, cx| {
panel.update(cx, |panel, cx| {
panel.open_thread(thread, window, cx);
});
});
}
}),
@@ -3731,8 +3736,11 @@ pub(crate) fn open_context(
AgentContextHandle::TextThread(text_thread_context) => {
workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.open_prompt_editor(text_thread_context.context.clone(), window, cx)
let context = text_thread_context.context.clone();
window.defer(cx, move |window, cx| {
panel.update(cx, |panel, cx| {
panel.open_prompt_editor(context, window, cx)
});
});
}
})
@@ -3887,7 +3895,7 @@ mod tests {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.set_default_model(
Some(ConfiguredModel {
provider: Arc::new(FakeLanguageModelProvider),
provider: Arc::new(FakeLanguageModelProvider::default()),
model,
}),
cx,
@@ -3971,7 +3979,7 @@ mod tests {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.set_default_model(
Some(ConfiguredModel {
provider: Arc::new(FakeLanguageModelProvider),
provider: Arc::new(FakeLanguageModelProvider::default()),
model: model.clone(),
}),
cx,

View File

@@ -1,3 +1,4 @@
mod add_llm_provider_modal;
mod configure_context_server_modal;
mod manage_profiles_modal;
mod tool_picker;
@@ -28,7 +29,7 @@ use proto::Plan;
use settings::{Settings, update_settings_file};
use ui::{
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
Scrollbar, ScrollbarState, Switch, SwitchColor, Tooltip, prelude::*,
Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
};
use util::ResultExt as _;
use workspace::Workspace;
@@ -37,7 +38,10 @@ use zed_actions::ExtensionCategoryFilter;
pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
pub(crate) use manage_profiles_modal::ManageProfilesModal;
use crate::AddContextServer;
use crate::{
AddContextServer,
agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
};
pub struct AgentConfiguration {
fs: Arc<dyn Fs>,
@@ -182,6 +186,7 @@ impl AgentConfiguration {
};
v_flex()
.w_full()
.when(is_expanded, |this| this.mb_2())
.child(
div()
@@ -212,6 +217,7 @@ impl AgentConfiguration {
.hover(|hover| hover.bg(cx.theme().colors().element_hover))
.child(
h_flex()
.w_full()
.gap_2()
.child(
Icon::new(provider.icon())
@@ -220,6 +226,7 @@ impl AgentConfiguration {
)
.child(
h_flex()
.w_full()
.gap_1()
.child(
Label::new(provider_name.clone())
@@ -303,21 +310,62 @@ impl AgentConfiguration {
let providers = LanguageModelRegistry::read_global(cx).providers();
v_flex()
.w_full()
.child(
v_flex()
h_flex()
.p(DynamicSpacing::Base16.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.pb_0()
.mb_2p5()
.gap_0p5()
.child(Headline::new("LLM Providers"))
.items_start()
.justify_between()
.child(
Label::new("Add at least one provider to use AI-powered features.")
.color(Color::Muted),
v_flex()
.gap_0p5()
.child(Headline::new("LLM Providers"))
.child(
Label::new("Add at least one provider to use AI-powered features.")
.color(Color::Muted),
),
)
.child(
PopoverMenu::new("add-provider-popover")
.trigger(
Button::new("add-provider", "Add Provider")
.icon_position(IconPosition::Start)
.icon(IconName::Plus)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.label_size(LabelSize::Small),
)
.anchor(gpui::Corner::TopRight)
.menu({
let workspace = self.workspace.clone();
move |window, cx| {
Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
menu.header("Compatible APIs").entry("OpenAI", None, {
let workspace = workspace.clone();
move |window, cx| {
workspace
.update(cx, |workspace, cx| {
AddLlmProviderModal::toggle(
LlmCompatibleProvider::OpenAi,
workspace,
window,
cx,
);
})
.log_err();
}
})
}))
}
}),
),
)
.child(
div()
.w_full()
.pl(DynamicSpacing::Base08.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.children(
@@ -330,119 +378,74 @@ impl AgentConfiguration {
fn render_command_permission(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let always_allow_tool_actions = AgentSettings::get_global(cx).always_allow_tool_actions;
let fs = self.fs.clone();
h_flex()
.gap_4()
.justify_between()
.flex_wrap()
.child(
v_flex()
.gap_0p5()
.max_w_5_6()
.child(Label::new("Allow running editing tools without asking for confirmation"))
.child(
Label::new(
"The agent can perform potentially destructive actions without asking for your confirmation.",
)
.color(Color::Muted),
),
)
.child(
Switch::new(
"always-allow-tool-actions-switch",
always_allow_tool_actions.into(),
)
.color(SwitchColor::Accent)
.on_click({
let fs = self.fs.clone();
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
update_settings_file::<AgentSettings>(
fs.clone(),
cx,
move |settings, _| {
settings.set_always_allow_tool_actions(allow);
},
);
}
}),
)
SwitchField::new(
"single-file-review",
"Enable single-file agent reviews",
"Agent edits are also displayed in single-file editors for review.",
always_allow_tool_actions,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
settings.set_always_allow_tool_actions(allow);
});
},
)
}
fn render_single_file_review(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let single_file_review = AgentSettings::get_global(cx).single_file_review;
let fs = self.fs.clone();
h_flex()
.gap_4()
.justify_between()
.flex_wrap()
.child(
v_flex()
.gap_0p5()
.max_w_5_6()
.child(Label::new("Enable single-file agent reviews"))
.child(
Label::new(
"Agent edits are also displayed in single-file editors for review.",
)
.color(Color::Muted),
),
)
.child(
Switch::new("single-file-review-switch", single_file_review.into())
.color(SwitchColor::Accent)
.on_click({
let fs = self.fs.clone();
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
update_settings_file::<AgentSettings>(
fs.clone(),
cx,
move |settings, _| {
settings.set_single_file_review(allow);
},
);
}
}),
)
SwitchField::new(
"single-file-review",
"Enable single-file agent reviews",
"Agent edits are also displayed in single-file editors for review.",
single_file_review,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
settings.set_single_file_review(allow);
});
},
)
}
fn render_sound_notification(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let play_sound_when_agent_done = AgentSettings::get_global(cx).play_sound_when_agent_done;
let fs = self.fs.clone();
h_flex()
.gap_4()
.justify_between()
.flex_wrap()
.child(
v_flex()
.gap_0p5()
.max_w_5_6()
.child(Label::new("Play sound when finished generating"))
.child(
Label::new(
"Hear a notification sound when the agent is done generating changes or needs your input.",
)
.color(Color::Muted),
),
)
.child(
Switch::new("play-sound-notification-switch", play_sound_when_agent_done.into())
.color(SwitchColor::Accent)
.on_click({
let fs = self.fs.clone();
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
update_settings_file::<AgentSettings>(
fs.clone(),
cx,
move |settings, _| {
settings.set_play_sound_when_agent_done(allow);
},
);
}
}),
)
SwitchField::new(
"sound-notification",
"Play sound when finished generating",
"Hear a notification sound when the agent is done generating changes or needs your input.",
play_sound_when_agent_done,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
settings.set_play_sound_when_agent_done(allow);
});
},
)
}
fn render_modifier_to_send(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let use_modifier_to_send = AgentSettings::get_global(cx).use_modifier_to_send;
let fs = self.fs.clone();
SwitchField::new(
"modifier-send",
"Use modifier to submit a message",
"Make a modifier (cmd-enter on macOS, ctrl-enter on Linux) required to send messages.",
use_modifier_to_send,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
settings.set_use_modifier_to_send(allow);
});
},
)
}
fn render_general_settings_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -456,6 +459,7 @@ impl AgentConfiguration {
.child(self.render_command_permission(cx))
.child(self.render_single_file_review(cx))
.child(self.render_sound_notification(cx))
.child(self.render_modifier_to_send(cx))
}
fn render_zed_plan_info(&self, plan: Option<Plan>, cx: &mut Context<Self>) -> impl IntoElement {

View File

@@ -0,0 +1,639 @@
use std::sync::Arc;
use anyhow::Result;
use collections::HashSet;
use fs::Fs;
use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, Task};
use language_model::LanguageModelRegistry;
use language_models::{
AllLanguageModelSettings, OpenAiCompatibleSettingsContent,
provider::open_ai_compatible::AvailableModel,
};
use settings::update_settings_file;
use ui::{Banner, KeyBinding, Modal, ModalFooter, ModalHeader, Section, prelude::*};
use ui_input::SingleLineInput;
use workspace::{ModalView, Workspace};
#[derive(Clone, Copy)]
pub enum LlmCompatibleProvider {
OpenAi,
}
impl LlmCompatibleProvider {
fn name(&self) -> &'static str {
match self {
LlmCompatibleProvider::OpenAi => "OpenAI",
}
}
fn api_url(&self) -> &'static str {
match self {
LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1",
}
}
}
struct AddLlmProviderInput {
provider_name: Entity<SingleLineInput>,
api_url: Entity<SingleLineInput>,
api_key: Entity<SingleLineInput>,
models: Vec<ModelInput>,
}
impl AddLlmProviderInput {
fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self {
let provider_name = single_line_input("Provider Name", provider.name(), None, window, cx);
let api_url = single_line_input("API URL", provider.api_url(), None, window, cx);
let api_key = single_line_input(
"API Key",
"000000000000000000000000000000000000000000000000",
None,
window,
cx,
);
Self {
provider_name,
api_url,
api_key,
models: vec![ModelInput::new(window, cx)],
}
}
fn add_model(&mut self, window: &mut Window, cx: &mut App) {
self.models.push(ModelInput::new(window, cx));
}
fn remove_model(&mut self, index: usize) {
self.models.remove(index);
}
}
struct ModelInput {
name: Entity<SingleLineInput>,
max_completion_tokens: Entity<SingleLineInput>,
max_output_tokens: Entity<SingleLineInput>,
max_tokens: Entity<SingleLineInput>,
}
impl ModelInput {
fn new(window: &mut Window, cx: &mut App) -> Self {
let model_name = single_line_input(
"Model Name",
"e.g. gpt-4o, claude-opus-4, gemini-2.5-pro",
None,
window,
cx,
);
let max_completion_tokens = single_line_input(
"Max Completion Tokens",
"200000",
Some("200000"),
window,
cx,
);
let max_output_tokens = single_line_input(
"Max Output Tokens",
"Max Output Tokens",
Some("32000"),
window,
cx,
);
let max_tokens = single_line_input("Max Tokens", "Max Tokens", Some("200000"), window, cx);
Self {
name: model_name,
max_completion_tokens,
max_output_tokens,
max_tokens,
}
}
fn parse(&self, cx: &App) -> Result<AvailableModel, SharedString> {
let name = self.name.read(cx).text(cx);
if name.is_empty() {
return Err(SharedString::from("Model Name cannot be empty"));
}
Ok(AvailableModel {
name,
display_name: None,
max_completion_tokens: Some(
self.max_completion_tokens
.read(cx)
.text(cx)
.parse::<u64>()
.map_err(|_| SharedString::from("Max Completion Tokens must be a number"))?,
),
max_output_tokens: Some(
self.max_output_tokens
.read(cx)
.text(cx)
.parse::<u64>()
.map_err(|_| SharedString::from("Max Output Tokens must be a number"))?,
),
max_tokens: self
.max_tokens
.read(cx)
.text(cx)
.parse::<u64>()
.map_err(|_| SharedString::from("Max Tokens must be a number"))?,
})
}
}
fn single_line_input(
label: impl Into<SharedString>,
placeholder: impl Into<SharedString>,
text: Option<&str>,
window: &mut Window,
cx: &mut App,
) -> Entity<SingleLineInput> {
cx.new(|cx| {
let input = SingleLineInput::new(window, cx, placeholder).label(label);
if let Some(text) = text {
input
.editor()
.update(cx, |editor, cx| editor.set_text(text, window, cx));
}
input
})
}
fn save_provider_to_settings(
input: &AddLlmProviderInput,
cx: &mut App,
) -> Task<Result<(), SharedString>> {
let provider_name: Arc<str> = input.provider_name.read(cx).text(cx).into();
if provider_name.is_empty() {
return Task::ready(Err("Provider Name cannot be empty".into()));
}
if LanguageModelRegistry::read_global(cx)
.providers()
.iter()
.any(|provider| {
provider.id().0.as_ref() == provider_name.as_ref()
|| provider.name().0.as_ref() == provider_name.as_ref()
})
{
return Task::ready(Err(
"Provider Name is already taken by another provider".into()
));
}
let api_url = input.api_url.read(cx).text(cx);
if api_url.is_empty() {
return Task::ready(Err("API URL cannot be empty".into()));
}
let api_key = input.api_key.read(cx).text(cx);
if api_key.is_empty() {
return Task::ready(Err("API Key cannot be empty".into()));
}
let mut models = Vec::new();
let mut model_names: HashSet<String> = HashSet::default();
for model in &input.models {
match model.parse(cx) {
Ok(model) => {
if !model_names.insert(model.name.clone()) {
return Task::ready(Err("Model Names must be unique".into()));
}
models.push(model)
}
Err(err) => return Task::ready(Err(err)),
}
}
let fs = <dyn Fs>::global(cx);
let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes());
cx.spawn(async move |cx| {
task.await
.map_err(|_| "Failed to write API key to keychain")?;
cx.update(|cx| {
update_settings_file::<AllLanguageModelSettings>(fs, cx, |settings, _cx| {
settings.openai_compatible.get_or_insert_default().insert(
provider_name,
OpenAiCompatibleSettingsContent {
api_url,
available_models: models,
},
);
});
})
.ok();
Ok(())
})
}
pub struct AddLlmProviderModal {
provider: LlmCompatibleProvider,
input: AddLlmProviderInput,
focus_handle: FocusHandle,
last_error: Option<SharedString>,
}
impl AddLlmProviderModal {
pub fn toggle(
provider: LlmCompatibleProvider,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
workspace.toggle_modal(window, cx, |window, cx| Self::new(provider, window, cx));
}
fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
input: AddLlmProviderInput::new(provider, window, cx),
provider,
last_error: None,
focus_handle: cx.focus_handle(),
}
}
fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context<Self>) {
let task = save_provider_to_settings(&self.input, cx);
cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| match result {
Ok(_) => {
cx.emit(DismissEvent);
}
Err(error) => {
this.last_error = Some(error);
cx.notify();
}
})
})
.detach_and_log_err(cx);
}
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
fn render_section(&self) -> Section {
Section::new()
.child(self.input.provider_name.clone())
.child(self.input.api_url.clone())
.child(self.input.api_key.clone())
}
fn render_model_section(&self, cx: &mut Context<Self>) -> Section {
Section::new().child(
v_flex()
.gap_2()
.child(
h_flex()
.justify_between()
.child(Label::new("Models").size(LabelSize::Small))
.child(
Button::new("add-model", "Add Model")
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.label_size(LabelSize::Small)
.on_click(cx.listener(|this, _, window, cx| {
this.input.add_model(window, cx);
cx.notify();
})),
),
)
.children(
self.input
.models
.iter()
.enumerate()
.map(|(ix, _)| self.render_model(ix, cx)),
),
)
}
fn render_model(&self, ix: usize, cx: &mut Context<Self>) -> impl IntoElement + use<> {
let has_more_than_one_model = self.input.models.len() > 1;
let model = &self.input.models[ix];
v_flex()
.p_2()
.gap_2()
.rounded_sm()
.border_1()
.border_dashed()
.border_color(cx.theme().colors().border.opacity(0.6))
.bg(cx.theme().colors().element_active.opacity(0.15))
.child(model.name.clone())
.child(
h_flex()
.gap_2()
.child(model.max_completion_tokens.clone())
.child(model.max_output_tokens.clone()),
)
.child(model.max_tokens.clone())
.when(has_more_than_one_model, |this| {
this.child(
Button::new(("remove-model", ix), "Remove Model")
.icon(IconName::Trash)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.label_size(LabelSize::Small)
.style(ButtonStyle::Outlined)
.full_width()
.on_click(cx.listener(move |this, _, _window, cx| {
this.input.remove_model(ix);
cx.notify();
})),
)
})
}
}
impl EventEmitter<DismissEvent> for AddLlmProviderModal {}
impl Focusable for AddLlmProviderModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for AddLlmProviderModal {}
impl Render for AddLlmProviderModal {
fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
div()
.id("add-llm-provider-modal")
.key_context("AddLlmProviderModal")
.w(rems(34.))
.elevation_3(cx)
.on_action(cx.listener(Self::cancel))
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
this.focus_handle(cx).focus(window);
}))
.child(
Modal::new("configure-context-server", None)
.header(ModalHeader::new().headline("Add LLM Provider").description(
match self.provider {
LlmCompatibleProvider::OpenAi => {
"This provider will use an OpenAI compatible API."
}
},
))
.when_some(self.last_error.clone(), |this, error| {
this.section(
Section::new().child(
Banner::new()
.severity(ui::Severity::Warning)
.child(div().text_xs().child(error)),
),
)
})
.child(
v_flex()
.id("modal_content")
.max_h_128()
.overflow_y_scroll()
.gap_2()
.child(self.render_section())
.child(self.render_model_section(cx)),
)
.footer(
ModalFooter::new().end_slot(
h_flex()
.gap_1()
.child(
Button::new("cancel", "Cancel")
.key_binding(
KeyBinding::for_action_in(
&menu::Cancel,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _event, window, cx| {
this.cancel(&menu::Cancel, window, cx)
})),
)
.child(
Button::new("save-server", "Save Provider")
.key_binding(
KeyBinding::for_action_in(
&menu::Confirm,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _event, window, cx| {
this.confirm(&menu::Confirm, window, cx)
})),
),
),
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use editor::EditorSettings;
use fs::FakeFs;
use gpui::{TestAppContext, VisualTestContext};
use language::language_settings;
use language_model::{
LanguageModelProviderId, LanguageModelProviderName,
fake_provider::FakeLanguageModelProvider,
};
use project::Project;
use settings::{Settings as _, SettingsStore};
use util::path;
#[gpui::test]
async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) {
let cx = setup_test(cx).await;
assert_eq!(
save_provider_validation_errors("", "someurl", "somekey", vec![], cx,).await,
Some("Provider Name cannot be empty".into())
);
assert_eq!(
save_provider_validation_errors("someprovider", "", "somekey", vec![], cx,).await,
Some("API URL cannot be empty".into())
);
assert_eq!(
save_provider_validation_errors("someprovider", "someurl", "", vec![], cx,).await,
Some("API Key cannot be empty".into())
);
assert_eq!(
save_provider_validation_errors(
"someprovider",
"someurl",
"somekey",
vec![("", "200000", "200000", "32000")],
cx,
)
.await,
Some("Model Name cannot be empty".into())
);
assert_eq!(
save_provider_validation_errors(
"someprovider",
"someurl",
"somekey",
vec![("somemodel", "abc", "200000", "32000")],
cx,
)
.await,
Some("Max Tokens must be a number".into())
);
assert_eq!(
save_provider_validation_errors(
"someprovider",
"someurl",
"somekey",
vec![("somemodel", "200000", "abc", "32000")],
cx,
)
.await,
Some("Max Completion Tokens must be a number".into())
);
assert_eq!(
save_provider_validation_errors(
"someprovider",
"someurl",
"somekey",
vec![("somemodel", "200000", "200000", "abc")],
cx,
)
.await,
Some("Max Output Tokens must be a number".into())
);
assert_eq!(
save_provider_validation_errors(
"someprovider",
"someurl",
"somekey",
vec![
("somemodel", "200000", "200000", "32000"),
("somemodel", "200000", "200000", "32000"),
],
cx,
)
.await,
Some("Model Names must be unique".into())
);
}
#[gpui::test]
async fn test_save_provider_name_conflict(cx: &mut TestAppContext) {
let cx = setup_test(cx).await;
cx.update(|_window, cx| {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.register_provider(
FakeLanguageModelProvider::new(
LanguageModelProviderId::new("someprovider"),
LanguageModelProviderName::new("Some Provider"),
),
cx,
);
});
});
assert_eq!(
save_provider_validation_errors(
"someprovider",
"someurl",
"someapikey",
vec![("somemodel", "200000", "200000", "32000")],
cx,
)
.await,
Some("Provider Name is already taken by another provider".into())
);
}
async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
workspace::init_settings(cx);
Project::init_settings(cx);
theme::init(theme::LoadThemes::JustBase, cx);
language_settings::init(cx);
EditorSettings::register(cx);
language_model::init_settings(cx);
language_models::init_settings(cx);
});
let fs = FakeFs::new(cx.executor());
cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
let (_, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
cx
}
async fn save_provider_validation_errors(
provider_name: &str,
api_url: &str,
api_key: &str,
models: Vec<(&str, &str, &str, &str)>,
cx: &mut VisualTestContext,
) -> Option<SharedString> {
fn set_text(
input: &Entity<SingleLineInput>,
text: &str,
window: &mut Window,
cx: &mut App,
) {
input.update(cx, |input, cx| {
input.editor().update(cx, |editor, cx| {
editor.set_text(text, window, cx);
});
});
}
let task = cx.update(|window, cx| {
let mut input = AddLlmProviderInput::new(LlmCompatibleProvider::OpenAi, window, cx);
set_text(&input.provider_name, provider_name, window, cx);
set_text(&input.api_url, api_url, window, cx);
set_text(&input.api_key, api_key, window, cx);
for (i, (name, max_tokens, max_completion_tokens, max_output_tokens)) in
models.iter().enumerate()
{
if i >= input.models.len() {
input.models.push(ModelInput::new(window, cx));
}
let model = &mut input.models[i];
set_text(&model.name, name, window, cx);
set_text(&model.max_tokens, max_tokens, window, cx);
set_text(
&model.max_completion_tokens,
max_completion_tokens,
window,
cx,
);
set_text(&model.max_output_tokens, max_output_tokens, window, cx);
}
save_provider_to_settings(&input, cx)
});
task.await.err()
}
}

View File

@@ -1,4 +1,5 @@
use std::{
path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
};
@@ -188,7 +189,7 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
}
None => (
"some-mcp-server".to_string(),
"".to_string(),
PathBuf::new(),
"[]".to_string(),
"{}".to_string(),
),
@@ -199,13 +200,14 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)
/// The name of your MCP server
"{name}": {{
/// The command which runs the MCP server
"command": "{command}",
"command": "{}",
/// The arguments to pass to the MCP server
"args": {args},
/// The environment variables to set
"env": {env}
}}
}}"#
}}"#,
command.display()
)
}

View File

@@ -1,5 +1,5 @@
use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll};
use acp::{AcpThread, AcpThreadEvent};
use acp_thread::{AcpThread, AcpThreadEvent};
use agent::{Thread, ThreadEvent, ThreadSummary};
use agent_settings::AgentSettings;
use anyhow::Result;
@@ -81,7 +81,7 @@ impl AgentDiffThread {
match self {
AgentDiffThread::Native(thread) => thread.read(cx).is_generating(),
AgentDiffThread::AcpThread(thread) => {
thread.read(cx).status() == acp::ThreadStatus::Generating
thread.read(cx).status() == acp_thread::ThreadStatus::Generating
}
}
}

View File

@@ -1,8 +1,6 @@
use crate::{
ModelUsageContext,
language_model_selector::{
LanguageModelSelector, ToggleModelSelector, language_model_selector,
},
language_model_selector::{LanguageModelSelector, language_model_selector},
};
use agent_settings::AgentSettings;
use fs::Fs;
@@ -12,6 +10,7 @@ use picker::popover_menu::PickerPopoverMenu;
use settings::update_settings_file;
use std::sync::Arc;
use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
pub struct AgentModelSelector {
selector: Entity<LanguageModelSelector>,
@@ -96,22 +95,18 @@ impl Render for AgentModelSelector {
let model_name = model
.as_ref()
.map(|model| model.model.name().0)
.unwrap_or_else(|| SharedString::from("No model selected"));
let provider_icon = model
.as_ref()
.map(|model| model.provider.icon())
.unwrap_or_else(|| IconName::Ai);
.unwrap_or_else(|| SharedString::from("Select a Model"));
let provider_icon = model.as_ref().map(|model| model.provider.icon());
let focus_handle = self.focus_handle.clone();
PickerPopoverMenu::new(
self.selector.clone(),
ButtonLike::new("active-model")
.child(
Icon::new(provider_icon)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.when_some(provider_icon, |this, icon| {
this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall))
})
.child(
Label::new(model_name)
.color(Color::Muted)

File diff suppressed because it is too large Load Diff

View File

@@ -25,12 +25,14 @@ mod thread_history;
mod tool_compatibility;
mod ui;
use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
use client::{Client, DisableAiSettings};
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
use gpui::{Action, App, Entity, actions};
@@ -40,8 +42,9 @@ use language_model::{
};
use prompt_store::PromptBuilder;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use settings::{Settings as _, SettingsStore};
use std::any::TypeId;
pub use crate::active_thread::ActiveThread;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
@@ -51,14 +54,13 @@ use crate::slash_command_settings::SlashCommandSettings;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
pub use ui::preview::{all_agent_previews, get_agent_preview};
use zed_actions;
actions!(
agent,
[
/// Creates a new text-based conversation thread.
NewTextThread,
/// Creates a new external agent conversation thread.
NewAcpThread,
/// Toggles the context picker interface for adding files, symbols, or other context.
ToggleContextPicker,
/// Toggles the navigation menu for switching between threads and views.
@@ -133,6 +135,32 @@ pub struct NewThread {
from_thread_id: Option<ThreadId>,
}
/// Creates a new external agent conversation thread.
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(deny_unknown_fields)]
pub struct NewExternalAgentThread {
/// Which agent to use for the conversation.
agent: Option<ExternalAgent>,
}
#[derive(Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum ExternalAgent {
#[default]
Gemini,
ClaudeCode,
}
impl ExternalAgent {
pub fn server(&self) -> Rc<dyn agent_servers::AgentServer> {
match self {
ExternalAgent::Gemini => Rc::new(agent_servers::Gemini),
ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
}
}
}
/// Opens the profile management interface for configuring agent tools and settings.
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
@@ -216,6 +244,66 @@ pub fn init(
})
.detach();
cx.observe_new(ManageProfilesModal::register).detach();
// Update command palette filter based on AI settings
update_command_palette_filter(cx);
// Watch for settings changes
cx.observe_global::<SettingsStore>(|app_cx| {
// When settings change, update the command palette filter
update_command_palette_filter(app_cx);
})
.detach();
}
fn update_command_palette_filter(cx: &mut App) {
let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
CommandPaletteFilter::update_global(cx, |filter, _| {
if disable_ai {
filter.hide_namespace("agent");
filter.hide_namespace("assistant");
filter.hide_namespace("zed_predict_onboarding");
filter.hide_namespace("edit_prediction");
use editor::actions::{
AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction,
PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction,
};
let edit_prediction_actions = [
TypeId::of::<AcceptEditPrediction>(),
TypeId::of::<AcceptPartialEditPrediction>(),
TypeId::of::<ShowEditPrediction>(),
TypeId::of::<NextEditPrediction>(),
TypeId::of::<PreviousEditPrediction>(),
TypeId::of::<ToggleEditPrediction>(),
];
filter.hide_action_types(&edit_prediction_actions);
filter.hide_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
} else {
filter.show_namespace("agent");
filter.show_namespace("assistant");
filter.show_namespace("zed_predict_onboarding");
filter.show_namespace("edit_prediction");
use editor::actions::{
AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction,
PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction,
};
let edit_prediction_actions = [
TypeId::of::<AcceptEditPrediction>(),
TypeId::of::<AcceptPartialEditPrediction>(),
TypeId::of::<ShowEditPrediction>(),
TypeId::of::<NextEditPrediction>(),
TypeId::of::<PreviousEditPrediction>(),
TypeId::of::<ToggleEditPrediction>(),
];
filter.show_action_types(edit_prediction_actions.iter());
filter
.show_action_types([TypeId::of::<zed_actions::OpenZedPredictOnboarding>()].iter());
}
});
}
fn init_language_model_settings(cx: &mut App) {

View File

@@ -16,7 +16,7 @@ use agent::{
};
use agent_settings::AgentSettings;
use anyhow::{Context as _, Result};
use client::telemetry::Telemetry;
use client::{DisableAiSettings, telemetry::Telemetry};
use collections::{HashMap, HashSet, VecDeque, hash_map};
use editor::SelectionEffects;
use editor::{
@@ -57,6 +57,17 @@ pub fn init(
cx: &mut App,
) {
cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry));
cx.observe_global::<SettingsStore>(|cx| {
if DisableAiSettings::get_global(cx).disable_ai {
// Hide any active inline assist UI when AI is disabled
InlineAssistant::update_global(cx, |assistant, cx| {
assistant.cancel_all_active_completions(cx);
});
}
})
.detach();
cx.observe_new(|_workspace: &mut Workspace, window, cx| {
let Some(window) = window else {
return;
@@ -141,6 +152,26 @@ impl InlineAssistant {
.detach();
}
/// Hides all active inline assists when AI is disabled
pub fn cancel_all_active_completions(&mut self, cx: &mut App) {
// Cancel all active completions in editors
for (editor_handle, _) in self.assists_by_editor.iter() {
if let Some(editor) = editor_handle.upgrade() {
let windows = cx.windows();
if !windows.is_empty() {
let window = windows[0];
let _ = window.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| {
if editor.has_active_inline_completion() {
editor.cancel(&Default::default(), window, cx);
}
});
});
}
}
}
}
fn handle_workspace_event(
&mut self,
workspace: Entity<Workspace>,
@@ -176,7 +207,7 @@ impl InlineAssistant {
window: &mut Window,
cx: &mut App,
) {
let is_assistant2_enabled = true;
let is_assistant2_enabled = !DisableAiSettings::get_global(cx).disable_ai;
if let Some(editor) = item.act_as::<Editor>(cx) {
editor.update(cx, |editor, cx| {
@@ -199,6 +230,13 @@ impl InlineAssistant {
cx,
);
if DisableAiSettings::get_global(cx).disable_ai {
// Cancel any active completions
if editor.has_active_inline_completion() {
editor.cancel(&Default::default(), window, cx);
}
}
// Remove the Assistant1 code action provider, as it still might be registered.
editor.remove_code_action_provider("assistant".into(), window, cx);
} else {
@@ -219,7 +257,7 @@ impl InlineAssistant {
cx: &mut Context<Workspace>,
) {
let settings = AgentSettings::get_global(cx);
if !settings.enabled {
if !settings.enabled || DisableAiSettings::get_global(cx).disable_ai {
return;
}

View File

@@ -2,7 +2,6 @@ use crate::agent_model_selector::AgentModelSelector;
use crate::buffer_codegen::BufferCodegen;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
use crate::language_model_selector::ToggleModelSelector;
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
use crate::terminal_codegen::TerminalCodegen;
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
@@ -38,6 +37,7 @@ use ui::{
CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*,
};
use workspace::Workspace;
use zed_actions::agent::ToggleModelSelector;
pub struct PromptEditor<T> {
pub editor: Entity<Editor>,

View File

@@ -3,9 +3,7 @@ use std::{cmp::Reverse, sync::Arc};
use collections::{HashSet, IndexMap};
use feature_flags::ZedProFeatureFlag;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task, actions,
};
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
use language_model::{
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
LanguageModelRegistry,
@@ -15,15 +13,6 @@ use picker::{Picker, PickerDelegate};
use proto::Plan;
use ui::{ListItem, ListItemSpacing, prelude::*};
actions!(
agent,
[
/// Toggles the language model selector dropdown.
#[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])]
ToggleModelSelector
]
);
const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;

View File

@@ -4,7 +4,6 @@ use std::sync::Arc;
use crate::agent_diff::AgentDiffThread;
use crate::agent_model_selector::AgentModelSelector;
use crate::language_model_selector::ToggleModelSelector;
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
use crate::ui::{
MaxModeTooltip,
@@ -15,6 +14,7 @@ use agent::{
context_store::ContextStoreEvent,
};
use agent_settings::{AgentSettings, CompletionMode};
use ai_onboarding::ApiKeysWithProviders;
use buffer_diff::BufferDiff;
use client::UserStore;
use collections::{HashMap, HashSet};
@@ -29,12 +29,13 @@ use fs::Fs;
use futures::future::Shared;
use futures::{FutureExt as _, future};
use gpui::{
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, Subscription, Task, TextStyle,
WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, KeyContext, Subscription, Task,
TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
};
use language::{Buffer, Language, Point};
use language_model::{
ConfiguredModel, LanguageModelRequestMessage, MessageContent, ZED_CLOUD_PROVIDER_ID,
ConfiguredModel, LanguageModelRegistry, LanguageModelRequestMessage, MessageContent,
ZED_CLOUD_PROVIDER_ID,
};
use multi_buffer;
use project::Project;
@@ -49,6 +50,7 @@ use ui::{
use util::ResultExt as _;
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::Chat;
use zed_actions::agent::ToggleModelSelector;
use zed_llm_client::CompletionIntent;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
@@ -65,6 +67,9 @@ use agent::{
thread_store::{TextThreadStore, ThreadStore},
};
pub const MIN_EDITOR_LINES: usize = 4;
pub const MAX_EDITOR_LINES: usize = 8;
#[derive(RegisterComponent)]
pub struct MessageEditor {
thread: Entity<Thread>,
@@ -88,9 +93,6 @@ pub struct MessageEditor {
_subscriptions: Vec<Subscription>,
}
const MIN_EDITOR_LINES: usize = 4;
const MAX_EDITOR_LINES: usize = 8;
pub(crate) fn create_editor(
workspace: WeakEntity<Workspace>,
context_store: WeakEntity<ContextStore>,
@@ -132,6 +134,7 @@ pub(crate) fn create_editor(
placement: Some(ContextMenuPlacement::Above),
});
editor.register_addon(ContextCreasesAddon::new());
editor.register_addon(MessageEditorAddon::new());
editor
});
@@ -609,7 +612,11 @@ impl MessageEditor {
)
}
fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
fn render_follow_toggle(
&self,
is_model_selected: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let following = self
.workspace
.read_with(cx, |workspace, _| {
@@ -618,6 +625,7 @@ impl MessageEditor {
.unwrap_or(false);
IconButton::new("follow-agent", IconName::Crosshair)
.disabled(!is_model_selected)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.toggle_state(following)
@@ -705,11 +713,11 @@ impl MessageEditor {
cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)),
)
.capture_action(cx.listener(Self::paste))
.gap_2()
.p_2()
.bg(editor_bg_color)
.gap_2()
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(editor_bg_color)
.child(
h_flex()
.justify_between()
@@ -786,7 +794,7 @@ impl MessageEditor {
.justify_between()
.child(
h_flex()
.child(self.render_follow_toggle(cx))
.child(self.render_follow_toggle(is_model_selected, cx))
.children(self.render_burn_mode_toggle(cx)),
)
.child(
@@ -902,6 +910,10 @@ impl MessageEditor {
.on_click({
let focus_handle = focus_handle.clone();
move |_event, window, cx| {
telemetry::event!(
"Agent Message Sent",
agent = "zed",
);
focus_handle.dispatch_action(
&Chat, window, cx,
);
@@ -1489,6 +1501,31 @@ pub struct ContextCreasesAddon {
_subscription: Option<Subscription>,
}
pub struct MessageEditorAddon {}
impl MessageEditorAddon {
pub fn new() -> Self {
Self {}
}
}
impl Addon for MessageEditorAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
}
fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
let settings = agent_settings::AgentSettings::get_global(cx);
if settings.use_modifier_to_send {
key_context.add("use_modifier_to_send");
}
}
}
impl Addon for ContextCreasesAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
@@ -1624,9 +1661,34 @@ impl Render for MessageEditor {
let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
let in_pro_trial = matches!(
self.user_store.read(cx).current_plan(),
Some(proto::Plan::ZedProTrial)
);
let pro_user = matches!(
self.user_store.read(cx).current_plan(),
Some(proto::Plan::ZedPro)
);
let configured_providers: Vec<(IconName, SharedString)> =
LanguageModelRegistry::read_global(cx)
.providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
})
.map(|provider| (provider.icon(), provider.name().0.clone()))
.collect();
let has_existing_providers = configured_providers.len() > 0;
v_flex()
.size_full()
.bg(cx.theme().colors().panel_background)
.when(
has_existing_providers && !in_pro_trial && !pro_user,
|this| this.child(cx.new(ApiKeysWithProviders::new)),
)
.when(changed_buffers.len() > 0, |parent| {
parent.child(self.render_edits_bar(&changed_buffers, window, cx))
})

View File

@@ -1,8 +1,6 @@
use crate::{
burn_mode_tooltip::BurnModeTooltip,
language_model_selector::{
LanguageModelSelector, ToggleModelSelector, language_model_selector,
},
language_model_selector::{LanguageModelSelector, language_model_selector},
};
use agent_settings::{AgentSettings, CompletionMode};
use anyhow::Result;
@@ -38,8 +36,7 @@ use language::{
language_settings::{SoftWrap, all_language_settings},
};
use language_model::{
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelProviderTosView,
LanguageModelRegistry, Role,
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
};
use multi_buffer::MultiBufferRow;
use picker::{Picker, popover_menu::PickerPopoverMenu};
@@ -74,6 +71,7 @@ use workspace::{
pane,
searchable::{SearchEvent, SearchableItem},
};
use zed_actions::agent::ToggleModelSelector;
use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
use assistant_context::{
@@ -1895,108 +1893,6 @@ impl TextThreadEditor {
.update(cx, |context, cx| context.summarize(true, cx));
}
fn render_notice(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
// This was previously gated behind the `zed-pro` feature flag. Since we
// aren't planning to ship that right now, we're just hard-coding this
// value to not show the nudge.
let nudge = Some(false);
let model_registry = LanguageModelRegistry::read_global(cx);
if nudge.map_or(false, |value| value) {
Some(
h_flex()
.p_3()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().editor_background)
.justify_between()
.child(
h_flex()
.gap_3()
.child(Icon::new(IconName::ZedAssistant).color(Color::Accent))
.child(Label::new("Zed AI is here! Get started by signing in →")),
)
.child(
Button::new("sign-in", "Sign in")
.size(ButtonSize::Compact)
.style(ButtonStyle::Filled)
.on_click(cx.listener(|this, _event, _window, cx| {
let client = this
.workspace
.read_with(cx, |workspace, _| workspace.client().clone())
.log_err();
if let Some(client) = client {
cx.spawn(async move |context_editor, cx| {
match client.authenticate_and_connect(true, cx).await {
util::ConnectionResult::Timeout => {
log::error!("Authentication timeout")
}
util::ConnectionResult::ConnectionReset => {
log::error!("Connection reset")
}
util::ConnectionResult::Result(r) => {
if r.log_err().is_some() {
context_editor
.update(cx, |_, cx| cx.notify())
.ok();
}
}
}
})
.detach()
}
})),
)
.into_any_element(),
)
} else if let Some(configuration_error) =
model_registry.configuration_error(model_registry.default_model(), cx)
{
Some(
h_flex()
.px_3()
.py_2()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().editor_background)
.justify_between()
.child(
h_flex()
.gap_3()
.child(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.child(Label::new(configuration_error.to_string())),
)
.child(
Button::new("open-configuration", "Configure Providers")
.size(ButtonSize::Compact)
.icon(Some(IconName::SlidersVertical))
.icon_size(IconSize::Small)
.icon_position(IconPosition::Start)
.style(ButtonStyle::Filled)
.on_click({
let focus_handle = self.focus_handle(cx).clone();
move |_event, window, cx| {
focus_handle.dispatch_action(
&zed_actions::agent::OpenConfiguration,
window,
cx,
);
}
}),
)
.into_any_element(),
)
} else {
None
}
}
fn render_send_button(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx).clone();
@@ -2128,12 +2024,13 @@ impl TextThreadEditor {
.map(|default| default.model);
let model_name = match active_model {
Some(model) => model.name().0,
None => SharedString::from("No model selected"),
None => SharedString::from("Select Model"),
};
let active_provider = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.provider);
let provider_icon = match active_provider {
Some(provider) => provider.icon(),
None => IconName::Ai,
@@ -2581,20 +2478,7 @@ impl EventEmitter<SearchEvent> for TextThreadEditor {}
impl Render for TextThreadEditor {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let provider = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.provider);
let accept_terms = if self.show_accept_terms {
provider.as_ref().and_then(|provider| {
provider.render_accept_terms(LanguageModelProviderTosView::PromptEditorPopup, cx)
})
} else {
None
};
let language_model_selector = self.language_model_selector_menu_handle.clone();
let burn_mode_toggle = self.render_burn_mode_toggle(cx);
v_flex()
.key_context("ContextEditor")
@@ -2611,28 +2495,12 @@ impl Render for TextThreadEditor {
language_model_selector.toggle(window, cx);
})
.size_full()
.children(self.render_notice(cx))
.child(
div()
.flex_grow()
.bg(cx.theme().colors().editor_background)
.child(self.editor.clone()),
)
.when_some(accept_terms, |this, element| {
this.child(
div()
.absolute()
.right_3()
.bottom_12()
.max_w_96()
.py_2()
.px_3()
.elevation_2(cx)
.bg(cx.theme().colors().surface_background)
.occlude()
.child(element),
)
})
.children(self.render_last_error(cx))
.child(
h_flex()
@@ -2649,7 +2517,7 @@ impl Render for TextThreadEditor {
h_flex()
.gap_0p5()
.child(self.render_inject_context_menu(cx))
.when_some(burn_mode_toggle, |this, element| this.child(element)),
.children(self.render_burn_mode_toggle(cx)),
)
.child(
h_flex()

View File

@@ -1,6 +1,8 @@
mod agent_notification;
mod burn_mode_tooltip;
mod context_pill;
mod end_trial_upsell;
mod new_thread_button;
mod onboarding_modal;
pub mod preview;
mod upsell;
@@ -8,4 +10,6 @@ mod upsell;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use context_pill::*;
pub use end_trial_upsell::*;
pub use new_thread_button::*;
pub use onboarding_modal::*;

View File

@@ -0,0 +1,112 @@
use std::sync::Arc;
use ai_onboarding::{AgentPanelOnboardingCard, BulletItem};
use client::zed_urls;
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
use ui::{Divider, List, prelude::*};
#[derive(IntoElement, RegisterComponent)]
pub struct EndTrialUpsell {
dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>,
}
impl EndTrialUpsell {
pub fn new(dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>) -> Self {
Self { dismiss_upsell }
}
}
impl RenderOnce for EndTrialUpsell {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let pro_section = v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Pro")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new("500 prompts per month with Claude models"))
.child(BulletItem::new("Unlimited edit predictions")),
)
.child(
Button::new("cta-button", "Upgrade to Zed Pro")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))),
);
let free_section = v_flex()
.mt_1p5()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Free")
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new(
"50 prompts per month with the Claude models",
))
.child(BulletItem::new(
"2000 accepted edit predictions using our open-source Zeta model",
)),
)
.child(
Button::new("dismiss-button", "Stay on Free")
.full_width()
.style(ButtonStyle::Outlined)
.on_click({
let callback = self.dismiss_upsell.clone();
move |_, window, cx| callback(window, cx)
}),
);
AgentPanelOnboardingCard::new()
.child(Headline::new("Your Zed Pro trial has expired."))
.child(
Label::new("You've been automatically reset to the Free plan.")
.size(LabelSize::Small)
.color(Color::Muted)
.mb_1(),
)
.child(pro_section)
.child(free_section)
}
}
impl Component for EndTrialUpsell {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn sort_name() -> &'static str {
"AgentEndTrialUpsell"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.p_4()
.gap_4()
.child(EndTrialUpsell {
dismiss_upsell: Arc::new(|_, _| {}),
})
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,75 @@
use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, Styled};
use ui::prelude::*;
#[derive(IntoElement)]
pub struct NewThreadButton {
id: ElementId,
label: SharedString,
icon: IconName,
keybinding: Option<ui::KeyBinding>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl NewThreadButton {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, icon: IconName) -> Self {
Self {
id: id.into(),
label: label.into(),
icon,
keybinding: None,
on_click: None,
}
}
pub fn keybinding(mut self, keybinding: Option<ui::KeyBinding>) -> Self {
self.keybinding = keybinding;
self
}
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&mut Window, &mut App) + 'static,
{
self.on_click = Some(Box::new(
move |_: &ClickEvent, window: &mut Window, cx: &mut App| handler(window, cx),
));
self
}
}
impl RenderOnce for NewThreadButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.w_full()
.py_1p5()
.px_2()
.gap_1()
.justify_between()
.rounded_md()
.border_1()
.border_color(cx.theme().colors().border.opacity(0.4))
.bg(cx.theme().colors().element_active.opacity(0.2))
.hover(|style| {
style
.bg(cx.theme().colors().element_hover)
.border_color(cx.theme().colors().border)
})
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(self.icon)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(Label::new(self.label).size(LabelSize::Small)),
)
.when_some(self.keybinding, |this, keybinding| {
this.child(keybinding.size(rems_from_px(10.)))
})
.when_some(self.on_click, |this, on_click| {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
}
}

View File

@@ -0,0 +1,28 @@
[package]
name = "ai_onboarding"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/ai_onboarding.rs"
[features]
default = []
[dependencies]
client.workspace = true
component.workspace = true
gpui.workspace = true
language_model.workspace = true
proto.workspace = true
serde.workspace = true
smallvec.workspace = true
telemetry.workspace = true
ui.workspace = true
workspace-hack.workspace = true
zed_actions.workspace = true

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,146 @@
use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use ui::{Divider, List, prelude::*};
use crate::BulletItem;
pub struct ApiKeysWithProviders {
configured_providers: Vec<(IconName, SharedString)>,
}
impl ApiKeysWithProviders {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.subscribe(
&LanguageModelRegistry::global(cx),
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
this.configured_providers = Self::compute_configured_providers(cx)
}
_ => {}
},
)
.detach();
Self {
configured_providers: Self::compute_configured_providers(cx),
}
}
fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> {
LanguageModelRegistry::read_global(cx)
.providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
})
.map(|provider| (provider.icon(), provider.name().0.clone()))
.collect()
}
}
impl Render for ApiKeysWithProviders {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let configured_providers_list =
self.configured_providers
.iter()
.cloned()
.map(|(icon, name)| {
h_flex()
.gap_1p5()
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
.child(Label::new(name))
});
div()
.mx_2p5()
.p_1()
.pb_0()
.gap_2()
.rounded_t_lg()
.border_t_1()
.border_x_1()
.border_color(cx.theme().colors().border.opacity(0.5))
.bg(cx.theme().colors().background.alpha(0.5))
.shadow(vec![gpui::BoxShadow {
color: gpui::black().opacity(0.15),
offset: point(px(1.), px(-1.)),
blur_radius: px(3.),
spread_radius: px(0.),
}])
.child(
h_flex()
.px_2p5()
.py_1p5()
.gap_2()
.flex_wrap()
.rounded_t(px(5.))
.overflow_hidden()
.border_t_1()
.border_x_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().panel_background)
.child(
h_flex()
.min_w_0()
.gap_2()
.child(
Icon::new(IconName::Info)
.size(IconSize::XSmall)
.color(Color::Muted)
)
.child(
div()
.w_full()
.child(
Label::new("Start now using API keys from your environment for the following providers:")
.color(Color::Muted)
)
)
)
.children(configured_providers_list)
)
}
}
#[derive(IntoElement)]
pub struct ApiKeysWithoutProviders;
impl ApiKeysWithoutProviders {
pub fn new() -> Self {
Self
}
}
impl RenderOnce for ApiKeysWithoutProviders {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.mt_2()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("API Keys")
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(List::new().child(BulletItem::new(
"Add your own keys to use AI without signing in.",
)))
.child(
Button::new("configure-providers", "Configure Providers")
.full_width()
.style(ButtonStyle::Outlined)
.on_click(move |_, window, cx| {
window.dispatch_action(
zed_actions::agent::OpenConfiguration.boxed_clone(),
cx,
);
}),
)
}
}

View File

@@ -0,0 +1,83 @@
use gpui::{AnyElement, IntoElement, ParentElement, linear_color_stop, linear_gradient};
use smallvec::SmallVec;
use ui::{Vector, VectorName, prelude::*};
#[derive(IntoElement)]
pub struct AgentPanelOnboardingCard {
children: SmallVec<[AnyElement; 2]>,
}
impl AgentPanelOnboardingCard {
pub fn new() -> Self {
Self {
children: SmallVec::new(),
}
}
}
impl ParentElement for AgentPanelOnboardingCard {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for AgentPanelOnboardingCard {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.m_2p5()
.p(px(3.))
.elevation_2(cx)
.rounded_lg()
.bg(cx.theme().colors().background.alpha(0.5))
.child(
v_flex()
.relative()
.size_full()
.px_4()
.py_3()
.gap_2()
.border_1()
.rounded(px(5.))
.border_color(cx.theme().colors().text.alpha(0.1))
.overflow_hidden()
.bg(cx.theme().colors().panel_background)
.child(
div()
.opacity(0.5)
.absolute()
.top(px(-8.0))
.right_0()
.w(px(400.))
.h(px(92.))
.rounded_md()
.child(
Vector::new(
VectorName::AiGrid,
rems_from_px(400.),
rems_from_px(92.),
)
.color(Color::Custom(cx.theme().colors().text.alpha(0.32))),
),
)
.child(
div()
.absolute()
.top_0p5()
.right_0p5()
.w(px(660.))
.h(px(401.))
.overflow_hidden()
.rounded_md()
.bg(linear_gradient(
75.,
linear_color_stop(
cx.theme().colors().panel_background.alpha(0.01),
1.0,
),
linear_color_stop(cx.theme().colors().panel_background, 0.45),
)),
)
.children(self.children),
)
}
}

View File

@@ -0,0 +1,85 @@
use std::sync::Arc;
use client::{Client, UserStore};
use gpui::{Entity, IntoElement, ParentElement};
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use ui::prelude::*;
use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
pub struct AgentPanelOnboarding {
user_store: Entity<UserStore>,
client: Arc<Client>,
configured_providers: Vec<(IconName, SharedString)>,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
}
impl AgentPanelOnboarding {
pub fn new(
user_store: Entity<UserStore>,
client: Arc<Client>,
continue_with_zed_ai: impl Fn(&mut Window, &mut App) + 'static,
cx: &mut Context<Self>,
) -> Self {
cx.subscribe(
&LanguageModelRegistry::global(cx),
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
this.configured_providers = Self::compute_available_providers(cx)
}
_ => {}
},
)
.detach();
Self {
user_store,
client,
configured_providers: Self::compute_available_providers(cx),
continue_with_zed_ai: Arc::new(continue_with_zed_ai),
}
}
fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> {
LanguageModelRegistry::read_global(cx)
.providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
})
.map(|provider| (provider.icon(), provider.name().0.clone()))
.collect()
}
}
impl Render for AgentPanelOnboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let enrolled_in_trial = matches!(
self.user_store.read(cx).current_plan(),
Some(proto::Plan::ZedProTrial)
);
AgentPanelOnboardingCard::new()
.child(
ZedAiOnboarding::new(
self.client.clone(),
&self.user_store,
self.continue_with_zed_ai.clone(),
cx,
)
.with_dismiss({
let callback = self.continue_with_zed_ai.clone();
move |window, cx| callback(window, cx)
}),
)
.map(|this| {
if enrolled_in_trial || self.configured_providers.len() >= 1 {
this
} else {
this.child(ApiKeysWithoutProviders::new())
}
})
}
}

View File

@@ -0,0 +1,480 @@
mod agent_api_keys_onboarding;
mod agent_panel_onboarding_card;
mod agent_panel_onboarding_content;
mod edit_prediction_onboarding_content;
mod young_account_banner;
pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders};
pub use agent_panel_onboarding_card::AgentPanelOnboardingCard;
pub use agent_panel_onboarding_content::AgentPanelOnboarding;
pub use edit_prediction_onboarding_content::EditPredictionOnboarding;
pub use young_account_banner::YoungAccountBanner;
use std::sync::Arc;
use client::{Client, UserStore, zed_urls};
use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString};
use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*};
pub struct BulletItem {
label: SharedString,
}
impl BulletItem {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
}
}
}
impl IntoElement for BulletItem {
type Element = AnyElement;
fn into_element(self) -> Self::Element {
ListItem::new("list-item")
.selectable(false)
.start_slot(
Icon::new(IconName::Dash)
.size(IconSize::XSmall)
.color(Color::Hidden),
)
.child(div().w_full().child(Label::new(self.label)))
.into_any_element()
}
}
pub enum SignInStatus {
SignedIn,
SigningIn,
SignedOut,
}
impl From<client::Status> for SignInStatus {
fn from(status: client::Status) -> Self {
if status.is_signing_in() {
Self::SigningIn
} else if status.is_signed_out() {
Self::SignedOut
} else {
Self::SignedIn
}
}
}
#[derive(RegisterComponent, IntoElement)]
pub struct ZedAiOnboarding {
pub sign_in_status: SignInStatus,
pub has_accepted_terms_of_service: bool,
pub plan: Option<proto::Plan>,
pub account_too_young: bool,
pub continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
pub accept_terms_of_service: Arc<dyn Fn(&mut Window, &mut App)>,
pub dismiss_onboarding: Option<Arc<dyn Fn(&mut Window, &mut App)>>,
}
impl ZedAiOnboarding {
pub fn new(
client: Arc<Client>,
user_store: &Entity<UserStore>,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
cx: &mut App,
) -> Self {
let store = user_store.read(cx);
let status = *client.status().borrow();
Self {
sign_in_status: status.into(),
has_accepted_terms_of_service: store.current_user_has_accepted_terms().unwrap_or(false),
plan: store.current_plan(),
account_too_young: store.account_too_young(),
continue_with_zed_ai,
accept_terms_of_service: Arc::new({
let store = user_store.clone();
move |_window, cx| {
let task = store.update(cx, |store, cx| store.accept_terms_of_service(cx));
task.detach_and_log_err(cx);
}
}),
sign_in: Arc::new(move |_window, cx| {
cx.spawn({
let client = client.clone();
async move |cx| {
client.authenticate_and_connect(true, cx).await;
}
})
.detach();
}),
dismiss_onboarding: None,
}
}
pub fn with_dismiss(
mut self,
dismiss_callback: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.dismiss_onboarding = Some(Arc::new(dismiss_callback));
self
}
fn free_plan_definition(&self, cx: &mut App) -> impl IntoElement {
v_flex()
.mt_2()
.gap_1()
.child(
h_flex()
.gap_2()
.child(
Label::new("Free")
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.child(
Label::new("(Current Plan)")
.size(LabelSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6)))
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new("50 prompts per month with Claude models"))
.child(BulletItem::new(
"2,000 accepted edit predictions with Zeta, our open-source model",
)),
)
}
fn pro_trial_definition(&self) -> impl IntoElement {
List::new()
.child(BulletItem::new("150 prompts with Claude models"))
.child(BulletItem::new(
"Unlimited accepted edit predictions with Zeta, our open-source model",
))
}
fn pro_plan_definition(&self, cx: &mut App) -> impl IntoElement {
v_flex().mt_2().gap_1().map(|this| {
if self.account_too_young {
this.child(
h_flex()
.gap_2()
.child(
Label::new("Pro")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new("500 prompts per month with Claude models"))
.child(BulletItem::new(
"Unlimited accepted edit predictions with Zeta, our open-source model",
))
.child(BulletItem::new("$20 USD per month")),
)
.child(
Button::new("pro", "Get Started")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!("Upgrade To Pro Clicked", state = "young-account");
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
}),
)
} else {
this.child(
h_flex()
.gap_2()
.child(
Label::new("Pro Trial")
.size(LabelSize::Small)
.color(Color::Accent)
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(self.pro_trial_definition())
.child(BulletItem::new(
"Try it out for 14 days for free, no credit card required",
)),
)
.child(
Button::new("pro", "Start Free Trial")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(move |_, _window, cx| {
telemetry::event!("Start Trial Clicked", state = "post-sign-in");
cx.open_url(&zed_urls::start_trial_url(cx))
}),
)
}
})
}
fn render_accept_terms_of_service(&self) -> AnyElement {
v_flex()
.gap_1()
.w_full()
.child(Headline::new("Accept Terms of Service"))
.child(
Label::new("We dont sell your data, track you across the web, or compromise your privacy.")
.color(Color::Muted)
.mb_2(),
)
.child(
Button::new("terms_of_service", "Review Terms of Service")
.full_width()
.style(ButtonStyle::Outlined)
.icon(IconName::ArrowUpRight)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.on_click(move |_, _window, cx| {
telemetry::event!("Review Terms of Service Clicked");
cx.open_url(&zed_urls::terms_of_service(cx))
}),
)
.child(
Button::new("accept_terms", "Accept")
.full_width()
.style(ButtonStyle::Tinted(TintColor::Accent))
.on_click({
let callback = self.accept_terms_of_service.clone();
move |_, window, cx| {
telemetry::event!("Terms of Service Accepted");
(callback)(window, cx)}
}),
)
.into_any_element()
}
fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement {
let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
v_flex()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.child(
Label::new("Sign in to try Zed Pro for 14 days, no credit card required.")
.color(Color::Muted)
.mb_2(),
)
.child(self.pro_trial_definition())
.child(
Button::new("sign_in", "Try Zed Pro for Free")
.disabled(signing_in)
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click({
let callback = self.sign_in.clone();
move |_, window, cx| {
telemetry::event!("Start Trial Clicked", state = "pre-sign-in");
callback(window, cx)
}
}),
)
.into_any_element()
}
fn render_free_plan_state(&self, cx: &mut App) -> AnyElement {
let young_account_banner = YoungAccountBanner;
v_flex()
.relative()
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.map(|this| {
if self.account_too_young {
this.child(young_account_banner)
} else {
this.child(self.free_plan_definition(cx)).when_some(
self.dismiss_onboarding.as_ref(),
|this, dismiss_callback| {
let callback = dismiss_callback.clone();
this.child(
h_flex().absolute().top_0().right_0().child(
IconButton::new("dismiss_onboarding", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click(move |_, window, cx| {
telemetry::event!(
"Banner Dismissed",
source = "AI Onboarding",
);
callback(window, cx)
}),
),
)
},
)
}
})
.child(self.pro_plan_definition(cx))
.into_any_element()
}
fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
v_flex()
.relative()
.gap_1()
.child(Headline::new("Welcome to the Zed Pro Trial"))
.child(
Label::new("Here's what you get for the next 14 days:")
.color(Color::Muted)
.mb_2(),
)
.child(
List::new()
.child(BulletItem::new("150 prompts with Claude models"))
.child(BulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
)),
)
.when_some(
self.dismiss_onboarding.as_ref(),
|this, dismiss_callback| {
let callback = dismiss_callback.clone();
this.child(
h_flex().absolute().top_0().right_0().child(
IconButton::new("dismiss_onboarding", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click(move |_, window, cx| {
telemetry::event!(
"Banner Dismissed",
source = "AI Onboarding",
);
callback(window, cx)
}),
),
)
},
)
.into_any_element()
}
fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
v_flex()
.gap_1()
.child(Headline::new("Welcome to Zed Pro"))
.child(
Label::new("Here's what you get:")
.color(Color::Muted)
.mb_2(),
)
.child(
List::new()
.child(BulletItem::new("500 prompts with Claude models"))
.child(BulletItem::new("Unlimited edit predictions")),
)
.child(
Button::new("pro", "Continue with Zed Pro")
.full_width()
.style(ButtonStyle::Outlined)
.on_click({
let callback = self.continue_with_zed_ai.clone();
move |_, window, cx| {
telemetry::event!("Banner Dismissed", source = "AI Onboarding");
callback(window, cx)
}
}),
)
.into_any_element()
}
}
impl RenderOnce for ZedAiOnboarding {
fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
if matches!(self.sign_in_status, SignInStatus::SignedIn) {
if self.has_accepted_terms_of_service {
match self.plan {
None | Some(proto::Plan::Free) => self.render_free_plan_state(cx),
Some(proto::Plan::ZedProTrial) => self.render_trial_state(cx),
Some(proto::Plan::ZedPro) => self.render_pro_plan_state(cx),
}
} else {
self.render_accept_terms_of_service()
}
} else {
self.render_sign_in_disclaimer(cx)
}
}
}
impl Component for ZedAiOnboarding {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
fn onboarding(
sign_in_status: SignInStatus,
has_accepted_terms_of_service: bool,
plan: Option<proto::Plan>,
account_too_young: bool,
) -> AnyElement {
ZedAiOnboarding {
sign_in_status,
has_accepted_terms_of_service,
plan,
account_too_young,
continue_with_zed_ai: Arc::new(|_, _| {}),
sign_in: Arc::new(|_, _| {}),
accept_terms_of_service: Arc::new(|_, _| {}),
dismiss_onboarding: None,
}
.into_any_element()
}
Some(
v_flex()
.p_4()
.gap_4()
.children(vec![
single_example(
"Not Signed-in",
onboarding(SignInStatus::SignedOut, false, None, false),
),
single_example(
"Not Accepted ToS",
onboarding(SignInStatus::SignedIn, false, None, false),
),
single_example(
"Account too young",
onboarding(SignInStatus::SignedIn, false, None, true),
),
single_example(
"Free Plan",
onboarding(SignInStatus::SignedIn, true, Some(proto::Plan::Free), false),
),
single_example(
"Pro Trial",
onboarding(
SignInStatus::SignedIn,
true,
Some(proto::Plan::ZedProTrial),
false,
),
),
single_example(
"Pro Plan",
onboarding(
SignInStatus::SignedIn,
true,
Some(proto::Plan::ZedPro),
false,
),
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,73 @@
use std::sync::Arc;
use client::{Client, UserStore};
use gpui::{Entity, IntoElement, ParentElement};
use ui::prelude::*;
use crate::ZedAiOnboarding;
pub struct EditPredictionOnboarding {
user_store: Entity<UserStore>,
client: Arc<Client>,
copilot_is_configured: bool,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
}
impl EditPredictionOnboarding {
pub fn new(
user_store: Entity<UserStore>,
client: Arc<Client>,
copilot_is_configured: bool,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
continue_with_copilot: Arc<dyn Fn(&mut Window, &mut App)>,
_cx: &mut Context<Self>,
) -> Self {
Self {
user_store,
copilot_is_configured,
client,
continue_with_zed_ai,
continue_with_copilot,
}
}
}
impl Render for EditPredictionOnboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let github_copilot = v_flex()
.gap_1()
.child(Label::new(if self.copilot_is_configured {
"Alternatively, you can continue to use GitHub Copilot as that's already set up."
} else {
"Alternatively, you can use GitHub Copilot as your edit prediction provider."
}))
.child(
Button::new(
"configure-copilot",
if self.copilot_is_configured {
"Use Copilot"
} else {
"Configure Copilot"
},
)
.full_width()
.style(ButtonStyle::Outlined)
.on_click({
let callback = self.continue_with_copilot.clone();
move |_, window, cx| callback(window, cx)
}),
);
v_flex()
.gap_2()
.child(ZedAiOnboarding::new(
self.client.clone(),
&self.user_store,
self.continue_with_zed_ai.clone(),
cx,
))
.child(ui::Divider::horizontal())
.child(github_copilot)
}
}

View File

@@ -0,0 +1,21 @@
use gpui::{IntoElement, ParentElement};
use ui::{Banner, prelude::*};
#[derive(IntoElement)]
pub struct YoungAccountBanner;
impl RenderOnce for YoungAccountBanner {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing-support@zed.dev.";
let label = div()
.w_full()
.text_sm()
.text_color(cx.theme().colors().text_muted)
.child(YOUNG_ACCOUNT_DISCLAIMER);
div()
.my_1()
.child(Banner::new().severity(ui::Severity::Warning).child(label))
}
}

View File

@@ -1323,7 +1323,7 @@ fn setup_context_editor_with_fake_model(
) -> (Entity<AssistantContext>, Arc<FakeLanguageModel>) {
let registry = Arc::new(LanguageRegistry::test(cx.executor().clone()));
let fake_provider = Arc::new(FakeLanguageModelProvider);
let fake_provider = Arc::new(FakeLanguageModelProvider::default());
let fake_model = Arc::new(fake_provider.test_model());
cx.update(|cx| {

View File

@@ -51,23 +51,13 @@ impl ActionLog {
Some(self.tracked_buffers.get(buffer)?.snapshot.clone())
}
pub fn has_unnotified_user_edits(&self) -> bool {
self.tracked_buffers
.values()
.any(|tracked| tracked.has_unnotified_user_edits)
}
/// Return a unified diff patch with user edits made since last read or notification
pub fn unnotified_user_edits(&self, cx: &Context<Self>) -> Option<String> {
if !self.has_unnotified_user_edits() {
return None;
}
let unified_diff = self
let diffs = self
.tracked_buffers
.values()
.filter_map(|tracked| {
if !tracked.has_unnotified_user_edits {
if !tracked.may_have_unnotified_user_edits {
return None;
}
@@ -95,9 +85,13 @@ impl ActionLog {
Some(result)
})
.collect::<Vec<_>>()
.join("\n\n");
.collect::<Vec<_>>();
if diffs.is_empty() {
return None;
}
let unified_diff = diffs.join("\n\n");
Some(unified_diff)
}
@@ -106,7 +100,7 @@ impl ActionLog {
pub fn flush_unnotified_user_edits(&mut self, cx: &Context<Self>) -> Option<String> {
let patch = self.unnotified_user_edits(cx);
self.tracked_buffers.values_mut().for_each(|tracked| {
tracked.has_unnotified_user_edits = false;
tracked.may_have_unnotified_user_edits = false;
tracked.last_seen_base = tracked.diff_base.clone();
});
patch
@@ -185,7 +179,7 @@ impl ActionLog {
version: buffer.read(cx).version(),
diff,
diff_update: diff_update_tx,
has_unnotified_user_edits: false,
may_have_unnotified_user_edits: false,
_open_lsp_handle: open_lsp_handle,
_maintain_diff: cx.spawn({
let buffer = buffer.clone();
@@ -331,32 +325,40 @@ impl ActionLog {
.get_mut(buffer)
.context("buffer not tracked")?;
if let ChangeAuthor::User = author {
tracked_buffer.has_unnotified_user_edits = true;
}
let rebase = cx.background_spawn({
let mut base_text = tracked_buffer.diff_base.clone();
let old_snapshot = tracked_buffer.snapshot.clone();
let new_snapshot = buffer_snapshot.clone();
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
let mut has_user_changes = false;
async move {
if let ChangeAuthor::User = author {
apply_non_conflicting_edits(
has_user_changes = apply_non_conflicting_edits(
&unreviewed_edits,
edits,
&mut base_text,
new_snapshot.as_rope(),
);
}
(Arc::new(base_text.to_string()), base_text)
(Arc::new(base_text.to_string()), base_text, has_user_changes)
}
});
anyhow::Ok(rebase)
})??;
let (new_base_text, new_diff_base) = rebase.await;
let (new_base_text, new_diff_base, has_user_changes) = rebase.await;
this.update(cx, |this, _| {
let tracked_buffer = this
.tracked_buffers
.get_mut(buffer)
.context("buffer not tracked")
.unwrap();
tracked_buffer.may_have_unnotified_user_edits |= has_user_changes;
})?;
Self::update_diff(
this,
buffer,
@@ -828,11 +830,12 @@ fn apply_non_conflicting_edits(
edits: Vec<Edit<u32>>,
old_text: &mut Rope,
new_text: &Rope,
) {
) -> bool {
let mut old_edits = patch.edits().iter().cloned().peekable();
let mut new_edits = edits.into_iter().peekable();
let mut applied_delta = 0i32;
let mut rebased_delta = 0i32;
let mut has_made_changes = false;
while let Some(mut new_edit) = new_edits.next() {
let mut conflict = false;
@@ -882,8 +885,10 @@ fn apply_non_conflicting_edits(
&new_text.chunks_in_range(new_bytes).collect::<String>(),
);
applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
has_made_changes = true;
}
}
has_made_changes
}
fn diff_snapshots(
@@ -957,7 +962,7 @@ struct TrackedBuffer {
diff: Entity<BufferDiff>,
snapshot: text::BufferSnapshot,
diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
has_unnotified_user_edits: bool,
may_have_unnotified_user_edits: bool,
_open_lsp_handle: OpenLspBufferHandle,
_maintain_diff: Task<()>,
_subscription: Subscription,

View File

@@ -20,6 +20,7 @@ anyhow.workspace = true
assistant_tool.workspace = true
buffer_diff.workspace = true
chrono.workspace = true
client.workspace = true
collections.workspace = true
component.workspace = true
derive_more.workspace = true

View File

@@ -20,14 +20,13 @@ mod thinking_tool;
mod ui;
mod web_search_tool;
use std::sync::Arc;
use assistant_tool::ToolRegistry;
use copy_path_tool::CopyPathTool;
use gpui::{App, Entity};
use http_client::HttpClientWithUrl;
use language_model::LanguageModelRegistry;
use move_path_tool::MovePathTool;
use std::sync::Arc;
use web_search_tool::WebSearchTool;
pub(crate) use templates::*;

View File

@@ -12,6 +12,7 @@ use collections::HashMap;
use fs::FakeFs;
use futures::{FutureExt, future::LocalBoxFuture};
use gpui::{AppContext, TestAppContext, Timer};
use http_client::StatusCode;
use indoc::{formatdoc, indoc};
use language_model::{
LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult,
@@ -1675,6 +1676,30 @@ async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) ->
Timer::after(retry_after + jitter).await;
continue;
}
LanguageModelCompletionError::UpstreamProviderError {
status,
retry_after,
..
} => {
// Only retry for specific status codes
let should_retry = matches!(
*status,
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
) || status.as_u16() == 529;
if !should_retry {
return Err(err.into());
}
// Use server-provided retry_after if available, otherwise use default
let retry_after = retry_after.unwrap_or(Duration::from_secs(5));
let jitter = retry_after.mul_f64(rand::thread_rng().gen_range(0.0..1.0));
eprintln!(
"Attempt #{attempt}: {err}. Retry after {retry_after:?} + jitter of {jitter:?}"
);
Timer::after(retry_after + jitter).await;
continue;
}
_ => return Err(err.into()),
},
Err(err) => return Err(err),

View File

@@ -278,6 +278,9 @@ impl Tool for EditFileTool {
.unwrap_or(false);
if format_on_save_enabled {
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx);
})?;
let format_task = project.update(cx, |project, cx| {
project.format(
HashSet::from_iter([buffer.clone()]),

View File

@@ -200,7 +200,7 @@ mod tests {
// Run the tool before any changes
let tool = Arc::new(ProjectNotificationsTool);
let provider = Arc::new(FakeLanguageModelProvider);
let provider = Arc::new(FakeLanguageModelProvider::default());
let model: Arc<dyn LanguageModel> = Arc::new(provider.test_model());
let request = Arc::new(LanguageModelRequest::default());
let tool_input = json!({});

View File

@@ -17,7 +17,5 @@ default = []
[dependencies]
aws-smithy-runtime-api.workspace = true
aws-smithy-types.workspace = true
futures.workspace = true
http_client.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
workspace-hack.workspace = true

View File

@@ -11,14 +11,11 @@ use aws_smithy_runtime_api::client::result::ConnectorError;
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
use aws_smithy_runtime_api::http::{Headers, StatusCode};
use aws_smithy_types::body::SdkBody;
use futures::AsyncReadExt;
use http_client::{AsyncBody, Inner};
use http_client::AsyncBody;
use http_client::{HttpClient, Request};
use tokio::runtime::Handle;
struct AwsHttpConnector {
client: Arc<dyn HttpClient>,
handle: Handle,
}
impl std::fmt::Debug for AwsHttpConnector {
@@ -42,18 +39,17 @@ impl AwsConnector for AwsHttpConnector {
.client
.send(Request::from_parts(parts, convert_to_async_body(body)));
let handle = self.handle.clone();
HttpConnectorFuture::new(async move {
let response = match response.await {
Ok(response) => response,
Err(err) => return Err(ConnectorError::other(err.into(), None)),
};
let (parts, body) = response.into_parts();
let body = convert_to_sdk_body(body, handle).await;
let mut response =
HttpResponse::new(StatusCode::try_from(parts.status.as_u16()).unwrap(), body);
let mut response = HttpResponse::new(
StatusCode::try_from(parts.status.as_u16()).unwrap(),
convert_to_sdk_body(body),
);
let headers = match Headers::try_from(parts.headers) {
Ok(headers) => headers,
@@ -70,7 +66,6 @@ impl AwsConnector for AwsHttpConnector {
#[derive(Clone)]
pub struct AwsHttpClient {
client: Arc<dyn HttpClient>,
handler: Handle,
}
impl std::fmt::Debug for AwsHttpClient {
@@ -80,11 +75,8 @@ impl std::fmt::Debug for AwsHttpClient {
}
impl AwsHttpClient {
pub fn new(client: Arc<dyn HttpClient>, handle: Handle) -> Self {
Self {
client,
handler: handle,
}
pub fn new(client: Arc<dyn HttpClient>) -> Self {
Self { client }
}
}
@@ -96,25 +88,12 @@ impl AwsClient for AwsHttpClient {
) -> SharedHttpConnector {
SharedHttpConnector::new(AwsHttpConnector {
client: self.client.clone(),
handle: self.handler.clone(),
})
}
}
pub async fn convert_to_sdk_body(body: AsyncBody, handle: Handle) -> SdkBody {
match body.0 {
Inner::Empty => SdkBody::empty(),
Inner::Bytes(bytes) => SdkBody::from(bytes.into_inner()),
Inner::AsyncReader(mut reader) => {
let buffer = handle.spawn(async move {
let mut buffer = Vec::new();
let _ = reader.read_to_end(&mut buffer).await;
buffer
});
SdkBody::from(buffer.await.unwrap_or_default())
}
}
pub fn convert_to_sdk_body(body: AsyncBody) -> SdkBody {
SdkBody::from_body_1_x(body)
}
pub fn convert_to_async_body(body: SdkBody) -> AsyncBody {

View File

@@ -343,8 +343,7 @@ impl BufferDiffInner {
..
} in hunks.iter().cloned()
{
let preceding_pending_hunks =
old_pending_hunks.slice(&buffer_range.start, Bias::Left, buffer);
let preceding_pending_hunks = old_pending_hunks.slice(&buffer_range.start, Bias::Left);
pending_hunks.append(preceding_pending_hunks, buffer);
// Skip all overlapping or adjacent old pending hunks
@@ -355,7 +354,7 @@ impl BufferDiffInner {
.cmp(&buffer_range.end, buffer)
.is_le()
}) {
old_pending_hunks.next(buffer);
old_pending_hunks.next();
}
if (stage && secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk)
@@ -379,10 +378,10 @@ impl BufferDiffInner {
);
}
// append the remainder
pending_hunks.append(old_pending_hunks.suffix(buffer), buffer);
pending_hunks.append(old_pending_hunks.suffix(), buffer);
let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::<DiffHunkSummary>(buffer);
unstaged_hunk_cursor.next(buffer);
unstaged_hunk_cursor.next();
// then, iterate over all pending hunks (both new ones and the existing ones) and compute the edits
let mut prev_unstaged_hunk_buffer_end = 0;
@@ -397,8 +396,7 @@ impl BufferDiffInner {
}) = pending_hunks_iter.next()
{
// Advance unstaged_hunk_cursor to skip unstaged hunks before current hunk
let skipped_unstaged =
unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left, buffer);
let skipped_unstaged = unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left);
if let Some(unstaged_hunk) = skipped_unstaged.last() {
prev_unstaged_hunk_base_text_end = unstaged_hunk.diff_base_byte_range.end;
@@ -425,7 +423,7 @@ impl BufferDiffInner {
buffer_offset_range.end =
buffer_offset_range.end.max(unstaged_hunk_offset_range.end);
unstaged_hunk_cursor.next(buffer);
unstaged_hunk_cursor.next();
continue;
}
}
@@ -514,7 +512,7 @@ impl BufferDiffInner {
});
let anchor_iter = iter::from_fn(move || {
cursor.next(buffer);
cursor.next();
cursor.item()
})
.flat_map(move |hunk| {
@@ -531,12 +529,12 @@ impl BufferDiffInner {
});
let mut pending_hunks_cursor = self.pending_hunks.cursor::<DiffHunkSummary>(buffer);
pending_hunks_cursor.next(buffer);
pending_hunks_cursor.next();
let mut secondary_cursor = None;
if let Some(secondary) = secondary.as_ref() {
let mut cursor = secondary.hunks.cursor::<DiffHunkSummary>(buffer);
cursor.next(buffer);
cursor.next();
secondary_cursor = Some(cursor);
}
@@ -564,7 +562,7 @@ impl BufferDiffInner {
.cmp(&pending_hunks_cursor.start().buffer_range.start, buffer)
.is_gt()
{
pending_hunks_cursor.seek_forward(&start_anchor, Bias::Left, buffer);
pending_hunks_cursor.seek_forward(&start_anchor, Bias::Left);
}
if let Some(pending_hunk) = pending_hunks_cursor.item() {
@@ -590,7 +588,7 @@ impl BufferDiffInner {
.cmp(&secondary_cursor.start().buffer_range.start, buffer)
.is_gt()
{
secondary_cursor.seek_forward(&start_anchor, Bias::Left, buffer);
secondary_cursor.seek_forward(&start_anchor, Bias::Left);
}
if let Some(secondary_hunk) = secondary_cursor.item() {
@@ -635,7 +633,7 @@ impl BufferDiffInner {
});
iter::from_fn(move || {
cursor.prev(buffer);
cursor.prev();
let hunk = cursor.item()?;
let range = hunk.buffer_range.to_point(buffer);
@@ -653,8 +651,8 @@ impl BufferDiffInner {
fn compare(&self, old: &Self, new_snapshot: &text::BufferSnapshot) -> Option<Range<Anchor>> {
let mut new_cursor = self.hunks.cursor::<()>(new_snapshot);
let mut old_cursor = old.hunks.cursor::<()>(new_snapshot);
old_cursor.next(new_snapshot);
new_cursor.next(new_snapshot);
old_cursor.next();
new_cursor.next();
let mut start = None;
let mut end = None;
@@ -669,7 +667,7 @@ impl BufferDiffInner {
Ordering::Less => {
start.get_or_insert(new_hunk.buffer_range.start);
end.replace(new_hunk.buffer_range.end);
new_cursor.next(new_snapshot);
new_cursor.next();
}
Ordering::Equal => {
if new_hunk != old_hunk {
@@ -686,25 +684,25 @@ impl BufferDiffInner {
}
}
new_cursor.next(new_snapshot);
old_cursor.next(new_snapshot);
new_cursor.next();
old_cursor.next();
}
Ordering::Greater => {
start.get_or_insert(old_hunk.buffer_range.start);
end.replace(old_hunk.buffer_range.end);
old_cursor.next(new_snapshot);
old_cursor.next();
}
}
}
(Some(new_hunk), None) => {
start.get_or_insert(new_hunk.buffer_range.start);
end.replace(new_hunk.buffer_range.end);
new_cursor.next(new_snapshot);
new_cursor.next();
}
(None, Some(old_hunk)) => {
start.get_or_insert(old_hunk.buffer_range.start);
end.replace(old_hunk.buffer_range.end);
old_cursor.next(new_snapshot);
old_cursor.next();
}
(None, None) => break,
}

View File

@@ -11,15 +11,18 @@ use client::{
use collections::{BTreeMap, HashMap, HashSet};
use fs::Fs;
use futures::{FutureExt, StreamExt};
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity};
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, ScreenCaptureSource,
ScreenCaptureStream, Task, WeakEntity,
};
use gpui_tokio::Tokio;
use language::LanguageRegistry;
use livekit::{LocalTrackPublication, ParticipantIdentity, RoomEvent};
use livekit_client::{self as livekit, TrackSid};
use livekit_client::{self as livekit, AudioStream, TrackSid};
use postage::{sink::Sink, stream::Stream, watch};
use project::Project;
use settings::Settings as _;
use std::{any::Any, future::Future, mem, rc::Rc, sync::Arc, time::Duration};
use std::{future::Future, mem, rc::Rc, sync::Arc, time::Duration};
use util::{ResultExt, TryFutureExt, post_inc};
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -1251,12 +1254,21 @@ impl Room {
})
}
pub fn is_screen_sharing(&self) -> bool {
pub fn is_sharing_screen(&self) -> bool {
self.live_kit.as_ref().map_or(false, |live_kit| {
!matches!(live_kit.screen_track, LocalTrack::None)
})
}
pub fn shared_screen_id(&self) -> Option<u64> {
self.live_kit.as_ref().and_then(|lk| match lk.screen_track {
LocalTrack::Published { ref _stream, .. } => {
_stream.metadata().ok().map(|meta| meta.id)
}
_ => None,
})
}
pub fn is_sharing_mic(&self) -> bool {
self.live_kit.as_ref().map_or(false, |live_kit| {
!matches!(live_kit.microphone_track, LocalTrack::None)
@@ -1369,11 +1381,15 @@ impl Room {
})
}
pub fn share_screen(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
pub fn share_screen(
&mut self,
source: Rc<dyn ScreenCaptureSource>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.status.is_offline() {
return Task::ready(Err(anyhow!("room is offline")));
}
if self.is_screen_sharing() {
if self.is_sharing_screen() {
return Task::ready(Err(anyhow!("screen was already shared")));
}
@@ -1386,20 +1402,8 @@ impl Room {
return Task::ready(Err(anyhow!("live-kit was not initialized")));
};
let sources = cx.screen_capture_sources();
cx.spawn(async move |this, cx| {
let sources = sources
.await
.map_err(|error| error.into())
.and_then(|sources| sources);
let source =
sources.and_then(|sources| sources.into_iter().next().context("no display found"));
let publication = match source {
Ok(source) => participant.publish_screenshare_track(&*source, cx).await,
Err(error) => Err(error),
};
let publication = participant.publish_screenshare_track(&*source, cx).await;
this.update(cx, |this, cx| {
let live_kit = this
@@ -1426,7 +1430,7 @@ impl Room {
} else {
live_kit.screen_track = LocalTrack::Published {
track_publication: publication,
_stream: Box::new(stream),
_stream: stream,
};
cx.notify();
}
@@ -1492,7 +1496,7 @@ impl Room {
}
}
pub fn unshare_screen(&mut self, cx: &mut Context<Self>) -> Result<()> {
pub fn unshare_screen(&mut self, play_sound: bool, cx: &mut Context<Self>) -> Result<()> {
anyhow::ensure!(!self.status.is_offline(), "room is offline");
let live_kit = self
@@ -1516,7 +1520,10 @@ impl Room {
cx.notify();
}
Audio::play_sound(Sound::StopScreenshare, cx);
if play_sound {
Audio::play_sound(Sound::StopScreenshare, cx);
}
Ok(())
}
}
@@ -1624,8 +1631,8 @@ fn spawn_room_connection(
struct LiveKitRoom {
room: Rc<livekit::Room>,
screen_track: LocalTrack,
microphone_track: LocalTrack,
screen_track: LocalTrack<dyn ScreenCaptureStream>,
microphone_track: LocalTrack<AudioStream>,
/// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user.
muted_by_user: bool,
deafened: bool,
@@ -1663,18 +1670,18 @@ impl LiveKitRoom {
}
}
enum LocalTrack {
enum LocalTrack<Stream: ?Sized> {
None,
Pending {
publish_id: usize,
},
Published {
track_publication: LocalTrackPublication,
_stream: Box<dyn Any>,
_stream: Box<Stream>,
},
}
impl Default for LocalTrack {
impl<T: ?Sized> Default for LocalTrack<T> {
fn default() -> Self {
Self::None
}

View File

@@ -333,7 +333,7 @@ impl ChannelChat {
if first_id <= message_id {
let mut cursor = chat.messages.cursor::<(ChannelMessageId, Count)>(&());
let message_id = ChannelMessageId::Saved(message_id);
cursor.seek(&message_id, Bias::Left, &());
cursor.seek(&message_id, Bias::Left);
return ControlFlow::Break(
if cursor
.item()
@@ -499,7 +499,7 @@ impl ChannelChat {
pub fn message(&self, ix: usize) -> &ChannelMessage {
let mut cursor = self.messages.cursor::<Count>(&());
cursor.seek(&Count(ix), Bias::Right, &());
cursor.seek(&Count(ix), Bias::Right);
cursor.item().unwrap()
}
@@ -516,13 +516,13 @@ impl ChannelChat {
pub fn messages_in_range(&self, range: Range<usize>) -> impl Iterator<Item = &ChannelMessage> {
let mut cursor = self.messages.cursor::<Count>(&());
cursor.seek(&Count(range.start), Bias::Right, &());
cursor.seek(&Count(range.start), Bias::Right);
cursor.take(range.len())
}
pub fn pending_messages(&self) -> impl Iterator<Item = &ChannelMessage> {
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
cursor.seek(&ChannelMessageId::Pending(0), Bias::Left, &());
cursor.seek(&ChannelMessageId::Pending(0), Bias::Left);
cursor
}
@@ -588,9 +588,9 @@ impl ChannelChat {
.collect::<HashSet<_>>();
let mut old_cursor = self.messages.cursor::<(ChannelMessageId, Count)>(&());
let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left, &());
let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left);
let start_ix = old_cursor.start().1.0;
let removed_messages = old_cursor.slice(&last_message.id, Bias::Right, &());
let removed_messages = old_cursor.slice(&last_message.id, Bias::Right);
let removed_count = removed_messages.summary().count;
let new_count = messages.summary().count;
let end_ix = start_ix + removed_count;
@@ -599,10 +599,10 @@ impl ChannelChat {
let mut ranges = Vec::<Range<usize>>::new();
if new_messages.last().unwrap().is_pending() {
new_messages.append(old_cursor.suffix(&()), &());
new_messages.append(old_cursor.suffix(), &());
} else {
new_messages.append(
old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left, &()),
old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left),
&(),
);
@@ -617,7 +617,7 @@ impl ChannelChat {
} else {
new_messages.push(message.clone(), &());
}
old_cursor.next(&());
old_cursor.next();
}
}
@@ -641,12 +641,12 @@ impl ChannelChat {
fn message_removed(&mut self, id: u64, cx: &mut Context<Self>) {
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left, &());
let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left);
if let Some(item) = cursor.item() {
if item.id == ChannelMessageId::Saved(id) {
let deleted_message_ix = messages.summary().count;
cursor.next(&());
messages.append(cursor.suffix(&()), &());
cursor.next();
messages.append(cursor.suffix(), &());
drop(cursor);
self.messages = messages;
@@ -680,7 +680,7 @@ impl ChannelChat {
cx: &mut Context<Self>,
) {
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
let mut messages = cursor.slice(&id, Bias::Left, &());
let mut messages = cursor.slice(&id, Bias::Left);
let ix = messages.summary().count;
if let Some(mut message_to_update) = cursor.item().cloned() {
@@ -688,10 +688,10 @@ impl ChannelChat {
message_to_update.mentions = mentions;
message_to_update.edited_at = edited_at;
messages.push(message_to_update, &());
cursor.next(&());
cursor.next();
}
messages.append(cursor.suffix(&()), &());
messages.append(cursor.suffix(), &());
drop(cursor);
self.messages = messages;

View File

@@ -151,6 +151,7 @@ impl Settings for ProxySettings {
pub fn init_settings(cx: &mut App) {
TelemetrySettings::register(cx);
DisableAiSettings::register(cx);
ClientSettings::register(cx);
ProxySettings::register(cx);
}
@@ -301,6 +302,13 @@ impl Status {
matches!(self, Self::Connected { .. })
}
pub fn is_signing_in(&self) -> bool {
matches!(
self,
Self::Authenticating | Self::Reauthenticating | Self::Connecting | Self::Reconnecting
)
}
pub fn is_signed_out(&self) -> bool {
matches!(self, Self::SignedOut | Self::UpgradeRequired)
}
@@ -541,6 +549,33 @@ impl settings::Settings for TelemetrySettings {
}
}
/// Whether to disable all AI features in Zed.
///
/// Default: false
#[derive(Copy, Clone, Debug)]
pub struct DisableAiSettings {
pub disable_ai: bool,
}
impl settings::Settings for DisableAiSettings {
const KEY: Option<&'static str> = Some("disable_ai");
type FileContent = Option<bool>;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
Ok(Self {
disable_ai: sources
.user
.or(sources.server)
.copied()
.flatten()
.unwrap_or(sources.default.ok_or_else(Self::missing_default)?),
})
}
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
}
impl Client {
pub fn new(
clock: Arc<dyn SystemClock>,

View File

@@ -764,6 +764,16 @@ impl UserStore {
}
pub fn current_plan(&self) -> Option<proto::Plan> {
#[cfg(debug_assertions)]
if let Ok(plan) = std::env::var("ZED_SIMULATE_ZED_PRO_PLAN").as_ref() {
return match plan.as_str() {
"free" => Some(proto::Plan::Free),
"trial" => Some(proto::Plan::ZedProTrial),
"pro" => Some(proto::Plan::ZedPro),
_ => None,
};
}
self.current_plan
}

View File

@@ -17,3 +17,21 @@ fn server_url(cx: &App) -> &str {
pub fn account_url(cx: &App) -> String {
format!("{server_url}/account", server_url = server_url(cx))
}
/// Returns the URL to the start trial page on zed.dev.
pub fn start_trial_url(cx: &App) -> String {
format!(
"{server_url}/account/start-trial",
server_url = server_url(cx)
)
}
/// Returns the URL to the upgrade page on zed.dev.
pub fn upgrade_to_zed_pro_url(cx: &App) -> String {
format!("{server_url}/account/upgrade", server_url = server_url(cx))
}
/// Returns the URL to Zed's terms of service.
pub fn terms_of_service(cx: &App) -> String {
format!("{server_url}/terms-of-service", server_url = server_url(cx))
}

View File

@@ -35,7 +35,7 @@ dashmap.workspace = true
derive_more.workspace = true
envy = "0.4.2"
futures.workspace = true
gpui = { workspace = true, features = ["screen-capture"] }
gpui.workspace = true
hex.workspace = true
http_client.workspace = true
jsonwebtoken.workspace = true

View File

@@ -11,7 +11,9 @@ use crate::{
db::{User, UserId},
rpc,
};
use ::rpc::proto;
use anyhow::Context as _;
use axum::extract;
use axum::{
Extension, Json, Router,
body::Body,
@@ -23,6 +25,7 @@ use axum::{
routing::{get, post},
};
use axum_extra::response::ErasedJson;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, OnceLock};
use tower::ServiceBuilder;
@@ -100,6 +103,8 @@ pub fn routes(rpc_server: Arc<rpc::Server>) -> Router<(), Body> {
.route("/user", get(update_or_create_authenticated_user))
.route("/users/look_up", get(look_up_user))
.route("/users/:id/access_tokens", post(create_access_token))
.route("/users/:id/refresh_llm_tokens", post(refresh_llm_tokens))
.route("/users/:id/update_plan", post(update_plan))
.route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
.merge(billing::router())
.merge(contributors::router())
@@ -334,3 +339,90 @@ async fn create_access_token(
encrypted_access_token,
}))
}
#[derive(Serialize)]
struct RefreshLlmTokensResponse {}
async fn refresh_llm_tokens(
Path(user_id): Path<UserId>,
Extension(rpc_server): Extension<Arc<rpc::Server>>,
) -> Result<Json<RefreshLlmTokensResponse>> {
rpc_server.refresh_llm_tokens_for_user(user_id).await;
Ok(Json(RefreshLlmTokensResponse {}))
}
#[derive(Debug, Serialize, Deserialize)]
struct UpdatePlanBody {
pub plan: zed_llm_client::Plan,
pub subscription_period: SubscriptionPeriod,
pub usage: zed_llm_client::CurrentUsage,
pub trial_started_at: Option<DateTime<Utc>>,
pub is_usage_based_billing_enabled: bool,
pub is_account_too_young: bool,
pub has_overdue_invoices: bool,
}
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
struct SubscriptionPeriod {
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
}
#[derive(Serialize)]
struct UpdatePlanResponse {}
async fn update_plan(
Path(user_id): Path<UserId>,
Extension(rpc_server): Extension<Arc<rpc::Server>>,
extract::Json(body): extract::Json<UpdatePlanBody>,
) -> Result<Json<UpdatePlanResponse>> {
let plan = match body.plan {
zed_llm_client::Plan::ZedFree => proto::Plan::Free,
zed_llm_client::Plan::ZedPro => proto::Plan::ZedPro,
zed_llm_client::Plan::ZedProTrial => proto::Plan::ZedProTrial,
};
let update_user_plan = proto::UpdateUserPlan {
plan: plan.into(),
trial_started_at: body
.trial_started_at
.map(|trial_started_at| trial_started_at.timestamp() as u64),
is_usage_based_billing_enabled: Some(body.is_usage_based_billing_enabled),
usage: Some(proto::SubscriptionUsage {
model_requests_usage_amount: body.usage.model_requests.used,
model_requests_usage_limit: Some(usage_limit_to_proto(body.usage.model_requests.limit)),
edit_predictions_usage_amount: body.usage.edit_predictions.used,
edit_predictions_usage_limit: Some(usage_limit_to_proto(
body.usage.edit_predictions.limit,
)),
}),
subscription_period: Some(proto::SubscriptionPeriod {
started_at: body.subscription_period.started_at.timestamp() as u64,
ended_at: body.subscription_period.ended_at.timestamp() as u64,
}),
account_too_young: Some(body.is_account_too_young),
has_overdue_invoices: Some(body.has_overdue_invoices),
};
rpc_server
.update_plan_for_user(user_id, update_user_plan)
.await?;
Ok(Json(UpdatePlanResponse {}))
}
fn usage_limit_to_proto(limit: zed_llm_client::UsageLimit) -> proto::UsageLimit {
proto::UsageLimit {
variant: Some(match limit {
zed_llm_client::UsageLimit::Limited(limit) => {
proto::usage_limit::Variant::Limited(proto::usage_limit::Limited {
limit: limit as u32,
})
}
zed_llm_client::UsageLimit::Unlimited => {
proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {})
}
}),
}
}

View File

@@ -1,527 +1,40 @@
use anyhow::{Context as _, bail};
use axum::routing::put;
use axum::{
Extension, Json, Router,
extract::{self, Query},
routing::{get, post},
};
use chrono::{DateTime, SecondsFormat, Utc};
use axum::{Extension, Json, Router, extract, routing::post};
use chrono::{DateTime, Utc};
use collections::{HashMap, HashSet};
use reqwest::StatusCode;
use sea_orm::ActiveValue;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{str::FromStr, sync::Arc, time::Duration};
use stripe::{
BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession,
CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion,
CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm,
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems,
CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents,
PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
};
use std::{sync::Arc, time::Duration};
use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus};
use util::{ResultExt, maybe};
use zed_llm_client::LanguageModelProvider;
use crate::api::events::SnowflakeRow;
use crate::db::billing_subscription::{
StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind,
};
use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
use crate::llm::db::subscription_usage_meter::{self, CompletionMode};
use crate::rpc::{ResultExt as _, Server};
use crate::stripe_client::{
StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription,
StripeSubscriptionId, UpdateCustomerParams,
StripeSubscriptionId,
};
use crate::{AppState, Error, Result};
use crate::{db::UserId, llm::db::LlmDatabase};
use crate::{
db::{
BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams,
CreateBillingCustomerParams, CreateBillingSubscriptionParams,
CreateProcessedStripeEventParams, UpdateBillingCustomerParams,
UpdateBillingPreferencesParams, UpdateBillingSubscriptionParams, billing_customer,
UpdateBillingSubscriptionParams, billing_customer,
},
stripe_billing::StripeBilling,
};
pub fn router() -> Router {
Router::new()
.route("/billing/preferences", put(update_billing_preferences))
.route("/billing/subscriptions", post(create_billing_subscription))
.route(
"/billing/subscriptions/manage",
post(manage_billing_subscription),
)
.route(
"/billing/subscriptions/sync",
post(sync_billing_subscription),
)
.route("/billing/usage", get(get_current_usage))
}
#[derive(Debug, Serialize)]
struct BillingPreferencesResponse {
trial_started_at: Option<String>,
max_monthly_llm_usage_spending_in_cents: i32,
model_request_overages_enabled: bool,
model_request_overages_spend_limit_in_cents: i32,
}
#[derive(Debug, Deserialize)]
struct UpdateBillingPreferencesBody {
github_user_id: i32,
#[serde(default)]
max_monthly_llm_usage_spending_in_cents: i32,
#[serde(default)]
model_request_overages_enabled: bool,
#[serde(default)]
model_request_overages_spend_limit_in_cents: i32,
}
async fn update_billing_preferences(
Extension(app): Extension<Arc<AppState>>,
Extension(rpc_server): Extension<Arc<crate::rpc::Server>>,
extract::Json(body): extract::Json<UpdateBillingPreferencesBody>,
) -> Result<Json<BillingPreferencesResponse>> {
let user = app
.db
.get_user_by_github_user_id(body.github_user_id)
.await?
.context("user not found")?;
let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
let max_monthly_llm_usage_spending_in_cents =
body.max_monthly_llm_usage_spending_in_cents.max(0);
let model_request_overages_spend_limit_in_cents =
body.model_request_overages_spend_limit_in_cents.max(0);
let billing_preferences =
if let Some(_billing_preferences) = app.db.get_billing_preferences(user.id).await? {
app.db
.update_billing_preferences(
user.id,
&UpdateBillingPreferencesParams {
max_monthly_llm_usage_spending_in_cents: ActiveValue::set(
max_monthly_llm_usage_spending_in_cents,
),
model_request_overages_enabled: ActiveValue::set(
body.model_request_overages_enabled,
),
model_request_overages_spend_limit_in_cents: ActiveValue::set(
model_request_overages_spend_limit_in_cents,
),
},
)
.await?
} else {
app.db
.create_billing_preferences(
user.id,
&crate::db::CreateBillingPreferencesParams {
max_monthly_llm_usage_spending_in_cents,
model_request_overages_enabled: body.model_request_overages_enabled,
model_request_overages_spend_limit_in_cents,
},
)
.await?
};
SnowflakeRow::new(
"Billing Preferences Updated",
Some(user.metrics_id),
user.admin,
None,
json!({
"user_id": user.id,
"model_request_overages_enabled": billing_preferences.model_request_overages_enabled,
"model_request_overages_spend_limit_in_cents": billing_preferences.model_request_overages_spend_limit_in_cents,
"max_monthly_llm_usage_spending_in_cents": billing_preferences.max_monthly_llm_usage_spending_in_cents,
}),
Router::new().route(
"/billing/subscriptions/sync",
post(sync_billing_subscription),
)
.write(&app.kinesis_client, &app.config.kinesis_stream)
.await
.log_err();
rpc_server.refresh_llm_tokens_for_user(user.id).await;
Ok(Json(BillingPreferencesResponse {
trial_started_at: billing_customer
.and_then(|billing_customer| billing_customer.trial_started_at)
.map(|trial_started_at| {
trial_started_at
.and_utc()
.to_rfc3339_opts(SecondsFormat::Millis, true)
}),
max_monthly_llm_usage_spending_in_cents: billing_preferences
.max_monthly_llm_usage_spending_in_cents,
model_request_overages_enabled: billing_preferences.model_request_overages_enabled,
model_request_overages_spend_limit_in_cents: billing_preferences
.model_request_overages_spend_limit_in_cents,
}))
}
#[derive(Debug, PartialEq, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ProductCode {
ZedPro,
ZedProTrial,
}
#[derive(Debug, Deserialize)]
struct CreateBillingSubscriptionBody {
github_user_id: i32,
product: ProductCode,
}
#[derive(Debug, Serialize)]
struct CreateBillingSubscriptionResponse {
checkout_session_url: String,
}
/// Initiates a Stripe Checkout session for creating a billing subscription.
async fn create_billing_subscription(
Extension(app): Extension<Arc<AppState>>,
extract::Json(body): extract::Json<CreateBillingSubscriptionBody>,
) -> Result<Json<CreateBillingSubscriptionResponse>> {
let user = app
.db
.get_user_by_github_user_id(body.github_user_id)
.await?
.context("user not found")?;
let Some(stripe_billing) = app.stripe_billing.clone() else {
log::error!("failed to retrieve Stripe billing object");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
if let Some(existing_subscription) = app.db.get_active_billing_subscription(user.id).await? {
let is_checkout_allowed = body.product == ProductCode::ZedProTrial
&& existing_subscription.kind == Some(SubscriptionKind::ZedFree);
if !is_checkout_allowed {
return Err(Error::http(
StatusCode::CONFLICT,
"user already has an active subscription".into(),
));
}
}
let existing_billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
if let Some(existing_billing_customer) = &existing_billing_customer {
if existing_billing_customer.has_overdue_invoices {
return Err(Error::http(
StatusCode::PAYMENT_REQUIRED,
"user has overdue invoices".into(),
));
}
}
let customer_id = if let Some(existing_customer) = &existing_billing_customer {
let customer_id = StripeCustomerId(existing_customer.stripe_customer_id.clone().into());
if let Some(email) = user.email_address.as_deref() {
stripe_billing
.client()
.update_customer(&customer_id, UpdateCustomerParams { email: Some(email) })
.await
// Update of email address is best-effort - continue checkout even if it fails
.context("error updating stripe customer email address")
.log_err();
}
customer_id
} else {
stripe_billing
.find_or_create_customer_by_email(user.email_address.as_deref())
.await?
};
let success_url = format!(
"{}/account?checkout_complete=1",
app.config.zed_dot_dev_url()
);
let checkout_session_url = match body.product {
ProductCode::ZedPro => {
stripe_billing
.checkout_with_zed_pro(&customer_id, &user.github_login, &success_url)
.await?
}
ProductCode::ZedProTrial => {
if let Some(existing_billing_customer) = &existing_billing_customer {
if existing_billing_customer.trial_started_at.is_some() {
return Err(Error::http(
StatusCode::FORBIDDEN,
"user already used free trial".into(),
));
}
}
let feature_flags = app.db.get_user_flags(user.id).await?;
stripe_billing
.checkout_with_zed_pro_trial(
&customer_id,
&user.github_login,
feature_flags,
&success_url,
)
.await?
}
};
Ok(Json(CreateBillingSubscriptionResponse {
checkout_session_url,
}))
}
#[derive(Debug, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ManageSubscriptionIntent {
/// The user intends to manage their subscription.
///
/// This will open the Stripe billing portal without putting the user in a specific flow.
ManageSubscription,
/// The user intends to update their payment method.
UpdatePaymentMethod,
/// The user intends to upgrade to Zed Pro.
UpgradeToPro,
/// The user intends to cancel their subscription.
Cancel,
/// The user intends to stop the cancellation of their subscription.
StopCancellation,
}
#[derive(Debug, Deserialize)]
struct ManageBillingSubscriptionBody {
github_user_id: i32,
intent: ManageSubscriptionIntent,
/// The ID of the subscription to manage.
subscription_id: BillingSubscriptionId,
redirect_to: Option<String>,
}
#[derive(Debug, Serialize)]
struct ManageBillingSubscriptionResponse {
billing_portal_session_url: Option<String>,
}
/// Initiates a Stripe customer portal session for managing a billing subscription.
async fn manage_billing_subscription(
Extension(app): Extension<Arc<AppState>>,
extract::Json(body): extract::Json<ManageBillingSubscriptionBody>,
) -> Result<Json<ManageBillingSubscriptionResponse>> {
let user = app
.db
.get_user_by_github_user_id(body.github_user_id)
.await?
.context("user not found")?;
let Some(stripe_client) = app.real_stripe_client.clone() else {
log::error!("failed to retrieve Stripe client");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let Some(stripe_billing) = app.stripe_billing.clone() else {
log::error!("failed to retrieve Stripe billing object");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let customer = app
.db
.get_billing_customer_by_user_id(user.id)
.await?
.context("billing customer not found")?;
let customer_id = CustomerId::from_str(&customer.stripe_customer_id)
.context("failed to parse customer ID")?;
let subscription = app
.db
.get_billing_subscription_by_id(body.subscription_id)
.await?
.context("subscription not found")?;
let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id)
.context("failed to parse subscription ID")?;
if body.intent == ManageSubscriptionIntent::StopCancellation {
let updated_stripe_subscription = Subscription::update(
&stripe_client,
&subscription_id,
stripe::UpdateSubscription {
cancel_at_period_end: Some(false),
..Default::default()
},
)
.await?;
app.db
.update_billing_subscription(
subscription.id,
&UpdateBillingSubscriptionParams {
stripe_cancel_at: ActiveValue::set(
updated_stripe_subscription
.cancel_at
.and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0))
.map(|time| time.naive_utc()),
),
..Default::default()
},
)
.await?;
return Ok(Json(ManageBillingSubscriptionResponse {
billing_portal_session_url: None,
}));
}
let flow = match body.intent {
ManageSubscriptionIntent::ManageSubscription => None,
ManageSubscriptionIntent::UpgradeToPro => {
let zed_pro_price_id: stripe::PriceId =
stripe_billing.zed_pro_price_id().await?.try_into()?;
let zed_free_price_id: stripe::PriceId =
stripe_billing.zed_free_price_id().await?.try_into()?;
let stripe_subscription =
Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?;
let is_on_zed_pro_trial = stripe_subscription.status == SubscriptionStatus::Trialing
&& stripe_subscription.items.data.iter().any(|item| {
item.price
.as_ref()
.map_or(false, |price| price.id == zed_pro_price_id)
});
if is_on_zed_pro_trial {
let payment_methods = PaymentMethod::list(
&stripe_client,
&stripe::ListPaymentMethods {
customer: Some(stripe_subscription.customer.id()),
..Default::default()
},
)
.await?;
let has_payment_method = !payment_methods.data.is_empty();
if !has_payment_method {
return Err(Error::http(
StatusCode::BAD_REQUEST,
"missing payment method".into(),
));
}
// If the user is already on a Zed Pro trial and wants to upgrade to Pro, we just need to end their trial early.
Subscription::update(
&stripe_client,
&stripe_subscription.id,
stripe::UpdateSubscription {
trial_end: Some(stripe::Scheduled::now()),
..Default::default()
},
)
.await?;
return Ok(Json(ManageBillingSubscriptionResponse {
billing_portal_session_url: None,
}));
}
let subscription_item_to_update = stripe_subscription
.items
.data
.iter()
.find_map(|item| {
let price = item.price.as_ref()?;
if price.id == zed_free_price_id {
Some(item.id.clone())
} else {
None
}
})
.context("No subscription item to update")?;
Some(CreateBillingPortalSessionFlowData {
type_: CreateBillingPortalSessionFlowDataType::SubscriptionUpdateConfirm,
subscription_update_confirm: Some(
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm {
subscription: subscription.stripe_subscription_id,
items: vec![
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems {
id: subscription_item_to_update.to_string(),
price: Some(zed_pro_price_id.to_string()),
quantity: Some(1),
},
],
discounts: None,
},
),
..Default::default()
})
}
ManageSubscriptionIntent::UpdatePaymentMethod => Some(CreateBillingPortalSessionFlowData {
type_: CreateBillingPortalSessionFlowDataType::PaymentMethodUpdate,
after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
return_url: format!(
"{}{path}",
app.config.zed_dot_dev_url(),
path = body.redirect_to.unwrap_or_else(|| "/account".to_string())
),
}),
..Default::default()
}),
..Default::default()
}),
ManageSubscriptionIntent::Cancel => {
if subscription.kind == Some(SubscriptionKind::ZedFree) {
return Err(Error::http(
StatusCode::BAD_REQUEST,
"free subscription cannot be canceled".into(),
));
}
Some(CreateBillingPortalSessionFlowData {
type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,
after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
return_url: format!("{}/account", app.config.zed_dot_dev_url()),
}),
..Default::default()
}),
subscription_cancel: Some(
stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel {
subscription: subscription.stripe_subscription_id,
retention: None,
},
),
..Default::default()
})
}
ManageSubscriptionIntent::StopCancellation => unreachable!(),
};
let mut params = CreateBillingPortalSession::new(customer_id);
params.flow_data = flow;
let return_url = format!("{}/account", app.config.zed_dot_dev_url());
params.return_url = Some(&return_url);
let session = BillingPortalSession::create(&stripe_client, params).await?;
Ok(Json(ManageBillingSubscriptionResponse {
billing_portal_session_url: Some(session.url),
}))
}
#[derive(Debug, Deserialize)]
@@ -1014,7 +527,7 @@ async fn handle_customer_subscription_event(
// When the user's subscription changes, push down any changes to their plan.
rpc_server
.update_plan_for_user(billing_customer.user_id)
.update_plan_for_user_legacy(billing_customer.user_id)
.await
.trace_err();
@@ -1027,157 +540,6 @@ async fn handle_customer_subscription_event(
Ok(())
}
#[derive(Debug, Deserialize)]
struct GetCurrentUsageParams {
github_user_id: i32,
}
#[derive(Debug, Serialize)]
struct UsageCounts {
pub used: i32,
pub limit: Option<i32>,
pub remaining: Option<i32>,
}
#[derive(Debug, Serialize)]
struct ModelRequestUsage {
pub model: String,
pub mode: CompletionMode,
pub requests: i32,
}
#[derive(Debug, Serialize)]
struct CurrentUsage {
pub model_requests: UsageCounts,
pub model_request_usage: Vec<ModelRequestUsage>,
pub edit_predictions: UsageCounts,
}
#[derive(Debug, Default, Serialize)]
struct GetCurrentUsageResponse {
pub plan: String,
pub current_usage: Option<CurrentUsage>,
}
async fn get_current_usage(
Extension(app): Extension<Arc<AppState>>,
Query(params): Query<GetCurrentUsageParams>,
) -> Result<Json<GetCurrentUsageResponse>> {
let user = app
.db
.get_user_by_github_user_id(params.github_user_id)
.await?
.context("user not found")?;
let feature_flags = app.db.get_user_flags(user.id).await?;
let has_extended_trial = feature_flags
.iter()
.any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG);
let Some(llm_db) = app.llm_db.clone() else {
return Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"LLM database not available".into(),
));
};
let Some(subscription) = app.db.get_active_billing_subscription(user.id).await? else {
return Ok(Json(GetCurrentUsageResponse::default()));
};
let subscription_period = maybe!({
let period_start_at = subscription.current_period_start_at()?;
let period_end_at = subscription.current_period_end_at()?;
Some((period_start_at, period_end_at))
});
let Some((period_start_at, period_end_at)) = subscription_period else {
return Ok(Json(GetCurrentUsageResponse::default()));
};
let usage = llm_db
.get_subscription_usage_for_period(user.id, period_start_at, period_end_at)
.await?;
let plan = subscription
.kind
.map(Into::into)
.unwrap_or(zed_llm_client::Plan::ZedFree);
let model_requests_limit = match plan.model_requests_limit() {
zed_llm_client::UsageLimit::Limited(limit) => {
let limit = if plan == zed_llm_client::Plan::ZedProTrial && has_extended_trial {
1_000
} else {
limit
};
Some(limit)
}
zed_llm_client::UsageLimit::Unlimited => None,
};
let edit_predictions_limit = match plan.edit_predictions_limit() {
zed_llm_client::UsageLimit::Limited(limit) => Some(limit),
zed_llm_client::UsageLimit::Unlimited => None,
};
let Some(usage) = usage else {
return Ok(Json(GetCurrentUsageResponse {
plan: plan.as_str().to_string(),
current_usage: Some(CurrentUsage {
model_requests: UsageCounts {
used: 0,
limit: model_requests_limit,
remaining: model_requests_limit,
},
model_request_usage: Vec::new(),
edit_predictions: UsageCounts {
used: 0,
limit: edit_predictions_limit,
remaining: edit_predictions_limit,
},
}),
}));
};
let subscription_usage_meters = llm_db
.get_current_subscription_usage_meters_for_user(user.id, Utc::now())
.await?;
let model_request_usage = subscription_usage_meters
.into_iter()
.filter_map(|(usage_meter, _usage)| {
let model = llm_db.model_by_id(usage_meter.model_id).ok()?;
Some(ModelRequestUsage {
model: model.name.clone(),
mode: usage_meter.mode,
requests: usage_meter.requests,
})
})
.collect::<Vec<_>>();
Ok(Json(GetCurrentUsageResponse {
plan: plan.as_str().to_string(),
current_usage: Some(CurrentUsage {
model_requests: UsageCounts {
used: usage.model_requests,
limit: model_requests_limit,
remaining: model_requests_limit.map(|limit| (limit - usage.model_requests).max(0)),
},
model_request_usage,
edit_predictions: UsageCounts {
used: usage.edit_predictions,
limit: edit_predictions_limit,
remaining: edit_predictions_limit
.map(|limit| (limit - usage.edit_predictions).max(0)),
},
}),
}))
}
impl From<SubscriptionStatus> for StripeSubscriptionStatus {
fn from(value: SubscriptionStatus) -> Self {
match value {

View File

@@ -389,53 +389,58 @@ pub async fn post_panic(
}
}
let backtrace = if panic.backtrace.len() > 25 {
let total = panic.backtrace.len();
format!(
"{}\n and {} more",
panic
.backtrace
.iter()
.take(20)
.cloned()
.collect::<Vec<_>>()
.join("\n"),
total - 20
)
} else {
panic.backtrace.join("\n")
};
if !report_to_slack(&panic) {
return Ok(());
}
let backtrace_with_summary = panic.payload + "\n" + &backtrace;
if let Some(slack_panics_webhook) = app.config.slack_panics_webhook.clone() {
let backtrace = if panic.backtrace.len() > 25 {
let total = panic.backtrace.len();
format!(
"{}\n and {} more",
panic
.backtrace
.iter()
.take(20)
.cloned()
.collect::<Vec<_>>()
.join("\n"),
total - 20
)
} else {
panic.backtrace.join("\n")
};
let backtrace_with_summary = panic.payload + "\n" + &backtrace;
let version = if panic.release_channel == "nightly"
&& !panic.app_version.contains("remote-server")
&& let Some(sha) = panic.app_commit_sha
{
format!("Zed Nightly {}", sha.chars().take(7).collect::<String>())
} else {
panic.app_version
};
let payload = slack::WebhookBody::new(|w| {
w.add_section(|s| s.text(slack::Text::markdown("Panic request".to_string())))
.add_section(|s| {
s.add_field(slack::Text::markdown(format!(
"*Version:*\n {} ",
panic.app_version
)))
.add_field({
let hostname = app.config.blob_store_url.clone().unwrap_or_default();
let hostname = hostname.strip_prefix("https://").unwrap_or_else(|| {
hostname.strip_prefix("http://").unwrap_or_default()
});
s.add_field(slack::Text::markdown(format!("*Version:*\n {version} ",)))
.add_field({
let hostname = app.config.blob_store_url.clone().unwrap_or_default();
let hostname = hostname.strip_prefix("https://").unwrap_or_else(|| {
hostname.strip_prefix("http://").unwrap_or_default()
});
slack::Text::markdown(format!(
"*{} {}:*\n<https://{}.{}/{}.json|{}…>",
panic.os_name,
panic.os_version.unwrap_or_default(),
CRASH_REPORTS_BUCKET,
hostname,
incident_id,
incident_id.chars().take(8).collect::<String>(),
))
})
slack::Text::markdown(format!(
"*{} {}:*\n<https://{}.{}/{}.json|{}…>",
panic.os_name,
panic.os_version.unwrap_or_default(),
CRASH_REPORTS_BUCKET,
hostname,
incident_id,
incident_id.chars().take(8).collect::<String>(),
))
})
})
.add_rich_text(|r| r.add_preformatted(|p| p.add_text(backtrace_with_summary)))
});

View File

@@ -42,9 +42,6 @@ pub use tests::TestDb;
pub use ids::*;
pub use queries::billing_customers::{CreateBillingCustomerParams, UpdateBillingCustomerParams};
pub use queries::billing_preferences::{
CreateBillingPreferencesParams, UpdateBillingPreferencesParams,
};
pub use queries::billing_subscriptions::{
CreateBillingSubscriptionParams, UpdateBillingSubscriptionParams,
};

View File

@@ -1,21 +1,5 @@
use anyhow::Context as _;
use super::*;
#[derive(Debug)]
pub struct CreateBillingPreferencesParams {
pub max_monthly_llm_usage_spending_in_cents: i32,
pub model_request_overages_enabled: bool,
pub model_request_overages_spend_limit_in_cents: i32,
}
#[derive(Debug, Default)]
pub struct UpdateBillingPreferencesParams {
pub max_monthly_llm_usage_spending_in_cents: ActiveValue<i32>,
pub model_request_overages_enabled: ActiveValue<bool>,
pub model_request_overages_spend_limit_in_cents: ActiveValue<i32>,
}
impl Database {
/// Returns the billing preferences for the given user, if they exist.
pub async fn get_billing_preferences(
@@ -30,62 +14,4 @@ impl Database {
})
.await
}
/// Creates new billing preferences for the given user.
pub async fn create_billing_preferences(
&self,
user_id: UserId,
params: &CreateBillingPreferencesParams,
) -> Result<billing_preference::Model> {
self.transaction(|tx| async move {
let preferences = billing_preference::Entity::insert(billing_preference::ActiveModel {
user_id: ActiveValue::set(user_id),
max_monthly_llm_usage_spending_in_cents: ActiveValue::set(
params.max_monthly_llm_usage_spending_in_cents,
),
model_request_overages_enabled: ActiveValue::set(
params.model_request_overages_enabled,
),
model_request_overages_spend_limit_in_cents: ActiveValue::set(
params.model_request_overages_spend_limit_in_cents,
),
..Default::default()
})
.exec_with_returning(&*tx)
.await?;
Ok(preferences)
})
.await
}
/// Updates the billing preferences for the given user.
pub async fn update_billing_preferences(
&self,
user_id: UserId,
params: &UpdateBillingPreferencesParams,
) -> Result<billing_preference::Model> {
self.transaction(|tx| async move {
let preferences = billing_preference::Entity::update_many()
.set(billing_preference::ActiveModel {
max_monthly_llm_usage_spending_in_cents: params
.max_monthly_llm_usage_spending_in_cents
.clone(),
model_request_overages_enabled: params.model_request_overages_enabled.clone(),
model_request_overages_spend_limit_in_cents: params
.model_request_overages_spend_limit_in_cents
.clone(),
..Default::default()
})
.filter(billing_preference::Column::UserId.eq(user_id))
.exec_with_returning(&*tx)
.await?;
Ok(preferences
.into_iter()
.next()
.context("billing preferences not found")?)
})
.await
}
}

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