Compare commits

..

92 Commits

Author SHA1 Message Date
Cole Miller
554382a87e git: Rework side-by-side diff to use distinct buffers for the left-hand side (#44838)
This PR reworks the (still feature-gated) side-by-side diff view to use
a different approach to representing the multibuffers on the left- and
right-hand sides.

Previously, these two multibuffers used identical sets of buffers and
excerpts, and were made to behave differently by adding a new knob to
the multibuffer controlling how diffs are displayed. Specifically, the
left-hand side multibuffer would filter out the added range of each hunk
from the excerpts using a new `FilteredInsertedHunk` diff transform, and
the right-hand side would simply not show the deleted sides of expanded
hunks. This approach has some problems:

- Line numbers, and actions that navigate by line number, behaved
incorrectly for the left-hand side.
- Syntax highlighting and other features that use the buffer syntax tree
also behaved incorrectly for the left-hand side.

In this PR, we've switched to using independent buffers to build the
left-hand side. These buffers are constructed using the base texts for
the corresponding diffs, and their lifecycle is managed by `BufferDiff`.
The red "deleted" regions on the left-hand side are represented by
`BufferContent` diff transforms, not `DeletedHunk` transforms. This
means each excerpt on the left represents a contiguous slice of a single
buffer, which fixes the above issues by construction.

The tradeoff with this new approach is that we now have to manually
synchronize excerpt ranges from the right side to the left, which we do
using `BufferDiffSnapshot::row_to_base_text_row`.

Release Notes:

- N/A

---------

Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: HactarCE <6060305+HactarCE@users.noreply.github.com>
Co-authored-by: Miguel Raz Guzmán Macedo <miguel@zed.dev>
Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Cameron <cameron@zed.dev>
2025-12-31 20:02:45 -05:00
Haojian Wu
9677da9a60 docs: Remove reference to non-existing binding for tab switcher: toggle all (#45919)
There is no default binding for `{#kb tab_switcher::ToggleAll}`, so the
doc is rendered as:

<img width="1330" height="227" alt="image"
src="https://github.com/user-attachments/assets/a5ce2efd-69c4-4eb4-a28b-3fdb7825ce34"
/>

Release Notes:

- N/A
2025-12-31 22:59:14 +00:00
Haojian Wu
9e5546edc5 docs: Use inline code in debugger.md (#45920)
Release Notes:

- N/A
2025-12-31 22:56:39 +00:00
Haojian Wu
c7203f7a60 docs: Fix an incorrect code-snippet in multibuffers.md (#45918)
Release Notes:

- N/A
2025-12-31 23:54:18 +01:00
Haojian Wu
c87f12ee42 docs: Update workspace symbol search command in configuring-languages.md (#45906)
Release Notes:

- N/A

---------

Co-authored-by: Finn Evers <finn.evers@outlook.de>
2025-12-31 23:00:53 +01:00
Haojian Wu
084d01b5f0 docs: Fix project-wide diagnostic command in configuring-languages.md (#45905)
Release Notes:

- N/A

---------

Co-authored-by: Finn Evers <finn.evers@outlook.de>
2025-12-31 23:00:36 +01:00
Piotr Osiewicz
ed7360206f debugger: Fix breakpoint store RPC handlers not being registered correctly on SSH remotes (#44908)
Closes #36789

Release Notes:

- Fixed setting breakpoints on remotes

---------

Co-authored-by: Zed AI <ai@zed.dev>
2025-12-31 19:40:11 +01:00
Bennet Bo Fenner
9f599466b5 agent_servers: Fix process leaks after terminating ACP server (#45902)
Closes #45211

This ensures that all sub-processes that were launched by the ACP server
are terminated. One scenario where this is easily reproducible:
- Start a new Claude Code ACP session
- Submit a prompt
- While Claude-code is still responding, start a new session
- The `claude-code` subprocess is leaked from the previous session (The
Claude-code SDK runs the Claude-code binary in a sub process)

This PR fixes this by using process groups on Unix. 
It does not fix the process leaks on Windows yet (will follow up with
another PR)

Release Notes:

- Fixed an issue where subprocesses of ACP servers could be leaked after
starting a new session
2025-12-31 11:59:50 +00:00
Nick Cho
dffda91838 Fixes a doc link to the license detection code (#45894)
The existing link was 404-ing as the crate was renamed.

Not attached to any issue.

Release Notes:

- N/A
2025-12-31 10:07:05 +00:00
flou
6b3412232f python: Add support for uv run as the shebang line (#45881)
`uv run` can be used to specify a shebang for a Python script.

See:
https://docs.astral.sh/uv/guides/scripts/#using-a-shebang-to-create-an-executable-file

Release Notes:

- Added support for `uv run` in the shebang line to be interpreted as a
Python script
2025-12-31 01:54:05 +01:00
Danilo Leal
963cb2c200 settings_ui: Make font size settings use editable number fields (#45875)
Now that the edit mode in number fields is finally working well, we can
make the UX of editing font sizes much nicer because you can now type
inside the number field :)


https://github.com/user-attachments/assets/8df7c6ee-e82b-4e10-a175-e0ca5f1bab1f

Release Notes:

- settings UI: Improved the UX of editing font size fields as you can
now type the desired value as opposed to just using the
decrement/increment buttons.
2025-12-30 14:51:58 -03:00
Danilo Leal
0c7d639cbd ui: Add fixes to the NumberField edit mode (#45871)
Follow up to https://github.com/zed-industries/zed/pull/45447 and in
preparation to enable the edit mode in number field instances within the
settings UI. This PR fixes the editor in the number field capturing
focus automatically (unnecessary), tab index in the buttons and editor,
and other things.

Release Notes:

- N/A
2025-12-30 14:24:33 -03:00
Danilo Leal
189b5ff3a2 ui: Make docs aside in pickers and context menu render centered to its trigger (#45868)
This has been something we've wanted to do for a long time since docs
aside thus far have been hard-anchored either at the top or bottom of
the container that their trigger is sitting in. This PR introduces the
change so that they're centered with their trigger, regardless of
whether it's on a context menu or picker, by including a canvas element
in both the container and the trigger to calculate where the docs aside
should precisely sit on. Here's the result:


https://github.com/user-attachments/assets/8147ad05-1927-4353-991d-405631de67d0

Note that at the moment, docs aside are only visible through _hovering_,
and ideally, they should be available on both hover and selection
(keyboard nav). But I'll leave that for later.

Release Notes:

- N/A
2025-12-30 13:29:44 -03:00
Finn Evers
a3bf58d393 extension_ci: Improve title formatting (#45870)
Just some minor formatting touchups I noticed after the first rollout

Release Notes:

- N/A
2025-12-30 15:39:02 +00:00
Finn Evers
d844f5db30 extension_ci: Fix version bump not working (#45869)
I changed the runner sizes to a smaller but more recent image yesterday
and broke the version bumping in the process. This PR fixes this by
force installing the needed package.

Release Notes:

- N/A
2025-12-30 15:27:20 +00:00
Jason Lee
fc45f6e042 title_bar: Fix close button hover color on Windows (#45867)
Release Notes:

- Fix close button hover text color on Windows

| Before | After |
| --- | --- |
| <img width="166" height="137" alt="before"
src="https://github.com/user-attachments/assets/fcd225c4-c73f-4e21-8e8d-459395df27ec"
/> |<img width="188" height="156" alt="after"
src="https://github.com/user-attachments/assets/89f5dcba-d677-4eb9-a9b4-170eb9792cb2"
/> |


https://github.com/user-attachments/assets/27e88bf1-eddd-4ec8-a360-6a68eef484e0

And I also improved this file's implementation to merge the
`WindowsCaptionButtonIcon` into `WindowsCaptionButton`.
2025-12-30 15:17:17 +00:00
Piotr Osiewicz
843be766df project search: Make cancellation smoother (#45406)
- **search: Make search cancellation more responsive (again)**
- **Fix project benchmarks build**
- **Less scoping and lifetimes for workers**

Related to #45300

Release Notes:

- Project search will consume less resources immediately after
cancellation.

---------

Co-authored-by: Max Brunsfeld <max@zed.dev>
2025-12-30 15:11:05 +01:00
Danilo Leal
469ad070b0 ui: Add submenus to ContextMenu (#45743)
This PR introduces submenu functionality for Zed's dropdown/context
menus.

<img width="600" height="830" alt="Screenshot 2025-12-27 at 2  03@2x"
src="https://github.com/user-attachments/assets/eadfcd74-07fe-4a1f-be76-11b547c16dc8"
/>

```rs
.submenu("Trigger", |menu, _, _| {
    menu.entry("Item…", None, |_, _| {})
        .entry("Item…", None, |_, _| {})
})
```

Release Notes:

- N/A

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-30 07:49:01 -03:00
Kirill Bulatov
392b6184bf Fix worktree trust handling of multiple projects on the same remote host (#45834)
Closes https://github.com/zed-industries/zed/issues/45630

Remote host location alone is not enough to distinguish between remote
worktrees: different remote projects open in different windows will have
the same remote host location and _will_ have the same `WorktreeId`.

Thus, require an associated `WorktreeStore` with all
`WorktreeId`-related trust questions, and store those IDs based on the
store key.

Release Notes:

- Fixed worktree trust handling of multiple projects on the same remote
host
2025-12-29 20:21:59 +00:00
Finn Evers
fc89e19098 extension_ci: Move shared workflows into nested folder (#45828)
This makes the rollout as well as distinguishing this in the future
easier.

Release Notes:

- N/A
2025-12-29 16:44:47 +00:00
Finn Evers
f53b01d5a2 ci: Grant GitHub token more granular permissions (#45825)
Release Notes:

- N/A
2025-12-29 16:16:01 +00:00
Finn Evers
bf1c8819d9 ci: Properly request token for extension repositories (#45824)
Release Notes:

- N/A
2025-12-29 15:41:56 +00:00
Xiaobo Liu
3247264288 ui: Remove stray blank lines in ButtonStyle methods (#45822)
Release Notes:

- N/A
2025-12-29 14:23:49 +00:00
Danilo Leal
6d947b7746 ui: Add a CopyButton component (#45821)
There were several places adding a copy icon button, so thought of
encapsulating the logic to copy a given string into the clipboard (and
other small details like swapping the icon and tooltip if copied) into a
component, making it easier to introduce this sort of functionality in
the future, with fewer lines of code.

All it takes (for the simplest case) is:

```rs
CopyButton::new(your_message)
```

<img width="600" height="714" alt="Screenshot 2025-12-29 at 10  50@2x"
src="https://github.com/user-attachments/assets/e6949863-a056-4855-82d8-e4ffb5d62c90"
/>

Release Notes:

- N/A
2025-12-29 11:01:19 -03:00
Finn Evers
db221ca72d Add workflow to rollout changes to the extension organization (#45579)
This PR adds a workflow that we can utilize to rollout changes to the CI
workflows for the `zed-extensions` organization.

Release Notes:

- N/A
2025-12-29 14:51:17 +01:00
Finn Evers
1d006a8cb0 extension_ci: Specify needed permissions for jobs (#45542)
GitHub flags these as security vulnerabilities. Hence, this PR specifies
the needed permissions for the workflows used in the `zed-extensions`
organization.

Release Notes:

- N/A
2025-12-29 13:19:22 +00:00
Rocky Shi
aaab9f6960 Add a button to copy diagnostic messages from the hover popover to the clipboard (#45625)
Closes https://github.com/zed-industries/zed/issues/45346

Release Notes:

- Added a button to copy diagnostic messages from the hove popover

Screenshot:

<img width="842" height="360" alt="image"
src="https://github.com/user-attachments/assets/9c00fba5-82aa-4179-95b1-afd5c1173889"
/>

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-29 12:31:29 +00:00
Ahmed Hesham Abdelkader
209cf0a48f ui: Make long Callout descriptions scrollable (#45792)
Fixes #43306

Long error messages from LLM providers in the Agent Panel were not
scrollable, making it impossible to read the full error content.

Changes:
- Add max_h_48() and overflow_y_scroll() to description containers
- Add element IDs required for scroll functionality
- Add min_h_0() and overflow_hidden() to parent flex container
- Add component preview example demonstrating scrollable content

Release Notes:

- Fixed long error messages in Agent Panel being unreadable by making
them scrollable
([#43306](https://github.com/zed-industries/zed/issues/43306)).

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-29 12:03:50 +00:00
Murilo Cunha
260691c99c docs: Specify that dev containers are currently previeiw-only (#45816)
Thanks for the cool project and making it open source! Started using Zed
recently and I really enjoy it.

Made a tiny addition to devcontainer docs to specify the version. Wasn't
able to get it to work as shown in the
[docs](https://zed.dev/docs/dev-containers) (should "just work"). The
feature was introduced recently on [PR
44442](https://github.com/zed-industries/zed/pull/44442) and is only
available as of v0.218 (currently still in preview), while I was still
on the latest stable version.

So I thought of opening a small PR 😊 

Thanks again for the awesome project!

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
2025-12-29 11:34:52 +00:00
Danilo Leal
9e88f3f33c agent_ui: Fix issues with mention crease (#45683)
This PR introduces the `MentionCrease` component, aimed at solving two
issues with mention creases in the agent panel:
- Previously, the mention crease was using a button with a regular size,
which is bigger than the default buffer font line height. That made the
crease look clipped and also overlapping with one another when in a
multiple line scenario where the creases would be on top of each other.
`MentionCrease` uses the window line height value to set the button
height, with a small one pixel vertical padding just for a bit of
spacing.
- Previously, given the crease used a `Label`, its font size wouldn't
scale if you changed the `agent_buffer_font_size` setting. Now,
`MentionCrease` uses that font size value, which makes regular text and
its text grow together as you'd expect.

Release Notes:

- agent: Fix a bug where mention creases didn't scale with
`agent_buffer_font_size` and got clipped/jumbled when rendered one above
the other.
2025-12-29 08:27:43 -03:00
Gabe Shahbazian
2cad6c8ef1 svg_preview: Detect SVG in single-file mode by checking file name (#45747)
Release Notes:

- Use the files name for "is svg" checks so SVG previews and the toolbar
button work in single-file mode.
2025-12-27 23:29:36 +00:00
Ben Brandt
bc24ffe863 acp: Beta support for Session Config Options (#45751)
Adds beta support for the ACP draft feature of Session Config Options:
https://agentclientprotocol.com/rfds/session-config-options

Release Notes:

- N/A
2025-12-27 22:10:37 +00:00
Kirill Bulatov
1e4a970ae2 Bump glsl to 0.2.0 (#45744)
Includes https://github.com/zed-industries/zed/pull/45727

Release Notes:

- N/A
2025-12-27 19:09:52 +00:00
Thinkseal
3e656a0911 Add colorized brackets support for GLSL (#45727)
Closes #45674

before:
<img width="482" height="271" alt="Screenshot 2025-12-26 at 21 05 50"
src="https://github.com/user-attachments/assets/d1bba3e1-04f3-4b8d-a187-8da80cee7e22"
/>
after
<img width="481" height="244" alt="Screenshot 2025-12-26 at 21 06 23"
src="https://github.com/user-attachments/assets/4778c779-7082-4701-821e-b825b05b4097"
/>


Release Notes:

- Fixes colorized brackets for the GLSL extension

---------

Co-authored-by: Finn Evers <finn.evers@outlook.de>
2025-12-27 18:54:23 +00:00
Finn Evers
57ea23d161 editor: Fix active line number regressions with relative counting (#45741)
Follow-up to https://github.com/zed-industries/zed/pull/45164 which
caused the active line number to always be `0` instead of the actual
current line number. No release notes since its only on nightly

Release Notes:

- N/A
2025-12-27 16:26:08 +00:00
Kirill Bulatov
a50c5b2c10 Fix Zed OOM-ing when macOS file descriptors become invalid (#45669)
Closes https://github.com/zed-industries/zed/issues/42845

Repro steps:
https://github.com/zed-industries/zed/issues/42845#issuecomment-3687413958
Initial investigation and Zed memory trace:
https://github.com/zed-industries/zed/issues/42845#issuecomment-3687877977

The PR consists of 2 commits:
*
[first](732d308c8d)
adds cosmetic fixes to remove backtraces from logs yet again and print
paths in quotes, as file descriptors may return empty paths.
It also stubs the cause if OOM in project panel: that one traversed all
worktrees in `for worktree_snapshot in visible_worktrees` and "accepted"
the one with empty paths + never called `entry_iter.advance();` in "no
file name found for the worktree" case, thus looping endlessly and
bloating the memory quite fast.

*
[second](7ebfe5da2f)
adds something that resembles a fix: `fn current_path` on macOS used the
file handler to re-fetch the worktree root file path on worktree root
canonicalization failure.
What's odd, is that `libc::fcntl` returns `0` in the case when external
volume is not mounted, thus resulting in the `""` path string that is
propagated all the way up.

*
[third](1a7560cef3)
moves the fix down to the platform-related FS implementations

The "fix" now checks the only usage of this method inside `async fn
process_events` for an empty path and bails if that is the case.
I am not sure what is a better fix, but this stops any memory leaks and
given how bad the situation now, seems ok to merge for now with the
`TODO` comment for more clever people to fix properly later.

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

Now, when I disconnect the SMB share and reconnect it again, Zed stops
displaying any files in the project tree but the ones opened as editors.

As before, at first, when the share is unmounted, Zed fails to save any
changes because of the timeouts.

Later, when the share is re-connected, macOS Finder hangs still but Zed
starts to react on saves yet still only shows the files that are open as
editors.
The files can be edited and saved from now on.

Later, when Finder finally stops hanging and indicates that the share is
mounted fully, the rest of the file structure reappear in the project
panel, and all file saves are propagated, hence can be observed in the
share in Finder.

It feels that one good improvement to add on top is some "disconnected"
indicator that clearly shows that the file is not properly handles in
the OS.
This requires much more changes and thinking as nothing like that exists
in Zed yet, hence not done.

Release Notes:

- Fixed Zed OOM-ing when macOS file descriptors become invalid
2025-12-26 18:47:53 +00:00
Agus Zubiaga
f1b723973b mac: Delay initial find pasteboard search until ⌘G or ⌘F (#45605)
Follow up to https://github.com/zed-industries/zed/pull/45311. Instead
of searching for the string in the find pasteboard as soon as the pane
is focused, we will now wait until the search bar is either deployed or
`Select{Next|Prev}Match` is triggered.

Release Notes:

- N/A
2025-12-26 15:22:57 -03:00
Nathan Sobo
a7ce677ac3 Optimize terminal rendering when clipped by parent containers (#45537)
This brings the terminal element's viewport culling in line with the
editor optimization in PR #44995 and the fix in PR #45077.

## Problem

When a terminal is inside a scrollable container (e.g., the Agent Panel
thread view), it would render ALL cells during prepaint, even when the
terminal was entirely outside the viewport. This caused unnecessary CPU
usage when multiple terminal tool outputs existed in the Agent Panel.

## Solution

Calculate the intersection of the terminal's bounds with the current
content_mask (the visible viewport after all parent clipping). If the
intersection has zero area, skip all cell processing entirely.

### Three code paths

1. **Offscreen** (`intersection.size <= 0`): Early exit, process 0 cells
2. **Fully visible** (`intersection == bounds`): Fast path, stream cells
directly (no allocation)
3. **Partially clipped**: Group cells by line, skip/take visible rows
only

### Key insight: filter by screen position, not buffer coordinates

The previous approach tried to filter cells by `cell.point.line`
(terminal buffer coordinates), which breaks in Scrollable mode where
cells can have negative line numbers for scrollback history.

The new approach filters by **screen position** using
`chunk_by(line).skip(N).take(M)`, which works regardless of the actual
line numbers because we're filtering on enumerated line group index.

## Testing

Added comprehensive unit tests for:
- Screen-position filtering with positive lines (Inline mode)
- Screen-position filtering with negative lines (Scrollable mode with
scrollback)
- Edge cases (skip all, positioning math)
- Unified filtering works for both modes

Manually verified:
- Terminal fully visible (no clipping) ✓
- Terminal clipped from top/bottom ✓
- Terminal completely outside viewport ✓
- Scrollable terminals with scrollback history ✓
- Selection/interaction still works ✓

Release Notes:

- Improved Agent Panel performance when terminals are scrolled
offscreen.

/cc @as-cii
2025-12-26 11:20:40 -07:00
Finn Evers
ed67f246cb Fix formatting in json_schema_store.rs (#45698)
There are some too long lines which make `rustfmt` unable to format the
file, which in turn makes editing and working with this file rather
hard. This PR fixes this.

Release Notes:

- N/A
2025-12-26 18:10:33 +00:00
Marshall Bowers
93f29326c4 docs: Update link to Tree-sitter Query extension (#45697)
Release Notes:

- N/A
2025-12-26 17:53:40 +00:00
Haojian Wu
85f4681299 docs: Link to Tree-sitter query extension (#45682)
Release Notes:

- N/A
2025-12-26 17:43:18 +00:00
Finn Evers
741c5d5010 Revive "good first issue" notifier (#45679)
We adjusted the labels some time ago, but never took care of the `good
first issue` notifier that posts the good first issues to discord.

Adjusting the label accordingly so that it notifies people again.

Release Notes:

- N/A
2025-12-26 10:16:32 +00:00
Marco Mihai Condrache
f03987fb68 search: Remove intermediate allocation (#45633)
Release Notes:

- N/A

---------

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
2025-12-25 20:29:17 +01:00
Teoh Han Hui
ca47822667 Associate devcontainer.json with JSONC language (#45593)
Release Notes:

- N/A
2025-12-23 21:23:28 +00:00
Danilo Leal
a34fe06bb1 agent_ui: Allow "token reached" callout to be dismissed (#45595)
It was previously impossible to dismiss the "token usage
reaching/reached the limit" callout.

<img width="500" height="392" alt="Screenshot 2025-12-23 at 5  49@2x"
src="https://github.com/user-attachments/assets/7fd8b126-dd3f-430b-9fea-ca05c73e5643"
/>

Release Notes:

- N/A
2025-12-23 21:14:58 +00:00
Kirill Bulatov
0ce484e66c Do not trust Docker hosts by default (#45587)
It's still possible to leak secrets by spawning odd MCP/LSP servers from
`.zed/settings.json`

Release Notes:

- N/A
2025-12-23 19:27:09 +00:00
Kirill Bulatov
251033f88f Fix the argument order when starting devcontainers (#45584)
Release Notes:

- (Preview only) Fix devcontainers not starting when certain env
variables were set

Co-authored-by: KyleBarton <kjb@initialcapacity.io>
2025-12-23 19:10:51 +00:00
Xiaobo Liu
9f90c1a1b7 git_ui: Show copy-SHA button on commit header hover (#45478)
Release Notes:

- git: Added the ability to copy a commit's SHA in the commit view.

---------

Signed-off-by: Xiaobo Liu <cppcoffee@gmail.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-23 17:11:56 +00:00
Danilo Leal
d43cc46288 agent_ui: Add more items in the right-click context menu (#45575)
Follow up to https://github.com/zed-industries/zed/pull/45440 adding an
item for "Open Thread as Markdown" and another for scroll to top and
scroll to bottom.

<img width="500" height="646" alt="Screenshot 2025-12-23 at 1  12@2x"
src="https://github.com/user-attachments/assets/c82e26bb-c255-4d73-b733-ef6ea269fabe"
/>

Release Notes:

- N/A
2025-12-23 13:22:42 -03:00
Daniel Byiringiro
fdb8e71b43 docs: Remove reference to outdated curated issues board (#45568)
The documentation referenced a “Curated board of issues” GitHub Project
that no longer exists.
The linked project returns a 404, and only three public projects are
currently available under
zed-industries.

This PR removes the outdated reference. Documentation-only change.

Release Notes:

- N/A
2025-12-23 15:15:58 +00:00
zchira
6bc433ed43 agent_ui: Add right-click context menu to the thread view (#45440)
Closes #23158

Release Notes:

- Added a right-click context menu for the thread view in the agent
panel.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-23 12:09:46 -03:00
Danilo Leal
1281f4672c markdown: Add support for right-click menu copy item (#45572)
In https://github.com/zed-industries/zed/pull/45440, we're implementing
the ability to right-click in the agent panel and copy the rendered
markdown. However, that presented itself as not as straightforward as
just making the menu item fire the `CopyAsMarkdown` action because any
selection in markdown is cleared after a new mouse click, and for the
right-click copy menu item to work, we need to persist that selection
even after the menu itself is opened and the "Copy" menu item is
clicked.

This all demanded a bit of work in the markdown file itself, and given
we may want to use this functionality for other non-agent thread view
markdown use cases in the future, I felt like it'd be better breaking it
down into a separate PR that we can more easily track in the future.

The context menu still needs to be built in the place where the markdown
is created and rendered, though. This PR only adds the infrastructure
needed so that this menu can simply fire the `CopyAsMarkdown` and make
the copying work.

Release Notes:

- N/A
2025-12-23 12:09:10 -03:00
Rocky Shi
ed705c0cbc Conditionally display debugger panel icon based on a setting (#45544)
Closes [#ISSUE](https://github.com/zed-industries/zed/issues/45506)

Release Notes:

- Conditionally display the debugger panel icon based on a setting to
avoid too many error logs
2025-12-23 13:28:04 +01:00
Joseph T. Lyons
8980333e23 Add support for automatic Markdown task list continuation when using uppercase X (#45561)
Release Notes:

- Added support for automatic Markdown task list continuation when using
uppercase X
2025-12-23 08:07:48 +00:00
Cole Miller
acee48bfda git: Fix "Commit Tracked" being shown when files are partially staged (#45551)
Release Notes:

- N/A
2025-12-22 21:32:55 -05:00
Finn Evers
71298e6949 extension_ci: Use larger runners for extension bundling (#45540)
`2x4` is not nearly enough for some of the grammars in use, hence change
this to a larger runner.

Also, reduce the size for the Rust runners a bit, as they don't need to
be quite as large for the amount of Rust code we have in extensions.

Release Notes:

- N/A
2025-12-22 22:08:42 +00:00
Max Brunsfeld
07ada58466 Improve edit prediction example capture (#45536)
This PR improves the `edit prediction: Capture Example` in several ways:
* fixed bugs in how the uncommitted diff was calculated
* added a `edit_predictions.examples_dir` setting that can be set in
order to have the action automatically save examples into the given
folder
* moved the action into the `edit_predictions` crate, in preparation for
collecting this data passively from end users, when they have opted in
to data sharing, similar to what we did for Zeta 1

Release Notes:

- N/A
2025-12-22 20:40:02 +00:00
Kirill Bulatov
dd521a96fb Bump proto extension to 0.3.1 (#45531)
Includes https://github.com/zed-industries/zed/pull/45413

Release Notes:

- N/A
2025-12-22 18:40:27 +00:00
Danilo Leal
f9d9721b93 agent_ui: Expand model favoriting feature to external agents (#45528)
This PR adds the ability to favorite models for external agents—writing
to the settings in the `agent_servers` key—as well as a handful of other
improvements:

- Make the cycling keybinding `alt-enter` work for the inline assistant
as well as previous user messages
- Better organized the keybinding files removing some outdated
agent-related keybinding definitions
- Renamed the inline assistant key context to "InlineAssistant" as
"PromptEditor" is old and confusing
- Made the keybindings to rate an inline assistant response visible in
the thumbs up/down button's tooltip
- Created a unified component for the model selector tooltip given we
had 3 different places creating the same element
- Make the "Cycle Favorited Models" row in the tooltip visible only if
there is more than one favorite models

Release Notes:

- agent: External agents also now support the favoriting model feature,
which comes with a handy keybinding to cycle through the favorite list.
2025-12-22 14:06:54 -03:00
Alejandro Fernández Gómez
cff3ac6f93 docs: Fix download_file documentation (#45517)
Fix a small error in the docs for the extension capabilities

Release Notes:

- N/A
2025-12-22 10:17:26 +00:00
Finn Evers
746b76488c util: Keep default permissions when extracting Zip with unset permissions (#45515)
This ensures that we do not extract files with no permissions (`0o000`),
because these would become unusable on the host

Release Notes:

- N/A
2025-12-22 09:28:11 +00:00
Marshall Bowers
397fcf6083 docs: Fix Edit Prediction docs for Codestral (#45509)
This PR fixes the Edit Prediction docs for Codestral after they got
mangled in https://github.com/zed-industries/zed/pull/45503.

Release Notes:

- N/A
2025-12-22 04:10:26 +00:00
morgankrey
9adb3e1daa docs: Testing automatic documentation updates locally (2025-12-21) (#45503)
## Documentation Update Summary

### Changes Made

| File | Change | Related Code |
| --- | --- | --- |
| `docs/src/ai/edit-prediction.md` | Updated Codestral setup
instructions to use Settings Editor path instead of outdated
`agent::OpenSettings` action reference | Settings Editor provider
configuration flow |

### Rationale

The primary documentation update addresses outdated instructions in the
Codestral setup section. The original text referenced an
`agent::OpenSettings` action that directed users to an "Agent Panel
settings view" which no longer reflects the current UI flow. The updated
instructions now guide users through the Settings Editor with
platform-specific keyboard shortcuts and provide an alternative status
bar path.

### Review Notes

- **Codestral instructions**: Reviewers should verify the Settings
Editor navigation path (`Cmd+,` → search "Edit Predictions" →
**Configure Providers**) matches the current Zed UI
- **Status bar alternative**: The alternative path via "edit prediction
icon in the status bar" should be confirmed as accurate

---

## Update from 2025-12-21 20:25

---
**Source**: [#44914](https://github.com/zed-industries/zed/pull/44914) -
settings_ui: Add Edit keybindings button
**Author**: @probably-neb

Now I have all the context needed to create a comprehensive
documentation update summary.

## Documentation Update Summary

### Changes Made
| File | Change | Related Code |
| --- | --- | --- |
| docs/src/ai/agent-panel.md | Added documentation for `agent::PasteRaw`
action, explaining automatic @mention formatting for pasted code and how
to bypass it | PR #45254 |

### Rationale
PR #45254 ("agent_ui: Improve UX when pasting code into message editor")
introduced the `agent::PasteRaw` action, which allows users to paste
clipboard content without automatic formatting. When users copy
multi-line code from an editor buffer and paste it into the Agent panel,
Zed now automatically formats it as an @mention with file context. The
`PasteRaw` action provides a way to bypass this behavior when raw text
is preferred.

This documentation update ensures users can discover both:
1. The new automatic @mention formatting behavior
2. The keybinding to bypass it when needed

### Review Notes
- The new paragraph was placed in the "Adding Context" section,
immediately after the existing note about image pasting support—this
maintains logical flow since both relate to pasting behavior
- Uses the standard `{#kb agent::PasteRaw}` syntax for keybinding
references, consistent with other keybinding documentation in the file
- The documentation passed Prettier formatting validation without
modifications

---

### Condensed Version (for commit message)
```
docs(agent-panel): Document PasteRaw action for bypassing auto @mention formatting

Added explanation that multi-line code pasted from editor buffers is
automatically formatted as @mentions, with keybinding to paste raw text.

Related: PR #45254
```

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-21 20:31:24 -06:00
Daeksell
1469d94683 Fix dock panel button tooltip not dismissed when state changes via keyboard shortcut (#44746)
Closes #44720

Release Notes:

- Fixed dock panel button tooltips not being dismissed when toggling
panels via keyboard shortcut




**Problem:** When hovering over a dock panel button and using a keyboard
shortcut to toggle the panel, the tooltip remains visible with stale
content. This is inconsistent with mouse click behavior, where the
tooltip is dismissed on mouse down.

**Solution:** Include the panel's active state in the button's element
ID. When the state changes, the element ID changes (e.g., `"DebugPanel"`
→ `"DebugPanel-active"`), which causes GPUI to discard the old element
state including the cached tooltip.

**Testing:** Manually verified:
1. Hover over a dock panel button, wait for tooltip
2. Press keyboard shortcut to toggle the panel
3. Tooltip is now dismissed (consistent with mouse click behavior)


https://github.com/user-attachments/assets/ed92fb6c-6c22-44e2-87e3-5461d35f7106

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
2025-12-22 00:50:54 +01:00
Yves Ineichen
3b626c8ac1 Allow empty splits on panes (#40245)
Draft as a base for continuing the discussion in #8008 : adds a
`SplitOperation` enum to support bindings like `["pane::SplitLeft",
{"operation": "Clear"}]`

To be discussed @MrSubidubi and others:

- Naming: Generally not happy with names yet and specifically `Empty` is
unclear, e.g., what does this mean for terminal panes? Added placeholder
code to split without cloning, but unsure what users would expect in
this case.
- ~~I removed `SplitAndMoveXyz` actions but I guess we should keep them
for backwards compatibility?~~
- May have missed details in the move implementation. Will check the
code again for opportunities to refactor more code after we agree on the
approach.
- ~~Tests should go to `crates/collab/src/tests/integration_tests.rs`?~~

Closes #8008

Release Notes:

- Add `pane::Split` mode (`{ClonePane,EmptyPane,MovePane}`) to allow
creating an empty buffer.

---------

Co-authored-by: Finn Evers <finn.evers@outlook.de>
Co-authored-by: MrSubidubi <finn@zed.dev>
2025-12-21 23:50:02 +00:00
Kirill Bulatov
3dc0614dba Small worktree trust fixes (#45500)
* Abs path trust should transitively trust all single file worktrees on
the same host
* Init worktree trust on the client side even when devcontainers are
run: remote host unconditionally checks trust, hence the client has to
keep track of it and respond with approves/declines.
Do trust all devcontainers' remote worktrees, as containers are isolated
and "safe".

Release Notes:

- N/A
2025-12-21 23:07:49 +00:00
Mayank Verma
045e154915 gpui: Fix hover state getting stuck when rapidly hovering over elements (#45437)
Closes #45436

Release Notes:

- N/A

---------

Co-authored-by: MrSubidubi <finn@zed.dev>
2025-12-21 22:07:30 +00:00
Finn Evers
dc72e1c4ba collab: Fix capitalization of copilot name alias (#45497)
This fixes copilot currently not passing the CLA check. 

Release Notes:

- N/A
2025-12-21 21:36:54 +00:00
Lukas Wirth
0884305e43 gpui(windows): Don't log incorrect errors on SetActiveWindow calls (#45493)
The function returns the previous focus handle, which may be null if
there is no previous focus. Unfortunately that also overlaps with the
error return value, so winapi will hand us a error 0 back in those cases
which we log ...


Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-21 16:14:23 +00:00
Nereuxofficial
83449293b6 Add autocomplete for initialization_options (#43104)
Closes #18287

Release Notes:

- Added autocomplete for lsp initialization_options

## Description
This MR adds the following code-changes:
- `initialization_options_schema` to the `LspAdapter` to get JSON
Schema's from the language server
- Adds a post-processing step to inject schema request paths into the
settings schema in `SettingsStore::json_schema`
- Adds an implementation for fetching the schema for rust-analyzer which
fetches it from the binary it is provided with
- Similarly for ruff
<img width="857" height="836" alt="image"
src="https://github.com/user-attachments/assets/3cc10883-364f-4f04-b3b9-3c3881f64252"
/>


## Open Questions(Would be nice to get some advice here)
- Binary Fetching:
- I'm pretty sure the binary fetching is suboptimal. The main problem
here was getting access to the delegate but i figured that out
eventually in a way that i _hope_ should be fine.
- The toolchain and binary options can differ from what the user has
configured potentially leading to mismatches in the autocomplete values
returned(these are probably rarely changed though). I could not really
find a way to fetch these in this context so the provided ones are for
now just `default` values.
- For the trait API it is just provided a binary, since i wanted to use
the potentially cached binary from the CachedLspAdapter. Is that fine
our should the arguments be passed to the LspAdapter such that it can
potentially download the LSP?
- As for those LSPs with JSON schema files in their repositories i can
add the files to zed manually e.g. in
languages/language/initialization_options_schema.json, which could cause
mismatches with the actual binary. Is there a preferred approach for Zed
here also with regards to updating them?
2025-12-21 10:29:38 -05:00
Marco Mihai Condrache
213cb30445 gpui: Enable direct-to-display optimization for metal (#45434)
Continuing of #44334 

I removed disabling of vsync which was causing jitter on some external
displays

cc: @maxbrunsfeld @Anthony-Eid 

Release Notes:

- Mark metal layers opaque for non-transparent windows to allow
direct-to-display when supported

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
2025-12-21 11:17:02 +02:00
Finn Evers
4b56fec971 acp_thread: Fix broken main build (#45461)
Release Notes:

- N/A
2025-12-20 20:01:03 +00:00
Nathan Sobo
32621dc5de Fix race condition in update_last_checkpoint (#44801)
Release Notes:

- Fixed spurious "no checkpoint" error in agent panel

---

## Summary

`update_last_checkpoint` would call `last_user_message()` twice - once
at the start to capture the checkpoint, and again in an async closure
after the checkpoint comparison completed. If a new user message without
a checkpoint was added between these two calls, the second call would
find the new message and fail with "no checkpoint".

## Fix

Capture the user message ID at the start and use `user_message_mut(&id)`
in the async closure to find the specific message.

cc @mikayla-maki
2025-12-20 10:42:58 -07:00
Danilo Leal
215ac50bc8 agent_ui: Fix markdown block for tool call input and output content (#45454)
This PR fixes two issues with regards to markdown codeblocks rendered in
tool call input and output content display:
- the JSON code snippets weren't properly indented
- codeblocks weren't being rendered in unique containers; e.g., if you
hovered one scrollbar, all of them would also be hovered, even though
horizontal scrolling itself worked properly

Here's the end result:


https://github.com/user-attachments/assets/3d6daf64-0f88-4a16-a5a0-94998c1ba7e2

Release Notes:

- agent: Fix scrollbar and JSON indentation for tool call input/output
content's markdown codeblocks.
2025-12-20 16:29:13 +00:00
Danilo Leal
a5540a08fb ui: Make the NumberField in edit mode work (#45447)
- Make the buttons capable of changing the editor's content
(incrementing or decrementing the value)
- Make arrow key up and down increment and decrement the editor value
- Tried to apply a bit of DRY here by creating some functions that can
be reused across the buttons and editor given they all essentially do
the same thing (change the value)
- Fixed an issue where the editor would not allow focus to move
elsewhere, making it impossible to open a dropdown, for example, if your
focus was on the number field's editor

Release Notes:

- N/A
2025-12-20 11:15:46 -03:00
ᴀᴍᴛᴏᴀᴇʀ
3e8c25f5a9 Remove extra shortcut separator in default mode & model selection tooltips (#45439)
Closes #44118

Release Notes:

- N/A
2025-12-20 14:08:56 +00:00
Zachiah Sawyer
7f0842e3a6 proto: Add extend keyword (#45413)
Closes #45385

Release Notes:
- Add extend keyword to proto
2025-12-20 08:00:04 +02:00
Hidehiro Anto
6dad419cd5 Update version example in remote-development.md (#45427)
Updated the version example for maintaining the remote server binary.

Release Notes:

- N/A
2025-12-20 05:51:22 +00:00
Danilo Leal
0facdfa5ca editor: Make TextAlign::Center and TextAlign::Right work (#45417)
Closes https://github.com/zed-industries/zed/issues/43208

This PR essentially unblocks the editable number field. The function
that shapes editor lines was hard-coding text alignment to the left,
meaning that whatever different alignment we'd pass through
`EditorStyles`would be ignored. To solve this, I just added a text align
and align width fields to the line paint function and updated all call
sites keeping the default configuration. Had to also add an
`alignment_offset()` helper to make sure the cursor positioning, the
selection background element, and the click-to-focus functionality were
kept in-sync with the non-left aligned editor.

Then... the big star of the show here is being able to add the `mode`
method to the number field, which uses `TextAlign::Center`, thus making
it work as we designed it to work.


https://github.com/user-attachments/assets/3539c976-d7bf-4d94-8188-a14328f94fbf

Next up, is turning the number filed to edit mode where applicable.

Release Notes:

- Fixed a bug where different text alignment configurations (i.e.,
center and right-aligned) wouldn't take effect in editors.
2025-12-19 17:37:15 -08:00
Marshall Bowers
58461377ca ci: Disable automated docs on pushes to main (#45416)
This PR disables the automated docs on pushes to `main`, as it is
currently making CI red.

Release Notes:

- N/A
2025-12-20 01:01:19 +00:00
Lieunoir
42d5f7e73e Set override_redirect for PopUps (#42224)
Currently on x11, gpui PopUp windows only rely on the "notification"
type in order to indicate that they should spawn as floating window.
Several window managers (leftwm in my case, but it also seems to be the
case for dwm and ratpoison) do not this property into account thus not
spawning them as float. On the other hand, using Floating instead of
PopUp do make those windows spawn as floating, as these window manager
do take into account the (older) "dialog" type.

The [freedekstop
documentation](https://specifications.freedesktop.org/wm/1.5/ar01s05.html#id-1.6.7)
does seem to suggest that these windows should also have the override
redirect property :
> This property is typically used on override-redirect windows. 

Note that this also disables pretty much all interactions with the
window manager (such as moving the window, resizing etc...)

Release Notes:

- Fix popup windows not spawning floating sometime on x11
2025-12-19 16:38:45 -08:00
Mikayla Maki
5395197619 Separate out component_preview crate and add easy-to-use example binaries (#45382)
Release Notes:

- N/A
2025-12-20 00:34:14 +00:00
Mayank Verma
1d76539d28 gpui: Fix hover styles not being applied during layout (#43324)
Closes #43214

Release Notes:

- Fixed GPUI hover styles not being applied during layout

Here's the before/after:


https://github.com/user-attachments/assets/5b1828bb-234a-493b-a33d-368ca01a773b
2025-12-19 16:33:32 -08:00
jkugs
e5eb26e8d6 gpui: Reset mouse scroll state on FocusOut to prevent large jumps (#43841)
This fixes an X11 scrolling issue where Zed may jump by a large amount
due to the scroll valuator state not being reset when the window loses
focus. If you Alt-Tab away from Zed, scroll in another application, then
return, the first scroll event in Zed applies the entire accumulated
delta instead of a single step.

The missing FocusOut reset was originally identified in issue #34901.

Resetting scroll positions on FocusOut matches the behavior already
implemented in the XinputLeave handler and prevents this jump.

Closes #34901
Closes #40538

Release Notes:

- Fixed an X11 issue where Alt-Tabbing to another application,
scrolling, and returning to Zed could cause the next scroll event to
jump by a large amount.
2025-12-19 16:29:44 -08:00
Serophots
a86b0ab2e0 gpui: Improve the tab stop example by demonstrating tab_group (#44647)
I've just enriched the existing tab_stop.rs example for GPUI with a
demonstration of tab_group. I don't think tab groups existed when the
original example was written.

(I didn't understand the behaviour for tab_group from the doccomments
and the example was missing, so I think this is a productive PR)

Release Notes:

- N/A
2025-12-20 00:29:26 +00:00
Jason Lee
5fb220a19a gpui: Add a Popover example for test deferred (#44473)
Release Notes:

- N/A

<img width="1036" height="659" alt="image"
src="https://github.com/user-attachments/assets/8ca06306-719f-4495-92b3-2a609aa09249"
/>
2025-12-19 16:27:41 -08:00
Anthony Eid
12dbbdd1d3 git: Fix bug where opening a git blob from historic commit view could fail (#44226)
The failure would happen if the current version of the file was open as
an editor. This happened because the git blob and current version of the
buffer would have the same `ProjectPath`.

The fix was adding a new `DiskState::Historic` variant to represent
buffers that are past versions of a file (usually a snapshot from
version control). Historic buffers don't return a `ProjectPath` because
the file isn't real, thus there isn't and shouldn't be a `ProjectPath`
to it. (At least with the current way we represent a project path)

I also change the display name to use the local OS's path style instead
of being hardcoded to Posix, and cleaned up some code too.

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
Co-authored-by: xipengjin <jinxp18@gmail.com>
2025-12-19 18:55:17 -05:00
Max Brunsfeld
6dfabddbb4 Revert "gpui: Enable direct-to-display optimization for metal" (#45405)
Reverts zed-industries/zed#44334

From my testing, this PR introduced screen tearing, or some kind of
strange visual artifact, when scrolling at medium speed on a large
display.

Release notes:

- N/A
2025-12-19 15:21:10 -08:00
Haojian Wu
895213a94d Support union declarations in C/C++ textobjects.scm (#45308)
Release Notes:

- C/C++: Add `union` declarations to the list of text objects
2025-12-19 17:37:13 -05:00
Richard Feldman
1c576ccf82 Fix OpenRouter giving errors for some Anthropic models (#45399)
Fixes #44032

Release Notes:

- Fix OpenRouter giving errors for some Anthropic models
2025-12-19 17:04:43 -05:00
Anthony Eid
3f4da03d38 settings ui: Change window kind from floating to normal (#45401)
#40291 made floating windows always stay on top, which made the settings
ui window always on top of Zed. To maintain the old behavior, this PR
changes the setting window to be a normal window.

Release Notes:

- N/A
2025-12-19 16:48:53 -05:00
Conrad Irwin
ff71f4d46d Run cargo fix as well as cargo clippy --fix (#45394)
Release Notes:

- N/A
2025-12-19 14:27:44 -07:00
284 changed files with 12590 additions and 12402 deletions

View File

@@ -25,6 +25,7 @@ self-hosted-runner:
- namespace-profile-32x64-ubuntu-2204
# Namespace Ubuntu 24.04 (like ubuntu-latest)
- namespace-profile-2x4-ubuntu-2404
- namespace-profile-8x32-ubuntu-2404
# Namespace Limited Preview
- namespace-profile-8x16-ubuntu-2004-arm-m4
- namespace-profile-8x32-ubuntu-2004-arm-m4

View File

@@ -54,6 +54,10 @@ jobs:
- name: autofix_pr::run_autofix::run_cargo_fmt
run: cargo fmt --all
shell: bash -euxo pipefail {0}
- name: autofix_pr::run_autofix::run_cargo_fix
if: ${{ inputs.run_clippy }}
run: cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged
shell: bash -euxo pipefail {0}
- name: autofix_pr::run_autofix::run_clippy_fix
if: ${{ inputs.run_clippy }}
run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged

View File

@@ -1,11 +1,11 @@
name: Documentation Automation
on:
push:
branches: [main]
paths:
- 'crates/**'
- 'extensions/**'
# push:
# branches: [main]
# paths:
# - 'crates/**'
# - 'extensions/**'
workflow_dispatch:
inputs:
pr_number:
@@ -29,7 +29,7 @@ jobs:
docs-automation:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -77,7 +77,7 @@ jobs:
echo "ref=$SHA" >> "$GITHUB_OUTPUT"
git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt || git diff --name-only HEAD~1 HEAD > /tmp/changed_files.txt
fi
echo "Changed files:"
cat /tmp/changed_files.txt
env:
@@ -102,24 +102,24 @@ jobs:
run: |
CHANGED_FILES=$(tr '\n' ' ' < /tmp/changed_files.txt)
echo "Analyzing changes in: $CHANGED_FILES"
# Build prompt with context
cat > /tmp/phase3-prompt.md << 'EOF'
$(cat .factory/prompts/docs-automation/phase3-analyze.md)
## Context
### Changed Files
$CHANGED_FILES
### Phase 2 Output
$(cat /tmp/phase2-output.txt)
EOF
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
"$(cat .factory/prompts/docs-automation/phase3-analyze.md)
Changed files: $CHANGED_FILES" \
> /tmp/phase3-output.md 2>&1 || true
echo "Change analysis complete"
@@ -135,7 +135,7 @@ jobs:
> /tmp/phase4-plan.md 2>&1 || true
echo "Documentation plan complete"
cat /tmp/phase4-plan.md
# Check if updates are required
if grep -q "NO_UPDATES_REQUIRED" /tmp/phase4-plan.md; then
echo "updates_required=false" >> "$GITHUB_OUTPUT"
@@ -163,10 +163,10 @@ jobs:
run: |
echo "Formatting documentation with Prettier..."
cd docs && prettier --write src/
echo "Verifying Prettier formatting passes..."
cd docs && prettier --check src/
echo "Prettier formatting complete"
# Phase 6: Summarize Changes (Read-Only - default)
@@ -176,7 +176,7 @@ jobs:
run: |
# Get git diff of docs
git diff docs/src/ > /tmp/docs-diff.txt || true
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
-f .factory/prompts/docs-automation/phase6-summarize.md \
@@ -194,17 +194,17 @@ jobs:
echo "No documentation changes detected"
exit 0
fi
# Configure git
git config user.name "factory-droid[bot]"
git config user.email "138933559+factory-droid[bot]@users.noreply.github.com"
# Daily batch branch - one branch per day, multiple commits accumulate
BRANCH_NAME="docs/auto-update-$(date +%Y-%m-%d)"
# Stash local changes from phase 5
git stash push -m "docs-automation-changes" -- docs/src/
# Check if branch already exists on remote
if git ls-remote --exit-code --heads origin "$BRANCH_NAME" > /dev/null 2>&1; then
echo "Branch $BRANCH_NAME exists, checking out and updating..."
@@ -214,10 +214,10 @@ jobs:
echo "Creating new branch $BRANCH_NAME..."
git checkout -b "$BRANCH_NAME"
fi
# Apply stashed changes
git stash pop || true
# Stage and commit
git add docs/src/
SUMMARY=$(head -50 < /tmp/phase6-summary.md)
@@ -228,13 +228,13 @@ jobs:
Triggered by: ${{ steps.changed.outputs.source }} ${{ steps.changed.outputs.ref }}
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>"
# Push
git push -u origin "$BRANCH_NAME"
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' || echo "")
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists for branch $BRANCH_NAME, updated with new commit"
else
@@ -254,7 +254,7 @@ jobs:
run: |
echo "## Documentation Automation Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.phase4.outputs.updates_required }}" == "false" ]; then
echo "No documentation updates required for this change." >> "$GITHUB_STEP_SUMMARY"
elif [ -f /tmp/phase6-summary.md ]; then

View File

@@ -66,7 +66,7 @@ jobs:
if: |-
(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') &&
(inputs.force-bump == 'true' || needs.check_bump_needed.outputs.needs_bump == 'true')
runs-on: namespace-profile-8x16-ubuntu-2204
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: generate-token
name: extension_bump::generate_token
@@ -79,7 +79,7 @@ jobs:
with:
clean: false
- name: extension_bump::install_bump_2_version
run: pip install bump2version
run: pip install bump2version --break-system-packages
shell: bash -euxo pipefail {0}
- id: bump-version
name: extension_bump::bump_version
@@ -119,7 +119,7 @@ jobs:
needs:
- check_bump_needed
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check_bump_needed.outputs.needs_bump == 'false'
runs-on: namespace-profile-8x16-ubuntu-2204
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: generate-token
name: extension_bump::generate_token

View File

@@ -13,7 +13,7 @@ on:
jobs:
create_release:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
runs-on: namespace-profile-8x16-ubuntu-2204
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: generate-token
name: extension_bump::generate_token

View File

@@ -51,7 +51,7 @@ jobs:
needs:
- orchestrate
if: needs.orchestrate.outputs.check_rust == 'true'
runs-on: namespace-profile-16x32-ubuntu-2204
runs-on: namespace-profile-4x8-ubuntu-2204
steps:
- name: steps::checkout_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -79,7 +79,7 @@ jobs:
needs:
- orchestrate
if: needs.orchestrate.outputs.check_extension == 'true'
runs-on: namespace-profile-2x4-ubuntu-2404
runs-on: namespace-profile-8x32-ubuntu-2404
steps:
- name: steps::checkout_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

View File

@@ -0,0 +1,106 @@
# Generated from xtask::workflows::extension_workflow_rollout
# Rebuild with `cargo xtask workflows`.
name: extension_workflow_rollout
env:
CARGO_TERM_COLOR: always
on:
workflow_dispatch: {}
jobs:
fetch_extension_repos:
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: list-repos
name: extension_workflow_rollout::fetch_extension_repos::get_repositories
uses: actions/github-script@v7
with:
script: |
const repos = await github.paginate(github.rest.repos.listForOrg, {
org: 'zed-extensions',
type: 'public',
per_page: 100,
});
const filteredRepos = repos
.filter(repo => !repo.archived)
.filter(repo => repo.name !== 'workflows' && repo.name !== 'material-icon-theme')
.map(repo => repo.name);
console.log(`Found ${filteredRepos.length} extension repos`);
return filteredRepos;
result-encoding: json
outputs:
repos: ${{ steps.list-repos.outputs.result }}
timeout-minutes: 5
rollout_workflows_to_extension:
needs:
- fetch_extension_repos
if: needs.fetch_extension_repos.outputs.repos != '[]'
runs-on: namespace-profile-2x4-ubuntu-2404
strategy:
matrix:
repo: ${{ fromJson(needs.fetch_extension_repos.outputs.repos) }}
fail-fast: false
max-parallel: 5
steps:
- id: generate-token
name: extension_bump::generate_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
owner: zed-extensions
repositories: ${{ matrix.repo }}
permission-pull-requests: write
permission-contents: write
permission-workflows: write
- name: checkout_zed_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
clean: false
path: zed
- name: steps::checkout_repo_with_token
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
clean: false
token: ${{ steps.generate-token.outputs.token }}
repository: zed-extensions/${{ matrix.repo }}
path: extension
- name: extension_workflow_rollout::rollout_workflows_to_extension::copy_workflow_files
run: |
mkdir -p extension/.github/workflows
cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
shell: bash -euxo pipefail {0}
- id: short-sha
name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
working-directory: zed
- id: create-pr
name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request
uses: peter-evans/create-pull-request@v7
with:
path: extension
title: Update CI workflows to `zed@${{ steps.short-sha.outputs.sha_short }}`
body: |
This PR updates the CI workflow files from the main Zed repository
based on the commit zed-industries/zed@${{ github.sha }}
commit-message: Update CI workflows to `zed@${{ steps.short-sha.outputs.sha_short }}`
branch: update-workflows
committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
author: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
base: main
delete-branch: true
token: ${{ steps.generate-token.outputs.token }}
sign-commits: true
- name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge
run: |
PR_NUMBER="${{ steps.create-pr.outputs.pull-request-number }}"
if [ -n "$PR_NUMBER" ]; then
cd extension
gh pr merge "$PR_NUMBER" --auto --squash
fi
shell: bash -euxo pipefail {0}
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
timeout-minutes: 10

View File

@@ -6,7 +6,7 @@ on:
jobs:
handle-good-first-issue:
if: github.event.label.name == 'good first issue' && github.repository_owner == 'zed-industries'
if: github.event.label.name == '.contrib/good first issue' && github.repository_owner == 'zed-industries'
runs-on: ubuntu-latest
steps:

View File

@@ -23,7 +23,6 @@ In particular we love PRs that are:
If you're looking for concrete ideas:
- [Curated board of issues](https://github.com/orgs/zed-industries/projects/69) suitable for everyone from first-time contributors to seasoned community champions.
- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible).
- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search).

59
Cargo.lock generated
View File

@@ -268,6 +268,7 @@ dependencies = [
"client",
"collections",
"env_logger 0.11.8",
"feature_flags",
"fs",
"futures 0.3.31",
"gpui",
@@ -2380,7 +2381,6 @@ dependencies = [
name = "buffer_diff"
version = "0.1.0"
dependencies = [
"anyhow",
"clock",
"ctor",
"futures 0.3.31",
@@ -3525,6 +3525,33 @@ dependencies = [
"theme",
]
[[package]]
name = "component_preview"
version = "0.1.0"
dependencies = [
"anyhow",
"client",
"collections",
"component",
"db",
"fs",
"gpui",
"language",
"log",
"node_runtime",
"notifications",
"project",
"release_channel",
"reqwest_client",
"session",
"settings",
"theme",
"ui",
"ui_input",
"uuid",
"workspace",
]
[[package]]
name = "compression-codecs"
version = "0.4.31"
@@ -5185,6 +5212,7 @@ dependencies = [
"anyhow",
"arrayvec",
"brotli",
"buffer_diff",
"client",
"clock",
"cloud_api_types",
@@ -5222,7 +5250,9 @@ dependencies = [
"strum 0.27.2",
"telemetry",
"telemetry_events",
"text",
"thiserror 2.0.17",
"time",
"ui",
"util",
"uuid",
@@ -5327,8 +5357,10 @@ dependencies = [
"anyhow",
"buffer_diff",
"client",
"clock",
"cloud_llm_client",
"codestral",
"collections",
"command_palette_hooks",
"copilot",
"edit_prediction",
@@ -5337,18 +5369,20 @@ dependencies = [
"feature_flags",
"fs",
"futures 0.3.31",
"git",
"gpui",
"indoc",
"language",
"log",
"language_model",
"lsp",
"markdown",
"menu",
"multi_buffer",
"paths",
"pretty_assertions",
"project",
"regex",
"release_channel",
"semver",
"serde_json",
"settings",
"supermaven",
@@ -5361,6 +5395,7 @@ dependencies = [
"workspace",
"zed_actions",
"zeta_prompt",
"zlog",
]
[[package]]
@@ -5924,11 +5959,9 @@ dependencies = [
"async-trait",
"client",
"collections",
"credentials_provider",
"criterion",
"ctor",
"dap",
"dirs 4.0.0",
"extension",
"fs",
"futures 0.3.31",
@@ -5937,11 +5970,8 @@ dependencies = [
"http_client",
"language",
"language_extension",
"language_model",
"log",
"lsp",
"markdown",
"menu",
"moka",
"node_runtime",
"parking_lot",
@@ -5956,21 +5986,17 @@ dependencies = [
"serde_json",
"serde_json_lenient",
"settings",
"smol",
"task",
"telemetry",
"tempfile",
"theme",
"theme_extension",
"toml 0.8.23",
"ui",
"ui_input",
"url",
"util",
"wasmparser 0.221.3",
"wasmtime",
"wasmtime-wasi",
"workspace",
"zlog",
]
@@ -8627,6 +8653,7 @@ dependencies = [
"extension",
"gpui",
"language",
"lsp",
"paths",
"project",
"schemars",
@@ -14882,7 +14909,6 @@ dependencies = [
"copilot",
"edit_prediction",
"editor",
"extension_host",
"feature_flags",
"fs",
"futures 0.3.31",
@@ -14890,7 +14916,6 @@ dependencies = [
"gpui",
"heck 0.5.0",
"language",
"language_model",
"language_models",
"log",
"menu",
@@ -20654,6 +20679,7 @@ dependencies = [
"collections",
"command_palette",
"component",
"component_preview",
"copilot",
"crashes",
"dap",
@@ -20759,7 +20785,6 @@ dependencies = [
"tree-sitter-md",
"tree-sitter-rust",
"ui",
"ui_input",
"ui_prompt",
"url",
"urlencoding",
@@ -20939,7 +20964,7 @@ dependencies = [
[[package]]
name = "zed_glsl"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"zed_extension_api 0.1.0",
]
@@ -20953,7 +20978,7 @@ dependencies = [
[[package]]
name = "zed_proto"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"zed_extension_api 0.7.0",
]

View File

@@ -39,6 +39,7 @@ members = [
"crates/command_palette",
"crates/command_palette_hooks",
"crates/component",
"crates/component_preview",
"crates/context_server",
"crates/copilot",
"crates/crashes",
@@ -275,6 +276,7 @@ collections = { path = "crates/collections", version = "0.1.0" }
command_palette = { path = "crates/command_palette" }
command_palette_hooks = { path = "crates/command_palette_hooks" }
component = { path = "crates/component" }
component_preview = { path = "crates/component_preview" }
context_server = { path = "crates/context_server" }
copilot = { path = "crates/copilot" }
crashes = { path = "crates/crashes" }

View File

@@ -55,6 +55,13 @@
"down": "menu::SelectNext",
},
},
{
"context": "menu",
"bindings": {
"right": "menu::SelectChild",
"left": "menu::SelectParent",
},
},
{
"context": "Editor",
"bindings": {
@@ -241,6 +248,7 @@
"ctrl-alt-l": "agent::OpenRulesLibrary",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
"ctrl-shift-j": "agent::ToggleNavigationMenu",
"ctrl-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
@@ -253,7 +261,6 @@
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -285,38 +292,6 @@
"ctrl-alt-t": "agent::NewThread",
},
},
{
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"bindings": {
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"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",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"context": "EditMessageEditor > Editor",
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"bindings": {
@@ -331,14 +306,25 @@
"ctrl-enter": "menu::Confirm",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
"context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
},
},
{
@@ -346,11 +332,7 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"enter": "editor::Newline",
},
},
{
@@ -817,7 +799,7 @@
},
},
{
"context": "PromptEditor",
"context": "InlineAssistant",
"bindings": {
"ctrl-[": "agent::CyclePreviousInlineAssist",
"ctrl-]": "agent::CycleNextInlineAssist",

View File

@@ -54,6 +54,13 @@
"ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
},
},
{
"context": "menu",
"bindings": {
"right": "menu::SelectChild",
"left": "menu::SelectParent",
},
},
{
"context": "Editor",
"use_key_equivalents": true,
@@ -282,6 +289,7 @@
"cmd-alt-p": "agent::ManageProfiles",
"cmd-i": "agent::ToggleProfileSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
@@ -294,7 +302,6 @@
"cmd-y": "agent::AllowOnce",
"cmd-alt-y": "agent::AllowAlways",
"cmd-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -326,41 +333,6 @@
"cmd-alt-t": "agent::NewThread",
},
},
{
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"cmd-enter": "agent::ChatWithFollow",
"cmd-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"cmd-shift-v": "agent::PasteRaw",
},
},
{
"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",
"cmd-shift-v": "agent::PasteRaw",
},
},
{
"context": "EditMessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
@@ -382,16 +354,25 @@
"cmd-enter": "menu::Confirm",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"cmd-enter": "agent::ChatWithFollow",
"cmd-shift-v": "agent::PasteRaw",
"cmd-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
"context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -399,11 +380,7 @@
"use_key_equivalents": true,
"bindings": {
"cmd-enter": "agent::Chat",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"enter": "editor::Newline",
},
},
{
@@ -883,7 +860,7 @@
},
},
{
"context": "PromptEditor",
"context": "InlineAssistant > Editor",
"use_key_equivalents": true,
"bindings": {
"cmd-alt-/": "agent::ToggleModelSelector",

View File

@@ -54,6 +54,13 @@
"down": "menu::SelectNext",
},
},
{
"context": "menu",
"bindings": {
"right": "menu::SelectChild",
"left": "menu::SelectParent",
},
},
{
"context": "Editor",
"use_key_equivalents": true,
@@ -241,6 +248,7 @@
"shift-alt-l": "agent::OpenRulesLibrary",
"shift-alt-p": "agent::ManageProfiles",
"ctrl-i": "agent::ToggleProfileSelector",
"alt-tab": "agent::CycleFavoriteModels",
"shift-alt-/": "agent::ToggleModelSelector",
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
@@ -254,7 +262,6 @@
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"shift-alt-z": "agent::RejectOnce",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -287,41 +294,6 @@
"ctrl-alt-t": "agent::NewThread",
},
},
{
"context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::Chat",
"enter": "editor::Newline",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
},
},
{
"context": "EditMessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline",
},
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
@@ -337,16 +309,25 @@
"ctrl-enter": "menu::Confirm",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"ctrl-shift-v": "agent::PasteRaw",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
"context": "AcpThread > Editor && !use_modifier_to_send",
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
},
},
{
@@ -354,11 +335,7 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-enter": "agent::Chat",
"ctrl-shift-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"enter": "editor::Newline",
},
},
{
@@ -826,7 +803,7 @@
},
},
{
"context": "PromptEditor",
"context": "InlineAssistant",
"use_key_equivalents": true,
"bindings": {
"ctrl-[": "agent::CyclePreviousInlineAssist",

View File

@@ -24,7 +24,7 @@
},
},
{
"context": "InlineAssistEditor",
"context": "InlineAssistant > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-shift-backspace": "editor::Cancel",

View File

@@ -24,7 +24,7 @@
},
},
{
"context": "InlineAssistEditor",
"context": "InlineAssistant > Editor",
"use_key_equivalents": true,
"bindings": {
"cmd-shift-backspace": "editor::Cancel",

View File

@@ -11,6 +11,7 @@ use language::language_settings::FormatOnSave;
pub use mention::*;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use serde::{Deserialize, Serialize};
use serde_json::to_string_pretty;
use settings::Settings as _;
use task::{Shell, ShellBuilder};
pub use terminal::*;
@@ -883,6 +884,7 @@ pub enum AcpThreadEvent {
Refusal,
AvailableCommandsUpdated(Vec<acp::AvailableCommand>),
ModeUpdated(acp::SessionModeId),
ConfigOptionsUpdated(Vec<acp::SessionConfigOption>),
}
impl EventEmitter<AcpThreadEvent> for AcpThread {}
@@ -1192,6 +1194,10 @@ impl AcpThread {
current_mode_id,
..
}) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)),
acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate {
config_options,
..
}) => cx.emit(AcpThreadEvent::ConfigOptionsUpdated(config_options)),
_ => {}
}
Ok(())
@@ -1992,37 +1998,42 @@ impl AcpThread {
fn update_last_checkpoint(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
let git_store = self.project.read(cx).git_store().clone();
let old_checkpoint = if let Some((_, message)) = self.last_user_message() {
if let Some(checkpoint) = message.checkpoint.as_ref() {
checkpoint.git_checkpoint.clone()
} else {
return Task::ready(Ok(()));
}
} else {
let Some((_, message)) = self.last_user_message() else {
return Task::ready(Ok(()));
};
let Some(user_message_id) = message.id.clone() else {
return Task::ready(Ok(()));
};
let Some(checkpoint) = message.checkpoint.as_ref() else {
return Task::ready(Ok(()));
};
let old_checkpoint = checkpoint.git_checkpoint.clone();
let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx));
cx.spawn(async move |this, cx| {
let new_checkpoint = new_checkpoint
let Some(new_checkpoint) = new_checkpoint
.await
.context("failed to get new checkpoint")
.log_err();
if let Some(new_checkpoint) = new_checkpoint {
let equal = git_store
.update(cx, |git, cx| {
git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
})?
.await
.unwrap_or(true);
this.update(cx, |this, cx| {
let (ix, message) = this.last_user_message().context("no user message")?;
let checkpoint = message.checkpoint.as_mut().context("no checkpoint")?;
checkpoint.show = !equal;
cx.emit(AcpThreadEvent::EntryUpdated(ix));
anyhow::Ok(())
})??;
}
.log_err()
else {
return Ok(());
};
let equal = git_store
.update(cx, |git, cx| {
git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
})?
.await
.unwrap_or(true);
this.update(cx, |this, cx| {
if let Some((ix, message)) = this.user_message_mut(&user_message_id) {
if let Some(checkpoint) = message.checkpoint.as_mut() {
checkpoint.show = !equal;
cx.emit(AcpThreadEvent::EntryUpdated(ix));
}
}
})?;
Ok(())
})
@@ -2422,8 +2433,10 @@ fn markdown_for_raw_output(
)
})),
value => Some(cx.new(|cx| {
let pretty_json = to_string_pretty(value).unwrap_or_else(|_| value.to_string());
Markdown::new(
format!("```json\n{}\n```", value).into(),
format!("```json\n{}\n```", pretty_json).into(),
Some(language_registry.clone()),
None,
cx,
@@ -4066,4 +4079,67 @@ mod tests {
"Should have exactly 2 terminals (the completed ones from before checkpoint)"
);
}
/// Tests that update_last_checkpoint correctly updates the original message's checkpoint
/// even when a new user message is added while the async checkpoint comparison is in progress.
///
/// This is a regression test for a bug where update_last_checkpoint would fail with
/// "no checkpoint" if a new user message (without a checkpoint) was added between when
/// update_last_checkpoint started and when its async closure ran.
#[gpui::test]
async fn test_update_last_checkpoint_with_new_message_added(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/test"), json!({".git": {}, "file.txt": "content"}))
.await;
let project = Project::test(fs.clone(), [Path::new(path!("/test"))], cx).await;
let handler_done = Arc::new(AtomicBool::new(false));
let handler_done_clone = handler_done.clone();
let connection = Rc::new(FakeAgentConnection::new().on_user_message(
move |_, _thread, _cx| {
handler_done_clone.store(true, SeqCst);
async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }.boxed_local()
},
));
let thread = cx
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
let send_future = thread.update(cx, |thread, cx| thread.send_raw("First message", cx));
let send_task = cx.background_executor.spawn(send_future);
// Tick until handler completes, then a few more to let update_last_checkpoint start
while !handler_done.load(SeqCst) {
cx.executor().tick();
}
for _ in 0..5 {
cx.executor().tick();
}
thread.update(cx, |thread, cx| {
thread.push_entry(
AgentThreadEntry::UserMessage(UserMessage {
id: Some(UserMessageId::new()),
content: ContentBlock::Empty,
chunks: vec!["Injected message (no checkpoint)".into()],
checkpoint: None,
indented: false,
}),
cx,
);
});
cx.run_until_parked();
let result = send_task.await;
assert!(
result.is_ok(),
"send should succeed even when new message added during update_last_checkpoint: {:?}",
result.err()
);
}
}

View File

@@ -3,11 +3,11 @@ use agent_client_protocol::{self as acp};
use anyhow::Result;
use collections::IndexMap;
use gpui::{Entity, SharedString, Task};
use language_model::{IconOrSvg, LanguageModelProviderId};
use language_model::LanguageModelProviderId;
use project::Project;
use serde::{Deserialize, Serialize};
use std::{any::Any, error::Error, fmt, path::Path, rc::Rc, sync::Arc};
use ui::App;
use ui::{App, IconName};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
@@ -86,6 +86,14 @@ pub trait AgentConnection {
None
}
fn session_config_options(
&self,
_session_id: &acp::SessionId,
_cx: &App,
) -> Option<Rc<dyn AgentSessionConfigOptions>> {
None
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
}
@@ -125,6 +133,26 @@ pub trait AgentSessionModes {
fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task<Result<()>>;
}
pub trait AgentSessionConfigOptions {
/// Get all current config options with their state
fn config_options(&self) -> Vec<acp::SessionConfigOption>;
/// Set a config option value
/// Returns the full updated list of config options
fn set_config_option(
&self,
config_id: acp::SessionConfigId,
value: acp::SessionConfigValueId,
cx: &mut App,
) -> Task<Result<Vec<acp::SessionConfigOption>>>;
/// Whenever the config options are updated the receiver will be notified.
/// Optional for agents that don't update their config options dynamically.
fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
None
}
}
#[derive(Debug)]
pub struct AuthRequired {
pub description: Option<String>,
@@ -202,12 +230,15 @@ pub trait AgentModelSelector: 'static {
fn should_render_footer(&self) -> bool {
false
}
}
/// Whether this selector supports the favorites feature.
/// Only the native agent uses the model ID format that maps to settings.
fn supports_favorites(&self) -> bool {
false
}
/// Icon for a model in the model selector.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentModelIcon {
/// A built-in icon from Zed's icon set.
Named(IconName),
/// Path to a custom SVG icon file.
Path(SharedString),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -215,7 +246,7 @@ pub struct AgentModelInfo {
pub id: acp::ModelId,
pub name: SharedString,
pub description: Option<SharedString>,
pub icon: Option<IconOrSvg>,
pub icon: Option<AgentModelIcon>,
}
impl From<acp::ModelInfo> for AgentModelInfo {

View File

@@ -1,10 +1,10 @@
use anyhow::Result;
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
use buffer_diff::BufferDiff;
use editor::{MultiBuffer, PathKey, multibuffer_context_lines};
use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task};
use itertools::Itertools;
use language::{
Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _, Point, Rope, TextBuffer,
Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _, Point, TextBuffer,
};
use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc};
use util::ResultExt;
@@ -49,15 +49,15 @@ impl Diff {
.update(cx, |multibuffer, cx| {
let hunk_ranges = {
let buffer = buffer.read(cx);
let diff = diff.read(cx);
diff.hunks_intersecting_range(
Anchor::min_for_buffer(buffer.remote_id())
..Anchor::max_for_buffer(buffer.remote_id()),
buffer,
cx,
)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
.collect::<Vec<_>>()
diff.read(cx)
.snapshot(cx)
.hunks_intersecting_range(
Anchor::min_for_buffer(buffer.remote_id())
..Anchor::max_for_buffer(buffer.remote_id()),
buffer,
)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
.collect::<Vec<_>>()
};
multibuffer.set_excerpts_for_path(
@@ -86,17 +86,9 @@ impl Diff {
pub fn new(buffer: Entity<Buffer>, cx: &mut Context<Self>) -> Self {
let buffer_text_snapshot = buffer.read(cx).text_snapshot();
let base_text_snapshot = buffer.read(cx).snapshot();
let base_text = base_text_snapshot.text();
debug_assert_eq!(buffer_text_snapshot.text(), base_text);
let buffer_diff = cx.new(|cx| {
let mut diff = BufferDiff::new_unchanged(&buffer_text_snapshot, base_text_snapshot);
let snapshot = diff.snapshot(cx);
let secondary_diff = cx.new(|cx| {
let mut diff = BufferDiff::new(&buffer_text_snapshot, cx);
diff.set_snapshot(snapshot, &buffer_text_snapshot, cx);
diff
});
let mut diff = BufferDiff::new_unchanged(&buffer_text_snapshot, cx);
let secondary_diff = cx.new(|cx| BufferDiff::new_unchanged(&buffer_text_snapshot, cx));
diff.set_secondary_diff(secondary_diff);
diff
});
@@ -109,7 +101,7 @@ impl Diff {
Self::Pending(PendingDiff {
multibuffer,
base_text: Arc::new(base_text),
base_text: Arc::from(buffer_text_snapshot.text().as_str()),
_subscription: cx.observe(&buffer, |this, _, cx| {
if let Diff::Pending(diff) = this {
diff.update(cx);
@@ -176,7 +168,7 @@ impl Diff {
new_buffer,
..
}) => {
base_text.as_str() != old_text
base_text.as_ref() != old_text
|| !new_buffer.read(cx).as_rope().chunks().equals_str(new_text)
}
Diff::Finalized(FinalizedDiff {
@@ -184,7 +176,7 @@ impl Diff {
new_buffer,
..
}) => {
base_text.as_str() != old_text
base_text.as_ref() != old_text
|| !new_buffer.read(cx).as_rope().chunks().equals_str(new_text)
}
}
@@ -193,7 +185,7 @@ impl Diff {
pub struct PendingDiff {
multibuffer: Entity<MultiBuffer>,
base_text: Arc<String>,
base_text: Arc<str>,
new_buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
revealed_ranges: Vec<Range<Anchor>>,
@@ -208,21 +200,22 @@ impl PendingDiff {
let base_text = self.base_text.clone();
self.update_diff = cx.spawn(async move |diff, cx| {
let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
let diff_snapshot = BufferDiff::update_diff(
buffer_diff.clone(),
text_snapshot.clone(),
Some(base_text),
false,
false,
None,
None,
cx,
)
.await?;
let language = buffer.read_with(cx, |buffer, _| buffer.language().cloned())?;
let update = buffer_diff
.update(cx, |diff, cx| {
diff.update_diff(
text_snapshot.clone(),
Some(base_text.clone()),
false,
language,
cx,
)
})?
.await;
buffer_diff.update(cx, |diff, cx| {
diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx);
diff.set_snapshot(update.clone(), &text_snapshot, cx);
diff.secondary_diff().unwrap().update(cx, |diff, cx| {
diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx);
diff.set_snapshot(update, &text_snapshot, cx);
});
})?;
diff.update(cx, |diff, cx| {
@@ -319,13 +312,14 @@ impl PendingDiff {
fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
let buffer = self.new_buffer.read(cx);
let diff = self.diff.read(cx);
let mut ranges = diff
let mut ranges = self
.diff
.read(cx)
.snapshot(cx)
.hunks_intersecting_range(
Anchor::min_for_buffer(buffer.remote_id())
..Anchor::max_for_buffer(buffer.remote_id()),
buffer,
cx,
)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
.collect::<Vec<_>>();
@@ -357,60 +351,47 @@ impl PendingDiff {
pub struct FinalizedDiff {
path: String,
base_text: Arc<String>,
base_text: Arc<str>,
new_buffer: Entity<Buffer>,
multibuffer: Entity<MultiBuffer>,
_update_diff: Task<Result<()>>,
}
async fn build_buffer_diff(
old_text: Arc<String>,
old_text: Arc<str>,
buffer: &Entity<Buffer>,
language_registry: Option<Arc<LanguageRegistry>>,
cx: &mut AsyncApp,
) -> Result<Entity<BufferDiff>> {
let language = cx.update(|cx| buffer.read(cx).language().cloned())?;
let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
let old_text_rope = cx
.background_spawn({
let old_text = old_text.clone();
async move { Rope::from(old_text.as_str()) }
})
.await;
let base_buffer = cx
.update(|cx| {
Buffer::build_snapshot(
old_text_rope,
buffer.language().cloned(),
language_registry,
cx,
)
})?
.await;
let secondary_diff = cx.new(|cx| BufferDiff::new(&buffer, cx))?;
let diff_snapshot = cx
.update(|cx| {
BufferDiffSnapshot::new_with_base_buffer(
let update = secondary_diff
.update(cx, |secondary_diff, cx| {
secondary_diff.update_diff(
buffer.text.clone(),
Some(old_text),
base_buffer,
true,
language.clone(),
cx,
)
})?
.await;
let secondary_diff = cx.new(|cx| {
let mut diff = BufferDiff::new(&buffer, cx);
diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
diff
secondary_diff.update(cx, |secondary_diff, cx| {
secondary_diff.language_changed(language.clone(), language_registry.clone(), cx);
secondary_diff.set_snapshot(update.clone(), &buffer, cx);
})?;
cx.new(|cx| {
let mut diff = BufferDiff::new(&buffer.text, cx);
diff.set_snapshot(diff_snapshot, &buffer, cx);
let diff = cx.new(|cx| BufferDiff::new(&buffer, cx))?;
diff.update(cx, |diff, cx| {
diff.language_changed(language, language_registry, cx);
diff.set_snapshot(update.clone(), &buffer, cx);
diff.set_secondary_diff(secondary_diff);
diff
})
})?;
Ok(diff)
}
#[cfg(test)]

View File

@@ -4,22 +4,20 @@ use std::{
fmt::Display,
rc::{Rc, Weak},
sync::Arc,
time::Duration,
};
use agent_client_protocol as acp;
use collections::HashMap;
use gpui::{
App, ClipboardItem, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment,
ListState, StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list,
prelude::*,
App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState,
StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*,
};
use language::LanguageRegistry;
use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
use project::Project;
use settings::Settings;
use theme::ThemeSettings;
use ui::{Tooltip, WithScrollbar, prelude::*};
use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*};
use util::ResultExt as _;
use workspace::{
Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
@@ -544,15 +542,11 @@ impl Render for AcpTools {
pub struct AcpToolsToolbarItemView {
acp_tools: Option<Entity<AcpTools>>,
just_copied: bool,
}
impl AcpToolsToolbarItemView {
pub fn new() -> Self {
Self {
acp_tools: None,
just_copied: false,
}
Self { acp_tools: None }
}
}
@@ -572,37 +566,14 @@ impl Render for AcpToolsToolbarItemView {
h_flex()
.gap_2()
.child({
let acp_tools = acp_tools.clone();
IconButton::new(
"copy_all_messages",
if self.just_copied {
IconName::Check
} else {
IconName::Copy
},
)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text(if self.just_copied {
"Copied!"
} else {
"Copy All Messages"
}))
.disabled(!has_messages)
.on_click(cx.listener(move |this, _, _window, cx| {
if let Some(content) = acp_tools.read(cx).serialize_observed_messages() {
cx.write_to_clipboard(ClipboardItem::new_string(content));
let message = acp_tools
.read(cx)
.serialize_observed_messages()
.unwrap_or_default();
this.just_copied = true;
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
this.update(cx, |this, cx| {
this.just_copied = false;
cx.notify();
})
})
.detach();
}
}))
CopyButton::new(message)
.tooltip_label("Copy All Messages")
.disabled(!has_messages)
})
.child(
IconButton::new("clear_messages", IconName::Trash)

View File

@@ -6,7 +6,7 @@ use futures::{FutureExt, StreamExt, channel::mpsc};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
};
use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
use language::{Anchor, Buffer, BufferEvent, Point, ToPoint};
use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
use std::{cmp, ops::Range, sync::Arc};
use text::{Edit, Patch, Rope};
@@ -150,7 +150,7 @@ impl ActionLog {
if buffer
.read(cx)
.file()
.is_some_and(|file| file.disk_state() == DiskState::Deleted)
.is_some_and(|file| file.disk_state().is_deleted())
{
// If the buffer had been edited by a tool, but it got
// deleted externally, we want to stop tracking it.
@@ -162,7 +162,7 @@ impl ActionLog {
if buffer
.read(cx)
.file()
.is_some_and(|file| file.disk_state() != DiskState::Deleted)
.is_some_and(|file| !file.disk_state().is_deleted())
{
// If the buffer had been deleted by a tool, but it got
// resurrected externally, we want to clear the edits we
@@ -262,7 +262,7 @@ impl ActionLog {
);
}
(Arc::new(base_text.to_string()), base_text)
(Arc::from(base_text.to_string().as_str()), base_text)
}
});
@@ -302,7 +302,7 @@ impl ActionLog {
.context("buffer not tracked")?;
let old_unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
let agent_diff_base = tracked_buffer.diff_base.clone();
let git_diff_base = git_diff.read(cx).base_text().as_rope().clone();
let git_diff_base = git_diff.read(cx).base_text(cx).as_rope().clone();
let buffer_text = tracked_buffer.snapshot.as_rope().clone();
anyhow::Ok(cx.background_spawn(async move {
let mut old_unreviewed_edits = old_unreviewed_edits.into_iter().peekable();
@@ -352,7 +352,7 @@ impl ActionLog {
}
(
Arc::new(new_agent_diff_base.to_string()),
Arc::from(new_agent_diff_base.to_string().as_str()),
new_agent_diff_base,
)
}))
@@ -374,11 +374,11 @@ impl ActionLog {
this: &WeakEntity<ActionLog>,
buffer: &Entity<Buffer>,
buffer_snapshot: text::BufferSnapshot,
new_base_text: Arc<String>,
new_base_text: Arc<str>,
new_diff_base: Rope,
cx: &mut AsyncApp,
) -> Result<()> {
let (diff, language, language_registry) = this.read_with(cx, |this, cx| {
let (diff, language) = this.read_with(cx, |this, cx| {
let tracked_buffer = this
.tracked_buffers
.get(buffer)
@@ -386,25 +386,28 @@ impl ActionLog {
anyhow::Ok((
tracked_buffer.diff.clone(),
buffer.read(cx).language().cloned(),
buffer.read(cx).language_registry(),
))
})??;
let diff_snapshot = BufferDiff::update_diff(
diff.clone(),
buffer_snapshot.clone(),
Some(new_base_text),
true,
false,
language,
language_registry,
cx,
)
.await;
let update = diff.update(cx, |diff, cx| {
diff.update_diff(
buffer_snapshot.clone(),
Some(new_base_text),
true,
language,
cx,
)
});
let mut unreviewed_edits = Patch::default();
if let Ok(diff_snapshot) = diff_snapshot {
if let Ok(update) = update {
let update = update.await;
let diff_snapshot = diff.update(cx, |diff, cx| {
diff.set_snapshot(update.clone(), &buffer_snapshot, cx);
diff.snapshot(cx)
})?;
unreviewed_edits = cx
.background_spawn({
let diff_snapshot = diff_snapshot.clone();
let buffer_snapshot = buffer_snapshot.clone();
let new_diff_base = new_diff_base.clone();
async move {
@@ -431,10 +434,6 @@ impl ActionLog {
}
})
.await;
diff.update(cx, |diff, cx| {
diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx);
})?;
}
this.update(cx, |this, cx| {
let tracked_buffer = this
@@ -769,7 +768,7 @@ impl ActionLog {
tracked.version != buffer.version
&& buffer
.file()
.is_some_and(|file| file.disk_state() != DiskState::Deleted)
.is_some_and(|file| !file.disk_state().is_deleted())
})
.map(|(buffer, _)| buffer)
}
@@ -975,7 +974,8 @@ impl TrackedBuffer {
fn has_edits(&self, cx: &App) -> bool {
self.diff
.read(cx)
.hunks(self.buffer.read(cx), cx)
.snapshot(cx)
.hunks(self.buffer.read(cx))
.next()
.is_some()
}
@@ -2388,13 +2388,14 @@ mod tests {
(
buffer,
diff.read(cx)
.hunks(&snapshot, cx)
.snapshot(cx)
.hunks(&snapshot)
.map(|hunk| HunkStatus {
diff_status: hunk.status().kind,
range: hunk.range,
old_text: diff
.read(cx)
.base_text()
.base_text(cx)
.text_for_range(hunk.diff_base_byte_range)
.collect(),
})

View File

@@ -739,7 +739,7 @@ impl ActivityIndicator {
extension_store.outstanding_operations().iter().next()
{
let (message, icon, rotate) = match operation {
ExtensionOperation::Install | ExtensionOperation::AutoInstall => (
ExtensionOperation::Install => (
format!("Installing {extension_id} extension…"),
IconName::LoadCircle,
true,

View File

@@ -30,7 +30,7 @@ use futures::{StreamExt, future};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
};
use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry};
use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry};
use project::{Project, ProjectItem, ProjectPath, Worktree};
use prompt_store::{
ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext,
@@ -153,7 +153,10 @@ impl LanguageModels {
id: Self::model_id(model),
name: model.name().0,
description: None,
icon: Some(provider.icon()),
icon: Some(match provider.icon() {
IconOrSvg::Svg(path) => acp_thread::AgentModelIcon::Path(path),
IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name),
}),
}
}
@@ -1164,10 +1167,6 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
fn should_render_footer(&self) -> bool {
true
}
fn supports_favorites(&self) -> bool {
true
}
}
impl acp_thread::AgentConnection for NativeAgentConnection {
@@ -1630,7 +1629,9 @@ mod internal_tests {
id: acp::ModelId::new("fake/fake"),
name: "Fake".into(),
description: None,
icon: Some(language_model::IconOrSvg::Icon(ui::IconName::ZedAssistant)),
icon: Some(acp_thread::AgentModelIcon::Named(
ui::IconName::ZedAssistant
)),
}]
)])
);

View File

@@ -1,10 +1,14 @@
use std::{any::Any, path::Path, rc::Rc, sync::Arc};
use agent_client_protocol as acp;
use agent_servers::{AgentServer, AgentServerDelegate};
use agent_settings::AgentSettings;
use anyhow::Result;
use collections::HashSet;
use fs::Fs;
use gpui::{App, Entity, SharedString, Task};
use prompt_store::PromptStore;
use settings::{LanguageModelSelection, Settings as _, update_settings_file};
use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates};
@@ -71,6 +75,38 @@ impl AgentServer for NativeAgentServer {
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
AgentSettings::get_global(cx).favorite_model_ids()
}
fn toggle_favorite_model(
&self,
model_id: acp::ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let selection = model_id_to_selection(&model_id);
update_settings_file(fs, cx, move |settings, _| {
let agent = settings.agent.get_or_insert_default();
if should_be_favorite {
agent.add_favorite_model(selection.clone());
} else {
agent.remove_favorite_model(&selection);
}
});
}
}
/// Convert a ModelId (e.g. "anthropic/claude-3-5-sonnet") to a LanguageModelSelection.
fn model_id_to_selection(model_id: &acp::ModelId) -> LanguageModelSelection {
let id = model_id.0.as_ref();
let (provider, model) = id.split_once('/').unwrap_or(("", id));
LanguageModelSelection {
provider: provider.to_owned().into(),
model: model.to_owned(),
}
}
#[cfg(test)]

View File

@@ -5,7 +5,7 @@ use futures::StreamExt;
use gpui::{App, Entity, SharedString, Task};
use language::{OffsetRangeExt, ParseStatus, Point};
use project::{
Project, WorktreeSettings,
Project, SearchResults, WorktreeSettings,
search::{SearchQuery, SearchResult},
};
use schemars::JsonSchema;
@@ -176,14 +176,17 @@ impl AgentTool for GrepTool {
let project = self.project.downgrade();
cx.spawn(async move |cx| {
futures::pin_mut!(results);
// Keep the search alive for the duration of result iteration. Dropping this task is the
// cancellation mechanism; we intentionally do not detach it.
let SearchResults {rx, _task_handle} = results;
futures::pin_mut!(rx);
let mut output = String::new();
let mut skips_remaining = input.offset;
let mut matches_found = 0;
let mut has_more_matches = false;
'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await {
'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = rx.next().await {
if ranges.is_empty() {
continue;
}

View File

@@ -21,6 +21,7 @@ acp_tools.workspace = true
acp_thread.workspace = true
action_log.workspace = true
agent-client-protocol.workspace = true
feature_flags.workspace = true
anyhow.workspace = true
async-trait.workspace = true
client.workspace = true

View File

@@ -4,6 +4,7 @@ use action_log::ActionLog;
use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
use anyhow::anyhow;
use collections::HashMap;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use futures::AsyncBufReadExt as _;
use futures::io::BufReader;
use project::Project;
@@ -12,8 +13,10 @@ use serde::Deserialize;
use settings::Settings as _;
use task::ShellBuilder;
use util::ResultExt as _;
use util::process::Child;
use std::path::PathBuf;
use std::process::Stdio;
use std::{any::Any, cell::RefCell};
use std::{path::Path, rc::Rc};
use thiserror::Error;
@@ -38,20 +41,37 @@ pub struct AcpConnection {
agent_capabilities: acp::AgentCapabilities,
default_mode: Option<acp::SessionModeId>,
default_model: Option<acp::ModelId>,
default_config_options: HashMap<String, String>,
root_dir: PathBuf,
// NB: Don't move this into the wait_task, since we need to ensure the process is
// killed on drop (setting kill_on_drop on the command seems to not always work).
child: smol::process::Child,
child: Child,
_io_task: Task<Result<(), acp::Error>>,
_wait_task: Task<Result<()>>,
_stderr_task: Task<Result<()>>,
}
struct ConfigOptions {
config_options: Rc<RefCell<Vec<acp::SessionConfigOption>>>,
tx: Rc<RefCell<watch::Sender<()>>>,
rx: watch::Receiver<()>,
}
impl ConfigOptions {
fn new(config_options: Rc<RefCell<Vec<acp::SessionConfigOption>>>) -> Self {
let (tx, rx) = watch::channel(());
Self {
config_options,
tx: Rc::new(RefCell::new(tx)),
rx,
}
}
}
pub struct AcpSession {
thread: WeakEntity<AcpThread>,
suppress_abort_err: bool,
models: Option<Rc<RefCell<acp::SessionModelState>>>,
session_modes: Option<Rc<RefCell<acp::SessionModeState>>>,
config_options: Option<ConfigOptions>,
}
pub async fn connect(
@@ -60,6 +80,7 @@ pub async fn connect(
root_dir: &Path,
default_mode: Option<acp::SessionModeId>,
default_model: Option<acp::ModelId>,
default_config_options: HashMap<String, String>,
is_remote: bool,
cx: &mut AsyncApp,
) -> Result<Rc<dyn AgentConnection>> {
@@ -69,6 +90,7 @@ pub async fn connect(
root_dir,
default_mode,
default_model,
default_config_options,
is_remote,
cx,
)
@@ -85,22 +107,19 @@ impl AcpConnection {
root_dir: &Path,
default_mode: Option<acp::SessionModeId>,
default_model: Option<acp::ModelId>,
default_config_options: HashMap<String, String>,
is_remote: bool,
cx: &mut AsyncApp,
) -> Result<Self> {
let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?;
let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive();
let mut child =
builder.build_command(Some(command.path.display().to_string()), &command.args);
child
.envs(command.env.iter().flatten())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
builder.build_std_command(Some(command.path.display().to_string()), &command.args);
child.envs(command.env.iter().flatten());
if !is_remote {
child.current_dir(root_dir);
}
let mut child = child.spawn()?;
let mut child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?;
let stdout = child.stdout.take().context("Failed to take stdout")?;
let stdin = child.stdin.take().context("Failed to take stdin")?;
@@ -217,6 +236,7 @@ impl AcpConnection {
agent_capabilities: response.agent_capabilities,
default_mode,
default_model,
default_config_options,
_io_task: io_task,
_wait_task: wait_task,
_stderr_task: stderr_task,
@@ -235,7 +255,6 @@ impl AcpConnection {
impl Drop for AcpConnection {
fn drop(&mut self) {
// See the comment on the child field.
self.child.kill().log_err();
}
}
@@ -256,6 +275,7 @@ impl AgentConnection for AcpConnection {
let sessions = self.sessions.clone();
let default_mode = self.default_mode.clone();
let default_model = self.default_model.clone();
let default_config_options = self.default_config_options.clone();
let cwd = cwd.to_path_buf();
let context_server_store = project.read(cx).context_server_store().read(cx);
let mcp_servers = if project.read(cx).is_local() {
@@ -322,8 +342,21 @@ impl AgentConnection for AcpConnection {
}
})?;
let modes = response.modes.map(|modes| Rc::new(RefCell::new(modes)));
let models = response.models.map(|models| Rc::new(RefCell::new(models)));
let use_config_options = cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>())?;
// Config options take precedence over legacy modes/models
let (modes, models, config_options) = if use_config_options && let Some(opts) = response.config_options {
(
None,
None,
Some(Rc::new(RefCell::new(opts))),
)
} else {
// Fall back to legacy modes/models
let modes = response.modes.map(|modes| Rc::new(RefCell::new(modes)));
let models = response.models.map(|models| Rc::new(RefCell::new(models)));
(modes, models, None)
};
if let Some(default_mode) = default_mode {
if let Some(modes) = modes.as_ref() {
@@ -411,6 +444,92 @@ impl AgentConnection for AcpConnection {
}
}
if let Some(config_opts) = config_options.as_ref() {
let defaults_to_apply: Vec<_> = {
let config_opts_ref = config_opts.borrow();
config_opts_ref
.iter()
.filter_map(|config_option| {
let default_value = default_config_options.get(&*config_option.id.0)?;
let is_valid = match &config_option.kind {
acp::SessionConfigKind::Select(select) => match &select.options {
acp::SessionConfigSelectOptions::Ungrouped(options) => {
options.iter().any(|opt| &*opt.value.0 == default_value.as_str())
}
acp::SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.any(|g| g.options.iter().any(|opt| &*opt.value.0 == default_value.as_str())),
_ => false,
},
_ => false,
};
if is_valid {
let initial_value = match &config_option.kind {
acp::SessionConfigKind::Select(select) => {
Some(select.current_value.clone())
}
_ => None,
};
Some((config_option.id.clone(), default_value.clone(), initial_value))
} else {
log::warn!(
"`{}` is not a valid value for config option `{}` in {}",
default_value,
config_option.id.0,
name
);
None
}
})
.collect()
};
for (config_id, default_value, initial_value) in defaults_to_apply {
cx.spawn({
let default_value_id = acp::SessionConfigValueId::new(default_value.clone());
let session_id = response.session_id.clone();
let config_id_clone = config_id.clone();
let config_opts = config_opts.clone();
let conn = conn.clone();
async move |_| {
let result = conn
.set_session_config_option(
acp::SetSessionConfigOptionRequest::new(
session_id,
config_id_clone.clone(),
default_value_id,
),
)
.await
.log_err();
if result.is_none() {
if let Some(initial) = initial_value {
let mut opts = config_opts.borrow_mut();
if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) {
if let acp::SessionConfigKind::Select(select) =
&mut opt.kind
{
select.current_value = initial;
}
}
}
}
}
})
.detach();
let mut opts = config_opts.borrow_mut();
if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id) {
if let acp::SessionConfigKind::Select(select) = &mut opt.kind {
select.current_value = acp::SessionConfigValueId::new(default_value);
}
}
}
}
let session_id = response.session_id;
let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
let thread = cx.new(|cx| {
@@ -432,6 +551,7 @@ impl AgentConnection for AcpConnection {
suppress_abort_err: false,
session_modes: modes,
models,
config_options: config_options.map(|opts| ConfigOptions::new(opts))
};
sessions.borrow_mut().insert(session_id, session);
@@ -567,6 +687,25 @@ impl AgentConnection for AcpConnection {
}
}
fn session_config_options(
&self,
session_id: &acp::SessionId,
_cx: &App,
) -> Option<Rc<dyn acp_thread::AgentSessionConfigOptions>> {
let sessions = self.sessions.borrow();
let session = sessions.get(session_id)?;
let config_opts = session.config_options.as_ref()?;
Some(Rc::new(AcpSessionConfigOptions {
session_id: session_id.clone(),
connection: self.connection.clone(),
state: config_opts.config_options.clone(),
watch_tx: config_opts.tx.clone(),
watch_rx: config_opts.rx.clone(),
}) as _)
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
@@ -685,6 +824,49 @@ impl acp_thread::AgentModelSelector for AcpModelSelector {
}
}
struct AcpSessionConfigOptions {
session_id: acp::SessionId,
connection: Rc<acp::ClientSideConnection>,
state: Rc<RefCell<Vec<acp::SessionConfigOption>>>,
watch_tx: Rc<RefCell<watch::Sender<()>>>,
watch_rx: watch::Receiver<()>,
}
impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions {
fn config_options(&self) -> Vec<acp::SessionConfigOption> {
self.state.borrow().clone()
}
fn set_config_option(
&self,
config_id: acp::SessionConfigId,
value: acp::SessionConfigValueId,
cx: &mut App,
) -> Task<Result<Vec<acp::SessionConfigOption>>> {
let connection = self.connection.clone();
let session_id = self.session_id.clone();
let state = self.state.clone();
let watch_tx = self.watch_tx.clone();
cx.foreground_executor().spawn(async move {
let response = connection
.set_session_config_option(acp::SetSessionConfigOptionRequest::new(
session_id, config_id, value,
))
.await?;
*state.borrow_mut() = response.config_options.clone();
watch_tx.borrow_mut().send(()).ok();
Ok(response.config_options)
})
}
fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
Some(self.watch_rx.clone())
}
}
struct ClientDelegate {
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
cx: AsyncApp,
@@ -778,6 +960,21 @@ impl acp::Client for ClientDelegate {
}
}
if let acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate {
config_options,
..
}) = &notification.update
{
if let Some(opts) = &session.config_options {
*opts.config_options.borrow_mut() = config_options.clone();
opts.tx.borrow_mut().send(()).ok();
} else {
log::error!(
"Got a `ConfigOptionUpdate` notification, but the agent didn't specify `config_options` during session setup."
);
}
}
// Clone so we can inspect meta both before and after handing off to the thread
let update_clone = notification.update.clone();

View File

@@ -10,7 +10,7 @@ pub mod e2e_tests;
pub use claude::*;
use client::ProxySettings;
pub use codex::*;
use collections::HashMap;
use collections::{HashMap, HashSet};
pub use custom::*;
use fs::Fs;
pub use gemini::*;
@@ -56,9 +56,19 @@ impl AgentServerDelegate {
pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName;
fn name(&self) -> SharedString;
fn default_mode(&self, _cx: &mut App) -> Option<agent_client_protocol::SessionModeId> {
fn connect(
&self,
root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>>;
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
fn default_mode(&self, _cx: &App) -> Option<agent_client_protocol::SessionModeId> {
None
}
fn set_default_mode(
&self,
_mode_id: Option<agent_client_protocol::SessionModeId>,
@@ -67,7 +77,7 @@ pub trait AgentServer: Send {
) {
}
fn default_model(&self, _cx: &mut App) -> Option<agent_client_protocol::ModelId> {
fn default_model(&self, _cx: &App) -> Option<agent_client_protocol::ModelId> {
None
}
@@ -79,14 +89,49 @@ pub trait AgentServer: Send {
) {
}
fn connect(
&self,
root_dir: Option<&Path>,
delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>>;
fn favorite_model_ids(&self, _cx: &mut App) -> HashSet<agent_client_protocol::ModelId> {
HashSet::default()
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
fn default_config_option(&self, _config_id: &str, _cx: &App) -> Option<String> {
None
}
fn set_default_config_option(
&self,
_config_id: &str,
_value_id: Option<&str>,
_fs: Arc<dyn Fs>,
_cx: &mut App,
) {
}
fn favorite_config_option_value_ids(
&self,
_config_id: &agent_client_protocol::SessionConfigId,
_cx: &mut App,
) -> HashSet<agent_client_protocol::SessionConfigValueId> {
HashSet::default()
}
fn toggle_favorite_config_option_value(
&self,
_config_id: agent_client_protocol::SessionConfigId,
_value_id: agent_client_protocol::SessionConfigValueId,
_should_be_favorite: bool,
_fs: Arc<dyn Fs>,
_cx: &App,
) {
}
fn toggle_favorite_model(
&self,
_model_id: agent_client_protocol::ModelId,
_should_be_favorite: bool,
_fs: Arc<dyn Fs>,
_cx: &App,
) {
}
}
impl dyn AgentServer {

View File

@@ -1,4 +1,5 @@
use agent_client_protocol as acp;
use collections::HashSet;
use fs::Fs;
use settings::{SettingsStore, update_settings_file};
use std::path::Path;
@@ -30,7 +31,7 @@ impl AgentServer for ClaudeCode {
ui::IconName::AiClaude
}
fn default_mode(&self, cx: &mut App) -> Option<acp::SessionModeId> {
fn default_mode(&self, cx: &App) -> Option<acp::SessionModeId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
});
@@ -51,7 +52,7 @@ impl AgentServer for ClaudeCode {
});
}
fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
fn default_model(&self, cx: &App) -> Option<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
});
@@ -72,6 +73,139 @@ impl AgentServer for ClaudeCode {
});
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
});
settings
.as_ref()
.map(|s| {
s.favorite_models
.iter()
.map(|id| acp::ModelId::new(id.clone()))
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_model(
&self,
model_id: acp::ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
update_settings_file(fs, cx, move |settings, _| {
let favorite_models = &mut settings
.agent_servers
.get_or_insert_default()
.claude
.get_or_insert_default()
.favorite_models;
let model_id_str = model_id.to_string();
if should_be_favorite {
if !favorite_models.contains(&model_id_str) {
favorite_models.push(model_id_str);
}
} else {
favorite_models.retain(|id| id != &model_id_str);
}
});
}
fn default_config_option(&self, config_id: &str, cx: &App) -> Option<String> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
});
settings
.as_ref()
.and_then(|s| s.default_config_options.get(config_id).cloned())
}
fn set_default_config_option(
&self,
config_id: &str,
value_id: Option<&str>,
fs: Arc<dyn Fs>,
cx: &mut App,
) {
let config_id = config_id.to_string();
let value_id = value_id.map(|s| s.to_string());
update_settings_file(fs, cx, move |settings, _| {
let config_options = &mut settings
.agent_servers
.get_or_insert_default()
.claude
.get_or_insert_default()
.default_config_options;
if let Some(value) = value_id.clone() {
config_options.insert(config_id.clone(), value);
} else {
config_options.remove(&config_id);
}
});
}
fn favorite_config_option_value_ids(
&self,
config_id: &acp::SessionConfigId,
cx: &mut App,
) -> HashSet<acp::SessionConfigValueId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
});
settings
.as_ref()
.and_then(|s| s.favorite_config_option_values.get(config_id.0.as_ref()))
.map(|values| {
values
.iter()
.cloned()
.map(acp::SessionConfigValueId::new)
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_config_option_value(
&self,
config_id: acp::SessionConfigId,
value_id: acp::SessionConfigValueId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let config_id = config_id.to_string();
let value_id = value_id.to_string();
update_settings_file(fs, cx, move |settings, _| {
let favorites = &mut settings
.agent_servers
.get_or_insert_default()
.claude
.get_or_insert_default()
.favorite_config_option_values;
let entry = favorites.entry(config_id.clone()).or_insert_with(Vec::new);
if should_be_favorite {
if !entry.iter().any(|v| v == &value_id) {
entry.push(value_id.clone());
}
} else {
entry.retain(|v| v != &value_id);
if entry.is_empty() {
favorites.remove(&config_id);
}
}
});
}
fn connect(
&self,
root_dir: Option<&Path>,
@@ -85,6 +219,14 @@ impl AgentServer for ClaudeCode {
let extra_env = load_proxy_env(cx);
let default_mode = self.default_mode(cx);
let default_model = self.default_model(cx);
let default_config_options = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.claude
.as_ref()
.map(|s| s.default_config_options.clone())
.unwrap_or_default()
});
cx.spawn(async move |cx| {
let (command, root_dir, login) = store
@@ -107,6 +249,7 @@ impl AgentServer for ClaudeCode {
root_dir.as_ref(),
default_mode,
default_model,
default_config_options,
is_remote,
cx,
)

View File

@@ -5,6 +5,7 @@ use std::{any::Any, path::Path};
use acp_thread::AgentConnection;
use agent_client_protocol as acp;
use anyhow::{Context as _, Result};
use collections::HashSet;
use fs::Fs;
use gpui::{App, AppContext as _, SharedString, Task};
use project::agent_server_store::{AllAgentServersSettings, CODEX_NAME};
@@ -31,7 +32,7 @@ impl AgentServer for Codex {
ui::IconName::AiOpenAi
}
fn default_mode(&self, cx: &mut App) -> Option<acp::SessionModeId> {
fn default_mode(&self, cx: &App) -> Option<acp::SessionModeId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).codex.clone()
});
@@ -52,7 +53,7 @@ impl AgentServer for Codex {
});
}
fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
fn default_model(&self, cx: &App) -> Option<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).codex.clone()
});
@@ -73,6 +74,139 @@ impl AgentServer for Codex {
});
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).codex.clone()
});
settings
.as_ref()
.map(|s| {
s.favorite_models
.iter()
.map(|id| acp::ModelId::new(id.clone()))
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_model(
&self,
model_id: acp::ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
update_settings_file(fs, cx, move |settings, _| {
let favorite_models = &mut settings
.agent_servers
.get_or_insert_default()
.codex
.get_or_insert_default()
.favorite_models;
let model_id_str = model_id.to_string();
if should_be_favorite {
if !favorite_models.contains(&model_id_str) {
favorite_models.push(model_id_str);
}
} else {
favorite_models.retain(|id| id != &model_id_str);
}
});
}
fn default_config_option(&self, config_id: &str, cx: &App) -> Option<String> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).codex.clone()
});
settings
.as_ref()
.and_then(|s| s.default_config_options.get(config_id).cloned())
}
fn set_default_config_option(
&self,
config_id: &str,
value_id: Option<&str>,
fs: Arc<dyn Fs>,
cx: &mut App,
) {
let config_id = config_id.to_string();
let value_id = value_id.map(|s| s.to_string());
update_settings_file(fs, cx, move |settings, _| {
let config_options = &mut settings
.agent_servers
.get_or_insert_default()
.codex
.get_or_insert_default()
.default_config_options;
if let Some(value) = value_id.clone() {
config_options.insert(config_id.clone(), value);
} else {
config_options.remove(&config_id);
}
});
}
fn favorite_config_option_value_ids(
&self,
config_id: &acp::SessionConfigId,
cx: &mut App,
) -> HashSet<acp::SessionConfigValueId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).codex.clone()
});
settings
.as_ref()
.and_then(|s| s.favorite_config_option_values.get(config_id.0.as_ref()))
.map(|values| {
values
.iter()
.cloned()
.map(acp::SessionConfigValueId::new)
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_config_option_value(
&self,
config_id: acp::SessionConfigId,
value_id: acp::SessionConfigValueId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let config_id = config_id.to_string();
let value_id = value_id.to_string();
update_settings_file(fs, cx, move |settings, _| {
let favorites = &mut settings
.agent_servers
.get_or_insert_default()
.codex
.get_or_insert_default()
.favorite_config_option_values;
let entry = favorites.entry(config_id.clone()).or_insert_with(Vec::new);
if should_be_favorite {
if !entry.iter().any(|v| v == &value_id) {
entry.push(value_id.clone());
}
} else {
entry.retain(|v| v != &value_id);
if entry.is_empty() {
favorites.remove(&config_id);
}
}
});
}
fn connect(
&self,
root_dir: Option<&Path>,
@@ -86,6 +220,14 @@ impl AgentServer for Codex {
let extra_env = load_proxy_env(cx);
let default_mode = self.default_mode(cx);
let default_model = self.default_model(cx);
let default_config_options = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.codex
.as_ref()
.map(|s| s.default_config_options.clone())
.unwrap_or_default()
});
cx.spawn(async move |cx| {
let (command, root_dir, login) = store
@@ -109,6 +251,7 @@ impl AgentServer for Codex {
root_dir.as_ref(),
default_mode,
default_model,
default_config_options,
is_remote,
cx,
)

View File

@@ -2,6 +2,7 @@ use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
use acp_thread::AgentConnection;
use agent_client_protocol as acp;
use anyhow::{Context as _, Result};
use collections::HashSet;
use fs::Fs;
use gpui::{App, AppContext as _, SharedString, Task};
use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName};
@@ -29,7 +30,7 @@ impl AgentServer for CustomAgentServer {
IconName::Terminal
}
fn default_mode(&self, cx: &mut App) -> Option<acp::SessionModeId> {
fn default_mode(&self, cx: &App) -> Option<acp::SessionModeId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
@@ -43,6 +44,86 @@ impl AgentServer for CustomAgentServer {
.and_then(|s| s.default_mode().map(acp::SessionModeId::new))
}
fn favorite_config_option_value_ids(
&self,
config_id: &acp::SessionConfigId,
cx: &mut App,
) -> HashSet<acp::SessionConfigValueId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.custom
.get(&self.name())
.cloned()
});
settings
.as_ref()
.and_then(|s| s.favorite_config_option_values(config_id.0.as_ref()))
.map(|values| {
values
.iter()
.cloned()
.map(acp::SessionConfigValueId::new)
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_config_option_value(
&self,
config_id: acp::SessionConfigId,
value_id: acp::SessionConfigValueId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let name = self.name();
let config_id = config_id.to_string();
let value_id = value_id.to_string();
update_settings_file(fs, cx, move |settings, _| {
let settings = settings
.agent_servers
.get_or_insert_default()
.custom
.entry(name.clone())
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
});
match settings {
settings::CustomAgentServerSettings::Custom {
favorite_config_option_values,
..
}
| settings::CustomAgentServerSettings::Extension {
favorite_config_option_values,
..
} => {
let entry = favorite_config_option_values
.entry(config_id.clone())
.or_insert_with(Vec::new);
if should_be_favorite {
if !entry.iter().any(|v| v == &value_id) {
entry.push(value_id.clone());
}
} else {
entry.retain(|v| v != &value_id);
if entry.is_empty() {
favorite_config_option_values.remove(&config_id);
}
}
}
}
});
}
fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
let name = self.name();
update_settings_file(fs, cx, move |settings, _| {
@@ -54,6 +135,9 @@ impl AgentServer for CustomAgentServer {
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
});
match settings {
@@ -65,7 +149,7 @@ impl AgentServer for CustomAgentServer {
});
}
fn default_model(&self, cx: &mut App) -> Option<acp::ModelId> {
fn default_model(&self, cx: &App) -> Option<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
@@ -90,6 +174,9 @@ impl AgentServer for CustomAgentServer {
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
});
match settings {
@@ -101,6 +188,125 @@ impl AgentServer for CustomAgentServer {
});
}
fn favorite_model_ids(&self, cx: &mut App) -> HashSet<acp::ModelId> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.custom
.get(&self.name())
.cloned()
});
settings
.as_ref()
.map(|s| {
s.favorite_models()
.iter()
.map(|id| acp::ModelId::new(id.clone()))
.collect()
})
.unwrap_or_default()
}
fn toggle_favorite_model(
&self,
model_id: acp::ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let name = self.name();
update_settings_file(fs, cx, move |settings, _| {
let settings = settings
.agent_servers
.get_or_insert_default()
.custom
.entry(name.clone())
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
});
let favorite_models = match settings {
settings::CustomAgentServerSettings::Custom {
favorite_models, ..
}
| settings::CustomAgentServerSettings::Extension {
favorite_models, ..
} => favorite_models,
};
let model_id_str = model_id.to_string();
if should_be_favorite {
if !favorite_models.contains(&model_id_str) {
favorite_models.push(model_id_str);
}
} else {
favorite_models.retain(|id| id != &model_id_str);
}
});
}
fn default_config_option(&self, config_id: &str, cx: &App) -> Option<String> {
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.custom
.get(&self.name())
.cloned()
});
settings
.as_ref()
.and_then(|s| s.default_config_option(config_id).map(|s| s.to_string()))
}
fn set_default_config_option(
&self,
config_id: &str,
value_id: Option<&str>,
fs: Arc<dyn Fs>,
cx: &mut App,
) {
let name = self.name();
let config_id = config_id.to_string();
let value_id = value_id.map(|s| s.to_string());
update_settings_file(fs, cx, move |settings, _| {
let settings = settings
.agent_servers
.get_or_insert_default()
.custom
.entry(name.clone())
.or_insert_with(|| settings::CustomAgentServerSettings::Extension {
default_model: None,
default_mode: None,
favorite_models: Vec::new(),
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
});
match settings {
settings::CustomAgentServerSettings::Custom {
default_config_options,
..
}
| settings::CustomAgentServerSettings::Extension {
default_config_options,
..
} => {
if let Some(value) = value_id.clone() {
default_config_options.insert(config_id.clone(), value);
} else {
default_config_options.remove(&config_id);
}
}
}
});
}
fn connect(
&self,
root_dir: Option<&Path>,
@@ -112,6 +318,23 @@ impl AgentServer for CustomAgentServer {
let is_remote = delegate.project.read(cx).is_via_remote_server();
let default_mode = self.default_mode(cx);
let default_model = self.default_model(cx);
let default_config_options = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.custom
.get(&self.name())
.map(|s| match s {
project::agent_server_store::CustomAgentServerSettings::Custom {
default_config_options,
..
}
| project::agent_server_store::CustomAgentServerSettings::Extension {
default_config_options,
..
} => default_config_options.clone(),
})
.unwrap_or_default()
});
let store = delegate.store.downgrade();
let extra_env = load_proxy_env(cx);
cx.spawn(async move |cx| {
@@ -137,6 +360,7 @@ impl AgentServer for CustomAgentServer {
root_dir.as_ref(),
default_mode,
default_model,
default_config_options,
is_remote,
cx,
)

View File

@@ -455,20 +455,12 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
project::agent_server_store::AllAgentServersSettings {
claude: Some(BuiltinAgentServerSettings {
path: Some("claude-code-acp".into()),
args: None,
env: None,
ignore_system_version: None,
default_mode: None,
default_model: None,
..Default::default()
}),
gemini: Some(crate::gemini::tests::local_command().into()),
codex: Some(BuiltinAgentServerSettings {
path: Some("codex-acp".into()),
args: None,
env: None,
ignore_system_version: None,
default_mode: None,
default_model: None,
..Default::default()
}),
custom: collections::HashMap::default(),
},

View File

@@ -4,9 +4,10 @@ use std::{any::Any, path::Path};
use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
use acp_thread::AgentConnection;
use anyhow::{Context as _, Result};
use gpui::{App, SharedString, Task};
use language_models::api_key_for_gemini_cli;
use project::agent_server_store::GEMINI_NAME;
use gpui::{App, AppContext as _, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider;
use project::agent_server_store::{AllAgentServersSettings, GEMINI_NAME};
use settings::SettingsStore;
#[derive(Clone)]
pub struct Gemini;
@@ -33,11 +34,23 @@ impl AgentServer for Gemini {
let mut extra_env = load_proxy_env(cx);
let default_mode = self.default_mode(cx);
let default_model = self.default_model(cx);
let default_config_options = cx.read_global(|settings: &SettingsStore, _| {
settings
.get::<AllAgentServersSettings>(None)
.gemini
.as_ref()
.map(|s| s.default_config_options.clone())
.unwrap_or_default()
});
cx.spawn(async move |cx| {
extra_env.insert("SURFACE".to_owned(), "zed".to_owned());
if let Some(api_key) = cx.update(api_key_for_gemini_cli)?.await.ok() {
if let Some(api_key) = cx
.update(GoogleLanguageModelProvider::api_key_for_gemini_cli)?
.await
.ok()
{
extra_env.insert("GEMINI_API_KEY".into(), api_key);
}
let (command, root_dir, login) = store
@@ -61,6 +74,7 @@ impl AgentServer for Gemini {
root_dir.as_ref(),
default_mode,
default_model,
default_config_options,
is_remote,
cx,
)

View File

@@ -1,3 +1,4 @@
mod config_options;
mod entry_view_state;
mod message_editor;
mod mode_selector;

View File

@@ -0,0 +1,775 @@
use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::AgentSessionConfigOptions;
use agent_client_protocol as acp;
use agent_servers::AgentServer;
use collections::HashSet;
use fs::Fs;
use fuzzy::StringMatchCandidate;
use gpui::{
BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*,
};
use ordered_float::OrderedFloat;
use picker::popover_menu::PickerPopoverMenu;
use picker::{Picker, PickerDelegate};
use settings::SettingsStore;
use ui::{
ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
};
use util::ResultExt as _;
use crate::ui::HoldForDefault;
const PICKER_THRESHOLD: usize = 5;
pub struct ConfigOptionsView {
config_options: Rc<dyn AgentSessionConfigOptions>,
selectors: Vec<Entity<ConfigOptionSelector>>,
agent_server: Rc<dyn AgentServer>,
fs: Arc<dyn Fs>,
config_option_ids: Vec<acp::SessionConfigId>,
_refresh_task: Task<()>,
}
impl ConfigOptionsView {
pub fn new(
config_options: Rc<dyn AgentSessionConfigOptions>,
agent_server: Rc<dyn AgentServer>,
fs: Arc<dyn Fs>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let selectors = Self::build_selectors(&config_options, &agent_server, &fs, window, cx);
let config_option_ids = Self::config_option_ids(&config_options);
let rx = config_options.watch(cx);
let refresh_task = cx.spawn_in(window, async move |this, cx| {
if let Some(mut rx) = rx {
while let Ok(()) = rx.recv().await {
this.update_in(cx, |this, window, cx| {
this.refresh_selectors_if_needed(window, cx);
cx.notify();
})
.log_err();
}
}
});
Self {
config_options,
selectors,
agent_server,
fs,
config_option_ids,
_refresh_task: refresh_task,
}
}
fn config_option_ids(
config_options: &Rc<dyn AgentSessionConfigOptions>,
) -> Vec<acp::SessionConfigId> {
config_options
.config_options()
.into_iter()
.map(|option| option.id)
.collect()
}
fn refresh_selectors_if_needed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let current_ids = Self::config_option_ids(&self.config_options);
if current_ids != self.config_option_ids {
self.config_option_ids = current_ids;
self.rebuild_selectors(window, cx);
}
}
fn rebuild_selectors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.selectors = Self::build_selectors(
&self.config_options,
&self.agent_server,
&self.fs,
window,
cx,
);
cx.notify();
}
fn build_selectors(
config_options: &Rc<dyn AgentSessionConfigOptions>,
agent_server: &Rc<dyn AgentServer>,
fs: &Arc<dyn Fs>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<Entity<ConfigOptionSelector>> {
config_options
.config_options()
.into_iter()
.map(|option| {
let config_options = config_options.clone();
let agent_server = agent_server.clone();
let fs = fs.clone();
cx.new(|cx| {
ConfigOptionSelector::new(
config_options,
option.id.clone(),
agent_server,
fs,
window,
cx,
)
})
})
.collect()
}
}
impl Render for ConfigOptionsView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
if self.selectors.is_empty() {
return div().into_any_element();
}
h_flex()
.gap_1()
.children(self.selectors.iter().cloned())
.into_any_element()
}
}
struct ConfigOptionSelector {
config_options: Rc<dyn AgentSessionConfigOptions>,
config_id: acp::SessionConfigId,
picker_handle: PopoverMenuHandle<Picker<ConfigOptionPickerDelegate>>,
picker: Entity<Picker<ConfigOptionPickerDelegate>>,
setting_value: bool,
}
impl ConfigOptionSelector {
pub fn new(
config_options: Rc<dyn AgentSessionConfigOptions>,
config_id: acp::SessionConfigId,
agent_server: Rc<dyn AgentServer>,
fs: Arc<dyn Fs>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let option_count = config_options
.config_options()
.iter()
.find(|opt| opt.id == config_id)
.map(count_config_options)
.unwrap_or(0);
let is_searchable = option_count >= PICKER_THRESHOLD;
let picker = {
let config_options = config_options.clone();
let config_id = config_id.clone();
let agent_server = agent_server.clone();
let fs = fs.clone();
cx.new(move |picker_cx| {
let delegate = ConfigOptionPickerDelegate::new(
config_options,
config_id,
agent_server,
fs,
window,
picker_cx,
);
if is_searchable {
Picker::list(delegate, window, picker_cx)
} else {
Picker::nonsearchable_list(delegate, window, picker_cx)
}
.show_scrollbar(true)
.width(rems(20.))
.max_height(Some(rems(20.).into()))
})
};
Self {
config_options,
config_id,
picker_handle: PopoverMenuHandle::default(),
picker,
setting_value: false,
}
}
fn current_option(&self) -> Option<acp::SessionConfigOption> {
self.config_options
.config_options()
.into_iter()
.find(|opt| opt.id == self.config_id)
}
fn current_value_name(&self) -> String {
let Some(option) = self.current_option() else {
return "Unknown".to_string();
};
match &option.kind {
acp::SessionConfigKind::Select(select) => {
find_option_name(&select.options, &select.current_value)
.unwrap_or_else(|| "Unknown".to_string())
}
_ => "Unknown".to_string(),
}
}
fn render_trigger_button(&self, _window: &mut Window, _cx: &mut Context<Self>) -> Button {
let Some(option) = self.current_option() else {
return Button::new("config-option-trigger", "Unknown")
.label_size(LabelSize::Small)
.color(Color::Muted)
.disabled(true);
};
let icon = if self.picker_handle.is_deployed() {
IconName::ChevronUp
} else {
IconName::ChevronDown
};
Button::new(
ElementId::Name(format!("config-option-{}", option.id.0).into()),
self.current_value_name(),
)
.label_size(LabelSize::Small)
.color(Color::Muted)
.icon(icon)
.icon_size(IconSize::XSmall)
.icon_position(IconPosition::End)
.icon_color(Color::Muted)
.disabled(self.setting_value)
}
}
impl Render for ConfigOptionSelector {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(option) = self.current_option() else {
return div().into_any_element();
};
let trigger_button = self.render_trigger_button(window, cx);
let option_name = option.name.clone();
let option_description: Option<SharedString> = option.description.map(Into::into);
let tooltip = Tooltip::element(move |_window, _cx| {
let mut content = v_flex().gap_1().child(Label::new(option_name.clone()));
if let Some(desc) = option_description.as_ref() {
content = content.child(
Label::new(desc.clone())
.size(LabelSize::Small)
.color(Color::Muted),
);
}
content.into_any()
});
PickerPopoverMenu::new(
self.picker.clone(),
trigger_button,
tooltip,
gpui::Corner::BottomRight,
cx,
)
.with_handle(self.picker_handle.clone())
.render(window, cx)
.into_any_element()
}
}
#[derive(Clone)]
enum ConfigOptionPickerEntry {
Separator(SharedString),
Option(ConfigOptionValue),
}
#[derive(Clone)]
struct ConfigOptionValue {
value: acp::SessionConfigValueId,
name: String,
description: Option<String>,
group: Option<String>,
}
struct ConfigOptionPickerDelegate {
config_options: Rc<dyn AgentSessionConfigOptions>,
config_id: acp::SessionConfigId,
agent_server: Rc<dyn AgentServer>,
fs: Arc<dyn Fs>,
filtered_entries: Vec<ConfigOptionPickerEntry>,
all_options: Vec<ConfigOptionValue>,
selected_index: usize,
selected_description: Option<(usize, SharedString, bool)>,
favorites: HashSet<acp::SessionConfigValueId>,
_settings_subscription: Subscription,
}
impl ConfigOptionPickerDelegate {
fn new(
config_options: Rc<dyn AgentSessionConfigOptions>,
config_id: acp::SessionConfigId,
agent_server: Rc<dyn AgentServer>,
fs: Arc<dyn Fs>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Self {
let favorites = agent_server.favorite_config_option_value_ids(&config_id, cx);
let all_options = extract_options(&config_options, &config_id);
let filtered_entries = options_to_picker_entries(&all_options, &favorites);
let current_value = get_current_value(&config_options, &config_id);
let selected_index = current_value
.and_then(|current| {
filtered_entries.iter().position(|entry| {
matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
})
})
.unwrap_or(0);
let agent_server_for_subscription = agent_server.clone();
let config_id_for_subscription = config_id.clone();
let settings_subscription =
cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
let new_favorites = agent_server_for_subscription
.favorite_config_option_value_ids(&config_id_for_subscription, cx);
if new_favorites != picker.delegate.favorites {
picker.delegate.favorites = new_favorites;
picker.refresh(window, cx);
}
});
cx.notify();
Self {
config_options,
config_id,
agent_server,
fs,
filtered_entries,
all_options,
selected_index,
selected_description: None,
favorites,
_settings_subscription: settings_subscription,
}
}
fn current_value(&self) -> Option<acp::SessionConfigValueId> {
get_current_value(&self.config_options, &self.config_id)
}
}
impl PickerDelegate for ConfigOptionPickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_entries.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
cx.notify();
}
fn can_select(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> bool {
match self.filtered_entries.get(ix) {
Some(ConfigOptionPickerEntry::Option(_)) => true,
Some(ConfigOptionPickerEntry::Separator(_)) | None => false,
}
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select an option…".into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let all_options = self.all_options.clone();
cx.spawn_in(window, async move |this, cx| {
let filtered_options = match this
.read_with(cx, |_, cx| {
if query.is_empty() {
None
} else {
Some((all_options.clone(), query.clone(), cx.background_executor().clone()))
}
})
.ok()
.flatten()
{
Some((options, q, executor)) => fuzzy_search_options(options, &q, executor).await,
None => all_options,
};
this.update_in(cx, |this, window, cx| {
this.delegate.filtered_entries =
options_to_picker_entries(&filtered_options, &this.delegate.favorites);
let current_value = this.delegate.current_value();
let new_index = current_value
.and_then(|current| {
this.delegate.filtered_entries.iter().position(|entry| {
matches!(entry, ConfigOptionPickerEntry::Option(opt) if opt.value == current)
})
})
.unwrap_or(0);
this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
cx.notify();
})
.ok();
})
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(ConfigOptionPickerEntry::Option(option)) =
self.filtered_entries.get(self.selected_index)
{
if window.modifiers().secondary() {
let default_value = self
.agent_server
.default_config_option(self.config_id.0.as_ref(), cx);
let is_default = default_value.as_deref() == Some(&*option.value.0);
self.agent_server.set_default_config_option(
self.config_id.0.as_ref(),
if is_default {
None
} else {
Some(option.value.0.as_ref())
},
self.fs.clone(),
cx,
);
}
let task = self.config_options.set_config_option(
self.config_id.clone(),
option.value.clone(),
cx,
);
cx.spawn(async move |_, _| {
if let Err(err) = task.await {
log::error!("Failed to set config option: {:?}", err);
}
})
.detach();
cx.emit(DismissEvent);
}
}
fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
cx.defer_in(window, |picker, window, cx| {
picker.set_query("", window, cx);
});
}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered_entries.get(ix)? {
ConfigOptionPickerEntry::Separator(title) => Some(
div()
.when(ix > 0, |this| this.mt_1())
.child(
div()
.px_2()
.py_1()
.text_xs()
.text_color(cx.theme().colors().text_muted)
.child(title.clone()),
)
.into_any_element(),
),
ConfigOptionPickerEntry::Option(option) => {
let current_value = self.current_value();
let is_selected = current_value.as_ref() == Some(&option.value);
let default_value = self
.agent_server
.default_config_option(self.config_id.0.as_ref(), cx);
let is_default = default_value.as_deref() == Some(&*option.value.0);
let is_favorite = self.favorites.contains(&option.value);
let option_name = option.name.clone();
let description = option.description.clone();
Some(
div()
.id(("config-option-picker-item", ix))
.when_some(description, |this, desc| {
let desc: SharedString = desc.into();
this.on_hover(cx.listener(move |menu, hovered, _, cx| {
if *hovered {
menu.delegate.selected_description =
Some((ix, desc.clone(), is_default));
} else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix)
{
menu.delegate.selected_description = None;
}
cx.notify();
}))
})
.child(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(h_flex().w_full().child(Label::new(option_name).truncate()))
.end_slot(div().pr_2().when(is_selected, |this| {
this.child(Icon::new(IconName::Check).color(Color::Accent))
}))
.end_hover_slot(div().pr_1p5().child({
let (icon, color, tooltip) = if is_favorite {
(IconName::StarFilled, Color::Accent, "Unfavorite")
} else {
(IconName::Star, Color::Default, "Favorite")
};
let config_id = self.config_id.clone();
let value_id = option.value.clone();
let agent_server = self.agent_server.clone();
let fs = self.fs.clone();
IconButton::new(("toggle-favorite-config-option", ix), icon)
.layer(ElevationIndex::ElevatedSurface)
.icon_color(color)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text(tooltip))
.on_click(move |_, _, cx| {
agent_server.toggle_favorite_config_option_value(
config_id.clone(),
value_id.clone(),
!is_favorite,
fs.clone(),
cx,
);
})
})),
)
.into_any_element(),
)
}
}
}
fn documentation_aside(
&self,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<ui::DocumentationAside> {
self.selected_description
.as_ref()
.map(|(_, description, is_default)| {
let description = description.clone();
let is_default = *is_default;
ui::DocumentationAside::new(
ui::DocumentationSide::Left,
Rc::new(move |_| {
v_flex()
.gap_1()
.child(Label::new(description.clone()))
.child(HoldForDefault::new(is_default))
.into_any_element()
}),
)
})
}
fn documentation_aside_index(&self) -> Option<usize> {
self.selected_description.as_ref().map(|(ix, _, _)| *ix)
}
}
fn extract_options(
config_options: &Rc<dyn AgentSessionConfigOptions>,
config_id: &acp::SessionConfigId,
) -> Vec<ConfigOptionValue> {
let Some(option) = config_options
.config_options()
.into_iter()
.find(|opt| &opt.id == config_id)
else {
return Vec::new();
};
match &option.kind {
acp::SessionConfigKind::Select(select) => match &select.options {
acp::SessionConfigSelectOptions::Ungrouped(options) => options
.iter()
.map(|opt| ConfigOptionValue {
value: opt.value.clone(),
name: opt.name.clone(),
description: opt.description.clone(),
group: None,
})
.collect(),
acp::SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| {
group.options.iter().map(|opt| ConfigOptionValue {
value: opt.value.clone(),
name: opt.name.clone(),
description: opt.description.clone(),
group: Some(group.name.clone()),
})
})
.collect(),
_ => Vec::new(),
},
_ => Vec::new(),
}
}
fn get_current_value(
config_options: &Rc<dyn AgentSessionConfigOptions>,
config_id: &acp::SessionConfigId,
) -> Option<acp::SessionConfigValueId> {
config_options
.config_options()
.into_iter()
.find(|opt| &opt.id == config_id)
.and_then(|opt| match &opt.kind {
acp::SessionConfigKind::Select(select) => Some(select.current_value.clone()),
_ => None,
})
}
fn options_to_picker_entries(
options: &[ConfigOptionValue],
favorites: &HashSet<acp::SessionConfigValueId>,
) -> Vec<ConfigOptionPickerEntry> {
let mut entries = Vec::new();
let mut favorite_options = Vec::new();
for option in options {
if favorites.contains(&option.value) {
favorite_options.push(option.clone());
}
}
if !favorite_options.is_empty() {
entries.push(ConfigOptionPickerEntry::Separator("Favorites".into()));
for option in favorite_options {
entries.push(ConfigOptionPickerEntry::Option(option));
}
// If the remaining list would start ungrouped (group == None), insert a separator so
// Favorites doesn't visually run into the main list.
if let Some(option) = options.first()
&& option.group.is_none()
{
entries.push(ConfigOptionPickerEntry::Separator("All Options".into()));
}
}
let mut current_group: Option<String> = None;
for option in options {
if option.group != current_group {
if let Some(group_name) = &option.group {
entries.push(ConfigOptionPickerEntry::Separator(
group_name.clone().into(),
));
}
current_group = option.group.clone();
}
entries.push(ConfigOptionPickerEntry::Option(option.clone()));
}
entries
}
async fn fuzzy_search_options(
options: Vec<ConfigOptionValue>,
query: &str,
executor: BackgroundExecutor,
) -> Vec<ConfigOptionValue> {
let candidates = options
.iter()
.enumerate()
.map(|(ix, opt)| StringMatchCandidate::new(ix, &opt.name))
.collect::<Vec<_>>();
let mut matches = fuzzy::match_strings(
&candidates,
query,
false,
true,
100,
&Default::default(),
executor,
)
.await;
matches.sort_unstable_by_key(|mat| {
let candidate = &candidates[mat.candidate_id];
(Reverse(OrderedFloat(mat.score)), candidate.id)
});
matches
.into_iter()
.map(|mat| options[mat.candidate_id].clone())
.collect()
}
fn find_option_name(
options: &acp::SessionConfigSelectOptions,
value_id: &acp::SessionConfigValueId,
) -> Option<String> {
match options {
acp::SessionConfigSelectOptions::Ungrouped(opts) => opts
.iter()
.find(|o| &o.value == value_id)
.map(|o| o.name.clone()),
acp::SessionConfigSelectOptions::Grouped(groups) => groups.iter().find_map(|group| {
group
.options
.iter()
.find(|o| &o.value == value_id)
.map(|o| o.name.clone())
}),
_ => None,
}
}
fn count_config_options(option: &acp::SessionConfigOption) -> usize {
match &option.kind {
acp::SessionConfigKind::Select(select) => match &select.options {
acp::SessionConfigSelectOptions::Ungrouped(options) => options.len(),
acp::SessionConfigSelectOptions::Grouped(groups) => {
groups.iter().map(|g| g.options.len()).sum()
}
_ => 0,
},
_ => 0,
}
}

View File

@@ -31,7 +31,7 @@ use rope::Point;
use settings::Settings;
use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
use theme::ThemeSettings;
use ui::prelude::*;
use ui::{ContextMenu, prelude::*};
use util::{ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::{Chat, PasteRaw};
@@ -132,6 +132,21 @@ impl MessageEditor {
placement: Some(ContextMenuPlacement::Above),
});
editor.register_addon(MessageEditorAddon::new());
editor.set_custom_context_menu(|editor, _point, window, cx| {
let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
Some(ContextMenu::build(window, cx, |menu, _, _| {
menu.action("Cut", Box::new(editor::actions::Cut))
.action_disabled_when(
!has_selection,
"Copy",
Box::new(editor::actions::Copy),
)
.action("Paste", Box::new(editor::actions::Paste))
}))
});
editor
});
let mention_set =

View File

@@ -7,8 +7,8 @@ use gpui::{Context, Entity, FocusHandle, WeakEntity, Window, prelude::*};
use settings::Settings as _;
use std::{rc::Rc, sync::Arc};
use ui::{
Button, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, KeyBinding,
PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
Button, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
PopoverMenuHandle, Tooltip, prelude::*,
};
use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault};
@@ -105,7 +105,7 @@ impl ModeSelector {
.toggleable(IconPosition::End, is_selected);
let entry = if let Some(description) = &mode.description {
entry.documentation_aside(side, DocumentationEdge::Bottom, {
entry.documentation_aside(side, {
let description = description.clone();
move |_| {

View File

@@ -1,23 +1,22 @@
use std::{cmp::Reverse, rc::Rc, sync::Arc};
use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
use agent_client_protocol::ModelId;
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
use anyhow::Result;
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::FutureExt;
use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
WeakEntity,
};
use itertools::Itertools;
use language_model::IconOrSvg;
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
use settings::Settings;
use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
use settings::SettingsStore;
use ui::{DocumentationAside, DocumentationSide, IntoElement, prelude::*};
use util::ResultExt;
use zed_actions::agent::OpenSettings;
@@ -55,7 +54,9 @@ pub struct AcpModelPickerDelegate {
selected_index: usize,
selected_description: Option<(usize, SharedString, bool)>,
selected_model: Option<AgentModelInfo>,
favorites: HashSet<ModelId>,
_refresh_models_task: Task<()>,
_settings_subscription: Subscription,
focus_handle: FocusHandle,
}
@@ -103,6 +104,19 @@ impl AcpModelPickerDelegate {
})
};
let agent_server_for_subscription = agent_server.clone();
let settings_subscription =
cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
// Only refresh if the favorites actually changed to avoid redundant work
// when other settings are modified (e.g., user editing settings.json)
let new_favorites = agent_server_for_subscription.favorite_model_ids(cx);
if new_favorites != picker.delegate.favorites {
picker.delegate.favorites = new_favorites;
picker.refresh(window, cx);
}
});
let favorites = agent_server.favorite_model_ids(cx);
Self {
selector,
agent_server,
@@ -112,7 +126,9 @@ impl AcpModelPickerDelegate {
selected_model: None,
selected_index: 0,
selected_description: None,
favorites,
_refresh_models_task: refresh_models_task,
_settings_subscription: settings_subscription,
focus_handle,
}
}
@@ -121,40 +137,37 @@ impl AcpModelPickerDelegate {
self.selected_model.as_ref()
}
pub fn favorites_count(&self) -> usize {
self.favorites.len()
}
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if !self.selector.supports_favorites() {
if self.favorites.is_empty() {
return;
}
let favorites = AgentSettings::get_global(cx).favorite_model_ids();
if favorites.is_empty() {
return;
}
let Some(models) = self.models.clone() else {
let Some(models) = &self.models else {
return;
};
let all_models: Vec<AgentModelInfo> = match models {
AgentModelList::Flat(list) => list,
AgentModelList::Grouped(index_map) => index_map
.into_values()
.flatten()
.collect::<Vec<AgentModelInfo>>(),
let all_models: Vec<&AgentModelInfo> = match models {
AgentModelList::Flat(list) => list.iter().collect(),
AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
};
let favorite_models = all_models
.iter()
.filter(|model| favorites.contains(&model.id))
let favorite_models: Vec<_> = all_models
.into_iter()
.filter(|model| self.favorites.contains(&model.id))
.unique_by(|model| &model.id)
.cloned()
.collect::<Vec<_>>();
.collect();
let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
if favorite_models.is_empty() {
return;
}
let current_id = self.selected_model.as_ref().map(|m| &m.id);
let current_index_in_favorites = current_id
.as_ref()
.and_then(|id| favorite_models.iter().position(|m| &m.id == id))
.unwrap_or(usize::MAX);
@@ -221,11 +234,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let favorites = if self.selector.supports_favorites() {
AgentSettings::get_global(cx).favorite_model_ids()
} else {
Default::default()
};
let favorites = self.favorites.clone();
cx.spawn_in(window, async move |this, cx| {
let filtered_models = match this
@@ -318,21 +327,20 @@ impl PickerDelegate for AcpModelPickerDelegate {
let default_model = self.agent_server.default_model(cx);
let is_default = default_model.as_ref() == Some(&model_info.id);
let supports_favorites = self.selector.supports_favorites();
let is_favorite = *is_favorite;
let handle_action_click = {
let model_id = model_info.id.clone();
let fs = self.fs.clone();
let agent_server = self.agent_server.clone();
move |cx: &App| {
crate::favorite_models::toggle_model_id_in_settings(
cx.listener(move |_, _, _, cx| {
agent_server.toggle_favorite_model(
model_id.clone(),
!is_favorite,
fs.clone(),
cx,
);
}
})
};
Some(
@@ -352,16 +360,14 @@ impl PickerDelegate for AcpModelPickerDelegate {
.child(
ModelSelectorListItem::new(ix, model_info.name.clone())
.map(|this| match &model_info.icon {
Some(IconOrSvg::Svg(path)) => this.icon_path(path.clone()),
Some(IconOrSvg::Icon(icon)) => this.icon(*icon),
Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()),
Some(AgentModelIcon::Named(icon)) => this.icon(*icon),
None => this,
})
.is_selected(is_selected)
.is_focused(selected)
.when(supports_favorites, |this| {
this.is_favorite(is_favorite)
.on_toggle_favorite(handle_action_click)
}),
.is_favorite(is_favorite)
.on_toggle_favorite(handle_action_click),
)
.into_any_element(),
)
@@ -382,7 +388,6 @@ impl PickerDelegate for AcpModelPickerDelegate {
DocumentationAside::new(
DocumentationSide::Left,
DocumentationEdge::Top,
Rc::new(move |_| {
v_flex()
.gap_1()
@@ -394,6 +399,10 @@ impl PickerDelegate for AcpModelPickerDelegate {
})
}
fn documentation_aside_index(&self) -> Option<usize> {
self.selected_description.as_ref().map(|(ix, _, _)| *ix)
}
fn render_footer(
&self,
_window: &mut Window,
@@ -604,6 +613,46 @@ mod tests {
.collect()
}
#[gpui::test]
async fn test_fuzzy_match(cx: &mut TestAppContext) {
let models = create_model_list(vec![
(
"zed",
vec![
"Claude 3.7 Sonnet",
"Claude 3.7 Sonnet Thinking",
"gpt-4.1",
"gpt-4.1-nano",
],
),
("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]),
("ollama", vec!["mistral", "deepseek"]),
]);
// Results should preserve models order whenever possible.
// In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
// similarity scores, but `zed/gpt-4.1` was higher in the models list,
// so it should appear first in the results.
let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1", "gpt-4.1-nano"]),
("openai", vec!["gpt-4.1", "gpt-4.1-nano"]),
],
);
// Fuzzy search
let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1-nano"]),
("openai", vec!["gpt-4.1-nano"]),
],
);
}
#[gpui::test]
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
let models = create_model_list(vec![
@@ -740,42 +789,48 @@ mod tests {
}
#[gpui::test]
async fn test_fuzzy_match(cx: &mut TestAppContext) {
let models = create_model_list(vec![
(
"zed",
vec![
"Claude 3.7 Sonnet",
"Claude 3.7 Sonnet Thinking",
"gpt-4.1",
"gpt-4.1-nano",
],
),
("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]),
("ollama", vec!["mistral", "deepseek"]),
fn test_favorites_count_returns_correct_count(_cx: &mut TestAppContext) {
let empty_favorites: HashSet<ModelId> = HashSet::default();
assert_eq!(empty_favorites.len(), 0);
let one_favorite = create_favorites(vec!["model-a"]);
assert_eq!(one_favorite.len(), 1);
let multiple_favorites = create_favorites(vec!["model-a", "model-b", "model-c"]);
assert_eq!(multiple_favorites.len(), 3);
let with_duplicates = create_favorites(vec!["model-a", "model-a", "model-b"]);
assert_eq!(with_duplicates.len(), 2);
}
#[gpui::test]
fn test_is_favorite_flag_set_correctly_in_entries(_cx: &mut TestAppContext) {
let models = AgentModelList::Flat(vec![
acp_thread::AgentModelInfo {
id: acp::ModelId::new("favorite-model".to_string()),
name: "Favorite".into(),
description: None,
icon: None,
},
acp_thread::AgentModelInfo {
id: acp::ModelId::new("regular-model".to_string()),
name: "Regular".into(),
description: None,
icon: None,
},
]);
let favorites = create_favorites(vec!["favorite-model"]);
// Results should preserve models order whenever possible.
// In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
// similarity scores, but `zed/gpt-4.1` was higher in the models list,
// so it should appear first in the results.
let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1", "gpt-4.1-nano"]),
("openai", vec!["gpt-4.1", "gpt-4.1-nano"]),
],
);
let entries = info_list_to_picker_entries(models, &favorites);
// Fuzzy search
let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await;
assert_models_eq(
results,
vec![
("zed", vec!["gpt-4.1-nano"]),
("openai", vec!["gpt-4.1-nano"]),
],
);
for entry in &entries {
if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
if info.id.0.as_ref() == "favorite-model" {
assert!(*is_favorite, "favorite-model should have is_favorite=true");
} else if info.id.0.as_ref() == "regular-model" {
assert!(!*is_favorite, "regular-model should have is_favorite=false");
}
}
}
}
}

View File

@@ -1,19 +1,14 @@
use std::rc::Rc;
use std::sync::Arc;
use acp_thread::{AgentModelInfo, AgentModelSelector};
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector};
use fs::Fs;
use gpui::{Entity, FocusHandle};
use language_model::IconOrSvg;
use picker::popover_menu::PickerPopoverMenu;
use settings::Settings as _;
use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use crate::CycleFavoriteModels;
use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
use crate::ui::ModelSelectorTooltip;
pub struct AcpModelSelectorPopover {
selector: Entity<AcpModelSelector>,
@@ -24,7 +19,7 @@ pub struct AcpModelSelectorPopover {
impl AcpModelSelectorPopover {
pub(crate) fn new(
selector: Rc<dyn AgentModelSelector>,
agent_server: Rc<dyn AgentServer>,
agent_server: Rc<dyn agent_servers::AgentServer>,
fs: Arc<dyn Fs>,
menu_handle: PopoverMenuHandle<AcpModelSelector>,
focus_handle: FocusHandle,
@@ -65,7 +60,8 @@ impl AcpModelSelectorPopover {
impl Render for AcpModelSelectorPopover {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let model = self.selector.read(cx).delegate.active_model();
let selector = self.selector.read(cx);
let model = selector.delegate.active_model();
let model_name = model
.as_ref()
.map(|model| model.name.clone())
@@ -81,43 +77,13 @@ impl Render for AcpModelSelectorPopover {
(Color::Muted, IconName::ChevronDown)
};
let tooltip = Tooltip::element({
move |_, cx| {
let focus_handle = focus_handle.clone();
let should_show_cycle_row = !AgentSettings::get_global(cx)
.favorite_model_ids()
.is_empty();
let show_cycle_row = selector.delegate.favorites_count() > 1;
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Change Model"))
.child(KeyBinding::for_action_in(
&ToggleModelSelector,
&focus_handle,
cx,
)),
)
.when(should_show_cycle_row, |this| {
this.child(
h_flex()
.pt_1()
.gap_2()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.child(Label::new("Cycle Favorited Models"))
.child(KeyBinding::for_action_in(
&CycleFavoriteModels,
&focus_handle,
cx,
)),
)
})
.into_any()
let tooltip = Tooltip::element({
move |_, _cx| {
ModelSelectorTooltip::new(focus_handle.clone())
.show_cycle_row(show_cycle_row)
.into_any_element()
}
});
@@ -128,8 +94,8 @@ impl Render for AcpModelSelectorPopover {
.when_some(model_icon, |this, icon| {
this.child(
match icon {
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
IconOrSvg::Icon(icon_name) => Icon::new(icon_name),
AgentModelIcon::Path(path) => Icon::from_external_svg(path),
AgentModelIcon::Named(icon_name) => Icon::new(icon_name),
}
.color(color)
.size(IconSize::XSmall),

View File

@@ -24,11 +24,11 @@ use file_icons::FileIcons;
use fs::Fs;
use futures::FutureExt as _;
use gpui::{
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
ListOffset, ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task,
TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div,
ease_in_out, linear_color_stop, linear_gradient, list, point, pulsating_between,
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, CursorStyle,
EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task, TextStyle,
TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div, ease_in_out,
linear_color_stop, linear_gradient, list, point, pulsating_between,
};
use language::Buffer;
@@ -47,14 +47,16 @@ use terminal_view::terminal_panel::TerminalPanel;
use text::Anchor;
use theme::{AgentFontSize, ThemeSettings};
use ui::{
Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*,
Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, Disclosure, Divider,
DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip,
WithScrollbar, prelude::*, right_click_menu,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, NewTerminal, Workspace};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
use super::config_options::ConfigOptionsView;
use super::entry_view_state::EntryViewState;
use crate::acp::AcpModelSelectorPopover;
use crate::acp::ModeSelector;
@@ -271,12 +273,14 @@ pub struct AcpThreadView {
message_editor: Entity<MessageEditor>,
focus_handle: FocusHandle,
model_selector: Option<Entity<AcpModelSelectorPopover>>,
config_options_view: Option<Entity<ConfigOptionsView>>,
profile_selector: Option<Entity<ProfileSelector>>,
notifications: Vec<WindowHandle<AgentNotification>>,
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
thread_retry_status: Option<RetryStatus>,
thread_error: Option<ThreadError>,
thread_error_markdown: Option<Entity<Markdown>>,
token_limit_callout_dismissed: bool,
thread_feedback: ThreadFeedbackState,
list_state: ListState,
auth_task: Option<Task<()>>,
@@ -428,14 +432,15 @@ impl AcpThreadView {
login: None,
message_editor,
model_selector: None,
config_options_view: None,
profile_selector: None,
notifications: Vec::new(),
notification_subscriptions: HashMap::default(),
list_state: list_state,
thread_retry_status: None,
thread_error: None,
thread_error_markdown: None,
token_limit_callout_dismissed: false,
thread_feedback: Default::default(),
auth_task: None,
expanded_tool_calls: HashSet::default(),
@@ -612,42 +617,64 @@ impl AcpThreadView {
AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx);
this.model_selector = thread
// Check for config options first
// Config options take precedence over legacy mode/model selectors
// (feature flag gating happens at the data layer)
let config_options_provider = thread
.read(cx)
.connection()
.model_selector(thread.read(cx).session_id())
.map(|selector| {
let agent_server = this.agent.clone();
let fs = this.project.read(cx).fs().clone();
cx.new(|cx| {
AcpModelSelectorPopover::new(
selector,
agent_server,
fs,
PopoverMenuHandle::default(),
this.focus_handle(cx),
window,
cx,
)
})
});
.session_config_options(thread.read(cx).session_id(), cx);
let mode_selector = thread
.read(cx)
.connection()
.session_modes(thread.read(cx).session_id(), cx)
.map(|session_modes| {
let fs = this.project.read(cx).fs().clone();
let focus_handle = this.focus_handle(cx);
cx.new(|_cx| {
ModeSelector::new(
session_modes,
this.agent.clone(),
fs,
focus_handle,
)
})
});
let mode_selector;
if let Some(config_options) = config_options_provider {
// Use config options - don't create mode_selector or model_selector
let agent_server = this.agent.clone();
let fs = this.project.read(cx).fs().clone();
this.config_options_view = Some(cx.new(|cx| {
ConfigOptionsView::new(config_options, agent_server, fs, window, cx)
}));
this.model_selector = None;
mode_selector = None;
} else {
// Fall back to legacy mode/model selectors
this.config_options_view = None;
this.model_selector = thread
.read(cx)
.connection()
.model_selector(thread.read(cx).session_id())
.map(|selector| {
let agent_server = this.agent.clone();
let fs = this.project.read(cx).fs().clone();
cx.new(|cx| {
AcpModelSelectorPopover::new(
selector,
agent_server,
fs,
PopoverMenuHandle::default(),
this.focus_handle(cx),
window,
cx,
)
})
});
mode_selector = thread
.read(cx)
.connection()
.session_modes(thread.read(cx).session_id(), cx)
.map(|session_modes| {
let fs = this.project.read(cx).fs().clone();
let focus_handle = this.focus_handle(cx);
cx.new(|_cx| {
ModeSelector::new(
session_modes,
this.agent.clone(),
fs,
focus_handle,
)
})
});
}
let mut subscriptions = vec![
cx.subscribe_in(&thread, window, Self::handle_thread_event),
@@ -1393,6 +1420,7 @@ impl AcpThreadView {
fn clear_thread_error(&mut self, cx: &mut Context<Self>) {
self.thread_error = None;
self.thread_error_markdown = None;
self.token_limit_callout_dismissed = true;
cx.notify();
}
@@ -1519,6 +1547,10 @@ impl AcpThreadView {
// The connection keeps track of the mode
cx.notify();
}
AcpThreadEvent::ConfigOptionsUpdated(_) => {
// The watch task in ConfigOptionsView handles rebuilding selectors
cx.notify();
}
}
cx.notify();
}
@@ -2038,7 +2070,7 @@ impl AcpThreadView {
}
})
.text_xs()
.child(editor.clone().into_any_element()),
.child(editor.clone().into_any_element())
)
.when(editor_focus, |this| {
let base_container = h_flex()
@@ -2154,7 +2186,6 @@ impl AcpThreadView {
if this_is_blank {
return None;
}
Some(
self.render_thinking_block(
entry_ix,
@@ -2180,7 +2211,7 @@ impl AcpThreadView {
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
.child(message_body)
.child(self.render_message_context_menu(entry_ix, message_body, cx))
.into_any()
}
}
@@ -2287,6 +2318,70 @@ impl AcpThreadView {
}
}
fn render_message_context_menu(
&self,
entry_ix: usize,
message_body: AnyElement,
cx: &Context<Self>,
) -> AnyElement {
let entity = cx.entity();
let workspace = self.workspace.clone();
right_click_menu(format!("agent_context_menu-{}", entry_ix))
.trigger(move |_, _, _| message_body)
.menu(move |window, cx| {
let focus = window.focused(cx);
let entity = entity.clone();
let workspace = workspace.clone();
ContextMenu::build(window, cx, move |menu, _, cx| {
let is_at_top = entity.read(cx).list_state.logical_scroll_top().item_ix == 0;
let scroll_item = if is_at_top {
ContextMenuEntry::new("Scroll to Bottom").handler({
let entity = entity.clone();
move |_, cx| {
entity.update(cx, |this, cx| {
this.scroll_to_bottom(cx);
});
}
})
} else {
ContextMenuEntry::new("Scroll to Top").handler({
let entity = entity.clone();
move |_, cx| {
entity.update(cx, |this, cx| {
this.scroll_to_top(cx);
});
}
})
};
let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown")
.handler({
let entity = entity.clone();
let workspace = workspace.clone();
move |window, cx| {
if let Some(workspace) = workspace.upgrade() {
entity
.update(cx, |this, cx| {
this.open_thread_as_markdown(workspace, window, cx)
})
.detach_and_log_err(cx);
}
}
});
menu.when_some(focus, |menu, focus| menu.context(focus))
.action("Copy", Box::new(markdown::CopyAsMarkdown))
.separator()
.item(scroll_item)
.item(open_thread_as_markdown)
})
})
.into_any_element()
}
fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
cx.theme()
.colors()
@@ -2489,9 +2584,11 @@ impl AcpThreadView {
.border_color(self.tool_card_border_color(cx))
.child(input_output_header("Raw Input:".into()))
.children(tool_call.raw_input_markdown.clone().map(|input| {
self.render_markdown(
input,
default_markdown_style(false, false, window, cx),
div().id(("tool-call-raw-input-markdown", entry_ix)).child(
self.render_markdown(
input,
default_markdown_style(false, false, window, cx),
),
)
}))
.child(input_output_header("Output:".into())),
@@ -2499,15 +2596,17 @@ impl AcpThreadView {
})
.children(tool_call.content.iter().enumerate().map(
|(content_ix, content)| {
div().child(self.render_tool_call_content(
entry_ix,
content,
content_ix,
tool_call,
use_card_layout,
window,
cx,
))
div().id(("tool-call-output", entry_ix)).child(
self.render_tool_call_content(
entry_ix,
content,
content_ix,
tool_call,
use_card_layout,
window,
cx,
),
)
},
))
.into_any(),
@@ -4284,37 +4383,6 @@ impl AcpThreadView {
v_flex()
.on_action(cx.listener(Self::expand_message_editor))
.on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.read(cx).menu_handle().toggle(window, cx);
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.read(cx).menu_handle().toggle(window, cx);
}
}))
.on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.update(cx, |profile_selector, cx| {
profile_selector.cycle_profile(cx);
});
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.update(cx, |mode_selector, cx| {
mode_selector.cycle_mode(window, cx);
});
}
}))
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
if let Some(model_selector) = this.model_selector.as_ref() {
model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}
}))
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
if let Some(model_selector) = this.model_selector.as_ref() {
model_selector.update(cx, |model_selector, cx| {
model_selector.cycle_favorite_models(window, cx);
});
}
}))
.p_2()
.gap_2()
.border_t_1()
@@ -4378,8 +4446,12 @@ impl AcpThreadView {
.gap_1()
.children(self.render_token_usage(cx))
.children(self.profile_selector.clone())
.children(self.mode_selector().cloned())
.children(self.model_selector.clone())
// Either config_options_view OR (mode_selector + model_selector)
.children(self.config_options_view.clone())
.when(self.config_options_view.is_none(), |this| {
this.children(self.mode_selector().cloned())
.children(self.model_selector.clone())
})
.child(self.render_send_button(cx)),
),
)
@@ -5354,22 +5426,26 @@ impl AcpThreadView {
cx.notify();
}
fn render_token_limit_callout(
&self,
line_height: Pixels,
cx: &mut Context<Self>,
) -> Option<Callout> {
fn render_token_limit_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
if self.token_limit_callout_dismissed {
return None;
}
let token_usage = self.thread()?.read(cx).token_usage()?;
let ratio = token_usage.ratio();
let (severity, title) = match ratio {
let (severity, icon, title) = match ratio {
acp_thread::TokenUsageRatio::Normal => return None,
acp_thread::TokenUsageRatio::Warning => {
(Severity::Warning, "Thread reaching the token limit soon")
}
acp_thread::TokenUsageRatio::Exceeded => {
(Severity::Error, "Thread reached the token limit")
}
acp_thread::TokenUsageRatio::Warning => (
Severity::Warning,
IconName::Warning,
"Thread reaching the token limit soon",
),
acp_thread::TokenUsageRatio::Exceeded => (
Severity::Error,
IconName::XCircle,
"Thread reached the token limit",
),
};
let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| {
@@ -5389,7 +5465,7 @@ impl AcpThreadView {
Some(
Callout::new()
.severity(severity)
.line_height(line_height)
.icon(icon)
.title(title)
.description(description)
.actions_slot(
@@ -5421,7 +5497,8 @@ impl AcpThreadView {
})),
)
}),
),
)
.dismiss_action(self.dismiss_error_button(cx)),
)
}
@@ -5844,18 +5921,13 @@ impl AcpThreadView {
fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
let message = message.into();
IconButton::new("copy", IconName::Copy)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Copy Error Message"))
.on_click(move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(message.clone()))
})
CopyButton::new(message).tooltip_label("Copy Error Message")
}
fn dismiss_error_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
IconButton::new("dismiss", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss Error"))
.tooltip(Tooltip::text("Dismiss"))
.on_click(cx.listener({
move |this, _, _, cx| {
this.clear_thread_error(cx);
@@ -6001,6 +6073,37 @@ impl Render for AcpThreadView {
.on_action(cx.listener(Self::allow_always))
.on_action(cx.listener(Self::allow_once))
.on_action(cx.listener(Self::reject_once))
.on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.read(cx).menu_handle().toggle(window, cx);
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.read(cx).menu_handle().toggle(window, cx);
}
}))
.on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
if let Some(profile_selector) = this.profile_selector.as_ref() {
profile_selector.update(cx, |profile_selector, cx| {
profile_selector.cycle_profile(cx);
});
} else if let Some(mode_selector) = this.mode_selector() {
mode_selector.update(cx, |mode_selector, cx| {
mode_selector.cycle_mode(window, cx);
});
}
}))
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
if let Some(model_selector) = this.model_selector.as_ref() {
model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}
}))
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
if let Some(model_selector) = this.model_selector.as_ref() {
model_selector.update(cx, |model_selector, cx| {
model_selector.cycle_favorite_models(window, cx);
});
}
}))
.track_focus(&self.focus_handle)
.bg(cx.theme().colors().panel_background)
.child(match &self.thread_state {
@@ -6084,7 +6187,7 @@ impl Render for AcpThreadView {
if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
Some(usage_callout.into_any_element())
} else {
self.render_token_limit_callout(line_height, cx)
self.render_token_limit_callout(cx)
.map(|token_limit_callout| token_limit_callout.into_any_element())
},
)

View File

@@ -1370,6 +1370,9 @@ async fn open_new_agent_servers_entry_in_settings_editor(
env: Some(HashMap::default()),
default_mode: None,
default_model: None,
favorite_models: vec![],
default_config_options: Default::default(),
favorite_config_option_values: Default::default(),
},
);
}

View File

@@ -17,7 +17,7 @@ use gpui::{
Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*,
};
use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point};
use language::{Buffer, Capability, OffsetRangeExt, Point};
use multi_buffer::PathKey;
use project::{Project, ProjectItem, ProjectPath};
use settings::{Settings, SettingsStore};
@@ -146,13 +146,13 @@ impl AgentDiffPane {
paths_to_delete.remove(&path_key);
let snapshot = buffer.read(cx).snapshot();
let diff = diff_handle.read(cx);
let diff_hunk_ranges = diff
let diff_hunk_ranges = diff_handle
.read(cx)
.snapshot(cx)
.hunks_intersecting_range(
language::Anchor::min_max_range_for_buffer(snapshot.remote_id()),
&snapshot,
cx,
)
.map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
.collect::<Vec<_>>();
@@ -192,7 +192,7 @@ impl AgentDiffPane {
&& buffer
.read(cx)
.file()
.is_some_and(|file| file.disk_state() == DiskState::Deleted)
.is_some_and(|file| file.disk_state().is_deleted())
{
editor.fold_buffer(snapshot.text.remote_id(), cx)
}
@@ -1363,7 +1363,8 @@ impl AgentDiff {
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::ModeUpdated(_) => {}
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_) => {}
}
}

View File

@@ -1,6 +1,7 @@
use crate::{
ModelUsageContext,
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::ModelSelectorTooltip,
};
use fs::Fs;
use gpui::{Entity, FocusHandle, SharedString};
@@ -9,7 +10,6 @@ use picker::popover_menu::PickerPopoverMenu;
use settings::update_settings_file;
use std::sync::Arc;
use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
pub struct AgentModelSelector {
selector: Entity<LanguageModelSelector>,
@@ -81,6 +81,12 @@ impl AgentModelSelector {
pub fn active_model(&self, cx: &App) -> Option<language_model::ConfiguredModel> {
self.selector.read(cx).delegate.active_model(cx)
}
pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
self.selector.update(cx, |selector, cx| {
selector.delegate.cycle_favorite_models(window, cx);
});
}
}
impl Render for AgentModelSelector {
@@ -98,8 +104,18 @@ impl Render for AgentModelSelector {
Color::Muted
};
let show_cycle_row = self.selector.read(cx).delegate.favorites_count() > 1;
let focus_handle = self.focus_handle.clone();
let tooltip = Tooltip::element({
move |_, _cx| {
ModelSelectorTooltip::new(focus_handle.clone())
.show_cycle_row(show_cycle_row)
.into_any_element()
}
});
PickerPopoverMenu::new(
self.selector.clone(),
ButtonLike::new("active-model")
@@ -125,9 +141,7 @@ impl Render for AgentModelSelector {
.color(color)
.size(IconSize::XSmall),
),
move |_window, cx| {
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
},
tooltip,
gpui::Corner::TopRight,
cx,
)

View File

@@ -368,49 +368,26 @@ fn update_active_language_model_from_settings(cx: &mut App) {
}
}
// Filter out models from providers that are not authenticated
fn is_provider_authenticated(
selection: &LanguageModelSelection,
registry: &LanguageModelRegistry,
cx: &App,
) -> bool {
let provider_id = LanguageModelProviderId::from(selection.provider.0.clone());
registry
.provider(&provider_id)
.map_or(false, |provider| provider.is_authenticated(cx))
}
let registry = LanguageModelRegistry::global(cx);
let registry_ref = registry.read(cx);
let default = settings
.default_model
.as_ref()
.filter(|s| is_provider_authenticated(s, registry_ref, cx))
.map(to_selected_model);
let default = settings.default_model.as_ref().map(to_selected_model);
let inline_assistant = settings
.inline_assistant_model
.as_ref()
.filter(|s| is_provider_authenticated(s, registry_ref, cx))
.map(to_selected_model);
let commit_message = settings
.commit_message_model
.as_ref()
.filter(|s| is_provider_authenticated(s, registry_ref, cx))
.map(to_selected_model);
let thread_summary = settings
.thread_summary_model
.as_ref()
.filter(|s| is_provider_authenticated(s, registry_ref, cx))
.map(to_selected_model);
let inline_alternatives = settings
.inline_alternatives
.iter()
.filter(|s| is_provider_authenticated(s, registry_ref, cx))
.map(to_selected_model)
.collect::<Vec<_>>();
registry.update(cx, |registry, cx| {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.select_default_model(default.as_ref(), cx);
registry.select_inline_assistant_model(inline_assistant.as_ref(), cx);
registry.select_commit_message_model(commit_message.as_ref(), cx);

View File

@@ -1,6 +1,5 @@
use std::sync::Arc;
use agent_client_protocol::ModelId;
use fs::Fs;
use language_model::LanguageModel;
use settings::{LanguageModelSelection, update_settings_file};
@@ -13,20 +12,11 @@ fn language_model_to_selection(model: &Arc<dyn LanguageModel>) -> LanguageModelS
}
}
fn model_id_to_selection(model_id: &ModelId) -> LanguageModelSelection {
let id = model_id.0.as_ref();
let (provider, model) = id.split_once('/').unwrap_or(("", id));
LanguageModelSelection {
provider: provider.to_owned().into(),
model: model.to_owned(),
}
}
pub fn toggle_in_settings(
model: Arc<dyn LanguageModel>,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
cx: &mut App,
) {
let selection = language_model_to_selection(&model);
update_settings_file(fs, cx, move |settings, _| {
@@ -38,20 +28,3 @@ pub fn toggle_in_settings(
}
});
}
pub fn toggle_model_id_in_settings(
model_id: ModelId,
should_be_favorite: bool,
fs: Arc<dyn Fs>,
cx: &App,
) {
let selection = model_id_to_selection(&model_id);
update_settings_file(fs, cx, move |settings, _| {
let agent = settings.agent.get_or_insert_default();
if should_be_favorite {
agent.add_favorite_model(selection.clone());
} else {
agent.remove_favorite_model(&selection);
}
});
}

View File

@@ -40,7 +40,9 @@ use crate::completion_provider::{
use crate::mention_set::paste_images_as_context;
use crate::mention_set::{MentionSet, crease_for_mention};
use crate::terminal_codegen::TerminalCodegen;
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
use crate::{
CycleFavoriteModels, CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext,
};
actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]);
@@ -148,7 +150,7 @@ impl<T: 'static> Render for PromptEditor<T> {
.into_any_element();
v_flex()
.key_context("PromptEditor")
.key_context("InlineAssistant")
.capture_action(cx.listener(Self::paste))
.block_mouse_except_scroll()
.size_full()
@@ -162,10 +164,6 @@ impl<T: 'static> Render for PromptEditor<T> {
.bg(cx.theme().colors().editor_background)
.child(
h_flex()
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
this.model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::move_up))
@@ -174,6 +172,15 @@ impl<T: 'static> Render for PromptEditor<T> {
.on_action(cx.listener(Self::thumbs_down))
.capture_action(cx.listener(Self::cycle_prev))
.capture_action(cx.listener(Self::cycle_next))
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
this.model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}))
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
this.model_selector.update(cx, |model_selector, cx| {
model_selector.cycle_favorite_models(window, cx);
});
}))
.child(
WithRemSize::new(ui_font_size)
.h_full()
@@ -855,7 +862,7 @@ impl<T: 'static> PromptEditor<T> {
.map(|this| {
if rated {
this.disabled(true)
.icon_color(Color::Ignored)
.icon_color(Color::Disabled)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Good Result",
@@ -865,8 +872,15 @@ impl<T: 'static> PromptEditor<T> {
)
})
} else {
this.icon_color(Color::Muted)
.tooltip(Tooltip::text("Good Result"))
this.icon_color(Color::Muted).tooltip(
move |_, cx| {
Tooltip::for_action(
"Good Result",
&ThumbsUpResult,
cx,
)
},
)
}
})
.on_click(cx.listener(|this, _, window, cx| {
@@ -879,7 +893,7 @@ impl<T: 'static> PromptEditor<T> {
.map(|this| {
if rated {
this.disabled(true)
.icon_color(Color::Ignored)
.icon_color(Color::Disabled)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Bad Result",
@@ -889,8 +903,15 @@ impl<T: 'static> PromptEditor<T> {
)
})
} else {
this.icon_color(Color::Muted)
.tooltip(Tooltip::text("Bad Result"))
this.icon_color(Color::Muted).tooltip(
move |_, cx| {
Tooltip::for_action(
"Bad Result",
&ThumbsDownResult,
cx,
)
},
)
}
})
.on_click(cx.listener(|this, _, window, cx| {
@@ -1088,7 +1109,6 @@ impl<T: 'static> PromptEditor<T> {
let colors = cx.theme().colors();
div()
.key_context("InlineAssistEditor")
.size_full()
.p_2()
.pl_1()

View File

@@ -2,9 +2,10 @@ use std::{cmp::Reverse, sync::Arc};
use agent_settings::AgentSettings;
use collections::{HashMap, HashSet, IndexMap};
use futures::{StreamExt, channel::mpsc};
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Task};
use gpui::{
Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
};
use language_model::{
AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId,
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
@@ -19,14 +20,14 @@ use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &App) + 'static>;
type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static>;
pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
pub fn language_model_selector(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static,
popover_styles: bool,
focus_handle: FocusHandle,
window: &mut Window,
@@ -75,7 +76,7 @@ fn all_models(cx: &App) -> GroupedModels {
})
.collect();
let all: Vec<ModelInfo> = providers
let all = providers
.iter()
.flat_map(|provider| {
provider
@@ -123,7 +124,7 @@ pub struct LanguageModelPickerDelegate {
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
_authenticate_all_providers_task: Task<()>,
_refresh_models_task: Task<()>,
_subscriptions: Vec<Subscription>,
popover_styles: bool,
focus_handle: FocusHandle,
}
@@ -132,7 +133,7 @@ impl LanguageModelPickerDelegate {
fn new(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &mut App) + 'static,
popover_styles: bool,
focus_handle: FocusHandle,
window: &mut Window,
@@ -150,42 +151,24 @@ impl LanguageModelPickerDelegate {
get_active_model: Arc::new(get_active_model),
on_toggle_favorite: Arc::new(on_toggle_favorite),
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_refresh_models_task: {
// Create a channel to signal when models need refreshing
let (refresh_tx, mut refresh_rx) = mpsc::unbounded::<()>();
// Subscribe to registry events and send refresh signals through the channel
let registry = LanguageModelRegistry::global(cx);
cx.subscribe(&registry, move |_picker, _, event, _cx| match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_)
| language_model::Event::ProvidersChanged => {
refresh_tx.unbounded_send(()).ok();
}
language_model::Event::DefaultModelChanged
| language_model::Event::InlineAssistantModelChanged
| language_model::Event::CommitMessageModelChanged
| language_model::Event::ThreadSummaryModelChanged => {}
})
.detach();
// Spawn a task that listens for refresh signals and updates the picker
cx.spawn_in(window, async move |this, cx| {
while let Some(()) = refresh_rx.next().await {
if this
.update_in(cx, |picker, window, cx| {
picker.delegate.all_models = Arc::new(all_models(cx));
picker.refresh(window, cx);
})
.is_err()
{
// Picker was dropped, exit the loop
break;
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
|picker, _, event, window, cx| {
match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
let query = picker.query(cx);
picker.delegate.all_models = Arc::new(all_models(cx));
// Update matches will automatically drop the previous task
// if we get a provider event again
picker.update_matches(query, window, cx)
}
_ => {}
}
})
},
},
)],
popover_styles,
focus_handle,
}
@@ -267,6 +250,10 @@ impl LanguageModelPickerDelegate {
(self.get_active_model)(cx)
}
pub fn favorites_count(&self) -> usize {
self.all_models.favorites.len()
}
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if self.all_models.favorites.is_empty() {
return;
@@ -578,7 +565,10 @@ impl PickerDelegate for LanguageModelPickerDelegate {
let handle_action_click = {
let model = model_info.model.clone();
let on_toggle_favorite = self.on_toggle_favorite.clone();
move |cx: &App| on_toggle_favorite(model.clone(), !is_favorite, cx)
cx.listener(move |picker, _, window, cx| {
on_toggle_favorite(model.clone(), !is_favorite, cx);
picker.refresh(window, cx);
})
};
Some(

View File

@@ -12,8 +12,8 @@ use editor::{
};
use futures::{AsyncReadExt as _, FutureExt as _, future::Shared};
use gpui::{
Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Empty, Entity, EntityId,
Image, ImageFormat, Img, SharedString, Task, WeakEntity, pulsating_between,
AppContext, ClipboardEntry, Context, Empty, Entity, EntityId, Image, ImageFormat, Img,
SharedString, Task, WeakEntity,
};
use http_client::{AsyncBody, HttpClientWithUrl};
use itertools::Either;
@@ -32,13 +32,14 @@ use std::{
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
time::Duration,
};
use text::OffsetRangeExt;
use ui::{ButtonLike, Disclosure, TintColor, Toggleable, prelude::*};
use ui::{Disclosure, Toggleable, prelude::*};
use util::{ResultExt, debug_panic, rel_path::RelPath};
use workspace::{Workspace, notifications::NotifyResultExt as _};
use crate::ui::MentionCrease;
pub type MentionTask = Shared<Task<Result<Mention, String>>>;
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -754,25 +755,8 @@ fn render_fold_icon_button(
.update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
.unwrap_or_default();
ButtonLike::new(fold_id)
.style(ButtonStyle::Filled)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(is_in_text_selection)
.child(
h_flex()
.gap_1()
.child(
Icon::from_path(icon_path.clone())
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(
Label::new(label.clone())
.size(LabelSize::Small)
.buffer_font(cx)
.single_line(),
),
)
MentionCrease::new(fold_id, icon_path.clone(), label.clone())
.is_toggled(is_in_text_selection)
.into_any_element()
}
})
@@ -947,12 +931,14 @@ impl Render for LoadingContext {
.editor
.update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
.unwrap_or_default();
ButtonLike::new(("loading-context", self.id))
.style(ButtonStyle::Filled)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(is_in_text_selection)
.when_some(self.image.clone(), |el, image_task| {
el.hoverable_tooltip(move |_, cx| {
let id = ElementId::from(("loading_context", self.id));
MentionCrease::new(id, self.icon.clone(), self.label.clone())
.is_toggled(is_in_text_selection)
.is_loading(self.loading.is_some())
.when_some(self.image.clone(), |this, image_task| {
this.image_preview(move |_, cx| {
let image = image_task.peek().cloned().transpose().ok().flatten();
let image_task = image_task.clone();
cx.new::<ImageHover>(|cx| ImageHover {
@@ -971,35 +957,6 @@ impl Render for LoadingContext {
.into()
})
})
.child(
h_flex()
.gap_1()
.child(
Icon::from_path(self.icon.clone())
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(
Label::new(self.label.clone())
.size(LabelSize::Small)
.buffer_font(cx)
.single_line(),
)
.map(|el| {
if self.loading.is_some() {
el.with_animation(
"loading-context-crease",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.opacity(delta),
)
.into_any()
} else {
el.into_any()
}
}),
)
}
}

View File

@@ -15,8 +15,8 @@ use std::{
sync::{Arc, atomic::AtomicBool},
};
use ui::{
DocumentationAside, DocumentationEdge, DocumentationSide, HighlightedLabel, KeyBinding,
LabelSize, ListItem, ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem,
ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
};
/// Trait for types that can provide and manage agent profiles
@@ -244,6 +244,7 @@ pub(crate) struct ProfilePickerDelegate {
string_candidates: Arc<Vec<StringMatchCandidate>>,
filtered_entries: Vec<ProfilePickerEntry>,
selected_index: usize,
hovered_index: Option<usize>,
query: String,
cancel: Option<Arc<AtomicBool>>,
focus_handle: FocusHandle,
@@ -270,6 +271,7 @@ impl ProfilePickerDelegate {
string_candidates,
filtered_entries,
selected_index: 0,
hovered_index: None,
query: String::new(),
cancel: None,
focus_handle,
@@ -578,23 +580,38 @@ impl PickerDelegate for ProfilePickerDelegate {
let candidate = self.candidates.get(entry.candidate_index)?;
let active_id = self.provider.profile_id(cx);
let is_active = active_id == candidate.id;
let has_documentation = Self::documentation(candidate).is_some();
Some(
ListItem::new(candidate.id.0.clone())
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(HighlightedLabel::new(
candidate.name.clone(),
entry.positions.clone(),
))
.when(is_active, |this| {
this.end_slot(
div()
.pr_2()
.child(Icon::new(IconName::Check).color(Color::Accent)),
)
div()
.id(("profile-picker-item", ix))
.when(has_documentation, |this| {
this.on_hover(cx.listener(move |picker, hovered, _, cx| {
if *hovered {
picker.delegate.hovered_index = Some(ix);
} else if picker.delegate.hovered_index == Some(ix) {
picker.delegate.hovered_index = None;
}
cx.notify();
}))
})
.child(
ListItem::new(candidate.id.0.clone())
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(HighlightedLabel::new(
candidate.name.clone(),
entry.positions.clone(),
))
.when(is_active, |this| {
this.end_slot(
div()
.pr_2()
.child(Icon::new(IconName::Check).color(Color::Accent)),
)
}),
)
.into_any_element(),
)
}
@@ -608,7 +625,8 @@ impl PickerDelegate for ProfilePickerDelegate {
) -> Option<DocumentationAside> {
use std::rc::Rc;
let entry = match self.filtered_entries.get(self.selected_index)? {
let hovered_index = self.hovered_index?;
let entry = match self.filtered_entries.get(hovered_index)? {
ProfilePickerEntry::Profile(entry) => entry,
ProfilePickerEntry::Header(_) => return None,
};
@@ -626,11 +644,14 @@ impl PickerDelegate for ProfilePickerDelegate {
Some(DocumentationAside {
side,
edge: DocumentationEdge::Top,
render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()),
})
}
fn documentation_aside_index(&self) -> Option<usize> {
self.hovered_index
}
fn render_footer(
&self,
_: &mut Window,
@@ -718,6 +739,7 @@ mod tests {
string_candidates: Arc::new(Vec::new()),
filtered_entries: Vec::new(),
selected_index: 0,
hovered_index: None,
query: String::new(),
cancel: None,
focus_handle,
@@ -752,6 +774,7 @@ mod tests {
background: cx.background_executor().clone(),
candidates,
string_candidates: Arc::new(Vec::new()),
hovered_index: None,
filtered_entries: vec![
ProfilePickerEntry::Profile(ProfileMatchEntry {
candidate_index: 0,

View File

@@ -1,8 +1,8 @@
use crate::{
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::BurnModeTooltip,
ui::{BurnModeTooltip, ModelSelectorTooltip},
};
use agent_settings::{AgentSettings, CompletionMode};
use agent_settings::CompletionMode;
use anyhow::Result;
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
@@ -2252,43 +2252,18 @@ impl TextThreadEditor {
.color(color)
.size(IconSize::XSmall);
let tooltip = Tooltip::element({
move |_, cx| {
let focus_handle = focus_handle.clone();
let should_show_cycle_row = !AgentSettings::get_global(cx)
.favorite_model_ids()
.is_empty();
let show_cycle_row = self
.language_model_selector
.read(cx)
.delegate
.favorites_count()
> 1;
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Change Model"))
.child(KeyBinding::for_action_in(
&ToggleModelSelector,
&focus_handle,
cx,
)),
)
.when(should_show_cycle_row, |this| {
this.child(
h_flex()
.pt_1()
.gap_2()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.child(Label::new("Cycle Favorited Models"))
.child(KeyBinding::for_action_in(
&CycleFavoriteModels,
&focus_handle,
cx,
)),
)
})
.into_any()
let tooltip = Tooltip::element({
move |_, _cx| {
ModelSelectorTooltip::new(focus_handle.clone())
.show_cycle_row(show_cycle_row)
.into_any_element()
}
});

View File

@@ -4,6 +4,7 @@ mod burn_mode_tooltip;
mod claude_code_onboarding_modal;
mod end_trial_upsell;
mod hold_for_default;
mod mention_crease;
mod model_selector_components;
mod onboarding_modal;
mod usage_callout;
@@ -14,6 +15,7 @@ pub use burn_mode_tooltip::*;
pub use claude_code_onboarding_modal::*;
pub use end_trial_upsell::*;
pub use hold_for_default::*;
pub use mention_crease::*;
pub use model_selector_components::*;
pub use onboarding_modal::*;
pub use usage_callout::*;

View File

@@ -27,7 +27,7 @@ impl RenderOnce for HoldForDefault {
PlatformStyle::platform(),
None,
Some(TextSize::Default.rems(cx).into()),
true,
false,
)))
.child(div().map(|this| {
if self.is_default {

View File

@@ -0,0 +1,100 @@
use std::time::Duration;
use gpui::{Animation, AnimationExt, AnyView, IntoElement, Window, pulsating_between};
use settings::Settings;
use theme::ThemeSettings;
use ui::{ButtonLike, TintColor, prelude::*};
#[derive(IntoElement)]
pub struct MentionCrease {
id: ElementId,
icon: SharedString,
label: SharedString,
is_toggled: bool,
is_loading: bool,
image_preview: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
}
impl MentionCrease {
pub fn new(
id: impl Into<ElementId>,
icon: impl Into<SharedString>,
label: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
icon: icon.into(),
label: label.into(),
is_toggled: false,
is_loading: false,
image_preview: None,
}
}
pub fn is_toggled(mut self, is_toggled: bool) -> Self {
self.is_toggled = is_toggled;
self
}
pub fn is_loading(mut self, is_loading: bool) -> Self {
self.is_loading = is_loading;
self
}
pub fn image_preview(
mut self,
builder: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
) -> Self {
self.image_preview = Some(Box::new(builder));
self
}
}
impl RenderOnce for MentionCrease {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let font_size = settings.agent_buffer_font_size(cx);
let buffer_font = settings.buffer_font.clone();
let button_height = DefiniteLength::Absolute(AbsoluteLength::Pixels(
px(window.line_height().into()) - px(1.),
));
ButtonLike::new(self.id)
.style(ButtonStyle::Outlined)
.size(ButtonSize::Compact)
.height(button_height)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(self.is_toggled)
.when_some(self.image_preview, |this, image_preview| {
this.hoverable_tooltip(image_preview)
})
.child(
h_flex()
.pb_px()
.gap_1()
.font(buffer_font)
.text_size(font_size)
.child(
Icon::from_path(self.icon.clone())
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(self.label.clone())
.map(|this| {
if self.is_loading {
this.with_animation(
"loading-context-crease",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.opacity(delta),
)
.into_any()
} else {
this.into_any()
}
}),
)
}
}

View File

@@ -1,5 +1,13 @@
use gpui::{Action, FocusHandle, prelude::*};
use gpui::{Action, ClickEvent, FocusHandle, prelude::*};
use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
use zed_actions::agent::ToggleModelSelector;
use crate::CycleFavoriteModels;
enum ModelIcon {
Name(IconName),
Path(SharedString),
}
#[derive(IntoElement)]
pub struct ModelSelectorHeader {
@@ -35,11 +43,6 @@ impl RenderOnce for ModelSelectorHeader {
}
}
enum ModelIcon {
Name(IconName),
Path(SharedString),
}
#[derive(IntoElement)]
pub struct ModelSelectorListItem {
index: usize,
@@ -48,7 +51,7 @@ pub struct ModelSelectorListItem {
is_selected: bool,
is_focused: bool,
is_favorite: bool,
on_toggle_favorite: Option<Box<dyn Fn(&App) + 'static>>,
on_toggle_favorite: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl ModelSelectorListItem {
@@ -89,7 +92,10 @@ impl ModelSelectorListItem {
self
}
pub fn on_toggle_favorite(mut self, handler: impl Fn(&App) + 'static) -> Self {
pub fn on_toggle_favorite(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle_favorite = Some(Box::new(handler));
self
}
@@ -141,7 +147,7 @@ impl RenderOnce for ModelSelectorListItem {
.icon_color(color)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text(tooltip))
.on_click(move |_, _, cx| (handle_click)(cx)),
.on_click(move |event, window, cx| (handle_click)(event, window, cx)),
)
}
}))
@@ -187,3 +193,57 @@ impl RenderOnce for ModelSelectorFooter {
)
}
}
#[derive(IntoElement)]
pub struct ModelSelectorTooltip {
focus_handle: FocusHandle,
show_cycle_row: bool,
}
impl ModelSelectorTooltip {
pub fn new(focus_handle: FocusHandle) -> Self {
Self {
focus_handle,
show_cycle_row: true,
}
}
pub fn show_cycle_row(mut self, show: bool) -> Self {
self.show_cycle_row = show;
self
}
}
impl RenderOnce for ModelSelectorTooltip {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Change Model"))
.child(KeyBinding::for_action_in(
&ToggleModelSelector,
&self.focus_handle,
cx,
)),
)
.when(self.show_cycle_row, |this| {
this.child(
h_flex()
.pt_1()
.gap_2()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.child(Label::new("Cycle Favorited Models"))
.child(KeyBinding::for_action_in(
&CycleFavoriteModels,
&self.focus_handle,
cx,
)),
)
})
}
}

View File

@@ -8,7 +8,7 @@ use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::B
use http_client::http::{self, HeaderMap, HeaderValue};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, StatusCode};
use serde::{Deserialize, Serialize};
pub use settings::ModelMode;
pub use settings::{AnthropicAvailableModel as AvailableModel, ModelMode};
use strum::{EnumIter, EnumString};
use thiserror::Error;

View File

@@ -15,7 +15,6 @@ path = "src/buffer_diff.rs"
test-support = ["settings"]
[dependencies]
anyhow.workspace = true
clock.workspace = true
futures.workspace = true
git2.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -113,7 +113,7 @@ impl CopilotSweAgentBot {
const USER_ID: i32 = 198982749;
/// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot
/// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases.
const NAME_ALIAS: &'static str = "copilot";
const NAME_ALIAS: &'static str = "Copilot";
/// Returns the `created_at` timestamp for the Dependabot bot user.
fn created_at() -> &'static NaiveDateTime {

View File

@@ -2647,13 +2647,13 @@ async fn test_git_diff_base_change(
local_unstaged_diff_a.read_with(cx_a, |diff, cx| {
let buffer = buffer_local_a.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&diff.base_text_string().unwrap(),
&diff.base_text_string(cx).unwrap(),
&[(1..2, "", "two\n", DiffHunkStatus::added_none())],
);
});
@@ -2677,13 +2677,13 @@ async fn test_git_diff_base_change(
remote_unstaged_diff_a.read_with(cx_b, |diff, cx| {
let buffer = remote_buffer_a.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&diff.base_text_string().unwrap(),
&diff.base_text_string(cx).unwrap(),
&[(1..2, "", "two\n", DiffHunkStatus::added_none())],
);
});
@@ -2699,13 +2699,13 @@ async fn test_git_diff_base_change(
remote_uncommitted_diff_a.read_with(cx_b, |diff, cx| {
let buffer = remote_buffer_a.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(committed_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&diff.base_text_string().unwrap(),
&diff.base_text_string(cx).unwrap(),
&[(
1..2,
"TWO\n",
@@ -2731,13 +2731,13 @@ async fn test_git_diff_base_change(
local_unstaged_diff_a.read_with(cx_a, |diff, cx| {
let buffer = buffer_local_a.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(new_staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&diff.base_text_string().unwrap(),
&diff.base_text_string(cx).unwrap(),
&[(2..3, "", "three\n", DiffHunkStatus::added_none())],
);
});
@@ -2746,13 +2746,13 @@ async fn test_git_diff_base_change(
remote_unstaged_diff_a.read_with(cx_b, |diff, cx| {
let buffer = remote_buffer_a.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(new_staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&diff.base_text_string().unwrap(),
&diff.base_text_string(cx).unwrap(),
&[(2..3, "", "three\n", DiffHunkStatus::added_none())],
);
});
@@ -2760,13 +2760,13 @@ async fn test_git_diff_base_change(
remote_uncommitted_diff_a.read_with(cx_b, |diff, cx| {
let buffer = remote_buffer_a.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(new_committed_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&diff.base_text_string().unwrap(),
&diff.base_text_string(cx).unwrap(),
&[(
1..2,
"TWO_HUNDRED\n",
@@ -2813,13 +2813,13 @@ async fn test_git_diff_base_change(
local_unstaged_diff_b.read_with(cx_a, |diff, cx| {
let buffer = buffer_local_b.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&diff.base_text_string().unwrap(),
&diff.base_text_string(cx).unwrap(),
&[(1..2, "", "two\n", DiffHunkStatus::added_none())],
);
});
@@ -2842,11 +2842,11 @@ async fn test_git_diff_base_change(
remote_unstaged_diff_b.read_with(cx_b, |diff, cx| {
let buffer = remote_buffer_b.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&staged_text,
&[(1..2, "", "two\n", DiffHunkStatus::added_none())],
@@ -2864,11 +2864,11 @@ async fn test_git_diff_base_change(
local_unstaged_diff_b.read_with(cx_a, |diff, cx| {
let buffer = buffer_local_b.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(new_staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&new_staged_text,
&[(2..3, "", "three\n", DiffHunkStatus::added_none())],
@@ -2878,11 +2878,11 @@ async fn test_git_diff_base_change(
remote_unstaged_diff_b.read_with(cx_b, |diff, cx| {
let buffer = remote_buffer_b.read(cx);
assert_eq!(
diff.base_text_string().as_deref(),
diff.base_text_string(cx).as_deref(),
Some(new_staged_text.as_str())
);
assert_hunks(
diff.hunks_in_row_range(0..4, buffer, cx),
diff.snapshot(cx).hunks_in_row_range(0..4, buffer),
buffer,
&new_staged_text,
&[(2..3, "", "three\n", DiffHunkStatus::added_none())],
@@ -5195,7 +5195,7 @@ async fn test_project_search(
cx,
)
});
while let Ok(result) = search_rx.recv().await {
while let Ok(result) = search_rx.rx.recv().await {
match result {
SearchResult::Buffer { buffer, ranges } => {
results.entry(buffer).or_insert(ranges);
@@ -6745,8 +6745,13 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
});
// Split pane to the right
pane.update(cx, |pane, cx| {
pane.split(workspace::SplitDirection::Right, cx);
pane.update_in(cx, |pane, window, cx| {
pane.split(
workspace::SplitDirection::Right,
workspace::SplitMode::default(),
window,
cx,
);
});
cx.run_until_parked();
let right_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());

View File

@@ -905,7 +905,7 @@ impl RandomizedTest for ProjectCollaborationTest {
drop(project);
let search = cx.executor().spawn(async move {
let mut results = HashMap::default();
while let Ok(result) = search.recv().await {
while let Ok(result) = search.rx.recv().await {
if let SearchResult::Buffer { buffer, ranges } = result {
results.entry(buffer).or_insert(ranges);
}
@@ -1377,7 +1377,7 @@ impl RandomizedTest for ProjectCollaborationTest {
.get_unstaged_diff(host_buffer.read(cx).remote_id(), cx)
.unwrap()
.read(cx)
.base_text_string()
.base_text_string(cx)
});
let guest_diff_base = guest_project.read_with(client_cx, |project, cx| {
project
@@ -1386,7 +1386,7 @@ impl RandomizedTest for ProjectCollaborationTest {
.get_unstaged_diff(guest_buffer.read(cx).remote_id(), cx)
.unwrap()
.read(cx)
.base_text_string()
.base_text_string(cx)
});
assert_eq!(
guest_diff_base, host_diff_base,

View File

@@ -855,8 +855,6 @@ async fn test_slow_adapter_startup_retries(
#[gpui::test]
async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) {
use project::trusted_worktrees::RemoteHostLocation;
cx_a.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
project::trusted_worktrees::init(HashMap::default(), None, None, cx);
@@ -991,23 +989,19 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m
});
assert_eq!(worktree_ids.len(), 2);
let remote_host = project_a.read_with(cx_a, |project, cx| {
project
.remote_connection_options(cx)
.map(RemoteHostLocation::from)
});
let trusted_worktrees =
cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
let can_trust_a =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
let can_trust_b =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
let can_trust_a = trusted_worktrees.update(cx_a, |store, cx| {
store.can_trust(&worktree_store, worktree_ids[0], cx)
});
let can_trust_b = trusted_worktrees.update(cx_a, |store, cx| {
store.can_trust(&worktree_store, worktree_ids[1], cx)
});
assert!(!can_trust_a, "project_a should be restricted initially");
assert!(!can_trust_b, "project_b should be restricted initially");
let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| {
store.has_restricted_worktrees(&worktree_store, cx)
});
@@ -1054,8 +1048,8 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m
trusted_worktrees.update(cx_a, |store, cx| {
store.trust(
&worktree_store,
HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
remote_host.clone(),
cx,
);
});
@@ -1080,25 +1074,29 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m
"inlay hints should be queried after trust approval"
);
let can_trust_a =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
let can_trust_b =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
let can_trust_a = trusted_worktrees.update(cx_a, |store, cx| {
store.can_trust(&worktree_store, worktree_ids[0], cx)
});
let can_trust_b = trusted_worktrees.update(cx_a, |store, cx| {
store.can_trust(&worktree_store, worktree_ids[1], cx)
});
assert!(can_trust_a, "project_a should be trusted after trust()");
assert!(!can_trust_b, "project_b should still be restricted");
trusted_worktrees.update(cx_a, |store, cx| {
store.trust(
&worktree_store,
HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
remote_host.clone(),
cx,
);
});
let can_trust_a =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
let can_trust_b =
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
let can_trust_a = trusted_worktrees.update(cx_a, |store, cx| {
store.can_trust(&worktree_store, worktree_ids[0], cx)
});
let can_trust_b = trusted_worktrees.update(cx_a, |store, cx| {
store.can_trust(&worktree_store, worktree_ids[1], cx)
});
assert!(can_trust_a, "project_a should remain trusted");
assert!(can_trust_b, "project_b should now be trusted");

View File

@@ -31,9 +31,9 @@ use smallvec::SmallVec;
use std::{mem, sync::Arc};
use theme::{ActiveTheme, ThemeSettings};
use ui::{
Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, Facepile, HighlightedLabel,
Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem, Tab, Tooltip,
prelude::*, tooltip_container,
Avatar, AvatarAvailabilityIndicator, Button, Color, ContextMenu, CopyButton, Facepile,
HighlightedLabel, Icon, IconButton, IconName, IconSize, Indicator, Label, ListHeader, ListItem,
Tab, Tooltip, prelude::*, tooltip_container,
};
use util::{ResultExt, TryFutureExt, maybe};
use workspace::{
@@ -2527,16 +2527,9 @@ impl CollabPanel {
let button = match section {
Section::ActiveCall => channel_link.map(|channel_link| {
let channel_link_copy = channel_link;
IconButton::new("channel-link", IconName::Copy)
.icon_size(IconSize::Small)
.size(ButtonSize::None)
CopyButton::new(channel_link)
.visible_on_hover("section-header")
.on_click(move |_, _, cx| {
let item = ClipboardItem::new_string(channel_link_copy.clone());
cx.write_to_clipboard(item)
})
.tooltip(Tooltip::text("Copy channel link"))
.tooltip_label("Copy Channel Link")
.into_any_element()
}),
Section::Contacts => Some(

View File

@@ -0,0 +1,45 @@
[package]
name = "component_preview"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/component_preview.rs"
[features]
default = []
preview = []
test-support = ["db/test-support"]
[dependencies]
anyhow.workspace = true
client.workspace = true
collections.workspace = true
component.workspace = true
db.workspace = true
fs.workspace = true
gpui.workspace = true
language.workspace = true
log.workspace = true
node_runtime.workspace = true
notifications.workspace = true
project.workspace = true
release_channel.workspace = true
reqwest_client.workspace = true
session.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
ui_input.workspace = true
uuid.workspace = true
workspace.workspace = true
[[example]]
name = "component_preview"
path = "examples/component_preview.rs"
required-features = ["preview"]

View File

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

View File

@@ -0,0 +1,18 @@
//! Component Preview Example
//!
//! Run with: `cargo run -p component_preview --example component_preview --features="preview"`
//!
//! To use this in other projects, add the following to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! component_preview = { path = "../component_preview", features = ["preview"] }
//!
//! [[example]]
//! name = "component_preview"
//! path = "examples/component_preview.rs"
//! ```
fn main() {
component_preview::run_component_preview();
}

View File

@@ -1,7 +1,4 @@
//! # Component Preview
//!
//! A view for exploring Zed components.
mod component_preview_example;
mod persistence;
use client::UserStore;
@@ -11,18 +8,21 @@ use gpui::{
App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
};
use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
use languages::LanguageRegistry;
use language::LanguageRegistry;
use notifications::status_toast::{StatusToast, ToastIcon};
use persistence::COMPONENT_PREVIEW_DB;
use project::Project;
use std::{iter::Iterator, ops::Range, sync::Arc};
use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
use ui_input::InputField;
use workspace::AppState;
use workspace::{
AppState, Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items,
item::ItemEvent,
Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,
};
#[allow(unused_imports)]
pub use component_preview_example::*;
pub fn init(app_state: Arc<AppState>, cx: &mut App) {
workspace::register_serializable_item::<ComponentPreview>(cx);

View File

@@ -0,0 +1,145 @@
/// Run the component preview application.
///
/// This initializes the application with minimal required infrastructure
/// and opens a workspace with the ComponentPreview item.
#[cfg(feature = "preview")]
pub fn run_component_preview() {
use fs::RealFs;
use gpui::{
AppContext as _, Application, Bounds, KeyBinding, WindowBounds, WindowOptions, actions,
size,
};
use client::{Client, UserStore};
use language::LanguageRegistry;
use node_runtime::NodeRuntime;
use project::Project;
use reqwest_client::ReqwestClient;
use session::{AppSession, Session};
use std::sync::Arc;
use ui::{App, px};
use workspace::{AppState, Workspace, WorkspaceStore};
use crate::{ComponentPreview, init};
actions!(zed, [Quit]);
fn quit(_: &Quit, cx: &mut App) {
cx.quit();
}
Application::new().run(|cx| {
component::init();
cx.on_action(quit);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
let version = release_channel::AppVersion::load(env!("CARGO_PKG_VERSION"), None, None);
release_channel::init(version, cx);
let http_client =
ReqwestClient::user_agent("component_preview").expect("Failed to create HTTP client");
cx.set_http_client(Arc::new(http_client));
let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
<dyn fs::Fs>::set_global(fs.clone(), cx);
settings::init(cx);
theme::init(theme::LoadThemes::JustBase, cx);
let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
let client = Client::production(cx);
client::init(&client, cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx));
let session_id = uuid::Uuid::new_v4().to_string();
let session = cx.background_executor().block(Session::new(session_id));
let session = cx.new(|cx| AppSession::new(session, cx));
let node_runtime = NodeRuntime::unavailable();
let app_state = Arc::new(AppState {
languages,
client,
user_store,
workspace_store,
fs,
build_window_options: |_, _| Default::default(),
node_runtime,
session,
});
AppState::set_global(Arc::downgrade(&app_state), cx);
workspace::init(app_state.clone(), cx);
init(app_state.clone(), cx);
let size = size(px(1200.), px(800.));
let bounds = Bounds::centered(None, size, cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
{
move |window, cx| {
let app_state = app_state;
theme::setup_ui_font(window, cx);
let project = Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
None,
false,
cx,
);
let workspace = cx.new(|cx| {
Workspace::new(
Default::default(),
project.clone(),
app_state.clone(),
window,
cx,
)
});
workspace.update(cx, |workspace, cx| {
let weak_workspace = cx.entity().downgrade();
let language_registry = app_state.languages.clone();
let user_store = app_state.user_store.clone();
let component_preview = cx.new(|cx| {
ComponentPreview::new(
weak_workspace,
project,
language_registry,
user_store,
None,
None,
window,
cx,
)
.expect("Failed to create component preview")
});
workspace.add_item_to_active_pane(
Box::new(component_preview),
None,
true,
window,
cx,
);
});
workspace
}
},
)
.expect("Failed to open component preview window");
cx.activate(true);
});
}

View File

@@ -34,7 +34,7 @@ impl StdioTransport {
let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?;
let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive();
let mut command =
builder.build_command(Some(binary.executable.display().to_string()), &binary.args);
builder.build_smol_command(Some(binary.executable.display().to_string()), &binary.args);
command
.envs(binary.env.unwrap_or_default())

View File

@@ -24,7 +24,7 @@ use std::{
time::Duration,
};
use task::TcpArgumentsTemplate;
use util::ConnectionResult;
use util::{ConnectionResult, ResultExt, process::Child};
use crate::{
adapters::{DebugAdapterBinary, TcpArguments},
@@ -528,7 +528,7 @@ impl TcpTransport {
command.args(&binary.arguments);
command.envs(&binary.envs);
let mut p = Child::spawn(command, Stdio::null())
let mut p = Child::spawn(command, Stdio::null(), Stdio::piped(), Stdio::piped())
.with_context(|| "failed to start debug adapter.")?;
stdout_task = p.stdout.take().map(|stdout| {
@@ -582,7 +582,7 @@ impl Transport for TcpTransport {
fn kill(&mut self) {
if let Some(process) = &mut *self.process.lock() {
process.kill();
process.kill().log_err();
}
}
@@ -647,7 +647,7 @@ impl Transport for TcpTransport {
impl Drop for TcpTransport {
fn drop(&mut self) {
if let Some(mut p) = self.process.lock().take() {
p.kill()
p.kill().log_err();
}
}
}
@@ -678,7 +678,7 @@ impl StdioTransport {
command.args(&binary.arguments);
command.envs(&binary.envs);
let mut process = Child::spawn(command, Stdio::piped())?;
let mut process = Child::spawn(command, Stdio::piped(), Stdio::piped(), Stdio::piped())?;
let _stderr_task = process.stderr.take().map(|stderr| {
cx.background_spawn(TransportDelegate::handle_adapter_log(
@@ -703,7 +703,7 @@ impl Transport for StdioTransport {
}
fn kill(&mut self) {
self.process.lock().kill();
self.process.lock().kill().log_err();
}
fn connect(
@@ -731,7 +731,7 @@ impl Transport for StdioTransport {
impl Drop for StdioTransport {
fn drop(&mut self) {
self.process.lock().kill();
self.process.lock().kill().log_err();
}
}
@@ -1024,68 +1024,3 @@ impl Transport for FakeTransport {
self
}
}
struct Child {
process: smol::process::Child,
}
impl std::ops::Deref for Child {
type Target = smol::process::Child;
fn deref(&self) -> &Self::Target {
&self.process
}
}
impl std::ops::DerefMut for Child {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.process
}
}
impl Child {
fn into_inner(self) -> smol::process::Child {
self.process
}
#[cfg(not(windows))]
fn spawn(mut command: std::process::Command, stdin: Stdio) -> Result<Self> {
util::set_pre_exec_to_start_new_session(&mut command);
let mut command = smol::process::Command::from(command);
let process = command
.stdin(stdin)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn command `{command:?}`",))?;
Ok(Self { process })
}
#[cfg(windows)]
fn spawn(command: std::process::Command, stdin: Stdio) -> Result<Self> {
// TODO(windows): create a job object and add the child process handle to it,
// see https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects
let mut command = smol::process::Command::from(command);
let process = command
.stdin(stdin)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn command `{command:?}`",))?;
Ok(Self { process })
}
#[cfg(not(windows))]
fn kill(&mut self) {
let pid = self.process.id();
unsafe {
libc::killpg(pid as i32, libc::SIGKILL);
}
}
#[cfg(windows)]
fn kill(&mut self) {
// TODO(windows): terminate the job object in kill
let _ = self.process.kill();
}
}

View File

@@ -1579,8 +1579,10 @@ impl Panel for DebugPanel {
Some(proto::PanelId::DebugPanel)
}
fn icon(&self, _window: &Window, _cx: &App) -> Option<IconName> {
Some(IconName::Debug)
fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
DebuggerSettings::get_global(cx)
.button
.then_some(IconName::Debug)
}
fn icon_tooltip(&self, _window: &Window, cx: &App) -> Option<&'static str> {

View File

@@ -19,6 +19,7 @@ ai_onboarding.workspace = true
anyhow.workspace = true
arrayvec.workspace = true
brotli.workspace = true
buffer_diff.workspace = true
client.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
@@ -52,7 +53,9 @@ settings.workspace = true
strum.workspace = true
telemetry.workspace = true
telemetry_events.workspace = true
text.workspace = true
thiserror.workspace = true
time.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,375 @@
use crate::{
EditPredictionStore, StoredEvent,
cursor_excerpt::editable_and_context_ranges_for_cursor_position, example_spec::ExampleSpec,
};
use anyhow::Result;
use buffer_diff::BufferDiffSnapshot;
use collections::HashMap;
use gpui::{App, Entity, Task};
use language::{Buffer, ToPoint as _};
use project::Project;
use std::{collections::hash_map, fmt::Write as _, path::Path, sync::Arc};
use text::{BufferSnapshot as TextBufferSnapshot, ToOffset as _};
pub fn capture_example(
project: Entity<Project>,
buffer: Entity<Buffer>,
cursor_anchor: language::Anchor,
last_event_is_expected_patch: bool,
cx: &mut App,
) -> Option<Task<Result<ExampleSpec>>> {
let ep_store = EditPredictionStore::try_global(cx)?;
let snapshot = buffer.read(cx).snapshot();
let file = snapshot.file()?;
let worktree_id = file.worktree_id(cx);
let repository = project.read(cx).active_repository(cx)?;
let repository_snapshot = repository.read(cx).snapshot();
let worktree = project.read(cx).worktree_for_id(worktree_id, cx)?;
let cursor_path = worktree.read(cx).root_name().join(file.path());
if worktree.read(cx).abs_path() != repository_snapshot.work_directory_abs_path {
return None;
}
let repository_url = repository_snapshot
.remote_origin_url
.clone()
.or_else(|| repository_snapshot.remote_upstream_url.clone())?;
let revision = repository_snapshot.head_commit.as_ref()?.sha.to_string();
let mut events = ep_store.update(cx, |store, cx| {
store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
let git_store = project.read(cx).git_store().clone();
Some(cx.spawn(async move |mut cx| {
let snapshots_by_path = collect_snapshots(&project, &git_store, &events, &mut cx).await?;
let cursor_excerpt = cx
.background_executor()
.spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) })
.await;
let uncommitted_diff = cx
.background_executor()
.spawn(async move { compute_uncommitted_diff(snapshots_by_path) })
.await;
let mut edit_history = String::new();
let mut expected_patch = String::new();
if last_event_is_expected_patch {
if let Some(stored_event) = events.pop() {
zeta_prompt::write_event(&mut expected_patch, &stored_event.event);
}
}
for stored_event in &events {
zeta_prompt::write_event(&mut edit_history, &stored_event.event);
if !edit_history.ends_with('\n') {
edit_history.push('\n');
}
}
let name = generate_timestamp_name();
Ok(ExampleSpec {
name,
repository_url,
revision,
uncommitted_diff,
cursor_path: cursor_path.as_std_path().into(),
cursor_position: cursor_excerpt,
edit_history,
expected_patch,
})
}))
}
fn compute_cursor_excerpt(
snapshot: &language::BufferSnapshot,
cursor_anchor: language::Anchor,
) -> String {
let cursor_point = cursor_anchor.to_point(snapshot);
let (_editable_range, context_range) =
editable_and_context_ranges_for_cursor_position(cursor_point, snapshot, 100, 50);
let context_start_offset = context_range.start.to_offset(snapshot);
let cursor_offset = cursor_anchor.to_offset(snapshot);
let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset);
let mut excerpt = snapshot.text_for_range(context_range).collect::<String>();
if cursor_offset_in_excerpt <= excerpt.len() {
excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER);
}
excerpt
}
async fn collect_snapshots(
project: &Entity<Project>,
git_store: &Entity<project::git_store::GitStore>,
events: &[StoredEvent],
cx: &mut gpui::AsyncApp,
) -> Result<HashMap<Arc<Path>, (TextBufferSnapshot, BufferDiffSnapshot)>> {
let mut snapshots_by_path = HashMap::default();
for stored_event in events {
let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref();
if let Some((project_path, full_path)) = project.read_with(cx, |project, cx| {
let project_path = project.find_project_path(path, cx)?;
let full_path = project
.worktree_for_id(project_path.worktree_id, cx)?
.read(cx)
.root_name()
.join(&project_path.path)
.as_std_path()
.into();
Some((project_path, full_path))
})? {
if let hash_map::Entry::Vacant(entry) = snapshots_by_path.entry(full_path) {
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})?
.await?;
let diff = git_store
.update(cx, |git_store, cx| {
git_store.open_uncommitted_diff(buffer.clone(), cx)
})?
.await?;
let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx))?;
entry.insert((stored_event.old_snapshot.clone(), diff_snapshot));
}
}
}
Ok(snapshots_by_path)
}
fn compute_uncommitted_diff(
snapshots_by_path: HashMap<Arc<Path>, (TextBufferSnapshot, BufferDiffSnapshot)>,
) -> String {
let mut uncommitted_diff = String::new();
for (full_path, (before_text, diff_snapshot)) in snapshots_by_path {
if let Some(head_text) = &diff_snapshot.base_text_string() {
let file_diff = language::unified_diff(head_text, &before_text.text());
if !file_diff.is_empty() {
let path_str = full_path.to_string_lossy();
writeln!(uncommitted_diff, "--- a/{path_str}").ok();
writeln!(uncommitted_diff, "+++ b/{path_str}").ok();
uncommitted_diff.push_str(&file_diff);
if !uncommitted_diff.ends_with('\n') {
uncommitted_diff.push('\n');
}
}
}
}
uncommitted_diff
}
fn generate_timestamp_name() -> String {
let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]");
match format {
Ok(format) => {
let now = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
now.format(&format)
.unwrap_or_else(|_| "unknown-time".to_string())
}
Err(_) => "unknown-time".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use client::{Client, UserStore};
use clock::FakeSystemClock;
use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient};
use indoc::indoc;
use language::{Anchor, Point};
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use std::path::Path;
#[gpui::test]
async fn test_capture_example(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let committed_contents = indoc! {"
fn main() {
one();
two();
three();
four();
five();
six();
seven();
eight();
nine();
}
"};
let disk_contents = indoc! {"
fn main() {
// comment 1
one();
two();
three();
four();
five();
six();
seven();
eight();
// comment 2
nine();
}
"};
fs.insert_tree(
"/project",
json!({
".git": {},
"src": {
"main.rs": disk_contents,
}
}),
)
.await;
fs.set_head_for_repo(
Path::new("/project/.git"),
&[("src/main.rs", committed_contents.to_string())],
"abc123def456",
);
fs.set_remote_for_repo(
Path::new("/project/.git"),
"origin",
"https://github.com/test/repo.git",
);
let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer("/project/src/main.rs", cx)
})
.await
.unwrap();
let ep_store = cx.read(|cx| EditPredictionStore::try_global(cx).unwrap());
ep_store.update(cx, |ep_store, cx| {
ep_store.register_buffer(&buffer, &project, cx)
});
cx.run_until_parked();
buffer.update(cx, |buffer, cx| {
let point = Point::new(6, 0);
buffer.edit([(point..point, " // comment 3\n")], None, cx);
let point = Point::new(4, 0);
buffer.edit([(point..point, " // comment 4\n")], None, cx);
pretty_assertions::assert_eq!(
buffer.text(),
indoc! {"
fn main() {
// comment 1
one();
two();
// comment 4
three();
four();
// comment 3
five();
six();
seven();
eight();
// comment 2
nine();
}
"}
);
});
cx.run_until_parked();
let mut example = cx
.update(|cx| {
capture_example(project.clone(), buffer.clone(), Anchor::MIN, false, cx).unwrap()
})
.await
.unwrap();
example.name = "test".to_string();
pretty_assertions::assert_eq!(
example,
ExampleSpec {
name: "test".to_string(),
repository_url: "https://github.com/test/repo.git".to_string(),
revision: "abc123def456".to_string(),
uncommitted_diff: indoc! {"
--- a/project/src/main.rs
+++ b/project/src/main.rs
@@ -1,4 +1,5 @@
fn main() {
+ // comment 1
one();
two();
three();
@@ -7,5 +8,6 @@
six();
seven();
eight();
+ // comment 2
nine();
}
"}
.to_string(),
cursor_path: Path::new("project/src/main.rs").into(),
cursor_position: indoc! {"
<|user_cursor|>fn main() {
// comment 1
one();
two();
// comment 4
three();
four();
// comment 3
five();
six();
seven();
eight();
// comment 2
nine();
}
"}
.to_string(),
edit_history: indoc! {"
--- a/project/src/main.rs
+++ b/project/src/main.rs
@@ -2,8 +2,10 @@
// comment 1
one();
two();
+ // comment 4
three();
four();
+ // comment 3
five();
six();
seven();
"}
.to_string(),
expected_patch: "".to_string(),
}
);
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
zlog::init_test();
let http_client = FakeHttpClient::with_404_response();
let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx);
language_model::init(client.clone(), cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
EditPredictionStore::global(&client, &user_store, cx);
})
}
}

View File

@@ -35,6 +35,7 @@ use semver::Version;
use serde::de::DeserializeOwned;
use settings::{EditPredictionProvider, SettingsStore, update_settings_file};
use std::collections::{VecDeque, hash_map};
use text::Edit;
use workspace::Workspace;
use std::ops::Range;
@@ -57,9 +58,9 @@ pub mod open_ai_response;
mod prediction;
pub mod sweep_ai;
#[cfg(any(test, feature = "test-support", feature = "cli-support"))]
pub mod udiff;
mod capture_example;
mod zed_edit_prediction_delegate;
pub mod zeta1;
pub mod zeta2;
@@ -74,6 +75,7 @@ pub use crate::prediction::EditPrediction;
pub use crate::prediction::EditPredictionId;
use crate::prediction::EditPredictionResult;
pub use crate::sweep_ai::SweepAi;
pub use capture_example::capture_example;
pub use language_model::ApiKeyState;
pub use telemetry_events::EditPredictionRating;
pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate;
@@ -231,8 +233,15 @@ pub struct EditPredictionFinishedDebugEvent {
pub type RequestDebugInfo = predict_edits_v3::DebugInfo;
/// An event with associated metadata for reconstructing buffer state.
#[derive(Clone)]
pub struct StoredEvent {
pub event: Arc<zeta_prompt::Event>,
pub old_snapshot: TextBufferSnapshot,
}
struct ProjectState {
events: VecDeque<Arc<zeta_prompt::Event>>,
events: VecDeque<StoredEvent>,
last_event: Option<LastEvent>,
recent_paths: VecDeque<ProjectPath>,
registered_buffers: HashMap<gpui::EntityId, RegisteredBuffer>,
@@ -248,7 +257,7 @@ struct ProjectState {
}
impl ProjectState {
pub fn events(&self, cx: &App) -> Vec<Arc<zeta_prompt::Event>> {
pub fn events(&self, cx: &App) -> Vec<StoredEvent> {
self.events
.iter()
.cloned()
@@ -260,7 +269,7 @@ impl ProjectState {
.collect()
}
pub fn events_split_by_pause(&self, cx: &App) -> Vec<Arc<zeta_prompt::Event>> {
pub fn events_split_by_pause(&self, cx: &App) -> Vec<StoredEvent> {
self.events
.iter()
.cloned()
@@ -415,7 +424,7 @@ impl LastEvent {
&self,
license_detection_watchers: &HashMap<WorktreeId, Rc<LicenseDetectionWatcher>>,
cx: &App,
) -> Option<Arc<zeta_prompt::Event>> {
) -> Option<StoredEvent> {
let path = buffer_path_with_id_fallback(self.new_file.as_ref(), &self.new_snapshot, cx);
let old_path = buffer_path_with_id_fallback(self.old_file.as_ref(), &self.old_snapshot, cx);
@@ -430,19 +439,22 @@ impl LastEvent {
})
});
let diff = language::unified_diff(&self.old_snapshot.text(), &self.new_snapshot.text());
let diff = compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?;
if path == old_path && diff.is_empty() {
None
} else {
Some(Arc::new(zeta_prompt::Event::BufferChange {
old_path,
path,
diff,
in_open_source_repo,
// TODO: Actually detect if this edit was predicted or not
predicted: false,
}))
Some(StoredEvent {
event: Arc::new(zeta_prompt::Event::BufferChange {
old_path,
path,
diff,
in_open_source_repo,
// TODO: Actually detect if this edit was predicted or not
predicted: false,
}),
old_snapshot: self.old_snapshot.clone(),
})
}
}
@@ -475,6 +487,52 @@ impl LastEvent {
}
}
pub(crate) fn compute_diff_between_snapshots(
old_snapshot: &TextBufferSnapshot,
new_snapshot: &TextBufferSnapshot,
) -> Option<String> {
let edits: Vec<Edit<usize>> = new_snapshot
.edits_since::<usize>(&old_snapshot.version)
.collect();
let (first_edit, last_edit) = edits.first().zip(edits.last())?;
let old_start_point = old_snapshot.offset_to_point(first_edit.old.start);
let old_end_point = old_snapshot.offset_to_point(last_edit.old.end);
let new_start_point = new_snapshot.offset_to_point(first_edit.new.start);
let new_end_point = new_snapshot.offset_to_point(last_edit.new.end);
const CONTEXT_LINES: u32 = 3;
let old_context_start_row = old_start_point.row.saturating_sub(CONTEXT_LINES);
let new_context_start_row = new_start_point.row.saturating_sub(CONTEXT_LINES);
let old_context_end_row =
(old_end_point.row + 1 + CONTEXT_LINES).min(old_snapshot.max_point().row);
let new_context_end_row =
(new_end_point.row + 1 + CONTEXT_LINES).min(new_snapshot.max_point().row);
let old_start_line_offset = old_snapshot.point_to_offset(Point::new(old_context_start_row, 0));
let new_start_line_offset = new_snapshot.point_to_offset(Point::new(new_context_start_row, 0));
let old_end_line_offset = old_snapshot
.point_to_offset(Point::new(old_context_end_row + 1, 0).min(old_snapshot.max_point()));
let new_end_line_offset = new_snapshot
.point_to_offset(Point::new(new_context_end_row + 1, 0).min(new_snapshot.max_point()));
let old_edit_range = old_start_line_offset..old_end_line_offset;
let new_edit_range = new_start_line_offset..new_end_line_offset;
let old_region_text: String = old_snapshot.text_for_range(old_edit_range).collect();
let new_region_text: String = new_snapshot.text_for_range(new_edit_range).collect();
let diff = language::unified_diff_with_offsets(
&old_region_text,
&new_region_text,
old_context_start_row,
new_context_start_row,
);
Some(diff)
}
fn buffer_path_with_id_fallback(
file: Option<&Arc<dyn File>>,
snapshot: &TextBufferSnapshot,
@@ -643,7 +701,7 @@ impl EditPredictionStore {
&self,
project: &Entity<Project>,
cx: &App,
) -> Vec<Arc<zeta_prompt::Event>> {
) -> Vec<StoredEvent> {
self.projects
.get(&project.entity_id())
.map(|project_state| project_state.events(cx))
@@ -654,7 +712,7 @@ impl EditPredictionStore {
&self,
project: &Entity<Project>,
cx: &App,
) -> Vec<Arc<zeta_prompt::Event>> {
) -> Vec<StoredEvent> {
self.projects
.get(&project.entity_id())
.map(|project_state| project_state.events_split_by_pause(cx))
@@ -1536,8 +1594,10 @@ impl EditPredictionStore {
self.get_or_init_project(&project, cx);
let project_state = self.projects.get(&project.entity_id()).unwrap();
let events = project_state.events(cx);
let has_events = !events.is_empty();
let stored_events = project_state.events(cx);
let has_events = !stored_events.is_empty();
let events: Vec<Arc<zeta_prompt::Event>> =
stored_events.into_iter().map(|e| e.event).collect();
let debug_tx = project_state.debug_tx.clone();
let snapshot = active_buffer.read(cx).snapshot();

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::{udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS};
use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS};
use client::{UserStore, test::FakeServer};
use clock::{FakeSystemClock, ReplicaId};
use cloud_api_types::{CreateLlmTokenResponse, LlmToken};
@@ -360,7 +360,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
ep_store.edit_history_for_project(&project, cx)
});
assert_eq!(events.len(), 1);
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref();
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref();
assert_eq!(
diff.as_str(),
indoc! {"
@@ -377,7 +377,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
assert_eq!(events.len(), 2);
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref();
let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref();
assert_eq!(
diff.as_str(),
indoc! {"
@@ -389,7 +389,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex
"}
);
let zeta_prompt::Event::BufferChange { diff, .. } = events[1].as_ref();
let zeta_prompt::Event::BufferChange { diff, .. } = events[1].event.as_ref();
assert_eq!(
diff.as_str(),
indoc! {"
@@ -2082,6 +2082,74 @@ async fn test_unauthenticated_with_custom_url_allows_prediction_impl(cx: &mut Te
);
}
#[gpui::test]
fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) {
let buffer = cx.new(|cx| {
Buffer::local(
indoc! {"
zero
one
two
three
four
five
six
seven
eight
nine
ten
eleven
twelve
thirteen
fourteen
fifteen
sixteen
seventeen
eighteen
nineteen
twenty
twenty-one
twenty-two
twenty-three
twenty-four
"},
cx,
)
});
let old_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
buffer.update(cx, |buffer, cx| {
let point = Point::new(12, 0);
buffer.edit([(point..point, "SECOND INSERTION\n")], None, cx);
let point = Point::new(8, 0);
buffer.edit([(point..point, "FIRST INSERTION\n")], None, cx);
});
let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
let diff = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap();
assert_eq!(
diff,
indoc! {"
@@ -6,10 +6,12 @@
five
six
seven
+FIRST INSERTION
eight
nine
ten
eleven
+SECOND INSERTION
twelve
thirteen
fourteen
"}
);
}
#[ctor::ctor]
fn init_logger() {
zlog::init_test();

View File

@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use std::{fmt::Write as _, mem, path::Path, sync::Arc};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExampleSpec {
#[serde(default)]
pub name: String,

View File

@@ -45,6 +45,11 @@ pub async fn run_format_prompt(
let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
let project = state.project.clone();
let (_, input) = ep_store.update(&mut cx, |ep_store, cx| {
let events = ep_store
.edit_history_for_project(&project, cx)
.into_iter()
.map(|e| e.event)
.collect();
anyhow::Ok(zeta2_prompt_input(
&snapshot,
example
@@ -53,7 +58,7 @@ pub async fn run_format_prompt(
.context("context must be set")?
.files
.clone(),
ep_store.edit_history_for_project(&project, cx),
events,
example.spec.cursor_path.clone(),
example
.buffer

View File

@@ -15,8 +15,7 @@ doctest = false
[dependencies]
anyhow.workspace = true
buffer_diff.workspace = true
git.workspace = true
log.workspace = true
collections.workspace = true
time.workspace = true
client.workspace = true
cloud_llm_client.workspace = true
@@ -50,11 +49,18 @@ zed_actions.workspace = true
zeta_prompt.workspace = true
[dev-dependencies]
clock.workspace = true
copilot = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
futures.workspace = true
indoc.workspace = true
language_model.workspace = true
lsp = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
semver.workspace = true
serde_json.workspace = true
theme = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
zlog.workspace = true

View File

@@ -36,8 +36,8 @@ use std::{
};
use supermaven::{AccountStatus, Supermaven};
use ui::{
Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape,
Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
};
use util::ResultExt as _;
use workspace::{
@@ -680,7 +680,7 @@ impl EditPredictionButton {
menu = menu.item(
entry
.disabled(true)
.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| {
.documentation_aside(DocumentationSide::Left, move |_cx| {
Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
.into_any_element()
})
@@ -726,7 +726,7 @@ impl EditPredictionButton {
.item(
ContextMenuEntry::new("Eager")
.toggleable(IconPosition::Start, eager_mode)
.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
.documentation_aside(DocumentationSide::Left, move |_| {
Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
})
.handler({
@@ -739,7 +739,7 @@ impl EditPredictionButton {
.item(
ContextMenuEntry::new("Subtle")
.toggleable(IconPosition::Start, subtle_mode)
.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
.documentation_aside(DocumentationSide::Left, move |_| {
Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
})
.handler({
@@ -778,7 +778,7 @@ impl EditPredictionButton {
.toggleable(IconPosition::Start, data_collection.is_enabled())
.icon(icon_name)
.icon_color(icon_color)
.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
.documentation_aside(DocumentationSide::Left, move |cx| {
let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
(true, true) => (
"Project identified as open source, and you're sharing data.",
@@ -862,7 +862,7 @@ impl EditPredictionButton {
ContextMenuEntry::new("Configure Excluded Files")
.icon(IconName::LockOutlined)
.icon_color(Color::Muted)
.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
.documentation_aside(DocumentationSide::Left, |_| {
Label::new(indoc!{"
Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
})
@@ -915,11 +915,8 @@ impl EditPredictionButton {
.when(
cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
|this| {
this.action(
"Capture Edit Prediction Example",
CaptureExample.boxed_clone(),
)
.action("Rate Predictions", RatePredictions.boxed_clone())
this.action("Capture Prediction Example", CaptureExample.boxed_clone())
.action("Rate Predictions", RatePredictions.boxed_clone())
},
);
}

View File

@@ -2,25 +2,17 @@ mod edit_prediction_button;
mod edit_prediction_context_view;
mod rate_prediction_modal;
use std::any::{Any as _, TypeId};
use std::path::Path;
use std::sync::Arc;
use command_palette_hooks::CommandPaletteFilter;
use edit_prediction::{
EditPredictionStore, ResetOnboarding, Zeta2FeatureFlag, example_spec::ExampleSpec,
};
use edit_prediction::{ResetOnboarding, Zeta2FeatureFlag, capture_example};
use edit_prediction_context_view::EditPredictionContextView;
use editor::Editor;
use feature_flags::FeatureFlagAppExt as _;
use git::repository::DiffType;
use gpui::{Window, actions};
use language::ToPoint as _;
use log;
use gpui::actions;
use language::language_settings::AllLanguageSettings;
use project::DisableAiSettings;
use rate_prediction_modal::RatePredictionsModal;
use settings::{Settings as _, SettingsStore};
use text::ToOffset as _;
use std::any::{Any as _, TypeId};
use ui::{App, prelude::*};
use workspace::{SplitDirection, Workspace};
@@ -56,7 +48,9 @@ pub fn init(cx: &mut App) {
}
});
workspace.register_action(capture_edit_prediction_example);
workspace.register_action(|workspace, _: &CaptureExample, window, cx| {
capture_example_as_markdown(workspace, window, cx);
});
workspace.register_action_renderer(|div, _, _, cx| {
let has_flag = cx.has_flag::<Zeta2FeatureFlag>();
div.when(has_flag, |div| {
@@ -138,182 +132,48 @@ fn feature_gate_predict_edits_actions(cx: &mut App) {
.detach();
}
fn capture_edit_prediction_example(
fn capture_example_as_markdown(
workspace: &mut Workspace,
_: &CaptureExample,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let Some(ep_store) = EditPredictionStore::try_global(cx) else {
return;
};
let project = workspace.project().clone();
let (worktree_root, repository) = {
let project_ref = project.read(cx);
let worktree_root = project_ref
.visible_worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).abs_path());
let repository = project_ref.active_repository(cx);
(worktree_root, repository)
};
let (Some(worktree_root), Some(repository)) = (worktree_root, repository) else {
log::error!("CaptureExampleSpec: missing worktree or active repository");
return;
};
let repository_snapshot = repository.read(cx).snapshot();
if worktree_root.as_ref() != repository_snapshot.work_directory_abs_path.as_ref() {
log::error!(
"repository is not at worktree root (repo={:?}, worktree={:?})",
repository_snapshot.work_directory_abs_path,
worktree_root
);
return;
}
let Some(repository_url) = repository_snapshot
.remote_origin_url
.clone()
.or_else(|| repository_snapshot.remote_upstream_url.clone())
else {
log::error!("active repository has no origin/upstream remote url");
return;
};
let Some(revision) = repository_snapshot
.head_commit
.as_ref()
.map(|commit| commit.sha.to_string())
else {
log::error!("active repository has no head commit");
return;
};
let mut events = ep_store.update(cx, |store, cx| {
store.edit_history_for_project_with_pause_split_last_event(&project, cx)
});
let Some(editor) = workspace.active_item_as::<Editor>(cx) else {
log::error!("no active editor");
return;
};
let Some(project_path) = editor.read(cx).project_path(cx) else {
log::error!("active editor has no project path");
return;
};
let Some((buffer, cursor_anchor)) = editor
.read(cx)
.buffer()
.read(cx)
.text_anchor_for_position(editor.read(cx).selections.newest_anchor().head(), cx)
else {
log::error!("failed to resolve cursor buffer/anchor");
return;
};
let snapshot = buffer.read(cx).snapshot();
let cursor_point = cursor_anchor.to_point(&snapshot);
let (_editable_range, context_range) =
edit_prediction::cursor_excerpt::editable_and_context_ranges_for_cursor_position(
cursor_point,
&snapshot,
100,
50,
);
let cursor_path: Arc<Path> = repository
.read(cx)
.project_path_to_repo_path(&project_path, cx)
.map(|repo_path| Path::new(repo_path.as_unix_str()).into())
.unwrap_or_else(|| Path::new(project_path.path.as_unix_str()).into());
let cursor_position = {
let context_start_offset = context_range.start.to_offset(&snapshot);
let cursor_offset = cursor_anchor.to_offset(&snapshot);
let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset);
let mut excerpt = snapshot.text_for_range(context_range).collect::<String>();
if cursor_offset_in_excerpt <= excerpt.len() {
excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER);
}
excerpt
};
) -> Option<()> {
let markdown_language = workspace
.app_state()
.languages
.language_for_name("Markdown");
let fs = workspace.app_state().fs.clone();
let project = workspace.project().clone();
let editor = workspace.active_item_as::<Editor>(cx)?;
let editor = editor.read(cx);
let (buffer, cursor_anchor) = editor
.buffer()
.read(cx)
.text_anchor_for_position(editor.selections.newest_anchor().head(), cx)?;
let example = capture_example(project.clone(), buffer, cursor_anchor, true, cx)?;
let examples_dir = AllLanguageSettings::get_global(cx)
.edit_predictions
.examples_dir
.clone();
cx.spawn_in(window, async move |workspace_entity, cx| {
let markdown_language = markdown_language.await?;
let example_spec = example.await?;
let buffer = if let Some(dir) = examples_dir {
fs.create_dir(&dir).await.ok();
let mut path = dir.join(&example_spec.name.replace(' ', "--").replace(':', "-"));
path.set_extension("md");
project.update(cx, |project, cx| project.open_local_buffer(&path, cx))
} else {
project.update(cx, |project, cx| project.create_buffer(false, cx))
}?
.await?;
let uncommitted_diff_rx = repository.update(cx, |repository, cx| {
repository.diff(DiffType::HeadToWorktree, cx)
})?;
let uncommitted_diff = match uncommitted_diff_rx.await {
Ok(Ok(diff)) => diff,
Ok(Err(error)) => {
log::error!("failed to compute uncommitted diff: {error:#}");
return Ok(());
}
Err(error) => {
log::error!("uncommitted diff channel dropped: {error:#}");
return Ok(());
}
};
let mut edit_history = String::new();
let mut expected_patch = String::new();
if let Some(last_event) = events.pop() {
for event in &events {
zeta_prompt::write_event(&mut edit_history, event);
if !edit_history.ends_with('\n') {
edit_history.push('\n');
}
edit_history.push('\n');
}
zeta_prompt::write_event(&mut expected_patch, &last_event);
}
let format =
time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]");
let name = match format {
Ok(format) => {
let now = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
now.format(&format)
.unwrap_or_else(|_| "unknown-time".to_string())
}
Err(_) => "unknown-time".to_string(),
};
let markdown = ExampleSpec {
name,
repository_url,
revision,
uncommitted_diff,
cursor_path,
cursor_position,
edit_history,
expected_patch,
}
.to_markdown();
let buffer = project
.update(cx, |project, cx| project.create_buffer(false, cx))?
.await?;
buffer.update(cx, |buffer, cx| {
buffer.set_text(markdown, cx);
buffer.set_text(example_spec.to_markdown(), cx);
buffer.set_language(Some(markdown_language), cx);
})?;
workspace_entity.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(
@@ -327,4 +187,5 @@ fn capture_edit_prediction_example(
})
})
.detach_and_log_err(cx);
None
}

View File

@@ -1,4 +1,4 @@
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
use buffer_diff::BufferDiff;
use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore};
use editor::{Editor, ExcerptRange, MultiBuffer};
use feature_flags::FeatureFlag;
@@ -323,22 +323,23 @@ impl RatePredictionsModal {
let start = Point::new(range.start.row.saturating_sub(5), 0);
let end = Point::new(range.end.row + 5, 0).min(new_buffer_snapshot.max_point());
let diff = cx.new::<BufferDiff>(|cx| {
let diff_snapshot = BufferDiffSnapshot::new_with_base_buffer(
let language = new_buffer_snapshot.language().cloned();
let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
diff.update(cx, |diff, cx| {
let update = diff.update_diff(
new_buffer_snapshot.text.clone(),
Some(old_buffer_snapshot.text().into()),
old_buffer_snapshot.clone(),
true,
language,
cx,
);
let diff = BufferDiff::new(&new_buffer_snapshot, cx);
cx.spawn(async move |diff, cx| {
let diff_snapshot = diff_snapshot.await;
let update = update.await;
diff.update(cx, |diff, cx| {
diff.set_snapshot(diff_snapshot, &new_buffer_snapshot.text, cx);
diff.set_snapshot(update, &new_buffer_snapshot.text, cx);
})
})
.detach();
diff
});
editor.disable_header_for_buffer(new_buffer_id, cx);

View File

@@ -232,8 +232,6 @@ impl DisplayMap {
.update(cx, |map, cx| map.sync(tab_snapshot, edits, cx));
let block_snapshot = self.block_map.read(wrap_snapshot, edits).snapshot;
// todo word diff here?
DisplaySnapshot {
block_snapshot,
diagnostics_max_severity: self.diagnostics_max_severity,

View File

@@ -1119,7 +1119,6 @@ impl Iterator for WrapRows<'_> {
RowInfo {
buffer_id: None,
buffer_row: None,
base_text_row: None,
multibuffer_row: None,
diff_status,
expand_info: None,

View File

@@ -1072,6 +1072,7 @@ pub struct Editor {
minimap_visibility: MinimapVisibility,
offset_content: bool,
disable_expand_excerpt_buttons: bool,
delegate_expand_excerpts: bool,
show_line_numbers: Option<bool>,
use_relative_line_numbers: Option<bool>,
show_git_diff_gutter: Option<bool>,
@@ -1203,6 +1204,7 @@ pub struct Editor {
hide_mouse_mode: HideMouseMode,
pub change_list: ChangeList,
inline_value_cache: InlineValueCache,
number_deleted_lines: bool,
selection_drag_state: SelectionDragState,
colors: Option<LspColorData>,
@@ -1215,7 +1217,6 @@ pub struct Editor {
applicable_language_settings: HashMap<Option<LanguageName>, LanguageSettings>,
accent_data: Option<AccentData>,
fetched_tree_sitter_chunks: HashMap<ExcerptId, HashSet<Range<BufferRow>>>,
use_base_text_line_numbers: bool,
}
#[derive(Debug, PartialEq)]
@@ -1256,6 +1257,7 @@ pub struct EditorSnapshot {
show_gutter: bool,
offset_content: bool,
show_line_numbers: Option<bool>,
number_deleted_lines: bool,
show_git_diff_gutter: Option<bool>,
show_code_actions: Option<bool>,
show_runnables: Option<bool>,
@@ -2237,6 +2239,7 @@ impl Editor {
show_line_numbers: (!full_mode).then_some(false),
use_relative_line_numbers: None,
disable_expand_excerpt_buttons: !full_mode,
delegate_expand_excerpts: false,
show_git_diff_gutter: None,
show_code_actions: None,
show_runnables: None,
@@ -2405,7 +2408,7 @@ impl Editor {
applicable_language_settings: HashMap::default(),
accent_data: None,
fetched_tree_sitter_chunks: HashMap::default(),
use_base_text_line_numbers: false,
number_deleted_lines: false,
};
if is_minimap {
@@ -2940,6 +2943,7 @@ impl Editor {
show_gutter: self.show_gutter,
offset_content: self.offset_content,
show_line_numbers: self.show_line_numbers,
number_deleted_lines: self.number_deleted_lines,
show_git_diff_gutter: self.show_git_diff_gutter,
show_code_actions: self.show_code_actions,
show_runnables: self.show_runnables,
@@ -11496,7 +11500,7 @@ impl Editor {
let buffer = buffer.read(cx);
let original_text = diff
.read(cx)
.base_text()
.base_text(cx)
.as_rope()
.slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0);
let buffer_snapshot = buffer.snapshot();
@@ -16590,7 +16594,6 @@ impl Editor {
&mut self,
lines: u32,
direction: ExpandExcerptDirection,
cx: &mut Context<Self>,
) {
let selections = self.selections.disjoint_anchors_arc();
@@ -16601,14 +16604,24 @@ impl Editor {
lines
};
let snapshot = self.buffer.read(cx).snapshot(cx);
let mut excerpt_ids = selections
.iter()
.flat_map(|selection| snapshot.excerpt_ids_for_range(selection.range()))
.collect::<Vec<_>>();
excerpt_ids.sort();
excerpt_ids.dedup();
if self.delegate_expand_excerpts {
cx.emit(EditorEvent::ExpandExcerptsRequested {
excerpt_ids,
lines,
direction,
});
return;
}
self.buffer.update(cx, |buffer, cx| {
let snapshot = buffer.snapshot(cx);
let mut excerpt_ids = selections
.iter()
.flat_map(|selection| snapshot.excerpt_ids_for_range(selection.range()))
.collect::<Vec<_>>();
excerpt_ids.sort();
excerpt_ids.dedup();
buffer.expand_excerpts(excerpt_ids, lines, direction, cx)
})
}
@@ -16620,8 +16633,18 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let current_scroll_position = self.scroll_position(cx);
let lines_to_expand = EditorSettings::get_global(cx).expand_excerpt_lines;
if self.delegate_expand_excerpts {
cx.emit(EditorEvent::ExpandExcerptsRequested {
excerpt_ids: vec![excerpt],
lines: lines_to_expand,
direction,
});
return;
}
let current_scroll_position = self.scroll_position(cx);
let mut scroll = None;
if direction == ExpandExcerptDirection::Down {
@@ -19698,10 +19721,6 @@ impl Editor {
self.display_map.read(cx).fold_placeholder.clone()
}
pub fn set_use_base_text_line_numbers(&mut self, show: bool, _cx: &mut Context<Self>) {
self.use_base_text_line_numbers = show;
}
pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) {
self.buffer.update(cx, |buffer, cx| {
buffer.set_all_diff_hunks_expanded(cx);
@@ -19943,7 +19962,7 @@ impl Editor {
buffer_word_diffs: Vec::default(),
diff_base_byte_range: hunk.diff_base_byte_range.start.0
..hunk.diff_base_byte_range.end.0,
secondary_status: hunk.secondary_status,
secondary_status: hunk.status.secondary,
range: Point::zero()..Point::zero(), // unused
})
.collect::<Vec<_>>(),
@@ -20572,6 +20591,10 @@ impl Editor {
cx.notify();
}
pub fn set_delegate_expand_excerpts(&mut self, delegate: bool) {
self.delegate_expand_excerpts = delegate;
}
pub fn set_show_git_diff_gutter(&mut self, show_git_diff_gutter: bool, cx: &mut Context<Self>) {
self.show_git_diff_gutter = Some(show_git_diff_gutter);
cx.notify();
@@ -20983,8 +21006,12 @@ impl Editor {
Some((
multi_buffer.buffer(buffer.remote_id()).unwrap(),
buffer_diff_snapshot.row_to_base_text_row(start_row_in_buffer, buffer)
..buffer_diff_snapshot.row_to_base_text_row(end_row_in_buffer, buffer),
buffer_diff_snapshot.row_to_base_text_row(start_row_in_buffer, Bias::Left, buffer)
..buffer_diff_snapshot.row_to_base_text_row(
end_row_in_buffer,
Bias::Left,
buffer,
),
))
});
@@ -25298,36 +25325,34 @@ impl EditorSnapshot {
/// Returns the line delta from `base` to `line` in the multibuffer, ignoring wrapped lines.
///
/// This is positive if `base` is before `line`.
fn relative_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 {
fn relative_line_delta(
&self,
base: DisplayRow,
line: DisplayRow,
consider_wrapped_lines: bool,
) -> i64 {
let point = DisplayPoint::new(line, 0).to_point(self);
self.relative_line_delta_to_point(base, point)
self.relative_line_delta_to_point(base, point, consider_wrapped_lines)
}
/// Returns the line delta from `base` to `point` in the multibuffer, ignoring wrapped lines.
/// Returns the line delta from `base` to `point` in the multibuffer.
///
/// This is positive if `base` is before `point`.
pub fn relative_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 {
pub fn relative_line_delta_to_point(
&self,
base: DisplayRow,
point: Point,
consider_wrapped_lines: bool,
) -> i64 {
let base_point = DisplayPoint::new(base, 0).to_point(self);
point.row as i64 - base_point.row as i64
}
/// Returns the line delta from `base` to `line` in the multibuffer, counting wrapped lines.
///
/// This is positive if `base` is before `line`.
fn relative_wrapped_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 {
let point = DisplayPoint::new(line, 0).to_point(self);
self.relative_wrapped_line_delta_to_point(base, point)
}
/// Returns the line delta from `base` to `point` in the multibuffer, counting wrapped lines.
///
/// This is positive if `base` is before `point`.
pub fn relative_wrapped_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 {
let base_point = DisplayPoint::new(base, 0).to_point(self);
let wrap_snapshot = self.wrap_snapshot();
let base_wrap_row = wrap_snapshot.make_wrap_point(base_point, Bias::Left).row();
let wrap_row = wrap_snapshot.make_wrap_point(point, Bias::Left).row();
wrap_row.0 as i64 - base_wrap_row.0 as i64
if consider_wrapped_lines {
let wrap_snapshot = self.wrap_snapshot();
let base_wrap_row = wrap_snapshot.make_wrap_point(base_point, Bias::Left).row();
let wrap_row = wrap_snapshot.make_wrap_point(point, Bias::Left).row();
wrap_row.0 as i64 - base_wrap_row.0 as i64
} else {
point.row as i64 - base_point.row as i64
}
}
/// Returns the unsigned relative line number to display for each row in `rows`.
@@ -25339,23 +25364,21 @@ impl EditorSnapshot {
relative_to: DisplayRow,
count_wrapped_lines: bool,
) -> HashMap<DisplayRow, u32> {
let initial_offset = if count_wrapped_lines {
self.relative_wrapped_line_delta(relative_to, rows.start)
} else {
self.relative_line_delta(relative_to, rows.start)
};
let display_row_infos = self
.row_infos(rows.start)
let initial_offset = self.relative_line_delta(relative_to, rows.start, count_wrapped_lines);
self.row_infos(rows.start)
.take(rows.len())
.enumerate()
.map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info));
display_row_infos
.map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info))
.filter(|(_row, row_info)| {
row_info.buffer_row.is_some()
|| (count_wrapped_lines && row_info.wrapped_buffer_row.is_some())
})
.enumerate()
.map(|(i, (row, _row_info))| (row, (initial_offset + i as i64).unsigned_abs() as u32))
.flat_map(|(i, (row, _row_info))| {
(row != relative_to)
.then_some((row, (initial_offset + i as i64).unsigned_abs() as u32))
})
.collect()
}
}
@@ -25413,6 +25436,11 @@ pub enum EditorEvent {
ExcerptsExpanded {
ids: Vec<ExcerptId>,
},
ExpandExcerptsRequested {
excerpt_ids: Vec<ExcerptId>,
lines: u32,
direction: ExpandExcerptDirection,
},
BufferEdited,
Edited {
transaction_id: clock::Lamport,

View File

@@ -36,8 +36,7 @@ use languages::markdown_lang;
use languages::rust_lang;
use lsp::CompletionParams;
use multi_buffer::{
ExcerptRange, IndentGuide, MultiBuffer, MultiBufferFilterMode, MultiBufferOffset,
MultiBufferOffsetUtf16, PathKey,
ExcerptRange, IndentGuide, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey,
};
use parking_lot::Mutex;
use pretty_assertions::{assert_eq, assert_ne};
@@ -13221,30 +13220,28 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) {
// Handle formatting requests to the language server.
cx.lsp
.set_request_handler::<lsp::request::Formatting, _, _>({
let buffer_changes = buffer_changes.clone();
move |_, _| {
let buffer_changes = buffer_changes.clone();
// Insert blank lines between each line of the buffer.
async move {
// When formatting is requested, trailing whitespace has already been stripped,
// and the trailing newline has already been added.
assert_eq!(
&buffer_changes.lock()[1..],
&[
(
lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)),
"".into()
),
(
lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)),
"".into()
),
(
lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)),
"\n".into()
),
]
);
// TODO: this assertion is not reliably true. Currently nothing guarantees that we deliver
// DidChangedTextDocument to the LSP before sending the formatting request.
// assert_eq!(
// &buffer_changes.lock()[1..],
// &[
// (
// lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)),
// "".into()
// ),
// (
// lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)),
// "".into()
// ),
// (
// lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)),
// "\n".into()
// ),
// ]
// );
Ok(Some(vec![
lsp::TextEdit {
@@ -13276,7 +13273,6 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) {
]
.join("\n"),
);
cx.run_until_parked();
// Submit a format request.
let format = cx
@@ -18346,7 +18342,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
"Some other server name".into(),
LspSettings {
binary: None,
@@ -18367,7 +18363,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
language_server_name.into(),
LspSettings {
binary: None,
@@ -18388,7 +18384,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
language_server_name.into(),
LspSettings {
binary: None,
@@ -18409,7 +18405,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon
);
update_test_project_settings(cx, |project_settings| {
project_settings.lsp.insert(
project_settings.lsp.0.insert(
language_server_name.into(),
LspSettings {
binary: None,
@@ -19884,7 +19880,9 @@ async fn test_multibuffer_reverts(cx: &mut TestAppContext) {
(buffer_2.clone(), base_text_2),
(buffer_3.clone(), base_text_3),
] {
let diff = cx.new(|cx| BufferDiff::new_with_base_text(diff_base, &buffer, cx));
let diff = cx.new(|cx| {
BufferDiff::new_with_base_text(diff_base, &buffer.read(cx).text_snapshot(), cx)
});
editor
.buffer
.update(cx, |buffer, cx| buffer.add_diff(diff, cx));
@@ -20509,7 +20507,9 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut TestAppContext) {
(buffer_2.clone(), file_2_old),
(buffer_3.clone(), file_3_old),
] {
let diff = cx.new(|cx| BufferDiff::new_with_base_text(diff_base, &buffer, cx));
let diff = cx.new(|cx| {
BufferDiff::new_with_base_text(diff_base, &buffer.read(cx).text_snapshot(), cx)
});
editor
.buffer
.update(cx, |buffer, cx| buffer.add_diff(diff, cx));
@@ -20615,7 +20615,9 @@ async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut TestAppContext) {
cx.add_window(|window, cx| Editor::new(EditorMode::full(), multi_buffer, None, window, cx));
editor
.update(cx, |editor, _window, cx| {
let diff = cx.new(|cx| BufferDiff::new_with_base_text(base, &buffer, cx));
let diff = cx.new(|cx| {
BufferDiff::new_with_base_text(base, &buffer.read(cx).text_snapshot(), cx)
});
editor
.buffer
.update(cx, |buffer, cx| buffer.add_diff(diff, cx))
@@ -22049,7 +22051,9 @@ async fn test_indent_guide_with_expanded_diff_hunks(cx: &mut TestAppContext) {
editor.buffer().update(cx, |multibuffer, cx| {
let buffer = multibuffer.as_singleton().unwrap();
let diff = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer, cx));
let diff = cx.new(|cx| {
BufferDiff::new_with_base_text(base_text, &buffer.read(cx).text_snapshot(), cx)
});
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer.add_diff(diff, cx);
@@ -28725,7 +28729,7 @@ fn test_relative_line_numbers(cx: &mut TestAppContext) {
assert_eq!(
relative_number,
snapshot
.relative_line_delta(display_row, base_display_row)
.relative_line_delta(display_row, base_display_row, false)
.unsigned_abs() as u32,
);
}
@@ -28735,6 +28739,7 @@ fn test_relative_line_numbers(cx: &mut TestAppContext) {
.into_iter()
.enumerate()
.map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32))
.filter(|(row, _)| *row != base_display_row)
.collect_vec();
let actual_relative_numbers = snapshot
.calculate_relative_line_numbers(
@@ -28751,7 +28756,7 @@ fn test_relative_line_numbers(cx: &mut TestAppContext) {
assert_eq!(
relative_number,
snapshot
.relative_wrapped_line_delta(display_row, base_display_row)
.relative_line_delta(display_row, base_display_row, true)
.unsigned_abs() as u32,
);
}
@@ -29222,208 +29227,6 @@ async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
async fn test_filtered_editor_pair(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut leader_cx = EditorTestContext::new(cx).await;
let diff_base = indoc!(
r#"
one
two
three
four
five
six
"#
);
let initial_state = indoc!(
r#"
ˇone
two
THREE
four
five
six
"#
);
leader_cx.set_state(initial_state);
leader_cx.set_head_text(&diff_base);
leader_cx.run_until_parked();
let follower = leader_cx.update_multibuffer(|leader, cx| {
leader.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
leader.set_all_diff_hunks_expanded(cx);
leader.get_or_create_follower(cx)
});
follower.update(cx, |follower, cx| {
follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
follower.set_all_diff_hunks_expanded(cx);
});
let follower_editor =
leader_cx.new_window_entity(|window, cx| build_editor(follower, window, cx));
// leader_cx.window.focus(&follower_editor.focus_handle(cx));
let mut follower_cx = EditorTestContext::for_editor_in(follower_editor, &mut leader_cx).await;
cx.run_until_parked();
leader_cx.assert_editor_state(initial_state);
follower_cx.assert_editor_state(indoc! {
r#"
ˇone
two
three
four
five
six
"#
});
follower_cx.editor(|editor, _window, cx| {
assert!(editor.read_only(cx));
});
leader_cx.update_editor(|editor, _window, cx| {
editor.edit([(Point::new(4, 0)..Point::new(5, 0), "FIVE\n")], cx);
});
cx.run_until_parked();
leader_cx.assert_editor_state(indoc! {
r#"
ˇone
two
THREE
four
FIVE
six
"#
});
follower_cx.assert_editor_state(indoc! {
r#"
ˇone
two
three
four
five
six
"#
});
leader_cx.update_editor(|editor, _window, cx| {
editor.edit([(Point::new(6, 0)..Point::new(6, 0), "SEVEN")], cx);
});
cx.run_until_parked();
leader_cx.assert_editor_state(indoc! {
r#"
ˇone
two
THREE
four
FIVE
six
SEVEN"#
});
follower_cx.assert_editor_state(indoc! {
r#"
ˇone
two
three
four
five
six
"#
});
leader_cx.update_editor(|editor, window, cx| {
editor.move_down(&MoveDown, window, cx);
editor.refresh_selected_text_highlights(true, window, cx);
});
leader_cx.run_until_parked();
}
#[gpui::test]
async fn test_filtered_editor_pair_complex(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let base_text = "base\n";
let buffer_text = "buffer\n";
let buffer1 = cx.new(|cx| Buffer::local(buffer_text, cx));
let diff1 = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer1, cx));
let extra_buffer_1 = cx.new(|cx| Buffer::local("dummy text 1\n", cx));
let extra_diff_1 = cx.new(|cx| BufferDiff::new_with_base_text("", &extra_buffer_1, cx));
let extra_buffer_2 = cx.new(|cx| Buffer::local("dummy text 2\n", cx));
let extra_diff_2 = cx.new(|cx| BufferDiff::new_with_base_text("", &extra_buffer_2, cx));
let leader = cx.new(|cx| {
let mut leader = MultiBuffer::new(Capability::ReadWrite);
leader.set_all_diff_hunks_expanded(cx);
leader.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
leader
});
let follower = leader.update(cx, |leader, cx| leader.get_or_create_follower(cx));
follower.update(cx, |follower, _| {
follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
});
leader.update(cx, |leader, cx| {
leader.insert_excerpts_after(
ExcerptId::min(),
extra_buffer_2.clone(),
vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
cx,
);
leader.add_diff(extra_diff_2.clone(), cx);
leader.insert_excerpts_after(
ExcerptId::min(),
extra_buffer_1.clone(),
vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
cx,
);
leader.add_diff(extra_diff_1.clone(), cx);
leader.insert_excerpts_after(
ExcerptId::min(),
buffer1.clone(),
vec![ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)],
cx,
);
leader.add_diff(diff1.clone(), cx);
});
cx.run_until_parked();
let mut cx = cx.add_empty_window();
let leader_editor = cx
.new_window_entity(|window, cx| Editor::for_multibuffer(leader.clone(), None, window, cx));
let follower_editor = cx.new_window_entity(|window, cx| {
Editor::for_multibuffer(follower.clone(), None, window, cx)
});
let mut leader_cx = EditorTestContext::for_editor_in(leader_editor.clone(), &mut cx).await;
leader_cx.assert_editor_state(indoc! {"
ˇbuffer
dummy text 1
dummy text 2
"});
let mut follower_cx = EditorTestContext::for_editor_in(follower_editor.clone(), &mut cx).await;
follower_cx.assert_editor_state(indoc! {"
ˇbase
"});
}
#[gpui::test]
async fn test_multibuffer_scroll_cursor_top_margin(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -29602,6 +29405,17 @@ async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
- [ ] ˇ
"});
// Case 2.1: Works with uppercase checked marker too
cx.set_state(indoc! {"
- [X] completed taskˇ
"});
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
- [X] completed task
- [ ] ˇ
"});
// Case 3: Cursor position doesn't matter - content after marker is what counts
cx.set_state(indoc! {"
- [ ] taˇsk
@@ -30169,11 +29983,14 @@ async fn test_local_worktree_trust(cx: &mut TestAppContext) {
.map(|wt| wt.read(cx).id())
.expect("should have a worktree")
});
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
let trusted_worktrees =
cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
let can_trust = trusted_worktrees.update(cx, |store, cx| {
store.can_trust(&worktree_store, worktree_id, cx)
});
assert!(!can_trust, "worktree should be restricted initially");
let buffer_before_approval = project
@@ -30219,8 +30036,8 @@ async fn test_local_worktree_trust(cx: &mut TestAppContext) {
trusted_worktrees.update(cx, |store, cx| {
store.trust(
&worktree_store,
std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
None,
cx,
);
});
@@ -30247,8 +30064,9 @@ async fn test_local_worktree_trust(cx: &mut TestAppContext) {
"inlay hints should be queried after trust approval"
);
let can_trust_after =
trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
let can_trust_after = trusted_worktrees.update(cx, |store, cx| {
store.can_trust(&worktree_store, worktree_id, cx)
});
assert!(can_trust_after, "worktree should be trusted after trust()");
}

View File

@@ -46,9 +46,9 @@ use gpui::{
KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
quad, relative, size, solid_background, transparent_black,
Size, StatefulInteractiveElement, Style, Styled, TextAlign, TextRun, TextStyleRefinement,
WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline,
point, px, quad, relative, size, solid_background, transparent_black,
};
use itertools::Itertools;
use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting};
@@ -1695,9 +1695,13 @@ impl EditorElement {
[cursor_position.row().minus(visible_display_row_range.start) as usize];
let cursor_column = cursor_position.column() as usize;
let cursor_character_x = cursor_row_layout.x_for_index(cursor_column);
let mut block_width =
cursor_row_layout.x_for_index(cursor_column + 1) - cursor_character_x;
let cursor_character_x = cursor_row_layout.x_for_index(cursor_column)
+ cursor_row_layout
.alignment_offset(self.style.text.text_align, text_hitbox.size.width);
let cursor_next_x = cursor_row_layout.x_for_index(cursor_column + 1)
+ cursor_row_layout
.alignment_offset(self.style.text.text_align, text_hitbox.size.width);
let mut block_width = cursor_next_x - cursor_character_x;
if block_width == Pixels::ZERO {
block_width = em_advance;
}
@@ -3260,17 +3264,15 @@ impl EditorElement {
line_number.clear();
let non_relative_number = if relative.wrapped() {
row_info.buffer_row.or(row_info.wrapped_buffer_row)? + 1
} else if self.editor.read(cx).use_base_text_line_numbers {
row_info.base_text_row?.0 + 1
} else {
row_info.buffer_row? + 1
};
let relative_number = relative_rows.get(&display_row);
if !(relative_line_numbers_enabled && relative_number.is_some())
&& !snapshot.number_deleted_lines
&& row_info
.diff_status
.is_some_and(|status| status.is_deleted())
&& !self.editor.read(cx).use_base_text_line_numbers
{
return None;
}
@@ -4607,15 +4609,15 @@ impl EditorElement {
);
let line_number = show_line_numbers.then(|| {
let relative_number = relative_to.and_then(|base| match relative_line_numbers {
RelativeLineNumbers::Disabled => None,
RelativeLineNumbers::Enabled => {
Some(snapshot.relative_line_delta_to_point(base, start_point))
}
RelativeLineNumbers::Wrapped => {
Some(snapshot.relative_wrapped_line_delta_to_point(base, start_point))
}
});
let relative_number = relative_to
.filter(|_| relative_line_numbers != RelativeLineNumbers::Disabled)
.map(|base| {
snapshot.relative_line_delta_to_point(
base,
start_point,
relative_line_numbers == RelativeLineNumbers::Wrapped,
)
});
let number = relative_number
.filter(|&delta| delta != 0)
.map(|delta| delta.unsigned_abs() as u32)
@@ -6160,10 +6162,25 @@ impl EditorElement {
let color = cx.theme().colors().editor_hover_line_number;
let line = self.shape_line_number(shaped_line.text.clone(), color, window);
line.paint(hitbox.origin, line_height, window, cx).log_err()
line.paint(
hitbox.origin,
line_height,
TextAlign::Left,
None,
window,
cx,
)
.log_err()
} else {
shaped_line
.paint(hitbox.origin, line_height, window, cx)
.paint(
hitbox.origin,
line_height,
TextAlign::Left,
None,
window,
cx,
)
.log_err()
}) else {
continue;
@@ -7252,23 +7269,27 @@ impl EditorElement {
.map(|row| {
let line_layout =
&layout.position_map.line_layouts[row.minus(start_row) as usize];
let alignment_offset =
line_layout.alignment_offset(layout.text_align, layout.content_width);
HighlightedRangeLine {
start_x: if row == range.start.row() {
layout.content_origin.x
+ Pixels::from(
ScrollPixelOffset::from(
line_layout.x_for_index(range.start.column() as usize),
line_layout.x_for_index(range.start.column() as usize)
+ alignment_offset,
) - layout.position_map.scroll_pixel_position.x,
)
} else {
layout.content_origin.x
layout.content_origin.x + alignment_offset
- Pixels::from(layout.position_map.scroll_pixel_position.x)
},
end_x: if row == range.end.row() {
layout.content_origin.x
+ Pixels::from(
ScrollPixelOffset::from(
line_layout.x_for_index(range.end.column() as usize),
line_layout.x_for_index(range.end.column() as usize)
+ alignment_offset,
) - layout.position_map.scroll_pixel_position.x,
)
} else {
@@ -7276,6 +7297,7 @@ impl EditorElement {
ScrollPixelOffset::from(
layout.content_origin.x
+ line_layout.width
+ alignment_offset
+ line_end_overshoot,
) - layout.position_map.scroll_pixel_position.x,
)
@@ -8516,8 +8538,15 @@ impl LineWithInvisibles {
for fragment in &self.fragments {
match fragment {
LineFragment::Text(line) => {
line.paint(fragment_origin, line_height, window, cx)
.log_err();
line.paint(
fragment_origin,
line_height,
layout.text_align,
Some(layout.content_width),
window,
cx,
)
.log_err();
fragment_origin.x += line.width;
}
LineFragment::Element { size, .. } => {
@@ -8559,8 +8588,15 @@ impl LineWithInvisibles {
for fragment in &self.fragments {
match fragment {
LineFragment::Text(line) => {
line.paint_background(fragment_origin, line_height, window, cx)
.log_err();
line.paint_background(
fragment_origin,
line_height,
layout.text_align,
Some(layout.content_width),
window,
cx,
)
.log_err();
fragment_origin.x += line.width;
}
LineFragment::Element { size, .. } => {
@@ -8609,7 +8645,7 @@ impl LineWithInvisibles {
[token_offset, token_end_offset],
Box::new(move |window: &mut Window, cx: &mut App| {
invisible_symbol
.paint(origin, line_height, window, cx)
.paint(origin, line_height, TextAlign::Left, None, window, cx)
.log_err();
}),
)
@@ -8770,6 +8806,15 @@ impl LineWithInvisibles {
None
}
pub fn alignment_offset(&self, text_align: TextAlign, content_width: Pixels) -> Pixels {
let line_width = self.width;
match text_align {
TextAlign::Left => px(0.0),
TextAlign::Center => (content_width - line_width) / 2.0,
TextAlign::Right => content_width - line_width,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -9008,14 +9053,8 @@ impl Element for EditorElement {
let em_advance = window.text_system().em_advance(font_id, font_size).unwrap();
let glyph_grid_cell = size(em_advance, line_height);
let gutter_dimensions = snapshot
.gutter_dimensions(
font_id,
font_size,
style,
window,
cx,
);
let gutter_dimensions =
snapshot.gutter_dimensions(font_id, font_size, style, window, cx);
let text_width = bounds.size.width - gutter_dimensions.width;
let settings = EditorSettings::get_global(cx);
@@ -9229,10 +9268,10 @@ impl Element for EditorElement {
};
let background_color = match diff_status.kind {
DiffHunkStatusKind::Added =>
cx.theme().colors().version_control_added,
DiffHunkStatusKind::Deleted =>
cx.theme().colors().version_control_deleted,
DiffHunkStatusKind::Added => cx.theme().colors().version_control_added,
DiffHunkStatusKind::Deleted => {
cx.theme().colors().version_control_deleted
}
DiffHunkStatusKind::Modified => {
debug_panic!("modified diff status for row info");
continue;
@@ -9376,25 +9415,26 @@ impl Element for EditorElement {
);
// relative rows are based on newest selection, even outside the visible area
let relative_row_base = self.editor.update(cx, |editor, cx| {
if editor.selections.count()==0 {
return None;
}
let relative_row_base = self.editor.update(cx, |editor, cx| {
(editor.selections.count() != 0).then(|| {
let newest = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx));
Some(SelectionLayout::new(
SelectionLayout::new(
newest,
editor.selections.line_mode(),
editor.cursor_offset_on_selection,
editor.cursor_shape,
&snapshot.display_snapshot,
&snapshot,
true,
true,
None,
)
.head.row())
});
.head
.row()
})
});
let mut breakpoint_rows = self.editor.update(cx, |editor, cx| {
editor.active_breakpoints(start_row..end_row, window, cx)
@@ -9554,9 +9594,10 @@ impl Element for EditorElement {
cx,
);
} else {
debug_panic!(
"skipping recursive prepaint at max depth. renderer widths may be stale."
);
debug_panic!(concat!(
"skipping recursive prepaint at max depth. ",
"renderer widths may be stale."
));
}
}
@@ -9668,9 +9709,10 @@ impl Element for EditorElement {
cx,
);
} else {
debug_panic!(
"skipping recursive prepaint at max depth. block layout may be stale."
);
debug_panic!(concat!(
"skipping recursive prepaint at max depth. ",
"block layout may be stale."
));
}
}
@@ -10172,6 +10214,8 @@ impl Element for EditorElement {
em_width,
em_advance,
snapshot,
text_align: self.style.text.text_align,
content_width: text_hitbox.size.width,
gutter_hitbox: gutter_hitbox.clone(),
text_hitbox: text_hitbox.clone(),
inline_blame_bounds: inline_blame_layout
@@ -10225,6 +10269,8 @@ impl Element for EditorElement {
sticky_buffer_header,
sticky_headers,
expand_toggles,
text_align: self.style.text.text_align,
content_width: text_hitbox.size.width,
}
})
})
@@ -10405,6 +10451,8 @@ pub struct EditorLayout {
sticky_buffer_header: Option<AnyElement>,
sticky_headers: Option<StickyHeaders>,
document_colors: Option<(DocumentColorsRenderMode, Vec<(Range<DisplayPoint>, Hsla)>)>,
text_align: TextAlign,
content_width: Pixels,
}
struct StickyHeaders {
@@ -10572,7 +10620,9 @@ impl StickyHeaderLine {
gutter_origin.x + gutter_width - gutter_right_padding - line_number.width,
gutter_origin.y,
);
line_number.paint(origin, line_height, window, cx).log_err();
line_number
.paint(origin, line_height, TextAlign::Left, None, window, cx)
.log_err();
}
}
}
@@ -11011,6 +11061,8 @@ pub(crate) struct PositionMap {
pub visible_row_range: Range<DisplayRow>,
pub line_layouts: Vec<LineWithInvisibles>,
pub snapshot: EditorSnapshot,
pub text_align: TextAlign,
pub content_width: Pixels,
pub text_hitbox: Hitbox,
pub gutter_hitbox: Hitbox,
pub inline_blame_bounds: Option<(Bounds<Pixels>, BufferId, BlameEntry)>,
@@ -11076,10 +11128,12 @@ impl PositionMap {
.line_layouts
.get(row as usize - scroll_position.y as usize)
{
if let Some(ix) = line.index_for_x(x) {
let alignment_offset = line.alignment_offset(self.text_align, self.content_width);
let x_relative_to_text = x - alignment_offset;
if let Some(ix) = line.index_for_x(x_relative_to_text) {
(ix as u32, px(0.))
} else {
(line.len as u32, px(0.).max(x - line.width))
(line.len as u32, px(0.).max(x_relative_to_text - line.width))
}
} else {
(0, x)
@@ -11268,7 +11322,14 @@ impl CursorLayout {
if let Some(block_text) = &self.block_text {
block_text
.paint(self.origin + origin, self.line_height, window, cx)
.paint(
self.origin + origin,
self.line_height,
TextAlign::Left,
None,
window,
cx,
)
.log_err();
}
}
@@ -11657,6 +11718,7 @@ mod tests {
assert_eq!(relative_rows[&DisplayRow(1)], 2);
assert_eq!(relative_rows[&DisplayRow(2)], 1);
// current line has no relative number
assert!(!relative_rows.contains_key(&DisplayRow(3)));
assert_eq!(relative_rows[&DisplayRow(4)], 1);
assert_eq!(relative_rows[&DisplayRow(5)], 2);
@@ -11803,6 +11865,7 @@ mod tests {
assert_eq!(relative_rows[&DisplayRow(1)], 2);
assert_eq!(relative_rows[&DisplayRow(2)], 1);
// current line has no relative number
assert!(!relative_rows.contains_key(&DisplayRow(3)));
assert_eq!(relative_rows[&DisplayRow(4)], 1);
assert_eq!(relative_rows[&DisplayRow(5)], 2);
@@ -11858,6 +11921,7 @@ mod tests {
assert_eq!(relative_rows[&DisplayRow(1)], 2);
assert_eq!(relative_rows[&DisplayRow(2)], 1);
// current line, even if deleted, has no relative number
assert!(!relative_rows.contains_key(&DisplayRow(3)));
assert_eq!(relative_rows[&DisplayRow(4)], 1);
assert_eq!(relative_rows[&DisplayRow(5)], 2);
}

View File

@@ -24,7 +24,7 @@ use std::{borrow::Cow, cell::RefCell};
use std::{ops::Range, sync::Arc, time::Duration};
use std::{path::PathBuf, rc::Rc};
use theme::ThemeSettings;
use ui::{Scrollbars, WithScrollbar, prelude::*, theme_is_transparent};
use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent};
use url::Url;
use util::TryFutureExt;
use workspace::{OpenOptions, OpenVisible, Workspace};
@@ -994,11 +994,13 @@ impl DiagnosticPopover {
.border_color(self.border_color)
.rounded_lg()
.child(
div()
h_flex()
.id("diagnostic-content-container")
.overflow_y_scroll()
.gap_1()
.items_start()
.max_w(max_size.width)
.max_h(max_size.height)
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.child(
MarkdownElement::new(
@@ -1021,7 +1023,11 @@ impl DiagnosticPopover {
}
},
),
),
)
.child({
let message = self.local_diagnostic.diagnostic.message.clone();
CopyButton::new(message).tooltip_label("Copy Diagnostic")
}),
)
.custom_scrollbars(
Scrollbars::for_settings::<EditorSettings>()

View File

@@ -17,8 +17,8 @@ use gpui::{
ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
};
use language::{
Bias, Buffer, BufferRow, CharKind, CharScopeContext, DiskState, LocalFile, Point,
SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
Bias, Buffer, BufferRow, CharKind, CharScopeContext, LocalFile, Point, SelectionGoal,
proto::serialize_anchor as serialize_text_anchor,
};
use lsp::DiagnosticSeverity;
use multi_buffer::MultiBufferOffset;
@@ -722,7 +722,7 @@ impl Item for Editor {
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
.is_some_and(|file| file.disk_state() == DiskState::Deleted);
.is_some_and(|file| file.disk_state().is_deleted());
h_flex()
.gap_2()

View File

@@ -164,11 +164,6 @@ pub fn deploy_context_menu(
window.focus(&editor.focus_handle(cx), cx);
}
// Don't show context menu for inline editors
if !editor.mode().is_full() {
return;
}
let display_map = editor.display_snapshot(cx);
let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right);
let context_menu = if let Some(custom) = editor.custom_context_menu.take() {
@@ -179,6 +174,11 @@ pub fn deploy_context_menu(
};
menu
} else {
// Don't show context menu for inline editors (only applies to default menu)
if !editor.mode().is_full() {
return;
}
// Don't show the context menu if there isn't a project associated with this editor
let Some(project) = editor.project.clone() else {
return;

View File

@@ -1,9 +1,16 @@
use std::ops::Range;
use buffer_diff::BufferDiff;
use collections::HashMap;
use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
use gpui::{
Action, AppContext as _, Entity, EventEmitter, Focusable, NoAction, Subscription, WeakEntity,
};
use multi_buffer::{MultiBuffer, MultiBufferFilterMode};
use language::{Buffer, Capability};
use multi_buffer::{Anchor, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, PathKey};
use project::Project;
use rope::Point;
use text::{Bias, OffsetRangeExt as _};
use ui::{
App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render,
Styled as _, Window, div,
@@ -33,6 +40,7 @@ struct SplitDiff;
struct UnsplitDiff;
pub struct SplittableEditor {
primary_multibuffer: Entity<MultiBuffer>,
primary_editor: Entity<Editor>,
secondary: Option<SecondaryEditor>,
panes: PaneGroup,
@@ -41,9 +49,12 @@ pub struct SplittableEditor {
}
struct SecondaryEditor {
multibuffer: Entity<MultiBuffer>,
editor: Entity<Editor>,
pane: Entity<Pane>,
has_latest_selection: bool,
primary_to_secondary: HashMap<ExcerptId, ExcerptId>,
secondary_to_primary: HashMap<ExcerptId, ExcerptId>,
_subscriptions: Vec<Subscription>,
}
@@ -63,14 +74,22 @@ impl SplittableEditor {
}
pub fn new_unsplit(
buffer: Entity<MultiBuffer>,
primary_multibuffer: Entity<MultiBuffer>,
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let primary_editor =
cx.new(|cx| Editor::for_multibuffer(buffer, Some(project.clone()), window, cx));
let primary_editor = cx.new(|cx| {
let mut editor = Editor::for_multibuffer(
primary_multibuffer.clone(),
Some(project.clone()),
window,
cx,
);
editor.set_expand_all_diff_hunks(cx);
editor
});
let pane = cx.new(|cx| {
let mut pane = Pane::new(
workspace.downgrade(),
@@ -88,17 +107,25 @@ impl SplittableEditor {
});
let panes = PaneGroup::new(pane);
// TODO(split-diff) we might want to tag editor events with whether they came from primary/secondary
let subscriptions =
vec![
cx.subscribe(&primary_editor, |this, _, event: &EditorEvent, cx| {
if let EditorEvent::SelectionsChanged { .. } = event
&& let Some(secondary) = &mut this.secondary
{
let subscriptions = vec![cx.subscribe(
&primary_editor,
|this, _, event: &EditorEvent, cx| match event {
EditorEvent::ExpandExcerptsRequested {
excerpt_ids,
lines,
direction,
} => {
this.expand_excerpts(excerpt_ids.iter().copied(), *lines, *direction, cx);
}
EditorEvent::SelectionsChanged { .. } => {
if let Some(secondary) = &mut this.secondary {
secondary.has_latest_selection = false;
}
cx.emit(event.clone())
}),
];
cx.emit(event.clone());
}
_ => cx.emit(event.clone()),
},
)];
window.defer(cx, {
let workspace = workspace.downgrade();
@@ -115,6 +142,7 @@ impl SplittableEditor {
});
Self {
primary_editor,
primary_multibuffer,
secondary: None,
panes,
workspace: workspace.downgrade(),
@@ -133,24 +161,22 @@ impl SplittableEditor {
return;
};
let project = workspace.read(cx).project().clone();
let follower = self.primary_editor.update(cx, |primary, cx| {
primary.buffer().update(cx, |buffer, cx| {
let follower = buffer.get_or_create_follower(cx);
buffer.set_filter_mode(Some(MultiBufferFilterMode::KeepInsertions));
follower
})
let secondary_multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadOnly);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
follower.update(cx, |follower, _| {
follower.set_filter_mode(Some(MultiBufferFilterMode::KeepDeletions));
});
let secondary_editor = workspace.update(cx, |workspace, cx| {
cx.new(|cx| {
let mut editor = Editor::for_multibuffer(follower, Some(project), window, cx);
// TODO(split-diff) this should be at the multibuffer level
editor.set_use_base_text_line_numbers(true, cx);
editor.added_to_workspace(workspace, window, cx);
editor
})
let secondary_editor = cx.new(|cx| {
let mut editor = Editor::for_multibuffer(
secondary_multibuffer.clone(),
Some(project.clone()),
window,
cx,
);
editor.number_deleted_lines = true;
editor.set_delegate_expand_excerpts(true);
editor
});
let secondary_pane = cx.new(|cx| {
let mut pane = Pane::new(
@@ -175,23 +201,59 @@ impl SplittableEditor {
pane
});
let subscriptions =
vec![
cx.subscribe(&secondary_editor, |this, _, event: &EditorEvent, cx| {
if let EditorEvent::SelectionsChanged { .. } = event
&& let Some(secondary) = &mut this.secondary
{
let subscriptions = vec![cx.subscribe(
&secondary_editor,
|this, _, event: &EditorEvent, cx| match event {
EditorEvent::ExpandExcerptsRequested {
excerpt_ids,
lines,
direction,
} => {
if let Some(secondary) = &this.secondary {
let primary_ids: Vec<_> = excerpt_ids
.iter()
.filter_map(|id| secondary.secondary_to_primary.get(id).copied())
.collect();
this.expand_excerpts(primary_ids.into_iter(), *lines, *direction, cx);
}
}
EditorEvent::SelectionsChanged { .. } => {
if let Some(secondary) = &mut this.secondary {
secondary.has_latest_selection = true;
}
cx.emit(event.clone())
}),
];
self.secondary = Some(SecondaryEditor {
cx.emit(event.clone());
}
_ => cx.emit(event.clone()),
},
)];
let mut secondary = SecondaryEditor {
editor: secondary_editor,
multibuffer: secondary_multibuffer,
pane: secondary_pane.clone(),
has_latest_selection: false,
primary_to_secondary: HashMap::default(),
secondary_to_primary: HashMap::default(),
_subscriptions: subscriptions,
};
self.primary_editor.update(cx, |editor, cx| {
editor.set_delegate_expand_excerpts(true);
editor.buffer().update(cx, |primary_multibuffer, cx| {
primary_multibuffer.set_show_deleted_hunks(false, cx);
let paths = primary_multibuffer.paths().cloned().collect::<Vec<_>>();
for path in paths {
let Some(excerpt_id) = primary_multibuffer.excerpts_for_path(&path).next()
else {
continue;
};
let snapshot = primary_multibuffer.snapshot(cx);
let buffer = snapshot.buffer_for_excerpt(excerpt_id).unwrap();
let diff = primary_multibuffer.diff_for(buffer.remote_id()).unwrap();
secondary.sync_path_excerpts(path.clone(), primary_multibuffer, diff, cx);
}
})
});
self.secondary = Some(secondary);
let primary_pane = self.panes.first_pane();
self.panes
.split(&primary_pane, &secondary_pane, SplitDirection::Left, cx)
@@ -205,8 +267,9 @@ impl SplittableEditor {
};
self.panes.remove(&secondary.pane, cx).unwrap();
self.primary_editor.update(cx, |primary, cx| {
primary.buffer().update(cx, |buffer, _| {
buffer.set_filter_mode(None);
primary.set_delegate_expand_excerpts(false);
primary.buffer().update(cx, |buffer, cx| {
buffer.set_show_deleted_hunks(true, cx);
});
});
cx.notify();
@@ -228,6 +291,299 @@ impl SplittableEditor {
});
}
}
pub fn set_excerpts_for_path(
&mut self,
path: PathKey,
buffer: Entity<Buffer>,
ranges: impl IntoIterator<Item = Range<Point>> + Clone,
context_line_count: u32,
diff: Entity<BufferDiff>,
cx: &mut Context<Self>,
) -> (Vec<Range<Anchor>>, bool) {
self.primary_multibuffer
.update(cx, |primary_multibuffer, cx| {
let (anchors, added_a_new_excerpt) = primary_multibuffer.set_excerpts_for_path(
path.clone(),
buffer,
ranges,
context_line_count,
cx,
);
primary_multibuffer.add_diff(diff.clone(), cx);
if let Some(secondary) = &mut self.secondary {
secondary.sync_path_excerpts(path, primary_multibuffer, diff, cx);
}
(anchors, added_a_new_excerpt)
})
}
fn expand_excerpts(
&mut self,
excerpt_ids: impl Iterator<Item = ExcerptId> + Clone,
lines: u32,
direction: ExpandExcerptDirection,
cx: &mut Context<Self>,
) {
let mut corresponding_paths = HashMap::default();
self.primary_multibuffer.update(cx, |multibuffer, cx| {
let snapshot = multibuffer.snapshot(cx);
if self.secondary.is_some() {
corresponding_paths = excerpt_ids
.clone()
.map(|excerpt_id| {
let path = multibuffer.path_for_excerpt(excerpt_id).unwrap();
let buffer = snapshot.buffer_for_excerpt(excerpt_id).unwrap();
let diff = multibuffer.diff_for(buffer.remote_id()).unwrap();
(path, diff)
})
.collect::<HashMap<_, _>>();
}
multibuffer.expand_excerpts(excerpt_ids.clone(), lines, direction, cx);
});
if let Some(secondary) = &mut self.secondary {
self.primary_multibuffer.update(cx, |multibuffer, cx| {
for (path, diff) in corresponding_paths {
secondary.sync_path_excerpts(path, multibuffer, diff, cx);
}
})
}
}
pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context<Self>) {
self.primary_multibuffer.update(cx, |buffer, cx| {
buffer.remove_excerpts_for_path(path.clone(), cx)
});
if let Some(secondary) = &mut self.secondary {
secondary.remove_mappings_for_path(&path, cx);
secondary
.multibuffer
.update(cx, |buffer, cx| buffer.remove_excerpts_for_path(path, cx))
}
}
}
#[cfg(test)]
impl SplittableEditor {
fn check_invariants(&self, quiesced: bool, cx: &App) {
use buffer_diff::DiffHunkStatusKind;
use collections::HashSet;
use multi_buffer::MultiBufferOffset;
use multi_buffer::MultiBufferRow;
use multi_buffer::MultiBufferSnapshot;
fn format_diff(snapshot: &MultiBufferSnapshot) -> String {
let text = snapshot.text();
let row_infos = snapshot.row_infos(MultiBufferRow(0)).collect::<Vec<_>>();
let boundary_rows = snapshot
.excerpt_boundaries_in_range(MultiBufferOffset(0)..)
.map(|b| b.row)
.collect::<HashSet<_>>();
text.split('\n')
.enumerate()
.zip(row_infos)
.map(|((ix, line), info)| {
let marker = match info.diff_status.map(|status| status.kind) {
Some(DiffHunkStatusKind::Added) => "+ ",
Some(DiffHunkStatusKind::Deleted) => "- ",
Some(DiffHunkStatusKind::Modified) => unreachable!(),
None => {
if !line.is_empty() {
" "
} else {
""
}
}
};
let boundary_row = if boundary_rows.contains(&MultiBufferRow(ix as u32)) {
" ----------\n"
} else {
""
};
let expand = info
.expand_info
.map(|expand_info| match expand_info.direction {
ExpandExcerptDirection::Up => " [↑]",
ExpandExcerptDirection::Down => " [↓]",
ExpandExcerptDirection::UpAndDown => " [↕]",
})
.unwrap_or_default();
format!("{boundary_row}{marker}{line}{expand}")
})
.collect::<Vec<_>>()
.join("\n")
}
let Some(secondary) = &self.secondary else {
return;
};
log::info!(
"primary:\n\n{}",
format_diff(&self.primary_multibuffer.read(cx).snapshot(cx))
);
log::info!(
"secondary:\n\n{}",
format_diff(&secondary.multibuffer.read(cx).snapshot(cx))
);
let primary_excerpts = self.primary_multibuffer.read(cx).excerpt_ids();
let secondary_excerpts = secondary.multibuffer.read(cx).excerpt_ids();
assert_eq!(primary_excerpts.len(), secondary_excerpts.len());
assert_eq!(
secondary.primary_to_secondary.len(),
primary_excerpts.len(),
"primary_to_secondary mapping count should match excerpt count"
);
assert_eq!(
secondary.secondary_to_primary.len(),
secondary_excerpts.len(),
"secondary_to_primary mapping count should match excerpt count"
);
for primary_id in &primary_excerpts {
assert!(
secondary.primary_to_secondary.contains_key(primary_id),
"primary excerpt {:?} should have a mapping to secondary",
primary_id
);
}
for secondary_id in &secondary_excerpts {
assert!(
secondary.secondary_to_primary.contains_key(secondary_id),
"secondary excerpt {:?} should have a mapping to primary",
secondary_id
);
}
for (primary_id, secondary_id) in &secondary.primary_to_secondary {
assert_eq!(
secondary.secondary_to_primary.get(secondary_id),
Some(primary_id),
"mappings should be bijective"
);
}
if quiesced {
let primary_snapshot = self.primary_multibuffer.read(cx).snapshot(cx);
let secondary_snapshot = secondary.multibuffer.read(cx).snapshot(cx);
let primary_diff_hunks = primary_snapshot
.diff_hunks()
.map(|hunk| hunk.diff_base_byte_range)
.collect::<Vec<_>>();
let secondary_diff_hunks = secondary_snapshot
.diff_hunks()
.map(|hunk| hunk.diff_base_byte_range)
.collect::<Vec<_>>();
pretty_assertions::assert_eq!(primary_diff_hunks, secondary_diff_hunks);
// Filtering out empty lines is a bit of a hack, to work around a case where
// the base text has a trailing newline but the current text doesn't, or vice versa.
// In this case, we get the additional newline on one side, but that line is not
// marked as added/deleted by rowinfos.
let primary_unmodified_rows = primary_snapshot
.text()
.split("\n")
.zip(primary_snapshot.row_infos(MultiBufferRow(0)))
.filter(|(line, row_info)| !line.is_empty() && row_info.diff_status.is_none())
.map(|(line, _)| line.to_owned())
.collect::<Vec<_>>();
let secondary_unmodified_rows = secondary_snapshot
.text()
.split("\n")
.zip(secondary_snapshot.row_infos(MultiBufferRow(0)))
.filter(|(line, row_info)| !line.is_empty() && row_info.diff_status.is_none())
.map(|(line, _)| line.to_owned())
.collect::<Vec<_>>();
pretty_assertions::assert_eq!(primary_unmodified_rows, secondary_unmodified_rows);
}
}
fn randomly_edit_excerpts(
&mut self,
rng: &mut impl rand::Rng,
mutation_count: usize,
cx: &mut Context<Self>,
) {
use collections::HashSet;
use rand::prelude::*;
use std::env;
use util::RandomCharIter;
let max_excerpts = env::var("MAX_EXCERPTS")
.map(|i| i.parse().expect("invalid `MAX_EXCERPTS` variable"))
.unwrap_or(5);
for _ in 0..mutation_count {
let paths = self
.primary_multibuffer
.read(cx)
.paths()
.cloned()
.collect::<Vec<_>>();
let excerpt_ids = self.primary_multibuffer.read(cx).excerpt_ids();
if rng.random_bool(0.1) && !excerpt_ids.is_empty() {
let mut excerpts = HashSet::default();
for _ in 0..rng.random_range(0..excerpt_ids.len()) {
excerpts.extend(excerpt_ids.choose(rng).copied());
}
let line_count = rng.random_range(0..5);
log::info!("Expanding excerpts {excerpts:?} by {line_count} lines");
self.expand_excerpts(
excerpts.iter().cloned(),
line_count,
ExpandExcerptDirection::UpAndDown,
cx,
);
continue;
}
if excerpt_ids.is_empty() || (rng.random() && excerpt_ids.len() < max_excerpts) {
let len = rng.random_range(100..500);
let text = RandomCharIter::new(&mut *rng).take(len).collect::<String>();
let buffer = cx.new(|cx| Buffer::local(text, cx));
log::info!(
"Creating new buffer {} with text: {:?}",
buffer.read(cx).remote_id(),
buffer.read(cx).text()
);
let buffer_snapshot = buffer.read(cx).snapshot();
let diff = cx.new(|cx| BufferDiff::new_unchanged(&buffer_snapshot, cx));
// Create some initial diff hunks.
buffer.update(cx, |buffer, cx| {
buffer.randomly_edit(rng, 1, cx);
});
let buffer_snapshot = buffer.read(cx).text_snapshot();
let ranges = diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
diff.snapshot(cx)
.hunks(&buffer_snapshot)
.map(|hunk| hunk.buffer_range.to_point(&buffer_snapshot))
.collect::<Vec<_>>()
});
let path = PathKey::for_buffer(&buffer, cx);
self.set_excerpts_for_path(path, buffer, ranges, 2, diff, cx);
} else {
let remove_count = rng.random_range(1..=paths.len());
let paths_to_remove = paths
.choose_multiple(rng, remove_count)
.cloned()
.collect::<Vec<_>>();
for path in paths_to_remove {
self.remove_excerpts_for_path(path.clone(), cx);
}
}
}
}
}
impl EventEmitter<EditorEvent> for SplittableEditor {}
@@ -265,3 +621,223 @@ impl Render for SplittableEditor {
.child(inner)
}
}
impl SecondaryEditor {
fn sync_path_excerpts(
&mut self,
path_key: PathKey,
primary_multibuffer: &mut MultiBuffer,
diff: Entity<BufferDiff>,
cx: &mut App,
) {
let Some(excerpt_id) = primary_multibuffer.excerpts_for_path(&path_key).next() else {
self.remove_mappings_for_path(&path_key, cx);
self.multibuffer.update(cx, |multibuffer, cx| {
multibuffer.remove_excerpts_for_path(path_key, cx);
});
return;
};
let primary_excerpt_ids: Vec<ExcerptId> =
primary_multibuffer.excerpts_for_path(&path_key).collect();
let primary_multibuffer_snapshot = primary_multibuffer.snapshot(cx);
let main_buffer = primary_multibuffer_snapshot
.buffer_for_excerpt(excerpt_id)
.unwrap();
let base_text_buffer = diff.read(cx).base_text_buffer();
let diff_snapshot = diff.read(cx).snapshot(cx);
let base_text_buffer_snapshot = base_text_buffer.read(cx).snapshot();
let new = primary_multibuffer
.excerpts_for_buffer(main_buffer.remote_id(), cx)
.into_iter()
.map(|(_, excerpt_range)| {
let point_range_to_base_text_point_range = |range: Range<Point>| {
let start_row = diff_snapshot.row_to_base_text_row(
range.start.row,
Bias::Left,
main_buffer,
);
let end_row =
diff_snapshot.row_to_base_text_row(range.end.row, Bias::Right, main_buffer);
let end_column = diff_snapshot.base_text().line_len(end_row);
Point::new(start_row, 0)..Point::new(end_row, end_column)
};
let primary = excerpt_range.primary.to_point(main_buffer);
let context = excerpt_range.context.to_point(main_buffer);
ExcerptRange {
primary: point_range_to_base_text_point_range(primary),
context: point_range_to_base_text_point_range(context),
}
})
.collect();
let main_buffer = primary_multibuffer.buffer(main_buffer.remote_id()).unwrap();
self.remove_mappings_for_path(&path_key, cx);
self.editor.update(cx, |editor, cx| {
editor.buffer().update(cx, |buffer, cx| {
buffer.update_path_excerpts(
path_key.clone(),
base_text_buffer,
&base_text_buffer_snapshot,
new,
cx,
);
buffer.add_inverted_diff(diff, main_buffer, cx);
})
});
let secondary_excerpt_ids: Vec<ExcerptId> = self
.multibuffer
.read(cx)
.excerpts_for_path(&path_key)
.collect();
for (primary_id, secondary_id) in primary_excerpt_ids.into_iter().zip(secondary_excerpt_ids)
{
self.primary_to_secondary.insert(primary_id, secondary_id);
self.secondary_to_primary.insert(secondary_id, primary_id);
}
}
fn remove_mappings_for_path(&mut self, path_key: &PathKey, cx: &App) {
let secondary_excerpt_ids: Vec<ExcerptId> = self
.multibuffer
.read(cx)
.excerpts_for_path(path_key)
.collect();
for secondary_id in secondary_excerpt_ids {
if let Some(primary_id) = self.secondary_to_primary.remove(&secondary_id) {
self.primary_to_secondary.remove(&primary_id);
}
}
}
}
#[cfg(test)]
mod tests {
use fs::FakeFs;
use gpui::AppContext as _;
use language::Capability;
use multi_buffer::{MultiBuffer, PathKey};
use project::Project;
use rand::rngs::StdRng;
use settings::SettingsStore;
use ui::VisualContext as _;
use workspace::Workspace;
use crate::SplittableEditor;
fn init_test(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
crate::init(cx);
});
}
#[gpui::test(iterations = 100)]
async fn test_random_split_editor(mut rng: StdRng, cx: &mut gpui::TestAppContext) {
use rand::prelude::*;
init_test(cx);
let project = Project::test(FakeFs::new(cx.executor()), [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let primary_multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let editor = cx.new_window_entity(|window, cx| {
let mut editor =
SplittableEditor::new_unsplit(primary_multibuffer, project, workspace, window, cx);
editor.split(&Default::default(), window, cx);
editor
});
let operations = std::env::var("OPERATIONS")
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(20);
let rng = &mut rng;
for _ in 0..operations {
editor.update(cx, |editor, cx| {
let buffers = editor
.primary_editor
.read(cx)
.buffer()
.read(cx)
.all_buffers();
if buffers.is_empty() {
editor.randomly_edit_excerpts(rng, 2, cx);
editor.check_invariants(true, cx);
return;
}
let quiesced = match rng.random_range(0..100) {
0..=69 if !buffers.is_empty() => {
let buffer = buffers.iter().choose(rng).unwrap();
buffer.update(cx, |buffer, cx| {
if rng.random() {
log::info!("randomly editing single buffer");
buffer.randomly_edit(rng, 5, cx);
} else {
log::info!("randomly undoing/redoing in single buffer");
buffer.randomly_undo_redo(rng, cx);
}
});
false
}
70..=79 => {
log::info!("mutating excerpts");
editor.randomly_edit_excerpts(rng, 2, cx);
false
}
80..=89 if !buffers.is_empty() => {
log::info!("recalculating buffer diff");
let buffer = buffers.iter().choose(rng).unwrap();
let diff = editor
.primary_multibuffer
.read(cx)
.diff_for(buffer.read(cx).remote_id())
.unwrap();
let buffer_snapshot = buffer.read(cx).text_snapshot();
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
false
}
_ => {
log::info!("quiescing");
for buffer in buffers {
let buffer_snapshot = buffer.read(cx).text_snapshot();
let diff = editor
.primary_multibuffer
.read(cx)
.diff_for(buffer.read(cx).remote_id())
.unwrap();
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
let diff_snapshot = diff.read(cx).snapshot(cx);
let ranges = diff_snapshot
.hunks(&buffer_snapshot)
.map(|hunk| hunk.range)
.collect::<Vec<_>>();
let path = PathKey::for_buffer(&buffer, cx);
editor.set_excerpts_for_path(path, buffer, ranges, 2, diff, cx);
}
true
}
};
editor.check_invariants(quiesced, cx);
});
}
}
}

View File

@@ -365,11 +365,12 @@ impl ExampleContext {
let snapshot = buffer.read(cx).snapshot();
let file = snapshot.file().unwrap();
let diff = diff.read(cx);
let base_text = diff.base_text().text();
let base_text = diff.read(cx).base_text(cx).text();
let hunks = diff
.hunks(&snapshot, cx)
.read(cx)
.snapshot(cx)
.hunks(&snapshot)
.map(|hunk| FileEditHunk {
base_text: base_text[hunk.diff_base_byte_range.clone()].to_string(),
text: snapshot

View File

@@ -389,49 +389,6 @@ pub trait ExtensionContextServerProxy: Send + Sync + 'static {
fn unregister_context_server(&self, server_id: Arc<str>, cx: &mut App);
}
/// A function that registers a language model provider with the registry.
/// This allows extension_host to create the provider (which requires WasmExtension)
/// and pass a registration closure to the language_models crate.
pub type LanguageModelProviderRegistration = Box<dyn FnOnce(&mut App) + Send + Sync + 'static>;
pub trait ExtensionLanguageModelProviderProxy: Send + Sync + 'static {
/// Register an LLM provider from an extension.
/// The `register_fn` closure will be called with the App context and should
/// register the provider with the LanguageModelRegistry.
fn register_language_model_provider(
&self,
provider_id: Arc<str>,
register_fn: LanguageModelProviderRegistration,
cx: &mut App,
);
/// Unregister an LLM provider when an extension is unloaded.
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App);
}
impl ExtensionLanguageModelProviderProxy for ExtensionHostProxy {
fn register_language_model_provider(
&self,
provider_id: Arc<str>,
register_fn: LanguageModelProviderRegistration,
cx: &mut App,
) {
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
return;
};
proxy.register_language_model_provider(provider_id, register_fn, cx)
}
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
return;
};
proxy.unregister_language_model_provider(provider_id, cx)
}
}
impl ExtensionContextServerProxy for ExtensionHostProxy {
fn register_context_server(
&self,

View File

@@ -298,58 +298,6 @@ pub struct LanguageModelProviderManifestEntry {
/// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg").
#[serde(default)]
pub icon: Option<String>,
/// Hardcoded models to always show (as opposed to a model list loaded over the network).
#[serde(default)]
pub models: Vec<LanguageModelManifestEntry>,
/// Authentication configuration.
#[serde(default)]
pub auth: Option<LanguageModelAuthConfig>,
}
/// Manifest entry for a language model.
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LanguageModelManifestEntry {
/// Unique identifier for the model.
pub id: String,
/// Display name for the model.
pub name: String,
/// Maximum input token count.
pub max_token_count: u64,
/// Maximum output tokens (optional).
pub max_output_tokens: Option<u64>,
/// Whether the model supports image inputs.
pub supports_images: bool,
/// Whether the model supports tool/function calling.
pub supports_tools: bool,
/// Whether the model supports extended thinking/reasoning.
pub supports_thinking: bool,
}
/// Authentication configuration for a language model provider.
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LanguageModelAuthConfig {
/// Human-readable name for the credential shown in the UI input field (e.g. "API Key", "Access Token").
pub credential_label: Option<String>,
/// Environment variable names for the API key (if env var auth supported).
/// Multiple env vars can be specified; they will be checked in order.
#[serde(default)]
pub env_vars: Option<Vec<String>>,
/// OAuth configuration for web-based authentication flows.
#[serde(default)]
pub oauth: Option<OAuthConfig>,
}
/// OAuth configuration for web-based authentication.
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct OAuthConfig {
/// The text to display on the sign-in button (e.g. "Sign in with GitHub").
pub sign_in_button_label: Option<String>,
/// The Zed icon path to display on the sign-in button (e.g. "github").
#[serde(default)]
pub sign_in_button_icon: Option<String>,
/// The description text shown next to the sign-in button in edit prediction settings.
#[serde(default)]
pub sign_in_description: Option<String>,
}
impl ExtensionManifest {

View File

@@ -29,26 +29,6 @@ pub use wit::{
GithubRelease, GithubReleaseAsset, GithubReleaseOptions, github_release_by_tag_name,
latest_github_release,
},
zed::extension::llm_provider::{
CacheConfiguration as LlmCacheConfiguration, CompletionEvent as LlmCompletionEvent,
CompletionRequest as LlmCompletionRequest, CustomModelConfig as LlmCustomModelConfig,
DeviceFlowPromptInfo as LlmDeviceFlowPromptInfo, ImageData as LlmImageData,
MessageContent as LlmMessageContent, MessageRole as LlmMessageRole,
ModelCapabilities as LlmModelCapabilities, ModelInfo as LlmModelInfo,
OauthWebAuthConfig as LlmOauthWebAuthConfig, OauthWebAuthResult as LlmOauthWebAuthResult,
ProviderInfo as LlmProviderInfo, ProviderSettings as LlmProviderSettings,
RequestMessage as LlmRequestMessage, StopReason as LlmStopReason,
ThinkingContent as LlmThinkingContent, TokenUsage as LlmTokenUsage,
ToolChoice as LlmToolChoice, ToolDefinition as LlmToolDefinition,
ToolInputFormat as LlmToolInputFormat, ToolResult as LlmToolResult,
ToolResultContent as LlmToolResultContent, ToolUse as LlmToolUse,
ToolUseJsonParseError as LlmToolUseJsonParseError,
delete_credential as llm_delete_credential, get_credential as llm_get_credential,
get_env_var as llm_get_env_var, get_provider_settings as llm_get_provider_settings,
oauth_open_browser as llm_oauth_open_browser,
oauth_send_http_request as llm_oauth_send_http_request,
oauth_start_web_auth as llm_oauth_start_web_auth, store_credential as llm_store_credential,
},
zed::extension::nodejs::{
node_binary_path, npm_install_package, npm_package_installed_version,
npm_package_latest_version,
@@ -279,93 +259,6 @@ pub trait Extension: Send + Sync {
) -> Result<DebugRequest, String> {
Err("`run_dap_locator` not implemented".to_string())
}
/// Returns information about language model providers offered by this extension.
fn llm_providers(&self) -> Vec<LlmProviderInfo> {
Vec::new()
}
/// Returns the models available for a provider.
fn llm_provider_models(&self, _provider_id: &str) -> Result<Vec<LlmModelInfo>, String> {
Ok(Vec::new())
}
/// Returns markdown content to display in the provider's settings UI.
/// This can include setup instructions, links to documentation, etc.
fn llm_provider_settings_markdown(&self, _provider_id: &str) -> Option<String> {
None
}
/// Check if the provider is authenticated.
fn llm_provider_is_authenticated(&self, _provider_id: &str) -> bool {
false
}
/// Start an OAuth device flow sign-in.
/// This is called when the user explicitly clicks "Sign in with GitHub" or similar.
/// Returns information needed to display the device flow prompt modal to the user.
fn llm_provider_start_device_flow_sign_in(
&mut self,
_provider_id: &str,
) -> Result<LlmDeviceFlowPromptInfo, String> {
Err("`llm_provider_start_device_flow_sign_in` not implemented".to_string())
}
/// Poll for device flow sign-in completion.
/// This is called after llm_provider_start_device_flow_sign_in returns the user code.
/// The extension should poll the OAuth provider until the user authorizes or the flow times out.
fn llm_provider_poll_device_flow_sign_in(&mut self, _provider_id: &str) -> Result<(), String> {
Err("`llm_provider_poll_device_flow_sign_in` not implemented".to_string())
}
/// Reset credentials for the provider.
fn llm_provider_reset_credentials(&mut self, _provider_id: &str) -> Result<(), String> {
Err("`llm_provider_reset_credentials` not implemented".to_string())
}
/// Count tokens for a request.
fn llm_count_tokens(
&self,
_provider_id: &str,
_model_id: &str,
_request: &LlmCompletionRequest,
) -> Result<u64, String> {
Err("`llm_count_tokens` not implemented".to_string())
}
/// Start streaming a completion from the model.
/// Returns a stream ID that can be used with `llm_stream_completion_next` and `llm_stream_completion_close`.
fn llm_stream_completion_start(
&mut self,
_provider_id: &str,
_model_id: &str,
_request: &LlmCompletionRequest,
) -> Result<String, String> {
Err("`llm_stream_completion_start` not implemented".to_string())
}
/// Get the next event from a completion stream.
/// Returns `Ok(None)` when the stream is complete.
fn llm_stream_completion_next(
&mut self,
_stream_id: &str,
) -> Result<Option<LlmCompletionEvent>, String> {
Err("`llm_stream_completion_next` not implemented".to_string())
}
/// Close a completion stream and release its resources.
fn llm_stream_completion_close(&mut self, _stream_id: &str) {
// Default implementation does nothing
}
/// Get cache configuration for a model (if prompt caching is supported).
fn llm_cache_configuration(
&self,
_provider_id: &str,
_model_id: &str,
) -> Option<LlmCacheConfiguration> {
None
}
}
/// Registers the provided type as a Zed extension.
@@ -624,67 +517,6 @@ impl wit::Guest for Component {
) -> Result<DebugRequest, String> {
extension().run_dap_locator(locator_name, build_task)
}
fn llm_providers() -> Vec<LlmProviderInfo> {
extension().llm_providers()
}
fn llm_provider_models(provider_id: String) -> Result<Vec<LlmModelInfo>, String> {
extension().llm_provider_models(&provider_id)
}
fn llm_provider_settings_markdown(provider_id: String) -> Option<String> {
extension().llm_provider_settings_markdown(&provider_id)
}
fn llm_provider_is_authenticated(provider_id: String) -> bool {
extension().llm_provider_is_authenticated(&provider_id)
}
fn llm_provider_start_device_flow_sign_in(
provider_id: String,
) -> Result<LlmDeviceFlowPromptInfo, String> {
extension().llm_provider_start_device_flow_sign_in(&provider_id)
}
fn llm_provider_poll_device_flow_sign_in(provider_id: String) -> Result<(), String> {
extension().llm_provider_poll_device_flow_sign_in(&provider_id)
}
fn llm_provider_reset_credentials(provider_id: String) -> Result<(), String> {
extension().llm_provider_reset_credentials(&provider_id)
}
fn llm_count_tokens(
provider_id: String,
model_id: String,
request: LlmCompletionRequest,
) -> Result<u64, String> {
extension().llm_count_tokens(&provider_id, &model_id, &request)
}
fn llm_stream_completion_start(
provider_id: String,
model_id: String,
request: LlmCompletionRequest,
) -> Result<String, String> {
extension().llm_stream_completion_start(&provider_id, &model_id, &request)
}
fn llm_stream_completion_next(stream_id: String) -> Result<Option<LlmCompletionEvent>, String> {
extension().llm_stream_completion_next(&stream_id)
}
fn llm_stream_completion_close(stream_id: String) {
extension().llm_stream_completion_close(&stream_id)
}
fn llm_cache_configuration(
provider_id: String,
model_id: String,
) -> Option<LlmCacheConfiguration> {
extension().llm_cache_configuration(&provider_id, &model_id)
}
}
/// The ID of a language server.

View File

@@ -1,8 +1,7 @@
//! An HTTP client.
pub use crate::wit::zed::extension::http_client::{
HttpMethod, HttpRequest, HttpResponse, HttpResponseStream, HttpResponseWithStatus,
RedirectPolicy, fetch, fetch_fallible, fetch_stream,
HttpMethod, HttpRequest, HttpResponse, HttpResponseStream, RedirectPolicy, fetch, fetch_stream,
};
impl HttpRequest {
@@ -16,11 +15,6 @@ impl HttpRequest {
fetch(self)
}
/// Like [`fetch`], except it doesn't treat any status codes as errors.
pub fn fetch_fallible(&self) -> Result<HttpResponseWithStatus, String> {
fetch_fallible(self)
}
/// Executes the [`HttpRequest`] with [`fetch_stream`].
pub fn fetch_stream(&self) -> Result<HttpResponseStream, String> {
fetch_stream(self)

View File

@@ -8,7 +8,6 @@ world extension {
import platform;
import process;
import nodejs;
import llm-provider;
use common.{env-vars, range};
use context-server.{context-server-configuration};
@@ -16,11 +15,6 @@ world extension {
use lsp.{completion, symbol};
use process.{command};
use slash-command.{slash-command, slash-command-argument-completion, slash-command-output};
use llm-provider.{
provider-info, model-info, completion-request,
cache-configuration, completion-event, token-usage,
device-flow-prompt-info
};
/// Initializes the extension.
export init-extension: func();
@@ -170,73 +164,4 @@ world extension {
export dap-config-to-scenario: func(config: debug-config) -> result<debug-scenario, string>;
export dap-locator-create-scenario: func(locator-name: string, build-config-template: build-task-template, resolved-label: string, debug-adapter-name: string) -> option<debug-scenario>;
export run-dap-locator: func(locator-name: string, config: resolved-task) -> result<debug-request, string>;
/// Returns information about language model providers offered by this extension.
export llm-providers: func() -> list<provider-info>;
/// Returns the models available for a provider.
export llm-provider-models: func(provider-id: string) -> result<list<model-info>, string>;
/// Returns markdown content to display in the provider's settings UI.
/// This can include setup instructions, links to documentation, etc.
export llm-provider-settings-markdown: func(provider-id: string) -> option<string>;
/// Check if the provider is authenticated.
export llm-provider-is-authenticated: func(provider-id: string) -> bool;
/// Start an OAuth device flow sign-in.
/// This is called when the user explicitly clicks "Sign in with GitHub" or similar.
///
/// The device flow works as follows:
/// 1. Extension requests a device code from the OAuth provider
/// 2. Extension returns prompt info including user code and verification URL
/// 3. Host displays a modal with the prompt info
/// 4. Host calls llm-provider-poll-device-flow-sign-in
/// 5. Extension polls for the access token while user authorizes in browser
/// 6. Once authorized, extension stores the credential and returns success
///
/// Returns information needed to display the device flow prompt modal.
export llm-provider-start-device-flow-sign-in: func(provider-id: string) -> result<device-flow-prompt-info, string>;
/// Poll for device flow sign-in completion.
/// This is called after llm-provider-start-device-flow-sign-in returns the user code.
/// The extension should poll the OAuth provider until the user authorizes or the flow times out.
/// Returns Ok(()) on successful authentication, or an error message on failure.
export llm-provider-poll-device-flow-sign-in: func(provider-id: string) -> result<_, string>;
/// Reset credentials for the provider.
export llm-provider-reset-credentials: func(provider-id: string) -> result<_, string>;
/// Count tokens for a request.
export llm-count-tokens: func(
provider-id: string,
model-id: string,
request: completion-request
) -> result<u64, string>;
/// Start streaming a completion from the model.
/// Returns a stream ID that can be used with llm-stream-next and llm-stream-close.
export llm-stream-completion-start: func(
provider-id: string,
model-id: string,
request: completion-request
) -> result<string, string>;
/// Get the next event from a completion stream.
/// Returns None when the stream is complete.
export llm-stream-completion-next: func(
stream-id: string
) -> result<option<completion-event>, string>;
/// Close a completion stream and release its resources.
export llm-stream-completion-close: func(
stream-id: string
);
/// Get cache configuration for a model (if prompt caching is supported).
export llm-cache-configuration: func(
provider-id: string,
model-id: string
) -> option<cache-configuration>;
}

View File

@@ -51,26 +51,9 @@ interface http-client {
body: list<u8>,
}
/// An HTTP response that includes the status code.
///
/// Used by `fetch-fallible` which returns responses for all status codes
/// rather than treating some status codes as errors.
record http-response-with-status {
/// The HTTP status code.
status: u16,
/// The response headers.
headers: list<tuple<string, string>>,
/// The response body.
body: list<u8>,
}
/// Performs an HTTP request and returns the response.
/// Returns an error if the response status is 4xx or 5xx.
fetch: func(req: http-request) -> result<http-response, string>;
/// Performs an HTTP request and returns the response regardless of its status code.
fetch-fallible: func(req: http-request) -> result<http-response-with-status, string>;
/// An HTTP response stream.
resource http-response-stream {
/// Retrieves the next chunk of data from the response stream.

View File

@@ -1,362 +0,0 @@
interface llm-provider {
use http-client.{http-request, http-response-with-status};
/// Information about a language model provider.
record provider-info {
/// Unique identifier for the provider (e.g. "my-extension.my-provider").
id: string,
/// Display name for the provider.
name: string,
/// Path to an SVG icon file relative to the extension root (e.g. "icons/provider.svg").
icon: option<string>,
}
/// Capabilities of a language model.
record model-capabilities {
/// Whether the model supports image inputs.
supports-images: bool,
/// Whether the model supports tool/function calling.
supports-tools: bool,
/// Whether the model supports the "auto" tool choice.
supports-tool-choice-auto: bool,
/// Whether the model supports the "any" tool choice.
supports-tool-choice-any: bool,
/// Whether the model supports the "none" tool choice.
supports-tool-choice-none: bool,
/// Whether the model supports extended thinking/reasoning.
supports-thinking: bool,
/// The format for tool input schemas.
tool-input-format: tool-input-format,
}
/// Format for tool input schemas.
enum tool-input-format {
/// Standard JSON Schema format.
json-schema,
/// A subset of JSON Schema supported by Google AI.
/// See https://ai.google.dev/api/caching#Schema
json-schema-subset,
/// Simplified schema format for certain providers.
simplified,
}
/// Information about a specific model.
record model-info {
/// Unique identifier for the model.
id: string,
/// Display name for the model.
name: string,
/// Maximum input token count.
max-token-count: u64,
/// Maximum output tokens (optional).
max-output-tokens: option<u64>,
/// Model capabilities.
capabilities: model-capabilities,
/// Whether this is the default model for the provider.
is-default: bool,
/// Whether this is the default fast model.
is-default-fast: bool,
}
/// The role of a message participant.
enum message-role {
/// User message.
user,
/// Assistant message.
assistant,
/// System message.
system,
}
/// A message in a completion request.
record request-message {
/// The role of the message sender.
role: message-role,
/// The content of the message.
content: list<message-content>,
/// Whether to cache this message for prompt caching.
cache: bool,
}
/// Content within a message.
variant message-content {
/// Plain text content.
text(string),
/// Image content.
image(image-data),
/// A tool use request from the assistant.
tool-use(tool-use),
/// A tool result from the user.
tool-result(tool-result),
/// Thinking/reasoning content.
thinking(thinking-content),
/// Redacted/encrypted thinking content.
redacted-thinking(string),
}
/// Image data for vision models.
record image-data {
/// Base64-encoded image data.
source: string,
/// Image width in pixels (optional).
width: option<u32>,
/// Image height in pixels (optional).
height: option<u32>,
}
/// A tool use request from the model.
record tool-use {
/// Unique identifier for this tool use.
id: string,
/// The name of the tool being used.
name: string,
/// JSON string of the tool input arguments.
input: string,
/// Whether the input JSON is complete (false while streaming, true when done).
is-input-complete: bool,
/// Thought signature for providers that support it (e.g., Anthropic).
thought-signature: option<string>,
}
/// A tool result to send back to the model.
record tool-result {
/// The ID of the tool use this is a result for.
tool-use-id: string,
/// The name of the tool.
tool-name: string,
/// Whether this result represents an error.
is-error: bool,
/// The content of the result.
content: tool-result-content,
}
/// Content of a tool result.
variant tool-result-content {
/// Text result.
text(string),
/// Image result.
image(image-data),
}
/// Thinking/reasoning content from models that support extended thinking.
record thinking-content {
/// The thinking text.
text: string,
/// Signature for the thinking block (provider-specific).
signature: option<string>,
}
/// A tool definition for function calling.
record tool-definition {
/// The name of the tool.
name: string,
/// Description of what the tool does.
description: string,
/// JSON Schema for input parameters.
input-schema: string,
}
/// Tool choice preference for the model.
enum tool-choice {
/// Let the model decide whether to use tools.
auto,
/// Force the model to use at least one tool.
any,
/// Prevent the model from using tools.
none,
}
/// A completion request to send to the model.
record completion-request {
/// The messages in the conversation.
messages: list<request-message>,
/// Available tools for the model to use.
tools: list<tool-definition>,
/// Tool choice preference.
tool-choice: option<tool-choice>,
/// Stop sequences to end generation.
stop-sequences: list<string>,
/// Temperature for sampling (0.0-1.0).
temperature: option<f32>,
/// Whether thinking/reasoning is allowed.
thinking-allowed: bool,
/// Maximum tokens to generate.
max-tokens: option<u64>,
}
/// Events emitted during completion streaming.
variant completion-event {
/// Completion has started.
started,
/// Text content chunk.
text(string),
/// Thinking/reasoning content chunk.
thinking(thinking-content),
/// Redacted thinking (encrypted) chunk.
redacted-thinking(string),
/// Tool use request from the model.
tool-use(tool-use),
/// JSON parse error when parsing tool input.
tool-use-json-parse-error(tool-use-json-parse-error),
/// Completion stopped.
stop(stop-reason),
/// Token usage update.
usage(token-usage),
/// Reasoning details (provider-specific JSON).
reasoning-details(string),
}
/// Error information when tool use JSON parsing fails.
record tool-use-json-parse-error {
/// The tool use ID.
id: string,
/// The tool name.
tool-name: string,
/// The raw input that failed to parse.
raw-input: string,
/// The parse error message.
error: string,
}
/// Reason the completion stopped.
enum stop-reason {
/// The model finished generating.
end-turn,
/// Maximum tokens reached.
max-tokens,
/// The model wants to use a tool.
tool-use,
/// The model refused to respond.
refusal,
}
/// Token usage statistics.
record token-usage {
/// Number of input tokens used.
input-tokens: u64,
/// Number of output tokens generated.
output-tokens: u64,
/// Tokens used for cache creation (if supported).
cache-creation-input-tokens: option<u64>,
/// Tokens read from cache (if supported).
cache-read-input-tokens: option<u64>,
}
/// Cache configuration for prompt caching.
record cache-configuration {
/// Maximum number of cache anchors.
max-cache-anchors: u32,
/// Whether caching should be applied to tool definitions.
should-cache-tool-definitions: bool,
/// Minimum token count for a message to be cached.
min-total-token-count: u64,
}
/// Configuration for starting an OAuth web authentication flow.
record oauth-web-auth-config {
/// The URL to open in the user's browser to start authentication.
/// This should include client_id, redirect_uri, scope, state, etc.
/// Use `{port}` as a placeholder in the URL - it will be replaced with
/// the actual localhost port before opening the browser.
/// Example: "https://example.com/oauth?redirect_uri=http://127.0.0.1:{port}/callback"
auth-url: string,
/// The path to listen on for the OAuth callback (e.g., "/callback").
/// A localhost server will be started to receive the redirect.
callback-path: string,
/// Timeout in seconds to wait for the callback (default: 300 = 5 minutes).
timeout-secs: option<u32>,
}
/// Result of an OAuth web authentication flow.
record oauth-web-auth-result {
/// The full callback URL that was received, including query parameters.
/// The extension is responsible for parsing the code, state, etc.
callback-url: string,
/// The port that was used for the localhost callback server.
port: u32,
}
/// Get a stored credential for this provider.
get-credential: func(provider-id: string) -> option<string>;
/// Store a credential for this provider.
store-credential: func(provider-id: string, value: string) -> result<_, string>;
/// Delete a stored credential for this provider.
delete-credential: func(provider-id: string) -> result<_, string>;
/// Read an environment variable.
get-env-var: func(name: string) -> option<string>;
/// Start an OAuth web authentication flow.
///
/// This will:
/// 1. Start a localhost server to receive the OAuth callback
/// 2. Open the auth URL in the user's default browser
/// 3. Wait for the callback (up to the timeout)
/// 4. Return the callback URL with query parameters
///
/// The extension is responsible for:
/// - Constructing the auth URL with client_id, redirect_uri, scope, state, etc.
/// - Parsing the callback URL to extract the authorization code
/// - Exchanging the code for tokens using fetch-fallible from http-client
oauth-start-web-auth: func(config: oauth-web-auth-config) -> result<oauth-web-auth-result, string>;
/// Make an HTTP request for OAuth token exchange.
///
/// This is a convenience wrapper around http-client's fetch-fallible for OAuth flows.
/// Unlike the standard fetch, this does not treat non-2xx responses as errors,
/// allowing proper handling of OAuth error responses.
oauth-send-http-request: func(request: http-request) -> result<http-response-with-status, string>;
/// Open a URL in the user's default browser.
///
/// Useful for OAuth flows that need to open a browser but handle the
/// callback differently (e.g., polling-based flows).
oauth-open-browser: func(url: string) -> result<_, string>;
/// Provider settings from user configuration.
/// Extensions can use this to allow custom API URLs, custom models, etc.
record provider-settings {
/// Custom API URL override (if configured by the user).
api-url: option<string>,
/// Custom models configured by the user.
available-models: list<custom-model-config>,
}
/// Configuration for a custom model defined by the user.
record custom-model-config {
/// The model's API identifier.
name: string,
/// Display name for the UI.
display-name: option<string>,
/// Maximum input token count.
max-tokens: u64,
/// Maximum output tokens (optional).
max-output-tokens: option<u64>,
/// Thinking budget for models that support extended thinking (None = auto).
thinking-budget: option<u32>,
}
/// Get provider-specific settings configured by the user.
/// Returns settings like custom API URLs and custom model configurations.
get-provider-settings: func(provider-id: string) -> option<provider-settings>;
/// Information needed to display the device flow prompt modal to the user.
record device-flow-prompt-info {
/// The user code to display (e.g., "ABC-123").
user-code: string,
/// The URL the user needs to visit to authorize (for the "Connect" button).
verification-url: string,
/// The headline text for the modal (e.g., "Use GitHub Copilot in Zed.").
headline: string,
/// A description to show below the headline (e.g., "Using Copilot requires an active subscription on GitHub.").
description: string,
/// Label for the connect button (e.g., "Connect to GitHub").
connect-button-label: string,
/// Success headline shown when authorization completes.
success-headline: string,
/// Success message shown when authorization completes.
success-message: string,
}
}

View File

@@ -255,21 +255,6 @@ async fn copy_extension_resources(
}
}
for (_, provider_entry) in &manifest.language_model_providers {
if let Some(icon_path) = &provider_entry.icon {
let source_icon = extension_path.join(icon_path);
let dest_icon = output_dir.join(icon_path);
// Create parent directory if needed
if let Some(parent) = dest_icon.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&source_icon, &dest_icon)
.with_context(|| format!("failed to copy LLM provider icon '{}'", icon_path))?;
}
}
if !manifest.languages.is_empty() {
let output_languages_dir = output_dir.join("languages");
fs::create_dir_all(&output_languages_dir)?;

View File

@@ -22,9 +22,7 @@ async-tar.workspace = true
async-trait.workspace = true
client.workspace = true
collections.workspace = true
credentials_provider.workspace = true
dap.workspace = true
dirs.workspace = true
extension.workspace = true
fs.workspace = true
futures.workspace = true
@@ -32,11 +30,8 @@ gpui.workspace = true
gpui_tokio.workspace = true
http_client.workspace = true
language.workspace = true
language_model.workspace = true
log.workspace = true
markdown.workspace = true
lsp.workspace = true
menu.workspace = true
moka.workspace = true
node_runtime.workspace = true
paths.workspace = true
@@ -48,16 +43,11 @@ serde.workspace = true
serde_json.workspace = true
serde_json_lenient.workspace = true
settings.workspace = true
smol.workspace = true
task.workspace = true
telemetry.workspace = true
tempfile.workspace = true
theme.workspace = true
toml.workspace = true
ui.workspace = true
ui_input.workspace = true
url.workspace = true
workspace.workspace = true
util.workspace = true
wasmparser.workspace = true
wasmtime-wasi.workspace = true

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