Compare commits

...

147 Commits

Author SHA1 Message Date
Danilo Leal
cd76e66fde Bring back individual language server controls 2025-12-30 18:41:02 -03:00
Danilo Leal
066e27aa20 Add some necessary context menu (submenu) improvements 2025-12-30 18:38:17 -03: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
morgankrey
71f4dc2481 docs: Stash local changes before branch checkout in droid auto docs CLI (#45395)
Stashes local changes before branch checkout in droid auto docs CLI

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 14:10:16 -06:00
Nathan Sobo
b091cc4d9a Enforce 5MB per-image limit when converting images for language models (#45313)
## Problem

When users paste or drag large images into the agent panel, the encoded
payload can exceed upstream provider limits (e.g., Anthropic's 5MB
per-image limit), causing API errors.

## Solution

Enforce a default 5MB limit on encoded PNG bytes in
`LanguageModelImage::from_image`:

1. Apply existing Anthropic dimension limits first (1568px max in either
dimension)
2. Iteratively downscale by ~15% per pass until the encoded PNG is under
5MB
3. Return `None` if the image can't be shrunk within 8 passes
(fail-safe)

The limit is enforced at the `LanguageModelImage` conversion layer,
which is the choke point for all image ingestion paths (agent panel
paste/drag, file mentions, text threads, etc.).

## Future Work

The 5MB limit is a conservative default. Provider-specific limits can be
introduced later by adding a `from_image_with_constraints` API.

## Testing

Added a regression test that:
1. Generates a noisy 4096x4096 PNG (guaranteed >5MB)
2. Converts it via `LanguageModelImage::from_image`
3. Asserts the result is ≤5MB and was actually downscaled

---

**Note:** This PR builds on #45312 (prompt store fail-open fix). Please
merge that first.

cc @rtfeldman

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-19 15:04:41 -05:00
Nathan Sobo
8e5d33ebc6 Make prompt store fail-open when DB contains undecodable records (#45312)
Release Notes

- N/A

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-19 14:59:01 -05:00
morgankrey
99224ccc75 docs: Droid needs a real model (#45393)
Droid needs a specific model with a date
Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 13:43:10 -06:00
Michael Benfield
56646e6bc3 Inline assistant: Don't scroll up too high (#45171)
In the case of large vertical_scroll_margin, we could scroll up such
that the assistant was out of view. Now, keep it no lower than the
center of the editor.

Closes #18058

Release Notes:

- N/A
2025-12-19 11:37:57 -08:00
morgankrey
bb2f037407 docs: Droid doesn't know its own commands (#45391)
Correctly uses droid commands in auto docs actions

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 13:20:05 -06:00
Cole Miller
07db88a327 git: Optimistically stage hunks when staging a file, take 2 (#45278)
Relanding #43434 with an improved approach.

Release Notes:

- N/A

---------

Co-authored-by: Ramon <55579979+van-sprundel@users.noreply.github.com>
2025-12-19 19:08:49 +00:00
morgankrey
e61f9081d4 docs: More droid docs debugging (#45388)
Path issues

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 12:24:23 -06:00
Ichimura Tomoo
1bc3fa8154 Correct UTF-16 saving and add heuristic encoding detection (#45243)
This commit fixes an issue where saving UTF-16 files resulted in UTF-8
bytes due to `encoding_rs` default behavior. It also introduces a
heuristic to detect BOM-less UTF-16 and binary files.

Changes:
- Manually implement UTF-16LE/BE encoding during file save to avoid
implicit UTF-8 conversion.
- Add `analyze_byte_content` to guess UTF-16LE/BE or Binary based on
null byte distribution.
- Prevent loading binary files as text by returning an error when binary
content is detected.

Special thanks to @CrazyboyQCD for pointing out the `encoding_rs`
behavior and providing the fix, and to @ConradIrwin for the suggestion
on the detection heuristic.

Closes #14654

Release Notes:

- (nightly only) Fixed an issue where saving files with UTF-16 encoding
incorrectly wrote them as UTF-8. Also improved detection for binary
files and BOM-less UTF-16.
2025-12-19 18:18:20 +00:00
morgankrey
22916311cd ci: Fix Factory CLI installation URL (#45386)
Change from cli.factory.ai/install.sh to app.factory.ai/cli per official
Factory documentation.

Release Notes:

- N/A

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 12:12:14 -06:00
Miguel Raz Guzmán Macedo
1edd050baf Add script/triage_watcher.jl (#45384)
Release Notes:

- N/A
2025-12-19 18:09:40 +00:00
morgankrey
b53f661515 docs: Fix auto docs GitHub Action (#45383)
Small fixes to Droid workflow

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 11:53:39 -06:00
Andrew Farkas
4ef5d2c814 Fix relative line numbers in sticky headers (#45164)
Closes #42586 

This includes a rewrite of `calculate_relative_line_numbers()`. Now it's
linear-time with respect to the number of rows displayed, instead of
linear time with respect to the number of rows displayed _plus_ the
distance to the base row.

Release Notes:

- Improved performance when using relative line numbers in large files
- Fixed relative line numbers not appearing in sticky headers
2025-12-19 17:32:38 +00:00
morgankrey
bfe3c66c3e docs: Automatic Documentation Github Action using Droid (#45374)
Adds a multi-step agentic loop to github actions for opening a
once-daily documentation PR that can be merged only be a Zedi

Release Notes:

- N/A

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-19 11:19:12 -06:00
Julia Ryan
361b8e0ba9 Fix sticky header scroll offset (#45377)
Closes #43319

Release Notes:

- Sticky headers no longer obscure the cursor when it moves.

---------

Co-authored-by: HactarCE <6060305+HactarCE@users.noreply.github.com>
2025-12-19 09:04:15 -08:00
Agus Zubiaga
d7e41f74fb search: Respect macOS' find pasteboard (#45311)
Closes #17467

Release Notes:

- On macOS, buffer search now syncs with the system find pasteboard,
allowing <kbd>⌘E</kbd> and <kbd>⌘G</kbd> to work seamlessly across Zed
and other apps.
2025-12-19 13:31:27 -03:00
Ben Kunkle
e05dcecac4 Make pane::CloseAllItems best effort (#45368)
Closes #ISSUE

Release Notes:

- Fixed an issue where the `pane: close all items` action would give up
if you hit "Cancel" on the prompt for what to do with a dirty buffer
2025-12-19 16:21:56 +00:00
Danilo Leal
32600f255a gpui: Fix truncation flickering (#45373)
It's been a little that we've noticed some flickering and other weird
resizing behavior with text truncation in Zed:


https://github.com/user-attachments/assets/4d5691a3-cd3d-45e0-8b96-74a4e0e273d2


https://github.com/user-attachments/assets/d1d0e587-7676-4da0-8818-f4e50f0e294e

Initially, we suspected this could be due to how we calculate the length
of a line to insert truncation, which is based first on the length of
each individual character, and then second goes through a pass
calculating the line length as a whole. This could cause mismatch and
culminate in our bug.

However, even though that felt like a reasonable suspicion, I realized
something rather simple at some point: the `truncate` and
`truncate_start` methods in the `Label` didn't use `whitespace_nowrap`.
If you take Tailwind as an example, their `truncate` utility class takes
`overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`. This
pointed out to a potential bug with `whitespace_nowrap` where that was
blocking truncation entirely, even though that's technically part of
what's necessary to truncate as you don't want text that will be
truncated to wrap.

Ultimately, what was happening was that the text element was caching its
layout based on its `wrap_width` but not considering its
`truncate_width`. The truncate width is essentially the new definitive
width of the text based on the available space, which was never being
computed. So the fix here was to add `truncate_width.is_none()` to the
cache validation check, so that it only uses the cached text element
size _if the truncation width is untouched_. But if that changes, we
need to account for the new width. Then, in the Label component, we
added `min_w_0` to allow the label div to shrink below its original
size, and finally, we added `whitespace_nowrap()` as the cache check
fundamentally fixed that method's problem.

In a future PR, we can basically remove the `single_line()` label method
because: 1) whenever you want a single label, you most likely want it to
truncate, and 2) most instances of `truncate` are already followed by
`single_line` in Zed today, so we can cut that part.

Result is no flickering with truncated labels!


https://github.com/user-attachments/assets/ae17cbde-0de7-42ca-98a4-22fcb452016b

Release Notes:

- Fixed a bug in GPUI where truncated text would flicker as you resized
the container in which the text was in.

Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
2025-12-19 13:14:31 -03:00
Raduan A.
a7e07010e5 editor: Add automatic markdown list continuation on newline and indent on tab (#42800)
Closes #5089

Release notes:
- Markdown lists now continue automatically when you press Enter
(unordered, ordered, and task lists). This can be configured with
`extend_list_on_newline` (default: true).
- You can now indent list markers with Tab to quickly create nested
lists. This can be configured with `indent_list_on_tab` (default: true).

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-12-19 21:44:02 +05:30
feeiyu
ea34cc5324 Fix terminal doesn't switch to project directory when opening remote project on Windows (#45328)
Closes #45253

Release Notes:

- Fixed terminal doesn't switch to project directory when opening remote
project on Windows
2025-12-19 17:06:16 +01:00
Danilo Leal
a7d43063d4 workspace: Make title bar pickers render nearby the trigger when mouse-triggered (#45361)
From Zed's title bar, you can click on buttons to open three modal
pickers: remote projects, projects, and branches. All of these pickers
use the modal layer, which by default, renders them centered on the UI.
However, a UX issue we've been bothered by is that when you _click_ to
open them, they show up just way too far from where your mouse likely is
(nearby the trigger you just clicked). So, this PR introduces a
`ModalPlacement` enum to the modal layer, so that we can pick between
the "centered" and "anchored" options to render the picker. This way, we
can make the pickers use anchored positioning when triggered through a
mouse click and use the default centered positioning when triggered
through the keybinding.

One thing to note is that the anchored positioning here is not as
polished as regular popovers/dropdowns, because it simply uses the x and
y coordinates of the click to place the picker as opposed to using
GPUI's `Corner` enum, thus making them more connected to their triggers.
I chose to do it this way for now because it's a simpler and more
contained change, given it wouldn't require a tighter connection at the
code level between trigger and picker. But maybe we will want to do that
in the near future because we can bake in some other related behaviors
like automatically hiding the button trigger tooltip if the picker is
open and changing its text color to communicate which button triggered
the open picker.


https://github.com/user-attachments/assets/30d9c26a-24de-4702-8b7d-018b397f77e1

Release Notes:

- Improved the UX of title bar modal pickers (remote projects, projects,
and branches) by making them open closer to the trigger when triggering
them with the mouse.
2025-12-19 13:01:48 -03:00
AidanV
8001877df2 vim: Add :r[ead] [name] command (#45332)
This adds the following Vim commands: 
- `:r[ead] [name]`
- `:{range}r[ead] [name]`

The most important parts of this feature are outlined
[here](https://vimhelp.org/insert.txt.html#%3Ar).

The only intentional difference between this and Vim is that Vim only
allows `:read` (no filename) for buffers with a file attached. I am
allowing it for all buffers because I think that could be useful.

Release Notes:

- vim: Added the [`:r[ead] [name]` Vim
command](https://vimhelp.org/insert.txt.html#:read)

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-12-19 15:31:16 +00:00
Antonio Scandurra
b603372f44 Reduce GPU usage by activating VRR optimization only during high-rate input (#45369)
Fixes #29073

This PR reduces unnecessary GPU usage by being more selective about when
we present frames to prevent display underclocking (VRR optimization).

## Problem

Previously, we would keep presenting frames for 1 second after *any*
input event, regardless of whether it triggered a re-render. This caused
unnecessary GPU work when the user was idle or during low-frequency
interactions.

## Solution

1. **Only track input that triggers re-renders**: We now only record
input timestamps when the input actually causes the window to become
dirty, rather than on every input event.

2. **Rate-based activation**: The VRR optimization now only activates
when input arrives at a high rate (≥ 60fps over the last 100ms). This
means casual mouse movements or occasional keystrokes won't trigger
continuous frame presentation.

3. **Sustained optimization**: Once high-rate input is detected (e.g.,
during scrolling or dragging), we sustain frame presentation for 1
second to prevent display underclocking, even if input briefly pauses.

## Implementation

Added `InputRateTracker` which:
- Tracks input timestamps in a 100ms sliding window
- Activates when the window contains ≥ 6 events (60fps × 0.1s)
- Extends a `sustain_until` timestamp by 1 second each time high rate is
detected

Release Notes:

- Reduced GPU usage when idle by only presenting frames during bursts of
high-frequency input.
2025-12-19 16:06:28 +01:00
Yara 🏳️‍⚧️
7427924405 adjusted scheduler prioritization algorithm (#45367)
This fixes a number of issues where zed depends on the order of polling which changed when switching scheduler. We have adjusted the algorithm so it matches the previous order while keeping the prioritization feature.

Release Notes:
- N/A
2025-12-19 15:03:35 +00:00
Cole Miller
ae44c3c881 Fix extra terminal being created when a task replaces a terminal in the center pane (#45317)
Closes https://github.com/zed-industries/zed/issues/21144

Release Notes:

- Fixed spawned tasks creating an extra terminal in the dock in some
cases.
2025-12-19 09:39:58 -05:00
ozzy
4e0471cf66 git panel: Truncate file paths from the left (#43462)
https://github.com/user-attachments/assets/758e1ec9-6c34-4e13-b605-cf00c18ca16f

Release Notes:

- Improved: Git panel now truncates long file paths from the left,
showing "…path/filename" when space is limited, keeping filenames always
visible.

@cole-miller @mattermill

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-19 13:50:35 +00:00
Danilo Leal
62d36b22fd gpui: Add text_ellipsis_start method (#45122)
This PR is an additive change introducing the `truncate_start` method to
labels, which gives us the ability to add an ellipsis at the beginning
of the text as opposed to the regular `truncate`. This will be generally
used for truncating file paths, where the end is typically more relevant
than the beginning, but given it's a general method, there's the
possibility to be used anywhere else, too.

<img width="500" height="690" alt="Screenshot 2025-12-17 at 12  35@2x"
src="https://github.com/user-attachments/assets/f853f5a3-60b3-4380-a11c-bb47868a4470"
/>

Release Notes:

- N/A

---------

Co-authored-by: Lukas Wirth <lukas@zed.dev>
2025-12-19 10:25:19 -03:00
Piotr Osiewicz
69f6eeaa3a toolchains: Fix persistence by not relying on unstable worktree id (#45357)
Closes #42268
We've migrated user selections when a given workspace has a single
worktree (as then we could determine what the target worktree is).

Release Notes:

- python: Fixed selected virtual environments not being
persisted/deserialized correctly within long-running Zed sessions (where
multiple different projects might've been opened). This is a breaking
change for users of multi-worktree projects - your selected toolchain
for those projects will be reset.

Co-authored-by: Dino <dino@zed.dev>
2025-12-19 14:06:15 +01:00
Jakub Konka
1dc5de4592 workspace: Auto-switch git context when focus changed (#45354)
Closes #44955 

Release Notes:

- Fixed workspace incorrectly automatically switching Git
repository/branch context in multi-repository projects when repo/branch
switched manually from the Git panel.
2025-12-19 13:54:30 +01:00
Lena
b9aef75f2d Turn on the fixed stalebot (#45355)
It will run weekly and it promised not to touch issues of the wrong
types anymore.

Release Notes:

- N/A
2025-12-19 12:41:03 +00:00
Agus Zubiaga
95ae388c0c Fix title bar spacing when building on the macOS Tahoe SDK (#45351)
The size and spacing around the traffic light buttons changes after
macOS SDK 26. Our official builds aren't using this SDK yet, but dev
builds sometimes are and the official will in the future.

<table>
<tr>
<th>Before</th>
<th>After</th>
</tr>
<tr>
<td>
<img width="582" height="146" alt="CleanShot 2025-12-19 at 08 58 53@2x"
src="https://github.com/user-attachments/assets/1a28d74a-98a3-49d0-98d6-ab05b0580665"
/>
</td>
<td>
<img width="610" height="156" alt="CleanShot 2025-12-19 at 08 57 02@2x"
src="https://github.com/user-attachments/assets/7b7693b3-baa1-4d7e-9fc1-bd7a7bfacd36"
/>
</td>
</tr>
<tr>
<td>
<img width="532" height="154" alt="CleanShot 2025-12-19 at 08 59 40@2x"
src="https://github.com/user-attachments/assets/df7f40e7-7576-44f2-9cf3-047a5d00bb4e"
/>
</td>
<td>
<img width="520" height="150" alt="CleanShot 2025-12-19 at 09 01 17@2x"
src="https://github.com/user-attachments/assets/b0fbdeb6-1b1d-4e7a-95d0-3c78f0569df1"
/>
</td>
</tr>
</table>

Release Notes:

- N/A
2025-12-19 12:19:04 +00:00
Lena
1ac170e663 Upgrade stalebot and make testing it easier (#45350)
- adjust wording for the upcoming simplified process
- upgrade to the github action version that has a fix for configuring issue types the bot should look at
- add two inputs for the manual runs of stalebot that help testing it in a safe and controlled manner 

Release Notes:

- N/A
2025-12-19 12:46:20 +01:00
Angelo Verlain
3104482c6c languages: Detect .bst files as YAML (#45015)
These files are used by the BuildStream build project:
https://buildstream.build/index.html


Release Notes:

- Added recognition for .bst files as yaml.
2025-12-19 10:34:40 +00:00
Piotr Osiewicz
7ee56e1a18 chore: Add worktree_benchmarks to cargo workspace (#45344)
Idk why it was missing, but

Release Notes:

- N/A
2025-12-19 11:18:36 +01:00
Korbin de Man
f2495a6f98 Add Restore File action in project_panel for git modified files (#42490)
Co-authored-by: cameron <cameron.studdstreet@gmail.com>
2025-12-19 10:12:01 +00:00
prayansh_chhablani
6d776c3157 project: Sanitize single-line completions from trailing newlines (#44965)
Closes #43991
trim documentation string to prevent completion overlap


previous
[Screencast from 2025-12-16
14-55-58.webm](https://github.com/user-attachments/assets/d7674d82-63b0-4a85-a90f-b5c5091e4a82)
after change
[Screencast from 2025-12-16
14-50-05.webm](https://github.com/user-attachments/assets/109c22b5-3fff-49c8-a2ec-b1af467d6320)
Release Notes:

- Fixed an issue where completions in the completion menu would span
multiple lines.
2025-12-19 10:11:36 +01:00
Mayank Verma
596826f741 editor: Strip trailing newlines from completion documentation (#45342)
Closes #45337

Release Notes:

- Fixed broken completion menu layout caused by trailing newlines in ty
documentation

<table>
  <tr>
    <td>Before</td>
    <td>After</td>
  </tr>
  <tr>
    <td>
<img width="756" height="875" alt="before"
src="https://github.com/user-attachments/assets/1d9da7d8-437a-4f03-8158-32ff1af9a428"
/>
    </td>
    <td>  
<img width="755" height="875" alt="after"
src="https://github.com/user-attachments/assets/dca31af3-e571-445a-b4a9-c300bb4c63fa"
/>
    </td>
  </tr>
</table>
2025-12-19 08:43:35 +00:00
Mustaque Ahmed
e44529ed7b Hide inline overlays when context menu is open (#45266)
Closes #23367 

**Summary**
- Prevents inline diagnostics, code actions, blame annotations, and
hover popovers from overlapping with the right-click context menu by
checking for `mouse_context_menu` presence before rendering these UI
elements.

PS: Same behaviour is present in other editors like VS Code.


**Screen recording**


https://github.com/user-attachments/assets/8290412b-0f86-4985-8c70-13440686e530



Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-19 08:24:30 +00:00
rabsef-bicrym
e052127e1c terminal: Prevent scrollbar arithmetic underflow panic (#45282)
## Summary

Fixes arithmetic underflow panics in `terminal_scrollbar.rs` by
converting unsafe subtractions to `saturating_sub`.

Closes #45281

## Problem

Two locations perform raw subtraction on `usize` values that panic when
underflow occurs:

- `offset()`: `state.total_lines - state.viewport_lines -
state.display_offset`
- `set_offset()`: `state.total_lines - state.viewport_lines`

This happens when `total_lines < viewport_lines + display_offset`, which
can occur during terminal creation, with small window sizes, or when
display state becomes stale.

## Solution

Replace the two unsafe subtractions with `saturating_sub`, which returns
0 on underflow instead of panicking.

Also standardizes the existing `checked_sub().unwrap_or(0)` in
`max_offset()` to `saturating_sub` for consistency across the file.

## Changes

- N/A
2025-12-19 06:33:59 +00:00
Ryan Steil
0531035b86 docs: Fix link to Anthropic prompt engineering resource (#45329) 2025-12-19 01:47:40 -03:00
Ben Kunkle
05ce34eea4 ci: Fix docs build post #45130 (#45330)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-19 03:40:27 +00:00
Alvaro Parker
63c4406137 git: Add git clone open listener (#41669) 2025-12-19 00:21:46 -03:00
Ben Kunkle
3f67c5220d Remove zed dependency from docs_preprocessor (#45130)
Closes #ISSUE

Uses the existing `--dump-all-actions` arg on the Zed binary to generate
an asset of all of our actions so that the `docs_preprocessor` can
injest it, rather than depending on the Zed crate itself to collect all
action names

Release Notes:

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

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-18 21:59:05 -05:00
Ben Kunkle
435d4c5f24 vim: Make vaf include const for arrow functions in JS/TS/TSX (#45327)
Closes #24264

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-18 21:56:47 -05:00
Danilo Leal
e0ff995e2d agent ui: Make some UI elements more consistent (#45319)
- Both the mode, profile, and model selectors have the option to cycle
through its options with a keybinding. In the tooltip that shows it, in
some of them the "Cycle Through..." label was at the top, and in others
at the bottom. Now it's all at the bottom.
- We used different language in different places for "going to a file".
The tool call edit card's header said "_Jump_ to File" while the edit
files list said "_Go_ to File". Now it's both "Go to File".

Release Notes:

- N/A
2025-12-19 01:18:41 +00:00
Conrad Irwin
6976208e21 Move autofix stuff to zippy (#45304)
Although I wanted to avoid the dependency, it's hard to get github to do
what we want.

Release Notes:

- N/A
2025-12-18 15:23:09 -07:00
Richard Feldman
6055b45ee1 Add support for provider extensions (but no extensions yet) (#45277)
This adds support for provider extensions but doesn't actually add any
yet.

Release Notes:

- N/A
2025-12-18 17:05:04 -05:00
Joseph T. Lyons
88f90c12ed Add language server version in a tooltip on language server hover (#45302)
I wanted a way to make it easy to figure out which version of a language
server Zed is running. Now, you get a tooltip when hovering on a
language server in the Language Servers popover.

<img width="498" height="168" alt="SCR-20251218-ovln"
src="https://github.com/user-attachments/assets/1ced4214-b868-4405-8881-eb7c0b75a53e"
/>

This PR also fixes a bug. We had existing code to open a tooltip on
these language server entrees and display the language server message,
which was never fully wired up for `CustomEntry`s. Now, in this PR, we
will show show either version, message, or both, in the documentation
aside, depending on what the server has given us.

Mostly done with Droid (using GPT-5.2), with manual review and multiple
follow ups to guide it into using existing patterns in the codebase,
when it did something abnormal.

Release Notes:

- Added language server version in a tooltip on language server hover

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-18 21:59:21 +00:00
Marshall Bowers
0d74f982a5 danger: Upgrade danger-plugin-pr-hygiene to v0.7.1 (#45303)
This PR upgrades `danger-plugin-pr-hygiene` to v0.7.1.

Release Notes:

- N/A
2025-12-18 21:52:34 +00:00
Marshall Bowers
ca90b8555d docs: Remove local collaboration docs (#45301)
This PR removes the docs for running Collab locally, as they are
outdated and don't reflect the current state of affairs.

Release Notes:

- N/A
2025-12-18 21:42:28 +00:00
Richard Feldman
8516d81e13 Fix display name for Ollama models (#45287)
Closes #43646

Release Notes:

- Fixed display name for Ollama models
2025-12-18 16:32:59 -05:00
Danilo Leal
af589ff25f agent_ui: Simplify timestamp display (#45296)
This PR simplifies how we display thread timestamps in the agent panel's
history view. For threads that are older-than-yesterday, we just show
how many days ago that thread was had in. Hovering over the thread item
shows you both the title and the full date, if needed (time and date).

<img width="450" height="786" alt="Screenshot 2025-12-18 at 5  24@2x"
src="https://github.com/user-attachments/assets/11416e9b-f1b0-4307-9db0-988a95a316a1"
/>


Release Notes:

- N/A
2025-12-18 17:49:17 -03:00
Julia Ryan
d2bbfbb3bf lsp: Broadcast our capability for MessageActionItems (#45047)
Closes #37902

Release Notes:

- Enable LSP Message action items for more language servers. These are interactive prompts, often for things like downloading build inputs for a project.
2025-12-18 11:09:40 -08:00
Peter Tripp
413f4ea49c Redact environment variables from language server spawn errors (#44783)
Redact environment variables from zed logs when lsp fails to spawn.

Release Notes:

- N/A
2025-12-18 21:05:14 +02:00
Marshall Bowers
1b6d588413 danger: Deny conventional commits in PR titles (#45283)
This PR upgrades `danger-plugin-pr-hygiene` to v0.7.0 so that we can
have Danger deny conventional commits in PR titles.

Release Notes:

- N/A
2025-12-18 18:42:28 +00:00
Gaauwe Rombouts
334ca21857 Truncate code actions with a long label and show full label aside (#45268)
Closes #43355

Fixes the issue were code actions with long labels would get cut off
without being able to see the full description. We now properly truncate
those labels with an ellipsis and show the full description in an aside.

Release Notes:

- Added ellipsis to truncated code actions and an aside showing the full
action description.
2025-12-18 18:05:53 +01:00
Emmanuel Amoah
f58278aaf4 glossary: Fix grammar and typo (#45267)
Fixes grammar and a typo in `Picker` description.

Release Notes:

- N/A
2025-12-18 17:03:46 +00:00
Leo
e10b9b70ef git: Add global git integration enable/disable setting (#43326)
Closes #13304

Release Notes:

- Add global `git status` and `git diff` on/off in one place instead of
control everywhere

We can first review to ensure this change meets both `Zed` and user
requirements, as well as code rules. Currently, we only support
user-level settings. We can wait for this PR:
https://github.com/zed-industries/zed/pull/43173 to be merged, then
modify it to support both user and project levels.
2025-12-18 11:45:26 -05:00
Marco Mihai Condrache
098adf3bdd gpui: Enable direct-to-display optimization for metal (#44334)
When profiling Zed with Instruments, a warning appears indicating that
surfaces cannot be pushed directly to the display as they are
non-opaque. This happens because the metal layer is currently marked as
non-opaque by default, even though the window itself is not transparent.

<img width="590" height="55" alt="image"
src="https://github.com/user-attachments/assets/2647733e-c75b-4aec-aa19-e8b2ffd6194b"
/>

Metal on macOS can bypass compositing and present frames directly to the
display when several conditions are met. One of those conditions is that
the backing layer must be declared opaque. Apple’s documentation notes
that marking layers as opaque allows the system to avoid unnecessary
compositing work, reducing GPU load and improving frame pacing

Ref:
https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos

This PR updates the Metal renderer to mark the layer as opaque whenever
the window does not use transparency. This makes Zed eligible for
macOS’s direct-to-display optimization in scenarios where the system can
apply it.

Release Notes:

- gpui: 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-18 11:29:25 -05:00
Jakub Konka
a85c508f69 Fix self-referential symbolic link (#45265)
Release Notes:

- N/A
2025-12-18 17:26:20 +01:00
tidely
2a713c546b gpui: Small tab group performance improvements (#41885)
Closes #ISSUE

Removes a few eager container clones and iterations.

Added a todo to `get_prev_tab_group_window` and
`get_next_tab_group_window`. They seem to use `HashMap::keys()` for
choosing the previous tab group, however `.keys()` returns an arbitrary
order, so I'm not sure if previous actually means anything here. Conrad
seems to have worked on this part previously, maybe he has some
insights. That can possibly be a follow-up PR, but I'd be willing to
work on it here as well since the other changes are so simple.

Release Notes:

- N/A
2025-12-18 11:24:38 -05:00
Bennet Bo Fenner
f937c1931f rules_library: Only store built-in prompts when they are customized (#45112)
Follow up to #45004

Release Notes:

- N/A
2025-12-18 17:21:41 +01:00
Danilo Leal
7a62f01ea5 agent_ui: Use display name for the message editor placeholder (#45264)
Follow up to a regression that happened when we introduced agent servers
that made everywhere displaying agent names use the extension name
instead of the display name. This has been since fixed in other places
and this PR now updates the agent panel's message editor, too:

| Before | After |
|--------|--------|
| <img width="1154" height="254" alt="Screenshot 2025-12-18 at 12  54
2@2x"
src="https://github.com/user-attachments/assets/5f3de9f9-4e11-42f6-90c2-56fc8cdff32e"
/> | <img width="1154" height="254" alt="Screenshot 2025-12-18 at 12 
54@2x"
src="https://github.com/user-attachments/assets/46ed5c45-7e1d-4cc6-b219-b6cc19206d1b"
/> |

Release Notes:

- N/A
2025-12-18 13:08:46 -03:00
331 changed files with 16905 additions and 5120 deletions

View File

@@ -0,0 +1,55 @@
# Phase 2: Explore Repository
You are analyzing a codebase to understand its structure before reviewing documentation impact.
## Objective
Produce a structured overview of the repository to inform subsequent documentation analysis.
## Instructions
1. **Identify Primary Languages and Frameworks**
- Scan for Cargo.toml, package.json, or other manifest files
- Note the primary language(s) and key dependencies
2. **Map Documentation Structure**
- This project uses **mdBook** (https://rust-lang.github.io/mdBook/)
- Documentation is in `docs/src/`
- Table of contents: `docs/src/SUMMARY.md` (mdBook format: https://rust-lang.github.io/mdBook/format/summary.html)
- Style guide: `docs/.rules`
- Agent guidelines: `docs/AGENTS.md`
- Formatting: Prettier (config in `docs/.prettierrc`)
3. **Identify Build and Tooling**
- Note build systems (cargo, npm, etc.)
- Identify documentation tooling (mdbook, etc.)
4. **Output Format**
Produce a JSON summary:
```json
{
"primary_language": "Rust",
"frameworks": ["GPUI"],
"documentation": {
"system": "mdBook",
"location": "docs/src/",
"toc_file": "docs/src/SUMMARY.md",
"toc_format": "https://rust-lang.github.io/mdBook/format/summary.html",
"style_guide": "docs/.rules",
"agent_guidelines": "docs/AGENTS.md",
"formatter": "prettier",
"formatter_config": "docs/.prettierrc",
"custom_preprocessor": "docs_preprocessor (handles {#kb action::Name} syntax)"
},
"key_directories": {
"source": "crates/",
"docs": "docs/src/",
"extensions": "extensions/"
}
}
```
## Constraints
- Read-only: Do not modify any files
- Focus on structure, not content details
- Complete within 2 minutes

View File

@@ -0,0 +1,57 @@
# Phase 3: Analyze Changes
You are analyzing code changes to understand their nature and scope.
## Objective
Produce a clear, neutral summary of what changed in the codebase.
## Input
You will receive:
- List of changed files from the triggering commit/PR
- Repository structure from Phase 2
## Instructions
1. **Categorize Changed Files**
- Source code (which crates/modules)
- Configuration
- Tests
- Documentation (already existing)
- Other
2. **Analyze Each Change**
- Review diffs for files likely to impact documentation
- Focus on: public APIs, settings, keybindings, commands, user-visible behavior
3. **Identify What Did NOT Change**
- Note stable interfaces or behaviors
- Important for avoiding unnecessary documentation updates
4. **Output Format**
Produce a markdown summary:
```markdown
## Change Analysis
### Changed Files Summary
| Category | Files | Impact Level |
| --- | --- | --- |
| Source - [crate] | file1.rs, file2.rs | High/Medium/Low |
| Settings | settings.json | Medium |
| Tests | test_*.rs | None |
### Behavioral Changes
- **[Feature/Area]**: Description of what changed from user perspective
- **[Feature/Area]**: Description...
### Unchanged Areas
- [Area]: Confirmed no changes to [specific behavior]
### Files Requiring Deeper Review
- `path/to/file.rs`: Reason for deeper review
```
## Constraints
- Read-only: Do not modify any files
- Neutral tone: Describe what changed, not whether it's good/bad
- Do not propose documentation changes yet

View File

@@ -0,0 +1,76 @@
# Phase 4: Plan Documentation Impact
You are determining whether and how documentation should be updated based on code changes.
## Objective
Produce a structured documentation plan that will guide Phase 5 execution.
## Documentation System
This is an **mdBook** site (https://rust-lang.github.io/mdBook/):
- `docs/src/SUMMARY.md` defines book structure per https://rust-lang.github.io/mdBook/format/summary.html
- If adding new pages, they MUST be added to SUMMARY.md
- Use `{#kb action::ActionName}` syntax for keybindings (custom preprocessor expands these)
- Prettier formatting (80 char width) will be applied automatically
## Input
You will receive:
- Change analysis from Phase 3
- Repository structure from Phase 2
- Documentation guidelines from `docs/AGENTS.md`
## Instructions
1. **Review AGENTS.md**
- Load and apply all rules from `docs/AGENTS.md`
- Respect scope boundaries (in-scope vs out-of-scope)
2. **Evaluate Documentation Impact**
For each behavioral change from Phase 3:
- Does existing documentation cover this area?
- Is the documentation now inaccurate or incomplete?
- Classify per AGENTS.md "Change Classification" section
3. **Identify Specific Updates**
For each required update:
- Exact file path
- Specific section or heading
- Type of change (update existing, add new, deprecate)
- Description of the change
4. **Flag Uncertainty**
Explicitly mark:
- Assumptions you're making
- Areas where human confirmation is needed
- Ambiguous requirements
5. **Output Format**
Use the exact format specified in `docs/AGENTS.md` Phase 4 section:
```markdown
## Documentation Impact Assessment
### Summary
Brief description of code changes analyzed.
### Documentation Updates Required: [Yes/No]
### Planned Changes
#### 1. [File Path]
- **Section**: [Section name or "New section"]
- **Change Type**: [Update/Add/Deprecate]
- **Reason**: Why this change is needed
- **Description**: What will be added/modified
### Uncertainty Flags
- [ ] [Description of any assumptions or areas needing confirmation]
### No Changes Needed
- [List files reviewed but not requiring updates, with brief reason]
```
## Constraints
- Read-only: Do not modify any files
- Conservative: When uncertain, flag for human review rather than planning changes
- Scoped: Only plan changes that trace directly to code changes from Phase 3
- No scope expansion: Do not plan "improvements" unrelated to triggering changes

View File

@@ -0,0 +1,67 @@
# Phase 5: Apply Documentation Plan
You are executing a pre-approved documentation plan for an **mdBook** documentation site.
## Objective
Implement exactly the changes specified in the documentation plan from Phase 4.
## Documentation System
- **mdBook**: https://rust-lang.github.io/mdBook/
- **SUMMARY.md**: Follows mdBook format (https://rust-lang.github.io/mdBook/format/summary.html)
- **Prettier**: Will be run automatically after this phase (80 char line width)
- **Custom preprocessor**: Use `{#kb action::ActionName}` for keybindings instead of hardcoding
## Input
You will receive:
- Documentation plan from Phase 4
- Documentation guidelines from `docs/AGENTS.md`
- Style rules from `docs/.rules`
## Instructions
1. **Validate Plan**
- Confirm all planned files are within scope per AGENTS.md
- Verify no out-of-scope files are targeted
2. **Execute Each Planned Change**
For each item in "Planned Changes":
- Navigate to the specified file
- Locate the specified section
- Apply the described change
- Follow style rules from `docs/.rules`
3. **Style Compliance**
Every edit must follow `docs/.rules`:
- Second person, present tense
- No hedging words ("simply", "just", "easily")
- Proper keybinding format (`Cmd+Shift+P`)
- Settings Editor first, JSON second
- Correct terminology (folder not directory, etc.)
4. **Preserve Context**
- Maintain surrounding content structure
- Keep consistent heading levels
- Preserve existing cross-references
## Constraints
- Execute ONLY changes listed in the plan
- Do not discover new documentation targets
- Do not make stylistic improvements outside planned sections
- Do not expand scope beyond what Phase 4 specified
- If a planned change cannot be applied (file missing, section not found), skip and note it
## Output
After applying changes, output a summary:
```markdown
## Applied Changes
### Successfully Applied
- `path/to/file.md`: [Brief description of change]
### Skipped (Could Not Apply)
- `path/to/file.md`: [Reason - e.g., "Section not found"]
### Warnings
- [Any issues encountered during application]
```

View File

@@ -0,0 +1,54 @@
# Phase 6: Summarize Changes
You are generating a summary of documentation updates for PR review.
## Objective
Create a clear, reviewable summary of all documentation changes made.
## Input
You will receive:
- Applied changes report from Phase 5
- Original change analysis from Phase 3
- Git diff of documentation changes
## Instructions
1. **Gather Change Information**
- List all modified documentation files
- Identify the corresponding code changes that triggered each update
2. **Generate Summary**
Use the format specified in `docs/AGENTS.md` Phase 6 section:
```markdown
## Documentation Update Summary
### Changes Made
| File | Change | Related Code |
| --- | --- | --- |
| docs/src/path.md | Brief description | PR #123 or commit SHA |
### Rationale
Brief explanation of why these updates were made, linking back to the triggering code changes.
### Review Notes
- Items reviewers should pay special attention to
- Any uncertainty flags from Phase 4 that were addressed
- Assumptions made during documentation
```
3. **Add Context for Reviewers**
- Highlight any changes that might be controversial
- Note if any planned changes were skipped and why
- Flag areas where reviewer expertise is especially needed
## Output Format
The summary should be suitable for:
- PR description body
- Commit message (condensed version)
- Team communication
## Constraints
- Read-only (documentation changes already applied in Phase 5)
- Factual: Describe what was done, not justify why it's good
- Complete: Account for all changes, including skipped items

View File

@@ -0,0 +1,67 @@
# Phase 7: Commit and Open PR
You are creating a git branch, committing documentation changes, and opening a PR.
## Objective
Package documentation updates into a reviewable pull request.
## Input
You will receive:
- Summary from Phase 6
- List of modified files
## Instructions
1. **Create Branch**
```sh
git checkout -b docs/auto-update-{date}
```
Use format: `docs/auto-update-YYYY-MM-DD` or `docs/auto-update-{short-sha}`
2. **Stage and Commit**
- Stage only documentation files in `docs/src/`
- Do not stage any other files
Commit message format:
```
docs: auto-update documentation for [brief description]
[Summary from Phase 6, condensed]
Triggered by: [commit SHA or PR reference]
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
```
3. **Push Branch**
```sh
git push -u origin docs/auto-update-{date}
```
4. **Create Pull Request**
Use the Phase 6 summary as the PR body.
PR Title: `docs: [Brief description of documentation updates]`
Labels (if available): `documentation`, `automated`
Base branch: `main`
## Constraints
- Do NOT auto-merge
- Do NOT request specific reviewers (let CODEOWNERS handle it)
- Do NOT modify files outside `docs/src/`
- If no changes to commit, exit gracefully with message "No documentation changes to commit"
## Output
```markdown
## PR Created
- **Branch**: docs/auto-update-{date}
- **PR URL**: https://github.com/zed-industries/zed/pull/XXXX
- **Status**: Ready for review
### Commit
- SHA: {commit-sha}
- Files: {count} documentation files modified
```

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

@@ -19,6 +19,18 @@ runs:
shell: bash -euxo pipefail {0}
run: ./script/linux
- name: Install mold linker
shell: bash -euxo pipefail {0}
run: ./script/install-mold
- name: Download WASI SDK
shell: bash -euxo pipefail {0}
run: ./script/download-wasi-sdk
- name: Generate action metadata
shell: bash -euxo pipefail {0}
run: ./script/generate-action-metadata
- name: Check for broken links (in MD)
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
with:

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,29 +1,40 @@
name: "Close Stale Issues"
on:
schedule:
- cron: "0 8 31 DEC *"
- cron: "0 2 * * 5"
workflow_dispatch:
inputs:
debug-only:
description: "Run in dry-run mode (no changes made)"
type: boolean
default: false
operations-per-run:
description: "Max number of issues to process (default: 1000)"
type: number
default: 1000
jobs:
stale:
if: github.repository_owner == 'zed-industries'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: >
Hi there! 👋
We're working to clean up our issue tracker by closing older bugs that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and it will be kept open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, it will close automatically in 14 days.
Hi there!
Zed development moves fast and a significant number of bugs become outdated.
If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version.
If the bug doesn't appear for you anymore, feel free to close the issue yourself; otherwise, the bot will close it in a couple of weeks.
Thanks for your help!
close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue."
close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please leave a comment with your Zed version so that we can reopen the issue."
days-before-stale: 60
days-before-close: 14
only-issue-types: "Bug,Crash"
operations-per-run: 1000
operations-per-run: ${{ inputs.operations-per-run || 1000 }}
ascending: true
enable-statistics: true
debug-only: ${{ inputs.debug-only }}
stale-issue-label: "stale"
exempt-issue-labels: "never stale"

264
.github/workflows/docs_automation.yml vendored Normal file
View File

@@ -0,0 +1,264 @@
name: Documentation Automation
on:
# push:
# branches: [main]
# paths:
# - 'crates/**'
# - 'extensions/**'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to analyze (gets full PR diff)'
required: false
type: string
trigger_sha:
description: 'Commit SHA to analyze (ignored if pr_number is set)'
required: false
type: string
permissions:
contents: write
pull-requests: write
env:
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
DROID_MODEL: claude-opus-4-5-20251101
jobs:
docs-automation:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Droid CLI
id: install-droid
run: |
curl -fsSL https://app.factory.ai/cli | sh
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV"
# Verify installation
"${HOME}/.local/bin/droid" --version
- name: Setup Node.js (for Prettier)
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Prettier
run: npm install -g prettier
- name: Get changed files
id: changed
run: |
if [ -n "${{ inputs.pr_number }}" ]; then
# Get full PR diff
echo "Analyzing PR #${{ inputs.pr_number }}"
echo "source=pr" >> "$GITHUB_OUTPUT"
echo "ref=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
gh pr diff "${{ inputs.pr_number }}" --name-only > /tmp/changed_files.txt
elif [ -n "${{ inputs.trigger_sha }}" ]; then
# Get single commit diff
SHA="${{ inputs.trigger_sha }}"
echo "Analyzing commit $SHA"
echo "source=commit" >> "$GITHUB_OUTPUT"
echo "ref=$SHA" >> "$GITHUB_OUTPUT"
git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt
else
# Default to current commit
SHA="${{ github.sha }}"
echo "Analyzing commit $SHA"
echo "source=commit" >> "$GITHUB_OUTPUT"
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:
GH_TOKEN: ${{ github.token }}
# Phase 0: Guardrails are loaded via AGENTS.md in each phase
# Phase 2: Explore Repository (Read-Only - default)
- name: "Phase 2: Explore Repository"
id: phase2
run: |
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
-f .factory/prompts/docs-automation/phase2-explore.md \
> /tmp/phase2-output.txt 2>&1 || true
echo "Repository exploration complete"
cat /tmp/phase2-output.txt
# Phase 3: Analyze Changes (Read-Only - default)
- name: "Phase 3: Analyze Changes"
id: phase3
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"
cat /tmp/phase3-output.md
# Phase 4: Plan Documentation Impact (Read-Only - default)
- name: "Phase 4: Plan Documentation Impact"
id: phase4
run: |
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
-f .factory/prompts/docs-automation/phase4-plan.md \
> /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"
else
echo "updates_required=true" >> "$GITHUB_OUTPUT"
fi
# Phase 5: Apply Plan (Write-Enabled with --auto medium)
- name: "Phase 5: Apply Documentation Plan"
id: phase5
if: steps.phase4.outputs.updates_required == 'true'
run: |
"$DROID_BIN" exec \
-m "$DROID_MODEL" \
--auto medium \
-f .factory/prompts/docs-automation/phase5-apply.md \
> /tmp/phase5-report.md 2>&1 || true
echo "Documentation updates applied"
cat /tmp/phase5-report.md
# Phase 5b: Format with Prettier
- name: "Phase 5b: Format with Prettier"
id: phase5b
if: steps.phase4.outputs.updates_required == 'true'
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)
- name: "Phase 6: Summarize Changes"
id: phase6
if: steps.phase4.outputs.updates_required == 'true'
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 \
> /tmp/phase6-summary.md 2>&1 || true
echo "Summary generated"
cat /tmp/phase6-summary.md
# Phase 7: Commit and Open PR
- name: "Phase 7: Create PR"
id: phase7
if: steps.phase4.outputs.updates_required == 'true'
run: |
# Check if there are actual changes
if git diff --quiet docs/src/; then
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..."
git fetch origin "$BRANCH_NAME"
git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME"
else
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)
git commit -m "docs: auto-update documentation
${SUMMARY}
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
# Create new PR
gh pr create \
--title "docs: automated documentation update ($(date +%Y-%m-%d))" \
--body-file /tmp/phase6-summary.md \
--base main || true
echo "PR created on branch: $BRANCH_NAME"
fi
env:
GH_TOKEN: ${{ github.token }}
# Summary output
- name: "Summary"
if: always()
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
cat /tmp/phase6-summary.md >> "$GITHUB_STEP_SUMMARY"
else
echo "Workflow completed. Check individual phase outputs for details." >> "$GITHUB_STEP_SUMMARY"
fi

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
@@ -61,8 +61,7 @@ jobs:
uses: namespacelabs/nscloud-cache-action@v1
with:
cache: rust
- id: cargo_fmt
name: steps::cargo_fmt
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
- name: extension_tests::run_clippy
@@ -80,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

@@ -26,8 +26,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::clear_target_dir_if_large
@@ -72,15 +71,9 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- id: record_clippy_failure
name: steps::record_clippy_failure
if: always()
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
- name: steps::cargo_install_nextest
uses: taiki-e/install-action@nextest
- name: steps::clear_target_dir_if_large
@@ -94,8 +87,6 @@ jobs:
run: |
rm -rf ./../.cargo
shell: bash -euxo pipefail {0}
outputs:
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
timeout-minutes: 60
run_tests_windows:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
@@ -114,8 +105,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- name: steps::clear_target_dir_if_large

View File

@@ -20,8 +20,7 @@ jobs:
with:
clean: false
fetch-depth: 0
- id: cargo_fmt
name: steps::cargo_fmt
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
- name: ./script/clippy
@@ -45,8 +44,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- name: steps::clear_target_dir_if_large

View File

@@ -74,19 +74,12 @@ jobs:
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
with:
version: '9'
- id: prettier
name: steps::prettier
- name: steps::prettier
run: ./script/prettier
shell: bash -euxo pipefail {0}
- id: cargo_fmt
name: steps::cargo_fmt
- name: steps::cargo_fmt
run: cargo fmt --all -- --check
shell: bash -euxo pipefail {0}
- id: record_style_failure
name: steps::record_style_failure
if: always()
run: echo "failed=${{ steps.prettier.outcome == 'failure' || steps.cargo_fmt.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
- name: ./script/check-todos
run: ./script/check-todos
shell: bash -euxo pipefail {0}
@@ -97,8 +90,6 @@ jobs:
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06
with:
config: ./typos.toml
outputs:
style_failed: ${{ steps.record_style_failure.outputs.failed == 'true' }}
timeout-minutes: 60
run_tests_windows:
needs:
@@ -119,8 +110,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- name: steps::clear_target_dir_if_large
@@ -167,15 +157,9 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- id: record_clippy_failure
name: steps::record_clippy_failure
if: always()
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
- name: steps::cargo_install_nextest
uses: taiki-e/install-action@nextest
- name: steps::clear_target_dir_if_large
@@ -189,8 +173,6 @@ jobs:
run: |
rm -rf ./../.cargo
shell: bash -euxo pipefail {0}
outputs:
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
timeout-minutes: 60
run_tests_mac:
needs:
@@ -211,8 +193,7 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- id: clippy
name: steps::clippy
- name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::clear_target_dir_if_large
@@ -372,6 +353,9 @@ jobs:
- name: steps::download_wasi_sdk
run: ./script/download-wasi-sdk
shell: bash -euxo pipefail {0}
- name: ./script/generate-action-metadata
run: ./script/generate-action-metadata
shell: bash -euxo pipefail {0}
- name: run_tests::check_docs::install_mdbook
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08
with:
@@ -592,24 +576,6 @@ jobs:
exit $EXIT_CODE
shell: bash -euxo pipefail {0}
call_autofix:
needs:
- check_style
- run_tests_linux
if: always() && (needs.check_style.outputs.style_failed == 'true' || needs.run_tests_linux.outputs.clippy_failed == 'true') && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: get-app-token
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- name: run_tests::call_autofix::dispatch_autofix
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=${{ needs.run_tests_linux.outputs.clippy_failed == 'true' }}
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@
DerivedData/
Packages
xcuserdata/
crates/docs_preprocessor/actions.json
# Don't commit any secrets to the repo.
.env

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).

65
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",
@@ -3525,6 +3526,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"
@@ -5021,8 +5049,6 @@ name = "docs_preprocessor"
version = "0.1.0"
dependencies = [
"anyhow",
"command_palette",
"gpui",
"mdbook",
"regex",
"serde",
@@ -5031,7 +5057,6 @@ dependencies = [
"task",
"theme",
"util",
"zed",
"zlog",
]
@@ -5188,6 +5213,7 @@ dependencies = [
"anyhow",
"arrayvec",
"brotli",
"buffer_diff",
"client",
"clock",
"cloud_api_types",
@@ -5225,7 +5251,9 @@ dependencies = [
"strum 0.27.2",
"telemetry",
"telemetry_events",
"text",
"thiserror 2.0.17",
"time",
"ui",
"util",
"uuid",
@@ -5330,8 +5358,10 @@ dependencies = [
"anyhow",
"buffer_diff",
"client",
"clock",
"cloud_llm_client",
"codestral",
"collections",
"command_palette_hooks",
"copilot",
"edit_prediction",
@@ -5340,18 +5370,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",
@@ -5364,6 +5396,7 @@ dependencies = [
"workspace",
"zed_actions",
"zeta_prompt",
"zlog",
]
[[package]]
@@ -8621,6 +8654,7 @@ dependencies = [
"extension",
"gpui",
"language",
"lsp",
"paths",
"project",
"schemars",
@@ -8932,6 +8966,8 @@ dependencies = [
"credentials_provider",
"deepseek",
"editor",
"extension",
"extension_host",
"fs",
"futures 0.3.31",
"google_ai",
@@ -12571,6 +12607,7 @@ dependencies = [
"gpui",
"language",
"menu",
"notifications",
"pretty_assertions",
"project",
"rayon",
@@ -12648,6 +12685,8 @@ dependencies = [
"paths",
"rope",
"serde",
"strum 0.27.2",
"tempfile",
"text",
"util",
"uuid",
@@ -20263,6 +20302,16 @@ dependencies = [
"zlog",
]
[[package]]
name = "worktree_benchmarks"
version = "0.1.0"
dependencies = [
"fs",
"gpui",
"settings",
"worktree",
]
[[package]]
name = "writeable"
version = "0.6.1"
@@ -20631,6 +20680,7 @@ dependencies = [
"collections",
"command_palette",
"component",
"component_preview",
"copilot",
"crashes",
"dap",
@@ -20736,7 +20786,6 @@ dependencies = [
"tree-sitter-md",
"tree-sitter-rust",
"ui",
"ui_input",
"ui_prompt",
"url",
"urlencoding",
@@ -20916,7 +20965,7 @@ dependencies = [
[[package]]
name = "zed_glsl"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"zed_extension_api 0.1.0",
]
@@ -20930,7 +20979,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",
@@ -198,6 +199,7 @@ members = [
"crates/web_search_providers",
"crates/workspace",
"crates/worktree",
"crates/worktree_benchmarks",
"crates/x_ai",
"crates/zed",
"crates/zed_actions",
@@ -274,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

@@ -20,7 +20,6 @@ Other platforms are not yet available:
- [Building Zed for macOS](./docs/src/development/macos.md)
- [Building Zed for Linux](./docs/src/development/linux.md)
- [Building Zed for Windows](./docs/src/development/windows.md)
- [Running Collaboration Locally](./docs/src/development/local-collaboration.md)
### Contributing

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

@@ -1178,6 +1178,10 @@
"remove_trailing_whitespace_on_save": true,
// Whether to start a new line with a comment when a previous line is a comment as well.
"extend_comment_on_newline": true,
// Whether to continue markdown lists when pressing enter.
"extend_list_on_newline": true,
// Whether to indent list items when pressing tab after a list marker.
"indent_list_on_tab": true,
// Removes any lines containing only whitespace at the end of the file and
// ensures just one newline at the end.
"ensure_final_newline_on_save": true,
@@ -1321,6 +1325,14 @@
"hidden_files": ["**/.*"],
// Git gutter behavior configuration.
"git": {
// Global switch to enable or disable all git integration features.
// If set to true, disables all git integration features.
// If set to false, individual git integration features below will be independently enabled or disabled.
"disable_git": false,
// Whether to enable git status tracking.
"enable_status": true,
// Whether to enable git diff display.
"enable_diff": true,
// Control whether the git gutter is shown. May take 2 values:
// 1. Show the gutter
// "git_gutter": "tracked_files"

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

@@ -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<IconName>,
pub icon: Option<AgentModelIcon>,
}
impl From<acp::ModelInfo> for AgentModelInfo {

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
@@ -769,7 +769,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)
}

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,
@@ -93,7 +93,7 @@ impl LanguageModels {
fn refresh_list(&mut self, cx: &App) {
let providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.visible_providers()
.into_iter()
.filter(|provider| provider.is_authenticated(cx))
.collect::<Vec<_>>();
@@ -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),
}),
}
}
@@ -164,7 +167,7 @@ impl LanguageModels {
fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
let authenticate_all_providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.visible_providers()
.iter()
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
@@ -426,7 +429,7 @@ impl NativeAgent {
.into_iter()
.flat_map(|(contents, prompt_metadata)| match contents {
Ok(contents) => Some(UserRulesContext {
uuid: prompt_metadata.id.user_id()?,
uuid: prompt_metadata.id.as_user()?,
title: prompt_metadata.title.map(|title| title.to_string()),
contents,
}),
@@ -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(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;
@@ -38,6 +39,7 @@ 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).
@@ -47,11 +49,29 @@ pub struct AcpConnection {
_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,6 +107,7 @@ 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> {
@@ -217,6 +240,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,
@@ -256,6 +280,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 +347,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 +449,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 +556,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 +692,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 +829,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 +965,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 gpui::{App, AppContext as _, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider;
use project::agent_server_store::GEMINI_NAME;
use project::agent_server_store::{AllAgentServersSettings, GEMINI_NAME};
use settings::SettingsStore;
#[derive(Clone)]
pub struct Gemini;
@@ -33,6 +34,14 @@ 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());
@@ -65,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 |_| {
@@ -186,6 +186,17 @@ impl Render for ModeSelector {
move |_window, cx| {
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Toggle Mode Menu"))
.child(KeyBinding::for_action_in(
&ToggleProfileSelector,
&focus_handle,
cx,
)),
)
.child(
h_flex()
.pb_1()
@@ -200,17 +211,6 @@ impl Render for ModeSelector {
cx,
)),
)
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Toggle Mode Menu"))
.child(KeyBinding::for_action_in(
&ToggleProfileSelector,
&focus_handle,
cx,
)),
)
.into_any()
}
}),

View File

@@ -1,22 +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 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;
@@ -54,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,
}
@@ -102,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,
@@ -111,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,
}
}
@@ -120,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);
@@ -220,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
@@ -317,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(
@@ -350,13 +359,15 @@ impl PickerDelegate for AcpModelPickerDelegate {
})
.child(
ModelSelectorListItem::new(ix, model_info.name.clone())
.when_some(model_info.icon, |this, icon| this.icon(icon))
.map(|this| match &model_info.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(),
)
@@ -377,7 +388,6 @@ impl PickerDelegate for AcpModelPickerDelegate {
DocumentationAside::new(
DocumentationSide::Left,
DocumentationEdge::Top,
Rc::new(move |_| {
v_flex()
.gap_1()
@@ -389,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,
@@ -599,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![
@@ -735,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,18 +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 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>,
@@ -23,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,
@@ -64,13 +60,14 @@ 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())
.unwrap_or_else(|| SharedString::from("Select a Model"));
let model_icon = model.as_ref().and_then(|model| model.icon);
let model_icon = model.as_ref().and_then(|model| model.icon.clone());
let focus_handle = self.focus_handle.clone();
@@ -80,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()
}
});
@@ -125,7 +92,14 @@ impl Render for AcpModelSelectorPopover {
ButtonLike::new("active-model")
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.when_some(model_icon, |this, icon| {
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
this.child(
match icon {
AgentModelIcon::Path(path) => Icon::from_external_svg(path),
AgentModelIcon::Named(icon_name) => Icon::new(icon_name),
}
.color(color)
.size(IconSize::XSmall),
)
})
.child(
Label::new(model_name)

View File

@@ -1,7 +1,7 @@
use crate::acp::AcpThreadView;
use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
use agent::{HistoryEntry, HistoryStore};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
use editor::{Editor, EditorEvent};
use fuzzy::StringMatchCandidate;
use gpui::{
@@ -402,7 +402,22 @@ impl AcpThreadHistory {
let selected = ix == self.selected_index;
let hovered = Some(ix) == self.hovered_index;
let timestamp = entry.updated_at().timestamp();
let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
let display_text = match format {
EntryTimeFormat::DateAndTime => {
let entry_time = entry.updated_at();
let now = Utc::now();
let duration = now.signed_duration_since(entry_time);
let days = duration.num_days();
format!("{}d", days)
}
EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
};
let title = entry.title().clone();
let full_date =
EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
h_flex()
.w_full()
@@ -423,11 +438,14 @@ impl AcpThreadHistory {
.truncate(),
)
.child(
Label::new(thread_timestamp)
Label::new(display_text)
.color(Color::Muted)
.size(LabelSize::XSmall),
),
)
.tooltip(move |_, cx| {
Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
})
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
if *is_hovered {
this.hovered_index = Some(ix);

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<()>>,
@@ -338,7 +342,13 @@ impl AcpThreadView {
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![]));
let placeholder = placeholder_text(agent.name().as_ref(), false);
let agent_server_store = project.read(cx).agent_server_store().clone();
let agent_display_name = agent_server_store
.read(cx)
.agent_display_name(&ExternalAgentServerName(agent.name()))
.unwrap_or_else(|| agent.name());
let placeholder = placeholder_text(agent_display_name.as_ref(), false);
let message_editor = cx.new(|cx| {
let mut editor = MessageEditor::new(
@@ -377,7 +387,6 @@ impl AcpThreadView {
)
});
let agent_server_store = project.read(cx).agent_server_store().clone();
let subscriptions = [
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<AgentFontSize>(window, Self::agent_ui_font_size_changed),
@@ -423,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(),
@@ -607,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),
@@ -1388,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();
}
@@ -1498,7 +1531,13 @@ impl AcpThreadView {
let has_commands = !available_commands.is_empty();
self.available_commands.replace(available_commands);
let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands);
let agent_display_name = self
.agent_server_store
.read(cx)
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
.unwrap_or_else(|| self.agent.name());
let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
self.message_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(&new_placeholder, window, cx);
@@ -1508,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();
}
@@ -2027,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()
@@ -2143,7 +2186,6 @@ impl AcpThreadView {
if this_is_blank {
return None;
}
Some(
self.render_thinking_block(
entry_ix,
@@ -2169,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()
}
}
@@ -2276,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()
@@ -2478,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())),
@@ -2488,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(),
@@ -2707,7 +2817,7 @@ impl AcpThreadView {
..default_markdown_style(false, true, window, cx)
},
))
.tooltip(Tooltip::text("Jump to File"))
.tooltip(Tooltip::text("Go to File"))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
}))
@@ -4273,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()
@@ -4367,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)),
),
)
@@ -5343,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| {
@@ -5378,7 +5465,7 @@ impl AcpThreadView {
Some(
Callout::new()
.severity(severity)
.line_height(line_height)
.icon(icon)
.title(title)
.description(description)
.actions_slot(
@@ -5410,7 +5497,8 @@ impl AcpThreadView {
})),
)
}),
),
)
.dismiss_action(self.dismiss_error_button(cx)),
)
}
@@ -5833,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);
@@ -5990,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 {
@@ -6073,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

@@ -22,7 +22,8 @@ use gpui::{
};
use language::LanguageRegistry;
use language_model::{
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
ZED_CLOUD_PROVIDER_ID,
};
use language_models::AllLanguageModelSettings;
use notifications::status_toast::{StatusToast, ToastIcon};
@@ -117,7 +118,7 @@ impl AgentConfiguration {
}
fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let providers = LanguageModelRegistry::read_global(cx).providers();
let providers = LanguageModelRegistry::read_global(cx).visible_providers();
for provider in providers {
self.add_provider_configuration_view(&provider, window, cx);
}
@@ -261,9 +262,12 @@ impl AgentConfiguration {
.w_full()
.gap_1p5()
.child(
Icon::new(provider.icon())
.size(IconSize::Small)
.color(Color::Muted),
match provider.icon() {
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
IconOrSvg::Icon(name) => Icon::new(name),
}
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
h_flex()
@@ -416,7 +420,7 @@ impl AgentConfiguration {
&mut self,
cx: &mut Context<Self>,
) -> impl IntoElement {
let providers = LanguageModelRegistry::read_global(cx).providers();
let providers = LanguageModelRegistry::read_global(cx).visible_providers();
let popover_menu = PopoverMenu::new("add-provider-popover")
.trigger(
@@ -1366,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};
@@ -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,14 +1,15 @@
use crate::{
ModelUsageContext,
language_model_selector::{LanguageModelSelector, language_model_selector},
ui::ModelSelectorTooltip,
};
use fs::Fs;
use gpui::{Entity, FocusHandle, SharedString};
use language_model::IconOrSvg;
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>,
@@ -80,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 {
@@ -97,13 +104,30 @@ 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")
.when_some(provider_icon, |this, icon| {
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
this.child(
match icon {
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
IconOrSvg::Icon(name) => Icon::new(name),
}
.color(color)
.size(IconSize::XSmall),
)
})
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.child(
@@ -115,11 +139,9 @@ impl Render for AgentModelSelector {
.child(
Icon::new(IconName::ChevronDown)
.color(color)
.size(IconSize::Small),
.size(IconSize::XSmall),
),
move |_window, cx| {
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
},
tooltip,
gpui::Corner::TopRight,
cx,
)

View File

@@ -2428,7 +2428,7 @@ impl AgentPanel {
let history_is_empty = self.history_store.read(cx).is_empty(cx);
let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
.providers()
.visible_providers()
.iter()
.any(|provider| {
provider.is_authenticated(cx)

View File

@@ -348,7 +348,8 @@ fn init_language_model_settings(cx: &mut App) {
|_, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
| language_model::Event::RemovedProvider(_)
| language_model::Event::ProvidersChanged => {
update_active_language_model_from_settings(cx);
}
_ => {}

View File

@@ -1586,7 +1586,7 @@ pub(crate) fn search_rules(
None
} else {
Some(RulesContextEntry {
prompt_id: metadata.id.user_id()?,
prompt_id: metadata.id.as_user()?,
title: metadata.title?,
})
}

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

@@ -1259,28 +1259,26 @@ impl InlineAssistant {
let bottom = top + 1.0;
(top, bottom)
});
let mut scroll_target_top = scroll_target_range.0;
let mut scroll_target_bottom = scroll_target_range.1;
scroll_target_top -= editor.vertical_scroll_margin() as ScrollOffset;
scroll_target_bottom += editor.vertical_scroll_margin() as ScrollOffset;
let height_in_lines = editor.visible_line_count().unwrap_or(0.);
let vertical_scroll_margin = editor.vertical_scroll_margin() as ScrollOffset;
let scroll_target_top = (scroll_target_range.0 - vertical_scroll_margin)
// Don't scroll up too far in the case of a large vertical_scroll_margin.
.max(scroll_target_range.0 - height_in_lines / 2.0);
let scroll_target_bottom = (scroll_target_range.1 + vertical_scroll_margin)
// Don't scroll down past where the top would still be visible.
.min(scroll_target_top + height_in_lines);
let scroll_top = editor.scroll_position(cx).y;
let scroll_bottom = scroll_top + height_in_lines;
if scroll_target_top < scroll_top {
editor.set_scroll_position(point(0., scroll_target_top), window, cx);
} else if scroll_target_bottom > scroll_bottom {
if (scroll_target_bottom - scroll_target_top) <= height_in_lines {
editor.set_scroll_position(
point(0., scroll_target_bottom - height_in_lines),
window,
cx,
);
} else {
editor.set_scroll_position(point(0., scroll_target_top), window, cx);
}
editor.set_scroll_position(
point(0., scroll_target_bottom - height_in_lines),
window,
cx,
);
}
});
}

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

@@ -7,8 +7,8 @@ use gpui::{
Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
};
use language_model::{
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider,
LanguageModelProviderId, LanguageModelRegistry,
AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId,
LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
@@ -20,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,
@@ -55,7 +55,7 @@ pub fn language_model_selector(
fn all_models(cx: &App) -> GroupedModels {
let lm_registry = LanguageModelRegistry::global(cx).read(cx);
let providers = lm_registry.providers();
let providers = lm_registry.visible_providers();
let mut favorites_index = FavoritesIndex::default();
@@ -94,7 +94,7 @@ type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>
#[derive(Clone)]
struct ModelInfo {
model: Arc<dyn LanguageModel>,
icon: IconName,
icon: IconOrSvg,
is_favorite: bool,
}
@@ -133,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,
@@ -203,7 +203,7 @@ impl LanguageModelPickerDelegate {
fn authenticate_all_providers(cx: &mut App) -> Task<()> {
let authenticate_all_providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.visible_providers()
.iter()
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
@@ -250,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;
@@ -474,7 +478,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
let configured_providers = language_model_registry
.read(cx)
.providers()
.visible_providers()
.into_iter()
.filter(|provider| provider.is_authenticated(cx))
.collect::<Vec<_>>();
@@ -561,12 +565,18 @@ 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(
ModelSelectorListItem::new(ix, model_info.model.name().0)
.icon(model_info.icon)
.map(|this| match &model_info.icon {
IconOrSvg::Icon(icon_name) => this.icon(*icon_name),
IconOrSvg::Svg(icon_path) => this.icon_path(icon_path.clone()),
})
.is_selected(is_selected)
.is_focused(selected)
.is_favorite(is_favorite)
@@ -702,7 +712,7 @@ mod tests {
.any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
ModelInfo {
model: Arc::new(TestLanguageModel::new(name, provider)),
icon: IconName::Ai,
icon: IconOrSvg::Icon(IconName::Ai),
is_favorite,
}
})

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
@@ -191,6 +191,9 @@ impl Render for ProfileSelector {
let container = || h_flex().gap_1().justify_between();
v_flex()
.gap_1()
.child(container().child(Label::new("Toggle Profile Menu")).child(
KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
))
.child(
container()
.pb_1()
@@ -203,9 +206,6 @@ impl Render for ProfileSelector {
cx,
)),
)
.child(container().child(Label::new("Toggle Profile Menu")).child(
KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
))
.into_any()
}
}),
@@ -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};
@@ -33,7 +33,8 @@ use language::{
language_settings::{SoftWrap, all_language_settings},
};
use language_model::{
ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role,
ConfigurationError, IconOrSvg, LanguageModelExt, LanguageModelImage, LanguageModelRegistry,
Role,
};
use multi_buffer::MultiBufferRow;
use picker::{Picker, popover_menu::PickerPopoverMenu};
@@ -2231,10 +2232,10 @@ impl TextThreadEditor {
.default_model()
.map(|default| default.provider);
let provider_icon = match active_provider {
Some(provider) => provider.icon(),
None => IconName::Ai,
};
let provider_icon = active_provider
.as_ref()
.map(|p| p.icon())
.unwrap_or(IconOrSvg::Icon(IconName::Ai));
let focus_handle = self.editor().focus_handle(cx);
@@ -2244,43 +2245,25 @@ impl TextThreadEditor {
(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 provider_icon_element = match provider_icon {
IconOrSvg::Svg(path) => Icon::from_external_svg(path),
IconOrSvg::Icon(name) => Icon::new(name),
}
.color(color)
.size(IconSize::XSmall);
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 show_cycle_row = self
.language_model_selector
.read(cx)
.delegate
.favorites_count()
> 1;
let tooltip = Tooltip::element({
move |_, _cx| {
ModelSelectorTooltip::new(focus_handle.clone())
.show_cycle_row(show_cycle_row)
.into_any_element()
}
});
@@ -2291,7 +2274,7 @@ impl TextThreadEditor {
.child(
h_flex()
.gap_0p5()
.child(Icon::new(provider_icon).color(color).size(IconSize::XSmall))
.child(provider_icon_element)
.child(
Label::new(model_name)
.color(color)

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 {
@@ -39,11 +47,11 @@ impl RenderOnce for ModelSelectorHeader {
pub struct ModelSelectorListItem {
index: usize,
title: SharedString,
icon: Option<IconName>,
icon: Option<ModelIcon>,
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 {
@@ -60,7 +68,12 @@ impl ModelSelectorListItem {
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self.icon = Some(ModelIcon::Name(icon));
self
}
pub fn icon_path(mut self, path: SharedString) -> Self {
self.icon = Some(ModelIcon::Path(path));
self
}
@@ -79,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
}
@@ -105,9 +121,12 @@ impl RenderOnce for ModelSelectorListItem {
.gap_1p5()
.when_some(self.icon, |this, icon| {
this.child(
Icon::new(icon)
.color(model_icon_color)
.size(IconSize::Small),
match icon {
ModelIcon::Name(icon_name) => Icon::new(icon_name),
ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path),
}
.color(model_icon_color)
.size(IconSize::Small),
)
})
.child(Label::new(self.title).truncate()),
@@ -128,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)),
)
}
}))
@@ -174,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

@@ -1,5 +1,5 @@
use agent::{HistoryEntry, HistoryStore};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
use editor::{Editor, EditorEvent};
use fuzzy::StringMatchCandidate;
use gpui::{
@@ -411,7 +411,22 @@ impl AcpThreadHistory {
let selected = ix == self.selected_index;
let hovered = Some(ix) == self.hovered_index;
let timestamp = entry.updated_at().timestamp();
let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
let display_text = match format {
EntryTimeFormat::DateAndTime => {
let entry_time = entry.updated_at();
let now = Utc::now();
let duration = now.signed_duration_since(entry_time);
let days = duration.num_days();
format!("{}d", days)
}
EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
};
let title = entry.title().clone();
let full_date =
EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
h_flex()
.w_full()
@@ -432,11 +447,14 @@ impl AcpThreadHistory {
.truncate(),
)
.child(
Label::new(thread_timestamp)
Label::new(display_text)
.color(Color::Muted)
.size(LabelSize::XSmall),
),
)
.tooltip(move |_, cx| {
Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
})
.on_hover(cx.listener(move |this, is_hovered, _window, cx| {
if *is_hovered {
this.hovered_index = Some(ix);

View File

@@ -1,9 +1,9 @@
use gpui::{Action, IntoElement, ParentElement, RenderOnce, point};
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use language_model::{IconOrSvg, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use ui::{Divider, List, ListBulletItem, prelude::*};
pub struct ApiKeysWithProviders {
configured_providers: Vec<(IconName, SharedString)>,
configured_providers: Vec<(IconOrSvg, SharedString)>,
}
impl ApiKeysWithProviders {
@@ -13,7 +13,8 @@ impl ApiKeysWithProviders {
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
| language_model::Event::RemovedProvider(_)
| language_model::Event::ProvidersChanged => {
this.configured_providers = Self::compute_configured_providers(cx)
}
_ => {}
@@ -26,9 +27,9 @@ impl ApiKeysWithProviders {
}
}
fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> {
fn compute_configured_providers(cx: &App) -> Vec<(IconOrSvg, SharedString)> {
LanguageModelRegistry::read_global(cx)
.providers()
.visible_providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
@@ -47,7 +48,14 @@ impl Render for ApiKeysWithProviders {
.map(|(icon, name)| {
h_flex()
.gap_1p5()
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
.child(
match icon {
IconOrSvg::Icon(icon_name) => Icon::new(icon_name),
IconOrSvg::Svg(icon_path) => Icon::from_external_svg(icon_path),
}
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(Label::new(name))
});
div()

View File

@@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
pub struct AgentPanelOnboarding {
user_store: Entity<UserStore>,
client: Arc<Client>,
configured_providers: Vec<(IconName, SharedString)>,
has_configured_providers: bool,
continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
}
@@ -27,8 +27,9 @@ impl AgentPanelOnboarding {
|this: &mut Self, _registry, event: &language_model::Event, cx| match event {
language_model::Event::ProviderStateChanged(_)
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
this.configured_providers = Self::compute_available_providers(cx)
| language_model::Event::RemovedProvider(_)
| language_model::Event::ProvidersChanged => {
this.has_configured_providers = Self::has_configured_providers(cx)
}
_ => {}
},
@@ -38,20 +39,16 @@ impl AgentPanelOnboarding {
Self {
user_store,
client,
configured_providers: Self::compute_available_providers(cx),
has_configured_providers: Self::has_configured_providers(cx),
continue_with_zed_ai: Arc::new(continue_with_zed_ai),
}
}
fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> {
fn has_configured_providers(cx: &App) -> bool {
LanguageModelRegistry::read_global(cx)
.providers()
.visible_providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
})
.map(|provider| (provider.icon(), provider.name().0))
.collect()
.any(|provider| provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID)
}
}
@@ -81,7 +78,7 @@ impl Render for AgentPanelOnboarding {
}),
)
.map(|this| {
if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() {
if enrolled_in_trial || is_pro_user || self.has_configured_providers {
this
} else {
this.child(ApiKeysWithoutProviders::new())

View File

@@ -314,6 +314,12 @@ impl BufferDiffSnapshot {
self.inner.hunks.is_empty()
}
pub fn base_text_string(&self) -> Option<String> {
self.inner
.base_text_exists
.then(|| self.inner.base_text.text())
}
pub fn secondary_diff(&self) -> Option<&BufferDiffSnapshot> {
self.secondary_diff.as_deref()
}
@@ -1159,6 +1165,34 @@ impl BufferDiff {
new_index_text
}
pub fn stage_or_unstage_all_hunks(
&mut self,
stage: bool,
buffer: &text::BufferSnapshot,
file_exists: bool,
cx: &mut Context<Self>,
) {
let hunks = self
.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
.collect::<Vec<_>>();
let Some(secondary) = self.secondary_diff.as_ref() else {
return;
};
self.inner.stage_or_unstage_hunks_impl(
&secondary.read(cx).inner,
stage,
&hunks,
buffer,
file_exists,
);
if let Some((first, last)) = hunks.first().zip(hunks.last()) {
let changed_range = first.buffer_range.start..last.buffer_range.end;
cx.emit(BufferDiffEvent::DiffChanged {
changed_range: Some(changed_range),
});
}
}
pub fn range_to_hunk_range(
&self,
range: Range<Anchor>,

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

@@ -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);
}

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

@@ -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

@@ -7,8 +7,6 @@ license = "GPL-3.0-or-later"
[dependencies]
anyhow.workspace = true
command_palette.workspace = true
gpui.workspace = true
# We are specifically pinning this version of mdbook, as later versions introduce issues with double-nested subdirectories.
# Ask @maxdeviant about this before bumping.
mdbook = "= 0.4.40"
@@ -17,7 +15,6 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
util.workspace = true
zed.workspace = true
zlog.workspace = true
task.workspace = true
theme.workspace = true
@@ -27,4 +24,4 @@ workspace = true
[[bin]]
name = "docs_preprocessor"
path = "src/main.rs"
path = "src/main.rs"

View File

@@ -22,16 +22,13 @@ static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
});
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(load_all_actions);
const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
fn main() -> Result<()> {
zlog::init();
zlog::init_output_stderr();
// call a zed:: function so everything in `zed` crate is linked and
// all actions in the actual app are registered
zed::stdout_is_a_pty();
let args = std::env::args().skip(1).collect::<Vec<_>>();
match args.get(0).map(String::as_str) {
@@ -72,8 +69,8 @@ enum PreprocessorError {
impl PreprocessorError {
fn new_for_not_found_action(action_name: String) -> Self {
for action in &*ALL_ACTIONS {
for alias in action.deprecated_aliases {
if alias == &action_name {
for alias in &action.deprecated_aliases {
if alias == action_name.as_str() {
return PreprocessorError::DeprecatedActionUsed {
used: action_name,
should_be: action.name.to_string(),
@@ -214,7 +211,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Prepr
chapter.content = regex
.replace_all(&chapter.content, |caps: &regex::Captures| {
let action = caps[1].trim();
if find_action_by_name(action).is_none() {
if is_missing_action(action) {
errors.insert(PreprocessorError::new_for_not_found_action(
action.to_string(),
));
@@ -244,10 +241,12 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
.replace_all(&chapter.content, |caps: &regex::Captures| {
let name = caps[1].trim();
let Some(action) = find_action_by_name(name) else {
errors.insert(PreprocessorError::new_for_not_found_action(
name.to_string(),
));
return String::new();
if actions_available() {
errors.insert(PreprocessorError::new_for_not_found_action(
name.to_string(),
));
}
return format!("<code class=\"hljs\">{}</code>", name);
};
format!("<code class=\"hljs\">{}</code>", &action.human_name)
})
@@ -257,11 +256,19 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
fn find_action_by_name(name: &str) -> Option<&ActionDef> {
ALL_ACTIONS
.binary_search_by(|action| action.name.cmp(name))
.binary_search_by(|action| action.name.as_str().cmp(name))
.ok()
.map(|index| &ALL_ACTIONS[index])
}
fn actions_available() -> bool {
!ALL_ACTIONS.is_empty()
}
fn is_missing_action(name: &str) -> bool {
actions_available() && find_action_by_name(name).is_none()
}
fn find_binding(os: &str, action: &str) -> Option<String> {
let keymap = match os {
"macos" => &KEYMAP_MACOS,
@@ -384,18 +391,13 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
let keymap = settings::KeymapFile::parse(&snippet_json_fixed)
.context("Failed to parse keymap JSON")?;
for section in keymap.sections() {
for (keystrokes, action) in section.bindings() {
keystrokes
.split_whitespace()
.map(|source| gpui::Keystroke::parse(source))
.collect::<std::result::Result<Vec<_>, _>>()
.context("Failed to parse keystroke")?;
for (_keystrokes, action) in section.bindings() {
if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
.map_err(|err| anyhow::format_err!(err))
.context("Failed to parse action")?
{
anyhow::ensure!(
find_action_by_name(action_name).is_some(),
!is_missing_action(action_name),
"Action not found: {}",
action_name
);
@@ -491,27 +493,35 @@ where
});
}
#[derive(Debug, serde::Serialize)]
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct ActionDef {
name: &'static str,
name: String,
human_name: String,
deprecated_aliases: &'static [&'static str],
docs: Option<&'static str>,
deprecated_aliases: Vec<String>,
#[serde(rename = "documentation")]
docs: Option<String>,
}
fn dump_all_gpui_actions() -> Vec<ActionDef> {
let mut actions = gpui::generate_list_of_all_registered_actions()
.map(|action| ActionDef {
name: action.name,
human_name: command_palette::humanize_action_name(action.name),
deprecated_aliases: action.deprecated_aliases,
docs: action.documentation,
})
.collect::<Vec<ActionDef>>();
actions.sort_by_key(|a| a.name);
actions
fn load_all_actions() -> Vec<ActionDef> {
let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
match std::fs::read_to_string(asset_path) {
Ok(content) => {
let mut actions: Vec<ActionDef> =
serde_json::from_str(&content).expect("Failed to parse actions.json");
actions.sort_by(|a, b| a.name.cmp(&b.name));
actions
}
Err(err) => {
if std::env::var("CI").is_ok() {
panic!("actions.json not found at {}: {}", asset_path, err);
}
eprintln!(
"Warning: actions.json not found, action validation will be skipped: {}",
err
);
Vec::new()
}
}
}
fn handle_postprocessing() -> Result<()> {
@@ -647,7 +657,7 @@ fn generate_big_table_of_actions() -> String {
let mut output = String::new();
let mut actions_sorted = actions.iter().collect::<Vec<_>>();
actions_sorted.sort_by_key(|a| a.name);
actions_sorted.sort_by_key(|a| a.name.as_str());
// Start the definition list with custom styling for better spacing
output.push_str("<dl style=\"line-height: 1.8;\">\n");
@@ -664,7 +674,7 @@ fn generate_big_table_of_actions() -> String {
output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\n");
// Add the description, escaping HTML if needed
if let Some(description) = action.docs {
if let Some(description) = action.docs.as_ref() {
output.push_str(
&description
.replace("&", "&amp;")
@@ -674,7 +684,7 @@ fn generate_big_table_of_actions() -> String {
output.push_str("<br>\n");
}
output.push_str("Keymap Name: <code>");
output.push_str(action.name);
output.push_str(&action.name);
output.push_str("</code><br>\n");
if !action.deprecated_aliases.is_empty() {
output.push_str("Deprecated Alias(es): ");

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

@@ -51,6 +51,8 @@ pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.);
pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.);
pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.);
pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.);
pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.);
// Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
// documentation not yet being parsed.
@@ -179,7 +181,7 @@ impl CodeContextMenu {
) -> Option<AnyElement> {
match self {
CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx),
CodeContextMenu::CodeActions(_) => None,
CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx),
}
}
@@ -891,7 +893,7 @@ impl CompletionsMenu {
None
} else {
Some(
Label::new(text.clone())
Label::new(text.trim().to_string())
.ml_4()
.size(LabelSize::Small)
.color(Color::Muted),
@@ -1419,26 +1421,6 @@ pub enum CodeActionsItem {
}
impl CodeActionsItem {
fn as_task(&self) -> Option<&ResolvedTask> {
let Self::Task(_, task) = self else {
return None;
};
Some(task)
}
fn as_code_action(&self) -> Option<&CodeAction> {
let Self::CodeAction { action, .. } = self else {
return None;
};
Some(action)
}
fn as_debug_scenario(&self) -> Option<&DebugScenario> {
let Self::DebugScenario(scenario) = self else {
return None;
};
Some(scenario)
}
pub fn label(&self) -> String {
match self {
Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(),
@@ -1446,6 +1428,14 @@ impl CodeActionsItem {
Self::DebugScenario(scenario) => scenario.label.to_string(),
}
}
pub fn menu_label(&self) -> String {
match self {
Self::CodeAction { action, .. } => action.lsp_action.title().replace("\n", ""),
Self::Task(_, task) => task.resolved_label.replace("\n", ""),
Self::DebugScenario(scenario) => format!("debug: {}", scenario.label),
}
}
}
pub struct CodeActionsMenu {
@@ -1555,60 +1545,33 @@ impl CodeActionsMenu {
let item_ix = range.start + ix;
let selected = item_ix == selected_item;
let colors = cx.theme().colors();
div().min_w(px(220.)).max_w(px(540.)).child(
ListItem::new(item_ix)
.inset(true)
.toggle_state(selected)
.when_some(action.as_code_action(), |this, action| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child(
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
action.lsp_action.title().replace("\n", ""),
)
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.when_some(action.as_task(), |this, task| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child(task.resolved_label.replace("\n", ""))
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.when_some(action.as_debug_scenario(), |this, scenario| {
this.child(
h_flex()
.overflow_hidden()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.child("debug: ")
.child(scenario.label.clone())
.when(selected, |this| {
this.text_color(colors.text_accent)
}),
)
})
.on_click(cx.listener(move |editor, _, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_code_action(
&ConfirmCodeAction {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
})),
)
ListItem::new(item_ix)
.inset(true)
.toggle_state(selected)
.overflow_x()
.child(
div()
.min_w(CODE_ACTION_MENU_MIN_WIDTH)
.max_w(CODE_ACTION_MENU_MAX_WIDTH)
.overflow_hidden()
.text_ellipsis()
.when(is_quick_action_bar, |this| this.text_ui(cx))
.when(selected, |this| this.text_color(colors.text_accent))
.child(action.menu_label()),
)
.on_click(cx.listener(move |editor, _, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_code_action(
&ConfirmCodeAction {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
}))
})
.collect()
}),
@@ -1635,4 +1598,46 @@ impl CodeActionsMenu {
Popover::new().child(list).into_any_element()
}
fn render_aside(
&mut self,
max_size: Size<Pixels>,
window: &mut Window,
_cx: &mut Context<Editor>,
) -> Option<AnyElement> {
let Some(action) = self.actions.get(self.selected_item) else {
return None;
};
let label = action.menu_label();
let text_system = window.text_system();
let mut line_wrapper = text_system.line_wrapper(
window.text_style().font(),
window.text_style().font_size.to_pixels(window.rem_size()),
);
let is_truncated = line_wrapper.should_truncate_line(
&label,
CODE_ACTION_MENU_MAX_WIDTH,
"",
gpui::TruncateFrom::End,
);
if is_truncated.is_none() {
return None;
}
Some(
Popover::new()
.child(
div()
.child(label)
.id("code_actions_menu_extended")
.px(MENU_ASIDE_X_PADDING / 2.)
.max_w(max_size.width)
.max_h(max_size.height)
.occlude(),
)
.into_any_element(),
)
}
}

View File

@@ -163,6 +163,7 @@ use project::{
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings},
};
use rand::seq::SliceRandom;
use regex::Regex;
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager};
use selections_collection::{MutableSelectionsCollection, SelectionsCollection};
@@ -4787,82 +4788,146 @@ impl Editor {
let end = selection.end;
let selection_is_empty = start == end;
let language_scope = buffer.language_scope_at(start);
let (comment_delimiter, doc_delimiter, newline_formatting) =
if let Some(language) = &language_scope {
let mut newline_formatting =
NewlineFormatting::new(&buffer, start..end, language);
let (delimiter, newline_config) = if let Some(language) = &language_scope {
let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets(
&buffer,
start..end,
language,
)
|| NewlineConfig::insert_extra_newline_tree_sitter(
&buffer,
start..end,
);
// Comment extension on newline is allowed only for cursor selections
let comment_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_comment_on_newline
{
return None;
}
return comment_delimiter_for_newline(
&start_point,
&buffer,
language,
);
});
let doc_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_comment_on_newline
{
return None;
}
return documentation_delimiter_for_newline(
&start_point,
&buffer,
language,
&mut newline_formatting,
);
});
(comment_delimiter, doc_delimiter, newline_formatting)
} else {
(None, None, NewlineFormatting::default())
let mut newline_config = NewlineConfig::Newline {
additional_indent: IndentSize::spaces(0),
extra_line_additional_indent: if needs_extra_newline {
Some(IndentSize::spaces(0))
} else {
None
},
prevent_auto_indent: false,
};
let prevent_auto_indent = doc_delimiter.is_some();
let delimiter = comment_delimiter.or(doc_delimiter);
let comment_delimiter = maybe!({
if !selection_is_empty {
return None;
}
let capacity_for_delimiter =
delimiter.as_deref().map(str::len).unwrap_or_default();
let mut new_text = String::with_capacity(
1 + capacity_for_delimiter
+ existing_indent.len as usize
+ newline_formatting.indent_on_newline.len as usize
+ newline_formatting.indent_on_extra_newline.len as usize,
);
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(newline_formatting.indent_on_newline.chars());
if !multi_buffer.language_settings(cx).extend_comment_on_newline {
return None;
}
if let Some(delimiter) = &delimiter {
new_text.push_str(delimiter);
}
return comment_delimiter_for_newline(
&start_point,
&buffer,
language,
);
});
if newline_formatting.insert_extra_newline {
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(newline_formatting.indent_on_extra_newline.chars());
}
let doc_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_comment_on_newline {
return None;
}
return documentation_delimiter_for_newline(
&start_point,
&buffer,
language,
&mut newline_config,
);
});
let list_delimiter = maybe!({
if !selection_is_empty {
return None;
}
if !multi_buffer.language_settings(cx).extend_list_on_newline {
return None;
}
return list_delimiter_for_newline(
&start_point,
&buffer,
language,
&mut newline_config,
);
});
(
comment_delimiter.or(doc_delimiter).or(list_delimiter),
newline_config,
)
} else {
(
None,
NewlineConfig::Newline {
additional_indent: IndentSize::spaces(0),
extra_line_additional_indent: None,
prevent_auto_indent: false,
},
)
};
let (edit_start, new_text, prevent_auto_indent) = match &newline_config {
NewlineConfig::ClearCurrentLine => {
let row_start =
buffer.point_to_offset(Point::new(start_point.row, 0));
(row_start, String::new(), false)
}
NewlineConfig::UnindentCurrentLine { continuation } => {
let row_start =
buffer.point_to_offset(Point::new(start_point.row, 0));
let tab_size = buffer.language_settings_at(start, cx).tab_size;
let tab_size_indent = IndentSize::spaces(tab_size.get());
let reduced_indent =
existing_indent.with_delta(Ordering::Less, tab_size_indent);
let mut new_text = String::new();
new_text.extend(reduced_indent.chars());
new_text.push_str(continuation);
(row_start, new_text, true)
}
NewlineConfig::Newline {
additional_indent,
extra_line_additional_indent,
prevent_auto_indent,
} => {
let capacity_for_delimiter =
delimiter.as_deref().map(str::len).unwrap_or_default();
let extra_line_len = extra_line_additional_indent
.map(|i| 1 + existing_indent.len as usize + i.len as usize)
.unwrap_or(0);
let mut new_text = String::with_capacity(
1 + capacity_for_delimiter
+ existing_indent.len as usize
+ additional_indent.len as usize
+ extra_line_len,
);
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(additional_indent.chars());
if let Some(delimiter) = &delimiter {
new_text.push_str(delimiter);
}
if let Some(extra_indent) = extra_line_additional_indent {
new_text.push('\n');
new_text.extend(existing_indent.chars());
new_text.extend(extra_indent.chars());
}
(start, new_text, *prevent_auto_indent)
}
};
let anchor = buffer.anchor_after(end);
let new_selection = selection.map(|_| anchor);
(
((start..end, new_text), prevent_auto_indent),
(newline_formatting.insert_extra_newline, new_selection),
((edit_start..end, new_text), prevent_auto_indent),
(newline_config.has_extra_line(), new_selection),
)
})
.unzip()
@@ -10387,6 +10452,22 @@ impl Editor {
}
prev_edited_row = selection.end.row;
// If cursor is after a list prefix, make selection non-empty to trigger line indent
if selection.is_empty() {
let cursor = selection.head();
let settings = buffer.language_settings_at(cursor, cx);
if settings.indent_list_on_tab {
if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) {
if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) {
row_delta = Self::indent_selection(
buffer, &snapshot, selection, &mut edits, row_delta, cx,
);
continue;
}
}
}
}
// If the selection is non-empty, then increase the indentation of the selected lines.
if !selection.is_empty() {
row_delta =
@@ -20394,7 +20475,7 @@ impl Editor {
EditorSettings::get_global(cx).gutter.line_numbers
}
pub fn relative_line_numbers(&self, cx: &mut App) -> RelativeLineNumbers {
pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers {
match (
self.use_relative_line_numbers,
EditorSettings::get_global(cx).relative_line_numbers,
@@ -23355,7 +23436,7 @@ fn documentation_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
newline_formatting: &mut NewlineFormatting,
newline_config: &mut NewlineConfig,
) -> Option<Arc<str>> {
let BlockCommentConfig {
start: start_tag,
@@ -23407,6 +23488,9 @@ fn documentation_delimiter_for_newline(
}
};
let mut needs_extra_line = false;
let mut extra_line_additional_indent = IndentSize::spaces(0);
let cursor_is_before_end_tag_if_exists = {
let mut char_position = 0u32;
let mut end_tag_offset = None;
@@ -23424,11 +23508,11 @@ fn documentation_delimiter_for_newline(
let cursor_is_before_end_tag = column <= end_tag_offset;
if cursor_is_after_start_tag {
if cursor_is_before_end_tag {
newline_formatting.insert_extra_newline = true;
needs_extra_line = true;
}
let cursor_is_at_start_of_end_tag = column == end_tag_offset;
if cursor_is_at_start_of_end_tag {
newline_formatting.indent_on_extra_newline.len = *len;
extra_line_additional_indent.len = *len;
}
}
cursor_is_before_end_tag
@@ -23440,39 +23524,240 @@ fn documentation_delimiter_for_newline(
if (cursor_is_after_start_tag || cursor_is_after_delimiter)
&& cursor_is_before_end_tag_if_exists
{
if cursor_is_after_start_tag {
newline_formatting.indent_on_newline.len = *len;
}
let additional_indent = if cursor_is_after_start_tag {
IndentSize::spaces(*len)
} else {
IndentSize::spaces(0)
};
*newline_config = NewlineConfig::Newline {
additional_indent,
extra_line_additional_indent: if needs_extra_line {
Some(extra_line_additional_indent)
} else {
None
},
prevent_auto_indent: true,
};
Some(delimiter.clone())
} else {
None
}
}
#[derive(Debug, Default)]
struct NewlineFormatting {
insert_extra_newline: bool,
indent_on_newline: IndentSize,
indent_on_extra_newline: IndentSize,
const ORDERED_LIST_MAX_MARKER_LEN: usize = 16;
fn list_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
newline_config: &mut NewlineConfig,
) -> Option<Arc<str>> {
let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
let num_of_whitespaces = snapshot
.chars_for_range(range.clone())
.take_while(|c| c.is_whitespace())
.count();
let task_list_entries: Vec<_> = language
.task_list()
.into_iter()
.flat_map(|config| {
config
.prefixes
.iter()
.map(|prefix| (prefix.as_ref(), config.continuation.as_ref()))
})
.collect();
let unordered_list_entries: Vec<_> = language
.unordered_list()
.iter()
.map(|marker| (marker.as_ref(), marker.as_ref()))
.collect();
let all_entries: Vec<_> = task_list_entries
.into_iter()
.chain(unordered_list_entries)
.collect();
if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() {
let candidate: String = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(max_prefix_len)
.collect();
if let Some((prefix, continuation)) = all_entries
.iter()
.filter(|(prefix, _)| candidate.starts_with(*prefix))
.max_by_key(|(prefix, _)| prefix.len())
{
let end_of_prefix = num_of_whitespaces + prefix.len();
let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
let has_content_after_marker = snapshot
.chars_for_range(range)
.skip(end_of_prefix)
.any(|c| !c.is_whitespace());
if has_content_after_marker && cursor_is_after_prefix {
return Some((*continuation).into());
}
if start_point.column as usize == end_of_prefix {
if num_of_whitespaces == 0 {
*newline_config = NewlineConfig::ClearCurrentLine;
} else {
*newline_config = NewlineConfig::UnindentCurrentLine {
continuation: (*continuation).into(),
};
}
}
return None;
}
}
let candidate: String = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(ORDERED_LIST_MAX_MARKER_LEN)
.collect();
for ordered_config in language.ordered_list() {
let regex = match Regex::new(&ordered_config.pattern) {
Ok(r) => r,
Err(_) => continue,
};
if let Some(captures) = regex.captures(&candidate) {
let full_match = captures.get(0)?;
let marker_len = full_match.len();
let end_of_prefix = num_of_whitespaces + marker_len;
let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
let has_content_after_marker = snapshot
.chars_for_range(range)
.skip(end_of_prefix)
.any(|c| !c.is_whitespace());
if has_content_after_marker && cursor_is_after_prefix {
let number: u32 = captures.get(1)?.as_str().parse().ok()?;
let continuation = ordered_config
.format
.replace("{1}", &(number + 1).to_string());
return Some(continuation.into());
}
if start_point.column as usize == end_of_prefix {
let continuation = ordered_config.format.replace("{1}", "1");
if num_of_whitespaces == 0 {
*newline_config = NewlineConfig::ClearCurrentLine;
} else {
*newline_config = NewlineConfig::UnindentCurrentLine {
continuation: continuation.into(),
};
}
}
return None;
}
}
None
}
impl NewlineFormatting {
fn new(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
language: &LanguageScope,
) -> Self {
Self {
insert_extra_newline: Self::insert_extra_newline_brackets(
buffer,
range.clone(),
language,
) || Self::insert_extra_newline_tree_sitter(buffer, range),
indent_on_newline: IndentSize::spaces(0),
indent_on_extra_newline: IndentSize::spaces(0),
fn is_list_prefix_row(
row: MultiBufferRow,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
) -> bool {
let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else {
return false;
};
let num_of_whitespaces = snapshot
.chars_for_range(range.clone())
.take_while(|c| c.is_whitespace())
.count();
let task_list_prefixes: Vec<_> = language
.task_list()
.into_iter()
.flat_map(|config| {
config
.prefixes
.iter()
.map(|p| p.as_ref())
.collect::<Vec<_>>()
})
.collect();
let unordered_list_markers: Vec<_> = language
.unordered_list()
.iter()
.map(|marker| marker.as_ref())
.collect();
let all_prefixes: Vec<_> = task_list_prefixes
.into_iter()
.chain(unordered_list_markers)
.collect();
if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() {
let candidate: String = snapshot
.chars_for_range(range.clone())
.skip(num_of_whitespaces)
.take(max_prefix_len)
.collect();
if all_prefixes
.iter()
.any(|prefix| candidate.starts_with(*prefix))
{
return true;
}
}
let ordered_list_candidate: String = snapshot
.chars_for_range(range)
.skip(num_of_whitespaces)
.take(ORDERED_LIST_MAX_MARKER_LEN)
.collect();
for ordered_config in language.ordered_list() {
let regex = match Regex::new(&ordered_config.pattern) {
Ok(r) => r,
Err(_) => continue,
};
if let Some(captures) = regex.captures(&ordered_list_candidate) {
return captures.get(0).is_some();
}
}
false
}
#[derive(Debug)]
enum NewlineConfig {
/// Insert newline with optional additional indent and optional extra blank line
Newline {
additional_indent: IndentSize,
extra_line_additional_indent: Option<IndentSize>,
prevent_auto_indent: bool,
},
/// Clear the current line
ClearCurrentLine,
/// Unindent the current line and add continuation
UnindentCurrentLine { continuation: Arc<str> },
}
impl NewlineConfig {
fn has_extra_line(&self) -> bool {
matches!(
self,
Self::Newline {
extra_line_additional_indent: Some(_),
..
}
)
}
fn insert_extra_newline_brackets(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
@@ -25009,6 +25294,66 @@ impl EditorSnapshot {
let digit_count = self.widest_line_number().ilog10() + 1;
column_pixels(style, digit_count as usize, window)
}
/// 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,
consider_wrapped_lines: bool,
) -> i64 {
let point = DisplayPoint::new(line, 0).to_point(self);
self.relative_line_delta_to_point(base, point, consider_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,
consider_wrapped_lines: bool,
) -> i64 {
let base_point = DisplayPoint::new(base, 0).to_point(self);
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`.
///
/// Wrapped rows are excluded from the hashmap if `count_relative_lines` is `false`.
pub fn calculate_relative_line_numbers(
&self,
rows: &Range<DisplayRow>,
relative_to: DisplayRow,
count_wrapped_lines: bool,
) -> HashMap<DisplayRow, u32> {
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))
.filter(|(_row, row_info)| {
row_info.buffer_row.is_some()
|| (count_wrapped_lines && row_info.wrapped_buffer_row.is_some())
})
.enumerate()
.flat_map(|(i, (row, _row_info))| {
(row != relative_to)
.then_some((row, (initial_offset + i as i64).unsigned_abs() as u32))
})
.collect()
}
}
pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels {

View File

@@ -215,7 +215,8 @@ impl Settings for EditorSettings {
},
scrollbar: Scrollbar {
show: scrollbar.show.map(Into::into).unwrap(),
git_diff: scrollbar.git_diff.unwrap(),
git_diff: scrollbar.git_diff.unwrap()
&& content.git.unwrap().enabled.unwrap().is_git_diff_enabled(),
selected_text: scrollbar.selected_text.unwrap(),
selected_symbol: scrollbar.selected_symbol.unwrap(),
search_results: scrollbar.search_results.unwrap(),

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